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