Working tree (including .git) was lost to an rm. Rebuilt by replaying Write/Edit/ Read/attachment events from 25 Claude sessions and 22 successful codex apply_patch blocks into one timestamp-ordered timeline. Verified against ground truth recorded in the transcripts: wc -l on 10 files and ls -l on 5 files at 2026-07-18T13:13:44Z both match exactly; 18 files are byte-identical to their newest ~/.claude/file-history blob. See HANDOFF.md for sources, gaps, and how to rebuild .venv. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
19 KiB
plan — workpc (compute + transport)
workpc owns: GPU, model lifecycle, per-item worker endpoints, the session manager that enforces one-warm-model-at-a-time. workpc holds NO durable state — every artifact goes to homesrv (minio) before a stage is considered done. sqlite lives on homesrv; workpc workers are stateless HTTP endpoints that read inputs by URI and write outputs to minio.
this plan assumes the homesrv plan (sqlite schema, minio buckets, orchestrator service, n8n workflow) lands in parallel. workpc can't be fully tested without homesrv's transport layer, but each worker can be developed and self-checked in isolation against a local minio + sqlite stub.
phase 0 — repo hygiene
git initin/home/kami/Programs/n8n-worker/, initial commit of current state on alegacy/branch. main branch starts clean..gitignore:.venv/,__pycache__/,dots.tts/(submodule-ish, has its own repo), any local model paths.- move existing workers into
legacy/for reference, don't delete — the new code is a rewrite, not an edit.
phase 1 — session manager (the GPU mutex)
new file: session_manager.py. FastAPI on 127.0.0.1:8095.
this is the single most important new component on workpc. it owns:
- which model is currently loaded (gemma4 / siglip2 / dots.tts / comfyui-via-external)
- the llama-server subprocess lifecycle (start, health-wait, teardown)
- a GPU mutex — only one model resident at a time, enforced via a lock
- session lease tracking (who opened what, when, for timeout/reaper logic)
endpoints
POST /session/open {model: "gemma4"|"siglip2"|"dots", ttl: 3600}
-> {session_id, port} # port is the warm server's localhost port
409 if another model is resident (caller retries or queues)
POST /session/close {session_id}
-> {ok: true} # tears down the subprocess, releases GPU
GET /session/active
-> {model, session_id, opened_at, port} | null
POST /session/heartbeat {session_id}
-> {ok: true} # extends the lease; stale sessions get reaped
model registry
hardcoded map in the file (not config — these paths are workpc-specific):
MODELS = {
"gemma4": {
"binary": "/mnt/D/AI/llama-cpp/llama.cpp/build/bin/llama-server",
"model": "/mnt/D/AI/gemma4/gemma-4-12B-it-qat-UD-Q4_K_XL.gguf",
"mmproj": "/mnt/D/AI/gemma4/mmproj-F16.gguf",
"port": 8090, "ctx": 4096, "args_extra": ["--jinja", "--reasoning-budget", "0"],
},
"siglip2": {
"binary": "transformers", # in-process, not subprocess
"model": "google/siglip2-so400m-patch16-384",
"port": None, # no http server, direct python call
},
"dots": {
"binary": "transformers", # in-process
"model": "rednote-hilab/dots.tts-base",
},
}
siglip2 and dots load in-process (transformers, the rocm torch build already on workpc). gemma4 stays subprocess (llama-server, the working mtmd path). the session manager handles both kinds.
reaper
background thread: any session without a heartbeat in ttl seconds gets force-closed. prevents a crashed n8n node from holding the GPU forever.
systemd
system unit /etc/systemd/system/manga-session.service, User=kami, ExecStart via the workpc venv (python -m uvicorn session_manager:app --host 127.0.0.1 --port 8095). --system-site-packages venv to reuse the rocm torch build.
self-check
python session_manager.py:
- open gemma4 session → active → open second → 409 → close first → open second → ok
- heartbeat: open with ttl=2, sleep 5, assert reaped via
/session/active - (gemma4 subprocess not actually started in self-check — mock the binary path to
/bin/sleep)
phase 2 — transport layer (minio client + sqlite status reporter)
new file: transport.py. shared by all workers. two concerns:
2a. minio artifact client
def put(local_path: str, uri: str) -> str:
"""upload local file to minio at uri (e.g. 'manga/{mid}/{cid}/panels/p001.png'),
return the full s3-style uri. raises on failure."""
def get(uri: str, local_path: str) -> str:
"""download uri to local_path, return local_path. workers pull inputs to /dev/shm
before processing, push outputs back."""
def exists(uri: str) -> bool:
"""HEAD check. used for skip-if-exists at the worker level (defense in depth —
the orchestrator on homesrv is the real skip authority via sqlite)."""
def put_bytes(data: bytes, uri: str) -> str:
"""for generated artifacts that don't touch disk (embeddings, json)."""
minio endpoint: 192.168.1.104:9000 (homesrv). creds via env, defaults match existing storage.py.
2b. sqlite status reporter
workers don't own state, but they need to report progress so n8n/the orchestrator can poll. two options:
option A (chosen): workers don't touch sqlite directly. they return progress in their HTTP response, the orchestrator on homesrv writes to sqlite. workers are pure compute — input URI in, output URI + metadata out. keeps the "workpc = compute only" boundary clean.
option B (rejected): workers write to sqlite over the nginx-exposed endpoint. adds a network-sqlite dependency to every worker, couples them to homesrv's db schema. rejected.
so transport.py is just minio. sqlite stays homesrv-side. workers are stateless.
self-check
python transport.py: put a tempfile, get it back, assert bytes match, exists() true, missing uri exists() false. mock minio client for offline test.
phase 3 — worker: OCR (stage 3)
new file: worker_ocr.py. FastAPI on 127.0.0.1:8001.
input
POST /ocr
{
"panel_uri": "s3://manga/{mid}/{cid}/panels/p001.png",
"job_id": "...",
"panel_id": "p001"
}
process
transport.get(panel_uri, /dev/shm/ocr_input.png)- pytesseract.image_to_data with bbox output (
--psm 11sparse, manga bubbles are isolated) - filter low-confidence (<0.5), dedupe overlapping boxes
- cluster text lines (group words on same row within x-threshold)
- return structured result
output
{
"panel_id": "p001",
"texts": [
{"id": "t001", "content": "...", "bbox": [x,y,w,h], "confidence": 0.97}
]
}
the orchestrator (homesrv) takes this, writes to sqlite ocr_results table, and feeds it forward to vision + scene graph. worker doesn't persist anything — it returns the json, homesrv stores it.
self-check
synthetic image with two text blocks, assert two texts returned with sane bboxes. no minio in self-check (local path variant of the endpoint for testing).
phase 4 — worker: vision (stage 4, reworked)
new file: worker_vision.py. FastAPI on 127.0.0.1:8002.
input
POST /vision
{
"panel_uri": "...",
"ocr_texts": [...], // from stage 3, injected into prompt
"known_characters": [ // from identity store, injected into prompt
{"name": "Gojo", "description": "white hair, blindfold, black coat"}
],
"panel_id": "p001",
"session_id": "..." // gemma4 session, opened by orchestrator via session manager
}
process
transport.get(panel_uri, /dev/shm/vision_input.png)- build prompt: structured output request + OCR text + known characters
- call gemma4 at
localhost:8090/v1/chat/completions(the session manager's warm server) - parse structured json from response (gemma4 with
--jinjacan do json mode; enforce via prompt + json.loads with retry on parse failure)
output (matches spec §4)
{
"panel_id": "p001",
"characters": [
{"local_id": "person_1", "appearance": {"hair": "...", "clothing": "...", "features": [...]}, "emotion": "...", "action": "..."}
],
"scene": {"location": "...", "time": "..."}
}
prompt design (the hard part)
prompt must produce parseable json. structure:
You are analyzing a manga panel. Known characters in this story: Gojo (white hair, blindfold, black coat).
Text found in speech bubbles: "..." , "..."
Respond in this exact JSON format:
{
"characters": [{"local_id": "person_1", "appearance": {"hair": "", "clothing": "", "features": []}, "emotion": "", "action": ""}],
"scene": {"location": "", "time": ""}
}
gemma4's reasoning output (<|channel>thought...<channel|>) must be stripped before json parse — reuse _strip_thought from legacy code.
self-check
mock the gemma4 call (return canned json), assert parse + structured output. real gemma4 test is integration (needs session manager + model).
phase 5 — worker: character identity (stage 5)
new file: worker_identity.py. FastAPI on 127.0.0.1:8003.
this is the biggest new worker. it does NOT load a model itself — it uses the siglip2 session (opened via session manager) for embeddings, and queries homesrv's sqlite (via an orchestrator endpoint, not direct sqlite) for known characters.
input
POST /identity/resolve
{
"panel_uri": "...",
"panel_id": "p001",
"vision_characters": [ // from stage 4
{"local_id": "person_1", "appearance": {...}, "bbox_in_panel": [...]}
],
"manga_id": "...",
"session_id": "..." // siglip2 session
}
process
transport.get(panel_uri, /dev/shm/ident_input.png)- for each vision character:
a. crop the character region from the panel (bbox from vision stage — open question: does vision return character bboxes? currently spec §4 doesn't include them. needs adding.)
b. compute siglip2 image embedding of the crop
c. query orchestrator:
GET /characters/known?manga_id=...→ list of known characters with their reference embedding URIs d. for each known character:transport.get(embedding_uri, /dev/shm/ref.npy), cosine similarity vs the new crop embedding e. if best match > threshold (start 0.85, tune per-manga): assigncharacter_idf. if below threshold: create unknown candidate →POST /characters/createto orchestrator, get newcharacter_id, upload the crop as its first reference image
output
{
"panel_id": "p001",
"assignments": [
{"local_id": "person_1", "character_id": "character_001", "confidence": 0.96}
],
"new_characters": ["character_007"]
}
threshold + matching strategy
- cosine similarity on siglip2 image embeddings, top-1 match
- threshold per-manga, stored in sqlite
manga_configtable (default 0.85) - if multiple known characters are close (within 0.05 of each other), pick the best but flag low-confidence for the scene graph to handle gracefully (use "a person" in narration rather than a wrong name)
self-check
mock siglip2 (return deterministic vectors), mock orchestrator (return known chars), assert: high-similarity → assign, low → create new. no real model in self-check.
phase 6 — worker: scene graph (stage 6)
new file: worker_scene.py. FastAPI on 127.0.0.1:8004.
this is a join stage, not a model stage. no GPU needed. it could run on homesrv, but keeping it on workpc for uniformity (and it's cheap enough to not need the session manager).
input
POST /scene/build
{
"panel_id": "p001",
"panel_uri": "...",
"ocr_texts": [...], // stage 3
"vision_result": {...}, // stage 4
"identity_assignments": [...], // stage 5
"characters_registry": [...] // known characters with names, for speaker attribution
}
process
- join vision characters with identity assignments → characters with real names
- speaker attribution (the hard sub-problem):
- for each OCR text block, find the nearest vision character bbox (spatial heuristic)
- if text contains a name that ISN'T the nearest character (e.g. "Teto, come here" nearest to person_2), the speaker is likely person_2 addressing Teto
- if text is a question/exclamation with no names, speaker = nearest character
- if ambiguous, mark
speaker: null(narration will quote without attribution) - this is heuristic and will be wrong sometimes — accept it for v2, flag for v3 improvement with a dedicated model
- build scene graph
output (matches spec §6)
{
"panel_id": "p001",
"characters": [{"id": "character_001", "name": "Gojo Satoru"}],
"dialogue": [{"speaker": "character_001", "text": "..."}],
"action": "character approaches enemy"
}
self-check
synthetic inputs with known spatial layout, assert speaker attribution picks nearest character. test the name-exception case.
phase 7 — worker: script (stage 7, reworked)
new file: worker_script.py. FastAPI on 127.0.0.1:8005.
input
POST /script
{
"scene_graph": {...}, // stage 6
"chapter_context": "...", // prior scenes summary, for continuity
"panel_id": "p001",
"session_id": "..." // gemma4 session (reused from vision stage if still open)
}
process
- build prompt from scene graph: characters (with names), dialogue (quoted), action, scene setting
- call gemma4 (warm session)
- return narration text
output
{"panel_id": "p001", "text": "..."}
prompt design
Write narration for this manga panel. Use the characters' real names. Quote dialogue naturally. One paragraph.
Characters present: Gojo Satoru (white hair, blindfold)
Dialogue: Gojo says "..."
Action: Gojo approaches the enemy
Setting: street, night
Narration:
self-check
mock gemma4, assert prompt construction includes character names + dialogue. real test is integration.
phase 8 — worker: TTS (stage 8, reworked)
new file: worker_tts.py. FastAPI on 127.0.0.1:8006.
reuses the dots.tts code from legacy render_worker.py but decoupled from layers.
input
POST /tts
{
"text": "...",
"speaker": "narrator", // for now always narrator; multi-voice is v3
"panel_id": "p001",
"session_id": "..." // dots session
}
output
{
"audio_uri": "s3://manga/{mid}/{cid}/audio/p001.wav",
"duration": 4.2
}
worker generates audio locally, transport.put to minio, returns the uri. no local persistence.
self-check
mock the tts model (write a sine wave), assert upload + uri return. real test is integration.
phase 9 — worker: layers (stage 9 part 1)
new file: worker_layers.py. FastAPI on 127.0.0.1:8007.
wraps the comfyui layered workflow from legacy render_worker.py. comfyui runs as its own external process (not via session manager — it has its own server lifecycle).
input
POST /layers
{
"panel_uri": "...",
"panel_id": "p001",
"num_layers": 4,
"prompt": "..."
}
output
{
"layer_uris": ["s3://manga/{mid}/{cid}/layers/p001/0.png", ...]
}
self-check
mock comfyui (return canned image urls), assert upload + uri list. real test needs comfyui running.
phase 10 — worker: render (stage 9 part 2, the new part)
new file: worker_render.py. FastAPI on 127.0.0.1:8008.
this is the new rendering work — camera movement, transitions, subtitles, effects. spec §9. this is the most open-ended phase.
input
POST /render/scene
{
"panel_uri": "...",
"layer_uris": [...], // from stage 9 part 1 (optional)
"audio_uri": "...",
"narration_text": "...", // for subtitles
"panel_id": "p001",
"scene_timing": {"start": 0.0, "end": 4.2}
}
process
- pull inputs to /dev/shm
- if layers present: parallax motion (subtle pan/zoom on separated depth layers)
- if no layers: ken burns effect (slow zoom/pan on static panel)
- burn subtitles from narration_text (ass format, styled)
- transitions between scenes: crossfade (handled at the chapter assembly level, not per-scene — see below)
- encode scene clip with ffmpeg
output
{"clip_uri": "s3://manga/{mid}/{cid}/clips/p001.mp4"}
chapter assembly (separate endpoint)
POST /render/assemble
{
"clip_uris": [...], // ordered
"chapter_id": "..."
}
concat with crossfade transitions between clips, output final chapter.mp4, upload to minio.
self-check
synthetic images + silent audio, assert clip produced + uploaded. real motion test is visual (manual review).
phase 12 — worker: crop (stage 2, runs first)
new file: worker_crop.py. FastAPI on 127.0.0.1:8000.
the panel detector. stage 2 in the pipeline (before OCR) but written last — it's the one worker the homesrv plan referenced without a contract here. no GPU (kumiko panel detection is cpu/opencv), no session manager. one page in, N panel crops out.
input
POST /crop
{
"page_uri": "s3://manga/{mid}/{cid}/pages/p_000.png",
"manga_id": "...",
"chapter_id": "...",
"page_index": 0,
"job_id": "..."
}
process
transport.get(page_uri, /dev/shm/crop_input.png)- kumiko panel detection (reuse the legacy kumiko path) → ordered list of panel bboxes in reading order (right-to-left, top-to-bottom for manga)
- for each detected panel: crop from the page,
transport.puttomanga/{mid}/{cid}/panels/pg{page_index:03d}_p{panel_index:02d}.png - return the panel URIs + bboxes in reading order (panel_index is the within-page order; the orchestrator assigns the flat chapter-wide
panel_id/reading order)
output
{
"page_index": 0,
"panels": [
{"panel_index": 0, "uri": "s3://manga/{mid}/{cid}/panels/pg000_p00.png", "bbox": [x, y, w, h]}
]
}
bbox is [x, y, w, h] on the source page (matches the homesrv panels.bbox column). the orchestrator is the skip authority: it calls /crop once per page and skips pages that already have panel rows (defense-in-depth transport.exists in the worker is optional).
self-check
synthetic 2-panel page image, assert two panels returned in reading order with sane bboxes + uploaded URIs. no real kumiko model needed if the detector is opencv-only; otherwise mock the detector.
phase 11 — startup script
rewrite start_workers.sh to launch:
- session_manager (8095)
- worker_crop (8000)
- worker_ocr (8001)
- worker_vision (8002)
- worker_identity (8003)
- worker_scene (8004)
- worker_script (8005)
- worker_tts (8006)
- worker_layers (8007)
- worker_render (8008)
each as a background uvicorn process in a tmux session. session manager starts first, workers wait for it.
systemd units for each (system services, User=kami) — preferred over tmux for production, tmux for dev. provide both.
open questions deferred to homesrv plan
- exact sqlite schema (characters table, ocr_results, vision_results, scene_graphs, scripts, audio, clips, jobs, stages) — defined in homesrv plan
- orchestrator endpoint contracts (
/characters/known,/characters/create, stage status writes) — defined in homesrv plan - n8n workflow shape (which nodes, how session open/close maps to stage boundaries) — defined in homesrv plan
- minio bucket layout (
mangasingle bucket with key prefixes, or per-manga buckets?) — defined in homesrv plan, workpc transport.py just takes URIs
open question flagged for your decision
- vision stage character bboxes: spec §4 doesn't include character bounding boxes in the vision output, but the identity worker (phase 5) needs them to crop character regions for siglip2 embeddings. add
bboxto each character in the vision output? this is a spec amendment. my recommendation: yes, add it — gemma4 can produce bboxes if asked, and it's necessary for the identity crop. flag this in the homesrv plan too since it touches the spec.