comic-panel-prompt-builder
Phase-1 step of comic-author — the DETERMINISTIC compiler (搬运工原則) that turns ONE gate-approved panel_spec + its status:locked blueprint into the EXACT fixed-section bake string for the spiral engine via the shipped scripts/build_prompt.py (+ the canonical scripts/_validate.py vet
Install
npx skills add https://github.com/wanshuiyin/ARIS-Movie-Director/tree/main/skills/comic-panel-prompt-builder
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install wanshuiyin-aris-movie-director@llmmart
git clone https://github.com/wanshuiyin/ARIS-Movie-Director.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole wanshuiyin/aris-movie-director collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
comic-panel-prompt-builder — the Deterministic Bake Compiler (搬运工原則)
The last authoring step before a pixel ever gets baked. Given ONE upstream-approved panel_spec and its
status: locked blueprint, this skill COMPILES — it does not brainstorm — the exact message
comic-director (Phase 2/3) will hand to the agent mcp__codex__codex sidecar
bake (Codex's native image tool), plus the locked identity-ref list. It is the comic twin of the framework's
shot-prompt-builder: a pure Layer-3 transform
with no Codex call in the happy path — it only emits a prompt_bundle; the spiral engine submits it.
Everything the generation backend ever sees passes through here, so this is where the 搬运工原則 is
enforced at emit time: the bake backend (Codex's native image-generation tool; the underlying image model is
not pinned or attested by this repo) owns optics; ART_BIBLE.md owns style; no camera /
lens / lighting / "8K hyperrealistic cinematic" padding leaks into the bake, and no bubble text gets
baked unless the panel is explicitly text_mode: "baked". The author (storyboard/blueprint layers) decides
what; this skill decides nothing — it transcribes a locked spec into a backend-legal string and FAILS
(it never papers over) when the spec is malformed.
panel_spec (status:locked) ─▶ ① RESOLVE — load panel_spec + its blueprint + ART_BIBLE style_prefix
+ blueprint (status:locked) enforce 4 prerequisites (node_type, blueprint status:locked, REAL refs, style_prefix resolves)
▼
② COMPOSE — fixed-section condition_string (order is load-bearing, do NOT refactor)
▼ style_prefix + [BAKED DIALOGUE only if text_mode==baked] + SCENE + FIXED ELEMENTS
▼ + ALLOWED CHANGE vs prior panel + FORBIDDEN + SCREEN-TEXT WHITELIST(expected_literals)
▼
②·5 INJECT (retry only) — active failure_mode positive-invariants to the FRONT
▼
③ VALIDATE — banned-vocab + length + no-baked-bubbles + real-refs on the COMPOSED string
▼ (the hard gate — ANY hit = fail-closed, push the fix UPSTREAM)
▼
④ EMIT — write the prompt_bundle node; route html_bubbles[] to the JSON viewer ONLY
▼
pass? ─ no ─▶ exit 4 (banned/length) | exit 7 (failure_mode trigger still fires) | exit 3 (prereq)
│ yes
▼
⑤ stdout one-line JSON {prompt_bundle_id, panel_id, source_blueprint_id}
Role
You are a fail-closed transcriber, not an author. You take a panel_spec that an upstream layer already
gate-approved and its blueprint that the cross-layer gate already flipped to status: locked, and you
produce the single exact composed_prompt (style_prefix + a fixed-section condition string) + the locked
identity-ref paths that the spiral engine will bake — together with the deterministic proof (a clean
banned_vocab_scan) that the message is backend-legal. You never invent a scene element, a number, a cast
member, or a style adjective; you never silently fix a malformed spec (you FAIL and point upstream); and
you never call Codex on the happy path (a Codex call only ever appears as a reported upstream bug). The
cross-model judgement of the baked pixels is not yours — that lives downstream in comic-director's
panel_gate; keeping you deterministic is what stops the executor from self-acquitting.
Node — what this skill reads / writes (schemas/node_schema.json)
- Reads
panel_spec(node prefixpanel:) — payload fields used:source_storyboard_id,page_id,panel_id,sequence_index,page_type,world,asset_ids[],text_mode,expected_literals[],content_blueprint,bubbles[],side_narration,motifs. Thepanel_specmust be its layer's gate-approved output (status: locked), not a draft. - Reads the upstream
blueprint(node prefixblueprint:, found via the panel'scontent_blueprint) — payload fields used (the schema's 9blueprintrequired fields, verbatim fromvalidate_wiki.pyPAYLOAD_REQUIRED):source_panel_id,content_svg,expected_literals[],safe_zones[],html_bubbles[],crop,negative_space_policy,generator_script,file_sha256. Refuse unless the blueprint isstatus: locked— that is the schema signal that the cross-layer gate signed off (the gate's ⑥ FLIP writesstatus → lockedon advance;comic-blueprint-authoremits blueprints atstatus: locked). Theblueprintnode has NOreview_statusfield (only theassetnode does, and"approved"is not even a legalstatusenum value) — never gate onreview_statushere.html_bubbles[]is the JSON viewer-overlay payload — it is routed to the viewer, NEVER concatenated into the bake string. - Writes one
prompt_bundle(node prefixprompt:, node_id a lowercase[a-z0-9_-]+slug of thepanel_id) — payload required by the schema (the 6prompt_bundlePAYLOAD_REQUIRED fields, verbatim):source_panel_id,source_blueprint_id,style_prefix,composed_prompt,banned_vocab_scan,identity_ref_paths. Status canon: authordraft/pending→under_review→lockedon a clean validate (orrejected). Write LEGAL edges (everytype∈validate_wiki.pyEDGE_TYPES):derived_from(prompt_bundle → panel_spec) anduses_blueprint(prompt_bundle → blueprint). On retry, the failure_mode trace uses a legalderived_fromedge (prompt_bundle → failure_mode, withevidence.consulted_failure_mode+ the--reasontext — no match score; scoring is planned, not shipped); if a trigger still fires after repair, a legalfailure_ofedge (failure_mode → prompt_bundle —failure_ofpoints failure_mode → target, matching the gate + the script, trigger inevidence). There is NOgenerated_from/consulted_failure_mode/violates_failure_modeedge type in EDGE_TYPES — those would fail the release gate. Emit node IDs only to stdout — the orchestrator re-reads the node file from disk (no filename guessing).
Two engine contracts to compile to (fail-closed)
These are the same two contracts comic-author authors to — this skill is where they are enforced at emit
time, on the resolved spec, before any credit is spent:
- Every panel needs a content_svg. On the wiki node this is
blueprint.payload.content_svg(top-level on the blueprint payload — NOT nested under anycondition.key; thecondition.content_svgprefix exists ONLY in the runtimecomic.jsonthe engine reads). The blueprint'scontent_svgis the content authority + the bake's layout reference. If it isnull/ missing / not on disk (or itsfile_sha256mismatches) → refuse (exit 3). A scene-only panel still has a layout blueprint SVG; there is no such thing as a panel with nocontent_svg. - A
bakedfigure-panel must declare non-empty, ascii-tokenizableexpected_literals. Iftext_mode == "baked"and the blueprint carries a figure butexpected_literalsis empty → refuse (exit 3). Those literals become the SCREEN-TEXT WHITELIST block (below) and are exactly whatpanel_gate's blind token-diff later verifies. A scene panel with no audited numbers must betext_mode: "html"(dialogue is an HTML overlay, no baked-literal contract).
Procedure (numbered — an agent can execute this step by step)
The shipped entrypoint (do NOT hand-roll the validator):
python3 skills/comic-panel-prompt-builder/scripts/build_prompt.py <project_dir> <panel_id|panel:slug> \
[--retry --reason "<text>"]
scripts/build_prompt.py IS this procedure made executable — it reads the panel_spec + blueprint nodes
from <project_dir>/wiki/nodes/, resolves the identity lock + refs, composes the fixed-section message, and
calls the single canonical scripts/_validate.py (which owns MAX_MESSAGE_CHARS = 4000, the ~80-pattern
BANNED_VOCAB, no_baked_bubbles, real_refs — imported, never duplicated). It writes the trace files
wiki/prompt_build/<panel_slug>/{_resolved.json,_meta.json,_validation.json} and, on a clean validate, the
prompt_bundle node + its legal edges. --retry REQUIRES --reason (exit 2 otherwise) — a retry must
name the active failure it is repairing. Run python3 scripts/_validate.py --composed <f> --text-mode <m> --bubbles ... --refs ... standalone for a quick re-check of an already-composed message.
- Phase −1 · parse + scaffold. Parse
$2(thepanel_idorpanel:slugnode_id); makewiki/prompt_build/<panel_slug>/.--retrywithout--reason→ exit 2. The emitted node_id isprompt:<lowercase-slug-of-panel_id>(the schemanode_idpattern is[a-z0-9_-]+; the displaypanel_idmay carry case). Never guess output filenames. - Phase 0 · resolve + prerequisites (refuse to proceed unless ALL hold). Load into one
_resolved.json: thepanel_specnode; itsblueprintnode (viapanel_spec.payload.content_blueprint); and thestyle_prefixfromART_BIBLE.md(the world-keyedSTYLE_PREFIX[<world>]:line — §0 register + §0.5 two-world palette). Enforce:- (a)
node_type == "panel_spec"andstatus == "locked"— else exit 2 (input missing/malformed). - (b) the upstream
blueprint.status == "locked"— the schema signal that the cross-layer gate signed off (the gate's ⑥ FLIP writesstatus → locked). Theblueprintnode has noreview_statusfield; gating onreview_status == "approved"is unreachable (not even a legalstatusvalue) → never do it. Optionally also assert the legaldecisionnode /decidesedge for this blueprint exists as gate proof. Else exit 3. - (c) every ref is a REAL locked asset path, not a placeholder:
blueprint.payload.content_svgexists on disk (andfile_sha256matches), and each identity ref — resolved by followingpanel_spec.payload.asset_ids[]to its identity-sheetassetnode (the asset must bestatus: locked, and publishes a real.pngvia itsref_requirements) — is a real.png(NOTpending:*, NOTnullwhere one is required). Anullref is allowed ONLY as the documented "use project canonical" fallback; an unresolvedpending:*→ exit 3. - (d) the
style_prefixresolves and is sane: ≤ 200 chars (hard), ≤ 32 words (warn). Else exit 3. Also resolve asset names (scene/prop/identityasset_ids[]→ display names) for the whitelist/reviewer context only — names go in the whitelist, raw IDs never go in the prompt.
- (a)
- Phase 1 · compose the condition string DETERMINISTICALLY in this EXACT load-bearing layout (order is
load-bearing — it empirically drives baked-text fidelity; do NOT refactor headings or order without an A/B
test). Front = strongest conditioning weight.
- STYLE PREFIX — from
ART_BIBLE.md: tone + two-world palette + texture, world-keyed bypanel_spec.world(e.g.warm/seam/dark-cyber/starfield). NO camera/lens/lighting/quality vocab. - [BAKED DIALOGUE] — only if
text_mode == "baked"lift the panel'sbubbles[]text into the prompt (so the image model draws legible balloons). Iftext_mode == "html"(orcode): SKIP this block entirely — the bubbles are the viewer's job (see Phase 4). This is the single switch that decides baked-vs-overlay text. - SCENE COMPOSITION NARRATIVE — the scene staging authority is the
blueprint.payload.content_svglayout (the content blueprint already lays out the scene) summarized with the panel'spanel_spec.payload.side_narration. There is NOcondition.scene/condition.characters/chibi_actionon the panel_spec node — those keys live ONLY in the runtimecomic.json(do not read them off the node). The world's warm/cold contrast is by design, not drift. - "FIXED ELEMENTS (must NOT change):" — the identity lock, obtained by following
panel_spec.payload.asset_ids[]to each identity-sheetassetnode and reading itsasset.payload.identity_lock(the canonical-cast hex/beard/silhouette lock — there is NOidentity_descfield on the panel_spec node; the node-model identity authority is the asset'sidentity_lock) + the locked props the motif ledger pins for this panel. - "ALLOWED CHANGE (vs the prior panel):" — from the panel's
motifsdelta (the comic reinterpretation of "ALLOWED MOTION": what may legitimately differ from the previous panel). - "FORBIDDEN (must NOT happen):" — the FIXED baseline
["no new character entering frame", "no off-model drift", "no scene/world recolor"]+ any panel-specificmust_not_add. - "SCREEN-TEXT WHITELIST (exact characters to preserve):" — the
expected_literals[]from the blueprint (e.g.["REJECT","37","T-16:05"]) + any baked-in signage/label names. Even when HTML owns the bubbles, baked-in figure text (chips, stamps, code) still needs char-exact preservation — this whitelist is the upstream ofpanel_gate's literal diff. Write_meta.json(character/element counts) for the trace.
- STYLE PREFIX — from
- Phase 1·5 · failure_mode positive-invariant injection (retry only — the spiral active-memory hook).
This is what the SHIPPED
build_prompt.pydoes — mirror it exactly, do not hand-roll a richer version. On--retry, collect ALL ACTIVEfailure_modenodes (payload.active == true) project-wide — there is NO layer or panel scoping in the shipped code. Sort them bypayload.severityDESC (default 3 — severity ONLY, no recency term), take the top 10, and for each mode whoseencoding_style == "positive_invariant"inject itsrepair_patternUNCONDITIONALLY (no semantic matching against the composed string) — non-positive_invariantmodes are skipped here (negatives like "no missing ears" make diffusion fixate on the negated concept). The injected patterns form one "POSITIVE INVARIANTS (must be present in every panel, highest priority):" block at the FRONT of the message. Record the consult as a legalderived_fromedge (prompt_bundle → the consulted failure_mode) withevidence.consulted_failure_mode: true+ the--reasontext (there is noconsulted_failure_modeedge type). This is exactly the--reasona retry must name. (ASPIRATIONAL — planned, NOT yet implemented; do not describe as current behavior: scoping by the schema'slayerfield (∈ {prompt_pattern, visual_transition, global} — the field islayer, there is notarget_layerin the schema),recency*severityranking (recency= 1.0/age_days), asemantic_signatureweighted-Jaccard match (0.35*assets + 0.25*cterms + 0.20*kw + 0.20*frags, threshold 0.55), and a match score on the edge. The shipped injector is the severity-top-10 unconditional inject above.) - Phase 1·6 · trigger re-scan (retry only). Re-scan the FULL composed message against the
trigger_patternsof EVERY active failure_mode (not just injected ones). In the shipped script, trigger matching gates ONLY the exit-7 path: if a trigger of a non-positive_invariantmode still matches, it persists the bundle asstatus: "rejected"FIRST (so the edge endpoint resolves), writes a legalfailure_ofedge (failure_mode → prompt_bundle —failure_ofpoints failure_mode → target, the trigger inevidence; there is noviolates_failure_modeedge type) and exits 7 (a regenerate signal to the orchestrator). A matchedpositive_invariantmode never exit-7s and there is NO append-in-place repair branch — itsrepair_patternwas already injected in Phase 1·5 iff it made the severity top-10. - Phase 2 · VALIDATE = the hard gate (runs on the FULL composed message; this is the rubric). Delegate to
scripts/_validate.py(the single source):passes_all = length_ok AND no_banned_vocab AND no_baked_bubbles AND real_refs_ok. ANY failure → preserve_validation.json, set the panel'sreview_gates.storyboard_json_gate = "fail", and exit 4 — see the EXACT gate below. Do NOT silently add quotes / fix the spec — a malformed spec FAILS validation so the fix is pushed back to the authoring layer. - Phase 3 · emit. Write the
prompt_bundlenode (payload = the schema's 6 required fields exactly:{source_panel_id, source_blueprint_id, style_prefix, composed_prompt, banned_vocab_scan, identity_ref_paths}); set its statuslocked. Write the LEGAL edges (everytype∈ EDGE_TYPES):derived_from(prompt_bundle → panel_spec) anduses_blueprint(prompt_bundle → blueprint) — NOTgenerated_from(illegal, fails the release gate). Routehtml_bubbles[]to the JSON viewer overlay only — they are NOT incomposed_prompt. Append a timeline entry towiki/log.md. - Phase 4 · stdout. Emit one-line JSON
{"prompt_bundle_id":"prompt:...","panel_id":"...", "source_blueprint_id":"blueprint:..."}— the canonical orchestrator input. Setpanel_spec.review_gates.storyboard_json_gate = "pass".
The EXACT gate (deterministic fail-closed — dimensions + thresholds + veto)
This is a transform, so there is NO cross-model / scored gate here (that lives downstream in
comic-director's panel_gate). The gate is a deterministic contract/banned-vocab validator on the
COMPOSED message — ported verbatim from shot-prompt-builder / segment-intent-builder. Each dimension
is binary; ANY hit is a hard fail (no single-vote averaging — this is detect-only).
length_ok—len(composed_prompt) ≤ MAX_MESSAGE_CHARS = 4000(~600 words, the empirical backend tolerance). Over cap → fail.no_banned_vocab— the 搬运工 v2 validator: ~80 case-insensitive, word-boundary patterns the backend's own agents own. Veto = any single hit fails the whole message. The list below is the human-readable map; the single source of truth isscripts/_validate.py'sBANNED_VOCAB(imported bybuild_prompt.py, so the list never forks). Categories:- Quality padding:
\b8K\b,\b4K\b,hyperrealistic,photorealistic,ultra-realistic,cinematic,professional,award-winning,masterpiece,high/best quality,highly detailed,intricate details,trending on artstation. - Camera:
the camera, cameramoves/pans/tilts/zooms/tracks/pushes/pulls/cranes,wide/close-up/medium/long/establishing/POV shot,over-the-shoulder,dolly,tilt,pan,crane,drone,aerial,orbital,whip-pan,tracking shot,push-in,pull out,zoom,rack focus,focus pull,jib,steadicam,handheld. - Lens:
\d{2,3}mm,f/\d,bokeh,depth of field,shallow DOF,DOF,lens,wide-angle,telephoto,anamorphic,lens flare. - Lighting: bare
lighting,rim/key/fill light,three-point lighting,golden hour,blue hour,studio lighting,soft/hard light,low-key,volumetric,god rays,chiaroscuro. - Engine/brand:
Unreal Engine,Octane,V-Ray,Blender,Midjourney,Stable Diffusion,Sora,Runway,Pika. - Clichés:
epic,breathtaking,stunning,gorgeous,mesmerizing,otherworldly,surreal masterpiece.
- Quality padding:
no_baked_bubbles(comic-specific veto) — iftext_mode != "baked"and any of the panel'shtml_bubbles[]/bubbles[]quoted dialogue text appears incomposed_prompt→ fail. HTML owns the bubbles; baking them is the exact drift this gate exists to catch. (Whentext_mode == "baked"the dialogue is allowed in the BAKED DIALOGUE block by design — this veto fires only for html/code panels.)real_refs_ok(single-source ref veto) —identity_ref_paths[]must be REAL locked.pngpaths, and the ref set is exact: no silent concatenation of extra identity IDs, nopending:*, no rawasset_idstrings smuggled into the prose. (Single-source discipline — the ref-count linchpin; concatenating refs silently is forbidden.)
Exit-code contract (= the verdict surface; scripts/build_prompt.py returns these EXACT numeric codes):
0 validated + emitted + gate pass · 2 input missing / malformed CLI (--retry w/o --reason; panel_spec
not node_type panel_spec or not status: locked) · 3 upstream prereq unmet (blueprint not
status: locked / ref not real / content_svg missing / expected_literals missing on a baked
figure-panel / style_prefix bad / failure_mode query failed) · 4 banned-vocab | length | baked-bubble | ref
fail (gate → fail, _validation.json preserved) · 7 Phase 1·6 regenerate (a non-positive_invariant
mode's trigger_pattern still matches after repair; the bundle is persisted status: "rejected" and a legal
failure_of edge written).
Worked example
The reference movie's per-panel condition blocks —
../../examples/comic_m3_audit/comic.json — are the
compiled-artifact (comic.json runtime) OUTPUT shape this skill produces (the spiral engine inlines exactly
these condition.* keys). They are NOT the node fields this skill reads — those are the flat panel_spec /
blueprint / asset node payloads (the condition. prefix exists only in comic.json). Copy these output
patterns, but source each from its node field as noted:
- A baked figure-panel —
S11(the REJECT verdict). Compiled output (comic.json):condition.content_svg: "assets/reject_verdict_round1_v1.svg",text_mode: "baked",condition.expected_literals: ["REJECT","37","T-16:05"],world: "dark-cyber". Node sources:content_svg←blueprint.payload.content_svg;expected_literals←blueprint.payload.expected_literals; the identity lock ← the identity-sheetasset.payload.identity_lockreached viapanel_spec.payload. asset_ids[]. The compiler emits: STYLE PREFIX (dark-cyber palette, no camera vocab) → BAKED DIALOGUE (becausetext_mode==baked: frompanel_spec.payload.bubbles[], reviewer "Rejected. Fix it, then come back." / executor "...Copy that.") → SCENE COMPOSITION NARRATIVE (from theblueprint.content_svglayout +panel_spec.side_narration: the verdict chamber + the REVIEW·ROUND 1 card) → FIXED ELEMENTS (fromasset.identity_lock: "blue executor brown hair NO beard / green reviewer dark hair beard") → FORBIDDEN (no new character, no off-model drift) → SCREEN-TEXT WHITELISTREJECT·37·T-16:05. Those three literals are exactly whatpanel_gateblind-diffs — they MUST be present and char-exact, and this is why a baked figure-panel that omitsexpected_literalsis refused at Phase 1. - The 搬运工 line in the data. Note what is absent from every compiled
condition.scene/charactersstring (and from the node fields they were compiled from): no "the camera pushes in", no "85mm", no "cinematic lighting", no "8K". The author already speaks backend-legal; the validator is the second line of defense that keeps it that way when a style adjective sneaks in. - An HTML-bubble panel —
S22(the constellation endcard).text_mode: "html",bubbles: [],expected_literals: [], plussafe_zonesfor the HTML tagline. Here the compiler SKIPS the BAKED DIALOGUE block and routes nothing-text into the prompt; the scene string even hard-asserts "NO text, NO glyphs anywhere in the image". For a panel likeS02whose bubbles ARE drawn (text_mode: "baked", bubbles researcher/executor/reviewer), those same bubbles would instead go in the BAKED DIALOGUE block — the identicalbubbles[]field is routed to the prompt or to the viewer solely bytext_mode. That switch, applied per panel, is the whole "no baked bubbles unless baked" guard made concrete. - The single-ref discipline —
S01/S20/S21. Each pins exactly its locked identity sheet (trio_identity_sheet_v001.pngorresearcher_chibi_canonical_ref_v001.png) asidentity_ref— one exact ref set per panel, never a silent concatenation.identity_ref_paths[]in the emittedprompt_bundlemirrors precisely that set.
(The provenance of these panels — the debate→synthesize outline, the MOTIF STATE TABLE that pins each
literal, the user-approval gate — lives upstream in comic-outline-creator / comic-storyboard-creator;
by the time a panel_spec reaches this skill it is locked, and this skill only transcribes it.)
Hard do / don't (earned lessons)
- DO compose the EXACT fixed-section string
style_prefix + condition_stringdeterministically — and on retry inject the activefailure_moderepair_pattern(positive_invariant ONLY) at the FRONT. - DO run the banned-vocab + length + no-baked-bubble + real-ref validator on the COMPOSED message
(catches
ART_BIBLE/style contamination, not just per-field text) — and FAIL closed, never paper over. - DO route
html_bubbles[]to the JSON viewer overlay; the bake string carries dialogue only whentext_mode == "baked". - DON'T call Codex in the happy path — this is a pure transform; a Codex call is only ever a reported upstream bug, never a way to "fix" a malformed spec here.
- DON'T invent or "improve" a scene element, number, cast member, or style adjective — if the spec is
malformed or a ref is
pending:*, exit non-zero pointing upstream (creator bug vs blueprint-not-lockedvs ART_BIBLE contamination), do NOT patch locally. - DON'T let camera / lens / lighting / "8K hyperrealistic cinematic" padding into the prompt, and DON'T
concatenate extra identity refs — the backend owns optics,
ART_BIBLEowns style, and the ref set is exact. - DON'T emit on a blueprint that is not
status: lockedor a panel_spec that is notstatus: locked— refuse (exit 3 / exit 2). And DON'T write an edgetypeoutsidevalidate_wiki.pyEDGE_TYPES (nogenerated_from/consulted_failure_mode/violates_failure_mode— they fail the release gate).
Protocols (governance contracts this skill honors)
artifact-integrity— this compiler does NOT judge the panel it builds; it transcribes a locked spec and emits a deterministic banned-vocab proof. Numbers (expected_literals) are carried through verbatim, never originated; the pixels are judged downstream.reviewer-independence— there is no reviewer in this step (it's a transform). The downstream judge (comic-director'spanel_gate) gets the baked image + the blueprint'sexpected_literals, never this compiler's interpretation.acceptance-gate— a clean validate here is a transform pass, not an acquittal: a deterministic same-model check is allowed to confirm "the message is backend-legal", but the quality/correctness verdict on the result is the cross-modelpanel_gate, never this skill.reviewer-routing— N/A on the happy path (no model call); if a blocked retry ever escalates to an external consult, Codex atxhigh(no model pin — the CLI follows the local codex config) / Geminiauto-gemini-3, never downgraded.review-tracing— every emit logs towiki/log.md; every retry writes a legalderived_fromedge to the consultedfailure_mode(consult flag + retry reason inevidence; no match score — Jaccard scoring is planned, not shipped), and a legalfailure_ofedge if a trigger still fires, so the spiral memory is auditable (both edge types are invalidate_wiki.pyEDGE_TYPES).output-versioning— theprompt_bundlerecordsfile_sha256of the blueprint it compiled, so a re-bake traces to the exactcontent_svgversion.
Files (aris-movie-director)
-
scripts
-
build_prompt.py 20.2 KB
#!/usr/bin/env python3 """build_prompt.py — the DETERMINISTIC bake compiler (搬运工原則) for ONE locked panel_spec. Turns ONE gate-approved `panel_spec` node + its `status:locked` `blueprint` node into the EXACT fixed-section bake message the spiral engine will hand to the backend, plus the locked identity-ref list, then runs the canonical _validate.py vetoes and (on pass) emits a `prompt_bundle` node + LEGAL edges. It is a pure transform: it never calls Codex on the happy path, never invents a scene/number/cast/style adjective, and FAILS closed (pointing upstream) on a malformed spec instead of papering over it. python3 build_prompt.py <project_dir> <panel_id> [--retry --reason "<text>"] Reads wiki node JSON from <project_dir>/wiki/nodes/<node_id with ':'->'_'>.json (the example layout) and the world-keyed style_prefix from <project_dir>/ART_BIBLE.md. Writes _resolved.json / _meta.json / _validation.json under <project_dir>/wiki/prompt_build/<panel_id>/ for the trace. Exit codes (= the verdict surface): 0 validated + emitted + gate pass 2 input missing / malformed CLI (--retry w/o --reason; panel_spec not node_type panel_spec or not status:locked) 3 upstream prereq unmet (blueprint not status:locked / ref not real / content_svg missing / expected_literals missing on a baked figure-panel / style_prefix bad / failure_mode query failed) 4 banned-vocab | length | baked-bubble | ref fail (gate -> fail, _validation.json preserved) 7 Phase 1.6 regenerate (a trigger_pattern still matches after repair; failure_of edge written) """ import argparse, hashlib, json, os, re, sys, datetime sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import _validate # the ONE canonical validator (MAX_MESSAGE_CHARS / BANNED_VOCAB / vetoes) NODE_ID_RE = re.compile(r"^(intent|style|outline|asset|storyboard|panel|blueprint|prompt|motif|cont|attempt|review|decision|fail):[a-z0-9_-]+$") LEGAL_STATUS = {"draft", "pending", "under_review", "locked", "rejected", "superseded", "active", "complete", "final"} def slug(s): """node_id slugs are lowercase `[a-z0-9_-]+` (schema pattern); the display panel_id may carry case.""" return re.sub(r"[^a-z0-9_-]+", "-", str(s).lower()).strip("-") def die(code, msg): """Exit with the CONTRACTED numeric code (sys.exit(str) would force code 1 and break the exit-code surface).""" print(f"[build_prompt] exit {code}: {msg}", file=sys.stderr) sys.exit(code) def node_path(project_dir, node_id): return os.path.join(project_dir, "wiki", "nodes", node_id.replace(":", "_") + ".json") def load_node(project_dir, node_id): p = node_path(project_dir, node_id) if not os.path.exists(p): return None return json.load(open(p, encoding="utf-8")) def resolve_panel_node(project_dir, panel_arg): """panel_arg may be a node_id (panel:foo) or a payload panel_id; resolve to the panel_spec node.""" if panel_arg.startswith("panel:"): return load_node(project_dir, panel_arg) nd = os.path.join(project_dir, "wiki", "nodes") if not os.path.isdir(nd): return None for fn in os.listdir(nd): if not fn.endswith(".json"): continue n = json.load(open(os.path.join(nd, fn), encoding="utf-8")) if n.get("node_type") == "panel_spec" and n.get("payload", {}).get("panel_id") == panel_arg: return n return None def read_style_prefix(project_dir, world): """World-keyed style_prefix from ART_BIBLE.md (§0 register + §0.5 two-world palette). Deterministic line lookup keyed by the panel's `world`; no model call. Returns (prefix, problems).""" ab = os.path.join(project_dir, "ART_BIBLE.md") if not os.path.exists(ab): return None, ["ART_BIBLE.md missing"] text = open(ab, encoding="utf-8").read() # Convention: a line `STYLE_PREFIX[<world>]: <prefix...>` in ART_BIBLE pins each world's style declaration. m = re.search(rf"^STYLE_PREFIX\[{re.escape(world)}\]:\s*(.+)$", text, re.MULTILINE) if not m: return None, [f"no STYLE_PREFIX[{world}] declaration in ART_BIBLE.md"] prefix = m.group(1).strip() probs = [] if len(prefix) > 200: probs.append(f"style_prefix > 200 chars ({len(prefix)})") # hard return prefix, probs def sha256_file(p): h = hashlib.sha256() with open(p, "rb") as f: for chunk in iter(lambda: f.read(8192), b""): h.update(chunk) return h.hexdigest() def collect_active_failure_modes(project_dir): """Query ACTIVE failure_mode nodes (payload.active == true). Returns list or raises -> exit 3 on read error.""" nd = os.path.join(project_dir, "wiki", "nodes") out = [] if not os.path.isdir(nd): return out for fn in os.listdir(nd): if not fn.endswith(".json"): continue n = json.load(open(os.path.join(nd, fn), encoding="utf-8")) if n.get("node_type") == "failure_mode" and n.get("payload", {}).get("active") is True: out.append(n) return out def compose(panel, blueprint, style_prefix, identity_locks, scene_text, retry_invariants): """Build the fixed-section condition string. ORDER IS LOAD-BEARING — front = strongest conditioning. Reads ONLY real panel_spec/blueprint node fields + identity_lock payloads resolved from asset_ids[].""" pp = panel["payload"] bp = blueprint["payload"] text_mode = pp["text_mode"] sections = [] # POSITIVE INVARIANTS (retry only) — injected at the FRONT, highest priority. if retry_invariants: sections.append("POSITIVE INVARIANTS (must be present in every panel, highest priority):\n" + "\n".join(f"- {x}" for x in retry_invariants)) # STYLE PREFIX — world-keyed, NO camera/lens/lighting/quality vocab. sections.append(f"STYLE PREFIX: {style_prefix}") # BAKED DIALOGUE — only if text_mode == baked; else SKIP (bubbles are the viewer's job). if text_mode == "baked": bubbles = [b.get("text", "") if isinstance(b, dict) else str(b) for b in pp.get("bubbles", [])] bubbles = [b for b in bubbles if b.strip()] if bubbles: sections.append("BAKED DIALOGUE (draw legible balloons):\n" + "\n".join(f"- {b}" for b in bubbles)) # SCENE COMPOSITION NARRATIVE — the content authority is the blueprint's content_svg layout + the panel's # side_narration / motifs (the node model has NO `condition.scene`; scene staging is carried by the # blueprint SVG, summarized here from real node fields). sections.append("SCENE COMPOSITION NARRATIVE:\n" + (scene_text or pp.get("side_narration", "")).strip()) # FIXED ELEMENTS — identity lock resolved by following asset_ids[] to each identity-sheet asset node's # identity_lock payload (the node model's identity authority; there is NO identity_desc on panel_spec). if identity_locks: sections.append("FIXED ELEMENTS (must NOT change):\n" + "\n".join(f"- {x}" for x in identity_locks)) # ALLOWED CHANGE — the panel's motifs delta vs the prior panel. motifs = pp.get("motifs", {}) allowed = motifs.get("allowed_change") if isinstance(motifs, dict) else None if allowed: sections.append("ALLOWED CHANGE (vs the prior panel):\n" + "\n".join(f"- {x}" for x in (allowed if isinstance(allowed, list) else [allowed]))) # FORBIDDEN — fixed baseline + any panel-specific must_not_add. forbidden = ["no new character entering frame", "no off-model drift", "no scene/world recolor"] must_not = motifs.get("must_not_add") if isinstance(motifs, dict) else None if must_not: forbidden += (must_not if isinstance(must_not, list) else [must_not]) sections.append("FORBIDDEN (must NOT happen):\n" + "\n".join(f"- {x}" for x in forbidden)) # SCREEN-TEXT WHITELIST — the blueprint's expected_literals (char-exact, the upstream of panel_gate's diff). # The blueprint is the SOLE literal authority; NEVER fall back to panel literals (that weakens the contract). lits = bp.get("expected_literals", []) if lits: sections.append("SCREEN-TEXT WHITELIST (exact characters to preserve):\n" + " · ".join(json.dumps(x, ensure_ascii=False) for x in lits)) return "\n\n".join(sections) def write_node(project_dir, node): os.makedirs(os.path.join(project_dir, "wiki", "nodes"), exist_ok=True) p = node_path(project_dir, node["node_id"]) json.dump(node, open(p, "w", encoding="utf-8"), ensure_ascii=False, indent=2) def append_edge(project_dir, src, dst, etype, **extra): p = os.path.join(project_dir, "wiki", "edges.jsonl") rec = {"src": src, "dst": dst, "type": etype} rec.update(extra) with open(p, "a", encoding="utf-8") as f: f.write(json.dumps(rec, ensure_ascii=False) + "\n") def make_bundle(bundle_id, panel, blueprint, style_prefix, composed, ref_paths, status, banned): """Build a schema-valid prompt_bundle node (the 6 PAYLOAD_REQUIRED fields). Used on the happy path (status='locked') AND on the exit-7 regenerate path (status='rejected'), so the failure_of edge endpoint always resolves to a real node — never a dangling src.""" return { "node_id": bundle_id, "node_type": "prompt_bundle", "status": status, "title": f"bake bundle for {panel['payload']['panel_id']}", "created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), "payload": { "source_panel_id": panel["node_id"], "source_blueprint_id": blueprint["node_id"], "style_prefix": style_prefix, "composed_prompt": composed, "banned_vocab_scan": banned if banned is not None else {"hits": [], "clean": None}, "identity_ref_paths": ref_paths, }, } def main(): ap = argparse.ArgumentParser(description="deterministic bake compiler for one locked panel_spec") ap.add_argument("project_dir") ap.add_argument("panel", help="panel node_id (panel:foo) or payload panel_id") ap.add_argument("--retry", action="store_true") ap.add_argument("--reason", default=None) a = ap.parse_args() # Phase -1 — parse + scaffold. --retry REQUIRES --reason. if a.retry and not a.reason: die(2, "--retry requires --reason") proj = a.project_dir panel = resolve_panel_node(proj, a.panel) # Phase 0(a) — panel_spec must be node_type panel_spec AND status locked. if panel is None: die(2, f"panel_spec for '{a.panel}' not found") if panel.get("node_type") != "panel_spec": die(2, f"node '{panel.get('node_id')}' is {panel.get('node_type')}, not panel_spec") if panel.get("status") != "locked": die(2, f"panel_spec status is '{panel.get('status')}', not 'locked' (not gate-approved)") pid = panel["payload"]["panel_id"] bundle_id = "prompt:" + slug(pid) # lowercase node_id slug (schema pattern); pid keeps display case outdir = os.path.join(proj, "wiki", "prompt_build", slug(pid)) os.makedirs(outdir, exist_ok=True) # Phase 0(b) — resolve the blueprint via content_blueprint; it must be status:locked (the schema's signal # that the cross-layer gate signed off — blueprint nodes have NO review_status field). bp_id = panel["payload"].get("content_blueprint") blueprint = load_node(proj, bp_id) if bp_id else None if blueprint is None: die(3, f"blueprint '{bp_id}' (panel.content_blueprint) not found") if blueprint.get("node_type") != "blueprint": die(3, f"'{bp_id}' is {blueprint.get('node_type')}, not blueprint") if blueprint.get("status") != "locked": die(3, f"blueprint status is '{blueprint.get('status')}', not 'locked' (gate not signed off)") bp = blueprint["payload"] # Two engine contracts: content_svg must exist on disk (+ sha match); baked figure-panel needs expected_literals. csvg = bp.get("content_svg") if not csvg: die(3, "blueprint.content_svg is null/missing (no panel has no content_svg)") csvg_path = csvg if os.path.isabs(csvg) else os.path.join(proj, csvg) if not os.path.exists(csvg_path): die(3, f"content_svg not on disk: {csvg}") if bp.get("file_sha256") and sha256_file(csvg_path) != bp["file_sha256"]: die(3, "content_svg file_sha256 mismatch (stale blueprint)") text_mode = panel["payload"]["text_mode"] if text_mode == "baked": bp_lits = bp.get("expected_literals") or [] if not bp_lits: die(3, "baked figure-panel with empty blueprint.expected_literals — the blueprint is the SOLE " "authority; panel literals are NOT an accepted fallback (no baked-literal contract)") # EVERY baked literal must be an ascii-tokenizable string — mirror run_comic.py cfg_usable EXACTLY # (all(...), isinstance str, lowercase token), so this pre-gate refuses precisely what the engine floor # would later reject (never weaker than the deterministic enforcer). A non-audited scene panel → 'html'. if not all(isinstance(x, str) and re.findall(r"[a-z0-9+._-]+", x.lower()) for x in bp_lits): die(3, "baked expected_literals must each be an ascii-tokenizable string (cfg_usable contract); " "route a non-audited scene panel to text_mode:'html' instead of 'baked'") # Phase 0(c) — every ref is a REAL locked asset path; resolve identity locks from asset_ids[] -> asset nodes. identity_locks, ref_paths = [], [] for aid in panel["payload"].get("asset_ids", []): an = load_node(proj, aid) if an is None: die(3, f"asset '{aid}' (panel.asset_ids) not found") if an.get("node_type") == "asset": if an.get("status") != "locked": die(3, f"asset '{aid}' status '{an.get('status')}', not 'locked'") il = an["payload"].get("identity_lock") if il: identity_locks.append(il if isinstance(il, str) else json.dumps(il, ensure_ascii=False)) # the locked .png ref the asset publishes (ref_requirements / a published path field) ref = an["payload"].get("ref_requirements") if isinstance(ref, dict): ref = ref.get("output_ref") or ref.get("path") ref_abs = (ref if os.path.isabs(ref) else os.path.join(proj, ref)) if isinstance(ref, str) else None real_png = bool(isinstance(ref, str) and ref.lower().endswith(".png") and ref_abs and os.path.exists(ref_abs)) if real_png: ref_paths.append(ref) elif il: # an IDENTITY asset MUST publish a real on-disk .png ref — silently dropping it (the old bug) # defeats the ref-count veto, the linchpin that an identity figure is anchored to its sheet. die(3, f"identity asset '{aid}' publishes no real .png ref (got {ref!r}: missing/non-png/" f"nonexistent) — cannot anchor identity; fix the asset's ref or drop its identity_lock") if "pending:" in json.dumps(panel["payload"].get("asset_ids", [])): die(3, "an asset ref is still pending:* (unresolved)") # Phase 0(d) — style_prefix resolves and is sane (<=200 chars hard). world = panel["payload"]["world"] style_prefix, sp_probs = read_style_prefix(proj, world) if style_prefix is None or any("> 200" in p for p in sp_probs): die(3, f"style_prefix bad for world '{world}': {sp_probs}") json.dump({"panel": panel, "blueprint": blueprint, "style_prefix": style_prefix, "identity_locks": identity_locks, "ref_paths": ref_paths}, open(os.path.join(outdir, "_resolved.json"), "w", encoding="utf-8"), ensure_ascii=False, indent=2) # Phase 1.5 — failure_mode positive-invariant injection (retry only). retry_invariants = [] failure_node_for_edge = None if a.retry: try: fms = collect_active_failure_modes(proj) except Exception as e: die(3, f"failure_mode query failed: {e}") # rank by recency*severity; inject repair_pattern ONLY if encoding_style == positive_invariant. for fm in sorted(fms, key=lambda f: f["payload"].get("severity", 3), reverse=True)[:10]: if fm["payload"].get("encoding_style") == "positive_invariant": rp = fm["payload"].get("repair_pattern") if rp: retry_invariants.append(rp) failure_node_for_edge = fm["node_id"] scene_text = panel["payload"].get("side_narration", "") composed = compose(panel, blueprint, style_prefix, identity_locks, scene_text, retry_invariants) json.dump({"identity_count": len(identity_locks), "ref_count": len(ref_paths), "text_mode": text_mode, "retry_invariants": len(retry_invariants)}, open(os.path.join(outdir, "_meta.json"), "w", encoding="utf-8"), ensure_ascii=False, indent=2) # Phase 1.6 — trigger re-scan against active banlist trigger_patterns (a still-firing trigger -> exit 7). if a.retry: for fm in collect_active_failure_modes(proj): for trig in fm["payload"].get("trigger_patterns", []): if re.search(trig, composed, re.IGNORECASE) and fm["payload"].get("encoding_style") != "positive_invariant": # persist a REJECTED bundle FIRST so the edge endpoint resolves (the bundle node is only # otherwise created on the happy path); the failure_of edge then points failure_mode -> bundle. write_node(proj, make_bundle(bundle_id, panel, blueprint, style_prefix, composed, ref_paths, status="rejected", banned=None)) append_edge(proj, fm["node_id"], bundle_id, "failure_of", evidence={"trigger": trig, "note": "trigger still matches after repair"}) die(7, f"trigger_pattern '{trig}' of {fm['node_id']} still matches; failure_of edge written") # Phase 2 — VALIDATE = the hard gate (the four binary vetoes on the FULL composed message). bubble_texts = [b.get("text", "") if isinstance(b, dict) else str(b) for b in panel["payload"].get("bubbles", [])] val = _validate.validate(composed, text_mode, bubble_texts, ref_paths, proj) json.dump(val, open(os.path.join(outdir, "_validation.json"), "w", encoding="utf-8"), ensure_ascii=False, indent=2) if not val["passes_all"]: # set the panel's gate field to fail and exit 4 (push the fix UPSTREAM; never silently patch the spec). panel.setdefault("payload", {}).setdefault("review_gates", {})["storyboard_json_gate"] = "fail" write_node(proj, panel) print(json.dumps({"gate": "fail", "panel_id": pid, "validation": val}, ensure_ascii=False)) sys.exit(4) # Phase 3 — emit the prompt_bundle node (payload = the schema's 6 required fields) + LEGAL edges. bundle = make_bundle(bundle_id, panel, blueprint, style_prefix, composed, ref_paths, status="locked", banned={"hits": val["banned_hits"], "clean": val["no_banned_vocab"]}) write_node(proj, bundle) # LEGAL author-layer edges (validate_wiki.py EDGE_TYPES): derived_from -> panel_spec; uses_blueprint -> blueprint. append_edge(proj, bundle["node_id"], panel["node_id"], "derived_from") append_edge(proj, bundle["node_id"], blueprint["node_id"], "uses_blueprint") # retry failure_mode trace: a LEGAL `derived_from` edge to the failure_mode node, score in edge evidence # (there is NO consulted_failure_mode edge type in EDGE_TYPES). if failure_node_for_edge: append_edge(proj, bundle["node_id"], failure_node_for_edge, "derived_from", evidence={"consulted_failure_mode": True, "reason": a.reason}) # html_bubbles[] from the blueprint route to the JSON viewer overlay ONLY (never into composed_prompt). log = os.path.join(proj, "wiki", "log.md") with open(log, "a", encoding="utf-8") as f: f.write(f"- {datetime.datetime.now(datetime.timezone.utc).isoformat()} build_prompt {pid} -> {bundle['node_id']} (gate pass)\n") # Phase 4 — stdout one-line JSON; set the panel's storyboard_json_gate = pass. panel.setdefault("payload", {}).setdefault("review_gates", {})["storyboard_json_gate"] = "pass" write_node(proj, panel) print(json.dumps({"prompt_bundle_id": bundle["node_id"], "panel_id": pid, "source_blueprint_id": blueprint["node_id"]}, ensure_ascii=False)) sys.exit(0) if __name__ == "__main__": main() -
_validate.py 6.3 KB
#!/usr/bin/env python3 """_validate.py — the ONE canonical 搬运工 v2 validator for a composed bake message (pure stdlib). Single source of truth for the four binary vetoes the panel-prompt compiler enforces at emit time, so the banned-vocab list / MAX_MESSAGE_CHARS / no-baked-bubble / real-refs rules never fork between SKILL.md prose and the executable. Imported by build_prompt.py; also runnable standalone for a quick check: python3 _validate.py --composed <file.txt> --text-mode html --bubbles "a" "b" \ --refs assets/x.png assets/y.png Each dimension is BINARY; ANY hit fails (detect-only, no single-vote averaging). Exit 0 = clean, 4 = a veto fired (banned-vocab / length / baked-bubble / ref). This is a deterministic transform check, NOT a cross-model acquittal — the quality verdict on the baked pixels lives downstream in comic-director's panel_gate. """ import argparse, json, os, re, sys # --- single source of the limits/vocab (do NOT duplicate these constants elsewhere) --- MAX_MESSAGE_CHARS = 4000 # ~600 words, the empirical backend tolerance # ~80 case-insensitive, word-boundary patterns the backend's OWN agents own (camera/lens/lighting/quality/etc.). # 搬运工原則: the backend owns optics, ART_BIBLE owns style — none of this may leak into a bake message. BANNED_VOCAB = [ # quality padding r"\b8K\b", r"\b4K\b", r"hyperrealistic", r"photorealistic", r"ultra-?realistic", r"\bcinematic\b", r"\bprofessional\b", r"award-winning", r"\bmasterpiece\b", r"\b(?:high|best)\s+quality\b", r"highly detailed", r"intricate details", r"trending on artstation", # camera r"the camera", r"\bcamera\s+(?:moves|pans|tilts|zooms|tracks|pushes|pulls|cranes)\b", r"\b(?:wide|close-?up|medium|long|establishing|POV)\s+shot\b", r"over-the-shoulder", r"\bdolly\b", r"\btilt\b", r"\bpan\b", r"\bcrane\b", r"\bdrone\b", r"\baerial\b", r"\borbital\b", r"whip-?pan", r"tracking shot", r"push-?in", r"pull out", r"\bzoom\b", r"rack focus", r"focus pull", r"\bjib\b", r"steadicam", r"handheld", # lens r"\b\d{2,3}mm\b", r"\bf/\d", r"\bbokeh\b", r"depth of field", r"shallow DOF", r"\bDOF\b", r"\blens\b", r"wide-?angle", r"telephoto", r"anamorphic", r"lens flare", # lighting r"\blighting\b", r"\b(?:rim|key|fill)\s+light\b", r"three-point lighting", r"golden hour", r"blue hour", r"studio lighting", r"\b(?:soft|hard)\s+light\b", r"low-?key", r"volumetric", r"god rays", r"chiaroscuro", # engine / brand r"Unreal Engine", r"\bOctane\b", r"V-?Ray", r"\bBlender\b", r"Midjourney", r"Stable Diffusion", r"\bSora\b", r"\bRunway\b", r"\bPika\b", # clichés r"\bepic\b", r"breathtaking", r"\bstunning\b", r"\bgorgeous\b", r"mesmerizing", r"otherworldly", r"surreal masterpiece", ] _BANNED_RE = [(p, re.compile(p, re.IGNORECASE)) for p in BANNED_VOCAB] def scan_banned(composed): """Return the list of {pattern, match} hits (empty == clean).""" hits = [] for pat, rx in _BANNED_RE: m = rx.search(composed) if m: hits.append({"pattern": pat, "match": m.group(0)}) return hits def check_length(composed): return len(composed) <= MAX_MESSAGE_CHARS, len(composed) def check_no_baked_bubbles(composed, text_mode, bubble_texts): """no_baked_bubbles veto: if text_mode != 'baked', no quoted bubble dialogue may appear in the message. When text_mode == 'baked' the dialogue is allowed (BAKED DIALOGUE block) — this veto fires only for html/code panels. Returns (ok, leaked_texts).""" if text_mode == "baked": return True, [] leaked = [b for b in bubble_texts if b and b.strip() and b.strip() in composed] return (len(leaked) == 0), leaked _PENDING_RE = re.compile(r"(?:^|[\s:])pending:", re.IGNORECASE) def check_real_refs(ref_paths, project_dir): """real_refs_ok veto: every identity_ref_path must be a REAL locked .png on disk; the ref set is exact — no pending:*, no raw asset_id strings, no missing files. A null entry is the documented 'use project canonical' fallback and is allowed. Returns (ok, problems).""" problems = [] for r in ref_paths: if r is None: continue # documented canonical-fallback if not isinstance(r, str) or _PENDING_RE.search(r) or not r.lower().endswith(".png"): problems.append({"ref": r, "why": "not a real .png path (pending:* / raw id / wrong ext)"}) continue p = r if os.path.isabs(r) else os.path.join(project_dir, r) if not os.path.exists(p): problems.append({"ref": r, "why": "file does not exist on disk"}) return (len(problems) == 0), problems def validate(composed, text_mode, bubble_texts, ref_paths, project_dir): """Run all four binary vetoes. Returns a dict ready to dump as _validation.json with passes_all.""" banned = scan_banned(composed) length_ok, n = check_length(composed) no_bubbles_ok, leaked = check_no_baked_bubbles(composed, text_mode, bubble_texts) real_refs_ok, ref_problems = check_real_refs(ref_paths, project_dir) no_banned_vocab = len(banned) == 0 passes_all = length_ok and no_banned_vocab and no_bubbles_ok and real_refs_ok return { "passes_all": passes_all, "length_ok": length_ok, "composed_len": n, "max_message_chars": MAX_MESSAGE_CHARS, "no_banned_vocab": no_banned_vocab, "banned_hits": banned, "no_baked_bubbles": no_bubbles_ok, "leaked_bubble_texts": leaked, "real_refs_ok": real_refs_ok, "ref_problems": ref_problems, } def main(): ap = argparse.ArgumentParser(description="canonical 搬运工 v2 bake-message validator (binary vetoes)") ap.add_argument("--composed", required=True, help="path to the composed message text") ap.add_argument("--text-mode", default="html", choices=["baked", "html", "code"]) ap.add_argument("--bubbles", nargs="*", default=[], help="bubble dialogue strings (for no-baked-bubble veto)") ap.add_argument("--refs", nargs="*", default=[], help="identity_ref_paths[] (for real-refs veto)") ap.add_argument("--project-dir", default=".", help="project dir to resolve relative ref paths") a = ap.parse_args() composed = open(a.composed, encoding="utf-8").read() out = validate(composed, a.text_mode, a.bubbles, a.refs, a.project_dir) print(json.dumps(out, ensure_ascii=False, indent=2)) sys.exit(0 if out["passes_all"] else 4) if __name__ == "__main__": main()
-
-
SKILL.md 29.1 KB
--- name: comic-panel-prompt-builder description: "Phase-1 step of comic-author — the DETERMINISTIC compiler (搬运工原則) that turns ONE gate-approved panel_spec + its status:locked blueprint into the EXACT fixed-section bake string for the spiral engine via the shipped scripts/build_prompt.py (+ the canonical scripts/_validate.py vetoes). No Codex call in the happy path; it only emits, the engine bakes. Composes style_prefix + condition_string, injects the active failure_mode repair note on retry, and hard-REJECTS banned vocab (camera/lens/lighting/quality padding). Routes html_bubbles[] to the JSON viewer overlay, NEVER into the bake string (no baked bubbles unless the panel is text_mode:baked). Refuses unless the upstream blueprint is status==locked (the cross-layer gate's hand-off token) and every ref is a REAL locked asset path. Use when a panel_spec is locked and you need its prompt_bundle, just before comic-director's panel_gate." argument-hint: [panel_id | panel_spec.json] [--retry --reason "<text>"] allowed-tools: Bash(python3:*), Read, Write, Edit, Grep, Glob --- # comic-panel-prompt-builder — the Deterministic Bake Compiler (搬运工原則) The **last authoring step before a pixel ever gets baked**. Given ONE upstream-approved `panel_spec` and its `status: locked` `blueprint`, this skill COMPILES — it does not brainstorm — the *exact* message [`comic-director`](../comic-director/SKILL.md) (Phase 2/3) will hand to the agent `mcp__codex__codex` sidecar bake (Codex's native image tool), plus the locked identity-ref list. It is the comic twin of the framework's `shot-prompt-builder`: a pure Layer-3 transform with **no Codex call in the happy path** — it only emits a `prompt_bundle`; the spiral engine submits it. Everything the generation backend ever sees passes through here, so this is where the **搬运工原則** is enforced at emit time: the bake backend (Codex's native image-generation tool; the underlying image model is not pinned or attested by this repo) owns optics; `ART_BIBLE.md` owns style; **no camera / lens / lighting / "8K hyperrealistic cinematic" padding leaks into the bake**, and **no bubble text gets baked** unless the panel is explicitly `text_mode: "baked"`. The author (storyboard/blueprint layers) decides *what*; this skill decides *nothing* — it transcribes a locked spec into a backend-legal string and FAILS (it never papers over) when the spec is malformed. ```text panel_spec (status:locked) ─▶ ① RESOLVE — load panel_spec + its blueprint + ART_BIBLE style_prefix + blueprint (status:locked) enforce 4 prerequisites (node_type, blueprint status:locked, REAL refs, style_prefix resolves) ▼ ② COMPOSE — fixed-section condition_string (order is load-bearing, do NOT refactor) ▼ style_prefix + [BAKED DIALOGUE only if text_mode==baked] + SCENE + FIXED ELEMENTS ▼ + ALLOWED CHANGE vs prior panel + FORBIDDEN + SCREEN-TEXT WHITELIST(expected_literals) ▼ ②·5 INJECT (retry only) — active failure_mode positive-invariants to the FRONT ▼ ③ VALIDATE — banned-vocab + length + no-baked-bubbles + real-refs on the COMPOSED string ▼ (the hard gate — ANY hit = fail-closed, push the fix UPSTREAM) ▼ ④ EMIT — write the prompt_bundle node; route html_bubbles[] to the JSON viewer ONLY ▼ pass? ─ no ─▶ exit 4 (banned/length) | exit 7 (failure_mode trigger still fires) | exit 3 (prereq) │ yes ▼ ⑤ stdout one-line JSON {prompt_bundle_id, panel_id, source_blueprint_id} ``` ## Role You are a **fail-closed transcriber**, not an author. You take a `panel_spec` that an upstream layer already gate-approved and its `blueprint` that the cross-layer gate already flipped to `status: locked`, and you produce the single exact `composed_prompt` (style_prefix + a fixed-section condition string) + the locked identity-ref paths that the spiral engine will bake — together with the deterministic proof (a clean `banned_vocab_scan`) that the message is backend-legal. You **never** invent a scene element, a number, a cast member, or a style adjective; you **never** silently fix a malformed spec (you FAIL and point upstream); and you **never** call Codex on the happy path (a Codex call only ever appears as a reported upstream bug). The cross-model *judgement* of the baked pixels is not yours — that lives downstream in `comic-director`'s `panel_gate`; keeping you deterministic is what stops the executor from self-acquitting. ## Node — what this skill reads / writes (`schemas/node_schema.json`) - **Reads `panel_spec`** (node prefix `panel:`) — payload fields used: `source_storyboard_id`, `page_id`, `panel_id`, `sequence_index`, `page_type`, `world`, `asset_ids[]`, `text_mode`, `expected_literals[]`, `content_blueprint`, `bubbles[]`, `side_narration`, `motifs`. The `panel_spec` must be its layer's gate-approved output (`status: locked`), not a draft. - **Reads the upstream `blueprint`** (node prefix `blueprint:`, found via the panel's `content_blueprint`) — payload fields used (the schema's 9 `blueprint` required fields, verbatim from `validate_wiki.py` PAYLOAD_REQUIRED): `source_panel_id`, `content_svg`, `expected_literals[]`, `safe_zones[]`, **`html_bubbles[]`**, `crop`, `negative_space_policy`, `generator_script`, `file_sha256`. **Refuse unless the blueprint is `status: locked`** — that is the schema signal that the cross-layer gate signed off (the gate's ⑥ FLIP writes `status → locked` on advance; `comic-blueprint-author` emits blueprints at `status: locked`). The `blueprint` node has **NO `review_status` field** (only the `asset` node does, and `"approved"` is not even a legal `status` enum value) — never gate on `review_status` here. `html_bubbles[]` is the JSON viewer-overlay payload — it is **routed to the viewer, NEVER concatenated into the bake string**. - **Writes one `prompt_bundle`** (node prefix `prompt:`, node_id a lowercase `[a-z0-9_-]+` slug of the `panel_id`) — payload required by the schema (the 6 `prompt_bundle` PAYLOAD_REQUIRED fields, verbatim): `source_panel_id`, `source_blueprint_id`, `style_prefix`, `composed_prompt`, `banned_vocab_scan`, `identity_ref_paths`. Status canon: author `draft`/`pending` → `under_review` → **`locked`** on a clean validate (or `rejected`). Write **LEGAL** edges (every `type` ∈ `validate_wiki.py` EDGE_TYPES): `derived_from` (prompt_bundle → panel_spec) and `uses_blueprint` (prompt_bundle → blueprint). On retry, the failure_mode trace uses a **legal `derived_from`** edge (prompt_bundle → failure_mode, with `evidence.consulted_failure_mode` + the `--reason` text — no match score; scoring is planned, not shipped); if a trigger still fires after repair, a legal **`failure_of`** edge (failure_mode → prompt_bundle — `failure_of` points failure_mode → target, matching the gate + the script, trigger in `evidence`). There is **NO `generated_from` / `consulted_failure_mode` / `violates_failure_mode` edge type** in EDGE_TYPES — those would fail the release gate. Emit **node IDs only** to stdout — the orchestrator re-reads the node file from disk (no filename guessing). ## Two engine contracts to compile to (fail-closed) These are the same two contracts `comic-author` authors to — this skill is where they are *enforced at emit time*, on the resolved spec, before any credit is spent: 1. **Every panel needs a content_svg.** On the wiki node this is **`blueprint.payload.content_svg`** (top-level on the blueprint payload — NOT nested under any `condition.` key; the `condition.content_svg` prefix exists ONLY in the runtime `comic.json` the engine reads). The blueprint's `content_svg` is the content authority + the bake's layout reference. If it is `null` / missing / not on disk (or its `file_sha256` mismatches) → **refuse** (exit 3). A scene-only panel still has a *layout* blueprint SVG; there is no such thing as a panel with no `content_svg`. 2. **A `baked` figure-panel must declare non-empty, ascii-tokenizable `expected_literals`.** If `text_mode == "baked"` and the blueprint carries a figure but `expected_literals` is empty → **refuse** (exit 3). Those literals become the **SCREEN-TEXT WHITELIST** block (below) and are exactly what `panel_gate`'s blind token-diff later verifies. A scene panel with no audited numbers must be `text_mode: "html"` (dialogue is an HTML overlay, no baked-literal contract). ## Procedure (numbered — an agent can execute this step by step) **The shipped entrypoint (do NOT hand-roll the validator):** ```bash python3 skills/comic-panel-prompt-builder/scripts/build_prompt.py <project_dir> <panel_id|panel:slug> \ [--retry --reason "<text>"] ``` `scripts/build_prompt.py` IS this procedure made executable — it reads the `panel_spec` + `blueprint` nodes from `<project_dir>/wiki/nodes/`, resolves the identity lock + refs, composes the fixed-section message, and calls the **single canonical** `scripts/_validate.py` (which owns `MAX_MESSAGE_CHARS = 4000`, the ~80-pattern `BANNED_VOCAB`, `no_baked_bubbles`, `real_refs` — imported, never duplicated). It writes the trace files `wiki/prompt_build/<panel_slug>/{_resolved.json,_meta.json,_validation.json}` and, on a clean validate, the `prompt_bundle` node + its legal edges. `--retry` **REQUIRES** `--reason` (exit 2 otherwise) — a retry must name the active failure it is repairing. Run `python3 scripts/_validate.py --composed <f> --text-mode <m> --bubbles ... --refs ...` standalone for a quick re-check of an already-composed message. 0. **Phase −1 · parse + scaffold.** Parse `$2` (the `panel_id` or `panel:slug` node_id); make `wiki/prompt_build/<panel_slug>/`. `--retry` without `--reason` → **exit 2**. The emitted node_id is `prompt:<lowercase-slug-of-panel_id>` (the schema `node_id` pattern is `[a-z0-9_-]+`; the display `panel_id` may carry case). Never guess output filenames. 1. **Phase 0 · resolve + prerequisites (refuse to proceed unless ALL hold).** Load into one `_resolved.json`: the `panel_spec` node; its `blueprint` node (via `panel_spec.payload.content_blueprint`); and the `style_prefix` from `ART_BIBLE.md` (the world-keyed `STYLE_PREFIX[<world>]:` line — §0 register + §0.5 two-world palette). Enforce: - (a) `node_type == "panel_spec"` and `status == "locked"` — else **exit 2** (input missing/malformed). - (b) the upstream **`blueprint.status == "locked"`** — the schema signal that the cross-layer gate signed off (the gate's ⑥ FLIP writes `status → locked`). The `blueprint` node has **no `review_status` field**; gating on `review_status == "approved"` is unreachable (not even a legal `status` value) → never do it. Optionally also assert the legal `decision` node / `decides` edge for this blueprint exists as gate proof. Else **exit 3**. - (c) **every ref is a REAL locked asset path**, not a placeholder: `blueprint.payload.content_svg` exists on disk (and `file_sha256` matches), and each identity ref — resolved by following `panel_spec.payload.asset_ids[]` to its identity-sheet `asset` node (the asset must be `status: locked`, and publishes a real `.png` via its `ref_requirements`) — is a real `.png` (NOT `pending:*`, NOT `null` where one is required). A `null` ref is allowed ONLY as the documented "use project canonical" fallback; an unresolved `pending:*` → **exit 3**. - (d) the `style_prefix` resolves and is sane: ≤ 200 chars (hard), ≤ 32 words (warn). Else **exit 3**. Also resolve asset **names** (scene/prop/identity `asset_ids[]` → display names) for the whitelist/reviewer context only — names go in the whitelist, raw IDs never go in the prompt. 2. **Phase 1 · compose the condition string DETERMINISTICALLY in this EXACT load-bearing layout** (order is load-bearing — it empirically drives baked-text fidelity; do NOT refactor headings or order without an A/B test). Front = strongest conditioning weight. - **STYLE PREFIX** — from `ART_BIBLE.md`: tone + two-world palette + texture, world-keyed by `panel_spec.world` (e.g. `warm` / `seam` / `dark-cyber` / `starfield`). **NO camera/lens/lighting/quality vocab.** - **[BAKED DIALOGUE]** — **only if `text_mode == "baked"`** lift the panel's `bubbles[]` text into the prompt (so the image model draws legible balloons). **If `text_mode == "html"` (or `code`): SKIP this block entirely** — the bubbles are the viewer's job (see Phase 4). This is the single switch that decides baked-vs-overlay text. - **SCENE COMPOSITION NARRATIVE** — the scene staging authority is the **`blueprint.payload.content_svg`** layout (the content blueprint already lays out the scene) summarized with the panel's `panel_spec.payload.side_narration`. **There is NO `condition.scene` / `condition.characters` / `chibi_action` on the panel_spec node** — those keys live ONLY in the runtime `comic.json` (do not read them off the node). The world's warm/cold contrast is *by design*, not drift. - **"FIXED ELEMENTS (must NOT change):"** — the identity lock, obtained by following `panel_spec.payload.asset_ids[]` to each identity-sheet `asset` node and reading its **`asset.payload.identity_lock`** (the canonical-cast hex/beard/silhouette lock — there is NO `identity_desc` field on the panel_spec node; the node-model identity authority is the asset's `identity_lock`) + the locked props the motif ledger pins for this panel. - **"ALLOWED CHANGE (vs the prior panel):"** — from the panel's `motifs` delta (the comic reinterpretation of "ALLOWED MOTION": what may legitimately differ from the previous panel). - **"FORBIDDEN (must NOT happen):"** — the FIXED baseline `["no new character entering frame", "no off-model drift", "no scene/world recolor"]` + any panel-specific `must_not_add`. - **"SCREEN-TEXT WHITELIST (exact characters to preserve):"** — the `expected_literals[]` from the blueprint (e.g. `["REJECT","37","T-16:05"]`) + any baked-in signage/label names. Even when HTML owns the bubbles, baked-in figure text (chips, stamps, code) still needs char-exact preservation — this whitelist is the upstream of `panel_gate`'s literal diff. Write `_meta.json` (character/element counts) for the trace. 3. **Phase 1·5 · failure_mode positive-invariant injection (retry only — the spiral active-memory hook).** This is what the SHIPPED `build_prompt.py` does — mirror it exactly, do not hand-roll a richer version. On `--retry`, collect **ALL ACTIVE** `failure_mode` nodes (`payload.active == true`) **project-wide** — there is NO layer or panel scoping in the shipped code. Sort them by `payload.severity` DESC (default 3 — **severity ONLY**, no recency term), take the **top 10**, and for each mode whose `encoding_style == "positive_invariant"` inject its `repair_pattern` **UNCONDITIONALLY** (no semantic matching against the composed string) — non-`positive_invariant` modes are skipped here (negatives like "no missing ears" make diffusion fixate on the negated concept). The injected patterns form one **"POSITIVE INVARIANTS (must be present in every panel, highest priority):"** block at the **FRONT** of the message. Record the consult as a **legal `derived_from`** edge (prompt_bundle → the consulted failure_mode) with `evidence.consulted_failure_mode: true` + the `--reason` text (there is no `consulted_failure_mode` edge type). This is exactly the `--reason` a retry must name. *(ASPIRATIONAL — planned, NOT yet implemented; do not describe as current behavior: scoping by the schema's **`layer`** field (∈ {prompt_pattern, visual_transition, global} — the field is `layer`, there is no `target_layer` in the schema), `recency*severity` ranking (recency `= 1.0/age_days`), a `semantic_signature` weighted-Jaccard match (`0.35*assets + 0.25*cterms + 0.20*kw + 0.20*frags`, threshold 0.55), and a match score on the edge. The shipped injector is the severity-top-10 unconditional inject above.)* 4. **Phase 1·6 · trigger re-scan (retry only).** Re-scan the FULL composed message against the `trigger_patterns` of EVERY active failure_mode (not just injected ones). In the shipped script, trigger matching gates **ONLY the exit-7 path**: if a trigger of a **non-`positive_invariant`** mode still matches, it persists the bundle as `status: "rejected"` FIRST (so the edge endpoint resolves), writes a **legal `failure_of`** edge (failure_mode → prompt_bundle — `failure_of` points failure_mode → target, the trigger in `evidence`; there is no `violates_failure_mode` edge type) and **exits 7** (a regenerate signal to the orchestrator). A matched `positive_invariant` mode never exit-7s and there is **NO append-in-place repair branch** — its `repair_pattern` was already injected in Phase 1·5 iff it made the severity top-10. 5. **Phase 2 · VALIDATE = the hard gate (runs on the FULL composed message; this is the rubric).** Delegate to `scripts/_validate.py` (the single source): `passes_all = length_ok AND no_banned_vocab AND no_baked_bubbles AND real_refs_ok`. ANY failure → preserve `_validation.json`, set the panel's `review_gates.storyboard_json_gate = "fail"`, and **exit 4** — see the EXACT gate below. **Do NOT silently add quotes / fix the spec** — a malformed spec FAILS validation so the fix is pushed back to the authoring layer. 6. **Phase 3 · emit.** Write the `prompt_bundle` node (payload = the schema's 6 required fields exactly: `{source_panel_id, source_blueprint_id, style_prefix, composed_prompt, banned_vocab_scan, identity_ref_paths}`); set its status `locked`. Write the **LEGAL** edges (every `type` ∈ EDGE_TYPES): `derived_from` (prompt_bundle → panel_spec) and `uses_blueprint` (prompt_bundle → blueprint) — **NOT `generated_from`** (illegal, fails the release gate). **Route `html_bubbles[]` to the JSON viewer overlay only** — they are NOT in `composed_prompt`. Append a timeline entry to `wiki/log.md`. 7. **Phase 4 · stdout.** Emit one-line JSON `{"prompt_bundle_id":"prompt:...","panel_id":"...", "source_blueprint_id":"blueprint:..."}` — the canonical orchestrator input. Set `panel_spec.review_gates.storyboard_json_gate = "pass"`. ## The EXACT gate (deterministic fail-closed — dimensions + thresholds + veto) This is a **transform**, so there is **NO cross-model / scored gate here** (that lives downstream in `comic-director`'s `panel_gate`). The gate is a deterministic contract/banned-vocab validator on the **COMPOSED message** — ported verbatim from `shot-prompt-builder` / `segment-intent-builder`. Each dimension is binary; ANY hit is a hard fail (no single-vote averaging — this is detect-only). - **`length_ok`** — `len(composed_prompt) ≤ MAX_MESSAGE_CHARS = 4000` (~600 words, the empirical backend tolerance). Over cap → fail. - **`no_banned_vocab`** — the **搬运工 v2** validator: ~80 case-insensitive, word-boundary patterns the backend's own agents own. **Veto = any single hit fails the whole message.** The list below is the human-readable map; the **single source of truth is `scripts/_validate.py`'s `BANNED_VOCAB`** (imported by `build_prompt.py`, so the list never forks). Categories: - *Quality padding:* `\b8K\b`, `\b4K\b`, `hyperrealistic`, `photorealistic`, `ultra-realistic`, `cinematic`, `professional`, `award-winning`, `masterpiece`, `high/best quality`, `highly detailed`, `intricate details`, `trending on artstation`. - *Camera:* `the camera`, camera `moves/pans/tilts/zooms/tracks/pushes/pulls/cranes`, `wide/close-up/medium/long/establishing/POV shot`, `over-the-shoulder`, `dolly`, `tilt`, `pan`, `crane`, `drone`, `aerial`, `orbital`, `whip-pan`, `tracking shot`, `push-in`, `pull out`, `zoom`, `rack focus`, `focus pull`, `jib`, `steadicam`, `handheld`. - *Lens:* `\d{2,3}mm`, `f/\d`, `bokeh`, `depth of field`, `shallow DOF`, `DOF`, `lens`, `wide-angle`, `telephoto`, `anamorphic`, `lens flare`. - *Lighting:* bare `lighting`, `rim/key/fill light`, `three-point lighting`, `golden hour`, `blue hour`, `studio lighting`, `soft/hard light`, `low-key`, `volumetric`, `god rays`, `chiaroscuro`. - *Engine/brand:* `Unreal Engine`, `Octane`, `V-Ray`, `Blender`, `Midjourney`, `Stable Diffusion`, `Sora`, `Runway`, `Pika`. - *Clichés:* `epic`, `breathtaking`, `stunning`, `gorgeous`, `mesmerizing`, `otherworldly`, `surreal masterpiece`. - **`no_baked_bubbles` (comic-specific veto)** — if `text_mode != "baked"` and any of the panel's `html_bubbles[]` / `bubbles[]` quoted dialogue text appears in `composed_prompt` → **fail**. HTML owns the bubbles; baking them is the exact drift this gate exists to catch. (When `text_mode == "baked"` the dialogue is allowed in the BAKED DIALOGUE block by design — this veto fires only for html/code panels.) - **`real_refs_ok` (single-source ref veto)** — `identity_ref_paths[]` must be REAL locked `.png` paths, and the **ref set is exact**: no silent concatenation of extra identity IDs, no `pending:*`, no raw `asset_id` strings smuggled into the prose. (Single-source discipline — the ref-count linchpin; concatenating refs silently is forbidden.) **Exit-code contract (= the verdict surface; `scripts/build_prompt.py` returns these EXACT numeric codes):** `0` validated + emitted + gate pass · `2` input missing / malformed CLI (`--retry` w/o `--reason`; panel_spec not `node_type panel_spec` or not `status: locked`) · `3` upstream prereq unmet (**blueprint not `status: locked`** / ref not real / `content_svg` missing / `expected_literals` missing on a baked figure-panel / style_prefix bad / failure_mode query failed) · `4` banned-vocab | length | baked-bubble | ref fail (gate → fail, `_validation.json` preserved) · `7` Phase 1·6 regenerate (a **non-`positive_invariant`** mode's `trigger_pattern` still matches after repair; the bundle is persisted `status: "rejected"` and a legal **`failure_of`** edge written). ## Worked example The reference movie's per-panel `condition` blocks — **[`../../examples/comic_m3_audit/comic.json`](../../examples/comic_m3_audit/comic.json)** — are the **compiled-artifact (comic.json runtime) OUTPUT shape this skill produces** (the spiral engine inlines exactly these `condition.*` keys). They are NOT the node fields this skill *reads* — those are the flat `panel_spec` / `blueprint` / `asset` node payloads (the `condition.` prefix exists only in comic.json). Copy these output patterns, but source each from its node field as noted: - **A baked figure-panel — `S11` (the REJECT verdict).** Compiled output (comic.json): `condition.content_svg: "assets/reject_verdict_round1_v1.svg"`, `text_mode: "baked"`, `condition.expected_literals: ["REJECT","37","T-16:05"]`, `world: "dark-cyber"`. **Node sources:** `content_svg` ← `blueprint.payload.content_svg`; `expected_literals` ← `blueprint.payload.expected_literals`; the identity lock ← the identity-sheet `asset.payload.identity_lock` reached via `panel_spec.payload. asset_ids[]`. The compiler emits: STYLE PREFIX (dark-cyber palette, no camera vocab) → **BAKED DIALOGUE** (because `text_mode==baked`: from `panel_spec.payload.bubbles[]`, reviewer "Rejected. Fix it, then come back." / executor "...Copy that.") → SCENE COMPOSITION NARRATIVE (from the `blueprint.content_svg` layout + `panel_spec.side_narration`: the verdict chamber + the REVIEW·ROUND 1 card) → FIXED ELEMENTS (from `asset.identity_lock`: "blue executor brown hair NO beard / green reviewer dark hair beard") → FORBIDDEN (no new character, no off-model drift) → **SCREEN-TEXT WHITELIST `REJECT` · `37` · `T-16:05`**. Those three literals are exactly what `panel_gate` blind-diffs — they MUST be present and char-exact, and this is why a baked figure-panel that omits `expected_literals` is refused at Phase 1. - **The 搬运工 line in the data.** Note what is *absent* from every compiled `condition.scene`/`characters` string (and from the node fields they were compiled from): no "the camera pushes in", no "85mm", no "cinematic lighting", no "8K". The author already speaks backend-legal; the validator is the second line of defense that keeps it that way when a style adjective sneaks in. - **An HTML-bubble panel — `S22` (the constellation endcard).** `text_mode: "html"`, `bubbles: []`, `expected_literals: []`, plus `safe_zones` for the HTML tagline. Here the compiler **SKIPS the BAKED DIALOGUE block** and routes nothing-text into the prompt; the scene string even hard-asserts "NO text, NO glyphs anywhere in the image". For a panel like `S02` whose bubbles ARE drawn (`text_mode: "baked"`, bubbles researcher/executor/reviewer), those same bubbles would instead go in the BAKED DIALOGUE block — the identical `bubbles[]` field is routed to the *prompt* or to the *viewer* solely by `text_mode`. That switch, applied per panel, is the whole "no baked bubbles unless baked" guard made concrete. - **The single-ref discipline — `S01` / `S20` / `S21`.** Each pins exactly its locked identity sheet (`trio_identity_sheet_v001.png` or `researcher_chibi_canonical_ref_v001.png`) as `identity_ref` — one exact ref set per panel, never a silent concatenation. `identity_ref_paths[]` in the emitted `prompt_bundle` mirrors precisely that set. (The provenance of these panels — the debate→synthesize outline, the MOTIF STATE TABLE that pins each literal, the user-approval gate — lives upstream in `comic-outline-creator` / `comic-storyboard-creator`; by the time a `panel_spec` reaches *this* skill it is locked, and this skill only transcribes it.) ## Hard do / don't (earned lessons) - **DO** compose the EXACT fixed-section string `style_prefix + condition_string` deterministically — and on retry inject the active `failure_mode` `repair_pattern` (positive_invariant ONLY) at the FRONT. - **DO** run the banned-vocab + length + no-baked-bubble + real-ref validator on the **COMPOSED** message (catches `ART_BIBLE`/style contamination, not just per-field text) — and FAIL closed, never paper over. - **DO** route `html_bubbles[]` to the JSON viewer overlay; the bake string carries dialogue **only** when `text_mode == "baked"`. - **DON'T** call Codex in the happy path — this is a pure transform; a Codex call is only ever a reported upstream bug, never a way to "fix" a malformed spec here. - **DON'T** invent or "improve" a scene element, number, cast member, or style adjective — if the spec is malformed or a ref is `pending:*`, exit non-zero pointing upstream (creator bug vs blueprint-not-`locked` vs ART_BIBLE contamination), do NOT patch locally. - **DON'T** let camera / lens / lighting / "8K hyperrealistic cinematic" padding into the prompt, and **DON'T** concatenate extra identity refs — the backend owns optics, `ART_BIBLE` owns style, and the ref set is exact. - **DON'T** emit on a blueprint that is not `status: locked` or a panel_spec that is not `status: locked` — refuse (exit 3 / exit 2). And **DON'T** write an edge `type` outside `validate_wiki.py` EDGE_TYPES (no `generated_from` / `consulted_failure_mode` / `violates_failure_mode` — they fail the release gate). ## Protocols (governance contracts this skill honors) - [`artifact-integrity`](../../protocols/artifact-integrity.md) — this compiler does NOT judge the panel it builds; it transcribes a locked spec and emits a deterministic banned-vocab proof. Numbers (`expected_literals`) are *carried through verbatim*, never originated; the pixels are judged downstream. - [`reviewer-independence`](../../protocols/reviewer-independence.md) — there is no reviewer in this step (it's a transform). The downstream judge (`comic-director`'s `panel_gate`) gets the baked image + the blueprint's `expected_literals`, never this compiler's interpretation. - [`acceptance-gate`](../../protocols/acceptance-gate.md) — a clean validate here is a *transform pass*, not an acquittal: a deterministic same-model check is allowed to confirm "the message is backend-legal", but the quality/correctness verdict on the result is the cross-model `panel_gate`, never this skill. - [`reviewer-routing`](../../protocols/reviewer-routing.md) — N/A on the happy path (no model call); if a blocked retry ever escalates to an external consult, Codex at `xhigh` (no model pin — the CLI follows the local codex config) / Gemini `auto-gemini-3`, never downgraded. - [`review-tracing`](../../protocols/review-tracing.md) — every emit logs to `wiki/log.md`; every retry writes a legal **`derived_from`** edge to the consulted `failure_mode` (consult flag + retry reason in `evidence`; no match score — Jaccard scoring is planned, not shipped), and a legal **`failure_of`** edge if a trigger still fires, so the spiral memory is auditable (both edge types are in `validate_wiki.py` EDGE_TYPES). - [`output-versioning`](../../protocols/output-versioning.md) — the `prompt_bundle` records `file_sha256` of the blueprint it compiled, so a re-bake traces to the exact `content_svg` version.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.