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:
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user