#!/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/.webp 1.200 px no lado maior NARA 2004 "Access – Recommended" derivados/grande/.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())