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 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 23:02:06 +04:00
parent be63b2247d
commit bec9411af3
15 changed files with 131 additions and 58 deletions
+5 -1
View File
@@ -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 worker_scene.py # every module has an assert-based __main__ self-check
.venv/bin/python test_vision_parse.py .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 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, name. Ports: crop 8000, vision 8002, identity 8003, scene 8004, script 8005, tts 8006, layers 8007,
render 8008, session_manager 8095. 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 - Any non-trivial logic gets ONE runnable check in `__main__`, assert-based, no framework. Run the file
to verify it. to verify it.
- Editing a worker's request or response shape means editing the orchestrator too, in the same session. - Editing a worker's request or response shape means editing the orchestrator too, in the same session.
+1 -1
View File
@@ -87,7 +87,7 @@ if worked:
for ch in people: for ch in people:
cid, conf = assigns.get(ch["local_id"], (None, None)) cid, conf = assigns.get(ch["local_id"], (None, None))
name = reg.get(cid, {}).get("name") or (cid[:16] if cid else "-- 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}'}") f"{'' if conf is None else f'{conf:.2f}'}")
else: else:
print(f"\npanel_index {WORKED_EXAMPLE} not found in this chapter") print(f"\npanel_index {WORKED_EXAMPLE} not found in this chapter")
+1 -1
View File
@@ -38,7 +38,7 @@ def _letterbox(img, sz=1024):
return canvas, r, px, py 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, """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 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.""" holistic prompt). Load + inference are lazy so importing this never touches the GPU or the model."""
+1 -1
View File
@@ -32,7 +32,7 @@ def _letterbox(img, sz=640):
return canvas, r, px, py 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 """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.""" reading order. Empty on no faces. Lazy load so import never touches the model."""
conf = CONF if conf is None else conf conf = CONF if conf is None else conf
+21
View File
@@ -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
]
Regular → Executable
+1 -1
View File
@@ -27,7 +27,7 @@ def load_model(model_name: str):
) )
# Match worker_tts.py's torch/torchaudio minor-mismatch bypass too. # Match worker_tts.py's torch/torchaudio minor-mismatch bypass too.
import torch import torch
import importlib.metadata as metadata from importlib import metadata
real_version = metadata.version real_version = metadata.version
metadata.version = ( metadata.version = (
+1 -1
View File
@@ -6,7 +6,7 @@
# in-process models (siglip2, dots): no server — the lease just reserves the GPU; the # 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). # 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. # 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 import requests
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
+1 -1
View File
@@ -14,7 +14,7 @@ def test_extract_rejects_malformed_and_truncated():
try: try:
wv._extract_json(bad) wv._extract_json(bad)
assert False, f"should have raised on: {bad!r}" assert False, f"should have raised on: {bad!r}"
except (ValueError, ValueError): except ValueError:
pass pass
+44 -3
View File
@@ -12,6 +12,29 @@ from starlette.responses import Response
_client = None _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: <bucket>/<manga_id>/<chapter_id>/...
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: def _summarize(body: bytes, limit=6) -> str:
"""compact one-line view of a json body for observability: uri inputs/outputs (basename, or """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(): for k, v in obj.items():
if k == "panel_id": if k == "panel_id":
continue 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]}") 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)}") parts.append(f"{k}×{len(v)}")
elif isinstance(v, (int, float, bool)): elif isinstance(v, (int, float, bool)):
parts.append(f"{k}={v}") parts.append(f"{k}={v}")
@@ -102,7 +125,7 @@ def _mc():
def _split(uri: str): def _split(uri: str):
"""(bucket, key) from an s3-style or bare uri.""" """(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("/") bucket, _, key = u.partition("/")
if not bucket or not key: if not bucket or not key:
raise ValueError(f"bad uri: {uri!r}") raise ValueError(f"bad uri: {uri!r}")
@@ -213,4 +236,22 @@ if __name__ == "__main__":
assert _summarize(b"clip_uri", ) == "-" # non-json assert _summarize(b"clip_uri", ) == "-" # non-json
assert _summarize(json.dumps({"clip_uri": "s3://m/c/clips/p1.mp4", "duration": 4.1}).encode()) \ assert _summarize(json.dumps({"clip_uri": "s3://m/c/clips/p1.mp4", "duration": 4.1}).encode()) \
== "clip_uri=p1.mp4 duration=4.1" == "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") print("transport self-check ok")
+3 -2
View File
@@ -244,7 +244,7 @@ async def crop_webtoon(data: WebtoonInput):
context_links = context_fragment_links(crops) context_links = context_fragment_links(crops)
panels = [] panels = []
for idx, (crop_img, bbox) in enumerate(crops): 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. # 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 # 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 # 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): for idx, (crop_img, bbox) in enumerate(crops):
out = f"{SHM}/pg{data.page_index:03d}_p{idx:02d}.png" out = f"{SHM}/pg{data.page_index:03d}_p{idx:02d}.png"
cv2.imwrite(out, crop_img) 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) transport.put(out, uri)
os.remove(out) os.remove(out)
panels.append({"panel_index": idx, "uri": uri, "bbox": bbox, panels.append({"panel_index": idx, "uri": uri, "bbox": bbox,
+4 -4
View File
@@ -78,7 +78,7 @@ def match(emb: np.ndarray, known: list, threshold: float):
return None, best_conf, ambiguous 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 """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 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.""" 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) 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 """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.""" of a conflicting decided gender are excluded. character_id=i keeps the returned index original. pure."""
cands = [{"character_id": i, "embedding": e["emb"]} 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.""" """upload crop + embedding to S3 and register the row via the orchestrator; return its id."""
import cv2 import cv2
key = f"{manga_id}/characters/_new/{panel_id}_{local_id}" 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" ref_png = f"{SHM}/crop_{uuid.uuid4().hex[:8]}.png"
cv2.imwrite(ref_png, crop) cv2.imwrite(ref_png, crop)
transport.put(ref_png, ref_img_uri) 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 # Uploading it now is what removes the third siglip pass
# (`decisions/identity-bbox.md#none-mints-an-anonymous-character`). # (`decisions/identity-bbox.md#none-mints-an-anonymous-character`).
key = f"{data.manga_id}/characters/_crops/{data.panel_id}_{ch['local_id']}" 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) cp = f"{SHM}/cc_{uuid.uuid4().hex[:8]}.png"; cv2.imwrite(cp, crop)
transport.put(cp, crop_uri); os.remove(cp) transport.put(cp, crop_uri); os.remove(cp)
_save_npy(emb, emb_uri) _save_npy(emb, emb_uri)
+3 -3
View File
@@ -65,15 +65,15 @@ async def layers(data: LayerInput):
return {"layer_uris": [], "skipped": "comfyui down"} # ponytail: skip stage if ComfyUI not running 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") 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. # 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 = transport.ids_from_uri(data.panel_uri)
manga_id, chapter_id = parts[1], parts[2]
view_urls = _comfy_run(local, data.num_layers, data.prompt) view_urls = _comfy_run(local, data.num_layers, data.prompt)
layer_uris = [] layer_uris = []
for idx, url in enumerate(view_urls): for idx, url in enumerate(view_urls):
png = f"{SHM}/layer_{uuid.uuid4().hex[:6]}.png" png = f"{SHM}/layer_{uuid.uuid4().hex[:6]}.png"
with open(png, "wb") as f: with open(png, "wb") as f:
f.write(requests.get(url, timeout=60).content) 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) transport.put(png, uri)
os.remove(png) os.remove(png)
layer_uris.append(uri) layer_uris.append(uri)
+31 -33
View File
@@ -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 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://<bucket>/<manga_id>/<chapter_id>/...
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): def _ts(s):
h = int(s // 3600); m = int(s % 3600 // 60); sec = s % 60 h = int(s // 3600); m = int(s % 3600 // 60); sec = s % 60
return f"{h}:{m:02d}:{sec:05.2f}" 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_MODE = os.environ.get("SUB_MODE", "minimal").lower() # off | minimal | boxed
SUB_PRESETS = { SUB_PRESETS = {
# (mode, orientation): fontsize, borderstyle(1=outline,3=box), outline/pad, shadow, marginV, side # (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", "portrait"): {"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), ("minimal", "landscape"): {"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", "portrait"): {"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), ("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: def _audio_dur(path: str) -> float:
"""clip length = narration length. scene_timing arrives empty, so probe the audio.""" """clip length = narration length. scene_timing arrives empty, so probe the audio."""
r = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration", 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: try:
return float(r.stdout.strip()) return float(r.stdout.strip())
except ValueError: 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.""" """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", r = subprocess.run(["ffprobe", "-v", "error", "-select_streams", f"{kind}:0",
"-show_entries", "stream=duration", "-of", "default=nk=1:nw=1", path], "-show_entries", "stream=duration", "-of", "default=nk=1:nw=1", path],
capture_output=True, text=True) capture_output=True, text=True, check=False)
try: try:
return float(r.stdout.strip()) return float(r.stdout.strip())
except ValueError: 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.""" 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", r = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=r_frame_rate", "-of", "default=nk=1:nw=1", path], "-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() 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}" 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: pad: float = PAD_S) -> list:
"""ffmpeg: still panel over a blurred fill of itself + content-aware motion, burned subs, 9:16. """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. #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) _ass(data.narration_text, dur, ass)
out = f"{SHM}/rnd_{tag}.mp4" out = f"{SHM}/rnd_{tag}.mp4"
subprocess.run(scene_cmd(img, audio, ass, out, dur, data.camera), check=True, capture_output=True) 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) manga_id, chapter_id = transport.ids_from_uri(data.panel_uri)
uri = f"s3://video/{manga_id}/{chapter_id}/clips/{data.panel_id or 'p'}.mp4" uri = transport.CLIP_URI.format(manga_id=manga_id, chapter_id=chapter_id,
name=data.panel_id or 'p')
transport.put(out, uri) transport.put(out, uri)
for p in (img, audio, ass, out): for p in (img, audio, ass, out):
os.remove(p) os.remove(p)
@@ -341,7 +335,7 @@ class CompositeInput(BaseModel):
def _img_size(path: str) -> tuple: def _img_size(path: str) -> tuple:
r = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", 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") w, h = r.stdout.strip().split("x")
return int(w), int(h) 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) subprocess.run(composite_cmd(still, auds, ass, segs, rowh, out, t), check=True, capture_output=True)
for p in imgs + [still]: for p in imgs + [still]:
os.remove(p) os.remove(p)
manga_id, chapter_id = _mc_from_uri(data.panels[0]["panel_uri"]) manga_id, chapter_id = transport.ids_from_uri(data.panels[0]["panel_uri"])
uri = f"s3://video/{manga_id}/{chapter_id}/clips/{data.panel_id or 'p'}.mp4" uri = transport.CLIP_URI.format(manga_id=manga_id, chapter_id=chapter_id,
name=data.panel_id or 'p')
transport.put(out, uri) transport.put(out, uri)
for p in auds + [ass, out]: for p in auds + [ass, out]:
os.remove(p) 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] "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "192k", final]
subprocess.run(cmd, check=True, capture_output=True) subprocess.run(cmd, check=True, capture_output=True)
manga_id, chapter_id = _mc_from_uri(data.panels[0]["panel_uri"]) manga_id, chapter_id = transport.ids_from_uri(data.panels[0]["panel_uri"])
uri = f"s3://video/{manga_id}/{chapter_id}/clips/{data.panel_id or 'p'}.mp4" uri = transport.CLIP_URI.format(manga_id=manga_id, chapter_id=chapter_id,
name=data.panel_id or 'p')
transport.put(final, uri) transport.put(final, uri)
total = _audio_dur(final) or sum(durs) total = _audio_dur(final) or sum(durs)
for f in cleanup + [final]: 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 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. """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 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. (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 return events
def beat_cmd(imgs: list, audio: str, ass: str, out: str, D: float, cameras: list = None, def beat_cmd(imgs: list, audio: str, ass: str, out: str, D: float, cameras: list | None = None,
weights: list = None) -> list: weights: list | None = None) -> list:
"""ffmpeg: N images shown as a ken-burns montage under ONE narration audio; the last image holds """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 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 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), subprocess.run(beat_cmd(imgs, audio, ass, out, D, data.cameras, data.weights),
check=True, capture_output=True) check=True, capture_output=True)
manga_id, chapter_id = _mc_from_uri(data.panel_uris[0]) manga_id, chapter_id = transport.ids_from_uri(data.panel_uris[0])
uri = f"s3://video/{manga_id}/{chapter_id}/clips/{data.panel_id or 'p'}.mp4" uri = transport.CLIP_URI.format(manga_id=manga_id, chapter_id=chapter_id,
name=data.panel_id or 'p')
transport.put(out, uri) transport.put(out, uri)
total = _audio_dur(out) or (D + PAD_S) total = _audio_dur(out) or (D + PAD_S)
for f in imgs + [audio, ass, out]: 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: for img in imgs:
cmd += ["-loop", "1", "-i", img] # still held for the whole clip; -t caps output cmd += ["-loop", "1", "-i", img] # still held for the whole clip; -t caps output
cmd += ["-i", audio] cmd += ["-i", audio]
parts = [f"[{plate_i}:v]scale={W}:{H}:force_original_aspect_ratio=increase," parts = [(f"[{plate_i}:v]scale={W}:{H}:force_original_aspect_ratio=increase,"
f"crop={W}:{H},boxblur=24:2,setsar=1[bg]"] f"crop={W}:{H},boxblur=24:2,setsar=1[bg]")]
base = "bg" base = "bg"
for k, i in enumerate(z_order): # shadows first, at rest positions (static) 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]) 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" out = f"{SHM}/cl_{tag}.mp4"
subprocess.run(collage_cmd(imgs, active, lay["rects"], lay["entrances"], lay["z_order"], 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) audio, ass, out, D, lay["transition_s"]), check=True, capture_output=True)
manga_id, chapter_id = _mc_from_uri(uris[0]) manga_id, chapter_id = transport.ids_from_uri(uris[0])
uri = f"s3://video/{manga_id}/{chapter_id}/clips/{data.panel_id or 'p'}.mp4" uri = transport.CLIP_URI.format(manga_id=manga_id, chapter_id=chapter_id,
name=data.panel_id or 'p')
transport.put(out, uri) transport.put(out, uri)
total = _audio_dur(out) or (D + PAD_S) total = _audio_dur(out) or (D + PAD_S)
for f in imgs + [audio, ass, out]: for f in imgs + [audio, ass, out]:
@@ -895,8 +893,8 @@ async def assemble(data: AssembleInput):
out = _add_music_bed(out, tag, cleanup) out = _add_music_bed(out, tag, cleanup)
manga_id, chapter_id = _mc_from_uri(data.clip_uris[0]) manga_id, chapter_id = transport.ids_from_uri(data.clip_uris[0])
uri = f"s3://video/{manga_id}/{chapter_id}/chapter.mp4" uri = transport.CHAPTER_URI.format(manga_id=manga_id, chapter_id=chapter_id)
transport.put(out, uri) transport.put(out, uri)
for p in cleanup: for p in cleanup:
os.remove(p) os.remove(p)
+12 -4
View File
@@ -70,9 +70,10 @@ def _audio_uri(data: "TTSInput") -> str:
# ponytail: flat key collides across chapters (as does the homesrv audio table); pass # 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. # panel_uri from run_stage_tts to make it per-chapter unique.
if data.panel_uri: if data.panel_uri:
parts = data.panel_uri.replace("s3://", "").split("/") manga_id, chapter_id = transport.ids_from_uri(data.panel_uri)
return f"s3://audio/{parts[1]}/{parts[2]}/audio/{data.panel_id or 'p'}.wav" return transport.AUDIO_URI.format(manga_id=manga_id, chapter_id=chapter_id,
return f"s3://audio/_audio/{data.panel_id or 'p'}.wav" name=data.panel_id or 'p')
return transport.AUDIO_FLAT_URI.format(name=data.panel_id or 'p')
def _ensure_ref() -> str: def _ensure_ref() -> str:
@@ -217,6 +218,13 @@ if __name__ == "__main__":
os.remove(explicit) os.remove(explicit)
assert _calm("Stop!! Now!") == "Stop. Now." and _calm("no bangs") == "no bangs" # even prosody 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. # 158: pronunciation lexicon respells whole words only, case-insensitive, spoken text only.
import tempfile, json as _json import tempfile, json as _json
globals()["LEXICON_PATH"] = os.path.join(tempfile.mkdtemp(), "lex.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; # 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). # without ffmpeg it's a safe no-op returning the same path (audio never lost).
ln = _write_wav(samples, 16000) 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 if __import__("shutil").which("ffmpeg") else False
assert _loudnorm(ln) == ln and os.path.exists(ln) assert _loudnorm(ln) == ln and os.path.exists(ln)
assert wave.open(ln, "rb").getframerate() == 16000 assert wave.open(ln, "rb").getframerate() == 16000
+2 -2
View File
@@ -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} 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) _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.I) _ID_SHAPED = re.compile(r"^(?:person|char|face|p)[\s_-]*\d+$", re.IGNORECASE)
def _present_keys(present: list) -> dict: def _present_keys(present: list) -> dict: