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 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.
+1 -1
View File
@@ -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")
+1 -1
View File
@@ -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."""
+1 -1
View File
@@ -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
+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.
import torch
import importlib.metadata as metadata
from importlib import metadata
real_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
# 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
+1 -1
View File
@@ -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
+44 -3
View File
@@ -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: <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:
"""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")
+3 -2
View File
@@ -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,
+4 -4
View File
@@ -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)
+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
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)
+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
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):
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)
+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
# 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
+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}
_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: