You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Canvas (画板, /preview/canvas), phase 1: circle-select editing, upscaling and crop / flip / rotate / scale of an existing image, with every version kept and one set as final (2026-10-10). A more precise way to change an image than ✏️ 修改: circle the places to change, write what to change, attach reference images the text can name with @图N, and pick the image provider and model for this page only. A 🖌 画板 button now follows ✏️ 修改 on scene images and shot plates (scene preview page), character and costume images, creature and prop images and storyboard sketches, and sits under the cover on 成片发布. The rule: edits, upscales and adjustments only enter that image's own history (assets/canvas/<flattened original path>.<sha8>/, which stations neither read nor write), and production is touched only by 设为最终版. An edit is sent in one of three ways: through an agent (default) — the new on-demand station 「画板修图师」 (agents/06-art/image-retouch, Smart Assignment low tier) looks at the image, writes the selections and the note into one English prompt and submits it with code/canvas_edit.py submit; 直接发送 — the host fills a fixed English template with the text and generates without an agent; 发送前先看提示词 — the job waits, the prompt can be edited on the page, and the host generates once it is confirmed. 圈外锁定 (on by default when there is a selection) pastes only the selected area, expanded and feathered, back onto the base image; when the mean difference outside it exceeds 0.06 the version is tagged 「圈外漂移」, and 改用整图 saves the model's whole image as another version. Upscaling is run by the host without an agent, as 重绘放大 (an image model redraws from the base image as its only reference, with a fixed prompt) or 保真超分 (local Lanczos interpolation, or Fal's SeedVR2 / Topaz Precision / Real-ESRGAN / AuraSR / Bria upscalers); a model result that is not larger than the base counts as failed and is not padded by interpolation. Crop to selection, flips, rotate and scale are recorded as versions too. 设为最终版 lands by kind of image: a fixed-path image (character, costume, creature, prop, scene image, cover) has the version written back to its original path; a storyboard sketch also has its ledger entry set to mode=canvas; a shot plate keeps its original image and library entry, the version is registered as <key>_revN, and the shots that used the original (or an earlier canvas version) are repointed and their group prompts synced — registering without replacing is possible, and choosing the original version points the shots back. Before applying, the page lists the impact: which shots or group prompts use the image, and what follows (videos already generated do not change; the portrait library needs re-registering; the animatic needs re-rendering; the cover inside a publish package is a copy). Nothing downstream is re-run — a change record runs/revisions/canvas-*.json is written when the image is in use and the version really changed — and ticking 「把这次改动发给修改师」 has the reviser update the setting text only. Because the canvas flips images, the ⇋ 翻转 button under each shot plate on the scene preview page is removed (the backend flip endpoint stays, with no page calling it). Spec: docs/image_canvas.md. Not exercised for real: no image was generated, so how well models follow the circled image, the 0.06 drift threshold, redraw-upscale sizes and the five Fal upscalers are untested; the retouch agent was never dispatched and the desktop app was not tried. Not done: true mask inpainting, outpainting, multiple candidates, entry buttons on the storyboard preview page (分镜预览) and on panoramas; a registered _revN carries no master / grid9 mark, so episodes run later still auto-select the original plate. Restart the service to get the page (the buttons appear on refresh but lead to a 404 until then).
Scene links, phases 1 + 2: match cuts and relays between scenes are drafted at the script stage, disposed of one by one by the director, registered into the shot list by the host and written into both sides' group prompts (2026-10-10). A match cut needs the last image of one scene and the first image of the next designed as a pair, and both are decided by the script; by the storyboard stage they can only be forced together. Until now script transition lines were a bare CUT TO or free text, nothing reconciled the director's transition list with them (of 8 script match cuts that mapped onto a shot-list boundary, 3 survived as match_cut), and match_cut was a label with no matching sentence in either prompt. Phase 1: the scene-end transition line stays CUT TO by default; only when both ends are really written in the scene text, the link is one of seven whitelisted kinds (shape, action, same camera position, motion, sound, dialogue line, contrast; shape and action are trial kinds) and it joins the adjacent next scene does the line end with {衔接: 类型, 出: …, 入: …} (English anchors {link, out, in}). modules/scene_links.py is the single parser for script transition lines (across all project copies, transition design now reads the transition line of 2,536 scenes instead of 241), and the check scene_link_valid in code/check_screenplay_events.py fails on, among others, an unknown kind, a missing out or in, a link that is not to the adjacent next scene, and MATCH CUT / SMASH CUT without braces. The director's transition list must dispose of every link in one line, 「剧本衔接 Sxx→Syy:采纳 | 修改 | 弃用」 — adopt and modify name the transition type the link lands on (only hard_cut / match_cut / smash_cut / dissolve), drop and modify give a reason — checked by the new code/check_scene_links.py (script_links_disposed; --list lists the links). The script preview page shows the kind, this scene's ending and the next scene's opening, with a 「试验」 tag on the trial kinds. Phase 2: transition_design.propose registers each adopted or modified link on the first group of the later scene as transition_in.link {kind, out, in, out_shot, in_shot, note, trial?, device?} and applies it to shot_list at once; a withdrawn link is removed at the next propose, a boundary already ruled on in the transition card stays as set (「剧本衔接(导演采纳)」 remains a candidate), and in minimal mode the H3A sign-off hook registers the links instead. In cinematic mode a motion relay automatically brings a motion_pair (not with whitebox on) and a sound or line relay a J sound_bridge; classic and custom only offer them as candidates. code/sync_scene_links.py --write writes one 「【场间衔接】」 sentence (Scene link: when the interface is not Chinese) into the previous group's last Shot paragraph and one into this group's Shot 1 paragraph, from a fixed sentence pair per kind; each sentence describes only its own shot, because the two videos are generated separately. New checks: transition_link_valid (part of transition_ok), scene_link_bound (p7-prompt acceptance) and script_links_landed (fails when an adopted link is missing from the shot list, its type differs, the registration is stale or a dropped link is left over). The transition card on the storyboard preview page (分镜预览) shows 「🔗 类型」, the director's disposition and a 「📜 剧本衔接」 tag. Existing projects: screenplays whose generated_at is before 2026-10-10 only get WARNs from scene_link_valid (240 existing screenplays: 0 FAIL), and re-running the proposal on seven real episodes changed no shot list. Spec: docs/scene_links.md. Not done or verified: phase 3 (whitebox check by screen position, the previous group's last frame as a composition reference, picking the best-matching pair near the cut in editing); the screenwriter, director and prompt stations have not been dispatched for real under the new rules, and the effect of the link sentences on generated video is untested; the sound bridge that cinematic mode now adds has not been verified in a real project's final cut. Restart the service for the preview pages, the transition card and the H3A fallback; the check and sync CLIs work immediately.
NPC staging: nameless background people and foreground objects give scenes depth (2026-10-09). Shots can now be filled out with nameless NPC elements — passers-by, foreground objects — so that a scene has a foreground, a midground and a background. The switch sits on the scene: the script stage judges it, the storyboard places the NPCs shot by shot, and composition, whitebox and prompt only carry it out. The project-level master switch output.npc_staging (auto = judged per scene, the default; off = all off) is a new NPC 参与构图 row in 输出设置 and in the new-project wizard. In p5-breakdown, timeline-story writes scenes[].npc {on, density: sparse|medium|dense|null, reason} into script_breakdown.json for every scene (new check npc_judged, part of code/check_script_breakdown.py: skipped when the master switch is off, WARN only for breakdowns produced before 2026-10-09). On the script preview page and the storyboard page each scene header has a 🚶 chip to set 自动 / 开 / 关 and the density (POST /projects/<p>/episodes/<ep>/npc, stored in the host-owned story/episodes/<ep>/scene_npc.json, which re-analysis does not overwrite). The effective value is: master switch off > the user's value > the automatic judgment > not judged, which counts as off — so existing projects behave as before. Downstream: storyboard draft shots carry npc[{layer: fg|mg|bg, what}] and each scene block an npc_applied receipt (check npc_staging_applied; a scene whose receipt disagrees with the effective value is flagged 「分镜未按此设定」 on the storyboard page — changing the switch does not redo downstream work), and sketch prompts include the NPCs automatically; the shot list copies npc[] (npc_shots_consistent); composition names the NPCs in the matching layers (npc_layers_bound); whitebox uses EXTRA-NPC-xx extras with the legend anonymous NPC passer-by, not a named character; in the group prompt --write adds a host-owned fixed section Background figures (NPC): … End background figures. before Global constraints, and in groups with NPCs the identity lock ("every person on screen must match / no extra or duplicate person / exactly N characters") is narrowed to named characters; running it again after the scene is switched off removes the section (npc_prompt_bound); ambience adds a bed of crowd voices matching the density. Plates still contain no people, and extras named in the script still go through extras. New module modules/npc_staging.py and host CLI code/npc_staging.py (effective-value table, --suggest keyword hints, --check storyboard|shots|composition|prompt|all, --write); see the new docs/npc_staging.md. The UI was exercised on an isolated instance; no real agent work order was run.
Execution engines: one-click CLI install, and sign-in in a terminal window (2026-10-09). When the top bar is switched to claudecode / codex / kimicode / pi / opencode / grok (or one of them is restored at startup), the local CLI is checked. If it is not installed, a dialog shows the official install command, and 一键安装 runs that vendor's official install script in the background — a fixed command that does not go through a language model: claude.ai/install.sh, chatgpt.com/codex/install.sh, code.kimi.com/kimi-code/install.sh, pi.dev/install.sh, opencode.ai/install, x.ai/cli/install.sh; on Windows each one's install.ps1, except OpenCode, which has none and uses npm install -g opencode-ai. The log is shown live, together with the megabytes downloaded so far. On failure the dialog gives the cause read from the log (network, region restriction, missing Node.js) and offers 在终端中安装, which opens a terminal running the same command so that the script's questions can be answered. Once installed, if the CLI is not signed in the dialog moves on to 登录: a terminal opens with the CLI's own login command (claude auth login / codex login / kimi login / /login inside pi / opencode auth login / grok login), the user authorizes in the browser with their own account, and the dialog closes when polling sees the login; the app never handles or stores a token. In the new services/runtime/engine_setup.py the install subprocess's environment is stripped of VIDEOAGENTS_* variables and of secrets (including the desktop Agentics login token), and the install is stopped only after 10 minutes with neither output nor download growth (overall cap 2 hours), because a fixed time limit would kill slow downloads. When the sign-in state cannot be determined, for example with a custom provider key, nothing is shown. resolve_cli_executable falls back to each vendor's default install location when the command is not on PATH, so a fresh install is detected and usable without restarting the service (no fallback when a *_BIN environment variable is set). /engines/<e>/availability gains logged_in / login_method / install_command / install progress / local_backend; new endpoints POST /engines/<e>/install (mode=background|terminal), GET /engines/<e>/install and POST /engines/<e>/login; install and sign-in are only open to requests from the local machine. The desktop app adds the CLIs' default directories to its search path on Windows (and ~/.pi/agent/bin on macOS), which takes effect with a repackaged build. Exercised for real: a kimi one-click install on an isolated instance (about 480 s on a slow connection) up to the sign-in step, and the Terminal pop-up on macOS. Not exercised: anything on Windows, a complete claude install (only as far as download progress), real browser sign-ins. Known upstream issue: kimi's official install script writes fish_add_path into .zshrc when the rc path contains "fish" (for example in the user name).
Agent advanced settings: 多集并行 switch — episodes are produced one at a time by default (2026-10-09). Agent 高级设置 gains a 多集并行 checkbox under 单 Agent 并发额度 (state.jsonepisode_parallel, off by default; GET/POST /config/agent-advanced carry it). Off, the orchestrator advances in episode order and works on one episode at a time: the current episode is the lowest-numbered one that still has a node outside a terminal state (done / passed / passed_human_override / skipped / cancelled / waived), and the next episode — including its Phase 5 script nodes — is dispatched only when every node of the current one has ended. Publishing nodes (p11-*) are not counted, because a publish chain is often left pending and would otherwise keep the next episode from ever starting; blocked and failed nodes count as unfinished; a finished episode that is marked dirty becomes the current episode again. Inside an episode, groups, shots and scenes still fan out in parallel by dependency. On, the previous pilot-episode strategy applies (episodes run in parallel in bulk once the first one has passed H4), so tick the box to keep the earlier behaviour. The logic is in services/runtime/dagcheck.py: a node's episode comes from for_each.episode or the single epNN in its id, and a non-human node without an episode number inherits one when all of its dependencies belong to the same episode (human gates, and nodes that also depend on a project-level node, do not). New dagcheck.py --frontier reads the switch from state.json live (override with --episode-serial / --episode-parallel) and lists the current episode, the nodes that can be dispatched and the later-episode nodes held back by episode order. The runtime uses the same frontier: the idle watchdog's wake-up message lists only the current episode and project-level nodes plus a note about what is held; when the current episode is stuck (nothing to dispatch, nothing awaiting sign-off) the user is notified once an hour; sign-off tickets are not created for the held gates of later episodes; dispatching agents get a 「多集推进设定」 section in their run prompt according to the switch; and the end-of-run check in the dispatch rules now runs dagcheck.py --strict --frontier. WORKFLOW §8 「试点集策略」 is rewritten as 「集间推进」 and the orchestrator SOUL is updated. The setting was checked in the UI on an isolated instance and --frontier against existing project data; no real dispatch was run.
Scene preview: every shot plate gets a ✏️ 修改 button, and plate revisions accept reference images (2026-10-09). On the scene preview page each image in the 分镜背景图 section now has ✏️ 修改. It opens the edit popup at the bottom right and sends the feedback straight to the shot-plates agent (08-video-gen/shot-plates, not through the reviser), which runs code/revise_shot_plate.py in a new library-plate mode, --scene <id> --key <key> (modules/shot_plates.py#revise_library_plate): the library image is passed as [Image 1], a new <key>_revN is generated and added to the library, and the original image is left untouched. By default every shot that references the original is switched to the new plate — the episode indexes are scanned by scene and key, view / crop and the camera fingerprint are not changed, and the affected groups are synced; --only limits the swap to the named shots and --library-only only adds the plate to the library. Both modes (the existing per-shot one and the new library one) gain --ref for user reference images: local image paths attached with the popup's 「+」 are passed as [Image 2…] (up to 4) and copied to plates/revision_refs/ for the record; edit-popup.js gains a hint option, used here to point at 「+」. A revised plate is now always generated at the nine-grid fill-in size, 1920x1080, instead of following the size of the original image. Also fixed: nine-grid keys repeat across scenes, so the scene preview's 「用于分镜」 line, the swap candidates and swap_shot_plate.py --list mixed in shots from other scenes; all three now filter by scene. The shot-plates SOUL, docs/shot_plates.md and docs/reviser.md are updated, and the new strings are in all 11 UI dictionaries.
Long-take continuation material is enrolled in the virtual avatar library automatically (2026-10-09). A long take continues from the previous group's tail frame or tail segment, and Ark's PrivacyInformation filter rejects such real-person-looking material when it is submitted as is. When the project has long takes switched on, the 虚拟人像资产库 is enabled and the video model is Seedance 2.x on Volcengine Ark, genmedia now enrols the continuation material before it submits a group video, according to the connection mode: the previous group's tail segment (assets/continuity/epNN/<prev>.continuation.mp4, AssetType=Video) or tail frame (assets/clips/epNN/<prev>.last_frame.png, AssetType=Image) is uploaded through a file-hosting presigned URL, and once its review status is Active the request is sent with asset://<id>. New module modules/ark_assets.py; _ark_video_body takes asset_uri_for, which resolves each file after parameter validation has passed, and a dry run enrols nothing. A ledger entry that is already Active is re-checked with GetAsset and enrolled again if the asset is gone (fully automatic management empties the library on an episode change, and assets can be deleted by hand); an asset still under review is waited for, up to 600 s for an image and 900 s for a video. Material that does not meet Ark's asset specification, a failed upload or enrolment, a failed review or a timeout only produces a warning in the generation log (lines starting 「虚拟人像库:」), and the group is submitted the previous way. Material that failed content review is recorded as Failed in the ledger and is not submitted again, while occasional faults such as DownloadFailed are retried next time. The ledger is shared with character images (avatar_assets.json, kind=continuity, slot = project|episode|previous group|type); when the previous group is regenerated, the old asset in the same slot is deleted to save quota. Volcengine OpenAPI signing moved to the shared modules/volc_openapi.py (core._volc_signed_call is now an alias; requests are byte-identical). docs/continuity.md, the video-generation SOUL and WORKFLOW are updated; 15 new tests. Enrolment was not exercised against the real service.
Whitebox reel: can be produced before the H3W sign-off, from preview-grade group videos (2026-10-10). The 🧊 白模样片 at the top of the Storyboard preview page is what the H3W decision is made from, yet the reel needs every group's camera.mp4 and the whitebox-staging spec forbade exporting before the sign-off — so pressing 重新生成白模样片 on an unsigned episode ended with the agent refusing, while the button's own work order told it to render the missing groups first. What that rule protects against is paying for shot plates before the sign-off and wiring an unconfirmed whitebox into prompts (code/render_shot_plates.py treats the formal assets/whitebox/<ep>/<grp>/manifest.json as proof of sign-off, and a formal export ends by running sync_whitebox_refs); the rendering itself is a local headless browser plus ffmpeg and costs nothing. code/concat_whitebox.py now compiles the current whitebox in memory (the episode.json on disk is not changed), renders a preview video into directing/<ep>/whitebox/preview/<grp>/ for every group whose formal video is missing or whose source fingerprint has changed (same renderer and format; the manifest carries preview: true), and then builds the reel from each group's preview where one exists and its formal video otherwise. --no-render only joins the group videos that already exist; if a preview fails to render, the command exits non-zero instead of quietly joining old videos. A preview never writes the formal manifest and is never wired into a prompt: after a preview reel render_whitebox.py --verify-export still reports those groups as not exported and the shot-plate script still refuses to run. At the formal export after the sign-off, a group whose preview is still current (source fingerprint, renderer fingerprint and format) is promoted by moving the file (receipt videos.promoted) rather than rendered again; groups whose whitebox changed are rendered as usual. The reel manifest gains preview_groups and whitebox_sources, and a reel whose whitebox was changed afterwards (recompile, Director's console overlay) is reported stale with stale_reason=whitebox and whitebox_changed naming the groups. The status line shows 「预览版 · n 组未正式导出」 and 「白模已改(n 组),样片需重出」, and the missing-group note now reads 「n 组尚无白模视频(生成样片时自动补出)」. The whitebox-staging SOUL now forbids only the formal export in a staging work order and gains a conditional preview-reel section: a staging work order builds the reel once before delivery, a revision work order refreshes it only when the episode already has one, and a failure for lack of Chromium does not block delivery (receipt reel_preview). The orchestrator SOUL, WORKFLOW, docs/whitebox.md, code/README.md and all 11 dictionaries follow. Run for real on one unsigned episode (four preview groups, an 88 s reel); promotion was run on a temporary copy. Restart the service: until then the reel plays, but the status line wrongly reports it as possibly stale and the groups as missing.
Episode plan: derivative (original-story) mode is now a formal contract (2026-10-09). A project that only borrows the source novel's world and does not adapt its plot could never pass the three event-reconciliation machine checks: in one such project the episode plan failed events_classified_once (121 events unclassified) and treatments_complete (all 10 episodes), the screenplay failed dramatized_events_covered (its scenes say [EVENTS] none), and the colour script under --strict had 70 entries without event_refs. The orchestrator could only waive them one work order at a time, under a derivative_mode field it had invented itself and that no contract or code recognised. The field is now defined, as a conditional whitelist: only when brief.md or the user's instruction states in so many words that the source plot is not adapted (「不改编原著情节」 / 「不围绕原小说情节」 / 「只借原著世界观做原创衍生」) may the top level of episode_plan carry derivative_mode: true plus a derivative_note quoting those words and their source, with every episode's events / treatments empty — the whole work is derivative, never half adapted and half waived. A failing check is not a reason to set the field; the orchestrator no longer waives derivative projects per work order, and returns a plan that carries the field without such a basis as a defect. In derivative mode verify_plan (modules/episode_treatments.py) reports the six event checks (events_classified_once, treatments_complete, dramatize_within_cap, hook_points_dramatized, cut_not_on_causal_chain, merge_target_valid) as SKIPPED, adds derivative_mode_consistent (note present, events / treatments empty) and still runs duration_in_budget and ids_valid; only a boolean true counts — a string such as "true" is reconciled as an adaptation, with a WARN. verify_screenplay skips its three event checks, accepts [EVENTS] none / [事件] 无 on a scene's meta line as the legal placeholder, warns when a scene attaches an ev… id, and still runs scene_spacetime_continuous. code/verify_episode_plan.py no longer exits 2 when a derivative project has no events.json, and code/check_color_script.py reads the plan and stops checking event_refs (scene_ids remain required). Hook points are written as hook_open / cliffhanger text with no event attached, and the writing_v1 rubric's 「忠实原著」 item rates fidelity to the borrowed world (Bible settings, geography, institutions, character identities) without deducting for not dramatizing the source plot. WORKFLOW §5A, the episode-planner / screenplay / color-script / orchestrator SOULs, docs/screenplay_anchors.md and code/README.md are updated. Across 62 existing projects, only that one project's results changed (it now passes). Checks and specs only: no service restart is needed. (#128, folding in #123)
Subtitles: a basis declaration lets subtitles be shifted from the original rough-cut timeline after the mix is stamped (2026-10-09). Once the mix is stamped against the adopted versions (mix_basis current), the timemap drops its post_versions layer, and the audio track and the subtitles shared that one table — on the assumption that the subtitles had already been produced on the post-production timeline. Nothing recorded which timeline a subtitle file was actually on: in one episode subtitles.srt was still on the original rough-cut timeline (two deleted ranges, −9.709 s), finalization copied it unchanged, the last cue ended at 84.06 s in a 79.833 s final, and subtitle_within_final / intro_offset_ok failed. load_timemap now builds the subtitle table separately from the audio table, driven by a new declaration file edit/epNN/subtitles.basis.json: original (rough-cut basis — the post_versions layer is applied to the subtitles even though the mix stamp skipped it), post (already on the post-production timeline — not applied), or no declaration (follows the mix basis, exactly as before). The group-boundary layer always applies to subtitles, and the audio table is not affected by the declaration. finalize_episode.py gains --subs-basis {auto,original,post}: shift / assemble write the declaration (auto deletes it), probe / check use the value for that run only. With no declaration, when the post layer was skipped and the last cue still maps to more than 0.5 s past the end of the programme, shift / assemble now FAIL with subtitle_basis and print the --subs-basis original command, or the alternative of sending the subtitles back to be redone, instead of silently copying the file; the subtitle_within_final failure message in check carries the same hint. render_captions / caption_timeline go through the same load_timemap, so they follow the declaration as well. Applying the post layer to subtitles unconditionally was not an option: episodes whose subtitles are already on the post-production timeline would have been shifted a second time (16–33 cues per episode in one project, by up to −19.2 s). Existing episodes are unchanged: on four real episodes the subtitle table, the audio table and the mapped SRT text are identical before and after, and none of them hits the new FAIL. WORKFLOW §9B and the subtitle / edit SOULs gain conditional clauses: when post-production deleted ranges, subtitles are aligned to the final audio, and the declaration is written only when they can be delivered on the rough-cut basis alone. (#114)
Burned-in subtitles: new host CLI code/burn_subtitles.py (2026-10-09). With 「内嵌字幕」 on (output.subtitle_burn_in) the edit station was told to burn subtitles_final.* into the picture with ffmpeg's subtitles filter, but the host's ffmpeg is not guaranteed to include libass — a Homebrew ffmpeg 8.1.1 has none of ass / subtitles / drawtext — so the station wrote its own replacement script inside the project, one format per project, with a font size and margins no check could verify. python3 code/burn_subtitles.py burn --project <slug> --ep epNN [--lang en] now does it without libass and with the same output on every machine: it reads the final-basis subtitles subtitles_final[.<lang>|_<lang>].srt (or .ass; the programme-basis subtitles.srt is always refused), draws each on-screen segment with PIL as a full-frame transparent PNG, and overlays the sequence onto a copy of final.mp4, written as final_sub[.<lang>].mp4. The picture is re-encoded at crf 18, every audio track is stream-copied, and the clean final.mp4 is never modified. The style follows the subtitle SOUL: bottom centre, white text with a black outline of about 8% of the font size, no backing box; character height = min(4% of frame height, 5% of frame width), so a portrait frame is limited by its width; bottom margin 3% by default; two lines are kept within 12% of the frame by tightening line spacing first and reducing the font size second; a segment that still wraps to more than two lines at 80% font size is reported as a FAIL for the subtitle station to re-split. The source is checked before burning: a last cue more than 0.5 s past the end of the final, or an intro offset > 0 while the final-basis file is cue-for-cue identical to the programme-basis one (never shifted), is a FAIL and nothing is burned; a subtitle file older than the final is only a WARN. burn then runs check, the machine check subtitle_style_ok: subs_source_ok, style_lines_le_2, style_char_height, style_bottom_margin, style_band_height, duration_match (within one frame, equal frame counts), audio_stream_copy and first_cue_frame (the frame matches the source outside the subtitle area and differs inside it). Results are recorded per output name in edit/epNN/subtitles_burn.json. Other flags: --src, --out, --char-pct (4 or lower), --margin-pct (2–4), --font, --crf, --preset. With burn-in on, the injected output-settings prompt, WORKFLOW §9B and the edit / platform-adapter / subtitle SOULs now allow only this CLI — no hand-written subtitles / ass / drawtext filters and no project-local scripts — and platform-adapter takes final_sub[.<lang>].mp4 as its master, stopping to report when it is missing or failed. finalize_episode.py shift is unchanged. Exercised on a scratch copy of one portrait 1080×1920 final in English and in Chinese (--lang zh): about 25 s per version, all checks PASS. (#133)
Reference files: new 其他 section for files of any format (2026-10-10). The ten categories of the 参考文件 page (风格 / 封面 / 角色 / 场景 / 生物 / 道具 / 音频 / 视频 / 字体 / 文本) are all defined by purpose, so reference material that fits none of them — a PDF setting book, a spreadsheet, an archive, a project file — had nowhere to go. A new 📎 其他 section, placed under 文本, stores such files in refs/other/. It uses the existing upload / note / delete logic: neither the button nor drag-and-drop filters by file type (only the font category checks extensions), the 100 MB per-file limit is unchanged, images / audio / video are still previewed by extension and everything else shows a generic file icon. Existing projects need no migration: without an other/ directory the section lists as empty, and the first upload creates it. Agents do not go through this directory on every work order (WORKFLOW §2 rule 10 and the injected reference-material prompt): a file in refs/other/ is read only when its note in refs/NOTES.md describes a use that falls on what the work order is about, or when the work order names its path. A file with no note that no work order names is not read, not reported and never blocks; a path that is used is recorded in user_refs, a file whose format could not be read is named in the receipt, and the orchestrator names a file in the relevant work order when its note points at a station or an object. The price of that rule is that a file without a note is never used on an agent's own initiative, which is why the section's caption asks for one (「请添加注释说明用途」). All 11 dictionaries carry the caption. Checked in an isolated instance with PDF and XLSX uploads. Restart the service: until then the page receives no other category and uploads to it are rejected.
ComfyUI local / Comfy Cloud templates for Noct Q V4, text-to-image and multi-reference image-to-image (2026-10-10). Noct Q V4 is a photorealistic merge of Qwen Image 2.1. New comfy/image-noctq-v4-int8-api.json (text-to-image) and comfy/image-noctq-v4-int8-ref10-api.json (image-to-image, 1–10 reference images), each with a Chinese / English description; the 「文生图API」 / 「图生图API」 dropdowns on the 生成模型 page list them automatically. Sampling follows the author's workflow (TextEncodeQwenImage21 for both the positive and the negative conditioning, KSampler 25 steps, cfg 3.0, euler / simple, native nodes only), and so do the three model files, including the author's recommended int8 text encoder qwen3vl_8b_int8_convrot.safetensors; with the bf16 encoder already at hand, fork the template and change clip_name in node 2. Image-to-image uses the same files: the encoder node takes images.image_1…image_10 plus the VAE, and genmedia binds the Nth --ref to the Nth LoadImage and removes unused slots; an 11th reference is rejected before submission. Two things differ from the author's workflow. Size: the fixed 1024x1536 becomes an EmptyLatentImage at {{GEN_WIDTH}} / {{GEN_HEIGHT}} (the requested aspect ratio within 1.6 MP) plus a final ImageScale to {{WIDTH}}x{{HEIGHT}}, as in the repo's other Qwen templates; image-to-image samples from that empty latent too, rather than the node's own latent output (sized to the first reference), so the output size follows the request, at the cost that an in-place edit can shift — the description shows how to rewire it. Negative prompt: {{NEGATIVE}} feeds the node's negative_prompt (effective at cfg 3; use negative_modeconditioning); the author's recommended negative string is not hard-coded, because its "rough sketch" / "sloppy lines" terms suppress sketch and illustration styles, and is quoted in the description to pass per call. Requirements: ComfyUI 0.37.0 or later; the model is under the Qwen Research License (non-commercial use only) and can generate adult content. On Comfy Cloud none of the three model files is in the public model catalog, so they must be imported into the account first (the catalog does have the bf16 text encoder, which leaves only the diffusion model and the VAE to import). The templates were checked statically against the Comfy Cloud v0.39.2 node catalog; no real image was generated with them, locally or on Comfy Cloud, and time and VRAM with 10 references were not measured.
ComfyUI local templates for Qwen Image 2.1 GGUF (Q8_0), text-to-image and multi-reference image-to-image (2026-10-10). New comfy/image-qwen-image-2.1-q8-gguf-api.json (text-to-image) and comfy/image-qwen-image-2.1-q8-gguf-ref10-api.json (image-to-image, 1–10 reference images), each with a Chinese / English description, listed automatically in the 「文生图API」 / 「图生图API」 dropdowns on the 生成模型 page. They are the official Comfy-Org Qwen Image 2.1 text-to-image / image-edit templates with the diffusion loader swapped for UnetLoaderGGUF, loading qwen-image-2.1-Q8_0.gguf (another quantization is a one-line change in node 1). The quantized files without "UC" are on the base branch of the abenzerps/Qwen-Image-2.1-Uncensored-GGUF repository and are quantizations of the original weights; only the UC- files on its main branch are the uncensored variant. Sampling is unchanged (TextEncodeQwenImage21, KSampler 25 steps, cfg 1.0, euler / simple). At cfg 1 the negative conditioning takes no part in the computation, so there is no NEGATIVE placeholder: set negative_mode to append_exclusions, or fork the template (raise cfg and put {{NEGATIVE}} in node 4) to make negative terms count. Differences from the source workflows: ResolutionSelector is replaced by an EmptyLatentImage at {{GEN_WIDTH}} / {{GEN_HEIGHT}} (the requested aspect ratio within 1.6 MP) plus a final ImageScale to {{WIDTH}}x{{HEIGHT}}; image-to-image samples from that empty latent with the references as conditioning, rather than editing in place at the first reference's size, so the output size follows the request (an edit can shift as a result; the description shows how to rewire it); references are encoded at the node's default resolution 1024 instead of 0 (original pixels), because the platform's reference images are large and encoding them at original size makes the sequence too long; QwenImage21Cache (set to auto / default, the same as the model's behaviour without the node) is dropped, with a note on adding it back when VRAM is tight; SaveImageAdvanced becomes SaveImage and the ImageCompare preview is removed. Local only: UnetLoaderGGUF comes from the ComfyUI-GGUF custom node pack, which the Comfy Cloud v0.39.2 node catalog does not have, so a cloud submission is rejected with missing_node_type and importing models does not help; the description gives a variant without the GGUF node (node 1 as UNETLoader with a safetensors model), the only form that runs on the cloud. Requirements: ComfyUI 0.37.0 or later; ComfyUI-GGUF from the leejet fork (the architecture list of the original city96 pack has no qwen_image21); the model is under the Qwen Research License, non-commercial use only. The templates were checked statically against the Comfy Cloud v0.39.2 node catalog; no real image was generated with them, and time and VRAM with 10 references and the effect of resolution 1024 on reference fidelity were not measured.
New regression tests (2026-10-09). Five test files are added: tests/test_dialogue_tts_speed_trim.py (dialogue voice library: speed defaults, casting-key compatibility, silence trimming), tests/test_post_audio_lanes.py (the post page's per-lane playback data for narration, BGM and sound effects), tests/test_shot_plates_background.py (the shot-plate sentences written into video prompts), tests/test_storyboard_sketch_pick.py (the sketch picker behind the storyboard preview's 添加参考图) and tests/test_comfy_image_refs.py (reference images and negative prompts in ComfyUI / RunningHub image workflows). Three cases in test_comfy_image_refs had been written against a design dropped on 2026-09-16 (several --ref images composited into one contact sheet) and are rewritten to the current behaviour: --ref images are bound to LoadImage nodes one by one in order, more reference images than LoadImage nodes fails before any request is sent, and a single reference through the Krea image-to-image template folds the negative prompt into the positive one. The test_continuity_refs fixture is now isolated from the machine's own generation-model configuration, which had made two cases fail on a machine with a real configuration while a clean checkout and CI passed.
Changed
The license changes from Apache-2.0 to GPL-3.0 (GPL-3.0-only) (2026-10-10).LICENSE is replaced with the official GPLv3 text from gnu.org (line for line the same as GitHub's license template; GitHub's repository page detects the license from this file on the default branch). NOTICE becomes the standard GPL notice (version 3 only, no warranty, a copy of the license in LICENSE), keeps the origin attribution, and states that releases up to and including v1.0.38 were published under the Apache License, Version 2.0, and remain available under those terms. The license section of README.md / README.zh-CN.md is rewritten to the GPL-3.0 requirements: anyone distributing the project or a modified version must release it under the same license and provide the corresponding source code, keep the license and copyright notices, and mark the changes and their date; there is no warranty. CONTRIBUTING.md's redistribution requirements and contribution license are now GPL-3.0-only, and the license field of CITATION.cff and the license / classifier of pyproject.toml match. The metadata says GPL-3.0-only, not "or later". Dependencies were checked and all can be combined into a GPLv3 work, so THIRD_PARTY_NOTICES.md is unchanged: the package-lock production dependencies are MIT / Apache-2.0 / BSD / ISC / LGPL-3.0 / Python-2.0 / BlueOak / 0BSD plus libsignal (GPL-3.0, brought in by baileys), the pyproject dependencies are MIT / BSD / Apache-2.0 / Unlicense and similar, and three.js, Spark and XiaohongshuSkills, which are distributed with the repository, are MIT. tests/test_smoke.py gains test_license_metadata_consistent (LICENSE is GPLv3; pyproject / CITATION / README / CONTRIBUTING / NOTICE agree).
Plates and scene images are always 16:9, independent of the project aspect ratio (2026-10-09). Background images no longer follow the project (video) aspect ratio, so a portrait project now gets landscape plates too. Fixed at 16:9 (modules/shot_plates.pyPLATE_FMT): the nine-grid sheet and its tiles, nine-grid fill-in images, master plates (panorama reprojection / automatic world-model capture), plates regenerated from feedback, manual captures, and the crop box on the scene preview page. The 💾 背景图 capture in the panorama and world-model viewers follows the same rule: the dashed frame and the off-screen render are 1920×1080, the server-side plates/manual handler requires 16:9, and the stored camera facts compute the horizontal field of view at 16:9. Scene images use scene_plates.PLATE_SIZE = 2560x1440 (they had been 16:9 only by accident, through an exception fallback). What describes the video frame still follows the project aspect: the shot's own camera facts and the in-frame sentences in the group prompt. A master plate's field of view is widened through plate_fov_v so that it can hold a shot wider than 16:9; view_fits / find_master judge by each library entry's actual aspect (entry_aspect), so existing portrait plates keep being reused. When the two aspects differ, the episode index view records fraction_w and the group prompt states width and height separately; for 16:9 projects the wording is unchanged. The environment-concept and shot-plates SOULs and the system prompt's 「输出画幅」 section carry the exception, the experimental scripts (pano_plates_test, world_plates_test, grid9_center_test, grid4_test) use the same rule, and the crop hint is updated in all 11 UI dictionaries. Existing non-16:9 plates are not regenerated automatically.
Image model lists refreshed for OpenRouter and Fal, six new models wired in; OpenRouter images are requested by resolution tier and Fal's GPT Image is sent an explicit width / height (2026-10-10). The lists shared by the 生成模型 page and the preview pages (apps/web/static/image-models.js) were checked against the live catalogs. OpenRouter gains google/gemini-nano-banana-2.1 (now first), bytedance-seed/seedream-5-0-flash, tencent/hy-image-v3.5-preview and black-forest-labs/flux-3-image; ChatGPT Images 2.5 is GPT Image 2.5 (Sunburst precision tier / Flare speed tier), which was already listed and now carries that name in both descriptions. Fal gains google/nano-banana-2.1, openai/gpt-image-2.5/sunburst, bytedance/seedream/v5/pro and flash, blackforestlabs/flux-3 and ideogram/v4.5; the default fal-ai/bytedance/seedream/v5/lite is unchanged. Changes that affect cost: (1) OpenRouter's Images API request now carries resolution for models whose catalog entry lists tiers, chosen by pixel area — the smallest of 1K / 2K / 4K (about 1024² / 2048² / 4096²) with at least 80% of the requested area, or the largest if none reaches it: 1280x720 → 1K, 2560x1440 and the 2858x1608 master plate → 2K, 4K only from about 5.2 MP. The field was not sent before, so every image came out at the model's default tier; 14 of the 24 recommended models now carry a tier (GPT Image, MAI, Muse, FLUX.2 and Recraft have none in the catalog and are unchanged). (2) Models that return image and text (the Nano Banana family, gpt-5-image) went through chat/completions, where aspect ratio and size could only be written into the prompt; those in the catalog now use POST /images first and fall back to chat only on a 404. (3) Fal's GPT Image 2 / 2.5 is sent an explicit width and height instead of only an enum preset such as square_hd / landscape_16_9 (both sides multiples of 16, long side ≤3840, aspect ratio ≤3:1, 655,360–8,294,400 pixels); a size outside these limits is fitted and logged (1920x1080 → 1920x1088), and billing follows the output size. Also: Fal's Seedream 5.0 Pro / Flash output is limited to 1024x1024–2048x2048 total pixels (5.0 Lite / 4.5 go up to 4096x4096), so out-of-range requests are scaled into range first; FLUX.3 on Fal picks its tier (512sq / 768sq / 1k / 2k / 4k) by area and takes up to 10 references; with references, Ideogram V4.5 on Fal treats the first --ref as the base image being edited and up to 4 more as references. Models picked through 「搜索 Fal 模型」 now resolve the edit-image task segment (FLUX.3-style models with references used to get a non-existent endpoint) and, on edit endpoints with a required base-image field plus a reference list, send the first reference as the base image. Scene panoramas: Fal's GPT Image is no longer blocked, since 2880x1440 can now be sent. Not exercised for real: no image was generated — the new models' output, what OpenRouter's Images API actually returns for image-and-text models, how Fal handles out-of-range sizes at run time and a real panorama on Fal's GPT Image are unverified. Known gaps: Fal's Nano Banana still picks its tier by the old rule (long side over 2560 → 4K, so a 2858x1608 master plate comes out at 4K), unlike the area rule above; and the nine-grid sheet's model-maximum resolution does not cover Fal's GPT Image / FLUX.3 / Ideogram. Restart the service for the 「搜索 Fal 模型」 change; the lists apply on a page refresh and genmedia immediately.
Desktop on Windows: FFmpeg is downloaded from the product's own release source instead of WinGet (2026-10-10). On a Windows machine without WinGet (LTSC, Server, stripped-down systems) 「自动安装」 failed with 「未找到 WinGet,暂时无法安装 FFmpeg」 and left only a manual install. The app now downloads a portable build itself — gyan.dev's ffmpeg-9.0.2-essentials_build.zip (GPL v3, 114,768,076 bytes), a separate artifact that is not merged into the Python runtime package — with version, file name, SHA-256, size and upstream address pinned in the new apps/desktop/ffmpeg-artifact.json. On Windows the install tries this route first: download with byte progress, verify, unpack, delete ffplay.exe (about 100 MB) and move the build to <userData>/ffmpeg/current/, keeping the package's LICENSE and README.txt; no administrator rights are needed. If it does not succeed (the index has no entry, no network, checksum or acceptance failure) it falls back to WinGet as before, and with no WinGet either the manual-download prompt still appears, now with the reason the download failed. <userData>/ffmpeg/current/bin is put first on PATH, and is listed before anything is installed, so a backend that is already running finds FFmpeg after the install without a restart and a broken system FFmpeg does not take precedence. macOS still uses Homebrew. The release pipeline mirrors the package: in the publish job of .github/workflows/desktop.yaml the new apps/desktop/scripts/mirror_ffmpeg.py checks ffmpeg/<file name> on both release sources (a missing file, or a size different from the pinned one, counts as missing) and only then downloads from upstream and verifies size and SHA-256; it runs before the GitHub Release is published, so an unreachable upstream fails the release before anything is out, and once both sources hold the file later releases do not contact upstream. The two distributions each use their own address: update_runtime_index.py --ffmpeg-artifact writes ffmpeg.win.x64 {version, url, sha256, size} with url built from the distribution's --public-base (s3.agentics.world or cdn.shumati.cn), the same split as the Python runtime, and the desktop accepts only an address under its own distribution's package prefix on an ffmpeg/ path. Older clients read only the python / desktop sections of the index and are unaffected; they use the new route after upgrading. THIRD_PARTY_NOTICES.md records that an unmodified upstream build is distributed, with its license, source version (FFmpeg commit 946fcce07b) and upstream address. Not exercised on an actual Windows machine. A managed FFmpeg that is already installed is not upgraded automatically when the pinned version changes.
Whitebox: hand channels now follow the character's anatomical left and right, with a migration CLI for existing plans (2026-10-09). The renderer attached right_hand to the shoulder at local +X, but a whitebox character faces local +Z with +Y up, so its anatomical right is local −X: the two channels were mirrored, and a plan that wrote right_hand for a line such as 「右手握…」 moved the left arm. whitebox-renderer.js and modules/whitebox.py now share HAND_SIDES (left_hand at +0.52 × width, right_hand at −0.52 × width); the arm geometry is unchanged. Existing hand tracks were all written as "+X = right_hand", so with the renderer change alone every arm would cross the chest. The new code/whitebox_hand_migrate.py swaps the key names right_hand ↔ left_hand and leaves every coordinate alone, so the rendered picture is the same before and after (identical frame hashes). Migrated, per episode, is the whitebox data the host consumes: the keyframes of actors / scene_actors / extras in whitebox_plans/<grp>.json (each plan gains a top-level "hand_convention": "anatomical"), the Director's console overrides.json, the versions/<grp>/v<N>.json snapshots and whitebox/episode.json — by textual replacement at the located JSON paths, so every other byte stays as it was (3.0 does not become 3). cameras[].placement_fingerprint values, Director's console group approvals and latest-version hashes that were valid before are re-stamped on the migrated content; ones already stale stay stale. Deliberately not migrated: the copies of hand keys that agents wrote into shots/*/blocking.json, shot_list.json and assets/prompts, which are only counted and listed — the characters[] of blocking.json is part of the camera contract's source fingerprint, so editing it would invalidate the whole episode — and the archives under runs/, qa/ and backups. The default is a dry run: each episode is recompiled from the migrated files in a temporary shadow directory and must come out as the original with left and right swapped, with no new compile error and no lost group; an episode that does not is marked blocked and skipped. --apply requires --project <slug> or --all and backs the originals up to qa/whitebox-hand121-<time>/before/, --rollback <backup dir> restores every file not edited since, and the ledger directing/whitebox_hand_migration.json makes a second run a no-op. Nothing is migrated automatically: after upgrading and restarting the service, run --apply on each project still in use before dispatching any whitebox staging. The dry run also lists plans whose text says right or left hand while the channel used ends up on the other side — there the whitebox had been moving the wrong hand all along, and the plan needs restaging. Because the un-migrated copies can lead an agent to write the old convention back, the compiler adds a WARN 「手臂横穿胸前」: a hand channel whose target lies on the far side of the body midline in half or more of its keyframes and passes it by more than 0.25 × the character's width (frames with the two hands within 0.2 × the character's height of each other count as both hands on one object and are left out; creatures are not checked; a brief reach across the body does not trigger it). It is a WARN only, changes no compiled output, fingerprint or picture, and is shown in Chinese only under 「建模依据与检查」 in the whitebox panels; migrated projects compile with none, un-migrated copies of them with up to 134 per project. The whitebox-staging SOUL gains matching conditional clauses (keep hand_convention when editing a plan that has it; swap the key names when copying hand keyframes from pre-2026-10 blocking.json / shot_list copies; on this WARN, swap, or keep an intended crossing and say why in basis). Because the renderer file changed, every previously exported whitebox video is reported stale (re-exporting gives the same picture). (#121)
Post page: ticking 片头 / 片尾 now asks for the content first (2026-10-09). On the 后期处理 page, ticking 片头 or 片尾 opens a 片头内容 / 片尾内容 dialog. The box may be left empty, in which case the system improvises in the project's style. The switch and the text are saved only on 提交; cancelling reverts the tick. A ticked item gets a ✎ 内容 button for changing the text later. Unticking still saves immediately, and the text is kept. The text is stored in settings.json#packaging.intro_notes / outro_notes; the backend validation and the injection into the packaging station already existed. New strings are in all 11 UI dictionaries.
Console chat input: Enter sends, Shift+Enter starts a new line (2026-10-09). The two keys are swapped (it used to be Shift+Enter to send and Enter for a new line). The 修改意见 popup (edit-popup.js) shares the shortcut and is swapped as well. Both skip an Enter pressed while an input method is composing (isComposing / keyCode 229), so confirming a candidate with Enter in a Chinese, Japanese or Korean input method does not send the message. The placeholder texts are updated, in all 11 UI dictionaries too. The note dialogs on the script preview and storyboard pages keep Shift+Enter to save.
New-project wizard: the 模型限制 preset defaults to the video model currently in effect (2026-10-09). When the wizard opens, the 模型限制 step selects the Seedance 2.0 / Seedance 2.5 / MiniMax H3 / Wan 3.0 preset from the family returned by GET /config/video-model, that is, the video model in effect on the 生成模型 page (the whitebox switch follows the model as before: on by default only for Seedance 2.5). A model that fits none of the presets leaves the default, which is now MiniMax H3 (it was a fixed Seedance 2.5), and a yellow note says so and asks to check the four limits against that model's real capabilities. Once a preset is clicked or a value edited in that step, a late response no longer overwrites it; no note is shown when Agentics is not signed in and its profile cannot be fetched; the existing note for a selection that differs from the model in effect stays. The family comes from a new keyword rule, core.video_family_for (lower-cased, non-alphanumerics folded to -, matched in order, first match wins): minimax together with h3, or hailuo-3 / hailuo-h3 (OpenRouter's minimax/hailuo-3[-max] does not contain "h3") → mmh3; seedance / sparkvideo followed by 2.5 → sd25; any other seedance / sparkvideo 2.x, including fast / mini / global (sparkvideo-2.0 is the RunningHub model API's alias for Seedance 2.0) → sd20; wan-3 → wan30; Seedance 1.x, Hailuo 2.3 / 02, Wan 2.x, Kling, Veo, Vidu, Sora, LTX and the like match nothing. video_model_family now uses this rule: Agentics matches on the profile name (with no profile selected, the first catalog entry, as genmedia does); ComfyUI looks at the workflow file name or the selected RunningHub workflow's note first and then at the whole workflow JSON (RunningHub from the local cache only, no network). The older checks (is_minimax_h3, is_seedance20 and so on) are untouched, so the prompt skill and the reference-material limits still do not recognise OpenRouter hailuo-3 or RunningHub sparkvideo-2.0. 40 new tests in tests/test_video_model_family.py. Not exercised: real profile names with Agentics signed in.
Fixed
Slug-style character and creature IDs are recognised, and ID extraction is unified in modules/entity_ids (2026-10-09). Projects whose IDs are pinyin or English slugs (CHAR-jie-rui-er, CHAR-alice-sister) rather than numbers were not understood: speakers were treated as "speaker is not a character / creature ID" and a whole episode's dialogue voice was skipped; the script breakdown came out with an empty cast and no speaker IDs; the local voice library picked a voice for slug characters as if they were the narrator; and English character names could not be looked up. ID extraction now lives in one place (#117; #120, #122, #125 and #127 are merged into it): CHAR_ID_PAT / ACTOR_ID_PAT, extract_actor_id, normalize_actor_id, is_actor_id and known_actor_ids (the IDs in index.json plus the character-card directory names). The ID part is either digits only or an ASCII slug that starts with a letter and is split on hyphens; it ends at Chinese text, _, a bracket or whitespace. Numeric IDs stop at the digits exactly as they did before, so CHAR-0093-v1 is still CHAR-0093; for slugs, when the project's registered IDs are available, the longest registered ID at a hyphen boundary wins (CHAR-alice-v1 → CHAR-alice, while CHAR-alice-sister stays itself). It is used by check_dialogue_fit, check_generation_groups, dialogue_tts, voice_variants, offscreen_lines, native_lead, dialogue_direction, script_breakdown ([CAST] lines, dialogue parentheticals and leading IDs), storyboard_board (string dialogue_ref), the storyboard preview's dialogue index in services/runtime/core.py and timbre_selector (the old CHAR_0001 underscore form is kept); the prose_hygiene "internal ID" detection recognises slug IDs as well. name_index now also registers the ID itself and original_name as keys, after canonical names and aliases and without overriding existing mappings. #127: for a speaker with no pace setting, a draft placeholder est of 0 is no longer taken as a recorded value — missing, non-numeric or ≤ 0 counts as no record and the project's median pace is used, the real value being written back only with --write-est; a speaker that has a pace and a recorded 0 is still reported by line_est_consistent. Checked read-only against all 241 existing project episodes: numeric-ID projects give the same results as before, and slug projects now resolve their speakers and cast. No data file is rewritten and no whitebox or plate fingerprint changes; in a slug project check_time_budget at script scope now uses the character cards' pace, so its estimates change. Restart the service.
Shot-plate library entries are no longer lost when plate jobs overlap (2026-10-08). Generating a plate takes from tens of seconds to several minutes, and the result was saved by writing back the whole library as it had been read before generation started, which overwrote any entry another process had added in the meantime. In one project the nine tiles of a scene's nine-grid were lost twice, first to a fill-in image and then to a 修改 (the data was restored from the per-tile sidecar files). The library is now re-read after the nine-grid has been generated, and fill-in, master and revised plates are saved through upsert_library_entry, which takes a file lock, re-reads the library and replaces or appends only its own entry.
Dialogue voice library: quiet lines are no longer trimmed as silence (2026-10-10). In one episode the line 「Some of them have booked this hall every year of their lives.」 lasted only 3 seconds on the Storyboard preview page and stopped before its second half. The synthesized audio was complete at 5.74 s, but its direction asked for a quiet, low delivery and the whole line was very soft (peak −30.8 dB, mean −50 dB, 10–20 dB below the episode's other lines). Leading and trailing silence was trimmed on entry to the library with a fixed −35 dB threshold, so the last 2.5 s of quiet speech (100 ms RMS −44…−60 dB, where real silence measured −77 / −96 dB) was cut as silence and the library file was 3.19 s. The ledger had flagged the line (checks.est_vs_actual, ratio 0.56), but only as a WARN the page does not show. modules/dialogue_tts.py now measures each line's peak (peak_db, ffmpeg volumedetect) and uses silence_threshold = min(TRIM_NOISE_DB −35, the line's peak − TRIM_PEAK_HEADROOM_DB 18); the same threshold serves the head / tail trim and the pause detection of the paced copies, and the ledger's trim also records peak_db / noise_db_used. With 18 dB of headroom, 290 of the 296 existing source lines (peak −17 dB or louder) keep the same threshold and the same trim, and only 4 lines change by more than 0.05 s (the line above: 3.19 → 5.74 s). 20 dB was rejected because it lowers the threshold for 17 more normally loud lines, one of which then kept an extra 0.06 s, overshot its target duration by just over 2% and lost 0.85 s of in-line pauses to pacing. The trim parameter stamp changed, so each project's existing library is re-trimmed from _raw/ on its next sync — nothing is re-synthesized and nothing is billed — and lines whose trim is unchanged come out as before. Two related points are not addressed: the reel's dialogue track (dialogue_track.build_track) is not loudness-normalised, so a quiet line still plays quietly in the reel, and pacing squeezes every in-line pause over 0.3 s down to 0.3 s once a line exceeds its target by more than 2%, which can remove pauses the direction asked for.
Time scale: English and other Latin-script lines are now costed by word (2026-10-09).modules/time_cost.py counted every Latin letter as one effective character and divided by the Chinese speech rate (PACE_RATE, 4.2 characters per second, or the voice card's speed_cpm in Chinese characters per minute), so 「Dr. Seldon can't know you were here.」 was estimated at 7.53 s (27 letters, plus the dot of "Dr." counted as a pause). Measured on 39 real seed-audio English samples, speech runs at 1.72 words per second; with no speech rate set, the old estimate was on average about twice too long, and check_dialogue_fit, check_time_budget, line direction and the off-screen line window all ran on it — lines were wrongly judged not to fit, and episode extension and dialogue trimming were triggered for nothing. Russian and other non-Latin alphabets had an effective-character count of 0 and were not costed at all. The script is now recognised per line: a line containing Han characters, kana or hangul, or only digits and punctuation, is costed exactly as before; any other line (Latin, Cyrillic, accented letters…) is costed by word. A word is a whitespace-separated piece containing a letter or digit (can't and well-known are one word each), with letters ÷ 6 as a floor so that one long word, or a script written without spaces, is not costed as a single word. The word rate is the midpoint of the character's voice.jsonspeed_wpm, otherwise a conversion of speed_cpm (× 130/252, which keeps characters' relative speeds; a speed_cpm above 400 is taken as letters per minute and divided by 3.8), otherwise a deliberately conservative default of 130 wpm, then multiplied by the pace-tier factor. The dots of abbreviations such as Dr. / Mr. and decimal points no longer count as in-line pauses. check_dialogue_fit now uses time_cost instead of its own copy of the counting code (a speaker without speed_wpm gets the median of the characters' word rates), words its messages for such lines as 「N 词 × W wpm」 and gives their trim targets as target_words / cut_words (lines under 3 words are exempt); check_time_budget, the off-screen line window, and line direction's target duration and acceptable range (1.2–4.0 words per second) use the same figures. Voice cards in Latin-script projects must carry speed_wpm (voiceprint SOUL, conditional). The effective-character range, which had come to include U+A000–F8FF after a literal endpoint in the source was normalised from U+F900 to U+8C48, is now written as an escape. Recomputed over all 241 project episodes: Chinese lines are unchanged (0 of 190 script-level lines in one project), a few all-English lines in Chinese projects got shorter, and one English-language project's script-level dialogue over ten episodes went from 551.5 s to 365.2 s. An English-language episode already in production reports line_est_consistent FAIL until check_dialogue_fit --write-est is run once; estimates only get shorter, so no new duration failure appears. Restart the service. (#124, folding in #126)
Time scale: prompt_beats_bound no longer counts negation or degree uses of 「再」 as a sequence connective (2026-10-09). The pattern behind the check counted every 「再」 except 「再次」. A Shot paragraph that has to quote the upstream blocking performance.end_state verbatim — 「…不再回头看我」 in the reported case — was therefore charged one connective for 「不再」 and failed whenever its beat budget was exactly used up, and the prompt station is not allowed to reword a quoted fragment. The pattern in modules/time_cost.py is now (?<![不别没未莫勿无一])再(?![次也不无没度三]): 「再」 preceded by a negation or by 「一」 (不再 / 别再 / 没再 / 未再 / 一再), or followed by 次 / 也 / 不 / 无 / 没 / 度 / 三 (再次 / 再也 / 再不 / 再度 / 再三), is not counted, while 「再抖腕」 and 「停住,再」 still are. The change can only remove matches, so a result can only move from FAIL to PASS and nothing downstream has to be re-run. Recomputed on real episodes, the only episode in production whose result changed is the reported one (over-budget shots 1 → 0); older episodes, where the check only warns, also lose some over-budget shots (32 → 20 and 15 → 12 in two of them). (#119)
Transition check: frames are picked by frame number, fixing false reports at hard cuts and at the last content frame before an end-of-episode cut to black (2026-10-09).render_transitions.py check extracted its sample frames by seeking to a time in seconds, and most sample points (hard cut + 0.25 s, the sample points of dissolves, fades and black holds) fall exactly on a frame boundary. In a concatenated cut with a 1/12288 time base whose deleted ranges are not frame-aligned, frame timestamps run 4/12288 s early: frame 852 has pts 35.499674 < 35.5, so seeking to 35.5 skipped it and returned frame 853 from that file while the rendered output returned frame 852, hard_cut_positions_intact reported 「sh007->sh008 差 13.5」, and transition_render_ok and finalization exited 1 (#115). For an end-of-episode cut_black the last content frame was derived as container duration − hold − 1 frame; the container duration is rounded to milliseconds (89.542 against an actual 89.541667), which stepped past the last content frame into the first black frame, and transition_frames_verified reported a difference of 70.1 against the source's last frame (#116). Every frame the check fetches — hard-cut samples and the sample 0.5 s before the end, both sides and the midpoint of dissolves and style seams, the three dip points, fade end frames, black pads and freezes, insert-clip midpoints, bridge first and last frames, before and after text overlays — is now addressed by frame number: seconds are converted to a frame number (rounded, with an exact half frame always rounded up, so that source and output stay aligned after a whole-frame shift) and the frame is fetched by seeking to (n − ½)/fps, which is not thrown off by a timestamp less than half a frame away from its nominal value. The end-of-episode close is located from the video stream's frame count instead of the container duration (last content frame = frame count − hold frames − 1). Failure messages now include the frame number. Only the check's frame picking changed; rendering, finals and ledgers are as before. On the episode where both were reported, both version chains now pass, and a deliberate one-frame offset is still caught (13.5 / 14.5 against a threshold of 8 at the hard cut, 70.1 against 12 at the close). (#115, #116)
Layout pack: the top-down plan pixel gate no longer depends on Pillow (2026-10-09).scene_layout_pack_ok in code/blocking_map_check.py imported Pillow inside a try. Under an interpreter without Pillow, or on any error while reading the image, it only recorded a "cannot read" WARN, so a top-down plan below the 2560x1440-equivalent minimum still passed with exit code 0 and the gate was silently off. Width and height are now read from the file header by modules/whitebox.image_size (PNG IHDR / JPEG SOFn, standard library only — which also covers the common case of a layout_top.png whose payload is actually JPEG), and an image whose size cannot be read is a violation, 「像素闸门未执行(无法读取图片尺寸:…)」, not a WARN. On 1,726 existing layout_top.png files the old and new readers return the same size for every file, and no layout pack's result changed. (#132)
Novel parsing: p0-parse no longer judges the UNKNOWN-speaker ratio per batch; the ratio is judged on the whole book at p0-merge (2026-10-09). The p0-parse machine check speaker_missing_lt_2pct used a single batch as its denominator. Since the three-stage rework a batch is 10,000–15,000 characters and often holds only 40–60 quoted lines, and with fewer than 50 lines a single UNKNOWN crosses the 2% line (1/47 = 2.13%) — in direct conflict with the same SOUL's hard rule to mark a speaker UNKNOWN with candidates and never guess. A quotation whose speaker the text itself declares unidentifiable can only be UNKNOWN, so such a batch failed every time and redoing it could not help, while the same data passed the check of the same name at p0-merge, on the whole book, at under 1%. On one novel 6 of 77 batches were at or above 2% (the reported one at 3/47 = 6.38%) with the whole book at 39/4852 = 0.8%. In workflow.yaml the p0-parse auto check speaker_missing_lt_2pct is replaced by unknown_speaker_annotated: every speaker: "UNKNOWN" must carry speaker_candidates (an empty array is allowed when the text itself says the speaker cannot be identified; the existing spelling candidates is accepted too) and a one-sentence note on why the speaker could not be decided. p0-merge keeps speaker_missing_lt_2pct (whole book, under 2%). One conditional guard remains at batch level: only when a batch has 5 or more UNKNOWN lines and they make up 10% or more of it does the receipt carry a single WARN line (count, ratio, suspected cause) — no FAIL, and no re-parse on the agent's own initiative; no batch of the novel above reaches it. The novel-parser SOUL and WORKFLOW Phase 0 are updated. Spec change only: no service restart is needed, and no existing project's result changes. (#130)
YouTube draft upload: consecutive episodes each get their own tab (2026-10-09).ytstudio_draft.py preferred to reuse an existing studio.youtube.com tab. When several episodes are published one after another, the previous episode's upload dialog is still on the visibility step waiting for the user to press save, and the next episode's check-login / upload attached to that same tab — either navigating the earlier dialog away or getting stuck on the browser's "leave site?" prompt (seen as a 30 s Page.enable timeout) — so people wrapped the script in their own workaround. check-login and upload now probe a Studio tab before reusing it: a tab whose URL carries the upload-dialog deep link (d=ud, /videos/upload) or whose ytcp-uploads-dialog is visible is skipped with the reason printed (a tab that does not answer the probe within 5 seconds also counts as occupied), and a blank page or a newly opened tab is used instead; a new tab is brought to the front with /json/activate so that it is not throttled in the background. screenshot does not skip such tabs — they are exactly what it is asked to capture — and warns when several Studio tabs are open and none was specified. Two new global options, accepted before or after the subcommand: --new-tab (always open a new tab; check-login / upload only) and --tab-id (attach to exactly that tab; all three subcommands). They are mutually exclusive, and every run prints TAB_ID: <id>. The script only disconnects and never closes a tab, since the user still has to press save in it. SKILL.md documents the tab rule, screenshot --tab-id, a section on uploading several episodes in a row and the Page.enable timeout. The Bilibili / Douyin / TikTok draft scripts attach with the same reuse-by-URL logic and were not changed. Tested against a fake CDP endpoint only: the dialog-visibility probe and /json/activate have not been run against a real YouTube Studio page. (#129)
Browser version: saving generation-model settings always returned 401 (2026-10-09). The default provider for image, video, music, speech, digital human and deepagents is agentics, and api_genconfig_set validated the whole merged configuration against "using agentics requires a signed-in desktop account". In the browser version, which has no desktop sign-in, saving any unrelated setting therefore returned 401 AgenticsLLM requires a signed-in desktop account: syncing the model-assignment mode (agentmodel_mode) on an engine switch and syncing the UI language were both blocked, and the front end showed 「切换失败」 and stalled the startup flow. Only the categories that a save newly switches to agentics are checked now: categories that already were agentics, or are by default, no longer block the save, other settings are saved normally and the provider is kept as it was. Choosing AgenticsLLM in the browser version still returns 401 and writes nothing. 3 new cases in tests/test_genconfig_agentics_gate.py.
Portrait (9:16) projects: preview pages no longer show over-tall panels and videos (2026-10-10). Several previews sized their media by width only, so a 9:16 picture became far taller than the window. Storyboard preview page: the whitebox panel now has a maximum height of min(60vh, 520px), with the width scaled to the aspect and centred (the lazy-mount placeholder uses the same height). 成片发布 page: the final video has a maximum height of min(70vh, 640px), and once the metadata shows it is taller than wide the card is narrowed to the video's width. The 动态样片 player on the storyboard page and the 白模样片 player on the storyboard preview page were fixed at width: min(100%, 720px), so a 9:16 reel was laid out at 720×1280 and then cut off by the section body's 70vh / 60vh limit into an inner scroll area in which the picture and the playback bar never fitted on one screen; portrait reels are now capped at a height of min(70vh, 640px) with the width derived from the aspect, the section bodies have no max-height any more, and the box has its final size before the metadata arrives (the aspect is taken from the project on the storyboard page and from the reel manifest's width / height on the storyboard preview page, then corrected once from the video's real size). The scene preview page's 3D 白模 panel no longer follows the video aspect at all and is fixed at 16:9 (960×540), while the per-group whitebox panels on the storyboard preview and storyboard pages keep the project aspect, because they show the final framing; 创建全景图 is not affected. Landscape layouts are unchanged (a 16:9 reel is still 720×405). These are static-file changes: refresh the page, no restart needed.