feat: derivadas de acesso com tamanhos pela NARA e CONARQ (medio 1.200, grande 2.000)

- pipeline/deriva-acesso.py gera medio/ e grande/ da matriz no finland, em
  WebP sRGB, sem ampliar, com retomada
- MANIFEST e CONARQ.md documentam matriz, derivada de navegação e de acesso
  (Res. CONARQ 31/2010) e os tamanhos da NARA 2004
- INDEX.md revisto; teste-upload.txt e falhas-500 antigos saem do bucket
This commit is contained in:
2026-10-01 09:48:55 +02:00
parent ff7dc3cf3f
commit fd37348811
7 changed files with 195 additions and 10 deletions
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Derivadas de acesso (CONARQ, Res. 31/2010) a partir da matriz de preservação.
Gera, para cada item, duas cópias WebP em sRGB, sem metadados embutidos e sem
nunca ampliar (o que for menor que o alvo fica no tamanho original):
derivados/medio/<handle>.webp 1.200 px no lado maior NARA 2004 "Access – Recommended"
derivados/grande/<handle>.webp 2.000 px no lado maior NARA 2004 "Larger Alternative"
A miniatura (derivados/thumb256/, 256 px) já existe e não muda. Ver a tabela
de tamanhos em MANIFEST.md e CONARQ.md.
Roda no finland (tráfego intra-Hetzner, o bucket é hel1):
scp pipeline/deriva-acesso.py derivar.jsonl finland:/root/brasiliana/
ssh finland 'cd /root/brasiliana && set -a && . /root/.s3env && set +a && \
nohup python3 deriva-acesso.py derivar.jsonl > deriva-acesso.log 2>&1 &'
`derivar.jsonl`: uma linha {handle, fonte} por item, fonte = chave no bucket da
imagem de preservação (ou, sem ela, da imagem servida pelo DSpace). O progresso
vai para `deriva-acesso.resultados.jsonl`; rodar de novo retoma de onde parou.
Opções: --seco (gera em /tmp e não sobe), --limite N, --threads N.
"""
import argparse
import hashlib
import json
import os
import shutil
import subprocess
import sys
import tempfile
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
import boto3
BUCKET = os.environ.get('ARCHIVE_BUCKET', 'brasiliana')
ENDPOINT = os.environ.get('S3_ENDPOINT', 'https://hel1.your-objectstorage.com')
ALVOS = [ # (pasta, lado maior em px, qualidade WebP)
('medio', 1200, 80),
('grande', 2000, 82),
]
CACHE = 'public, max-age=604800'
trava = threading.Lock()
def processa(s3, item, seco, saida_seca):
h = item['handle']
pasta = h.replace('/', '_')
tmp = tempfile.mkdtemp(prefix='deriva-')
try:
ext = os.path.splitext(item['fonte'])[1] or '.img'
src = os.path.join(tmp, 'src' + ext)
s3.download_file(BUCKET, item['fonte'], src)
feitos = []
for nome, lado, q in ALVOS:
out = os.path.join(tmp, f'{nome}.webp')
# "WxH>" = só reduz; sRGB na saída (NARA pede sRGB nas derivadas de acesso)
r = subprocess.run(['vipsthumbnail', src, '--size', f'{lado}x{lado}>',
'--export-profile', 'srgb', '-o', f'{out}[Q={q},strip]'],
capture_output=True, text=True, timeout=600)
if r.returncode != 0 or not os.path.exists(out):
# perfil de cor ausente ou ilegível: tenta sem conversão de perfil
r = subprocess.run(['vipsthumbnail', src, '--size', f'{lado}x{lado}>',
'-o', f'{out}[Q={q},strip]'], capture_output=True, text=True, timeout=600)
if r.returncode != 0:
raise RuntimeError(f'vipsthumbnail {nome}: {r.stderr.strip()[:300]}')
dims = subprocess.run(['vipsheader', '-f', 'width', out], capture_output=True, text=True).stdout.strip(), \
subprocess.run(['vipsheader', '-f', 'height', out], capture_output=True, text=True).stdout.strip()
dado = open(out, 'rb').read()
chave = f'derivados/{nome}/{pasta}.webp'
if seco:
shutil.copy(out, os.path.join(saida_seca, f'{pasta}.{nome}.webp'))
else:
s3.put_object(Bucket=BUCKET, Key=chave, Body=dado, ACL='public-read',
ContentType='image/webp', CacheControl=CACHE)
feitos.append({'tipo': f'derivado-{nome}', 'chave': chave, 'bytes': len(dado),
'sha256': hashlib.sha256(dado).hexdigest(), 'w': int(dims[0]), 'h': int(dims[1])})
return {'handle': h, 'fonte': item['fonte'], 'ok': True, 'arquivos': feitos,
'quando': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}
except Exception as e: # noqa: BLE001 — um item ruim não para a fila
return {'handle': h, 'fonte': item['fonte'], 'ok': False, 'erro': f'{type(e).__name__}: {e}'[:400]}
finally:
shutil.rmtree(tmp, ignore_errors=True)
def main():
ap = argparse.ArgumentParser()
ap.add_argument('lista')
ap.add_argument('--seco', action='store_true')
ap.add_argument('--limite', type=int, default=0)
ap.add_argument('--threads', type=int, default=4)
ap.add_argument('--resultados', default='deriva-acesso.resultados.jsonl')
a = ap.parse_args()
itens = [json.loads(l) for l in open(a.lista, encoding='utf-8') if l.strip()]
feitos = set()
if os.path.exists(a.resultados) and not a.seco:
for l in open(a.resultados, encoding='utf-8'):
r = json.loads(l)
if r.get('ok'):
feitos.add(r['handle'])
fila = [i for i in itens if i['handle'] not in feitos]
if a.limite:
fila = fila[:a.limite]
saida_seca = tempfile.mkdtemp(prefix='deriva-seco-') if a.seco else None
print(f'{len(itens)} itens, {len(feitos)} já feitos, {len(fila)} na fila'
+ (f' (seco → {saida_seca})' if a.seco else ''), flush=True)
s3 = boto3.client('s3', endpoint_url=ENDPOINT)
t0, n, erros = time.time(), 0, 0
with ThreadPoolExecutor(a.threads) as ex, open(a.resultados if not a.seco else os.devnull, 'a', encoding='utf-8') as res:
for fut in as_completed([ex.submit(processa, s3, i, a.seco, saida_seca) for i in fila]):
r = fut.result()
n += 1
erros += not r['ok']
with trava:
res.write(json.dumps(r, ensure_ascii=False) + '\n')
res.flush()
if not r['ok']:
print(f"ERRO {r['handle']}: {r['erro']}", flush=True)
if n % 100 == 0 or n == len(fila):
dt = time.time() - t0
print(f'{n}/{len(fila)} · {erros} erros · {n / dt * 60:.0f}/min · '
f'faltam ~{(len(fila) - n) / max(n / dt, 1e-9) / 60:.0f} min', flush=True)
print('FIM', flush=True)
return 1 if erros else 0
if __name__ == '__main__':
sys.exit(main())