From bec9411af3bd652a778c42a9e81380fd47dc04d2 Mon Sep 17 00:00:00 2001 From: kami Date: Thu, 13 Aug 2026 23:02:06 +0400 Subject: [PATCH] Put every S3 URI in one place, and add a lint gate Five workers built output URIs with inline f-strings, so the bucket-per-artifact layout was spread across worker_tts, worker_identity, worker_crop, worker_layers and worker_render. Moving a class between buckets meant a grep. They are now templates in transport.py, formatted at each call site. Three of those workers also each reimplemented the same parse to recover manga_id and chapter_id from an input uri, because the orchestrator does not send them. That is transport.ids_from_uri now, and it raises on a uri too short to carry the ids rather than returning a wrong pair. ruff.toml makes `ruff check .` exit 0, so CI can gate on it and a new finding means a new defect. Fixed: an implicit Optional in 8 signatures, an unparenthesized implicit concatenation in the ASS filter list, 5 subprocess.run calls now saying check=False out loud, an unused import, a duplicate exception handler and a non-executable shebang. Every rule left off carries its reason in ruff.toml. The ASYNC rules are off because ffmpeg on the event loop is real and already recorded at caveats/audit-open.md#blocking-event-loop. It needs a refactor per handler, not a lint fix. Checked: transport, collage, bubble_detect, test_vision_parse, worker_crop, worker_scene, worker_script, worker_identity, worker_tts, session_manager, worker_vision and worker_render self-checks all pass. worker_layers still fails on a missing legacy/qwen_layered_workflow.json, which predates this branch. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 6 +++- audit_registry.py | 2 +- bubble_detect.py | 2 +- face_detect.py | 2 +- ruff.toml | 21 +++++++++++++ scripts/pick_tts_voice.py | 2 +- session_manager.py | 2 +- test_vision_parse.py | 2 +- transport.py | 47 ++++++++++++++++++++++++++-- worker_crop.py | 5 +-- worker_identity.py | 8 ++--- worker_layers.py | 6 ++-- worker_render.py | 64 +++++++++++++++++++-------------------- worker_tts.py | 16 +++++++--- worker_vision.py | 4 +-- 15 files changed, 131 insertions(+), 58 deletions(-) create mode 100644 ruff.toml mode change 100644 => 100755 scripts/pick_tts_voice.py diff --git a/CLAUDE.md b/CLAUDE.md index 34e92fc..ad45cd5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,14 +61,18 @@ sudo systemd/install.sh # production: one systemd unit per process (User .venv/bin/python worker_scene.py # every module has an assert-based __main__ self-check .venv/bin/python test_vision_parse.py +ruff check . # must exit 0; every ignore in ruff.toml carries its reason cd /mnt/server/home/kami/docker-apps/manga-infra/orchestrator && pytest -q --ignore=test_api.py ``` -There is no lint or build step. `.venv` is the ROCm torch env. Workers import `transport` by module +There is no build step. `.venv` is the ROCm torch env. Workers import `transport` by module name. Ports: crop 8000, vision 8002, identity 8003, scene 8004, script 8005, tts 8006, layers 8007, render 8008, session_manager 8095. +Every output S3 URI is a template in `transport.py`, not an f-string in a worker. Add one there when a +new artifact class appears. + - Any non-trivial logic gets ONE runnable check in `__main__`, assert-based, no framework. Run the file to verify it. - Editing a worker's request or response shape means editing the orchestrator too, in the same session. diff --git a/audit_registry.py b/audit_registry.py index 4c88149..c3e1948 100644 --- a/audit_registry.py +++ b/audit_registry.py @@ -87,7 +87,7 @@ if worked: for ch in people: cid, conf = assigns.get(ch["local_id"], (None, None)) name = reg.get(cid, {}).get("name") or (cid[:16] if cid else "-- none --") - print(f" {ch['local_id']:10} {str(ch['bbox']):28} {name:22} " + print(f" {ch['local_id']:10} {ch['bbox']!s:28} {name:22} " f"{'' if conf is None else f'{conf:.2f}'}") else: print(f"\npanel_index {WORKED_EXAMPLE} not found in this chapter") diff --git a/bubble_detect.py b/bubble_detect.py index d0cf8d3..e86f052 100644 --- a/bubble_detect.py +++ b/bubble_detect.py @@ -38,7 +38,7 @@ def _letterbox(img, sz=1024): return canvas, r, px, py -def detect_text_regions(img, conf: float = None) -> list: +def detect_text_regions(img, conf: float | None = None) -> list: """img: BGR ndarray (one panel). -> [{"bbox":[x1,y1,x2,y2], "conf":float}] in ORIGINAL pixels, reading order (top->bottom, left->right). Empty list on no detections (caller falls back to the holistic prompt). Load + inference are lazy so importing this never touches the GPU or the model.""" diff --git a/face_detect.py b/face_detect.py index c065688..f6c787d 100644 --- a/face_detect.py +++ b/face_detect.py @@ -32,7 +32,7 @@ def _letterbox(img, sz=640): return canvas, r, px, py -def detect_faces(img, conf: float = None) -> list: +def detect_faces(img, conf: float | None = None) -> list: """img: BGR ndarray. -> [{"bbox":[x1,y1,x2,y2], "conf":float}] in ORIGINAL pixels, sorted reading order. Empty on no faces. Lazy load so import never touches the model.""" conf = CONF if conf is None else conf diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..878b664 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,21 @@ +# Lint gate. `ruff check .` must exit 0, so CI can gate on it and a new finding means a new defect. +# +# Ruff's defaults flag about 100 things in this repo. Most are deliberate style in workers that must +# survive one bad panel rather than fail clean. Every rule turned off below carries its reason, so an +# ignore stays a decision rather than a shrug. Delete an entry the moment its reason stops holding. + +[lint] +ignore = [ + "I001", # import order: 25 files of churn, no behaviour change + "BLE001", # a worker catches Exception on purpose, so one bad panel cannot kill the stage + "SIM115", # short-lived open().read(); the handle drops with the refcount + "S110", # try/except/pass in best-effort cleanup, where the no-op IS the handling + "ASYNC210", # ffmpeg, ffprobe and MinIO run synchronously inside async endpoints. Real, and + "ASYNC221", # already recorded at caveats/audit-open.md#blocking-event-loop with [#199]. The fix + "ASYNC230", # is `def` over `async def` per handler, which is a refactor and not a lint fix. + "RUF046", # int(round(v)) says "pixels" out loud in the render geometry + "UP031", # the ASS subtitle template is %-formatted; f-string braces collide with its {\an} tags + "RUF059", # unpacking a whole bbox and using half of it beats indexing into it + "RUF007", # zip(x, x[1:]) reads better here than itertools.pairwise + "PLC3002", # one immediately-called lambda, in an audit script +] diff --git a/scripts/pick_tts_voice.py b/scripts/pick_tts_voice.py old mode 100644 new mode 100755 index f438fa6..96bef55 --- a/scripts/pick_tts_voice.py +++ b/scripts/pick_tts_voice.py @@ -27,7 +27,7 @@ def load_model(model_name: str): ) # Match worker_tts.py's torch/torchaudio minor-mismatch bypass too. import torch - import importlib.metadata as metadata + from importlib import metadata real_version = metadata.version metadata.version = ( diff --git a/session_manager.py b/session_manager.py index da3e9fc..b47efac 100644 --- a/session_manager.py +++ b/session_manager.py @@ -6,7 +6,7 @@ # in-process models (siglip2, dots): no server — the lease just reserves the GPU; the # worker loads the transformers model itself after /session/open returns (port=None). # the guarantee that matters is the mutex: a second open() gets 409 until the first closes. -import os, time, uuid, threading, subprocess +import time, uuid, threading, subprocess import requests from fastapi import FastAPI, HTTPException from pydantic import BaseModel diff --git a/test_vision_parse.py b/test_vision_parse.py index 572d2c5..f315edf 100644 --- a/test_vision_parse.py +++ b/test_vision_parse.py @@ -14,7 +14,7 @@ def test_extract_rejects_malformed_and_truncated(): try: wv._extract_json(bad) assert False, f"should have raised on: {bad!r}" - except (ValueError, ValueError): + except ValueError: pass diff --git a/transport.py b/transport.py index 50380c7..938eed9 100644 --- a/transport.py +++ b/transport.py @@ -12,6 +12,29 @@ from starlette.responses import Response _client = None +# --- artifact layout --------------------------------------------------------------------------- +# one bucket per artifact class (`decisions/storage-layout.md#bucket-per-artifact`). Every worker +# formats its output uri from these, so moving a class between buckets is one edit here rather than +# a grep across five workers. `name` is the panel id, or 'p' when a worker has none. +PANEL_URI = "s3://panels/{manga_id}/{chapter_id}/panels/p{idx:03d}.png" +PAGE_PANEL_URI = "s3://panels/{manga_id}/{chapter_id}/panels/pg{page_index:03d}_p{idx:02d}.png" +AUDIO_URI = "s3://audio/{manga_id}/{chapter_id}/audio/{name}.wav" +AUDIO_FLAT_URI = "s3://audio/_audio/{name}.wav" +LAYER_URI = "s3://layers/{manga_id}/{chapter_id}/layers/{name}/{idx}.png" +CLIP_URI = "s3://video/{manga_id}/{chapter_id}/clips/{name}.mp4" +CHAPTER_URI = "s3://video/{manga_id}/{chapter_id}/chapter.mp4" +CHAR_PNG_URI = "s3://manga/{key}.png" +CHAR_NPY_URI = "s3://manga/{key}.npy" + + +def ids_from_uri(uri: str): + """(manga_id, chapter_id) from any artifact uri: ///... + the orchestrator passes no ids to tts, layers or render, but every input uri encodes them.""" + parts = (uri.removeprefix("s3://")).split("/") + if len(parts) < 3: + raise ValueError(f"uri carries no manga/chapter: {uri!r}") + return parts[1], parts[2] + def _summarize(body: bytes, limit=6) -> str: """compact one-line view of a json body for observability: uri inputs/outputs (basename, or @@ -27,9 +50,9 @@ def _summarize(body: bytes, limit=6) -> str: for k, v in obj.items(): if k == "panel_id": continue - if isinstance(v, str) and (k.endswith("uri") or k.endswith("url")): + if isinstance(v, str) and (k.endswith(("uri", "url"))): parts.append(f"{k}={v.rsplit('/', 1)[-1]}") - elif isinstance(v, list) and v and isinstance(v[0], str) and (k.endswith("uris") or k.endswith("urls")): + elif isinstance(v, list) and v and isinstance(v[0], str) and (k.endswith(("uris", "urls"))): parts.append(f"{k}×{len(v)}") elif isinstance(v, (int, float, bool)): parts.append(f"{k}={v}") @@ -102,7 +125,7 @@ def _mc(): def _split(uri: str): """(bucket, key) from an s3-style or bare uri.""" - u = uri[5:] if uri.startswith("s3://") else uri + u = uri.removeprefix("s3://") bucket, _, key = u.partition("/") if not bucket or not key: raise ValueError(f"bad uri: {uri!r}") @@ -213,4 +236,22 @@ if __name__ == "__main__": assert _summarize(b"clip_uri", ) == "-" # non-json assert _summarize(json.dumps({"clip_uri": "s3://m/c/clips/p1.mp4", "duration": 4.1}).encode()) \ == "clip_uri=p1.mp4 duration=4.1" + + # artifact layout: templates format to the keys the workers wrote by hand before, and + # ids_from_uri recovers the ids the orchestrator never sends. + panel = PANEL_URI.format(manga_id="m1", chapter_id="c1", idx=7) + assert panel == "s3://panels/m1/c1/panels/p007.png", panel + assert PAGE_PANEL_URI.format(manga_id="m1", chapter_id="c1", page_index=2, idx=3) \ + == "s3://panels/m1/c1/panels/pg002_p03.png" + assert CLIP_URI.format(manga_id="m1", chapter_id="c1", name="p003") \ + == "s3://video/m1/c1/clips/p003.mp4" + assert LAYER_URI.format(manga_id="m1", chapter_id="c1", name="p003", idx=0) \ + == "s3://layers/m1/c1/layers/p003/0.png" + assert ids_from_uri(panel) == ("m1", "c1") + assert ids_from_uri("panels/m1/c1/panels/p007.png") == ("m1", "c1") + try: + ids_from_uri("s3://panels/p007.png") + raise AssertionError("a uri with no chapter segment must raise") + except ValueError: + pass print("transport self-check ok") diff --git a/worker_crop.py b/worker_crop.py index e0ec4ee..1d8eca6 100644 --- a/worker_crop.py +++ b/worker_crop.py @@ -244,7 +244,7 @@ async def crop_webtoon(data: WebtoonInput): context_links = context_fragment_links(crops) panels = [] for idx, (crop_img, bbox) in enumerate(crops): - uri = f"s3://panels/{data.manga_id}/{data.chapter_id}/panels/p{idx:03d}.png" + uri = transport.PANEL_URI.format(manga_id=data.manga_id, chapter_id=data.chapter_id, idx=idx) # slicing is deterministic, so on a resume the same idx -> same key; skip re-upload. # TRAP: that holds only while the PLAN is unchanged. Edit slice_webtoon or the merge pass and a # re-crop silently keeps the previous run's images under the same keys, because every one of them @@ -277,7 +277,8 @@ async def crop(data: CropInput): for idx, (crop_img, bbox) in enumerate(crops): out = f"{SHM}/pg{data.page_index:03d}_p{idx:02d}.png" cv2.imwrite(out, crop_img) - uri = f"s3://panels/{data.manga_id}/{data.chapter_id}/panels/pg{data.page_index:03d}_p{idx:02d}.png" + uri = transport.PAGE_PANEL_URI.format(manga_id=data.manga_id, chapter_id=data.chapter_id, + page_index=data.page_index, idx=idx) transport.put(out, uri) os.remove(out) panels.append({"panel_index": idx, "uri": uri, "bbox": bbox, diff --git a/worker_identity.py b/worker_identity.py index 477412e..e61b23f 100644 --- a/worker_identity.py +++ b/worker_identity.py @@ -78,7 +78,7 @@ def match(emb: np.ndarray, known: list, threshold: float): return None, best_conf, ambiguous -def shortlist(emb: np.ndarray, known: list, k: int = 5, gender: str = None) -> list: +def shortlist(emb: np.ndarray, known: list, k: int = 5, gender: str | None = None) -> list: """TIER-2 evidence: the top-k gender-gated known characters by cosine, best first. Cosine is now a SHORTLISTER, not the decider — gemma /vision/resolve picks from this list. Each item carries the full row (name/gender/description) so the resolver can build a text character-sheet. pure.""" @@ -104,7 +104,7 @@ def _save_npy(emb: np.ndarray, uri: str): os.remove(tmp) -def _pending_match(pend: list, emb, threshold: float, gender: str = None): +def _pending_match(pend: list, emb, threshold: float, gender: str | None = None): """index of the pending entry this embedding belongs to, or None (a new provisional). candidates of a conflicting decided gender are excluded. character_id=i keeps the returned index original. pure.""" cands = [{"character_id": i, "embedding": e["emb"]} @@ -117,7 +117,7 @@ def _persist_char(manga_id, panel_id, local_id, crop, emb, name, gender, appeara """upload crop + embedding to S3 and register the row via the orchestrator; return its id.""" import cv2 key = f"{manga_id}/characters/_new/{panel_id}_{local_id}" - ref_img_uri, emb_uri = f"s3://manga/{key}.png", f"s3://manga/{key}.npy" + ref_img_uri, emb_uri = transport.CHAR_PNG_URI.format(key=key), transport.CHAR_NPY_URI.format(key=key) ref_png = f"{SHM}/crop_{uuid.uuid4().hex[:8]}.png" cv2.imwrite(ref_png, crop) transport.put(ref_png, ref_img_uri) @@ -220,7 +220,7 @@ async def resolve(data: IdentityInput): # Uploading it now is what removes the third siglip pass # (`decisions/identity-bbox.md#none-mints-an-anonymous-character`). key = f"{data.manga_id}/characters/_crops/{data.panel_id}_{ch['local_id']}" - crop_uri, emb_uri = f"s3://manga/{key}.png", f"s3://manga/{key}.npy" + crop_uri, emb_uri = transport.CHAR_PNG_URI.format(key=key), transport.CHAR_NPY_URI.format(key=key) cp = f"{SHM}/cc_{uuid.uuid4().hex[:8]}.png"; cv2.imwrite(cp, crop) transport.put(cp, crop_uri); os.remove(cp) _save_npy(emb, emb_uri) diff --git a/worker_layers.py b/worker_layers.py index 03cda30..7c98f01 100644 --- a/worker_layers.py +++ b/worker_layers.py @@ -65,15 +65,15 @@ async def layers(data: LayerInput): return {"layer_uris": [], "skipped": "comfyui down"} # ponytail: skip stage if ComfyUI not running local = transport.get(data.panel_uri, f"{SHM}/layer_{uuid.uuid4().hex[:8]}.png") # orchestrator doesn't pass manga/chapter to layers; derive from the panel uri prefix. - parts = data.panel_uri.replace("s3://", "").split("/") - manga_id, chapter_id = parts[1], parts[2] + manga_id, chapter_id = transport.ids_from_uri(data.panel_uri) view_urls = _comfy_run(local, data.num_layers, data.prompt) layer_uris = [] for idx, url in enumerate(view_urls): png = f"{SHM}/layer_{uuid.uuid4().hex[:6]}.png" with open(png, "wb") as f: f.write(requests.get(url, timeout=60).content) - uri = f"s3://layers/{manga_id}/{chapter_id}/layers/{data.panel_id or 'p'}/{idx}.png" + uri = transport.LAYER_URI.format(manga_id=manga_id, chapter_id=chapter_id, + name=data.panel_id or 'p', idx=idx) transport.put(png, uri) os.remove(png) layer_uris.append(uri) diff --git a/worker_render.py b/worker_render.py index 5a0f89c..f3b8c57 100644 --- a/worker_render.py +++ b/worker_render.py @@ -19,13 +19,6 @@ MUSIC_BED = os.environ.get("MUSIC_BED", "") # #13 path/uri of a music t MUSIC_GAIN = os.environ.get("MUSIC_GAIN", "0.18") # bed level before ducking -def _mc_from_uri(uri: str): - """(manga_id, chapter_id) from s3://///... - the orchestrator doesn't pass ids to render/layers, but every input uri encodes them.""" - parts = uri.replace("s3://", "").split("/") - return parts[1], parts[2] - - def _ts(s): h = int(s // 3600); m = int(s % 3600 // 60); sec = s % 60 return f"{h}:{m:02d}:{sec:05.2f}" @@ -37,10 +30,10 @@ def _ts(s): SUB_MODE = os.environ.get("SUB_MODE", "minimal").lower() # off | minimal | boxed SUB_PRESETS = { # (mode, orientation): fontsize, borderstyle(1=outline,3=box), outline/pad, shadow, marginV, side - ("minimal", "portrait"): dict(fs=40, bs=1, outline=3, shadow=2, mv=0.055, side=110), - ("minimal", "landscape"): dict(fs=32, bs=1, outline=3, shadow=2, mv=0.09, side=260), - ("boxed", "portrait"): dict(fs=40, bs=3, outline=6, shadow=0, mv=0.055, side=110), - ("boxed", "landscape"): dict(fs=32, bs=3, outline=6, shadow=0, mv=0.09, side=260), + ("minimal", "portrait"): {"fs": 40, "bs": 1, "outline": 3, "shadow": 2, "mv": 0.055, "side": 110}, + ("minimal", "landscape"): {"fs": 32, "bs": 1, "outline": 3, "shadow": 2, "mv": 0.09, "side": 260}, + ("boxed", "portrait"): {"fs": 40, "bs": 3, "outline": 6, "shadow": 0, "mv": 0.055, "side": 110}, + ("boxed", "landscape"): {"fs": 32, "bs": 3, "outline": 6, "shadow": 0, "mv": 0.09, "side": 260}, } @@ -109,7 +102,7 @@ def _ass(text: str, dur: float, path: str): def _audio_dur(path: str) -> float: """clip length = narration length. scene_timing arrives empty, so probe the audio.""" r = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration", - "-of", "default=nk=1:nw=1", path], capture_output=True, text=True) + "-of", "default=nk=1:nw=1", path], capture_output=True, text=True, check=False) try: return float(r.stdout.strip()) except ValueError: @@ -120,7 +113,7 @@ def _stream_dur(path: str, kind: str) -> float: """duration of one stream. `format=duration` is max(video,audio) and so hides A/V drift.""" r = subprocess.run(["ffprobe", "-v", "error", "-select_streams", f"{kind}:0", "-show_entries", "stream=duration", "-of", "default=nk=1:nw=1", path], - capture_output=True, text=True) + capture_output=True, text=True, check=False) try: return float(r.stdout.strip()) except ValueError: @@ -132,7 +125,7 @@ def _fps_of(path: str) -> str: their rate AND therefore their time_base agree, and the stream-copy concat path cares about that.""" r = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=r_frame_rate", "-of", "default=nk=1:nw=1", path], - capture_output=True, text=True) + capture_output=True, text=True, check=False) return r.stdout.strip() @@ -184,7 +177,7 @@ def _motion(camera: dict, frames: int) -> str: return f"zoompan=z='{z}':x='{x}':y='{y}':d={frames}:s={W}x{H}:fps={FPS}" -def scene_cmd(img: str, audio: str, ass: str, out: str, dur: float, camera: dict = None, +def scene_cmd(img: str, audio: str, ass: str, out: str, dur: float, camera: dict | None = None, pad: float = PAD_S) -> list: """ffmpeg: still panel over a blurred fill of itself + content-aware motion, burned subs, 9:16. #5 blurred bg replaces black bars: one copy scaled to COVER + blurred, the fitted panel on top. @@ -231,8 +224,9 @@ async def render_scene(data: SceneInput): _ass(data.narration_text, dur, ass) out = f"{SHM}/rnd_{tag}.mp4" subprocess.run(scene_cmd(img, audio, ass, out, dur, data.camera), check=True, capture_output=True) - manga_id, chapter_id = _mc_from_uri(data.panel_uri) - uri = f"s3://video/{manga_id}/{chapter_id}/clips/{data.panel_id or 'p'}.mp4" + manga_id, chapter_id = transport.ids_from_uri(data.panel_uri) + uri = transport.CLIP_URI.format(manga_id=manga_id, chapter_id=chapter_id, + name=data.panel_id or 'p') transport.put(out, uri) for p in (img, audio, ass, out): os.remove(p) @@ -341,7 +335,7 @@ class CompositeInput(BaseModel): def _img_size(path: str) -> tuple: r = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", - "stream=width,height", "-of", "csv=p=0:s=x", path], capture_output=True, text=True) + "stream=width,height", "-of", "csv=p=0:s=x", path], capture_output=True, text=True, check=False) w, h = r.stdout.strip().split("x") return int(w), int(h) @@ -377,8 +371,9 @@ async def render_composite(data: CompositeInput): subprocess.run(composite_cmd(still, auds, ass, segs, rowh, out, t), check=True, capture_output=True) for p in imgs + [still]: os.remove(p) - manga_id, chapter_id = _mc_from_uri(data.panels[0]["panel_uri"]) - uri = f"s3://video/{manga_id}/{chapter_id}/clips/{data.panel_id or 'p'}.mp4" + manga_id, chapter_id = transport.ids_from_uri(data.panels[0]["panel_uri"]) + uri = transport.CLIP_URI.format(manga_id=manga_id, chapter_id=chapter_id, + name=data.panel_id or 'p') transport.put(out, uri) for p in auds + [ass, out]: os.remove(p) @@ -424,8 +419,9 @@ async def render_group(data: GroupInput): "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "192k", final] subprocess.run(cmd, check=True, capture_output=True) - manga_id, chapter_id = _mc_from_uri(data.panels[0]["panel_uri"]) - uri = f"s3://video/{manga_id}/{chapter_id}/clips/{data.panel_id or 'p'}.mp4" + manga_id, chapter_id = transport.ids_from_uri(data.panels[0]["panel_uri"]) + uri = transport.CLIP_URI.format(manga_id=manga_id, chapter_id=chapter_id, + name=data.panel_id or 'p') transport.put(final, uri) total = _audio_dur(final) or sum(durs) for f in cleanup + [final]: @@ -449,7 +445,7 @@ class BeatInput(BaseModel): BEAT_MIN_PANEL_S = 1.0 # 185: no member panel flashes by faster than this -def _beat_slices(D: float, n: int, weights: list = None) -> list: +def _beat_slices(D: float, n: int, weights: list | None = None) -> list: """185: split beat duration D across n member panels by content weight, not evenly. Each panel gets >= BEAT_MIN_PANEL_S so a low-weight panel never flashes. Degenerate input (no/short weights, non-positive sum, or D too small for the floors) -> deterministic equal split. @@ -501,8 +497,8 @@ def cue_plan(text: str, D: float, slices: list) -> list: return events -def beat_cmd(imgs: list, audio: str, ass: str, out: str, D: float, cameras: list = None, - weights: list = None) -> list: +def beat_cmd(imgs: list, audio: str, ass: str, out: str, D: float, cameras: list | None = None, + weights: list | None = None) -> list: """ffmpeg: N images shown as a ken-burns montage under ONE narration audio; the last image holds through the trailing pad. each image = blurred-fill bg + fitted panel + its camera move; the single burned subtitle (in `ass`) spans the whole beat. 185: per-image screen-time is content-weighted @@ -561,8 +557,9 @@ async def render_beat(data: BeatInput): subprocess.run(beat_cmd(imgs, audio, ass, out, D, data.cameras, data.weights), check=True, capture_output=True) - manga_id, chapter_id = _mc_from_uri(data.panel_uris[0]) - uri = f"s3://video/{manga_id}/{chapter_id}/clips/{data.panel_id or 'p'}.mp4" + manga_id, chapter_id = transport.ids_from_uri(data.panel_uris[0]) + uri = transport.CLIP_URI.format(manga_id=manga_id, chapter_id=chapter_id, + name=data.panel_id or 'p') transport.put(out, uri) total = _audio_dur(out) or (D + PAD_S) for f in imgs + [audio, ass, out]: @@ -603,8 +600,8 @@ def collage_cmd(imgs, plate_i, rects, entrances, z_order, audio, ass, out, D, tr for img in imgs: cmd += ["-loop", "1", "-i", img] # still held for the whole clip; -t caps output cmd += ["-i", audio] - parts = [f"[{plate_i}:v]scale={W}:{H}:force_original_aspect_ratio=increase," - f"crop={W}:{H},boxblur=24:2,setsar=1[bg]"] + parts = [(f"[{plate_i}:v]scale={W}:{H}:force_original_aspect_ratio=increase," + f"crop={W}:{H},boxblur=24:2,setsar=1[bg]")] base = "bg" for k, i in enumerate(z_order): # shadows first, at rest positions (static) x, y, w, h = (int(round(v)) for v in rects[i]) @@ -661,8 +658,9 @@ async def render_collage(data: CollageInput): out = f"{SHM}/cl_{tag}.mp4" subprocess.run(collage_cmd(imgs, active, lay["rects"], lay["entrances"], lay["z_order"], audio, ass, out, D, lay["transition_s"]), check=True, capture_output=True) - manga_id, chapter_id = _mc_from_uri(uris[0]) - uri = f"s3://video/{manga_id}/{chapter_id}/clips/{data.panel_id or 'p'}.mp4" + manga_id, chapter_id = transport.ids_from_uri(uris[0]) + uri = transport.CLIP_URI.format(manga_id=manga_id, chapter_id=chapter_id, + name=data.panel_id or 'p') transport.put(out, uri) total = _audio_dur(out) or (D + PAD_S) for f in imgs + [audio, ass, out]: @@ -895,8 +893,8 @@ async def assemble(data: AssembleInput): out = _add_music_bed(out, tag, cleanup) - manga_id, chapter_id = _mc_from_uri(data.clip_uris[0]) - uri = f"s3://video/{manga_id}/{chapter_id}/chapter.mp4" + manga_id, chapter_id = transport.ids_from_uri(data.clip_uris[0]) + uri = transport.CHAPTER_URI.format(manga_id=manga_id, chapter_id=chapter_id) transport.put(out, uri) for p in cleanup: os.remove(p) diff --git a/worker_tts.py b/worker_tts.py index 1913029..5116d3b 100644 --- a/worker_tts.py +++ b/worker_tts.py @@ -70,9 +70,10 @@ def _audio_uri(data: "TTSInput") -> str: # ponytail: flat key collides across chapters (as does the homesrv audio table); pass # panel_uri from run_stage_tts to make it per-chapter unique. if data.panel_uri: - parts = data.panel_uri.replace("s3://", "").split("/") - return f"s3://audio/{parts[1]}/{parts[2]}/audio/{data.panel_id or 'p'}.wav" - return f"s3://audio/_audio/{data.panel_id or 'p'}.wav" + manga_id, chapter_id = transport.ids_from_uri(data.panel_uri) + return transport.AUDIO_URI.format(manga_id=manga_id, chapter_id=chapter_id, + name=data.panel_id or 'p') + return transport.AUDIO_FLAT_URI.format(name=data.panel_id or 'p') def _ensure_ref() -> str: @@ -217,6 +218,13 @@ if __name__ == "__main__": os.remove(explicit) assert _calm("Stop!! Now!") == "Stop. Now." and _calm("no bangs") == "no bangs" # even prosody + # _audio_uri: a panel uri puts the wav beside its chapter, no panel uri falls back to the flat key. + assert _audio_uri(TTSInput(text="x", panel_id="p003", + panel_uri="s3://panels/m1/c1/panels/p003.png")) \ + == "s3://audio/m1/c1/audio/p003.wav" + assert _audio_uri(TTSInput(text="x", panel_id="p003")) == "s3://audio/_audio/p003.wav" + assert _audio_uri(TTSInput(text="x")) == "s3://audio/_audio/p.wav" + # 158: pronunciation lexicon respells whole words only, case-insensitive, spoken text only. import tempfile, json as _json globals()["LEXICON_PATH"] = os.path.join(tempfile.mkdtemp(), "lex.json") @@ -233,7 +241,7 @@ if __name__ == "__main__": # loudnorm: with ffmpeg present the wav is normalized in place and stays readable at its sr; # without ffmpeg it's a safe no-op returning the same path (audio never lost). ln = _write_wav(samples, 16000) - have_ffmpeg = subprocess.run(["ffmpeg", "-version"], capture_output=True).returncode == 0 \ + have_ffmpeg = subprocess.run(["ffmpeg", "-version"], capture_output=True, check=False).returncode == 0 \ if __import__("shutil").which("ffmpeg") else False assert _loudnorm(ln) == ln and os.path.exists(ln) assert wave.open(ln, "rb").getframerate() == 16000 diff --git a/worker_vision.py b/worker_vision.py index fb8efec..3bb6bc1 100644 --- a/worker_vision.py +++ b/worker_vision.py @@ -123,8 +123,8 @@ def _set_of_mark(local_path: str, present: list): return marked, "\n" + "\n".join(parts) + "\n", {f["label"]: f["local_id"] or "unknown" for f in faces} -_GENDER_SUFFIX = re.compile(r"\s*\((?:m|f|male|female|man|woman|unknown)\)\s*$", re.I) -_ID_SHAPED = re.compile(r"^(?:person|char|face|p)[\s_-]*\d+$", re.I) +_GENDER_SUFFIX = re.compile(r"\s*\((?:m|f|male|female|man|woman|unknown)\)\s*$", re.IGNORECASE) +_ID_SHAPED = re.compile(r"^(?:person|char|face|p)[\s_-]*\d+$", re.IGNORECASE) def _present_keys(present: list) -> dict: