web/, served by seriesmith serve
(seriesmith/server/standalone.py, default http://127.0.0.1:8190) beside the page
itself. The routes are thin: every one calls a seriesmith function (edit, jobs,
takes, refs, …) through seriesmith/api.py, which already has tests; the same
handlers can be mounted on a ComfyUI’s own server (seriesmith/server/comfyui.py). This
file is the contract between the routes and the UI. Change it first when either side
needs something new.
Every job — a render, a reference, a voice, an upscale, a post, a tour — goes on
seriesmith’s queue (seriesmith/queue.py, runner.py) and runs on a backend
(local, cloud, deployment:<target>, router; SETUP.md). Where a route
below returns a prompt_id (or a record has comfy_prompt_id), it is the queue’s job id:
the editor follows a job by it, and GET /seriesmith/jobs lists them. A route that takes
an optional backend sends its jobs there instead of each job’s own.
Sections dated before 2026-10 and marked [settled], [added] or “Phase N” are the
design record carried over from h3pipe: where they name h3pipe’s nodes (H3SaveRefTake,
H3ContinuityFrame, a project_root input), seriesmith does the same thing through the
queue — files go up with each job and come back as its outputs.
Route index
Every routeseriesmith/api.py (ROUTES) and seriesmith/server/queue_views.py serve,
with the section that documents it. Later sections amend earlier ones, so read a route’s
section and search for the path for its later additions.
Not seriesmith’s own: the page’s websocket
/ws (Live updates) and, under seriesmith serve, ComfyUI’s /models, /object_info/{node} and /queue (Models). The api.py
routes and server/stop also answer under /api/seriesmith/... (inside ComfyUI, every
route does, as ComfyUI serves its own).
Conventions
- Prefix
/seriesmith/. JSON in and out (Content-Type: application/json), UTF-8. - Episodes are named by absolute folder path,
ep, in the query string for GET and in the body for writes. Everyepmust be inside a configured root (see/seriesmith/config), or the route answers 403. Relativepaths are relative toep, use forward slashes, and stay inside it — with two exceptions for a series config that names a file above its episode (2026-09-22,_in_series_home): a config living in the episode’s parent opens that whole folder, and a config living in the episode that names../refs/...or../audio/...opens exactly those two folders of the parent. The second is the shared-refs layout a multi-episode series uses — onerefs/for the show, aseries.jsonper episode so an episode can add props and locations of its own — and without it every sheet and plate in the Refs tab is a broken image. A sibling episode’s files, the show folder’s own files and anything outside a configured root stay refused. passis"final"or"proxy". Default"proxy", except the upscale, post and master routes, where it is"final". (The editor itself opens onfinaluntil the user picks a pass; it always sends one.)- Seeds are strings in every request and response. Built seeds are 63-bit and JavaScript numbers lose precision above 2^53. Never parse them to a JS number.
- Errors: status 4xx/5xx with
{"error": "message"}. 400 bad input, 403 path outside roots, 404 unknown episode/shot/take/file, 409 conflict (e.g. picking an unusable take), 500 anything else. The message is written for a person. - Blocking work (file scans, hashing, ffmpeg, build) runs off the event loop
(
run_in_executor). No route blocks the server.
Config
POST /seriesmith/server/stop (standalone only)
Body {restart?: bool}. Stops seriesmith serve cleanly: the page’s connection closed,
the queue’s journal written, then the process exits — with code 3 when restart is set,
which run_seriesmith*.bat starts it again on. Only from the machine it runs on (403
otherwise: the server may listen on the network with no password). seriesmith stop [--restart] [--port N] calls it.
GET /seriesmith/config
%APPDATA%\seriesmith\ on
Windows, for both hosts) as default/seriesmith/config.json. If that file doesn’t exist,
roots comes from $SERIESMITH_ROOTS (os.pathsep-separated), else [].
review_copy (2026-09-21, false by default) keeps a workflow’s own video branch, so
ComfyUI writes a second copy of each render into its output/ folder. Every take is written
into the episode by the saver either way (renders[_proxy]/<shot>/), so the second copy is
only a convenience for watching in ComfyUI. render matches the default
(--review-copy keeps it; --no-review-copy is the old spelling of the default).
minimax_h3_ref2va’s graph used to end in a CreateVideo titled “Review copy” and a
SaveVideo titled “Daily” (output/video/H3_shot_*) beside SeriesmithSaveTake; both were
removed from the shipped workflow on 2026-09-21 (the graph snapshot golden was regenerated
for it: the graph is two nodes smaller, and the LoRA node jobs inserts is now id 43 rather
than 45). binding.review_nodes still lists those classes, so a saved graph that has them
— an older canvas copy, someone else’s — is still stripped at queue time unless
review_copy is on.
PUT /seriesmith/config
Body {"roots": [...], "review_copy": true|false}. The roots must exist; review_copy is
optional and keeps its stored value when the body leaves it out. Returns the new config.
Episodes
GET /seriesmith/episodes
edit.find_episodes(roots):
simple: one of Simple mode’s projects, which its project picker lists (clips.is_simple:
its series config says "simple": true, or it is Clips/clips/).
Every folder up to two levels below a root with a series config and a script. The config
is the folder’s own or — as episode_series_config has always allowed, and as of
2026-09-23 the listing too — the one in its parent, a series folder shared by the
episodes under it. In that case the script has to be named after the folder
(ep05/ep05.md), so a folder of loose notes beside the episodes isn’t taken for one.
GET /seriesmith/episode?ep=…&pass=proxy
edit.episode_status(ep, pass), with seeds turned into strings. First it sweeps
queued takes whose job is gone (takes.sweep_queued): one of seriesmith’s queue’s
(queue: "seriesmith") that the queue no longer has (the server restarted) is marked
failed; one queued on a ComfyUI directly (by h3pipe) is checked against that ComfyUI’s
queue, with as_of taken before reading it (see below). Shots come in cut order:
action: the first sentence of what happens in the shot, as the script says it (cut at a word past 160 characters), for the board; left out when the script has none.statusisqueued|ok|failed.staleholds any ofscript,ref,preset(andtarget,chain: acontinuous: latenttake whose previous shot in the cut now uses another take, or that take was rendered again, or the cut puts another shot before it);unknownmeans a take from before sidecars.thumb,stripandmp4are null when the file is missing.length/secondsare the build’s. A take’sframesis its real frame count, from the saver (its sidecar’sframes), or null (not rendered, or a take from before the saver wrote it).cut.framesis the cut take’sframeswhen that take is usable, else null. They differ for adur: modelshot: the build writes an estimate (length_estimatedon the entry) and the model picks the length when it renders (the sidecar’slength_sourceispredicted, versusestimateorscript). The timeline and Play all usecut.frames/ the take’sframesatfpswhen present.- A take’s
fpsis the frame rate it was rendered at (its sidecar’sfps; null for a take from before sidecars recorded it: the episode’s).cut.fpsis the cut take’s, else the shot’s target’s. They differ from the episode’sfpson Wan 2.2 14B (16 fps): the timeline and Play all time such a take asframes / fpsof its own, and trims stay in the episode’s frames. A shot’ssecondsis its build’slengthat its target’s rate. - The strip is
STRIP_FRAMES(8) cells of equal width, left to right, evenly spaced through the clip. Hover scrub picks a cell from the pointer’s x position. - The example is the original shape. Fields added since, each in its own section below:
the episode’s
target,target_source,series_target(The episode target) andtrack(Phase 9c); a shot’starget,target_source,built_target,profile(Phase 7/8),missing_refs,retarget_error,length_estimated,audio_in/audio_out;cut.fps,order,script_index,out_of_order(Phase 9b),audio,audio_file,audio_why(Phase 9d); a take’sfps,width,height,target,audio,draft/from_draft(Seedance drafts),upscaleandpost.
GET /seriesmith/shot?ep=…&pass=…&shot=sh020
edit.shot_detail(ep, pass, shot), seeds as strings. It has:
built: the shotlist entry.built_prompt: text.override: this pass’s effective override, withoutbase_hash.override_stale.effective: what a render would use now:prompt,seed,seed_source,model,loras,steps.takes: each with its fullsidecarandfiles(relative paths of mp4, thumb, strip, shotlist, h3_wav).
GET /seriesmith/shot: a one-off target, and what the build compiled (P9, 2026-09-23)
Two additions to the inspector’s detail, both from the same planner as effective
(edit.shot_detail → jobs.plan_job), so every field can be compared directly.
?target=<video target>answers for a one-off run on that target instead of the shot’s own, exactly as a render request’stargetwould: the size is that target’s, and the length is the shot’s on its frame grid. That is the part a preset can’t tell you — the same shot measured on three targets gave 448×256/124 frames, 448×256/129 and 640×352/125. Nothing is saved, and an unknown target is 400. The redo and render dialogs use it, so the size and length they print for a one-off retarget are the real ones.built_values: the same shape aseffectivewith every override dropped — what the build compiled, onbuilt_target. Absent when no override applies to the run being described, since it would only repeateffective; that includes a one-offtarget, because an override belongs to a target and none of the shot’s is in force on another one. The inspector reads it to say what a value is deviating from (“Built: steps 4”).
POST /seriesmith/build
Body {"ep"}. Runs build for the final pass, then the proxy pass, on the
episode’s series config and script (edit.episode_series_config / episode_script).
ok: false with the message (it names the line) in error.
The status is still 200: a script that doesn’t compile is a result, not a server error.
GET /seriesmith/episode/new?parent=…[&show=…]
The new episode wizard’s start: what an episode made in parent (or in the show folder
show inside it, which needn’t exist yet) would start from. {"parent" (with the show
folder), "from" (the newest episode there, or null: a new series), "series" (its series
config’s text, or null), "name" (the name to offer: ep03 after ep02, else ep01),
"episodes" (the names there)}. parent is checked as below.
POST /seriesmith/episode/new (P5, 2026-09-23)
Body {"parent", "name", "title"?, "series_id"?, "show"?, "series"?, "script"?} — the first step the editor could not
take before. parent is the folder the episodes live in (the show’s folder, not an
episode’s) and is checked like every other path: absolute, inside a configured root, or
403. name becomes both the folder and the script’s stem (ep02 → ep02.md), which is
what makes the episode discoverable; a name that isn’t a folder name, or is one
find_episodes walks past (refs, audio, shotlist, renders, renders_proxy,
views, targets) or a Windows device name, is 400. An existing <parent>/<name> is
409 — nothing is ever written over.
- The template is the newest episode already under
parent(template: "episode"): its series config is copied, so the cast, look, render profiles and targets carry over, and the script is a one-shot skeleton naming that config’s own first character and location — a place to start writing, not a story. With no episode there, the shippedexamples/starterpair is written instead (template: "starter"), which is also docs/AUTHORING.md’s opening example (tools/sync_starter_doc.pykeeps the two equal). - Every episode gets a series config of its own, even under a show that has one. That
is what lets episode four add a character without touching the first three, while
../refs/...paths keep one sharedrefsfolder.refsin the answer is where this config’s pictures go, resolved and created. titletitles the new episode (the script’s= <id> <title>line).series_idand the series’ own title are the series’, so they are written only when this is its first episode.- The only thing rewritten in a copied config is
audio.track, which follows the name (audio/ep01_dialogue.wav→audio/ep02_dialogue.wav): a recording is per episode. - The wizard sends what it made:
series(the series config’s text — a new series’, or the neighbour’s with the new cast added; it must be JSON with aseriesblock),script(the first shot’s; without one, the skeleton from that config), andshow(a show folder made insideparent, for a new series).templateis then"form"andfromthe neighbour or null. The recording still follows the name. checkischeck_texton what was written, so the editor can show at once that it builds.seriesmith.episodeis emitted for the new folder. Same thing from the CLI:seriesmith new Shows\ep02 [--title "…"] [--series-id …].
POST /seriesmith/import
The editor’s Import series…: a series config and a stack of scripts in, episode
folders out (source.import_series). Body {"parent" (a folder inside a root), "show"?
(the show folder to make or fill inside it; without one the episodes go straight into
parent), "series"? (the series config’s text, for every script without its own),
"scripts": [{"name", "text", "series"?}]}. Each script becomes an episode folder named
from its header. Nothing is built. Returns {"show", "episodes": [{"ep", "name", "own_config", "check"}], "skipped": [{"name", "why"}], "errors": [{"file", "error"}]} and emits seriesmith.episode for each episode made.
Issues: a pass’s notepad (P10, 2026-09-23)
<ep>/_issues.json — what is wrong with a shot as it was rendered, filled while watching a
pass and emptied once fixed. seriesmith/issues.py is the module; the file doesn’t exist until the first
note and is removed when the last one goes.
An issue snapshots what produced the take: the script’s lines for that shot, the prompt the
pipeline compiled from them, the reference files and the rendered mp4. That is the point — the
note describes that render, so deriving the text later (right for anything durable) would
destroy what the note is about. Editing the script never rewrites an issue.
addressed is computed on every read and never stored: true when the shot’s story_hash
has moved (a rebuild) or a newer usable take exists (a re-render). It is per shot, not per
episode, so a rebuild on its own addresses nothing. Nothing is deleted for the user.
GET /seriesmith/issues?ep=&pass=
{"issues": [{id, shot, pass, take, note, when, shot_hash, target, script, prompt, seed, refs, take_file, addressed}]}, in the order noted. No pass lists both.
POST /seriesmith/issues
{ep, pass, shot, note, take?} → the issue, snapshotted. take defaults to the one the cut
plays, else the newest usable, and may be null (a note before anything has rendered is worth
keeping). 404 for a shot that isn’t in the pass, 400 for a note that says nothing or is over
4000 characters. Emits seriesmith.issues.
DELETE /seriesmith/issues?ep=&id= / ?ep=&pass=&clear=1[&addressed=1]
id resolves one (comma-separated for several); clear=1 empties the pass, and addressed=1
with it takes only the ones whose shot has moved on. Answers {removed, issues}. 400 when
neither is given — there is no “delete everything by accident” shape.
GET /seriesmith/issues/export?ep=&pass=&format=md|json
One document to hand to an assistant: {"format": "md", "text"} by default, or the JSON bundle.
Markdown is the default because the script excerpt is multi-line — in JSON it is -escaped
soup you can’t eyeball before pasting — and because it carries its own instruction line. It is
generated from the file every time, one way: it has no machine markers, and nothing parses it
back. Addressed issues are left out unless addressed=1.
CLI
seriesmith issues <episode> [--proxy|--final] with --add <shot> <note>,
--add-from <file> (one sh0140: what is wrong per line), --export [--json] [-o FILE],
--resolve <id> and --clear [--addressed].
Files
GET /seriesmith/file?ep=…&path=renders_proxy/sh020/sh020_t01.mp4
Streams a file inside ep, with HTTP Range support (aiohttp FileResponse) so
<video> can seek. The content type comes from the extension. The response carries
Cache-Control: no-cache, because a take re-rendered into the same number
(--take N) keeps its name. A path may climb out of the episode only to what its series
config names above it: the show’s refs/ and audio/ (or the whole show folder, when the
config is there), and a sibling episode’s ref candidates (../<sibling>/refs/_takes/...,
what /refs/owner-takes lists); never a sibling’s renders or the show’s own files.
GET /seriesmith/thumb?ep=…&path=…&w=256
The same picture as /file finds it, made small for a picture shown small (the Refs tab
draws hundreds of reference pictures at a few dozen pixels; the files are full size, 722 MB
for one Porchlights episode’s): w wide, rounded up to 128, 256, 512 or 1024, a JPEG on a
white ground. Made once, kept in the settings folder’s thumbs/ by the file’s path, size
and time (so a shared ref’s is made once for every episode, and again when the file
changes). A file Pillow can’t shrink (not a picture, or unreadable) is served as it is.
Thumbnails run on two threads of their own, so a page asking for hundreds never holds up
the other requests; the editor asks for one only when its picture comes near the screen.
JSON replies over 64 KB are gzipped when the browser accepts it (the refs listing: 2.8 MB,
0.12 MB sent).
Rendering
POST /seriesmith/render
Queues takes on seriesmith’s queue and returns without waiting. An optional backend
renders every shot of the request there.
- These fields map to
jobs.RenderRequest, withseedas a string.seed_modeis one ofauto,neworsame.redo: falseskips shots that already have a usable or queued take, as the CLI does.nullmeans “not set in this request”, so the built value andoverrides.jsonapply.size("1344x768", or[1344, 768]) renders this run at that size instead of the shotlist’s; the take’s sidecar records it, and a master re-samples it by its own size (scale: "auto"). A size off the target’s grid is that shot’s error (H3: multiples of 32), or is snapped with a note on a target that snaps (LTX).
- Also in the body, each optional:
target(a one-off video target for this run, Phase 8),negative,allow_missing_refs,allow_model_mismatch(Model families),save_frames(keep the frames as PNGs),save_latent(keep the latent for an upscale; null: the pass’s default),backend, andfrom_draft({shot: proxy draft take}, final pass only: see Seedance drafts). - Each job’s workflow is its target’s (
jobs.target_workflow, resolved once per target per request): an environment variable’s file, else the copy saved in ComfyUI under the binding’sworkflow_name, else the repo’sseriesmith/targets/<kind>/<id>/workflow.json(Phase 11). A target whose workflow can’t be read fails every shot on it, per shot. - For each shot the route calls
plan_jobandRunner.enqueue_render, which reserves the take (start_job, its recordqueued,queue: "seriesmith", itsbackend) and queues the job. When the job starts its inputs are staged (keyframes, a composed sheet), the portable graph is built (graph_for(..., portable=True)) and sent to the backend, and the files it reports are written into the take (collect_take). - A shot that can’t be queued is reported for that shot; the other shots still queue. A job that fails when it runs marks its take failed with why.
- Continuity (
refs.refresh_continuity): eachcontinuous: firstshot’s first frame is cut again when its job starts (refs.continuity_at_start) and sent with it, from the take the cut uses then; each such job waits for the one it continues (the queue’safter), so a chain queues in one request, in cut order. Before queueing, a missing or stale keyframe is cut from the current take (continuity:[{shot, from, why}]); the shots cut at start are incontinuity_at_start([{shot, after}]). One with nothing to start from, now or coming, is an error and isn’t queued. - Latent chains (
refs.chains_before_render): acontinuous: latentshot (its built entry’shold) queues the same way, in cut order behind the shot it continues, and is incontinuity_at_startwhile that shot’s take is in the request or still rendering. When its job starts, the previous take is found (refs.chain_source) and sent with it: SeriesmithChainLatent before the sampler holds its kept latent, else its last frames through the VAEs, and hands what it held to the saver, which records it in the take’s sidecar when the job ends (continued_from:{shot, take, pass, via, overlap, video_steps, audio_steps}), and SeriesmithChainTrim before the saver. The sidecar’sholdis the frames held;lengthis the take’s own. - Its upscale (upscale.set_chain) holds the previous take’s upscaled latent tail; the
upscale record’s
recipe.held_fromnames that take, andchain_hold({file, held, why?, video_steps, ...}) says whether it was held when it ran. An upscale whose next shot in the cut is chained keeps<stem>.up.latent.safetensors.
POST /seriesmith/cancel
Body {"ep", "pass", "shot", "take"}. If the take’s job is waiting, it leaves the
queue (save_notes: "canceled before it ran"); if it’s running, the backend is asked to
stop it and the route waits for it to end (save_notes: "canceled"). Either way the take
is marked failed. Returns the take’s new status. (A take queued on a ComfyUI directly,
by h3pipe, is still cancelled there.)
Cut and overrides
PUT /seriesmith/pick
Body {"ep", "pass", "shot", "take": 3 | null, "from_pass": null | "final" | "proxy"}.
Uses takes.pick. A take that isn’t usable answers 409 unless "force": true.
take: null goes back to the latest usable take. Returns {"cut": <cut.json>}.
PUT /seriesmith/cut
Body {"ep", "pass", "entries": [{"shot", "take"?, "pass"?, "trim_in"?, "trim_out"?, "locked"?, "note"?, "audio"?, "out"?, "flag"?}, …]}. Replaces that pass’s list, for reordering and trims.
Unknown shots are allowed; they become orphans. Returns {"cut": …}.
"out": true leaves the shot out of the cut: it stays in the script with its takes, its
place and its pick, and the status’s cut.out says so; assemble, Play all, the cut’s
upscales (shots: null), Master and the cut’s neighbours (keyframes from the previous
shot) skip it, and the timeline shows it as a stub. A shot named outright (an upscale’s
shots / takes) is still done. Latent pruning keeps its pick’s latent. Reset keeps it
(as it keeps picks and locks); copying the other pass’s order copies it.
"flag": true is a bookmark, “come back to this one” (re-render it, change its prompt): it
changes nothing played or assembled, a locked entry takes one, and reset and copying keep each
pass’s own. The status’s cut.flag says so; the timeline shows a marker and a count.
PUT /seriesmith/override
Body:
- Only the fields present are changed;
nullclears a field. prompt,model,loras,stepsandsizeare per pass (both: truesets both passes);seedandnoteare shared.size(“WxH”) makes every later take of that pass render at that size.- Pass fields are stamped with that pass’s
base_hash(jobs.story_hashof the shot as that pass builds it now), exactly asseriesmith overridedoes. - Returns
{"override": {"final": {...}, "proxy": {...}}}, each the effective override withoutbase_hash, plusstaleper pass.
DELETE /seriesmith/override?ep=…&shot=…[&pass=…]
Removes the shot’s override: one pass’s fields if pass is given, else everything.
Assemble
POST /seriesmith/assemble
Body {"ep", "pass", "partial": true}. Runs assemble off the loop and returns
when it’s done (can take minutes).
"upscaled": true (either pass): each clip plays its take’s
fresh upscale, the cut is the upscales’ size and is written as <ep>_up.mp4, and clips
without one are scaled up (the report names them). "size": "1920x1080" sets the cut’s
size, letterboxing a clip whose aspect differs. seriesmith assemble <ep> --upscaled [--size WxH]
is the same.
"publish": true then runs publish on the cut: the series intro and outro
(_titles/INTRO.mp4, OUTRO.mp4 in the show folder, or the series config’s publish
block) around it, the episode’s title drawn on, into <ep>/publish/. output is then the
published file, cut the assembled one and published the published one (null when
publishing failed: ok false, the reason in error, the cut still written). The same
as seriesmith assemble followed by seriesmith publish <ep> --input <cut>.
Upscale (Phase 13)
An upscale is a version of a take (either pass), not a take:<stem>.up.mp4 and <stem>.up.json
beside it (seriesmith/upscale.py). It re-samples the take at 2x from late in
its own schedule (the target’s start, 0.875: step 7 of 8) under the take’s frozen
shotlist, from its kept latent (Phase 13a) or, without one, its frames and _h3.wav
through the VAE. The take’s audio is held while it samples and its audio stream is copied
onto the result unchanged. Only a target with an upscale block in its target.json
can re-sample (every built-in video target has one):
- mode
resample:minimax_h3_ref2vaandminimax_h3_fl2va(an external latent upscaler, then their own sampler from late in its schedule; FL2VA’s size goes to its I2V node, which stretches the keyframes to it), andltx2_ingredients(LTX-2.3’s own 2x latent upsampler,ltx-2.3-spatial-upscaler-x2-1.1.safetensors, into the graph’sLTXVAddGuide, which appends the ingredient sheet again at the new size; a kept latent has its guide frames cut off first; the KSampler run as the KSamplerAdvanced it is, from the step; always 2x); - mode
second_stage:ltx2(its render graph’s second stage run again on the take: its own upsampler, the tail of its fixed sigmas; always 2x); - mode
pixel_refine:wan22_i2v,wan22_ti2vandwan22_vace(the take’s frames through the pixel model,pixel_modelapplies, encoded with the Wan VAE, then the target’s last sampler from late in its schedule; VACE’s reference picture encoded in front, as its latent has it; always the VAE route:route: "latent"is refused).
.up.json records mode and the schedule’s steps. Options’ latent[target]
fixed_scale is the one scale a fixed upsampler makes (the LTX targets: 2), else null.
POST /seriesmith/upscale
Body {"ep", "shots"?: ["sh020"] | null, "takes"?: [{"shot", "take"}], "redo"?: false, "scale"?: 2, "start_step"?: null, "vae"?: false}. shots upscales the final cut’s take
of each (the pick, else the latest usable; null: the whole final cut); takes names
that pass’s takes directly and wins; pass (default "final") says whose. A proxy take
kept no latent by default, so its re-sample goes through the VAE. A take with a fresh
upscale is skipped unless redo.
vae forces the VAE route; start_step is an exact step of the take’s schedule.
method: "latent" (a re-sample: the default where the take’s target has an upscale
block) or "pixel" (an upscale model over the frames: any target, the default for the
rest), null for each take’s default. pixel_model names the pixel method’s model, a file
in ComfyUI’s models/upscale_models (null: RealESRGAN_x2.pth if installed, else the first
2x model), or "rtx_vsr" / "rtx_vsr:<level>" for NVIDIA RTX Video Super Resolution (its
RTXVideoSuperResolution node, ULTRA unless a level is named; listed in options’ pixel.models
when this ComfyUI has the node, and taken wherever a pixel model is, then_pixel_model too); detail (0, 1 or 2) starts a re-sample that many steps earlier than the default
(more detail, more change). The pixel method needs no latent, no frozen shotlist and none
of the take’s target’s models, and a scale only has to land on even sides. scale is more
than 1 and at most 4: a pixel scale lands on even sides, an H3 re-sample’s on the 32 grid,
an LTX-2 re-sample is 2x only. then_pixel_model (with then_scale, default 2) adds a
pixel step after a re-sample in the same job: that upscale model takes the re-sampled frames
on (re-sample 2x then RealESRGAN_x2.pth 2x = 4x); width/height in the answer are the
final size, and the .up.json records then_pixel: {model, scale, from: [w, h]}.
then_method: "seedvr2" makes that step SeedVR2 instead (its model from seedvr2_model,
7b by default; then_pixel_model is not needed): re-sample, then SeedVR2, in one job;
then_pixel then has method: "seedvr2" and its model is the SeedVR2 file.
deliver ("1080p", "1440p", "4k" or "WxH", both even, 64 to 8192; null: the size
the scale makes) makes the upscale exactly that size, and fit ("crop", the default, or
"pad") says how a frame of another shape meets it. The last step that can make any size
(the pixel method, SeedVR2, the then-pixel step) is sized to cover the delivery (crop) or
fit inside it (pad), aspect kept, its scale worked out from what it starts from (scale /
then_scale are then ignored); a re-sample alone keeps its scale. SeriesmithSaveUpscale then
resizes (only when no step made that size), crops or pads to the delivery with ffmpeg. A
pixel step that would have to shrink is an error. The answer’s width/height and the
.up.json’s are the delivery; the .up.json adds deliver: {width, height, fit, made: [w, h]} (what the last step made), and so does the episode’s upscale summary.
from_upscale: true runs the pixel method on each take’s existing fresh upscale instead of
the take (its .up.mp4 is the input and is replaced; scale applies to the upscale’s size;
the take’s audio is still what’s copied on). The new record keeps the old one as
on_upscale (nested for a longer chain); freshness stays tied to the take. A take with no
fresh upscale is an error; with method: "latent" it’s refused.
encoder: "auto" (the default: H.264 on NVENC up to 4096 on a side, x264 past that or
without NVENC — always H.264, which the editor’s browser plays), "nvenc" (forced: H.264
up to 4096, HEVC past it, which NVENC’s H.264 can’t do and a browser may show black; fails
without NVENC) or "x264" (the CPU; its faster preset past 4K). The .up.json’s encoder is
the one used. precision: the upscale model’s, "fp16" (the default: autocast, about
twice as fast) or "fp32". Options lists both as encoders / precisions. An upscaled
cut that mixes an HEVC upscale with H.264 clips is re-encoded by assemble, since concat
can’t copy mixed codecs.
The finish of anything a pixel model makes (the pixel method, a then-pixel step, the start
of a Wan re-sample): frequency_split (default true: colour and tone from a bicubic enlarge
of the source, only the high band from the model), keep_soft (0–1, default 0: the
model’s detail faded where the source had none, measured once per clip), grain (0–0.2,
default 0: monochrome, per frame, seeded by the take’s seed; never before a Wan re-sample).
Recorded as the .up.json’s finish. Queues and
returns at once:
recipe: true upscales each take by the series
config’s upscale.master instead of the body’s choices (only pass, shots / takes
and redo still apply): the take’s target’s section (its id, else the longest matching
glob, else "*"), the shot’s own recipe over it, the recipe’s finish, and the master’s
deliver / fit / encoder. A take whose target no section covers is that take’s error;
no recipe at all is 409.
method, scale, detail, start_step, vae, pixel_model,
seedvr2_model, then (an upscale model file, or "seedvr2"), then_scale, precision,
frequency_split, keep_soft, grain. quality (review | master) is read by Phase
13e3. The build warns about anything else (upscale.check_recipe).
PUT /seriesmith/upscale/recipe
Body {"ep", "shot", "recipe": {…} | null}: one shot’s own recipe, kept in the episode’s
overrides.json under a top-level "upscale": {"sh020": {…}} (not in the shot’s override
block, whose objects are per-target). recipe takes the upscale request’s fields as the
dialog sends them (then_pixel_model / then_method become then); null clears it. 400
on a field or value a recipe doesn’t take. Answers {shot, recipe, text}.
quality: "review" (the default) or "master": how the .up.mp4 is encoded. A master
is x264 CRF 12 on a slow preset (NVENC p7/hq at CQ 14 when encoder is "nvenc"), still
H.264 8-bit so the editor plays it; the .up.json records quality.
POST /seriesmith/master
Body {"ep", "pass"?, "action": "plan" | "queue" | "assemble" | "prores", "conform"?, "allow_gaps"?, "prores"?, "order"?, "post"?} (master). post is the dialog’s Post-process: false
/ "off" (the upscales), "present" (each shot’s fresh post where it has one, its upscale
otherwise; nothing queued) or true / "recipe" (see Post-process below). order (queue only): "cut" (the default: the cut’s
order) or "target" (grouped by what ComfyUI loads: a re-sample’s target model, else the
upscale or SeedVR2 model; groups where their first shot is, cut order within). Every answer has plan: {pass, size: [w, h], fit, quality, counts: {upscale, post, ok, kept, queued, gap}, ready, rows: [{shot, take, target, status, why, recipe, upscale: {width, height, status, keep} | null}]}, a row per shot of the cut in
order. status: upscale (none, from an older take, or failed: queued by the recipe),
ok (fresh, the recipe’s settings), kept (fresh, marked Keep or made with other
settings; conform: true makes the unmarked ones upscale), queued, gap (no usable
pick, the other pass’s take, no recipe section for its target, or a kept upscale at
another size). queue queues the upscale rows (409 when this ComfyUI can’t) and adds
queued / errors; assemble (409 while any are to upscale or queued, or with gaps
unless allow_gaps) writes master/<ep>_master_<WxH>.mp4 (and .mov with prores) and
<ep>_master.json / .md, and adds output, mov, report, titles. 409 without a recipe, or
one without deliver. When the episode has an intro or outro (_titles/INTRO.mp4,
OUTRO.mp4 in the show folder, or the series config’s publish block), the master is
made with them, the episode’s title drawn on (publish), and the .mov from that:
titles is {"intro", "outro", "picture", "why"} (the clips used, either null), else null.
picture is how publish laid the cut in between them: "copied" (only the title clips
encoded) or "re-encoded" (the whole cut again, why says what stopped the copy: a check
at a join, a cut assemble didn’t encode); the report’s Picture: line says the same. A
master that assembled but couldn’t be titled is a 500 saying so; the untitled master is
left in place.
assemble answers when the master is made, which can take a while (assemble re-encodes
the clips that need it — trims, dialogue windows, gaps scaled up — and copies the upscales
beside them when their H.264 headers agree, checking each join; when they don’t, every clip
is re-encoded, half an hour for a 300-shot episode), so it is also a job: job in the
answer, GET /seriesmith/master/job, and the seriesmith.master events. Only one master of an
episode is assembled at a time: <episode>/master/.assembling.json ({pid, host, by, started}) is held while one runs, whoever started it, the editor or seriesmith master, and a
second assemble is a 409 {"error": "a master of this episode is already being assembled by …", "job"}. A lock whose process has gone (or another machine’s, a day old)
is stale and taken over.
action: "prores" makes the ProRes 422 HQ .mov of the master already made at the recipe’s
size, beside it, and sets the report’s prores (seriesmith master <ep> --prores-only): nothing
is planned, upscaled or assembled. {"output", "mov"}; 409 without a master yet, or while
one is being assembled. Every other answer has existing: {"output", "mov"} (the master
already made and its .mov, mov null when there is none) or null.
GET /seriesmith/master/job?ep=…
{"job": {...} | null}: the master this ComfyUI is assembling for the episode, or the last
one it assembled (kept until ComfyUI restarts):
state: running, done (output, mov, report, titles as assemble answers them)
or failed (error). step is assemble (assemble’s stages: probe reading each clip,
clips writing each, join, prores when the master has no titles, verify counting the
master’s frames) or titles (publish’s: titles with text intro / outro, join,
verify checking the joins, reencode with a frame count, then prores: with titles the
.mov is made once, from the titled master); done / total count what has a count. A run
this ComfyUI didn’t start (seriesmith master, read from the lock) is {"state": "running", "elsewhere": true, "by", "started"} with nothing else; it sends no events, so ask again.
PUT /seriesmith/upscale/keep
Body {"ep", "pass"?, "shot", "take", "keep": true | false}: mark a take’s finished upscale
Keep (409 without one). A fresh kept upscale is skipped by a whole-cut request (shots
and takes null) and by Master, whatever the recipe says; naming the shot or take redoes
it, and the new upscale starts unkept. Every .up.json records recipe (the settings
that made it: method, scales, start, models, the then step, precision, finish, size, fit)
and recipe_hash; the episode’s upscale summary adds keep and recipe_match (same,
different, unknown for one from before, null without a series recipe).
GET /seriesmith/upscale/options
What the editor’s Upscale dialog offers on this ComfyUI:
method: "seedvr2" runs SeedVR2 (ComfyUI’s own nodes; any take, no prompt): seedvr2_model
is "7b" (the default), "3b" or a file name; it takes scale, from_upscale and the
finish like the pixel method, and options lists it as seedvr2: {status, missing, models, default}. latent[target] is null for a target with no latent upscale; otherwise it also has mode
(resample | second_stage), align and fixed_scale (second_stage’s only scale), and
the answer has max_scale, recipe (with ?ep=: the episode’s master recipe, null
without one: deliver, fit, quality, encoder, targets (each video target’s matching
section as {key, fields, text}, null when none covers it), shots (the shots’ own, as
{fields, text}) and problems), delivers ([{id, width, height}]: 1080p, 1440p, 4k) and
fits (["crop", "pad"]). Each take in GET /seriesmith/episode has its width and height,
so the dialog can show what every scale makes before anything is queued.
DELETE /seriesmith/upscale?ep=…&shot=sh020&take=3[&pass=proxy]
Removes the take’s upscale. 404 when it has none; 409 while ComfyUI still has it queued
or running.
Post-process (post, 2026-10-06)
A take’s post is its fresh upscale finished: an enhance step (SeedVR2, a pixel model or SUPIR, at the upscale’s size) then motion blur.<stem>.post.mp4 / .post.json beside
it, its audio the upscale’s; stale when the upscale changes. docs/POST_PROCESSING.md is
the design record. The series config’s recipe is "post": {"master": {"enhance": "production", "motion_blur": 0.3}}, a shot’s own in overrides.json’s top-level
"post": {"sh020": {…}}. enhance is a tier (draft: pixel at 1x; production:
SeedVR2 7B in fixed chunks; cinematic: SUPIR 0.2), a method (pixel, seedvr2,
supir), an object ({"tier" | "method", …fields}) or "none"; motion_blur 0–1 of
the frame interval (0.5 is a 180° shutter).
POST /seriesmith/post
Body {"ep", "pass"?, "shots"? | "takes"?, "redo"?, "recipe"?, "enhance"?, "blur"?, "quality"?}: queue posts. shots (their cut takes; null: the whole cut) or takes
([{shot, take}]). recipe: true, or neither enhance nor blur given: each by the
series’ post.master with its shot’s own over it (409 without one); else enhance and
blur as given. quality (review | master; default: as each upscale was encoded).
A take needs a fresh upscale (an error per take otherwise); a fresh post with the same
settings is skipped unless redo. 409 when this ComfyUI can’t run them (missing
sentences). {"queued": [{shot, take, what, width, height, prompt_id}], "skipped": [{shot, take?, reason}], "errors": [{shot, take, error}]}.
GET /seriesmith/post/options[?ep=…]
recipe is null without ?ep= or without a post.master.
DELETE /seriesmith/post?ep=…&shot=sh020&take=3[&pass=proxy]
Removes the take’s post. 404 when it has none; 409 while ComfyUI still has it queued or
running.
A take’s post
Every take in GET /seriesmith/episode has post: null (never post-processed), else
{"status": "queued" | "ok" | "failed", "fresh", "recipe", "width", "height", "comfy_prompt_id", "mp4", "save_notes"}. fresh is false once its upscale isn’t the one
it was made from (the upscale stamped by SeriesmithSaveUpscale as the post is saved, so a post
can be queued behind its upscale), or the upscale itself is stale.
Master with post: "present"
Nothing is post-processed and no post.master is needed: each shot’s recipe ends ; its post (a fresh one), ; its upscale (its post is stale), ; its upscale (a post is on its way: master again after) or ; its upscale, rows have post, the plan has post: true
and post_mode: "present", and assemble uses each fresh post (assemble --post) and
each other shot’s upscale. For posting only the shots that need it: post them from the take
menu (POST /seriesmith/post), then master with "present".
Master with post: true ("recipe")
The plan’s post is true, post_mode "recipe", each row’s recipe ends ; post: …, rows have post: {status, fresh, recipe} | null, and counts has post. A row is post when its upscale is good
but its post is missing, from an older upscale or failed. An upscale row’s post is
queued right behind its upscale (with order: "target", after all the upscales), so one
queue takes every shot all the way; ok, kept (a post made with other settings,
conform redoes it), queued and gap speak for both. assemble uses each shot’s fresh
post (assemble --post) and refuses while any are to upscale or post-process. 409 when
the series config has no post.master. A shot whose post recipe is "none" is mastered
from its upscale.
A take’s upscale
Every take in GET /seriesmith/episode has upscale: null (a proxy take, or never
upscaled), else {"status": "queued" | "ok" | "failed", "fresh", "width", "height", "route", "start_step", "comfy_prompt_id", "mp4", "save_notes"}. fresh is false once
the take’s mp4 isn’t the one it was made from (re-rendered into the same number).
Readiness
GET /seriesmith/targets?ready=1: a target that can upscale has upscale: {"status": "ready" | "not_ready" | "unknown", "missing": [sentences]} beside its render
readiness, which it never changes.
Live updates
The page listens on the server’s websocket (/ws, {"type", "data"} per message). The
queue sends seriesmith.job (a job’s to_json(), on every change) and each job’s
progress in ComfyUI’s shapes — execution_start, progress ({prompt_id, value, max}),
execution_success, execution_error, execution_interrupted — with the job’s id as the
prompt_id, which is how a take’s thumbnail follows its render. The rest:
seriesmith.take, sent when a take’s job ends and by the routes whenever they change a take’s status (queued, cancelled, swept):{"ep": "<abs path>", "pass": "proxy", "shot": "sh020", "take": 3, "status": "ok", "thumb": "renders_proxy/sh020/sh020_t03.jpg"}.seriesmith.episode,{"ep": "<abs path>"}, sent after a build, pick, cut or override change, so every open editor view can refetch.seriesmith.upscale(Phase 13),{"ep", "shot", "take", "status"}: “queued” fromPOST /seriesmith/upscale, “ok” or “failed” when its job ends and its.up.jsonis closed, “deleted” fromDELETE /seriesmith/upscale.seriesmith.post,{"ep", "shot", "take", "status"}: “queued” fromPOST /seriesmith/postand Master’squeue, “ok” or “failed” when its job ends and its.post.jsonis closed, “deleted” fromDELETE /seriesmith/post.seriesmith.ref,{"ep", "ref", "view", "take", "status"}: a reference take queued, finished, picked, cleared or discarded.seriesmith.queue, the lanes (GET /seriesmith/jobs’lanes) after a move or a pause.seriesmith.master, the whole job asGET /seriesmith/master/jobhas it: once when anassemblestarts, at every step (each clip written, each title encoded), and once more when it isdoneorfailed.seriesmith.issues,{"ep", "pass", ...}, when an issue is added or removed;seriesmith.align,{"ep", "stage", "pct", ...}, an alignment’s progress (Phase 9c).
The queue
These are the queue’s own routes (seriesmith/server/queue_views.py), registered by
seriesmith serve and by a ComfyUI whose node pack started the queue (inside ComfyUI the
editor shows Render on, Jobs and Settings once GET /seriesmith/backends answers).
GET /seriesmith/info
{"version", "settings" (as GET /seriesmith/settings describes them), "comfy_connected"}.
GET /seriesmith/settings / PUT /seriesmith/settings
This machine’s settings (seriesmith/settings.py; never the series config). GET answers
{"settings": {"local_url", "comfyui_url", "cloud_url", "api_key": "set" | null, "deployments": {target: url}, "path", "paid_limit", "enabled_drafts", "target_limits"}, "file", "from_env": {field: env var}} — never the key itself. PUT takes any of
local_url, comfyui_url, cloud_url, api_key (null removes it), deployments,
paid_limit (null: no cap), enabled_drafts, target_limits; saves the file, makes the
backends again from it (a running job keeps its own) and answers as GET. 400 for a value
it can’t take. A field the environment sets wins over the file.
GET /seriesmith/jobs
{"jobs": [job], "lanes": {name: {"paused", "parallel", "waiting": [id], "running": [id]}}, "paid": {"count", "limit"}}: every job queued or running, then the last 200 finished,
newest first; paid counts the runs sent to a paid backend this session against
paid_limit. A job is
{"id", "kind" (render | ref | upscale | post | tour), "lane" (its backend), "title", "after" (the job it waits for, or null), "info" ({ep, pass, shot, take, target, backend, ...}), "status" (queued | running | succeeded | failed | canceled), "progress" (0-1), "step", "steps", "created", "started", "finished", "error", "log" (the last 20 lines), "can_retry"}.
POST /seriesmith/jobs/{action}
cancel {"id"} (a waiting job leaves the queue; a running one is asked to stop),
move {"id", "index"} (a waiting job to that place in its lane; 0 is next), retry
{"id"} (a finished job again; a render: a new take with the same settings), pause
{"lane", "paused"} (a lane stops starting jobs, or resumes), paid-reset {} (the paid
run count back to 0). Answers with
GET /seriesmith/jobs’ body; 404 for an unknown job, 409 for one that can’t (moving a
running job).
GET /seriesmith/backends
{"backends": [{"name", "kind", "down" (why it doesn't answer, or null), "where"}], "api_key": true | false}: the backends this machine is set up for, each asked whether it
answers, and whether an API key is set (never the key).
Models
The UI reads ComfyUI’s own lists:GET /models/diffusion_models (and
/models/unet on older installs) for the model picker, GET /models/loras for LoRAs,
GET /object_info/<node> for a loader’s choices. Inside ComfyUI these are ComfyUI’s own
routes; seriesmith serve passes /models, /models/{folder} and /object_info/{node}
(each also under /api/) on to this machine’s ComfyUI (503 when it doesn’t answer), and
answers /queue in ComfyUI’s shape with ComfyUI’s own queue followed by seriesmith’s jobs.
(Later: GET /seriesmith/models groups a target’s model picker by
model family; see Model families at the end.)
As built (Phase 2): readings of the points above that were ambiguous
POST /seriesmith/cancel: 409 if the take isn’tqueued. A finished take is never overwritten.- Sweep in
GET /seriesmith/episode: for a queued take whose job has left the queue,/historydecides:- an execution error marks the take
failedwith ComfyUI’s exception message; - success with the sidecar still
queued(a save node from before sidecars) closes it from disk (jobs.finish_job); - no history at all falls back to
sweep_queued.
- an execution error marks the take
POST /seriesmith/assembleandPOST /seriesmith/build: a failure is 200 withok: falseand a top-levelerror. Build with no series config or no script returns{"ok": false, "error", "passes": {}}. Build always runs both passes.POST /seriesmith/render:shots: nullrenders every shot.lorasalso accepts"name:strength"strings.save_frames(2026-09-22, optional): also write the take’s frames as a PNG sequence in<take>/frames/, for retouching a shot that is right but for a frame or two. Absent leaves the workflow’s own value (off in every shipped graph). The editor asks for it per render (the redo dialog’s “Keep the frames”), never as a setting: it costs ~2.5 s a shot (measured;save_ms.frames) and 16-50 GB an episode.render --save-framesis the same switch. A render is reproducible — two renders at one seed gave byte-identical frames and mp4 (2026-09-22) — so the frames of a take you already have can be fetched by re-rendering it at its own seed.save_latent(Phase 13a, 2026-09-26, optionaltrue/false/null): keep the take’s sampled latent beside it as<stem>.latent.safetensors, for an upscale later (video and audio streams; about 4 MB per 3 s at 960×544, twice that at 1344×768). Absent or null: the series config’supscale.save_latents("final", the default: final pass only;"always";"never"). Only a target whose binding names its latent source (saver.latent:{"class_type", "output"}, todayminimax_h3_ref2va) can keep one; asked for on another, the take’snotessay so and nothing is linked. The queuer recordssave_latent: trueon the sidecar, and SeriesmithSaveTake records the file aslatent(absent if writing it failed, which is never fatal: an upscale can still go through the VAE). The editor’s redo dialog has “Keep the latent”, sent only when changed;render --latent / --no-latentis the same switch.skippedanderrorsentries carrytakewhen one was reserved.- The
seriesmith.takeevent sent at queue time saysqueued.
PUT /seriesmith/override:- Pass fields on a pass that isn’t built answer 409.
- A shot in no build answers 404.
- An empty
noteormodelclears the field. DELETEwithpasskeeps the sharedseedandnote.
PUT /seriesmith/cut: duplicate shots and unknown entry fields answer 400.- Not handled yet: ComfyUI multi-user mode (config always lives in
default/), and a TLS-fronted ComfyUI (self-queueing useshttp://). - Aliases: every route also answers under
/api/seriesmith/...(underseriesmith serve, the queue’s routes excepted: see the Route index).
Round 2 additions
GET /seriesmith/browse?path=…
A folder picker for choosing roots and episodes. It isn’t limited to the roots,
because this is how roots are chosen. It lists folder names only, never file
contents.
- No
pathgives the starting points: the home folder and the drives on Windows.parentis thennull; at a drive root it is"". episodemeans the folder has aseries.jsonand a script;series_configmeans it has aseries.json.- Status is 404 if the folder doesn’t exist, 403 if it can’t be read.
Missing references
GET /seriesmith/episode: every shot carries"missing_refs": [{"slot": "Picture 4", "kind": "image" | "audio", "path": "refs/_bg/x.png", "subject"?: "dean"}]. These are the references its render needs that aren’t on disk (jobs.missing_refs); empty means ready.POST /seriesmith/rendertakes"allow_missing_refs": falseby default.- When false, a shot with missing refs isn’t queued. It appears in
skippedwith areasonnaming the missing refs, plus the samemissing_refslist. - When true, it renders anyway. The loader replaces a missing picture with flat
mid-grey, and a missing voice sample or recording with no audio reference. For H3
that’s close to text-to-video. The take’s sidecar lists what was missing in
missing_refs(slots). - The prompt still names the missing pictures. Writing a prompt without them is model-specific, so it belongs to the Phase 7 target adapters. (Done in Phase 7: see Render anyway, target-aware below. Grey stand-ins are now the fallback.)
- When false, a shot with missing refs isn’t queued. It appears in
- The CLI equivalent is
render --allow-missing-refs. Without it, blocked shots are listed and skipped.
References (Phase 5)
A ref is any conditioning input a render reads that the pipeline generates or you supply. Refs are the same thing whatever model consumes them. Each has ascope:
- Series refs are listed from the series config, not from
refs_todo. A character nobody uses yet can still be generated.refs_todobecomes a filter: “used by this episode”, and which shots it blocks. - Shot keyframes: Phase 5 builds their storage, takes and pick. Generating them
(a still from the shot’s prompt, or the previous shot’s last frame) and feeding
them to an FL2V target come with that target. Their home is
refs/shots/<shot>/<first|last>.png. (The previous shot’s last frame is built: see Continuity keyframes at the end.) - A ref take is one generated or imported candidate. It lives in
refs/_takes/<ref key>/<ref key>[_<view>]_tNN.png, with a sidecar…_tNN.jsonin the video takes’ format (status, seed, prompt, model, LoRAs, steps, queued, finished, andsource:generatedorimported). The ref key is the id with:replaced by__. - A character has views (
01_threequarter,02_side,03_back,04_face, as inkreagen.VIEWS). Each view has its own takes and pick. Picking a view that completes the set stitches the sheet withmksheetinto the series config’ssheetpath. - A wardrobe variant (a subject with
of:) is listed like any other subject: its own sheet, its own views, its own takes. The listing carries"of": "<the subject it is a variant of>", and it has novoice:ref of its own — it is the same character and shares theirs, so a voice picked for it would split one character’s voice in two. It keeps that character’sname, because that name goes into every prompt; the listing’sofis what tells them apart on screen. Its views are generated as an edit of the base’s same view on any target that reads reference images, and on a text-to-image target from the base’s seed and the variant’s words. - Picking a take copies it to the path the series config names, which is the file renders
read. The pick is recorded in
refs/_picks.json, so the UI knows which take is live. Re-picking changes the file’s sha1, so video takes that used the old file showref-stale. - Ref overrides (prompt, seed, model, LoRAs, steps) live in
refs/_overrides.json, keyed by ref id (and view), in the same shape as shot overrides.
GET /seriesmith/refs?ep=…
- Characters have
views; every other ref hastakesandpickedat top level. existsmeans the file renders read is on disk.- A
voiceref liststakesfor imported audio only; nothing generates voices yet.
POST /seriesmith/refs/generate
Queues one candidate per call. It returns without waiting, like /render.
- A character with
view: nullqueues all four views, sharing one seed (askreagendoes). countgreater than 1 queues that many candidates, each with a new seed.- The workflow is
krea2_refs_t2i.json, found throughresolve_workflow. (Phase 7: thekrea2image target’s binding; the repo copy isseriesmith/targets/image/krea2/workflow.json.) - Each take is a job (
Runner.enqueue_ref): the workflow keeps its ownSaveImage(SaveAudiofor a voice), the take’s reference pictures and voice sample go up with it, and the file the backend hands back closes the take;seriesmith.ref{"ep", "ref", "view", "take", "status"}says so. A hosted image target (krea_2_router) is one Router call. - Returns
{"queued": [{"ref", "view", "take", "prompt_id", "seed", "backend"}], "errors": [...]}. An optionalbackendsends every take there.
"edit": {"take": n | null, "with": [{"ref", "view"?, "take"?}], "wrap": true} changes a picture the ref already has instead of drawing one
(refs.plan_edit):
- The picture is
takeof (ref,view), or withtake: nullthe live picture. That includes a plate dropped intorefs/_bgby hand, which has no take. A character is edited one view at a time. promptis the instruction, and it is required. It is wrapped byseriesmith/targets/image/common.edit_prompt, which names whatever the instruction doesn’t change as fixed.wrap: falsesends it exactly as typed, for a LoRA with its own trigger words (<mva> left side view, eye-level shot).withadds other refs’ pictures as image 2 onward. A character comes as its picked three-quarter view unlessviewsays otherwise.- The target must be an edit target (
capabilities.mode: "edit"; 400 otherwise). It is the request’s, else the first of the ref’s own target, the episode’s keyframe target and its refs target that can edit. The pictures must fit itsmax_refs. - The picture keeps its size, scaled onto the target’s grid and minimum.
- Each candidate gets a new seed unless
seedis typed. - The ref’s overrides are not read: they are for generating it from its description.
- The take is an ordinary new candidate of the same ref and view, with
source: "edited". Its sidecar recordsedit(take,view,instruction,wrap,with) andreferences, and/refslistseditandtargeton it. - The CLI is
seriesmith refs <ep> --edit REF[:VIEW][:TAKE] --instruction "…"(--with,--as-typed,--target,--lora,--count).
"pano": {"with": [location ids]} on a location makes
360 panoramas instead of a plate (refs.plan_pano):
- What goes in: the live plate, then up to two more locations’ plates, the most important first.
- How it’s made: Qwen-Image 2.1 (
qwen_image_21) with thepano360_qwen21_edit_v1.safetensorsLoRA at 1.0. The prompt is the LoRA’s trigger sentence plus the location’s description, cut to one sentence of at most 35 words. The text is encoded at 1088, and sampling starts from an empty 2048x1024 latent (sizecan ask for another 2:1 size, e.g. 1536x768, which is faster and softer): thelatent: "empty"value makes the target’s switch skip the references’ latent. engine: "klein"(the editor’s default) is the other way.seriesmith/helpers/erp.pyplaces the plate on a green 2:1 canvas: a pinhole camera looking straight ahead, 70° wide. nomadoor’s FLUX.2 Klein 9B 360 ERP outpaint LoRA then paints the 360 round it onklein_erp_360, which is the author’s own graph: the base Klein 9B, LoRA 0.9, 20 steps, cfg 5, the canvas as reference and latent. The plate stays where it was taken, where Qwen tends to pull the camera back. It takes about a minute. That target’smode: "pano"keeps it out of the model pickers.- Where it lands: takes of the location’s
panopseudo-view, listed aspanos, withsource: "pano".PUT /refs/pickrefuses them (400): a 360 is never the plate. - In the editor: the 360 viewer aims a camera into one and saves the view through
/refs/importas an unpicked plate candidate of the location or one of its angles. Save and sharpen then queues an edit of that candidate with the Sharpen preset, on Rapid AIO when installed. A cut view holds onlywidth × fov/360panorama pixels across, so it comes out soft.
view: edit.from_view. Add edit.turn: true to draw the view by
turning the camera round the figure:
- It runs on Qwen-Image 2.1 with the Multiple-Angles LoRA (
refs.TURN_LORAat 0.9). - The prompt is that view’s
<mva> …phrase (refs.VIEW_TURNS), sent as typed. - The editor’s ↻ on a view column turns it from the picked three-quarter view, else from the first picked view.
- The LoRA renders a whole scene as a diorama, so turning is for characters, not plates.
POST /seriesmith/refs/tour (P5)
Body {"ep", "ref", "move", "seconds"?: 2–10 (6), "count"?: 1–4, "seed"?}. A camera tour of
a location on MiniMax H3 (seriesmith/tour.py, ported from h3sets):
- The video:
seriesmith/workflows/h3_tour.json(H3 FL2VA image-to-video, the 4-step turbo LoRA) starts from the live plate. Themovesays where the camera goes and where it holds, andtour.TAIL(only the camera moves, and it holds still) is added to it. - The answer: sent as soon as the videos are queued:
{"queued": [{"tour", "comfy_prompt_id"}]}. - After that: a background thread waits for each video and keeps it in
refs/_takes/<key>/_tours/. It finds the video’s held moments withseriesmith/helpers/stills.py: runs of frames whose motion is below half the median, keeping the sharpest frame of each run, with the opening frame always first. That helper runs under the running Python and needs numpy, PIL and ffmpeg. - The holds: each becomes a take of the location’s
tourpseudo-view,source: "tour", withseriesmith.refsent as each one lands./refslists the runs astours(status,move,holds,error) and the frames astour_holds.PUT /refs/pickrefuses a hold where it is (400). - The CLI:
python -m seriesmith.tour EPISODE location:<id> "<move>". - Turning points: the stills step also keeps the moments a pan reverses or settles
without holding still (
turning_points: a short dip in motion, under 0.35× the most within half a second either side). H3 often turns straight back from “turn and hold”, so these are views a tour does reach.
POST /seriesmith/refs/tour-frame (P5)
Body {"ep", "ref", "tour", "seconds"}. Adds any moment of a finished tour’s video as a
hold (tour.grab_frame). The frame is cut exactly, by index, with ffmpeg; the take is
source: "tour" with hold: null, and it is added to the run’s holds. The answer is
the new take. The editor’s Tour section has a player for each recent tour, with Grab
frame.
POST /seriesmith/refs/copy-take (P5)
Body {"ep", "ref", "view"?, "take", "to"}. Copies a finished picture take (a tour’s hold,
for example) into another ref as a new, unpicked candidate (refs.copy_take). The new
take’s sidecar records copied_from. The answer is the new take. A character is copied
into one of its views, not into the character itself (400).
POST /seriesmith/refs/from-frame (step 13)
Body {"ep", "pass", "shot", "take", "frame", "ref", "view"?, "instruction"?, "target"?, "views"?, "pick"?}, or, for a video of your own, {"ep", "video", "time", "ref", ...}
(video as /refs/video answers it, time in seconds). The frame becomes a candidate
of ref (a character’s view, default the three-quarter, source: "frame"); with an
instruction an edit model makes the reference from it; for a character with views a
step then draws the other three views from that one, and with pick each is picked as
it comes out. Returns {"frame": take, "edits": [job ids], "step": id | null}. The
steps are journaled and made again after a restart (api.STEPS). A video outside the
show’s refs/_videos/ is refused (400).
POST /seriesmith/refs/video and GET /seriesmith/refs/videos?ep=… (step 5)
POST with {"ep", "source_path"} (a file on this machine) or a multipart file
(up to the upload limit) keeps a video in the show’s refs/_videos/ to take frames
from: the same bytes under the same name are reused, another file under it gets
-2. Answers {"path" (relative to the episode), "name", "size", "modified", "copied", "duration"}; 400 for a file that isn’t a video. GET lists the kept ones,
newest first: {"videos": [...]}. GET /seriesmith/browse takes files=video.
Seedance drafts: GET /seriesmith/drafts?ep=… and from_draft (2026-10-11)
Seedance 2.5’s proxy takes are Drafts (draft: true, 480p): the take’s record keeps
draft: {task, made, expires} (BytePlus’s task id; a draft lasts 7 days), and the
take summary carries draft (with expired) and, on a final made from one,
from_draft: {task, take, pass}. GET /seriesmith/drafts lists the drafts the
proxy cut uses: {"drafts": [{shot, take, thumb, mp4, expires, expired, finals, made_from_it, target}]}. POST /seriesmith/render with pass: "final" and
from_draft: {shot: proxy take} makes those shots’ finals from their drafts: the
request is the draft’s task id alone, at 1080p (the provider reuses the draft’s
prompt, pictures, length, ratio, seed and sound). 400 for a take that isn’t a
finished draft, 409 for an expired one. A Router take’s record has the size the
video came back at, and model_request.provider (its task id, seed, usage).
POST /seriesmith/clip/queue (step 12)
Body {"ep", "shot", "pass"?, "takes"? (1–8), "backend"?, "still"?, "image_target"?}.
The pictures the shot still needs are generated (each its own /refs/generate
request, so a restart asks for it again as itself), then a step waits on their takes,
makes them live and asks for the renders (or, still, the picture). With every
picture there, the renders are asked for at once. Returns {"refs", "waiting_on", "step", "renders", "stills"}.
POST /seriesmith/clips/ensure (step 12)
Body {"root"?}. Simple mode’s Clips project (clips.ensure_clips): Clips/clips/
in root (default the first project root), made the first time. Returns {"ep", "made"}
and, when it was made, emits seriesmith.episode. 409 when there is no project root yet.
POST /seriesmith/clips/new (step 12)
Body {"title", "root"?}. A Simple project of its own (clips.new_project), a show
folder named from the title: Storm at the lighthouse makes
Storm_at_the_lighthouse/storm_at_the_lighthouse/ in root (default the first project
root), its pictures in the show’s refs/, its series config the Clips one, titled and
saying "simple": true. Returns {"ep"} and emits seriesmith.episode. 400 for a title
with no letters or digits, 409 when that show folder is already there or there is no
project root yet.
PUT /seriesmith/refs/pick
Body {"ep", "ref", "view"?, "take"}. Copies the take into place, stitching the
sheet when all four views are picked. Returns the ref as /refs lists it. 409 if the
take isn’t usable.
POST /seriesmith/refs/import
Adds an image (or, for voice, an audio file) as a new take with source: "imported". Body {"ep", "ref", "view"?, "source_path"}: a file on the ComfyUI
machine. A multipart upload (file) comes later, with drag and drop. Returns the new
take.
PUT /seriesmith/refs/override and DELETE /seriesmith/refs/override
Same shape as the shot override routes, keyed by ref (and view).
More settings (P4, 2026-10-08). Besides prompt, seed, model, loras, steps,
note and target, a ref’s override takes:
size:"WxH", 256–4096 a side. Not for a keyframe (its size is its shot’s) or a voice. A character view’s prompt names it.cfg: 0–30.negative: text. It only applies on a target with anegativeparam, where it beats negative.txt, the series config and the preset, as sourceoverride.params:{sampler, scheduler, denoise, shift, guidance}(refs.TUNABLE_PARAMS). Each is sent only where the image target’s binding has that param; one it lacks is skipped, with a note on the take.
null, "" or {} clears a field. POST /seriesmith/refs/generate takes size, cfg and
params too; the request’s value beats the override’s. /refs lists cfg, params and
the negative in effective, and cfg and params on every take. The editor’s Use
these settings saves a take’s seed, model, LoRAs, steps, cfg, params and size (but not
its prompt) as the ref’s override.
A view’s overrides, and whose they are (P9, 2026-09-23). A character’s fields are inherited
by all four views (ref_override merges the character’s under the view’s), which left the
editor unable to say where a value came from or what an edited prompt had replaced. Two
additions to each entry of a character’s views:
override.own: the fields set on this view, a subset ofoverride.fields(the merge). The rest belong to the character and are changed on its row, andDELETE /seriesmith/refs/overridewith aviewdrops exactlyown. Absent on a ref’s own override block, and on a character’s, because there every field is its own.built_prompt: the series config’s wording for that view, before any override — what a prompt edit is diffed against and what “Series config” puts back. It used to be unavailable precisely when it was needed: the editor substituted""for an overridden view, so Diff and revert had nothing to work with.
References: as built (Phase 5), where the text above left room
- Extra fields on a ref:
key,subject,can_generate,why_not, andeffective(prompt, seed, seed_source, model, loras, steps, width, height).override.values.views: []for refs that aren’t characters. Each view has its ownprompt,overrideandeffective. - A character’s top-level
promptis the 4-panel sheet text (as inrefs_todo). What each view actually generates with is in that view’s entry. - Extra fields on a ref take:
view,usable,audio(voices:audioset,image: null),seed_source,prompt,model,loras,steps,width,height,queued,finished,comfy_prompt_id,save_notes,overrides. - Refs live next to the series config. A series config in the parent folder gives paths like
../refs/…, and/seriesmith/fileserves those only inside that series config’s folder and a configured root. - Keyframes are listed once they exist (a live file or a take). Any shot in a build can import or pick one; a shot in no build is 404.
seriesmith.refstatus values:queued,ok,failed,picked(after a pick or an auto-pick).- Auto-pick:
GET /seriesmith/refsgives a ref with NO live file its first finished candidate (refs.auto_pick), per view for a character, stitching the sheet once all four views have one. A ref whose file exists is never replaced without an explicit pick. This iskreagen’s rule for a missing file. - After the reply: a server (
seriesmith serve, the ComfyUI host) closes the ref takes whose job is gone and auto-picks after it has answered, on a thread, so the listing never waits for it; what that changes reaches the page asseriesmith.refevents, which reload the list. A direct caller (the CLI, tests) has it done first. - Ref overrides have one level: the ref, or a view, not per pass. A character’s
promptoverride needs a view. The response is{"override": {...fields, "stale"}}.base_hashis the sha1 of the built prompt. - Generate:
count1–16. Only the first candidate followsseed_modeor a typed or pinned seed; the rest get new seeds.autokeeps the stable seed until the ref has a usable take.promptwith a character andview: nullis 400; voice and keyframe generates are 400.- Pick accepts
force: true.
- Import: images png/jpg/jpeg/webp, audio wav/mp3/flac/ogg/m4a. The take keeps its extension, and picking copies the bytes to the series config’s path.
Round 2 contract fixes (after merging the Refs tab)
GET /seriesmith/browse?path=…&files=image|audioalso lists matching files asfiles: [{name, path, size}], for import.filesis absent without the parameter; an unknown type answers 400.- Each ref in
GET /seriesmith/refsalso carriesoverride_values(the same asoverride.values) andbuilt_prompt(the series config’s prompt before any override). The UI reads these flat names. - Already served, which the UI can use once its TODOs are cleared:
comfy_prompt_idon ref takes and episode takes,effectiveon refs and on each character view, and per-viewprompt/override/effective. Ref files beside a parent-folder series config come through/seriesmith/fileas../refs/…paths.
Targets (Phase 7)
Everything model-specific lives in a target:seriesmith/targets/video/<id>/ for a shot model,
seriesmith/targets/image/<id>/ for a reference-image model (targets/__init__.py). Today there is
one of each: minimax_h3_ref2va (MiniMax H3 Ref2VA) and krea2. A built shotlist
belongs to one video target, the series config’s series.target (default
minimax_h3_ref2va). Episodes that mix targets arrive with Phase 8. (Phase 8: they have;
ltx2 is the second video target. See Phase 8 as built at the end. Today seriesmith
ships two dozen video targets and about twenty image targets, the Comfy Router models
among them, plus ltx2_voice (audio); a show adds its own: Hosted models, limits and
your own workflows.)
GET /seriesmith/targets[?kind=video|image|audio][&ep=…][&ready=1]
Every target, for pickers. ep adds that show’s own targets (Phase 12); ready=1 adds each
one’s readiness (Readiness, below). Each also carries graph (Phase 11: which copy of its
workflow is in force) and, added since the example below, short, family, models,
capabilities, downloads, limits, draft / shipped_draft, custom and scope
(the later sections). Otherwise nothing here is per-episode.
presetsare the target’s own defaults. The series config’sseries/proxyblocks and profiles override them per episode;GET /seriesmith/shot’seffectiveis what a render of that shot would actually use.widgetsis the binding: which node class and widget takes each render parameter.{"via": "loader"}means the target’s loader reads it from the frozen shotlist, so the graph isn’t patched for it. A picker for a{"class_type", "field"}widget can list ComfyUI’s own choices from/object_info/<class_type>.presets.proxy.model: nullmeans the proxy renders the series model (then the final preset’s).- An unknown
kindanswers 400.
target and profile on shots and takes
GET /seriesmith/episodehas a top-level"target". Each shot has"target"(the video target it renders on) and"profile"(the render profile it was built with, ornull). Each take has"target"from its sidecar (nullfor a take from before sidecars).GET /seriesmith/shothas"target"and"profile".builtcarriesprofile, and a profile’s LoRA list asloras, when the shot has them.- Take sidecars record
"target": the job’s real target id, not a constant. The frozen shotlist beside the take records it too, at the top level. overrides.jsonis keyed by target already (shots.<shot>.<target>); the routes read and write the shot’s own target’s block.
Render profiles
A profile is a named{target?, model?, loras?, steps?} in the series config’s
profiles block, picked with profile: on a sequence or a shot (docs/AUTHORING.md).
Build folds it into the shotlist, so the routes see the result: model, steps, and
loras (a list) or lora on the built shot, plus profile naming it. Precedence, weakest
first: target preset → series config pass block → sequence profile → sequence lines →
shot profile → shot lines → overrides.json → the request.
Render anyway, target-aware
POST /seriesmith/render with "allow_missing_refs": true now asks the shot’s target to
write the shot without the missing refs, when it can:
- H3 can. A missing subject picture drops out of the
<Picture N>slots and the subject is described in words (its series configdesign). A missing plate leaves<Picture 4>unmentioned and describes the location in words; the loader still feeds flat grey into that input. A missing voice sample or recording makes the shotgenerate(no audio reference; voices come from eachvoiceline). The frozen shotlist carries this recompiled shot. - The recompile needs the build to be current. It reads
shotlist/shots.jsonand the series config, and first checks that they still build the exact shot in the shotlist. If they don’t, or the target can’t recompile, the render falls back to grey stand-ins with the built prompt (the Phase 5 behaviour). - A prompt override still wins: the text you wrote is rendered as is.
- The take’s sidecar says what happened:
missing_refs(the slots, as before),missing_mode("recompiled"or"blank"), andmissing_note(why it fell back toblank). The last two are only present on a take rendered anyway.
Phase 8 additions: retargeting a shot (contract written before building)
PUT /seriesmith/override:fields.targetsets a shot’s video target, e.g."ltx2", ornullto go back to the built target. It is shared by both passes, likeseed. The shot’s IR (shotlist/shots.json) is recompiled for that target at queue time. The target’s own prompt, model, LoRA and steps defaults apply. The per-passpromptoverride is ignored for a retargeted shot, because a prompt written for one model isn’t valid for another. The response and the override view showtarget.POST /seriesmith/rendertakes an optional"target"for one run, beating the override.GET /seriesmith/episodeandGET /seriesmith/shot: each shot’stargetis the one its next render will use: request, then override, then script, then series config.built_targetis what the build compiled it for. Shot detail’seffectivealso carriestargetand that target’swidth/height/length.- Takes record the
targetthey rendered with (already true since Phase 7). GET /seriesmith/targetsis what the UI’s target picker lists, video targets only. Model and LoRA pickers filter by the chosen target’s binding widgets.
Phase 8 as built
Everything in Phase 8 additions above is implemented as written. What the text left open, and what was added:PUT /seriesmith/override:fields.targetmust name a video target (GET /seriesmith/targets?kind=video), else 400. Setting the shot’s built target, ornull, clears the retarget.- Pass fields (
prompt,model,loras,steps) andseed/notego to the block of the target the shot renders on after the request’stargetchange, and are stamped against that target’s entry (the retargeted one when it is retargeted). So one request can retarget a shot and set its LTX steps. - The response adds
"target"(what the next render uses) and"built_target"beside"override". Each pass’s view carriestargetwhen the shot is retargeted. - A pass field on a retargeted shot whose IR no longer compiles (the build is out of date) answers 409.
DELETE /seriesmith/overridewithoutpassalso clears the retarget. The response addstargetandbuilt_target.POST /seriesmith/render:target(a video target id ornull) beats the override for this run; an unknown one is 400.- Each shot renders with its own target’s workflow, resolved once per request. A target
whose workflow can’t be read no longer fails the whole request with 500: each of its
shots is reported in
errors, and shots on other targets still queue. - A shot that can’t be retargeted (shots.json / the series config no longer build it:
rebuild) is reported in
errorswith the reason. queuedentries carry"target".- An LTX shot’s keyframes (
refs/shots/<shot>/first.png,last.png, when they exist) are uploaded to ComfyUI’s input folder asseriesmith/<sha1>.png(through ComfyUI’s ownPOST /upload/image, so it works however ComfyUI is installed) before the job is queued. - A retargeted shot’s takes on its old target don’t count as done:
redo: falsestill renders it on the new one.
GET /seriesmith/episode:- Shots come from every target’s shotlist, in script order (
shots.json), merged withcut.jsonas before. - Each shot has
target(the next render’s: override, else build) andbuilt_target.override.fieldsincludes"target"when the shot is retargeted.missing_refsis the next render’s (anltx2shot needs none: its keyframes are optional). retarget_error(a string) is present when the shot is retargeted but its IR can’t be compiled for that target: rebuild the episode.length,seconds,subjects,audio_policy,sizeare still the built entry’s. The next render’s length is in shot detail’seffective.length.- A take’s
stalecan also holdtarget: the take was rendered on another target than the shot’s next render uses. The other reasons are judged against the entry the take’s own target would render now. - Top-level
targetis the series target (shotlist.json’s).
- Shots come from every target’s shotlist, in script order (
GET /seriesmith/shotaddsbuilt_target.effectiveaddstarget,width,height,length, and, when there is something to say,notes(e.g. “the prompt override was ignored: …”, “audio clone renders as generate on LTX-2: …”) anderror(the shot can’t be rendered on its target).overridecarriestargetwhen retargeted.builtstays the built entry; for a retargeted shoteffective.promptis the new target’s prompt.GET /seriesmith/targetsadds, per target:short: a short label (“H3”, “LTX-2”).capabilities:{policies, policy_fallback, voice_reference, subject_refs, keyframes, prompt ("sections" | "prose";minimax_h3_fl2va: "fields"), negative_prompt, duration ("predict" | "script"), audio ("generate" | "none": the Wan targets make no sound)}.templatemay carryfps: "series"(the target renders at the series config’s fps),max_size({long_side, pixels}) andsize_fit: "snap".widgetsvalues are always single specs (for a param patched into several widgets, the first). Anltx2widget spec may carryfeeds([class, input]: which of several nodes of that class),allorscale; a picker only needsclass_typeandfield.ltx2has nostepswidget: its sampling schedule is fixed in the workflow.
- Take sidecars may add
built_target(retargeted),inputs({role: name in ComfyUI's input folder}of the keyframes used) andnotes(a list of strings: audio fallbacks, an ignored prompt override, astepsvalue the target doesn’t use). Anltx2take’srefslists its keyframes withroleandoptional: true,sha1: nullwhen the file didn’t exist. - Shot lengths (
dur: model):capabilities.durationis"predict"(onlyltx2: the model’s duration head can choose the length) or"script". Every take sidecar haslength_source:script(the build’s length),estimate(dur: modelrendered at the build’s estimate: the target can’t predict, or the duration head isn’t installed; anotesentry says which) orpredicted(the saver’sframesis the length;lengthis the estimate). Adur: modelentry haslength_estimated: true, and on a predicting targetduration_predict: {min_seconds, max_seconds}. - Files: an episode that mixes targets has
shotlist/shotlist.<target>.jsonandshotlist.<target>_proxy.jsonbesideshotlist.json/shotlist_proxy.json, each with a top-level"target".shotlist.jsonis always the series target’s, even when no shot is left on it.
Continuity keyframes
A shot’s first keyframe is usually the previous shot’s last frame, so the cut runs on without a jump.ltx2 reads shot:<shot>:first / last (Phase 8), and so does
minimax_h3_fl2va (see its section below); minimax_h3_ref2va doesn’t use keyframes (GET /seriesmith/targets: capabilities.keyframes is []).
POST /seriesmith/refs/keyframe
Cuts one frame out of a video take with ffmpeg and adds it as a new take of the shot’s
keyframe ref.
which:"first"(default) or"last": the keyframe written.source_shot: default the neighbouring shot in the pass’s cut order (cut.jsonreconciled with the script, orphans skipped): the previous shot forfirst, the next shot forlast.source_take: default the take that shot’s cut entry uses, asGET /seriesmith/episodereports it incut.take: its pick (a placeholder’s take comes from the other pass), else its latest usable take. A givensource_takeis a take ofpass.frame: a frame number (0-based, in decode order; negative counts from the end,-1being the last),"first"or"last". Default"last"forwhich: first,"first"forwhich: last. The frame is exact: ffmpeg’sselectcounts decoded frames.pick(added):null(default) picks the new take only when the keyframe has no live file yet (the auto-pick rule);truealways picks it;falsenever does.- Returns the ref as
GET /seriesmith/refslists it. - Events:
seriesmith.ref{ref, view: null, take, status: "ok"}, thenstatus: "picked"if it was picked, thenseriesmith.episode. - Errors: 400 for a shot with no previous (next) shot in the cut, a bad
which/frame/pick, or a frame outside the take; 404 for a shot in no build or asource_takethat doesn’t exist; 409 when the source shot has no usable take, or its cut entry names one that isn’t usable (queued, failed, no mp4); 500 without ffmpeg / ffprobe on ComfyUI’s PATH. - The take’s sidecar has
source: "frame"andsource_shot,source_take,source_pass,source_frame(the index used),source_frames(the take’s frame count),source_mp4(relative to the episode) andsource_sha1.width/heightare the frame’s.GET /seriesmith/refsshows such a take with"from": {"shot", "take", "pass", "frame", "frames"}. - Staleness: video takes record each keyframe’s sha1 (
refsin the sidecar), so re-extracting and picking a new frame marks the takes that used the old oneref-stale. Also new: a take rendered without an optional keyframe (sha1null) isref-stale once that keyframe exists, because a render would now use it. - The CLI is
seriesmith keyframe <ep> <shot> [--from-prev | --from <shot>[:take]] [--first | --last] [--frame N] [--proxy] [--pick | --no-pick](final pass unless--proxy, like the other edit commands). - Not yet: there is no route to unpick or delete a ref take, so the editor has no “Clear” for a keyframe.
The ltx2_ingredients target (as built)
A third video target: LTX-2.3 with the IC-LoRA “ingredients”, which keeps characters,
props and the set looking like their refs. Nothing new in the routes; what the existing
ones now show:
GET /seriesmith/targetslists it:label“LTX-2.3 ingredients (character/plate refs)”,short“LTX+refs”.capabilitiesgainsreference_sheet(true only here), andsubject_refsis true for it.template:fps: 24,frames: {step: 8, base: 121, max: 121}(every shot is 121 frames),size_multiple: 32. Presets: final 768×448, proxy 512×288, bothltx-2.3-22b-distilled-fp8.safetensors+ltx-2.3-22b-ic-lora-ingredients-0.9at 1.0, 8 steps, text encodergemma_3_12B_it_fp4_mixed. Its widgets includesteps(the template’s KSampler), andmodelis patched into all three loaders that read the checkpoint.- Shotlist entries carry
panels: the reference sheet, in order, each{"subject", "kind", "path", "view"?: "body" | "face"}or{"location", "kind": "plate", "path"}.promptis"Reference sheet: …\n\nGenerated video: …"(a shot with no panels: plain LTX-2 prose). missing_refs(episode listing, renderskipped) lists the panels’ files that aren’t on disk, with slots"sheet panel N"andsubjectorlocation. They block the shot like H3’s pictures.allow_missing_refs: truerecompiles the shot without them (missing_mode: "recompiled"): they leave the sheet and theReference sheet:half, and a shot left with none renders text-only (the take’snotessay so).- Queueing (
POST /seriesmith/render,render): before the take is reserved, the sheet is composed from the live ref files at the render size (no text, one panel per element tiling the sheet with thin black lines between them;seriesmith/helpers/refsheet.py, run as a subprocess of the running Python, which needs PIL: ComfyUI’s has it) and uploaded asseriesmith/<sha1>.png. Composing fails clearly when PIL is missing; the shot is reported inerrors. - Take files and sidecar: the sheet is kept as
<shot>_tNN_refsheet.pngbeside the mp4. The sidecar’srefslists each panel’s file with itssha1(so re-picking a view makes the takeref-stale) and then the sheet itself (slot: "reference sheet",role: "sheet", its path in the take folder andsha1);inputsis{"sheet": "seriesmith/<sha1>.png"}. When a LoRA list from a script line or profile didn’t name the IC-LoRA, it is put back first andnotessays so.
The minimax_h3_fl2va target (as built)
A fourth video target: MiniMax H3 first/last-frame to video + audio, on ComfyUI’s H3
image-to-video template (MiniMaxH3ImageToVideo; with no keyframe it is text-to-video).
Nothing new in the routes; what the existing ones now show:
GET /seriesmith/targetslists it:label“MiniMax H3 FL2VA (first/last frames)”,short“H3 FL2V”.capabilities:keyframes: ["first", "last"],policies: ["generate", "dub", "dub_keep_foley"],policy_fallback: "generate"(clone),subject_refs/voice_reference/negative_promptfalse,prompt: "fields".templateis H3’s (fps: 24,frames: {step: 17, base: 5, max: 3592},size_multiple: 32); the build warns past the model’s trained 362 frames. Presets: final 1344×768,minimax_h3_fl2va_pruned_int8_convrot+minimax_h3_fl2v_turbo_8step_v1.0at 8 steps (res_multistep); proxy 448×256, the 4-step lightx2v v0.1 LoRA at 4 steps (euler).widgetsaddsampler(KSamplerSelect) and havesteps(BasicScheduler).- Shotlist entries are like
ltx2’s:keyframes{first, last}(the conventional paths), nopanels, nonegative;audio_intent/audio_noteon a clone shot. Thepromptis H3’s base-mode format:integrated_multimodal_description: [Shot 1] …,overall_soundscape: …,non_diegetic_music: …, with no<Picture N>(subjects in words). - Keyframes come from continuity (
POST /seriesmith/refs/keyframe) or an import, and are optional. At queue time the prompt gets H3’s alignment line for the frames the render has, first line then a blank line: first only “For the target video, at 0.00 seconds into the target video, <Picture 1> (from [Shot 1]) is fully referenced.”; first and last “How the reference pictures align with the target video — Picture 1 (from Shot 1) aligns with the 0.00-second mark …; Picture 2 (from Shot 1) aligns with the S.SS-second mark …”; last only the same with<Picture 1>at S.SS. The take’s frozen shotlist has that prompt;effective.promptinGET /seriesmith/shotis the built one. - Dub: a
dub/dub_keep_foleyshot’srefsaddslot: "dialogue recording"(the series config’saudio.track, or the shot’saudio_file;kind: "audio", required, so a missing file blocks it; rendering anyway recompiles it asgenerate). At queue time itsaudio_in–audio_outslice is cut (stdlibwave; ffmpeg for other formats; mono made stereo), uploaded asseriesmith/<sha1>.wav, anchored at frame 0 by aMiniMaxH3AddGuide, and kept as<shot>_tNN_dub.wav(slot: "dialogue slice",role: "audio"). The sidecar’sinputsthen hasaudiobesidefirst/last.
Model families
Each target declares the model family every model setting must be (target.json
models: {"<param>": {"family", "patterns"}}, for model, text_encoder, video_vae,
audio_vae, upscaler, duration_head and, on two-stage targets, model_high /
model_low). A file passes by name (case-insensitive globs, plus the series config’s
model_families), else by its safetensors header (targets/modelid.py: tensor names and
key shapes, model_version / other metadata; the weights are never read). Header results
are cached by (path, size, mtime) in <ComfyUI user dir>/default/seriesmith/modelid_cache.json.
GET /seriesmith/targets: models
Each target has "models": {"<param>": {"family", "label", "patterns", "folder"}}, e.g.
ltx2’s "model": {"family": "ltx2.5", "label": "LTX 2.5", "patterns": ["ltx-2.5*", "*ltx*2.5*", "*ltx*2_5*"], "folder": "diffusion_models"}. {} for a target that declares
none.
GET /seriesmith/models?target=ltx2¶m=model[&ep=…]
One model param’s files, as ComfyUI lists them (folder_paths.get_filename_list of the
param’s folder), each checked against the family:
matchisname(named like the family; nothing read),fingerprint(the header says it is the family, or a parent family the header can’t narrow, e.g. an H3 merge named neither Ref2VA nor FL2VA) orother.fileslistsname, thenfingerprint, thenother, each group in ComfyUI’s order.mismatch: true: the header says another family. A render with that file is skipped unless the request saysallow_model_mismatch.confidenceis how the family was found:metadata,tensors,name(the header’s family narrowed to a variant by the name;baseis the header’s family) orunknown.fingerprint: false: the server couldn’t resolve model paths, so only names were checked.- Headers are read only for files whose name doesn’t match, then cached; the handler runs off the event loop like every route.
paramdefaults tomodel. A target that declares no family for the param, or an unknown target, answers 400.ep(optional, inside a root) adds that series config’smodel_familiespatterns.
POST /seriesmith/render: allow_model_mismatch
- Before a take is reserved, every model file the job loads is checked
(
jobs.check_models; paths through ComfyUI’sfolder_paths):- name match: nothing to say;
- no name match, the header says the family: renders, with a note in the sidecar’s
notes; - unknown header, or not found: renders, with a note;
- another family: the shot is skipped,
{"shot", "reason": "model mismatch: … (pass allow_model_mismatch: true to render anyway)", "model_mismatch": [check, …]}inskipped. A check is{"param", "file", "family", "label", "patterns", "match", "found", "message", "block"}.
allow_model_mismatch: true(a bool; anything else is 400) renders it anyway; the sidecar’snotessay “model mismatch, rendered anyway: …”.- The CLI does the same (
render --allow-model-mismatch), finding files under$COMFYUI_PATH/modelsand caching in the temp folder; without$COMFYUI_PATHit checks names only, and a name that doesn’t match is a note.--dry-run --check-nodeslists every check.
The editor
The model picker lists the files of the target’s family first, then an “Other files (unverified)” group. Picking one of those shows the file’sdetail. When it is a
mismatch, the redo dialog offers “Render anyway (model mismatch)”, which sends
allow_model_mismatch: true. A render that skips a shot for a mismatch shows a warning
toast with the reason. A server without /seriesmith/models keeps the flat list.
The Wan 2.2 targets (as built)
Three more video targets, all silent:wan22_i2v, wan22_ti2v, wan22_vace. What the
existing routes now show:
GET /seriesmith/targetslists them:label“Wan 2.2 14B I2V” / “Wan 2.2 5B TI2V” / “Wan 2.2 14B VACE (refs)”,short“Wan I2V” / “Wan 5B” / “Wan+refs”.capabilities:audio: "none",policies: ["silent"],policy_fallback: "silent",prompt: "prose",negative_prompt: true,keyframes["first", "last"](I2V, VACE) or["first"](5B),subject_refstrue only for VACE.template: 14Bfps: 16.0,frames: {step: 4, base: 5, max: 161},size_multiple: 16; 5Bfps: 24.0,max: 241,size_multiple: 32; allsize_fit: "snap"under 720p. Presets: I2V final 832×480 / proxy 640×352, 4 steps with the lightx2v LoRA pair; 5B final 1280×704 / proxy 640×352, 20 / 12 steps; VACE final 832×480 / proxy 640×352, 20 / 10 steps at cfg 3.5.widgetsaddmodel_low(the 14B’s low noise model),cfg,sampler,shift(and VACE’svace_strength);lorasis the high noise stage’s spec (a 14B binding has one LoRA chain per stage:stageroutes a LoRA, else its name’shigh_noise/low_noise, else both).- Shotlist entries are like
ltx2’s (keyframes,negative, a prosepromptwith no sound and the lines as silent acting), withaudio_policy: "silent"andaudio_intent/audio_noteon every shot; VACE entries addpanels(the subjects, asltx2_ingredients’, without the plate). A shotlist’sdefaultscarrymodel_low,loras(the 14B I2V preset’s per-stage list, used when noloranames one),cfg,split(the fraction of the steps the high noise model samples) and the rest. missing_refsentries may carryanyway: falseandwhy: the shot can’t render without that ref even withallow_missing_refs: true. That iswan22_i2v’s first frame:{"slot": "first frame", "kind": "image", "path": "refs/shots/sh050/first.png", "anyway": false, "why": "Wan 14B I2V needs a first frame: generate one or use continuity (or import one), or retarget to wan22_ti2v"}(Phase 8.5 wording).POST /seriesmith/renderskips such a shot either way, withwhyas itsreason. The editor showswhyand offers no render-anyway for it.- VACE’s reference image is composed when the shot is queued, like the ingredients
sheet but on white (one panel per subject), uploaded as
seriesmith/<sha1>.png, and kept as<shot>_tNN_reference.png(slot: "reference image",role: "reference"); its panels’ files are required refs (slot: "reference panel N"). Keyframes become VACE control frames. - Take sidecars now carry
fpson every take (the queuer writes it; the saver writes it too once ComfyUI has reloaded the node pack). Wan takes are mute mp4s at their target’s fps (16 for 14B), with anotesentry saying the audio renders silent. assemble(and theassembleroute) convert every clip to the episode’s fps (the series config’sseries.fps) with ffmpeg’sfpsfilter, timing each converted clip by its duration on the cut’s running clock, lay silence under mute clips, and scale any clip of another size to the cut’s.
Readiness, requirement tiers, and the episode target (contract written before building, 2026-09-19)
Requirement tiers (target.json)
Each model param in a target’s models block gets a tier:
An unmarked param is
required. A target may also declare nodes it needs beyond its
workflow’s own classes.
Resolving files by family
At queue time each model param resolves to an installed file:- the preset’s exact file, if installed;
- otherwise the best installed file of the same family (
targets/modelid.py): a name match beats a fingerprint match; within a tie, prefer the same precision as the preset (fp8 / int8 / bf16 / fp16), then the shortest name.
resolved: {param: {"want", "using", "how": "exact" | "family" | "base" | "off"}}.
want / using are a file name, except for the loras param, where both are lists of names.
render --dry-run --check-nodes prints the same resolution.
Downloads (target.json downloads)
{"<file name>": {"folder": "diffusion_models", "url": "…" | null, "source": "…"}}. A
URL is only given when it comes from a trustworthy record: a ComfyUI template’s embedded
properties.models entry, or ComfyUI-Manager’s model list. Otherwise url is null and
source says what to search for. Never guess a URL.
GET /seriesmith/targets?ready=1
Each target gains readiness (cached briefly; computed off the event loop from
/object_info and the model folders):
degraded: only accelerators or optional files are missing.unknown: ComfyUI didn’t answer.
seriesmith targets [<episode>] [--json]
The same readiness as a table: status, what’s missing, the folder, and the URL when known.
The episode target
overrides.jsongains a top-level"episode": {"target": "<id>"}. It is the episode’s default for every shot that has no target of its own (scripttarget:, a profile’s target, or a shot override). Precedence: request → shot override → shot/sequence script line or profile → episode override →series.target→minimax_h3_ref2va. Shots whose resolved target differs from what the build compiled are compiled at queue time, as a retarget is.PUT /seriesmith/episode-target, body{ep, target: "<id>" | null}.nullclears it. Returns the episode’s target info and emitsseriesmith.episode.GET /seriesmith/episodegains:target: the episode default now in force;target_source:"editor"|"series"|"default";series_target;- per shot,
target(already present) andtarget_source:"request"|"override"|"script"|"episode".
series.jsonis never written by the editor. The UI offers a copyable"target": "<id>"snippet forseries.seriesto make the choice permanent.
As built: where the build departs from the contract above
Everything above is implemented. Each point here is marked [differs] (the contract said otherwise), [added] (the contract said nothing) or [settled] (the contract left it open).- [added]
target.json:models.<param>may also carrykeep(regexes whose captured word a substitute must share with the wanted file: a LoRA’s step count, a Wan noise stage, LTX’sdistilled/dev),exclude(globs a substitute must not match: the H3 SLA LoRAs),default(the file the workflow loads when nothing names one: H3 Ref2VA’s text encoder and VAEs, krea2’s) andfeature(what an optional file switches on).lorasis a models param (family + tier) on H3 Ref2VA / FL2VA, Wan 14B I2V and ingredients.nodesis{class: {"tier", "feature"}}or a list of required classes. - [settled] Tiers: the accelerators are the H3 turbo LoRAs, the Wan I2V lightx2v pair
and the ingredients target’s
model(the distilled LTX 2.3 checkpoint): itsbasenames the dev checkpoint, which is then required. The LoRA in a pass’s ownloraslot (the preset’s, or the series config’s pass block’s) counts as that target’s accelerator even when it isn’t named like the family (it then resolves exactly or falls back to the base). A LoRA from a profile or shot line that isn’t installed is required. - [settled] Resolving by family: a substitute must share the
keepwords and match noexclude; headers are read only when no name matches and the family has a header signature (not LoRAs); a header naming only a parent family (an H3 file named neither Ref2VA nor FL2VA) is not picked. A file listed in a subfolder of the models folder (LTX-2.3\x.safetensors) is the exact file. A folder ComfyUI can’t list leaves its params unresolved, as before. - [added] Sidecar:
resolved.lorasis{"want": [names], "using": [names], "how", "tier"}(lists);resolved.<param>.tier;"base": trueon a take rendered with the base preset (whosenotessay so). The base’s LoRA loaders are removed from the graph (at strength 0 ComfyUI would still load the missing file). Astepsvalue set for the shot (request or override) is kept over the base’s, with a note. The frozen shotlist carries the base’s values (sampler,cfg, …) and any substitute file. - [settled] A missing required file (
POST /seriesmith/render): the shot is inskippedwithreason(“model files not installed: text_encoder … is not installed (models/text_encoders; download https://…)”),target, andmissing_files:[{param, tier, want, family, folder, url, source}]. No take is reserved. The CLI says the same (render: “blocked (model files not installed)”); a job’s action ismissing_files. - [added]
GET /seriesmith/targets(with or withoutready):models.<param>addstier(andfeature),presets.<pass>addsbase, and each target addsdownloads(all static data fromtarget.json).readymust be1/0(ortrue/false), else 400; it combines withkind. - [added]
readiness:missing[]entries addlabel(the family’s) andpasses(which passes miss the file); an optional one always hasfeature, the same words as itsfeatures_offentry.resolvedis the final pass’s;by_passhas each pass’s.howcan also bebaseandoff. Anunknownreadiness haserror. An optional node missing (LTX’sLTXVDurationPredictor) puts its feature infeatures_offand the class innodes_missing, with nomissing[]file entry. The node classes are the workflow’s as a job keeps them (saver in place, widgets patched, pruned) plus the loader, the saver andnodes. The cache is per ComfyUI address; an unanswered/object_infoisn’t cached. - [added]
seriesmith targets [<episode>] [--json] [--kind video|image] [--comfy URL]: exit 1 when ComfyUI doesn’t answer. Headers are read for substitutes only with$COMFYUI_PATHset. With an episode: its series config’s pass blocks andmodel_familiesapply, and its episode target is marked. - [differs]
overrides.jsonepisode: before, it was the episode’s name (a string). Setting a target makes it{"id": "<the name>", "target": "<id>"}; clearing it puts the plain name back. A stringepisodemeans no episode target. - [settled] Pinning a shot to its built target:
PUT /seriesmith/overridewithfields.targetequal to the built target clears the shot’s retarget only while no episode target is set. With one, it is kept as the shot’s own target (target_source: "override");nullclears it. The CLI’s--target builtdoes the same;--target noneclears. - [differs]
target_source(per shot inGET /seriesmith/episode, and inGET /seriesmith/shot, which also has it now): besides request / override / script / episode, a shot on the series default is"series"(the series config namesseries.target) or"default". “script” is judged fromshotlist/shots.jsonand the series config’s profiles (atarget:line or a profile’s target on the shot or its sequence); without a current shots.json, a built target other than the series config’s counts as the script’s. - [differs]
GET /seriesmith/episodetop-leveltargetis the episode default in force (the editor’s, elseseries.target, else the default). It wasshotlist.json’s target; the two are the same unless an episode target is set.series_targetisnullwhen the series config names none. - [settled]
PUT /seriesmith/episode-targetreturns{target, target_source, series_target}(the status fields). 400 for a target that isn’t a video target, or a body withouttarget; 404 for an episode with no build. CLI:seriesmith override <ep> --episode-target <id>;built(ornone/series) clears it.
Phase 8.5: refs, image targets, keyframes as needed refs, negatives (contract written before building, 2026-09-19)
Image targets
seriesmith/targets/image/<id>/, alongsidekrea2, built from the user’s saved workflows:z_image_turbo: fast text-to-image;flux2_klein: text-to-image, andflux2_klein_edit: image edit with reference images;flux_kontext: edit, one reference;- optionally an Illustrious/SDXL text-to-image target.
- Each has presets,
modelstiers, downloads and readiness, like the video targets. capabilities:{"mode": "t2i" | "edit", "max_refs": N}.- Series defaults: the series config’s
refsblock,{"target": "<image id>", "keyframe_target": "<image id>"}.- The defaults are
krea2and, when it is ready,flux2_klein_edit(else the refstarget). - A per-ref override lives in
refs/_overrides.jsonastarget. - A generate request’s
targetbeats both.
- The defaults are
GET /seriesmith/targets?kind=image&ready=1lists them, as for video.
Keyframes as needed refs
-
The build decides which shots need keyframes, from each shot’s resolved video target:
capabilities.keyframessays a target reads them;requires_first(Wan 14B I2V) makes the first keyframe required.
-
Optional script lines on a shot, or a sequence (as its default):
first:/last:withcontinuity | generate | import | none, or a path;nonemeans “don’t use one”, even when the target could.
-
GET /seriesmith/refslists keyframe refs for every shot that needs one, or whose script asks for one, even before any take exists. Each carries:need: "required" | "optional";method: the script’s (acontinuous: firstshot’s first iscontinuity), elsegenerate;shot,which, and thetargetthat will read it.
-
POST /seriesmith/refs/generateon a keyframe ref makes a still with the keyframe image target:- Prompt: from the shot’s IR, with the look, the location description, the subjects’ designs, and the action at that moment (“first”: how the shot opens; “last”: how it ends).
- Edit targets also receive reference images: the picked character views (face or body by shot size) and the plate, up to
max_refs. - Size: the shot’s render size for its target and pass.
-
method: continuitymeans “Generate missing” (and the CLI) callkeyframe_from_takeinstead, when the previous shot has a usable take; otherwise it falls back togenerate. -
DELETE /seriesmith/refs/pick?ep=…&ref=…[&view=…]unpicks a ref:- its live file is removed (the take stays);
- for a keyframe this is Clear, and the shot no longer uses a keyframe;
- for series refs it’s allowed, but the UI warns.
seriesmith.refwith statuscleared, thenseriesmith.episode.
Negatives
Any target (video or image) with anegative param takes, in order: the request’s
negative, then the shot’s negative override, then the episode’s negative.txt, then
the series config’s negative, then the target preset’s.
- The take records
negative_source. - H3 targets have no negative param and are unaffected.
kreagen --negative-filestill beats everything for its run.- The negative still has no effect at cfg ≤ 1 (turbo); the take notes that.
Inspector: the refs a shot uses
GET /seriesmith/shot gains refs_used: [{id, kind, role ("subject" | "plate" | "first" | "last" | "reference_sheet"), path, exists, need, thumb}] for the shot’s current target. A take’s detail gains reference_image: the refsheet or VACE reference that the take rendered with, if kept.
Small items
- An estimated length (
dur: modelbefore a take exists): episode shots carrylength_estimated: true, and the UI marks them ”≈”. - Wan’s
model_lowgets a picker, likemodel:GET /seriesmith/models?param=model_low, and the override fieldmodel_low.
Phase 8.5 as built
Everything above is implemented. Each point is marked [differs] (the contract said otherwise), [added] (the contract said nothing; the UI agent’s ten follow-up points are here too) or [settled] (the contract left it open). Image targets- [settled] Built:
z_image_turbo(savedz-image-turbo-t2i.json),flux2_klein(image_flux2_text_to_image_9b.json),flux2_klein_edit(image_flux2_klein_9b_kv_image_edit.json: its models are installed; the_9b_baseedit needsflux-2-klein-base-9b-fp8, which isn’t),flux_kontext(flux_kontext_image_base.json, trimmed to the branch its SaveImage reads). No Illustrious/SDXL target: the only SDXL workflow (sdxl_simple_example.json) is a base+refiner img2img canvas. Repo copies are API graphs with titled nodes, known byh3pipe_*.jsonnames so a canvas can’t leak in; the saved canvases’ LoRA loaders are left out. Each passescheck_graphagainsttests/fixtures/workflows/object_info_image.json(trimmed from the live/object_info). - [added]
GET /seriesmith/targetsimage entries:capabilities{mode, max_refs, negative_prompt}(krea2, Z-Image, Kleint2i/0; Klein editedit/4; Kontextedit/1). Video entries addcapabilities.requires_first(true only forwan22_i2v). - [settled] Klein and Klein edit: the model is an accelerator (the installed
distilled KV model, 4 steps, cfg 1);
baseis the base 9B at 20 steps, cfg 5 (the saved workflows’ own). Z-Image and Kontext: all required. Families from the installed files’ headers:z-image(Turbo by name),flux2-klein-9b,flux1(-dev/-schnellby tensors;flux1-kontext-devby name under dev),qwen3-4b,qwen3-8b,qwen3vl-4b,clip-l,t5-xxl,flux1-vae,flux2-vae. Files resolve by family at queue time (refs.resolve_job_models, the video code); a missing required file is anerrors[]entry withmissing_files. - [added] Reference images reach an edit graph through a chain titled “Reference 1 …”
that graph code repeats per image (or cuts out with none); each is uploaded as
seriesmith/<sha1>.png. A character with no picked view gets that panel cut out of its live sheet (h3_refsheet.py, PIL). - [added] Image defaults.
GET /seriesmith/refsreturns{"refs": [...], "defaults": {"target", "target_source", "keyframe_target", "keyframe_target_source"}}, sources"editor"|"series"|"default".PUT /seriesmith/refs/defaults{ep, target?, keyframe_target?}(null clears; a key left out is kept) storesoverrides.jsonepisode.refs_target/episode.keyframe_targetbeside the videoepisode.targetand returns{"defaults": ...}; 400 for a non-image target or a body with neither key. Precedence: request, then the ref’s override (targetinrefs/_overrides.json, now aPUT /seriesmith/refs/overridefield), then the episode’s, then the series config’srefs, then built-in. “Ready” for the built-in keyframe default means every required file offlux2_klein_editis among ComfyUI’s model lists. - [added] Each ref’s
effective.targetis the image target a generate would use (a character’s top-leveleffectiveis{target}; each view’s has the rest). Ref-take sidecars addtarget,negative,negative_source,values,resolved,notes, and for a keyframereferences([{role, subject|location, name, kind, view?, crop?, path, sha1}]),inputs,render_width,render_height,video_target. - [added]
POST /seriesmith/refs/generatetakestarget,negativeandpass(a keyframe’s size: defaultfinal, the larger; the file serves both passes).queued[]entries carrytarget.
- [settled] Script lines
first:/last:on a shot or a#header; IRfirst/laston shots and sequences, omitted when unset.noneremoves an optional keyframe from the built entry’skeyframes(a required one stays: the shot stays blocked). - [settled, changed 2026-10-09]
methodiscontinuityonly for the first frame of acontinuous: firstshot (docs/CONTINUOUS.md: the shot says it continues; it no longer follows from having a previous shot); elsegenerate. A path in the script ismethod: "import"withimport_path. - [added] Keyframe refs carry
shot,which,need,method,target(the video target),requested(the script asks for one),script(the raw line),reads(whether the target reads that end),import_path,cleared, and with an edit keyframe targetedit_refs: [{id, role, view?, crop?, path}](exactly what a generate feeds, aftermax_refs). Needed keyframes list before any take exists; ones that only exist (a take, a live file) list as before, withneed: null. - [differs] Size: the shot’s render size for its target and pass, scaled up (same
aspect, multiples of 16) to the image target’s
template.min_pixels(786432 for the new targets; krea2 has none, so exact): a 640x352 proxy frame is generated at 1200x656. Every video target scales a keyframe to its frame. The sidecar records both sizes and a note. - [settled] Prompt (
seriesmith/targets/image/common.py keyframe_prompt): an edit target’s references named first (“Image 1 is Bolt: draw this character exactly as in image 1 …”, “Image 2 is the background plate: …”); then “The first/last frame of a <size> of <location description>, one still picture. Drawn as <look>.” and each subject’s design; then the moment: first = the action’s first sentence “at the very start of the action” (the rest in parentheses, “not yet shown”); last = its last sentence “after the action is over, … completed”. Speakers are “about to speak” / “have just spoken”; no dialogue text; “No text, captions, speech bubbles or borders.” - [added] Reference choice: characters (script order), then props and vehicles, then the
plate, each only when its file exists; a character gives
04_faceon a single-subjectclose/cushot, else01_threequarter: its picked take of that view, else that panel of its live sheet. - [settled] “Generate missing” in the CLI:
seriesmith keyframe <ep> --missing [--proxy] [--dry-run] [--target T]fills required keyframes and any the script asks for, not live and not cleared: continuity (keyframe_from_take) when the previous shot has a usable take in the pass’s cut, else a still; a script path is imported and picked.seriesmith keyframe <ep> <shot> --generate [--first|--last] [--target T] [--pick|--no-pick] [--dry-run]makes one still. No new route: the UI calls/refs/keyframeand falls back to/refs/generateon 409 / 400.
- [added]
DELETE /seriesmith/refs/pickreturns the ref as/seriesmith/refslists it;seriesmith.refcarries the take that was picked (null if none). 400 for a voice._picks.jsonrecords"cleared": "<time>"on the ref (on a character’s view,views.<view> = {"cleared"}, and the stitched sheet is removed); auto-pick (GET /seriesmith/refs, andkeyframe_from_takewithpick: null) skips a cleared ref until anything is picked. Every ref and view in the listing carriescleared. - [added] CLI:
seriesmith keyframe <ep> <shot> --clear [--first|--last];kreagen --clear REF[:VIEW](repeatable) for series refs.
- [differs]
negative_sourcevalues:"request"|"override"|"negative.txt"|"series"|"preset"|"none"(a target without a negative param: H3, Klein edit).kreagen --negative-filerecords"request". The series config’s is a top-level"negative": "...". - [added] The shot override field
negativeis per pass (likeprompt;""is an explicit empty negative, null clears);POST /seriesmith/rendertakesnegativefor one run. Video sidecars on a negative-taking target recordnegative_source; a non-preset negative lands in the frozen shotlist and, at cfg <= 1, adds a note. Wan’s “Negative prompt”CLIPTextEncodefeedsWanImageToVideo.negative(checked). - [added]
GET /seriesmith/shoteffectiveaddsnegative(null fornone) andnegative_source, andmodel_lowon a two-stage target.
- [settled]
refs_usedentries also carryslot;rolecan also be"voice"(a clone sample) or"recording"(a dub track);idis null for a recording or a plate the series config doesn’t name.pathandthumbare relative to the episode (../beside a parent-folder series config),thumbnull when the file isn’t on disk. Thereference_sheetentry (ltx2_ingredients, wan22_vace) points at the latest take’s kept sheet or reference, else null. - [added] A take’s
reference_imageis relative to the episode, null when not kept. - [settled]
length_estimated: trueappears only on estimated shots. - [added]
model_lowis a per-pass override field;GET /seriesmith/models?target=...¶m= model_lowworks forwan22_i2vandwan22_vace(targetis required, as formodel).
Phase 8.6: look-back (contract written before building, 2026-09-19)
Loose ends from Phases 1-8, finished before Phase 9. Backend and UI build against this in parallel; the backend writes a “Phase 8.6 as built” section with [differs]/[added]/[settled].Discarding takes and ref candidates
Nothing is deleted: a discarded file moves to a_trash/ folder beside it, keeping its
relative name, so a mistake can be undone by hand. Discarding is refused (409) for a take
that is queued (cancel it first).
POST /seriesmith/discard{ep, shot, take, pass}: moves the take’s sidecar and every output it names (video, audio, reference image, thumbnails) fromrenders[_proxy]/<shot>/torenders[_proxy]/_trash/<shot>/. Ifcut.jsonpicks that take for that pass, the pick is removed (the cut falls back to the latest usable take). Returns{"shot", "take", "moved": [paths relative to the episode], "cut_changed": bool}and emitsseriesmith.episode.POST /seriesmith/refs/discard{ep, ref, view?, take}: moves the candidate (sidecar and image/audio) fromrefs/_takes/...torefs/_takes/_trash/.... If it is the ref’s (or view’s) pick, the ref is cleared exactly asDELETE /seriesmith/refs/pickdoes. Returns the ref asGET /seriesmith/refslists it and emitsseriesmith.ref.GET /seriesmith/episodeandGET /seriesmith/refsnever list anything under_trash/.- CLI:
seriesmith discard <ep> <shot> <take> [--proxy];kreagen --discard REF[:VIEW]:TAKE.
Generate missing, one route
POST /seriesmith/refs/generate-missing{ep, pass?, kinds?: ["series", "keyframe"], target?, keyframe_target?, dry_run?: false}: queues one candidate for every series ref that is missing (no live file, not cleared, no queued candidate), and fills every needed keyframe asseriesmith keyframe --missingdoes (continuity when the previous shot has a usable take in the pass’s cut, else a still; a script path is imported and picked). Returns{"queued": [{ref, view, take, prompt_id, seed, target, method}], "picked": [{ref, take, method}], "skipped": [{ref, reason}], "errors": [...]}.dry_runreturns the same shape with nothing queued (take/prompt_id null). The UI’s “Generate missing” button uses this instead of its two-step fallback.
Uploading a ref (drag and drop)
POST /seriesmith/refs/importalso acceptsmultipart/form-datawith fieldsep,ref,view(optional),pick(optional,"1"picks it) andfile(an image, or audio for a voice). Same result as the JSON form: a new take withsource: "imported", plusoriginal_name. 400 for a file type the ref can’t use; 413 over 64 MB. The JSON form also takespick.
Supplying references you already have (P8, 2026-09-23)
A picture copied into the folderseries.json names has always been fully supplied: the
build counts it, renders read it, and stale: ref follows it (a take’s sidecar records the
path and sha1 it used, which is independent of picks). None of this replaces that. What it
adds is the same thing through the editor, for a character, and in bulk.
The sheet pseudo-view
A character’s live file is a 4-panel sheet, and until now the only way the tool could write
one was to stitch four picked views. refs.SHEET_VIEW ("sheet") is a reserved view that
holds a ready-made sheet as an ordinary take:
POST /seriesmith/refs/import,PUT /seriesmith/refs/pick,POST /seriesmith/refs/discardandDELETE /seriesmith/refs/pickacceptview: "sheet"for a character. The takes are<key>_sheet_tNN.pngbeside the views’.POST /seriesmith/refs/generaterefuses it (400), as does any other path that words or generates a view: nothing draws a sheet, it is four views stitched — or one you supply.- Picking a sheet take copies it to the live file with no stitching. The pick is recorded
in
_picks.jsonunderrefs.<id>.views.sheet, beside the four views, which keep their own picks: picking all four later stitches over the supplied sheet, and that is allowed. GET /seriesmith/refsreports a character’s sheet takes as the ref’s owntakesandpicked(they were always[]/nullbefore — a character’s candidates live in its views), plussheet_cleared.live_fromon a character says which route wrote the file that is live now, judged from the file’s own sha1, not from timestamps:"sheet","views", ornull. Null covers no file and a file this tool didn’t write — the Explorer route, which is left exactly as it is and shown as “put there outside the editor”.- Clearing or discarding a supplied sheet removes the live file only if that pick still wrote it; a stitch that replaced it is left alone.
POST /seriesmith/refs/match
Body {"ep", "names": [...]} (at most MATCH_LIMIT, 500), answer
{"matched": [{"file", "ref", "view", "name", "kind", "audio", "path", "why"}], "unmatched": [{"file", "why"}]}. Names only — no file bodies — so the editor can show the
table it is about to act on before uploading a byte. It writes nothing.
refs.match_files is the one implementation of the rules, called by this route and by the
CLI, so the two cannot drift. A file matches a slot when its slug (basename, no
extension, lower case, every run of non-alphanumerics an underscore) is one of that slot’s
names:
- The extension decides between an image slot and an audio one, so
ada.pngis the sheet andada.wavthe voice. A file that is neither is refused for its type, not its name. - A trailing revision marker (
_v2,_3,_final,_copy,_new,_edit,_fixed) is ignored when the rest matches, andwhysays so. - Nothing is guessed. A name that could be two slots, or two files that want one slot, leave every file involved unmatched with the reason. Matching is exact, never fuzzy: the cost of a wrong guess is a render with the wrong character in it.
- Measured on a real show (Porchlights ep01, 131 refs): all 110 of its own reference file names match their own slot, with no ambiguity.
CLI: seriesmith supply
refs.supply_files — a folder is matched by name, --ref sends one file to one slot
(--ref subject:ada:sheet), each file becomes an imported take and is picked unless
--no-pick. --dry-run prints the same table and writes nothing. It answers
{matched, unmatched, supplied, failed}.
A live file other episodes read (2026-09-23)
With a series config per episode and../refs/... paths, one live file serves the whole
show: in Porchlights ep01 all 110 of its refs are read by the other nine episodes. So
“this is shared” is not news; who wrote the copy that is there now is. Two fields per ref in
GET /seriesmith/refs:
shared_with: the other episode folders whose series config resolves a ref to the same absolute file ([]for the one-folder layout). Computed from the siblings beside the episode — a sibling with a series config of its own or sharing the one above, and a script.live_owner: the episode whose pick recorded the sha1 the live file has now — this one, a sibling, ornull. Null means nobody’s record matches it: it was put there by hand or replaced outside the editor, so no candidate anywhere can put it back.
live_owner is a sibling, GET /seriesmith/refs/owner-takes?ep=…&ref=… gives that
episode’s candidates for the ref: {"owner", "takes"}, each a take as takes lists them
plus episode and picked (its pick), the image paths relative to this episode
(../ep01/refs/_takes/...). Read-only: each episode keeps its own refs/_takes/, so this
is what shows which candidate this episode is reading. The editor asks when the ref’s row
opens, so the listing never reads every sibling’s candidates; takes is [] when this
episode owns the file, or nothing does.
A character also says panels: 4 (its sheet is the four views) or 1 (sheet_panels: 1,
one whole picture of them), so a list shows the whole picture rather than a face cropped
from it; and panels_conflict when it says 1 but its picture is a strip of panels
(2.5:1 or wider, refs.is_strip): marked by mistake, and renders send the whole strip.
What touches a shared file, and what doesn’t:
refs.shared_context(ep) builds both maps in one pass and is memoised on the files it read
(each episode’s series config and _picks.json, by path, mtime and size), so a listing pays
for it once: measured on that 10-episode show, 0.44 s cold and 4 ms warm against a 2.7 s
listing. A neighbour’s pick or a new sibling shows up on the next call, with nothing to clear.
Two episodes that picked byte-identical files share one sha1, and this episode wins it —
arbitrary, and moot, since the file they would overwrite is the same file.
Nothing refuses. The routes behave exactly as before; reporting is the contract and the
caller decides. The editor confirms before a pick over a file it doesn’t own and before any
clear of a shared file (web/src/lib/shared.ts holds that rule, with tests), and
seriesmith/kreagen.py --clear refuses a shared live file without --yes.
Keyframe polish
- The keyframe prompt states the framing more strongly (a close-up must fill the frame with the face; the size word is repeated in the “Drawn as” sentence and as a closing line).
- A single-reference edit target (
max_refs: 1, Kontext) gets one composed reference: the characters’ reference panels pasted over the plate (side by side, bottom-aligned, about two thirds of the frame height), made byh3_refsheet.pyand uploaded like any reference; the sidecar’sreferencesrecordsrole: "composite"with its parts.
Phase 8.6 as built
Everything above is implemented. As for 8.5, each point is [differs] (the contract said otherwise), [added] (the contract said nothing) or [settled] (the contract left it open). Nothing here changes a field the contract named. Discarding- [settled]
POST /seriesmith/discard:passdefaults toproxy, as on every route; the response also carriespass.movedlists the files’ new paths (under_trash/), relative to the episode. What moves: the sidecar, every file sharing the take’s stem (.mp4,.jpg,_strip.jpg,.shotlist.json,_h3.wav, a kept_reference.pngor sheet), and any other file the sidecar names in the shot folder. 404 for no such take, 409 for a queued one (“cancel it first”). - [settled] The cut: every
cut.jsonentry, in either pass’s list, whose take comes from the discarded pass and names that take loses itstake. A placeholder keeps itspass, so it falls back to the other pass’s latest usable take.cut_changedsays whether anything was dropped. - [added] Numbers aren’t reused. The next take of that shot (or ref, or view) skips
every number still in the trash, so
t03is never two different takes. If a name is already in the trash anyway (an explicit--take Nre-render discarded twice), the whole set goes into_trash/<shot>/<YYYYmmdd-HHMMSS>/. Nothing is overwritten. - [added]
POST /seriesmith/refs/discardneedsviewfor a character (else 400), and is 404 for no such take and 409 for a queued one.seriesmith.refcarries statusdiscarded, thenclearedif it was the pick, thenseriesmith.episode. A voice’s take is only moved: voices aren’t cleared, so a picked voice keeps its live file. - [settled]
_trash/is never listed. Video takes are listed per shot folder, sorenders/_trash/is never read. Ref takes live underrefs/_takes/<key>/, and the sweep of queued ref takes skipsrefs/_takes/_trash/. - [added] CLI:
seriesmith discard <ep> <shot> <take> [--proxy]accepts3ort03.kreagen --discard REF[:VIEW]:TAKEis repeatable and acceptslocation:kitchen:3,subject:ada:02_side:t2orshot:sh020:first:1.kreagen --clearand--discardno longer needrefs_todo.json.
-
[settled] Which series refs count as missing: those this episode uses in
pass(used_by, which is what the Refs tab’s plan used), with no live file and not cleared. A ref is skipped if any of its candidates is queued, or has finished and waits to be picked (auto-pick takes it on the nextGET /seriesmith/refs). This is the UI’sinFlightrule. A character missing all four views gets one generate withview: null(the four share a seed). Otherwise each missing view is queued alone, and a skip for one view carriesview. A ref that can’t be generated (a voice) is inskippedwith the reason. -
[settled]
passdefaults toproxy, like/refs/keyframe. It sets both the cut that continuity reads and the render size a keyframe still is made for, asseriesmith keyframe --missingdoes.kindsdefaults to both kinds. -
[settled] Keyframes: a keyframe is filled only if it is required or asked for by the
script, and it must have no live file and not be cleared (
missing_keyframes, shared with the CLI). It is skipped if a candidate is queued or waiting.- Continuity: the frame is cut, and the result goes into
picked(it is always picked, since the keyframe had no live file). If the frame can’t be cut (no usable take, or a bad source), a still is queued instead. If ffmpeg is missing, the keyframe goes toerrors. - Still: it goes into
queuedwithmethod: "generate", andtargetis the image target. - Script path: the file is imported and picked (
method: "import").
refs.keyframe_plan,import_keyframeandmissing_keyframes. The CLI waits for each still; the route doesn’t. - Continuity: the frame is cut, and the result goes into
-
[added]
queued[]entries also carryseed_source. In a dry run,seedis null when a new random seed would be drawn, and continuity and import entries appear inpickedwithtake: null.errors[]arequeue_generate’s, so they includemissing_fileswhen a model isn’t installed. -
[added] Events:
seriesmith.refwith statusqueuedfor each queued candidate, thenokandpickedfor each picked one, thenseriesmith.episode. A dry run emits nothing. -
[added] Errors: 404 when
passhas no build. 400 for a badkinds, atarget/keyframe_targetthat isn’t an image target, or adry_runthat isn’t a boolean.
- [settled] The aiohttp adapter streams the
filepart to a temporary file, never holding it in memory, and deletes it after the handler. The handler takes it through the sameimport_takeas the JSON form. 413 comes either from aContent-Lengthover 65 MB (answered before anything is read), or from passing 64 MB while streaming. 400 covers an extension the ref can’t use (the type is judged by the upload’s file name), afilefield that isn’t a file, and a body that is neither JSON nor multipart. - [added] Ref-take sidecars and take entries carry
original_nameonly for uploads; an upload’s sidecar hassource_path: null. The form also takesnote. - [settled]
pick: a form sends"1"/"true"/"yes"/"on"or"0"/"false"/"no"/"off"/""; JSON sendstrue,falseor null. When picked, the events areokthenpicked, and the returned take is re-read after the pick. A stitch failure is 500, as onPUT /seriesmith/refs/pick.
-
[settled] The wording (
seriesmith/targets/image/common.py): after “The first/last frame of a <size> of <place>, one still picture.” comes a framing sentence:- close-up: “Framing: a close-up. Ada’s face fills the frame, large: from the chin to the top of the head, cut off at the shoulders. No full body, no wide view of the room.” With several characters it reads “Ada and Bo’s faces fill”; with only props, they fill the frame; with nothing, one detail of the place;
- medium shot: ”… seen from the waist up, filling most of the frame’s height” (no sentence without characters);
- wide shot: “The whole place in view, … small and full-length in it.”
-
[settled] When a composite is made: for a target whose
max_refsis 1, when more than one reference image is on disk.- Parts: the figures (characters, then props and vehicles, at most 4), then the plate. Without a plate, the figures go on white. With one image only, that image is sent as it is.
- Layout: the composite is the keyframe’s generation size. The plate covers the frame. The figures sit side by side, centred, standing on the bottom edge, two thirds of the frame tall (scaled down together if they’d be wider than the frame less 3% each side), 2% of the width apart. Each is pasted as a rectangle, background included.
-
[added] The composite is kept at
refs/_takes/<key>/_composites/<sha1>.png.- Sidecar: it records
{role: "composite", kind, name, path, sha1, parts: [{role, subject | location, name, kind, view?, crop?, path, sha1}]}. - Listing: a keyframe’s
edit_refsshows{id: null, role: "composite", path: null, name, parts: [...]}. There is no file until a generate composes one. - Prompt: “The image shows Ada, Bo, and the diner kettle in front of the background (kitchen): draw each of them exactly as in the image (the same face, body, clothes and colours), and use its background’s setting, layout, colours and light for the scene. The image is only a reference collage: pose and frame each of them for this shot.”
- Sidecar: it records
-
[added] Without PIL: composing runs
seriesmith/helpers/refsheet.pyas a subprocess of the running Python, as the ingredients sheet does. Under ComfyUI (the routes) that Python has PIL, and so does a CLI whose Python has it.- Fallback: when there is no PIL, the job sends one part alone. That is the first figure not cut from a sheet, else the plate, else the first figure.
- The prompt is rewritten for that one image, unless it was typed or overridden.
- The sidecar’s
notessay so: “couldn’t compose one reference from N (…); sent Ada alone”.
Phase 9a: script and series config windows, promote (contract written before building, 2026-09-19)
The editor can now show and edit the episode’s two authored files and move overrides into them. This replaces the old rule “the editor never writes series.json”: it writes it only through these routes, only on an explicit save or promote, and always keeps the previous version. Backend and UI build against this in parallel; the backend writes “Phase 9a as built” with [differs]/[added]/[settled].Reading and checking
GET /seriesmith/source?ep=…&file=script|series→{"file", "path"(relative to the episode;../series.jsonfor a parent-folder series config),"text", "hash"(sha1 of the bytes),"mtime", "shots": [{"id", "line", "end_line"}](script only; from the parser’s source spans, empty if it doesn’t parse)}.&hash_only=1returns just{file, hash, mtime}(the UI polls this on focus to notice edits made outside ComfyUI).POST /seriesmith/source/check{ep, file, text}: parses and checks without writing. A script is checked against the series config on disk; a series config is checked on its own (valid JSON, loads) and then with the script on disk. Returns{"ok", "errors": [{"file", "line", "col"?, "message"}], "warnings": [{...same}], "shots": [...]}— the same messagesseriesmith checkprints, with 1-based line numbers into the given text. A JSON syntax error carries its line/col.POST /seriesmith/shot/preview{ep, pass, text, shot}: the shot form’s preview.text(the script with one shot as edited) is parsed and compiled forpassin memory; nothing is written. Returns{"ok": true, "shot", "target", "target_label", "prompt"(what the model is told, one string),"negative", "frames", "fps", "seconds", "width", "height", "audio_policy", "subjects", "background", "pacing", "warnings"}.pacingis null without dialogue, else{"held", "syllables", "rate", "fits", "verdict"(ok | tight | CRAMMED),"pace", "pace_rate", "ceiling"}— the numbersseriesmith build --paceprints;warningsare the build’s that name this shot. A script or build that refuses it is{"ok": false, "error", "line"}(200: the form shows it).
Saving
PUT /seriesmith/source{ep, file, text, base_hash, rebuild: true}:- 409
{"error": "changed on disk", "hash", "text"}if the file’s hash isn’tbase_hash(edited outside meanwhile). The UI offers reload or overwrite (overwrite = resend with the newbase_hash). - A series config that isn’t valid JSON is refused (400 with line/col). A script with errors is saved (it’s the user’s file), and the check result comes back.
- Before writing, the old file is copied to
<ep>/_history/<name>.<YYYYmmdd-HHMMSS>(the newest 30 per file are kept). The write is atomic (temp file + replace), keeping the file’s existing line endings and encoding (UTF-8, BOM kept if present). rebuild: trueruns the same build asPOST /seriesmith/buildafter a successful write.- Returns
{"hash", "check": {...as above}, "build": {...as /build} | null}and emitsseriesmith.episode.
- 409
Promote
Moves overrides that the authored files can express into them, then drops those overrides. What can’t be expressed stays inoverrides.json, with the reason.
GET /seriesmith/promote?ep=…[&shot=…]→ a plan:{"items": [{"id", "scope": "shot" | "episode" | "ref", "shot"?, "ref"?, "view"?, "field", "value", "dest": "script" | "series", "line"? , "summary"}], "left": [{"scope", "shot"?, "ref"?, "field", "reason"}], "diffs": {"script": "<unified diff>", "series": "<unified diff>"}, "hashes": {"script", "series"}}.POST /seriesmith/promote{ep, items: [ids] | "all", hashes: {script, series}}: 409 as forPUT /seriesmith/sourceif either file changed since the plan. Writes the files (with_history/copies), removes exactly the promoted override fields, rebuilds, and returns{"promoted": [ids], "left": [...], "hashes", "build"}; emitsseriesmith.episode(andseriesmith.reffor promoted ref overrides).- What maps where (the backend confirms each against the parser and the series config
loader, and lists anything else under
left):- Shot
target→ atarget:line in that shot’s block. - Shot
model/loras/steps→model:/lora:/steps:lines, only when the script line would mean the same thing (e.g. the value is the same in both passes, or the script line only affects the pass the override is for); elseleftwith the reason. - Shot
prompt(compiled text),seed,negative,note→left(compiled text has no script form; a seed is kept by picking the take; a negative is per target/pass). - Episode
target→ the series config’s series-wide target;refs_target/keyframe_target→ itsrefsblock. - Ref overrides → the matching subject/location/view fields of the series config where
the loader has one (design sentences, per-view prompts,
target); elseleft.
- Shot
- Script edits are line-level: an existing
key:line in the shot’s own block (not the sequence header’s) is replaced, else a new line is inserted after thekey:lines that open the shot’s block (or right after## shot), never below its dialogue. Nothing else in the file changes. Series config edits rewrite the JSON with 2-space indent, keeping key order and non-ASCII text; if the file wasn’t already formatted that way the diff says so.
Phase 9a as built
Everything above is implemented (seriesmith/source.py: read, check, save; seriesmith/promote.py: the
plan and apply, and seriesmith promote). Each point is [differs], [added] or
[settled], as for 8.6. No field the contract named changes shape except where marked
[differs].
Reading
- [settled]
textalways has LF line endings and no BOM;hashis the sha1 of the bytes on disk (BOM and CRLF included), so it is whatbase_hashmust be.mtimeis seconds since the epoch (a float,os.stat). A file that isn’t UTF-8 is a 500 naming the byte. - [settled]
shotscomes from the parser against the series config on disk; it is[]when either doesn’t parse.pathis../series.jsonfor a parent-folder series config, as the contract said. An unknownfileis 400; no script (or no way to tell which.mdit is) or no series config is 404.
- [settled]
errors/warningsentries are{"file": "script" | "series", "line": <1-based> | null, "message"}, plus"col"for a JSON syntax error only. A parser error gives its own line, andmessageis its text without theline N:prefix and the echoed| line(the line is inline). A build error or warning about a shot (shot sh020: …,sh020: …,sh040 overrides steps: 10) points at the line the message is about when it names one in that shot’s block (target:,profile:,dur:,retention:,policy:,plate:,with:,model:,lora:,steps:), else at the shot’s##line;sequence sq04: …at the#line. A series config message points at the key it names (profile 'x',refs.x,series.x,proxy.x, a missing block’s name) and isline: nullwhen it names none (e.g. “series.json has nosubjectsblock”). - [settled] The check is
seriesmith check’s: parse, pick targets, compile the final pass of every target the episode uses (build.compile_groups, which the build now uses too), in memory. Warnings are the report’s!lines, each once. The build stops at its first error, soerrorshas at most one entry.--paceisn’t repeated: its CRAMMED/tight verdicts are already warnings. - [settled] Checking a series config: JSON, then the loader; if it loads, the script
on disk is checked with it, and those messages carry
"file": "script"and lines into the script on disk.shotsis[]forfile: series.
- [settled] Line endings: the file keeps the ending most of its lines use (CRLF or LF; a mixed file comes out uniform) and its BOM if it had one. A BOM at the start of the sent text is dropped (the file’s own rule decides).
- [settled] Text that would write the same bytes writes nothing (no history copy, the
hash unchanged);
checkstill comes back, andbuildtoo whenrebuildis true. - [settled] History:
<ep>/_history/<file name>.<YYYYmmdd-HHMMSS>, with-2,-3… when a copy that second exists; the newest 30 per file name are kept. A parent-folder series config’s copies also go in the episode’s_history/(the episode finder skips_folders). - [settled] The JSON refusal is 400
{"error", "line", "col"}and writes nothing. A series config that is JSON but doesn’t load is saved, with the check’s errors. - [added] The 409 body also has
"file"(which file changed).rebuilddefaults to true;base_hashis required (400 without it). - [settled] The write is a temp file beside the file, then
os.replace(retried briefly while Windows reports the file busy).
- [settled] Item ids:
shot:<shot>:<field>(target,model,loras,steps),episode:<field>(target,refs_target,keyframe_target) andref:<ref id>:prompt.valueis the override’s value (lorasis the list);summaryshows the line or key as it will be written, e.g.sh030: `lora: x.safetensors:0.5` (the override of both passes).line(script items) is the line in the promoted script (every item of the plan applied). - [differs] A character’s prompt is one item, not one per view: the design sentence
is every view’s, so it promotes only when all four views have a prompt override and all
four change the design the same way; the item has no
view. (Props, vehicles and locations have one prompt and one item.) - [added]
leftentries may carry"pass"(a per-pass field left in one pass: a prompt, a negative,model_low) and"ref"/"view"for ref overrides. - [settled]
diffs.script/diffs.seriesare""when that file doesn’t change. When the series config wasn’t already in the promote’s format (2-space indent, key order and non-ASCII kept; e.g. the fixture’s one-line arrays),diffs.seriesstarts with one line,# series.json wasn't formatted with a 2-space indent: promoting rewrites the whole file that way, before the---/+++header. - [settled]
&shot=narrows the plan to that shot’s overrides: no episode or ref items. - [settled] A script or series config that doesn’t parse, load or build is 400 (fix it first): a plan needs both.
- Shot
target(the retarget) →target: <id>in the shot’s block. - Shot
model/loras/steps, from the override block of the target the shot renders on now →model:/lora:/steps:. A script line sets both passes (the compilers layer it above the pass preset for final and proxy alike), so the item needs the same value in both passes: both overridden alike, or one pass overridden and the other already rendering that value.loras→lora: name(strength 1),lora: name:0.6, orlora: nonefor[]; a stack of two or more isleft(a script line holds one LoRA: use a profile’sloras). - left, always, for shots:
prompt(compiled text),seed(keep it by picking the take),note,negative(per target and pass),model_low(no script line); and every field of an override block written for a target the shot doesn’t render on now (a script line would give it to the other model). - Episode
target→ the series config’sseries.target, unless itsseries/proxyblocks setmodel,loraorstepsfor the old series target: as the series target, the new one would inherit them (Target.presetapplies the pass blocks to the series target only), so it isleft, naming the keys. - Episode
refs_target/keyframe_target→refs.target/refs.keyframe_target. - A ref’s
prompt→ the subject’sdesignor the location’sdescription, when the override is the built prompt with only that sentence replaced (it occurs once in the built prompt, and the override keeps everything around it); a character needs all four views, agreeing (above). The summary lists the shots that show it: their compiled prompts use the same sentence and change too. - left, always, for refs:
seed,model,loras,steps,note,target(the series config has no per-ref settings;refs.targetis every ref’s), a keyframe’s prompt (written from its shot) and a voice’s. - The check (every plan): the promoted files are parsed and compiled in memory, and
every shot must render with the same target, model, LoRAs and steps in both passes as it
does now (jobs’ precedence: the shot compiled for its effective target, then the
override for that target), and each promoted line must parse back as written. Each item
is checked alone, then all together; one that fails goes to
leftwith what would change (sh010 would render differently (final: steps 9 → 8)). A ref item is checked by generating its prompt from the changed series config with no override: it must equal the override’s text, in every view.tests/test_phase9a.pyasserts the end-to-end version throughjobs.plan_jobon disk, before and after a promote.
- [settled]
itemsmust be ids the plan (for the sameshot, if given) offers; an unknown id is 400 (“ask for the plan again”). The chosen subset is checked again on its own; anything that no longer passes comes back inleft, unpromoted. - [added] An optional
shotin the body narrows the plan as&shot=does. - [settled] 409 is
{"error": "changed on disk", "file", "hash", "text"}for the first file (script, then series) whose hash isn’t the plan’s; nothing is written.hashesis required (400 without it). - [settled] Order: the script, then the series config (each only if it changes, each
with a
_history/copy), thenoverrides.json(the promoted fields in both passes of that target’s block, the retarget, the episode fields), then<series home>/refs/_overrides.json(the promoted prompts and theirbase_hash), then the build.hashesin the answer are the files’ new hashes;buildis null when nothing was promoted. - [added]
seriesmith.reffor a promoted ref override has"status": "promoted",view: null,take: null;seriesmith.episodefollows any promote. - [settled] Known side effects: a take rendered with a promoted model/LoRA/steps
override shows
presetstale afterwards (a take’spreset_hashis of the built values, which now include the promoted ones), although a render uses the same values. A promoted design changes the compiled prompts of the shots that show that subject or location (listed in the summary), so their takes goscriptstale. An episode item whose series config is shared by a parent folder says so in its summary: every episode that uses it changes.
- [added]
seriesmith promote <ep> [<shot>]prints the plan (items,leftwith reasons, both diffs) and writes nothing;--allor--item ID(repeatable) promotes and rebuilds;--dry-runshows the plan even with those.
Phase 9b: timeline editing (contract written before building, 2026-09-19)
cut.json already holds order, trims and locks, PUT /seriesmith/cut writes them, and assemble
and Play all honour them; the editor just never wrote them. 9b adds the editing, the
waveforms, and play-through polish. Backend and UI build in parallel; the backend writes
“Phase 9b as built”.
Cut edits (backend)
PUT /seriesmith/cut(as before) also: 400 for a negative or non-integer trim, and for trims that leave less than one frame of a take whose frame count is known; the previouscut.jsongoes to<ep>/_history/cut.json.<stamp>(newest 30), as source saves do. Emitsseriesmith.episode.POST /seriesmith/cut/reset{ep, pass, what: "order" | "trims" | "all"}: script order (picks, locks and notes kept) and/or zero trims. Returns{"cut"}.POST /seriesmith/cut/copy{ep, from, to, what: "order" | "trims" | "all"}: copies one pass’s order/trims onto the other (trims converted by frame rate when the passes’ fps differ; picks never copied). Returns{"cut"}.GET /seriesmith/episode: each shot’scutalso carriesorder(its index in the pass’s cut),script_index, andout_of_order(true when it’s not where script order would put it); top level addstrack:{"path", "duration", "rate"}or null (the series config’s recorded dialogue,audio.track), and each shot with a dialogue window carriesaudio_in/audio_out(seconds on that track) if it doesn’t already.lockedentries:PUT /seriesmith/pickanswers 409 for a locked shot unlessforce; the UI also refuses to move, trim, leave out or put back them.- CLI:
seriesmith cut <ep> [--proxy] (--show | --order sh010,sh030,... | --move SH (--before| --after) SH | --trim SH IN OUT | --lock SH | --unlock SH | --out SH | --in SH | --flag SH | --unflag SH | --reset order|trims|all | --copy-from final|proxy [order|trims|all]).
Waveforms (backend)
GET /seriesmith/peaks?ep=…&path=…&bins=N[&start=S&end=E]:pathis a media file relative to the episode (a take’s mp4 or wav, or thetrack). Returns{"duration", "bins", "peaks": [0..255, ...]}— max absolute amplitude per bin, mono, overstart..endseconds (default the whole file);peaks: []and"silent": truefor a file with no audio stream. Computed once per file at 200 bins/second (stdlibwavefor PCM wav, elseffmpeg -ac 1 -ar 8000 -f s16le), cached in<ep>/_cache/peaks/<sha1 of path+size+ mtime>.json, then resampled tobins. 404 for a missing file, 400 for a path outside the episode (and its parent-folder series config’s folder).- Takes in
GET /seriesmith/episodecarryaudio: the file whose sound the cut plays for that take (the mp4 if it has an audio stream, else its_h3.wav, else null) — the same rule as assemble’s--audio auto.
Timeline (UI)
- Reorder: drag a clip between clips (drop marker; Esc cancels); Alt+←/→ moves the selected clip. Clips can move across sequences; an out-of-order clip gets a badge. Menu: “Reset order”, “Clear trims”, “Copy order/trims from the other pass”.
- Trims: drag a clip’s left/right edge (frame-snapped; tooltip shows frames and seconds; at least one frame stays); I / O set trim-in / trim-out at the playhead while Play all is on that clip; numeric trims in the Inspector’s cut section. The thumbnail strip shows the trimmed part dimmed.
- Undo/redo (Ctrl+Z / Ctrl+Shift+Z) of cut edits for the session; each edit is one
PUT /seriesmith/cut. - Locked clips show a lock and refuse moves, trims and re-picks (unlock from the menu).
- Flags: M flags (or unflags) the clip on screen in Play all, which keeps playing, or the selected clip; Shift+M, or the timeline’s “N flagged” count, goes to the next one. A clip with open issue notes shows a note icon.
- Play-through: click or drag in the ruler to seek (starts Play all paused there if it
isn’t open); Space play/pause; J / K / L; the playhead follows. With a
track, an “Audio: clips | recording” toggle plays the recording under the cut instead (the clip audio muted), asassemble --audio masterdoes; it warns when trims or reordering make the recording drift. - Waveforms: a toggleable lane under the clips: each clip’s own audio (
take.audio), or, for a shot with a dialogue window when the audio toggle is on “recording”, its slice of the track (audio_in..audio_out, trims applied). Peaks are fetched per clip at the zoom’s resolution and cached.
Phase 9b as built
The backend parts above are implemented (edit: the cut edits, the status fields and
seriesmith cut; seriesmith/peaks.py: sound detection, durations and peaks; the routes stay thin).
Each point is [differs], [added] or [settled], as for 9a. No field the contract
named changes shape.
PUT /seriesmith/cut
- [settled] The one-frame check is against the take the entry would use (its
takefrom itspass, else the latest usable one), counting itsframes(the sidecar’s) in the pass’s cut frames; a take at another rate (Wan 14B’s 16 fps) counts by duration,round(frames × cut fps / take fps). A take whose length isn’t known (nothing rendered, a picked take that isn’t usable, a sidecar withoutframes) isn’t checked. A shot with a dialogue window counts from the window’s frames (the smaller of that and the take’s), as assemble cuts to the window before trimming, so it is checked even before it has a take (changed after merging: first built without the window). The 400 names the shot. A float, a string or a boolean trim is 400 too. - [settled] History:
<ep>/_history/cut.json.<YYYYmmdd-HHMMSS>(-2,-3… within a second), the newest 30, through the same helper as source saves. A write that would change nothing writes nothing and makes no copy; there is no copy when there was nocut.json. PUT, reset, copy andseriesmith cutmake copies; picks and discards don’t (they would push the edits out of the 30). - [settled] The server doesn’t refuse moving or trimming a locked entry in a PUT (the UI
does;
seriesmith cutrefuses without--force).
POST /seriesmith/cut/reset and /cut/copy
- [settled] Both write the pass’s full list (every shot named, as a pick does), answer
{"cut"}and sendseriesmith.episode. 400 for a badwhator pass, orfrom=to; 404 when a pass they touch isn’t built. - [settled] Reset order is the script order of the pass’s build (every target’s shotlists); orphans go after the script’s shots, in their current order.
- [differs] A locked entry keeps its trims on “trims” / “all” resets and on a copy of trims: the lock protects them as it protects the pick. Its place in the order still changes.
- [settled] Copy order: the target pass’s entries sorted by their place in the source pass’s cut; a shot only the target has stays right after the entry it follows now. Picks, placeholder passes, locks and notes stay the target’s own.
- [settled] Copy trims: each pass counts in assemble’s cut rate (the series config’s
series.fps, else that pass’s shotlist’sdefaults.fps, else 24), so they differ only when the series config sets nofps; trims are thenround(trim × to fps / from fps). Trims that would leave less than one frame of the target’s take (when its length is known) are cut down,trim_outfirst, rather than refused.
GET /seriesmith/episode
- [settled]
cut.orderis the 0-based index in the pass’s resolved cut (orphans included);cut.script_indexthe index in the pass’s script order (every target’s shots), null for an orphan.cut.out_of_orderis true for the entries outside the longest run that is in script order: moving one shot flags just that shot; swapping two neighbours flags one of them. Orphans are never flagged. - [added]
track.exists.trackis{"path", "duration", "rate", "exists"}from the series config’saudio.track(relative to the episode, asalignwrites it).pathis relative to the episode with forward slashes (../audio/x.wavabove it; absolute on another drive, which/seriesmith/peakscan’t serve).duration(seconds) andrate(Hz) are null when the file is missing or unreadable (an empty placeholder).trackis null when the series config names no track. - [settled]
audio_in/audio_outare the build’s window (the shotlist entry’s), in seconds on the track; a shot with no window has neither key. - [settled]
take.audiois a path relative to the episode, or null. Whether an mp4 has sound is read from its boxes (moov/trak/mdia/hdlrof typesoun), not ffprobe, and cached per (path, size, mtime) for the process; an empty or unreadable mp4 counts as silent. assemble now uses the same functions (peaks.clip_audiofor--audio autoandmp4,peaks.has_audio), so its check moved from ffprobe to that box read too (ffprobe remains the fallback for files that aren’t mp4/mov/m4a or wav).
PUT /seriesmith/pick
- [added] The locked 409’s body also has
"locked": true; its message says to unlock or force. The lock is per pass (a final lock doesn’t stop a proxy pick).force: truepasses both the not-usable and the locked 409s. A discard still clears a locked entry’s pick of the take it discards (the take is gone).
GET /seriesmith/peaks
- [added]
start/endin the answer: the range actually used (clamped to the file). A silent file answers{"duration", "bins": 0, "peaks": [], "silent": true, "start", "end"};durationis the container’s (null if nothing can tell). - [settled]
binsis optional: the default is the cached resolution over the range (200 a second). 1 to 100000, else 400.start/endare optional, ≥ 0, clamped to the file;end≤startis 400. Each output bin is the max of the cached bins it overlaps; with more bins than the range has, a bin repeats the nearest one. - [settled] Scale:
round(|sample| / 32767 × 255). The stdlib path (PCM wav, 8/16/24/ 32-bit integer) takes the max over the channels; ffmpeg’s path is the-ac 1mix. A float wav, or any wavwavecan’t open, goes through ffmpeg. - [settled] No audio stream is judged from the mp4 boxes or the wav header, else ffprobe; with no ffprobe, a file that is neither counts as silent. A file with sound and no ffmpeg on PATH is 500 naming ffmpeg; an ffmpeg failure is 500 with its message.
- [settled] Cache:
<ep>/_cache/peaks/<sha1 of "path|size|mtime_ns">.json, wherepathis the requested path with forward slashes and no empty or.parts. It holds{"version": 1, "path", "duration", "rate": 200, "silent", "peaks": <hex, one byte a bin>}. Old entries aren’t pruned. An episode folder that can’t be written still gets its answer. - [settled] Paths: 400 for a path that is absolute or climbs out, and also for one that
leads out through a link (
/seriesmith/fileanswers 403 there; the contract said 400 here).../is allowed into a parent-folder series config’s folder, as for/seriesmith/file. 404 for a missing file.
seriesmith cut
- [settled] With no action it shows the cut (
--show): order, take, trims, frames and flags (locked,picked,placeholder(pass),OUT OF ORDER,orphan). After each edit it shows the cut again. - [settled]
--order SH,SH,…puts those shots first, in that order; the rest follow in their current order.--copy-from PASS [WHAT](defaultall) copies onto the pass--proxypicks (final without it).--moveand--trimrefuse a locked shot without--force. A refusal exits 1, a usage error 2.
Phase 9c: audio — a recording from the editor, and generated voice refs (contract written before building, 2026-09-19)
Two halves, built in parallel. A: recordings. The timeline’s “Audio: clips | recording” toggle only works for an episode whose series config setsaudio.track, and nothing in the
editor can set one; seriesmith/align.py already attaches a recording and writes the audio: windows
from the command line. B: voice refs. Voice references are import-only today. ComfyUI can
generate speech locally with the installed LTX-2 audio models (LTXVAudioOnlyModel,
LTXVAudioOnlyEmptyVideoLatent, LTXVEmptyLatentAudio, LTXVReferenceAudio,
LTXVAudioVAEDecode, both audio VAEs), so a voice ref becomes a generated take like any
other. Each half writes its own “as built” section.
A. Attaching and aligning a recording
GET /seriesmith/align/ready→{"ready", "ffmpeg", "missing": ["faster-whisper", …], "python", "install": "…pip line…", "models": {"default": "medium.en"}}.alignneeds ffmpeg, numpy and faster-whisper (or openai-whisper); none of the whisper packages is installed on this machine, so the editor must say what to install and where (the Python that runs the pipeline).POST /seriesmith/track{ep, source_path}or multipart (file, as/refs/import): puts the recording at<ep>/audio/<name>(an existing file is used in place whensource_pathis already inside the episode), sets the series config’saudio.trackandaudio.mode: "source_track"through the Phase 9a save path (history copy, atomic write), rebuilds, and returns{"track": {…asGET /seriesmith/episode's…}, "hash", "build"}.{ep, track: null}clears it (the mode goes back to what it was, orgenerate).POST /seriesmith/align{ep, track?, model?: "medium.en", snap?: true, dry_run?: false}: runsalignin a subprocess. 409{"missing": [...]}when a dependency is missing. Progress arrives asseriesmith.align{"ep", "stage": "transcribe" | "match" | "write", "pct", "text"}. Returns{"ok", "report", "changes": [{"shot", "audio_in", "audio_out", "note"}], "script_hash", "series_hash", "build"}.dry_runwrites nothing.align’s.bakfiles are replaced by the Phase 9a_history/copies, so the editor and the command line keep one history.GET /seriesmith/episode:trackgainswords(whether a cached transcript sits beside the recording) andaligned(how many shots carry a window).
B. Voice refs, generated
- A new target kind,
audio(seriesmith/targets/audio/<id>/), built exactly like the image targets:target.json(label, capabilities, models with tiers, presets, downloads), binding, prompt writer, workflow,check_graphagainst/object_info, family-based file resolution, readiness.GET /seriesmith/targets?kind=audio[&ready=1];capabilitiesis{"mode": "t2a" | "voice_clone", "reference_audio": bool, "max_seconds"}.- First target:
ltx2_voice— LTX-2 audio-only, withLTXVReferenceAudioused when the ref has a sample to copy (identity_guidance_scalea preset value); thebasepreset is the dev model, the accelerator preset the distilled one.
- First target:
POST /seriesmith/refs/generateon a voice ref queues a candidate wav, like an image candidate:{ep, ref: "voice:bolt", count, seed_mode, seed, prompt, target, note, seconds?}. The default prompt (seriesmith/targets/audio/common.py voice_prompt) is the character’s series configvoiceline, their design in one sentence, and a line to say: the character’s longest line in this episode’s script, else a fixed neutral sentence; the sidecar records the sentence. The take is a wav underrefs/_takes/voice:<id>/, saved by a newH3SaveRefAudionode (likeH3SaveRefTake), andseriesmith.refreports it as usual.- Picking a voice candidate copies it to the series config’s
voice_samplepath for that character; when the character has none, it writesrefs/voices/<id>.wavand setsvoice_samplein the series config through the 9a save path (history copy), saying so in the response:{"ref", "path", "series_changed": true}. POST /seriesmith/refs/voice-from-take{ep, ref, shot, take, pass, start, end}: cutsstart..endseconds out of that take’s audio (peaks.clip_audiopicks the file) into a new candidate withsource: "from_take", recording the shot, take and span. This is the no-model path: a line the model already spoke in a take becomes the voice sample.- Defaults:
PUT /seriesmith/refs/defaultsandGET /seriesmith/refs’sdefaultsgainvoice_target/voice_target_source; the series config’srefs.voice_targetand a per-reftargetoverride layer as for images. - CLI:
kreagen(andseriesmith refs) generate voice refs too, with--voice-targetand--from-take SHOT:TAKE:START-END. - UI: a voice ref row gets Generate (count, prompt, seconds), a candidate list with a
player and the waveform (
GET /seriesmith/peaks), pick / clear / discard / upload as for images, and “Use a line from a take…” — pick a shot and take, drag the span on its waveform, preview, then save it as a candidate.
Phase 9c-A as built
Part A above is implemented:seriesmith/track.py (the readiness check, the recording, running
align), seriesmith/align.py writing through source, edit.track_info and three thin routes.
Each point is [differs], [added] or [settled], as for 9a and 9b. No field the
contract named changes shape except where marked [differs].
GET /seriesmith/align/ready
- [settled]
{"ready", "ffmpeg", "missing", "python", "install", "models"}as the contract wrote them.ffmpegis the pathshutil.whichfinds (null when it isn’t on PATH);missinglistsffmpeg,numpyandfaster-whisperby their pip names, in that order;installis the pip line for the missing packages only, quoting the interpreter ("…\python_embeded\python.exe" -m pip install faster-whisper), and""when only ffmpeg is missing. - [settled] The interpreter is
sys.executable: inside ComfyUI that is its embedded Python, which is also what runs align (edit.run_tool’s rule), so the answer is about the Python that would really transcribe. Either Whisper counts:openai-whisperalone makes it ready. - [added]
packages{"numpy", "faster-whisper", "openai-whisper"}: the installed version (""when the package is there but has no metadata), null when it isn’t installed. [added]ffmpeg_hint(a line to show when ffmpeg is missing) and [added]models.choices(the model names align takes) besidemodels.default. - [settled] The probe runs
importlib.util.find_specandimportlib.metadata.versionin a subprocess of that interpreter, so it sees what align will; the answer is cached for 30 seconds per interpreter (track.PROBE_TTL), and if the subprocess can’t run at all this process’s own view is used instead. Nothing is imported, so the check is cheap and can’t crash on a broken install.
POST /seriesmith/track
- [settled]
{ep, source_path}(a file on this machine) or multipartfile, as/refs/import: overMAX_UPLOADis 413, afilein a JSON body is 400. The recording lands at<ep>/audio/<name>; asource_pathalready inside the episode is used where it is (copied: false), anywhere in the episode, not onlyaudio/. - [settled] The series config’s
audio.track(relative to the episode, forward slashes) andaudio.mode: "source_track"are written through the Phase 9a save path — a copy in<ep>/_history/, an atomic write, the file’s line endings and BOM kept — then the episode is rebuilt andseriesmith.episodegoes out. - [added] The answer is
{"track", "hash", "build", "path", "copied", "reformatted"}:pathis the recording’s path relative to the episode,copiedwhether it was copied in,reformattedwhether the whole series config was rewritten in the promote’s format (2-space indent, key order kept) because it wasn’t in it. [added] An optionalpass(defaultproxy, as everywhere) picks which passtrack.alignedcounts. - [added] The name is the upload’s (or the source file’s) basename, with anything
outside
A-Za-z0-9 ._()-turned into_, at most 120 characters, and the extension lowercased: it must be one of.wav .mp3 .flac .ogg .m4a .aac .opus, else 400 (“not a recording”). A missing file is 404. Nothing is written when the file is refused. - [added] An existing name is never clobbered: the same bytes (size and sha1) are reused
in place, other bytes go to
<stem>-2,-3… So attaching the same file twice is a no-op, and a secondtake 1.wavfrom another folder keeps both. - [added] Clearing (
{ep, track: null}):audio.trackgoes, and the mode goes back to the one the series config had when the editor attached the recording. That mode is remembered in overrides.json ({"audio": {"mode_before_track": "clone"}}) — the editor’s own state file, so the authored one carries no bookkeeping — and is dropped once it is used. With nothing remembered the mode becomesgenerate; a remembered""(the series config had nomodeat all) removes the key again. The recording itself stays on disk. - [settled] A series config that isn’t valid JSON is 400 and nothing is written (the 9a rule).
- [added, worth knowing] Attaching a recording to a script that has no
audio:windows yet makes the final build fail (“shot sh020: policy dub_keep_foley needs anaudio: in-outwindow…”):source_trackturns every dialogue shot into a dub. That is expected — aligning is the next step — so the route answers 200 with thatbuildrather than refusing, and the editor can show it as “align to finish”.
POST /seriesmith/align
- [settled]
{ep, track?, model?, snap?: true, dry_run?: false}runsalignin a subprocess (this Python, throughedit.RUN_SCRIPT, with the episode as the working directory).trackis a path relative to the episode (/seriesmith/file’s rules: 400 for one that climbs out or leads out through a link, 404 for a missing one); without it align takes the series config’saudio.track.snap: falsepasses--no-snap,modelpasses--model. A non-booleansnap/dry_runor a non-stringmodelis 400. - [settled] 409 when a dependency is missing, before anything runs:
{"error", "missing", "install", "python", "ffmpeg"}(GET /seriesmith/align/ready’s fields). - [differs] A Whisper is only needed when there is nothing to read. When the recording
already has a transcript beside it (
<recording>.words.json, newer than the recording), align never transcribes, so the 409 leaves the whisper out ofmissingand the run goes ahead; ffmpeg and numpy are still required. The body carries [added]words(whether that cache was found), so the editor can say “re-aligning needs no download”. Re-aligning after a script edit is the common case, and on this machine — no Whisper installed — it is the only one that can run. - [settled] Progress is
seriesmith.align{"ep", "stage", "pct", "text"}, from align’s own--progresslines:transcribe(10) when it starts transcribing or reads the cache,match(65) with the word and line counts,write(90) andwrite(100). A dry run’s last event iswrite100, “dry run — nothing written”. - [added] The answer is
{"ok", "dry_run", "report", "report_path", "changes", "notes", "recording", "duration", "words", "script_hash", "series_hash", "track", "build", "log"}.reportis align_report.md’s text (a dry run has it too, though it writes no file);report_pathisalign_report.md, or null on a dry run.changesis one entry per shot in script order,{"shot", "audio_in", "audio_out", "note"}, withaudio_in/audio_outnull for a shot that keeps itsdur:, andnotecarrying the report’s lines about that shot (keeps its dur:when there is no other reason).notesis the report’s whole list,logeverything align printed (progress lines removed). - [settled]
script_hash/series_hashare the files’ hashes after the write (whatPUT /seriesmith/sourcewants asbase_hash), null on a dry run. A dry run rebuilds nothing (build: null) and sends noseriesmith.episode; a real run does both. - [added] align failing is 500 with its own last
!!line as the message and the fulllogin the body. A run that outlivestrack.ALIGN_TIMEOUT(3600 s) is killed and says so.
- [settled] No more
.bak. The script and the series config are written throughsource(Source/write_source): the old bytes go to<episode>/_history/<name>.<stamp>(the newest 30), the write is atomic and keeps the file’s line endings and BOM — so a CRLF script stays CRLF, where the old write made it LF. The series config is rewritten in the promote’s format (2-space indent, key order and non-ASCII kept), which is what it already did apart from the final newline. The console lines name_history/instead of.bak. - [added]
--progressprints one##align {json}line per stage and [added]--json PATHwrites the run as the route reads it. Everything else about the CLI is unchanged: the same arguments, report, console output and exit codes, andseriesmith align <ep> …still passes its flags straight through. - [settled] A cached
<recording>.words.jsonnewer than the recording is still used instead of transcribing — which is what makes all of this testable without Whisper.
GET /seriesmith/episode: track
- [settled]
wordsis whether a usable transcript sits beside the recording: the cache exists and is newer than the recording, align’s own rule, so a recording replaced after its transcript reads false (align would transcribe it again). It is false for a recording that isn’t there. - [settled]
alignedis how many of the pass’s shots carry a dialogue window (audio_in), counted from the build, so it stays 0 until the script hasaudio:lines and the episode is rebuilt.edit.track_info(ep, pass)gives the same dict to the two routes above without a full episode status.
tests/test_phase9c_track.py(28): the readiness answers with a faked probe (and the real probe against this interpreter), the track route (by path, upload, in place, the-2rule, refusals, clearing and the remembered mode), the align route against a stub align that speaks the--progress/--jsonprotocol (events, dry run, 409, a failure, a hang, the command line), and the realaligndriven by a hand-made<recording>.words.jsonbeside a generated wav — no Whisper anywhere, since none is installed on this machine. The last group needs ffmpeg and numpy and skips without them.
Phase 9c-B as built
Everything in B. Voice refs, generated is implemented. Each point is marked [differs] (the contract said otherwise), [added] (the contract said nothing) or [settled] (the contract left it open). Part A (recordings,/track, /align) writes
its own section.
The audio target kind
- [settled]
seriesmith/targets/audio/<id>/, built exactly like the image targets:TG.KINDSgains"audio",TG.DEFAULT_AUDIO_TARGETisltx2_voice, andGET /seriesmith/targets’sdefaultis now{"video", "image", "audio"}(it was{"video", "image"}).GET /seriesmith/targets?kind=audio[&ready=1]works as for the other kinds;kindmust be one of the three, else 400. Readiness, requirement tiers, family resolution,downloadsandnodesare the video/image code unchanged (edit.readiness). - [settled]
capabilitiesfor an audio target:{"mode": "t2a" | "voice_clone", "reference_audio": bool, "max_seconds": float, "negative_prompt": bool}(negative_prompt[added], as for the image targets). - [added]
Target.seconds_range()→(default, max, min)seconds andTarget.snap_seconds(s)→(seconds, frames, fps): an audio target declares a frame grid (template.frames) and a length range (template.default_seconds/min_seconds/max_seconds), and a request in seconds is clamped into the range and snapped onto the grid.Target.voice_prompt()/Target.voice_line()are the audio kind’s code hooks, asref_promptis the image kind’s. - [added]
series.jsonrefs.voice_targetjoinstargetandkeyframe_target(targets.refs_block,targets.audio_target); each key is validated against targets of its own kind, sorefs.target: "ltx2_voice"is an error and so isrefs.voice_target: "krea2".overrides.json’sepisodegainsvoice_target(takes.EPISODE_FIELDS). A per-reftargetoverride on a voice ref must be an audio target (PUT /seriesmith/refs/override), and the same for an image ref.
ltx2_voice target
- [settled] LTX-2.5 audio-only, on the same stack as the
ltx2video target: the distilled 22B transformer throughLTXVAudioOnlyModel, the LTX-2.5 gemma text encoder,LTXVAudioOnlyEmptyVideoLatent+LTXVEmptyLatentAudioconcatenated (LTXVConcatAVLatent),LTXVDualCFGGuider,LTXVScheduler+SamplerCustomAdvanced, thenLTXVSeparateAVLatent→LTXVAudioVAEDecode.H3SaveRefAudiotakesSaveAudio’s place. No saved canvas has an audio-only LTX graph, so the workflow was written for this target (seriesmith/targets/audio/ltx2_voice/workflow.json, an API graph with titled nodes, known asseriesmith_ltx2_voice.json) and is resolved repo-first. - [settled] The model is the accelerator, as the contract asks: the preset is the
distilled transformer at 8 steps and audio cfg 1;
presets.final.baseis the LTX-2.5 dev transformer at 30 steps and audio cfg 6 (both have real download records in ComfyUI-Manager’s model list). A substitute must keep the wanted file’sdistilled/devword. The text encoder and the audio VAE are required.LTXVAudioVAELoaderis not used: it readsmodels/checkpoints, and the audio VAEs are installed inmodels/vae, whichVAELoaderlists. - [differs]
capabilities.reference_audioisfalse. The contract expectedLTXVReferenceAudioto be used “when the ref has a sample to copy”. The node is installed and the graph code wires it in (seriesmith/targets/audio/ltx2_voice/target.pypatch_graph:LoadAudio+LTXVReferenceAudiobetween the model/conditioning and the guider,identity_guidance_scalefrom the preset), but with the installed LTX-2.5 transformers it produces silence. Three live runs on 2026-09-19, identical to a run that gave 6.0 s of speech at peak 211/255, came back at peak 4/255 and 0/255 with no energy above the noise floor: a mono 5 s reference atidentity_guidance_scale3.0, the same at 0.0 (which only setsref_audioon the conditioning, no extra pass), and a stereo 5 s reference at 3.0. ComfyUI’s node is named “LTXV Reference Audio (ID-LoRA)” and only attaches reference tokens; the model has to carry the audio ID-LoRA weights to use them, and no such file is installed or recorded in ComfyUI-Manager’s model list on this machine. So the path ships off, the two node classes are declaredoptionalwith the feature"voice cloning (LTXVReferenceAudio)"(a ComfyUI without them isdegraded, not blocked), and turning it on is onecapabilitiesflag plus amodelsentry for the weights. - [added] Live validation. The repo’s workflow was queued on the running ComfyUI
0.36.0 (
POST /prompt, polled on/history) withSaveAudioin the saver’s place, because the live ComfyUI runs main’s node pack. An 8.0 s request producedh3voice/t8_00001.flac, 402455 bytes, 8.01 s at 48 kHz, peak 216/255, 39 % of the envelope above the noise floor, median spectral centroid 4.1 kHz and ~6 envelope mean-crossings a second (a speech-shaped envelope). A control run with a “complete silence” prompt at the same settings gave peak 1/255. The graphrefsitself builds (planned throughplan_generate/graph_foron a scratch copy of the kitchen_sink fixture, itsH3SaveRefAudioswapped back toSaveAudio) passedcheck_graphagainst the live/object_infoand produced 6.01 s at peak 186/255.
- [settled]
POST /seriesmith/refs/generateon a voice ref takessecondsas well ascount,seed_mode,seed,prompt,targetandnote;secondson anything but a voice ref is 400, and so is a length outside the target’s range or atargetof the wrong kind. The take is a wav underrefs/_takes/voice__<id>/, andseriesmith.refreports it as usual. - [settled] The brief (
seriesmith/targets/audio/common.py voice_prompt): “A clean voice recording of one speaker, close to the microphone, in a quiet room: one voice only, no music, no background noise, no other voices, no sound effects.” then<Name> is <the first sentence of their design>.,Voice: <the series config's voice line>.,About N seconds of speech.(whole seconds, however the grid snapped it) and<Name> says: "<line>". The line is the character’s longest line in this episode’s script (longest_line), elseNEUTRAL_LINE. - [added] The sidecar of a voice take records
seconds,frames,fps,lineandline_source("script"|"neutral"|"request"|"override"),target, andwidth/heightnull.GET /seriesmith/refstake entries carryseconds,lineandline_sourcebeside the existingaudiopath; a ref’seffectivecarriesseconds,line,line_sourceandmax_seconds. - [added] A voice ref for every character.
GET /seriesmith/refsused to listvoice:<id>only for subjects the series config already gave avoice_sample; it now lists one for every subject of kindcharacter(props and vehicles never speak). A character with no sample haspath: nullandexists: falseand can still generate. - [added] A generated voice is closed by
H3SaveRefAudio(below). When a graph keeps ComfyUI’s ownSaveAudioinstead,finish_from_historyfetches the file and keeps its extension (ComfyUI writes flac, not wav), renaming the take.
- [settled]
PUT /seriesmith/refs/pickon a voice copies the candidate to the series config’svoice_samplepath. When the character has none it writesrefs/voices/<id>.wav(under the ref’s home, keeping the candidate’s own extension) and setssubjects.<id>.voice_samplein the series config through the Phase 9a save path — a_history/copy, then an atomic write keeping the file’s line endings and BOM, the JSON rewritten as a promote writes it (promote.dump_series: 2-space indent, key order kept). The answer addsseries_changed: truebeside the usual ref JSON (whosepathis the new one). - [added]
refs.set_voice_sampleis that write, and it is a no-op returningFalsewhen the line is already what it would set. It is the only thing outsidePUT /seriesmith/sourceand the promote that writesseries.json. - [differs] Clearing a voice used to be 400 (“imported audio isn’t picked”). A voice
with a sample named can now be cleared like any ref: its live file goes,
_picks.jsonrecords the clear, and the series config’svoice_sampleline is left alone (the editor never unwrites it), so the ref still lists and a later pick puts a file back. A voice the series config names no sample for is still 400 (there is nothing to clear). Discarding a voice candidate that is the pick clears it, as for images. - [settled] Auto-pick still skips voices. Picking one can write the series config, so
it is never automatic;
GET /seriesmith/refsleaves voice candidates waiting.
POST /seriesmith/refs/voice-from-take (the no-model path)
- [settled]
{ep, ref, shot, take, pass, start, end, pick?, note?}.peaks.clip_audiopicks the file (the mp4 when it carries sound, else the take’s_h3.wav), ffmpeg cutsstart..endinto a 16-bit PCM wav, and it becomes a candidate withsource: "from_take". The sidecar recordssource_shot,source_take,source_pass,source_start,source_end,source_file,source_sha1andseconds; the take JSON shows them asfrom: {shot, take, pass, start, end}. - [added] The span must be between 0.2 s and 30 s and must start inside the take
(400); a take with no sound at all is 409; a ref that isn’t a voice is 400; ffmpeg
missing is 500. The answer is the ref as
GET /seriesmith/refslists it pluspicked,takeandsource.pickdefaults to “only if the voice has no live file”, as/refs/keyframedoes — and that pick can write the series config, as above.
H3SaveRefAudio (comfy_nodes/shot.py)
- [settled] The audio twin of
H3SaveRefTake: inputsaudio(AUDIO) andsidecar(STRING). It writes a 16-bit PCM wav with the stdlib (save_audio, not torchaudio, for the reasonSeriesmithSaveTakegives) beside the sidecar, named by the sidecar’simagefield and always given a.wavextension, then setsstatus,finished,image,duration,sample_rate,channelsandsave_notesatomically and sendsseriesmith.ref. Only the first clip of a batch is kept; a failure is recorded, never raised.
- [settled]
GET /seriesmith/refs’sdefaultsandPUT /seriesmith/refs/defaultsgainvoice_target/voice_target_source("editor"|"series"|"default"), stored inoverrides.jsonasepisode.voice_target.PUT /seriesmith/refs/defaultswith none of the three keys is 400, and a target of the wrong kind is 400. - [settled]
kreagen(andseriesmith refs):--voicesincludes the voice refs in a run ([differs]: off by default, so an existing image run is unchanged),--voice-target <id>,--voice-seconds N, and--from-take SHOT:TAKE:START-ENDwith--pass final|proxy(default proxy).--from-takeneeds the run narrowed to exactly one voice ref with--only voice:<id>, and says so otherwise;--pickforces the pick. A voice’s row in the listing shows its length instead of a picture size, and the banner names the audio target on its own line.
Phase 9c: fixes after merging
- [fixed]
align_report.mdis skipped when looking for the episode’s script (edit.SCRIPT_SKIP,seriesmith). Without it, an episode whose script isn’t<folder>.mdhad two.mdfiles after an align, and every later build,GET /seriesmith/sourceand promote failed with “can’t tell which .md”. - [added] Audio
capabilitiesalso carrymin_secondsanddefault_seconds, so a picker can offer the range a generate accepts (outside it is 400). - [fixed]
GET /seriesmith/browse?files=audiolists.aacand.opustoo, the same setPOST /seriesmith/trackaccepts. - [added] The interpreter alignment runs in (
track.align_python):SERIESMITH_ALIGN_PYTHON, else this process’s, else apythonon PATH — the first that has numpy and a Whisper. The editor runs in ComfyUI’s embedded Python, which usually has neither, while the system Python does; align is a subprocess either way.GET /seriesmith/align/readyreports (and its pip line names) that interpreter. - Known, not changed:
POST /seriesmith/refs/voice-from-takeanswerspickedas a boolean (the refs listing’spickedis a take number) andsourceas an object{shot, take, pass, start, end, file}; the editor refetches/refsinstead of using it.
Phase 9d: a shot’s audio from elsewhere (contract written before building, 2026-09-19)
A clip in the cut plays its own take’s sound, or the recording when the player/assemble is in master mode. There is no way to say “this take’s picture, that take’s sound”, which is what a fluffed line in an otherwise perfect take needs. 9d adds a per-entry audio source.cut.json
A cut entry gains audio, absent or null meaning “this take’s own sound” (today’s
behaviour):
source:"take"(any shot’s take, either pass — its mp4’s sound, else its_h3.wav, thepeaks.clip_audiorule),"file"(a media file inside the episode, or beside a parent-folder series config), or"none"(silence under this clip).start: seconds into the source where the audio begins.offset: seconds it is shifted against the picture (positive = later; the gap is silence).gain: a linear multiplier, 1.0 unchanged.- The audio is cut or padded with silence to the clip’s length on the cut’s clock (after the dialogue window and the trims), so a clip’s length never changes.
Backend
PUT /seriesmith/cuttakesaudioper entry: 400 for an unknown key, atakethat doesn’t exist or has no sound, apathoutside the episode or with no audio stream, a negativestart, or againoutside 0–4.POST /seriesmith/cut/resetwhatalso takes"audio"(and"all"clears it);/cut/copycopiesaudiowith"all".GET /seriesmith/episode: each shot’scutcarriesaudio(as stored) plusaudio_file(what will actually play, relative to the episode, null fornone) andaudio_why(short text for the badge, e.g. “sh020 t01” or “line_sh030.wav”).assemble: lays the chosen audio under each clip instead of the take’s, atgain, cut or padded to the clip.--audio masterstill wins, and now warns naming the clips whose own audio source it is overriding.--audio noneis unchanged.--audio h3/mp4apply to clips without a source.seriesmith cut:--audio SH take SHOT:TAKE[:PASS] [--at SECONDS] [--from SECONDS] [--gain G],--audio SH file PATH [...],--audio SH none,--audio SH own(clear).GET /seriesmith/peaksalready serves any episode file, so the editor can draw the source.
Editor
- A clip’s context menu gains “Audio from…”: a window with the take’s own audio at the
top, a shot+take picker (takes with sound; the clip’s own shot first), a file picker
(browse
files=audio, upload as/refs/importdoes), and “silent”.- Both waveforms are drawn, the clip’s picture length against the source, with the
startandoffsethandles draggable and as number fields; a preview plays the clip with the chosen audio.
- Both waveforms are drawn, the clip’s picture length against the source, with the
- A clip whose audio isn’t its own shows a small speaker badge with
audio_why; the Inspector’s Cut section shows and clears it. - In recording mode the badge is dimmed and the toggle says per-clip audio is ignored.
Phase 9d as built
The backend parts above are implemented (takes: the audio field, its shape and what it
resolves to; edit: the checks, the status fields, reset/copy and seriesmith cut --audio;
assemble: the sound under the clip; the routes stay thin). Each point is [differs],
[added] or [settled], as for 9b. No field the contract named changes shape. The
“which file is a take’s sound” rule stays in peaks.clip_audio, which assemble, the status
and the checks all call.
cut.json
- [settled] The stored form is normalised (
takes.audio_spec): defaults are dropped, sostart,offsetandgainonly appear when they aren’t 0, 0 and 1. A take source always carries itspass(the entry’s list pass when the writer left it out), so a copy between passes stays pointed where it was.source: "none"keeps nothing else: the knobs mean nothing under silence, and a picker may still send them. - [settled]
offsetmay be negative (onlystartis refused below 0): the sound is pulled earlier, which eats into the source exactly as a largerstartwould. - [added] The bounds on the numbers.
gainis 0–4 (GAIN_MAX), as the contract says.startis 0 toSECONDS_MAX(86400, a day) andoffsetis −SECONDS_MAXtoSECONDS_MAX; NaN and infinity are 400. That is a sanity bound, not a clamp to the clip: the backend does not know or care how long the clip is, and anoffsetorstartpast the clip’s end simply leaves silence there — the clip’s length never changes either way. The editor’s own clamp (|offset| ≤ the clip’s length) is a nicety on top, and the backend will accept a source the UI would not have offered. - [settled]
shotis required on a take source (withtake);passis optional and defaults to the entry’s list pass. Ashoton a file ornonesource is 400. - [settled]
audiois a known entry field (takes.ENTRY_FIELDS), not anextra, andresolve_cut/cut_entry_to_jsonround-trip it. Anaudioin a hand-editedcut.jsonthataudio_specwon’t take is ignored, and dropped by the next write. - [settled] A take source is read from its pass’s standard folder (
renders/,renders_proxy/);assemble --subfoldermoves the pictures, not the audio sources.
PUT /seriesmith/cut
- [settled] Every check is in
edit.check_audio, whichreplace_cut(the route) andset_cut_entry(the CLI) both run, so the CLI refuses exactly what the route refuses; it rewrites each entry’saudiointo its stored form as it goes. The 400s: an unknown key, an unknownsource, a take source without a shot or a take, atakethat isn’t a number ≥ 1, an unknownpass, a key that belongs to another source (apathon a take source, atakeon a file source, anything onnone), a take that doesn’t exist, a take with no sound (clip_audiofinds neither an mp4 with an audio stream nor a_h3.wav), a path that isn’t relative or climbs out of the episode, a file that isn’t there, a file with no audio stream, a negativestart, againoutside 0–4, and a number that is NaN or infinite. Each names the shot. - [settled]
../into a parent-folder series config’s folder is allowed, as for/seriesmith/fileand/seriesmith/peaks; anything else that leaves the episode (including through a link) is 400. The path is stored with forward slashes. - [added] A locked entry refuses an audio change the way it refuses a trim:
Lockedfromset_cut_entry(seriesmith cut --audiowithout--force). As in 9b the server doesn’t refuse it in aPUT— the UI does. - [settled] History is the 9b one: a PUT that changes the audio copies the old
cut.jsonto_history/and emitsseriesmith.episode.
POST /seriesmith/cut/reset and /cut/copy
- [settled]
whatgained"audio"(E.CUT_WHATis noworder, trims, audio, all), and"all"clears or copies it with the rest. A locked entry keeps its audio, as it keeps its trims. - [settled]
/cut/copycarriesaudioonly under"all"(and under the added"audio"):what: "order"andwhat: "trims"leave the target pass’s audio sources exactly as they were. - [added]
/cut/copytakeswhat: "audio"too, copying only the audio sources; the contract only promised them inside"all". - [settled] A copied source is copied as it stands — a take source keeps the pass it names, so “sh020’s final t01” is still that in the proxy cut. Nothing is re-checked: both passes see the same files.
GET /seriesmith/episode
- [settled] Each shot’s
cutcarriesaudio(the stored object, or null),audio_file(relative to the episode, forward slashes) andaudio_why. With no source all three are null.audio_whyis"sh020 t01","sh020 t01 (proxy)"when the take is from the other pass, the file’s base name, or"silent". - [added]
" (missing)"is appended toaudio_why, andaudio_fileis null, when the source’s file has gone (a discarded take, a deleted file). Nothing rewrites the entry: the badge says so, and assemble warns and lays silence. - [settled]
audio_fileis exactly what/seriesmith/peakstakes as itspath, so the editor can draw the source without resolving anything itself.
assemble
- [settled] The order of precedence:
--audio noneand--audio masterignore every source (master says which clips it is overriding, naming them); otherwise a clip with a source uses it, andauto/mp4/h3apply to the clips without one. - [settled] The clip’s length never changes. The source is written to a wav of exactly the
clip’s length first (
clip_track):atrimforstart(plus any negativeoffset),volumeforgain,adelayfor a positiveoffset, thenapadbounded by-t— never-shortest, which drops a video frame. A source shorter than the clip is padded, a longer one cut. The wav is then muxed in (normalise) or fed to the re-encode (conform, with the newreadyflag so atrim_indoesn’t cut the sound a second time — it is already cut to the finished clip). Because both the wav and the picture are counted in the cut’s frames, a placeholder from the other pass and a clip converted from another rate (a 16 fps take in a 24 fps cut) line up like any other clip; both are tested. - [added] The made-up track matches the clips that are copied.
normalisetakes alayout— the sample rate and channel count of the first clip that keeps its own sound and is stream-copied — and writes the silence, the wav and the encode at it (-ar/-ac). With nothing copied (a re-encoded cut, or--audio h3) there is no layout and ffmpeg’s own choice applies to every clip alike, as before. This is what lets a sourced clip sit in a cut the concat demuxer still copies; it also fixes a latent mismatch that predates 9d. - [settled] An audio source does not force a re-encode. Only the 9b reasons do (dialogue windows, trims, placeholders, rate or size changes).
- [added 2026-10-04] Those reasons re-encode only their own clips. The cut has a standard:
how conform writes a clip of it (its size, rate and quality), read from a few frames conformed
(
standard_sig: codec, profile, level, pixel format, size, rate, time base, pixel aspect and the H.264 headers). A clip already encoded that way is copied; every other one is conformed to it — a derived take’s 1:1 pixel tag, an NVENC upscale beside x264 ones. conform writes every clip untagged (setsar=0), as a render saves its takes, so a scaled clip matches too. When nothing needs re-encoding and the clips all agree, they are copied as they are, standard or not. The re-encoded clips are written in the copies’ audio layout, and a copy whose own sound is in another layout is remuxed (normalise). Afterwards every re-encoded clip’s headers must be the standard’s, the picture’s length its frames’, and the frames either side of each join next to a re-encoded clip must decode to what went in. Any check failing re-encodes every clip, as before; so does--reencode-all, and so does a cut where nothing matches. A master’s upscales are x264 CRF 12 slow, as conform’smasteris, so ep01 (18 trimmed clips of 323) re-encodes 18 (assembly 2 minutes instead of 12). - [added 2026-10-04] Sound never runs past a copied clip’s picture. The concat demuxer starts
the next clip where the longer stream ends, so a clip whose sound overruns its picture (ep01’s
sh1360 upscale: 22 ms) left a hole in the picture’s timing and every later frame late. Such a
clip is remuxed with its sound cut to the picture (
normalisebounds its sound withatrim), in a copied cut too; the joined picture’s length from its header must equal its frames (timing_gap). publish joins a picture-only copy of the cut for the same reason, checks the same length, and seeks by the picture’s offset from the file’s start (picture_offset): its join check had double-counted the start time on a cut whose picture starts after its sound, which is why every real master’s titles fell back to re-encoding the whole cut. - [added] A source whose file is gone or has no sound is a warning, not a failure: the clip
goes silent and assemble prints
sh010: line.wav is not there or has no sound. The run also lists which clips take their sound from elsewhere. - [added]
--checkand the_shots.txtname the source (audio sh020 t01,audio line.wav MISSING,audio silent).
POST /seriesmith/audio/import (added after the contract, for the editor’s file picker)
- [added]
{ep, source_path}(a file on this machine) or multipart (ep+file), the same plumbing as/refs/importand/track. It saves the media into<ep>/audio/throughtrack.import_audio(a thin wrapper onplace_recording, so the naming rules are the track’s: the name sanitised to[A-Za-z0-9 ._()-]and the extension lower-cased,-2,-3… on a clash, identical bytes reused rather than copied again, and a file already in the episode left where it is). It answers{"path", "copied"}, wherepathis relative to the episode with forward slashes and goes straight into a cut entry’saudio.path. - [added] The same allow-list as
/track(track.AUDIO_EXT:.wav .mp3 .flac .ogg .m4a .aac .opus) and the same failures: 400 for another extension, a name that is only an extension, or neithersource_pathnor a multipartfile; 403 for an episode outside the roots; 404 for asource_paththat isn’t there; 413 overMAX_UPLOAD(64 MB). - [added] The file is only a file. It does not become the episode’s dialogue
recording (the series config isn’t touched) and it is not a ref candidate; nothing is
rebuilt and no event is sent, because nothing in the episode points at it until a
PUT /seriesmith/cutdoes.
seriesmith cut --audio
- [settled]
--audio SH take SHOT:TAKE[:PASS],--audio SH file PATH,--audio SH noneand--audio SH own(clear), with--from SECONDS(start),--at SECONDS(offset) and--gain G. The take may be written3ort03; the pass defaults to the cut’s.owntakes no knobs. A usage error exits 2, a refusal (no such take, no sound, a path outside the episode, a locked entry) exits 1. After the edit it shows the cut, where a clip with a source carries anaudio: <why>flag.
- Discarding a take doesn’t clear an audio source that names it (it clears a pick of it,
as before). The source then reads as
"… (missing)"in the status, and assemble warns and lays silence, which is visible; rewriting other shots’ entries on a discard is not.
Phase 11: the graph handoff — the workflow a target renders with (as built, 2026-09-21)
A target’s graph is looked up by name (binding.workflow_name), and a workflow saved in
the running ComfyUI under that name beats the repo’s workflow.json
(jobs.resolve_workflow). Nothing in the editor used to say which one was in force, and
readiness judged the repo’s copy while renders used the saved one. Phase 11 makes the
source visible, lets the editor hand a target’s graph to ComfyUI for editing and take it
back, and points readiness at the graph that actually renders.
Which graph is in force
jobs.workflow_candidates(explicit, name, comfy_url, env, prefer_repo) is now the one
place that knows the order — an explicit path, $ENV, the running ComfyUI’s saved
workflows, $COMFYUI_PATH/user/default/workflows, then the repo’s copy — and
resolve_workflow walks it, unchanged in behaviour.
jobs.target_graph_source(target, comfy_url, with_graph=False) classifies it:
source:env|comfy|saved($COMFYUI_PATH) |repo|none(nothing of that name: renders fail).installed: a copy is saved in the running ComfyUI undername, whether or not it wins ($ENVbeats it).differs: the graph in force is not the repo’s copy. What a job patches is ignored:jobs.neutral_graphblanks every widget the binding names (model, LoRAs, prompt, size, sampler, the model files, …) and the loader’s and saver’s own inputs (project_root, the frozen shotlist, take, sidecar) beforejobs.graph_fingerprintcompares class-and-widget sets. Without that, a canvas that once rendered an episode looks edited because it still holds that episode’sproject_root— which is exactly what the first run on this machine reported. Node ids and canvas layout never count; adding or removing a node, or changing a widget no param covers (UNETLoader.weight_dtype,H3SLAAttention.sparsity_ratio) does.errorcollects a candidate that couldn’t be read; the rest of the answer still applies.- At most one request to ComfyUI: the walk stops once the answer can’t change. For a caller
asking about every target (
edit.target_graphs, the route,seriesmith targets), ComfyUI’s saved workflows are listed once (saved_names), so a machine that has saved none of them costs one request instead of one per target: 15 targets answer in about 0.1 s. prefer_repo(audio targets) only moves$COMFYUI_PATHafter the repo copy. A workflow saved in the running ComfyUI wins for every kind.
The ComfyUI client
jobs.Comfy gained the write side of ComfyUI’s user folder, beside userdata():
list_userdata(folder="workflows")→ the files in it, relative paths, recursive (GET /api/userdata?dir=workflows&recurse=true).put_userdata(path, data, overwrite=True)→POST /api/userdata/<url-encoded path>with the file as the raw body; returns its path relative to the user folder. Withoverwrite=Falsean existing file raises aRuntimeErrornaming 409 and writes nothing.delete_userdata(path)→DELETE;Falsewhen there was nothing to delete (404).
_json now goes through _open, which keeps the “ComfyUI <code> on <path>: <body>”
message, so callers can tell 404 / 409 apart by status.
GET /seriesmith/targets
Every target gains graph, the block above without the graph itself (the editor never
needs the nodes). It is computed for all targets at once and cached per ComfyUI address for
READY_TTL (30 s), like readiness, and an install or a revert drops both caches.
POST /seriesmith/workflow/install
workflow.json into the running ComfyUI’s saved workflows, byte
for byte (a canvas save keeps its layout and extra), under binding.workflow_name.
From then on that saved copy is what the target’s renders use — that is the point: open it
in ComfyUI, edit, save.
namesaves a scratch copy under another name instead; the answer’srendersis thenfalse, because no render looks that name up. It must end in.jsonand hold no..segment (400);""means “the name the binding looks up”, as omitting it does.- 409 when a workflow of that name is already saved and
overwriteisn’t true. The editor asks before replacing it, since the file may hold the user’s edits. - 400 for an unknown target, or one that ships no
workflow.json; 502 when ComfyUI refuses the write. - Answer:
{"ok": true, "target", "installed": "<name>", "renders": true|false, "graph": {…}}.
DELETE /seriesmith/workflow/install?target=<id>[&name=…]
Deletes that saved copy, so the target renders the repo’s graph again.
{"ok": true, "target", "deleted": true|false, "name", "graph": {…}} — deleted: false
when ComfyUI had none. $ENV is never written or deleted: when it is the source, the
editor disables both buttons and names the variable instead.
When a graph is re-read
seriesmith holds no long-lived copy of a workflow.POST /seriesmith/render resolves it per
request (base_for’s cache is local to the handler), so saving a workflow in ComfyUI is
picked up by the very next render — no restart, no cache to clear. render resolves once
per run, for the same reason. What it reads is the saved file, not the canvas: an edit
nobody saved is invisible.
The only caches are for display and readiness, both keyed so an edit can’t hide in them: the
graph block is kept READY_TTL (30 s) per ComfyUI address, and the node check’s key
carries a digest of the graph’s own shape (edit._graph_key), because a saved workflow
keeps its name when it is edited — keying on the name alone would answer for the graph as it
was when ComfyUI started.
Readiness judges the graph in force
edit.target_nodes(t, graph=None, where="") takes the graph a render would use (cached
per target and where), and edit.readiness(..., graph_of=…) takes a
graph_of(target) -> (graph, where); edit.target_graphs(targets, comfy_url) builds
them and edit.graph_lookup turns them into that callable. The routes and
seriesmith targets pass one, so a saved graph with a node this ComfyUI lacks makes the target
not_ready with that class in nodes_missing — previously invisible, because only the
repo’s copy was read. Without graph_of the repo copy is still used, as before.
CLI
seriesmith targets prints a graph line per target (the workflow name, where it comes from,
edited here, and “a saved copy exists but doesn’t win”), and --json gains graphs.
Two new flags, both needing a reachable ComfyUI:
--install-workflow <target> [--name X] [--force]--revert-workflow <target> [--name X]
The editor
The What’s missing window (now the Models window) is where a target’s detail already lives, so the Graph card sits at the top of it: the workflow name, where it comes from, an “edited here” chip whendiffers, and one button — Copy to ComfyUI (with a confirm before replacing a saved
file) or Revert to the repo’s copy when a saved copy is in force. $ENV shows the
variable and no buttons. The all-targets table gains a Graph column (repo / ComfyUI /
$VAR / missing, plus · edited).
Found on the way
On this machineH3_Ref2VA_Shotlist_v1.json was already saved in ComfyUI, so every H3
render from the editor had been using that canvas copy rather than the repo’s, and
krea2_refs_t2i.json the same for reference images. Both agree with the repo’s copies, but
nothing said so. That is the case Phase 11 exists to make visible.
H3 reference cost, and ref_image_size per pass (measured 2026-09-22)
What a shot costs on minimax_h3_ref2va is its sequence length, and the sparse-attention
node (H3SLAAttention) makes that worse than linear: reference and prompt tokens are
pinned — kept dense while video tokens are sparsified — so a token of conditioning costs
several times a token of video.
Measured on one shot (sh008, 73 frames, 4 references), fitting S = tokens-per-latent-frame ×
(latent frames + references) + prompt:
Three things follow, all of them checked against renders rather than reasoned about:
- A reference costs exactly one latent frame — four frames of video. Four references on a 73-frame shot are ~22% of the sequence.
- Under
match, the file’s own size is irrelevant. A 4096×1024 character strip and a 1024×1024 filler both cost 833 tokens at 864×480: the node puts every reference on the render’s latent grid. Sopanel_modeis an identity lever, not a speed one. maxonly costs extra below full resolution. At 1344×768 the cap binds andmaxandmatchare the same; at 864×480maxis 3.5× per reference, because the video shrinks with the pass and the references don’t. A reduced-resolution pass onmaxis the worst case.
ref_image_size is a binding param (MiniMaxH3ReferenceToVideo.ref_image_size) with a
value in each preset: max on final, match on proxy. The compile writes it into the
shotlist’s defaults, so it is frozen per take and no longer depends on what a saved canvas
holds. The goldens moved for it on 2026-09-22 (proxy graphs and both shotlists’ defaults).
Where a take’s own time goes (save_ms, 2026-09-22)
SeriesmithSaveTake times each thing it writes and records it in the take’s sidecar:
frames just with save_frames, audio only when the model
made some, and dub_keep_foley’s Demucs pass is inside audio); total is their sum, in
whole milliseconds. What it found straight away (73 frames at 864x480): the thumbnail and
the timeline’s contact strip are 85 ms together, the wav is 2 ms, and the mp4 was 4.5 s —
because the encoder wrote the whole clip out as PNGs to feed ffmpeg. It now pipes raw RGB
instead (_encode), measured at 3.9 s -> 1.1 s for the same file, so a take saves ~2.8 s and a
240-shot proxy pass about 11 minutes. save_frames still feeds ffmpeg the PNG sequence it
wrote, since those files are wanted anyway. ComfyUI reports only the whole graph’s “Prompt executed in”, so this is
what tells an ffmpeg encode from the strip’s eight full-frame LANCZOS resizes when a take
feels slow — the pipeline’s saver writes an mp4, a wav, a thumbnail and the timeline’s contact
strip where a stock graph writes one mp4. time.perf_counter costs ~100 ns a call, so the
measuring is free. The same fields reach takes’ sidecar contract.
Unused subject sockets are empty (2026-09-22). The loader used to pad them with the plate
(while len(refs) < 3), so a one-subject shot sent the same picture three times. Tested
first, because the fix depended on it: MiniMaxH3ReferenceToVideo accepts None on an
optional reference socket and does not renumber the rest — the plate stays <Picture 4>
whatever comes before it. (The ComfyUI canvas collapses the visible input list when a link is
removed, which is what made padding look necessary; the API graph keeps the names.) So no
prompt changed, no golden moved, and no override needed migrating.
Measured on a one-subject shot at 864x480, same seed, same frozen shotlist:
3.6 s a shot — more than the tokens alone imply, because fewer pinned references also let SLA
sparsify harder (54% -> 64%). On a 229-shot episode (156 one-subject, 64 two-subject) that is
379 references it no longer sends, about 11 minutes of a proxy pass.
Correction (2026-10-05): the node does renumber.
MiniMaxH3ReferenceToVideo drops a None
socket, and the text encoder labels only the pictures it is given, counting from 1
(comfy/text_encoders/minimax.py, unchanged since H3 support landed on 2026-08-03): the
table’s “2 pictures” were <Picture 1> and <Picture 2>. So from 2026-09-22 a shot with fewer
than three subjects sent its plate as <Picture n+1> under a prompt naming <Picture 4>
(ep01-ep03 of Porchlights: 833 of 879 shots), and the plate reached H3 unnamed; the room still
came out right from the prompt’s words. Unused sockets now hold a 32x32 grey placeholder
(PLACEHOLDER_SIZE): H3’s smallest canvas, never enlarged, so each costs a 2x2 latent and a
few vision tokens, and the plate is <Picture 4> again. No prompt or golden changed.
Phase 12: a show’s own targets, from any ComfyUI workflow (as built, 2026-09-21)
A target was code plus data in this repo. Now a show can have targets of its own — data only, in<show>/targets/<id>/target.json — and seriesmith can propose that file by reading a
workflow. This is the contract.
12a: the builtin prose compile, and a second target root
seriesmith/targets/generic/video_prose.pyis the compile every loader-less prose video target uses: the story IR to shotlist entries, ref slots, keyframes, the graph helpers. It wasseriesmith/targets/video/wan/common.py, moved and parameterised;wan/common.pyis now a thin binding that gives it Wan’s wording (Style) and re-exports the helpers the three Wan targets’patch_graphuse. Golden output is unchanged.Style(build_prompt, silent_dialogue): the prose writer and the--checkline about dialogue a silent model can’t voice.style_for(target)picks it when a caller doesn’t: a target that makes no sound gets the Wan writer (dialogue as silent acting), one that does gets the ltx2 writer (soundscape, spoken lines).recipe.prompt: "prose_silent"asks for the silent writer on a target that could make sound.- The silent warnings and the shotlist’s default
audio_policynow come from the target (capabilities.audio,recipe.policies) instead of being Wan’s constants.
"code": "builtin:<name>"in target.json importstargets.generic.<name>instead of a module beside target.json (Target.module). A custom target with nocodedefaults tobuiltin:video_prose(Target.default_code), so it ships no Python at all."code": "builtin:router_video"(2026-10-12): a video model on Comfy Router described by data. Itsrouter.inputssays where each part of a shot goes in the model’s own request: a field (a dotted path:prompt,parameters.seed,instances.0.prompt) or a list it’s appended to, with an item template ($prompt,$url). Inputs: the prompt, the negative, the length (min/maxorvalues, whole seconds,as: "str"), the resolution (names like720p/2K, sizes like1280x720, or by height), the ratio (values;with_framesfor frame mode), the seed (max), the sound (on/offwhen it isn’t true/false), the references (max) and the first and last frames.viasays how pictures go in (seedance: the virtual library);router.draftturns on Seedance’s Draft -> Final. A character’s 4-panel sheet goes up as the one panel the shot wants. Seedance 2.5 is this module plus data.router.providerasks another provider for the model (fal, higgsfield, runware, wavespeed: Router translates the model’s own arguments to its input) androuter.fallback: falsestops Router trying another when it’s down; they’re sent as Router’smodel_provider/fallback_providerquery on the queued submit (comfy-sdk 0.4 sends them onrunonly), and the take’s record says who served it (model_request.served_by, fromX-Comfy-Router-Fallback-Provider).- A show’s own targets live in
<show>/targets/<id>/target.json, where<show>is the folder holding series.json — the episode folder, else its parent (TG.show_folder). The kind is read from the file (there is no kind folder).TG.load_target(id, kind, root=…)andTG.list_targets(kind, root=…)take a show;TG.use_roots(...)(a context manager) andTG.add_thread_root(root)put one in force for the current thread, so the deep callers that only have an id — planning, a retarget, readiness — see them unchanged. Targets are cached per folder, so two shows may each have amy_wan.- Where the roots are set:
build(its-oroot),render(each episode),seriesmith targets <ep>, andapi.check_ep— which registers the episode’s show after validating it. Thehandlerdecorator clears the thread’s roots before and after every handler, so one request can’t inherit another show’s targets (the routes run handlers on pooled threads). - A custom target that takes a built-in’s id is ignored (the built-in is what
renders);
TG.shadowed_custom(root)finds such folders, and saving one is refused.
- Where the roots are set:
- Keyframes without code:
binding.inputsis the declarative form of the graph surgery a target would otherwise write in Python:With a staged keyframe the loader gets its name; without one thedisconnectinput is popped, so the graph renders from the prompt alone andprunedrops the loader. This is exactly whatwan22_ti2v’s hand-writtenpatch_graphdoes, and the two agree node for node (tests/test_phase12.py).
12b: proposing a target.json from a workflow
seriesmith/inspector.py reads a graph and proposes the target.json that would drive it. Nothing
in it writes.
frames.max is the one thing no graph states: it is a guess, always in warnings, and the
probe render is what catches it. Widgets no param covers are listed in unbound — they keep
the graph’s value for every shot.
Measured against the shipped targets (their own workflows, their own hand-written
bindings): 17/17 params recovered for wan22_ti2v, 18/18 for wan22_i2v, 15/15 for
minimax_h3_fl2va, 18/19 for wan22_vace (its VACE strength is model-specific), and 12/15
for ltx2 (its multi-widget specs and an uninstalled upscaler).
Routes:
GET /seriesmith/workflows— the workflows saved in this ComfyUI;target: truemarks one an seriesmith target already drives.POST /seriesmith/targets/inspect{workflow | graph, ep?, id?, label?}→ the answer above pluscan_save. 404 for a name ComfyUI hasn’t got, 400 for a graph with no saver.PUT /seriesmith/targets/custom{ep, target}validates (IN.validate_spec) and writes<show>/targets/<id>/target.json. Validation resolves every binding param against the graph as a job prepares it (the saver takes its node’s place first), so a spec that would fail at queue time fails here. 400 lists every problem inproblems. With{ep, id, draft}instead it only flips the draft flag.DELETE /seriesmith/targets/custom?ep=…&id=…removes one.GET /seriesmith/targetstakesep(a show’s own targets are included) and each target gainscustomanddraft.
seriesmith target-from-workflow <workflow> [<episode>] [--id X] [--label X] [--save] [--json] prints the proposal, what it matched, what it guessed, and with --save writes
it.
12c: the editor
The What’s missing window (now the Models window) — where a target’s detail already lives — gains This show’s own targets: each one withdraft / ready, its workflow, and buttons for Probe render,
Enable for shots, Back to draft and Remove. Add a target from a workflow…
opens a wizard: pick one of ComfyUI’s saved workflows, Read it, then confirm the handful of
fields no graph states (id, name, the frame grid, the size multiple, fps, sound, prompt
style), settle any ambiguity from a list of candidate widgets, and Save as a draft.
A draft is saved but not offered: videoTargets() leaves it out of the shot and episode
pickers, so a target nobody has rendered with can’t quietly become an episode’s model. The
probe render queues the episode’s first shot on it at the proxy pass; look at the take, then
Enable for shots.
12d: deferred, on purpose
Subject references for custom targets. Sheet composition, panel choice and backgrounds are code (seriesmith/helpers/refsheet.py, per-target recipes), and a JSON DSL for them would be
the wrong abstraction — the same call the plan’s decision table makes about prompt formats.
A custom target that needs subject refs gets a repo folder.
Hosted models, limits and your own workflows (as built, 2026-10-12)
Comfy Router models as data (video:builtin:router_video; image: builtin:router_image),
seriesmith’s limits form for every model, models for every show, and workflows that end in
any save node, video or image. The editor’s side: EDITOR.md, “The Models window” on.
Where models live
seriesmith/targets/<kind>/<id>/ (built-in), <settings dir>/targets/<id>/ (every show’s:
scope: "user") and <show>/targets/<id>/ (one show’s). GET /seriesmith/targets gives
each target scope (builtin | user | show), limits (a video or hosted image
target’s, below), draft (true while not offered for shots or refs) and shipped_draft
(a built-in that ships as a draft). The Router built-ins are written by
tools/make_router_targets.py from tests/fixtures/router_schemas/ and its limit
corrections; tests/test_router_inspect.py checks they’re current.
Settings (settings.json, beside which the user folder sits): enabled_drafts (built-in
drafts switched on for every show) and target_limits ({id: limits}: this machine’s
changes to built-ins, applied as each loads).
GET /seriesmith/router/models[?ep=…]
Router’s catalog for the wizard’s picker, read with the account’s key and kept an hour:
{models: [{id, provider, model, kind: "video" | "image" | "other", text_only, added?}]}.
kind is a guess from the name (router_inspect.kind_of); text_only when the name says
text-to-video; added the target that already drives it. 409 without a key, 502 when
Router can’t be read.
POST /seriesmith/targets/router-inspect
{model, ep?} → {proposal, mapped, unplaced, warnings, shared, limits}. An image model
(by kind_of) is read by router_inspect.propose_image, a video one by propose, which
refuses one that takes no picture (400). shared: {limits section: [{input, field, text}]}, the schema fields written for several models at once — this model’s limit is in
their prose. limits: the proposal’s (seriesmith.limits.describe).
POST /seriesmith/targets/router-dryrun
{ep, target: target.json | id, limits?, pass?, shot? | ref?, view?} → what a Router
target would send, nothing uploaded or paid: {ok: true, shot, model, arguments, routing?, prompt_chars: {written}}, each picture as {file, via?}; or {ok: false, shot, why} (a first-frame model on a shot with no first keyframe). A video target is tried on a
shot (default the first with a subject); an image target on a ref (default the first
character; a keyframe shows its pictures going in), planned as a hosted edit model plans it.
limits are written in first (the wizard’s changes).
PUT /seriesmith/targets/limits
{ep?, id, limits} changes a video or hosted image target’s limits: a built-in’s on this
machine (settings target_limits; limits: null puts it back as shipped), a user or show
target’s in its own target.json (validated). A local workflow’s pictures and sound are its
graph’s: only its length, size and pictures.needs_first change (400 otherwise); a local
image workflow’s not at all. → {ok, id, path, target} (the target as listed again).
Limits (seriesmith/limits.py): video {length: {frames: {step, base, max}, fps, trained?, seconds?: {min, max} | {values}}, size: {multiple, fit, long_side?, pixels?, sizes?, resolutions?}, prompt: {max_chars?}, pictures: {references, first, last, needs_first, via?}, sound, editable}; hosted image {size: {ratios?, resolutions?, sizes?, min_pixels?, max_pixels?}, prompt, pictures: {references, via}, editable}. A listed built-in whose
limits are changed here adds overridden: true and shipped (what it shipped with).
PUT /seriesmith/targets/custom, additions
scope: "user" saves for every show ("show", the default, in the show’s folder);
limits are written into the target first. {ep, id, draft} on a built-in that ships as a
draft switches it on or off for every show (settings enabled_drafts), its target.json
untouched; on a user target, saved where it lives. DELETE finds a user target too.
POST /seriesmith/targets/inspect, additions
kind: auto (the default: inspector.guess_kind — a video node, a frame count or a video
save makes it video), video or image. An image workflow is proposed as an image model
(builtin:workflow_image): its LoadImage nodes are binding.references, so with any it’s
an edit model (capabilities.mode / max_refs). The saver is read from /object_info
(inspector.generic_saver: the graph’s output nodes, a video file before an animation
before frames, a save before a preview; a node taking a finished video has the node making
it replaced), its other outputs in saver.drop, never read for params. The answer adds
notes: [{level: "error" | "warning" | "info", text}] (errors first; warnings stays).
Takes and jobs
A hosted take’s record:remote (Router’s state while it runs: “waiting at Router (#2 in
line)” / “running on Router”; the take’s status stays queued until it’s collected);
model_request.provider.cost (a video take) or provider.cost (a ref take) when the result
carries one, in the provider’s own units. The job (GET /seriesmith/jobs): info.remote,
the same words.
LTX-2.5: the quality profile and ingredients references (as built, 2026-09-19)
Two additions to theltx2 target. Both are decided at queue time, from the model file
the job loads and the ref files on disk, so a build is byte-for-byte what it always was:
shotlist.ltx2[_proxy].json gains no key, and the tests/golden shotlists, reports and
refs_todo are unchanged. No route changed either; what the existing ones now show is
below.
1. The quality profile: the dev transformer
ltx2’s presets are the distilled LTX-2.5 transformer, distilled for the workflow’s
fixed 8-step base schedule at video_cfg 1 — which is why steps has always been recorded
and not patched. The dev (non-distilled) transformer takes real steps and guidance, so
target.json recipe.sampling gives each transformer its own schedule and the job picks
one by the file name (match, case-insensitive globs):
- Selecting it needs nothing new: it is the shot’s
model. A series config profile ({"target": "ltx2", "model": "ltx-2.5-22b-dev-transformer-comfy-int8-convrot.safetensors"}) picked withprofile:on a shot or a sequence, amodel:line, the series config’sseriesblock for a whole episode,PUT /seriesmith/overridefields.modelfor one shot, orPOST /seriesmith/rendermodelfor one run. Precedence is the usual one. - Turning it off is naming the distilled file again (or clearing the override).
- The take records it.
stepson the sidecar is the real count, and the frozen shotlist carriescfg,audio_cfg,sampler,refine_cfgandrefine_sampler(job.values, as thebasepreset’s settings are).notesgets “the quality profile (LTX-2.5 dev transformer): 30 steps, video_cfg 4.0, audio_cfg 4.0, euler_ancestral”. - Readiness:
models.quality_modelis an optional param whosedefaultis the dev file and whosefeatureis “the quality profile (LTX-2.5 dev transformer)”. It has no widget of its own —modelis the widget — so it exists only sowanted_files/GET /seriesmith/targets?ready=1/seriesmith targetscan say whether the extra is installed. Without it the target isdegraded, nevernot_ready, and the distilled default renders.LTXVScheduleris an optional node with the same feature.
2. Reference sheets with the 2.5 ingredients IC-LoRA
When a shot’s subjects and plate have picked refs on disk andltx-2.5-22b-ic-lora-ingredients-0.9.safetensors is installed, ltx2 now composes the
same reference sheet ltx2_ingredients composes (its sheet_spec / h3_refsheet.py, from
ltx2’s own recipe.reference_sheet) and conditions the clip on it. Otherwise the shot
renders exactly as ltx2 always did.
- The panels come from the shotlist entry, not from a new build key: the entry’s
subjects(characters first, then props and vehicles) against the shotlist’ssubjectsmap (kind,sheet), thenbackgroundas the plate. A character contributes the face panel of its 4-panel sheet on a one-characterclose/cushot and the three-quarter body panel otherwise, as on H3 andltx2_ingredients. GET /seriesmith/targets:capabilities.reference_sheetandcapabilities.subject_refsare now true forltx2.capabilities.promptstays"prose"— the built prompt is prose; the two-part one is written at queue time (asminimax_h3_fl2vawrites its alignment line then).- Ref slots (
GET /seriesmith/shot, the sidecar’srefs): the keyframes as before, thensheet panel Nfor each panel, withsubjectwhere there is one. All of them areoptional, so they never appear inmissing_refsand never block a shot — a missing file only leaves that element off the sheet. They carry norole, sostage_inputsdoesn’t upload them one by one; theirsha1in the sidecar is what makes a takeref-stale when a view is re-picked. - At queue time (
jobs.stage_inputs→ the target’sstage_inputs): the sheet is composed at the render size, uploaded asseriesmith/<sha1>.png, kept in the take as<shot>_tNN_refsheet.png(slot: "reference sheet",role: "sheet"), andinputsis{"sheet": "seriesmith/<sha1>.png"}. The take’s frozen shotlist gainspanels(what was actually on the sheet) and itspromptbecomes the IC-LoRA’s two labelled parts, written byltx2_ingredients’ prompt writer:Reference sheet: …\n\nGenerated video: …, with a subject that is on the sheet named but no longer described. A prompt override is never rewritten. If the build is out of date the built prose prompt renders with the sheet and a note says so. - Nothing here can stop a render. Unlike
ltx2_ingredients, where composing is fatal, a ref that isn’t a readable image, or a Python without PIL, leaves the shot rendering from the prompt alone with a note. - The graph, wired as ComfyUI’s LTX-2.5 ingredients template wires it:
LoadImage→ResizeAndPadImage(the stage’s own size — the base stage samples at half the output, so the guide must match) →RepeatImageBatch(the clip’s frame count) →LTXAddVideoICLoRAGuideatframe_idx0, whoselatent_downscale_factoris the second output ofLTXICLoRALoaderModelOnly(the IC-LoRA at strength 1.0, the model card’s default). Only the guided stage’s guider reads the patched model, and oneLTXVCropGuidesper stage takes the guide frames back off (shared with the last-keyframe patch, so a shot with both gets one crop per stage, not two). - Length: the reference video is exactly the clip’s length. The model card asks for
≥ 121 frames, but
LTXAddVideoICLoRAGuiderefuses a guide longer than the latent (“Conditioning frames exceed the length of the latent sequence”), so a shorter shot renders with a note that identity may weaken. A 121-frame (5.04 s) shot is the bucket. - Turning it off is
lora: noneon the shot, the sequence, a profile ("loras": ["none"]) or the series config’s pass block — an empty LoRA list means no sheet. That is the opposite ofltx2_ingredients, where the IC-LoRA is put back into a list that doesn’t name it, because that target is pointless without it. - Readiness:
models.reference_lorais an optional param (familyltx2.5-ic-lora-ingredients, folderloras, loaderLTXICLoRALoaderModelOnly) whosedefaultis the IC-LoRA and whosefeatureis “reference sheets (LTX-2.5 ingredients IC-LoRA)”;LTXICLoRALoaderModelOnly,LTXAddVideoICLoRAGuide,RepeatImageBatchandResizeAndPadImageare optional nodes with the same feature. Missing →degraded, and the shot renders without a sheet. Whenresolve_modelshasn’t run (a dry run, or ComfyUI not asked) the target’s own file is assumed, as every other unresolved param is. ltx2_ingredientsis untouched. It still renders LTX-2.3 with the 2.3 IC-LoRA, its panels are still required, and its sheet still blocks a shot that is missing one. The two functionsltx2reuses from it,panel_viewandsheet_spec, took an optionalsheetargument so each target reads its ownreference_sheetrecipe.
Where the numbers came from (live, 2026-09-19)
About twenty 97-frame (4.04 s) 768×512 runs of one scratch shot on this machine’s ComfyUI, one job at a time, seed 12345 throughout. Two settings are not in any published template and were decided here:scheduler.latent.LTXVSchedulerassumes 4096 tokens when nothing feeds its optionallatent, and shifts as if the latent were far bigger than a 384×256×97 base latent (1248 tokens) really is. Unwired, every dev run came out soft and painterly with mangled hands at cfg 3 and 4. Wired to the base stage’s video latent before any guide widens it (a reference sheet doubles the sequence), it is right.euler_ancestral. ComfyUI’s LTX-2 dev templates use plaineuler, but they run the dev checkpoint with the distilled LoRA on it. On the bare 2.5 dev transformereulerstayed soft at every cfg tried;euler_ancestral— which the distilled 2.5 workflow itself uses — came out crisp. cfg 3, 4 and 5 were all good; 4 is the shipped middle. 20 steps was flatter than 30 for 6 s less, so 30 stands.
recipe.sampling.modes.dev.with_sheet): the IC-LoRA reads the sheet as frames in context
with the clip, and an ancestral sampler re-injects noise over those frames too — on the dev
model at real guidance that speckled the figure and made it come and go over three runs.
Plain euler holds it, and guidance comes down to 3.0 (ComfyUI’s ingredients graph advises
cfg near 1; the dev model needs more than 1 to resolve at all, and at cfg 1 it was a blur).
Distilled + sheet is still the cleaner picture of the two, and the take says which it
used.
Measured, same prompt, same seed, 97 frames at 768×512, 24 fps, audio present in all four:
The sheet’s effect is unmistakable: without it the shot invented its own kitchen and its own
version of the character; with it, the plate’s kitchen (tiles, ceiling panels, floor) and
the sheet’s character (bob, glasses, apron, trousers, shoes) are both reproduced.
Verdict on the quality profile (the user, looking at the clips, 2026-09-20): distilled
won every pairing, with a sheet and without. So the profile stays opt-in and unused by
default, and “dev is crisper without a sheet” (this section’s earlier claim, an agent’s own
reading of the frames) is withdrawn. Keep it only as a lever to re-test if LTX ships new
dev guidance, and change the default only on fresh side-by-sides. The sheet itself is a
separate matter and is on by default: nobody disputed it.