Files
rodado fd37348811 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
2026-10-01 09:48:55 +02:00

134 lines
6.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())