diff --git a/.archive/README.md b/.archive/README.md index 77685b0..55d1f1e 100644 --- a/.archive/README.md +++ b/.archive/README.md @@ -1,3 +1,8 @@ # .archive Build documentation from prior shotkit construction sessions. Kept for transparency. Not part of the user-facing documentation. + +**These files are historical and contradict the current tree in places.** `HANDOFF.md` discusses +an example directory named `one-shot-five-generators` holding two adapter files; it is now +`one-shot-all-adapters` with seven. Read them as a record of what was being decided at the time, +never as a description of how the kit works now. For that, start at `../README.md`. diff --git a/.github/workflows/validate-skills.yml b/.github/workflows/validate-skills.yml index e2b5000..b7d3cc3 100644 --- a/.github/workflows/validate-skills.yml +++ b/.github/workflows/validate-skills.yml @@ -1,40 +1,50 @@ name: validate-skills +# Runs on every branch, not just main. Filtering pushes to main meant a feature branch +# got no CI at all until someone opened a pull request, so the first signal arrived after +# the review had already started. Contributors should find out from the branch. on: - pull_request: - branches: [main] push: + branches: ['**'] + pull_request: branches: [main] +# A pull-request branch would otherwise run twice per push, once for each trigger. +# Superseded runs are cancelled rather than left to finish. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: validate: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.11" - name: Install dependencies run: pip install pyyaml jsonschema - - name: Validate every SKILL.md has correct frontmatter - run: python tools/validate_skills.py - - - name: Validate JSON schemas - run: python tools/validate_schemas.py - - - name: Validate brand-pack template and extracted example - run: python tools/validate_brand_lock.py brand-packs/_template.md skills/brand-lock-extractor/examples/brand-lock.md - - - name: Validate generator capability matrix - run: python tools/validate_capabilities.py - - - name: Validate critique gate (selftest, then example fixtures) + # One entry point, so a green local run means a green PR. tools/check.sh runs + # frontmatter, schema, capability-parity, brand-lock, storyboard-instance, + # prompt-file, critique-gate, provenance, renderer, and prompt-helper checks. + # Each check ships a selftest that proves the check itself fires, so a + # validator that silently stops catching things fails the build rather than + # going quiet. + - name: Run all checks + run: ./tools/check.sh + + # The bundled previews are committed artifacts. Re-render them with the same + # pinned timestamps they were built with and fail if the output moved: that is + # the determinism claim, tested rather than asserted. + - name: Bundled previews are reproducible run: | - python tools/validate_critique.py --selftest - python tools/validate_critique.py \ - skills/visual-asset-critic/examples/critique.accept.json \ - skills/visual-asset-critic/examples/critique.revise.json + python tools/shots-to-html.py skills/storyboard-architect/examples/30s-pain-proof-promise --rendered-at 2026-05-07T14:23:00Z + python tools/shots-to-html.py skills/storyboard-architect/examples/60s-founder-explainer --rendered-at 2026-05-07T14:23:00Z + python tools/shots-to-html.py skills/storyboard-architect/examples/shotkit-explainer --rendered-at 2026-05-08T14:34:24Z + python tools/shots-to-html.py skills/visual-asset-critic/examples/worked-run --rendered-at 2026-07-30T15:20:00Z + git diff --exit-code -- '**/preview.html' diff --git a/AUDIT-v2.md b/AUDIT-v2.md new file mode 100644 index 0000000..21457cc --- /dev/null +++ b/AUDIT-v2.md @@ -0,0 +1,704 @@ +# AUDIT-v2 + +Audit of shotkit v2.0.0 as it stands in the tree. Read-only pass. Findings only. + +> **Status.** This is a point-in-time record of v2.0.0 and is left unedited on purpose. The +> work addressing it shipped in v3.0.0; see `CHANGELOG.md`, which maps each change back to the +> defect it fixes. Line numbers below refer to the v2.0.0 tree, so read them against +> `git show v2.0.0:` rather than the current files. + +Tree state at audit time: + +- branch `main` at `673ee99`, level with `origin/main`, working tree clean +- tag `v2.0.0` exists locally and on `origin`, pointing at `39c2227`, one commit behind `main` +- 5 skills, 10 generator adapters, 5 validator scripts in `tools/` (4 named in the brief, plus `validate_brand_lock.py`) + +Severity vocabulary matches the critic (`skills/visual-asset-critic/templates/critique.schema.json:51`): `blocking`, `major`, `minor`. + +Finding IDs are stable within this document. Each blocking and major finding is scoped to one issue. + +--- + +## 1. Validator run + +`python3` on this machine is 3.14.5 with neither `pyyaml` nor `jsonschema` installed. All four named validators exit 1 on a dependency guard before doing any work: + +``` +$ python3 tools/validate_skills.py +ERROR: PyYAML not installed. Run: pip install pyyaml +EXIT=1 + +$ python3 tools/validate_schemas.py +ERROR: jsonschema not installed. Run: pip install jsonschema +EXIT=1 + +$ python3 tools/validate_capabilities.py +ERROR: jsonschema not installed. Run: pip install jsonschema +EXIT=1 + +$ python3 tools/validate_critique.py --selftest +ERROR: jsonschema not installed. Run: pip install jsonschema +EXIT=1 +``` + +Re-run under `~/.claude/media-tools-venv/bin/python` (yaml 6.0.3, jsonschema 4.26.0). Verbatim output: + +``` +======== validate_skills ======== +Validating 5 skill(s) in skills/ + + ok skills/brand-lock-extractor + ok skills/storyboard-architect + ok skills/storyboard-html-preview + ok skills/visual-asset-critic + ok skills/visual-prompt-forge + +All skills valid. +EXIT=0 + +======== validate_schemas ======== +Validating 4 schema file(s) + + ok skills/storyboard-architect/templates/shots.schema.json + ok skills/storyboard-architect/templates/text-overlays.schema.json + ok skills/visual-asset-critic/templates/critique.schema.json + ok skills/visual-prompt-forge/adapters/capabilities.schema.json + +All schemas valid. +EXIT=0 + +======== validate_capabilities ======== +Validating capability matrix: skills/visual-prompt-forge/adapters/_capabilities.json + +Capability matrix valid (10 generators, 0 warning(s)). +EXIT=0 + +======== validate_critique --selftest ======== + ok selftest: clean REVISE doc passes + ok selftest: ACCEPT-with-blocking is rejected by the gate + +Selftest passed. +EXIT=0 +``` + +Also run, because CI runs them and the brief named four: + +``` +======== validate_critique on example fixtures (CI step) ======== +Validating 2 critique file(s) + + ok skills/visual-asset-critic/examples/critique.accept.json + ok skills/visual-asset-critic/examples/critique.revise.json + +All critiques valid. +EXIT=0 + +======== validate_brand_lock (CI args) ======== +Validating 2 brand-lock file(s) + + ok brand-packs/_template.md + ok skills/brand-lock-extractor/examples/brand-lock.md + +All brand-locks valid. +EXIT=0 +``` + +Coverage of that green result, for the record: + +- `validate_skills.py` reads frontmatter `name` and `description` only (`tools/validate_skills.py:43-76`). +- `validate_schemas.py` checks that each `*.schema.json` is itself a valid schema and carries `$id`/`title`/`description` (`tools/validate_schemas.py:30-51`). It validates zero instances. +- `validate_capabilities.py` checks the matrix against its schema, checks that generator ids and adapter filenames match, and warns on staleness (`tools/validate_capabilities.py:67-99`). +- `validate_critique.py` checks two constructed in-memory documents under `--selftest`, and the two checked-in fixtures in CI. + +No validator in the repo reads a `shots.json` or a `text-overlays.json` instance. + +### F-01 `blocking` No instance validator exists for the two schemas the pipeline runs on + +`skills/storyboard-architect/templates/shots.schema.json` and `templates/text-overlays.schema.json` have no runnable validator anywhere in the repo. `tools/validate_schemas.py:25-27` globs `*.schema.json` and only calls `Draft202012Validator.check_schema` on each (`:39-42`). `.github/workflows/validate-skills.yml:22-45` has no instance step. + +Every "must validate against the schema" instruction is therefore model self-report: `skills/storyboard-architect/SKILL.md:151`, `skills/visual-prompt-forge/SKILL.md:65`, `skills/storyboard-html-preview/SKILL.md:53`. + +Failure: an operator produces a `shots.json` with a misspelled key, a bad `framing` value, or a missing `rationale`. Every documented gate passes. The break surfaces downstream as a `KeyError` in `tools/shots-to-html.py:152-160` or as silently absent content, at whatever point someone happens to run the preview. + +I wrote a one-off validator for this audit and confirmed all three bundled `shots.json` and all three `text-overlays.json` currently pass. Nothing in the repo will notice when they stop. + +### F-02 `major` The critique gate is repo-only and never runs on a real critique + +`tools/validate_critique.py` is the artifact that makes the verdict trustworthy (`docs/the-qa-loop.md:53`, `CHANGELOG.md:13`). It is not installed and not invoked: + +- `install.sh:38-44` copies only the five directories under `skills/`. `tools/` is never installed. +- No skill workflow step runs it. `skills/visual-asset-critic/SKILL.md:147` and `:149` mention it in the passive voice ("will reject it", "is validated by") inside Step 6, and Step 6 ends there. The critic writes `critique.json` and hands off (`:200-206`) without ever gating. +- CI runs it against `--selftest` plus two checked-in fixtures (`.github/workflows/validate-skills.yml:41-46`). + +Failure: a client project runs 36 critiques. Zero pass through the gate. The invariant is enforced only over two files that ship in the repo and never change. + +### F-03 `minor` `validate_critique.py` has no directory or glob mode + +`tools/validate_critique.py:121-135` requires explicit paths. `docs/the-qa-loop.md:75` shows `python tools/validate_critique.py output/critique.json`, a single file. `docs/the-qa-loop.md:88` describes the stop condition as "no critique.json has a verdict other than ACCEPT", plural. There is no command that checks a project's critiques. + +### F-04 `minor` `validate_capabilities.py` parity check globs all markdown in `adapters/` + +`tools/validate_capabilities.py:77` builds `md_ids` from `ADAPTERS_DIR.glob("*.md")`. Any non-adapter markdown placed in that directory (a README, a template, a note) fails parity at `:80-81` with "adapter 'X.md' has no entry in _capabilities.json". + +### F-05 `minor` `validate_skills.py` does not check that referenced files exist + +`tools/validate_skills.py:43-76` validates frontmatter only. `skills/storyboard-html-preview/SKILL.md:220` states that `examples/` contains generated `preview.html` files. `skills/storyboard-html-preview/examples/` is an empty, untracked directory. CI is green. + +--- + +## 2. QA loop trace: conventions the chain depends on with no schema or validator behind them + +Trace: `shots.schema.json` to the forge adapters to `critique.schema.json` to revision-mode `fix_type` branching. Each item below is a load-bearing convention with nothing enforcing it. + +### F-06 `major` `storyboard-architect` documents field names the schema rejects + +`skills/storyboard-architect/SKILL.md:103-104` instructs: + +``` +- `environment`, references series-lock language +- `lighting`, references series-lock language +``` + +The schema requires `environment_ref` and `lighting_ref` (`shots.schema.json:93-100`) and sets `additionalProperties: false` on the shot object (`shots.schema.json:62`). A model following Step 4 literally emits a file that fails the schema on two counts: unknown keys `environment` and `lighting`. + +The example block later in the same file uses the correct `_ref` names (`SKILL.md:174-175`), and all three bundled examples use `_ref`. The instruction and the example contradict each other inside one file, and F-01 means nothing catches whichever one the model followed. + +### F-07 `major` The documented motion vocabulary is missing four of eleven enum values + +`skills/storyboard-architect/SKILL.md:101` lists `static / push / pull / pan-left / pan-right / handheld / orbit`. + +`shots.schema.json:81-86` allows eleven: those seven plus `tilt-up`, `tilt-down`, `whip`, `rack`. `references/shot-grammar.md:39,42,52` documents all four missing ones. + +Failure: a model composing from SKILL.md never selects `tilt-up`, `tilt-down`, `whip`, or `rack`. Four documented camera moves are unreachable through the primary instruction path. + +### F-08 `major` Timing invariants are stated as a checklist and enforced nowhere + +`shots.schema.json:69-70` constrains `start >= 0` and `end > 0`. It does not constrain `end > start`, does not require shots to tile or order, and does not relate any shot to `project.duration_s` (`:26`). + +`skills/storyboard-architect/SKILL.md:193` states the invariant as a quality-bar checkbox: "Total of `(end - start)` across shots equals project duration (within 0.1s)". No tool computes it. The stated invariant is also the wrong one for a shot list: summed durations and timeline span only coincide when there are no gaps and no overlaps, which is itself unchecked. + +I verified all three bundled examples currently satisfy `end > start`, zero gaps, zero overlaps, and sum equal to `duration_s`. A `shots.json` with `start: 5.0, end: 3.0` validates. + +### F-09 `major` Cross-file reference integrity between `shots.json` and `text-overlays.json` is unenforced, and the two schemas disagree on cardinality + +`shots.schema.json:101-106` gives each shot one optional `on_screen_text` string. `text-overlays.schema.json:29-39` lets each overlay name one shot or an array of shots. Nothing checks that a `shot.on_screen_text` resolves to an overlay id, that an `overlay.shot_id` resolves to a shot, or that every overlay is reachable from some shot. `skills/storyboard-architect/SKILL.md:194` states the first of those three as a checkbox. + +The cardinality mismatch has already produced a defect in the repo's own flagship example: + +- `skills/storyboard-architect/examples/shotkit-explainer/text-overlays.json:77-88` defines `text_07` on `shot_06`. +- `skills/storyboard-architect/examples/shotkit-explainer/shots.json:109` sets `shot_06`'s `on_screen_text` to `text_06`. +- `text_07` is therefore unreachable from the shot side. Both renderers resolve exactly one overlay per shot (`tools/shots-to-html.py:227`, `skills/storyboard-html-preview/templates/preview.html.tpl:77-81,112-118`), so it is silently dropped. + +Verified: `text_07`'s content string "Pre-production for founder-led video at scale." appears zero times in the checked-in `skills/storyboard-architect/examples/shotkit-explainer/preview.html`. `skills/storyboard-html-preview/SKILL.md:214` asserts the opposite as a quality-bar item: "Every text overlay from `text-overlays.json` is rendered". + +### F-10 `major` Palette membership is claimed as schema-enforced and is not + +`docs/brand-lock-anatomy.md:87`: "The brand-lock file enforces this at the source. The schema enforces it at the output. The two together produce deterministic color across every run." + +`text-overlays.schema.json:53-57` validates hex format only, with a description that states the rule in prose: "Must come from brand-lock palette." `skills/storyboard-architect/SKILL.md:195` restates it as a checkbox. Nothing reads the brand-lock. + +Counterexample in tree: `skills/storyboard-architect/examples/shotkit-explainer/text-overlays.json:85` uses `#6B6B73`, which does not appear in `skills/storyboard-architect/examples/shotkit-explainer/brand-lock.snapshot.md`. The file validates. + +### F-11 `major` The `.md` versus `_capabilities.json` precedence rule is a convention with no check, and three entries currently disagree + +`skills/visual-prompt-forge/SKILL.md:84` establishes the rule: "Where a number in an adapter `.md` and in `_capabilities.json` disagree, the JSON wins." Every adapter repeats it on line 3. `tools/validate_capabilities.py:76-81` compares filenames to ids and never reads a number out of any `.md`. + +Live disagreements: + +- `adapters/gpt-image.md:38` states "150-300 words"; `_capabilities.json:60` caps `max_prompt_words` at 250. The upper half of the documented range is over the cap. +- `adapters/seedream.md:38` states "40-70 words per prompt. Shorter than most."; `_capabilities.json:92` sets 120. The prose is 40 percent of the cap. +- `_capabilities.json:12` sets midjourney `max_prompt_words` to 80 while `_capabilities.json:20` notes "Over 100 words underperforms." The single source of truth contradicts itself in adjacent lines. +- `adapters/ideogram.md` states no word budget at all; `_capabilities.json:44` sets 120. + +### F-12 `major` The nano-banana aspect parameter name is wrong in the file that wins + +`_capabilities.json:82` sets `aspect_param` to `aspect_ratio`. `adapters/nano-banana.md:30` documents the parameter as `aspectRatio`, and `:101` states it explicitly: "Nano Banana ignores `--ar`, expects `aspectRatio` parameter." + +Under the precedence rule at `skills/visual-prompt-forge/SKILL.md:84`, the JSON wins and the forge emits `aspect_ratio`. `capabilities.schema.json:52` types `aspect_param` as any non-empty string, so no validator can catch it. + +Failure: every nano-banana prompt file carries a parameter name the API does not recognize, and the adapter file that says so is the one the rule tells the model to disregard. + +### F-13 `major` Revision mode treats REJECT identically to REVISE + +`skills/visual-prompt-forge/SKILL.md:143-144` branches on one thing: "Skip any with `verdict: ACCEPT`, those are done. For every non-ACCEPT shot, walk its `issues[]`". + +`skills/visual-asset-critic/SKILL.md:106` defines REJECT as "Three+ layers fail or one critical layer (Brand Lock, Series Lock) hard-fails with no clear fix". `:139` defines `blocking` as "hard-fail on a critical layer with no clear fix, or a defect that makes the asset unusable". `tools/validate_critique.py:47-50` forces REJECT whenever any issue is `blocking`. + +So the loop takes a verdict that means "no fix path exists" and re-emits a prompt with the fix applied. `docs/the-qa-loop.md:24` routes REVISE and REJECT down the same arrow. There is no escalation branch, no human gate, no round cap in any file. The only stop conditions documented are "every shot is ACCEPT" or "you decide a shot is good enough" (`docs/the-qa-loop.md:37`). + +### F-14 `major` `fix_type: post-level` produces no artifact and no record + +`skills/visual-prompt-forge/SKILL.md:148`: "A shot that has only `post-level` issues needs no new prompt, leave it out of the revised file." `:162` says to tell the user which shots need only post work. + +That instruction is a chat message. Nothing is written. The compositing obligation exists only in the surviving `critique.json`, and per F-16 that file is overwritten on the next review. A shot whose only defect is post-level therefore exits the loop as an ACCEPT-equivalent with no on-disk record that post work is owed. + +### F-15 `major` The prompt-file format is defined only by a regex in a helper + +The forge writes prompt files whose structure is documented in prose at `skills/visual-prompt-forge/SKILL.md:99-117` (header comment block, then `# shot_NN` comment, then prompt body). There is no schema and no validator. The only executable definition is `tools/copy-prompt.py:20`: + +```python +SHOT_HEADER = re.compile(r'^#\s*(shot_\d+)\b(.*)$') +``` + +`tools/copy-prompt.py:54-55` treats every line after a shot header as prompt body, including further comment lines. See F-21 for the concrete break this produces on revision-mode output. + +### F-16 `blocking` Every loop artifact is written to a fixed path, so each write destroys the previous state + +- `output/critique.json`: `skills/visual-asset-critic/SKILL.md:24`, `:120`, `:149`; `docs/the-qa-loop.md:20`, `:40`, `:75`. +- `output/prompts/{generator}.txt`: `skills/visual-prompt-forge/SKILL.md:27-39`. +- `output/prompts/revised-{generator}.txt`: `skills/visual-prompt-forge/SKILL.md:153`; `docs/the-qa-loop.md:78`, `:87`. +- `output/generated/shot_NN.{png,jpg}`: `skills/storyboard-html-preview/SKILL.md:34`, `:51`, `:130-134`; `tools/shots-to-html.py:76-83`. + +One critique is one shot's verdict (`skills/visual-prompt-forge/SKILL.md:143`). A 12-shot project produces 12 critiques per round, all at the same path. No filename carries a shot id, a round number, an operator, or a run id. No schema has a field for any of those (see F-18). + +Failure: reviewing shot_02 destroys shot_01's verdict. The forge in revision mode then reads whatever single file survived and re-emits prompts for that one shot, reporting completion. `docs/the-qa-loop.md:41` describes revision mode as taking "one or more `critique.json` files"; nothing in the repo names or produces more than one. + +### F-17 `major` `assets.generated[].accepted` has no writer, no consumer, and no link to a verdict + +`shots.schema.json:114-137` adds the v1.1 asset block: `path`, `generator`, `accepted`. `shots.schema.json:116` states its purpose: "Lets visual-asset-critic and the HTML preview find images without a schema bump." + +- No skill writes it. Neither the critic's Step 6 output mapping (`skills/visual-asset-critic/SKILL.md:122-131`) nor revision mode (`skills/visual-prompt-forge/SKILL.md:141-162`) touches `shots.json`. +- No tool reads it. `tools/shots-to-html.py:76-83` finds images by filename convention and ignores `assets` entirely. +- No bundled example contains it. +- `README.md:221` states the wiring is unfinished: "The `shot.assets` field landed in shots schema v1.1; wiring the HTML preview and critic to consume it is the remaining work." + +`accepted: true` has no reference to the critique that produced the acceptance, no timestamp, and no approver. `generator` is an unconstrained string (`shots.schema.json:131`) with no relation to the ids in `_capabilities.json`. + +### F-18 `major` `critique.schema.json` forbids adding provenance in band + +`critique.schema.json:8` sets `additionalProperties: false` on the root. There is no `meta` passthrough, unlike `shots.schema.json:15-19`. + +Consequence: a content hash, run id, round number, prompt reference, generator id, model version, or seed cannot be added to a critique without a schema version bump. The one schema that records a decision is the one schema that cannot be extended by a downstream operator. + +### F-19 `minor` The `rack` token means two different things in one schema + +`shots.schema.json:85` lists `rack` in the `motion` enum. `shots.schema.json:90` lists `rack` in the `depth_of_field` enum. `references/shot-grammar.md:52` documents it only as a focus behavior. Disambiguation depends entirely on which field it appears in. + +### F-20 `minor` `depth_of_field` is in the schema and all three examples but absent from the architect instructions + +`shots.schema.json:88-91` defines it. All three bundled `shots.json` set it on every shot. `skills/storyboard-architect/SKILL.md:96-107` does not list it in the per-shot field set, and the example block at `:153-182` omits it. + +### F-21 `major` `copy-prompt.py` cannot read revision-mode output + +Verified empirically. Constructed the revision file exactly as documented at `skills/visual-prompt-forge/SKILL.md:155-160`: + +``` +# Revision of shot_03 (was REVISE) +# fix [Series Lock, major]: added 'salt-and-pepper hair' to the character anchor (was missing) +# fix [Shot Spec, minor]: medium shot -> medium close-up +medium close-up of founder mid-thirties, salt-and-pepper hair --ar 9:16 --style raw --s 50 +``` + +Result: + +``` +$ python tools/copy-prompt.py /tmp/revised-midjourney.txt --list +No shot blocks found in /tmp/revised-midjourney.txt. +Expected lines like: # shot_03, beat, timing, framing +EXIT=1 + +$ python tools/copy-prompt.py /tmp/revised-midjourney.txt --shot shot_03 +No shot blocks found in /tmp/revised-midjourney.txt. +EXIT=1 +``` + +`tools/copy-prompt.py:20` requires the shot id immediately after `#`. The documented revision header puts "Revision of" in front of it. The same command run against `skills/visual-prompt-forge/examples/one-shot-all-adapters/midjourney.txt` succeeds. + +`README.md:112` places `tools/copy-prompt.py` at step 4 of the round-trip workflow. It works on round one and fails on every revision round. + +### F-22 `major` Two renderers, no shared template, and a README that claims otherwise + +`tools/README.md` on `shots-to-html.py`: "The output is identical to what the skill produces. Same template, same CSS, same JavaScript." + +`tools/shots-to-html.py:210-211` reads `styles.css.tpl` and `print.css.tpl` only. `tools/shots-to-html.py:239-240` states in a comment: "using a simpler direct render rather than the templated one, to avoid full handlebars dependency. Output is equivalent." `skills/storyboard-html-preview/templates/preview.html.tpl` is never opened. + +`skills/storyboard-html-preview/SKILL.md:142-149` defines a template-flag convention (`has_image`, `has_no_image`, `on_screen_text`, plus nine resolved `overlay_*` fields) for a template the CLI does not use. Nothing compares the two renderers' output. + +### F-23 `major` The brand-lock snapshot header is required by the architect and checked by nothing + +`skills/storyboard-architect/SKILL.md:134-141` requires two HTML comments at the top of every snapshot, "ISO-8601 timestamp" and source path, and states: "This is what makes the storyboard reproducible later." `docs/audit-trail-pattern.md:59-66` repeats it as the mechanism. + +`tools/validate_brand_lock.py:89-115` checks section headings, four Identity fields, and the presence of a hex-shaped string. It never looks at the header. CI runs it against `brand-packs/_template.md` and `skills/brand-lock-extractor/examples/brand-lock.md` only (`.github/workflows/validate-skills.yml:32-33`), neither of which is a snapshot. + +The three snapshots in the tree already diverge: + +- `examples/30s-pain-proof-promise/brand-lock.snapshot.md:1-2` and `examples/60s-founder-explainer/brand-lock.snapshot.md:1-2`: `2026-05-07T14:23:00Z`, two comments. +- `examples/shotkit-explainer/brand-lock.snapshot.md:1-3`: `2026-05-08`, date only, no time, no zone, plus a third `` comment the others do not have. + +All three pass `validate_brand_lock.py`. So does a snapshot with no header at all. + +### F-24 `major` An unfilled template passes as a valid brand-lock + +`tools/validate_brand_lock.py:112` accepts template placeholders: + +```python +if not re.search(r"#[0-9A-Fa-f]{6}|#[_]{6}", palette_body): +``` + +`brand-packs/_template.md:19-23` supplies five rows of `#______`. `skills/storyboard-architect/SKILL.md:68` instructs the architect to copy that template into the output as `brand-lock.snapshot.md` when no brand-lock is provided, with an `UNCONFIGURED` note in a comment. + +The validator cannot distinguish a real brand-lock from a blank one. Downstream, `tools/shots-to-html.py:47-52` will not match `#______` with its hex regex and falls back to the hardcoded generic defaults at `:37-43` (`#3B82F6` accent) with no warning. + +### F-25 `major` A cross-skill relative path breaks under the repo's own documented packaging + +`skills/visual-prompt-forge/SKILL.md:65` refers to `../storyboard-architect/templates/shots.schema.json`. + +`docs/claude-ai-workflow.md:12-20` documents the Claude.ai path as zipping each skill directory individually and uploading five separate `.skill` artifacts. In that surface the parent directory does not exist. The same break occurs for any single-skill install, and `install.sh:93-96` allows skipping individual skills at the overwrite prompt, so a partial install produces the same result on Claude Code. + +### F-26 `major` `tools/` is not installed, so every tool path in a SKILL.md is unresolvable after install + +`install.sh:38-44` defines the install set as five skill directories. `install.sh:104` copies `skills/` to `/`. `tools/` is never copied, and no skill carries its own copy. + +Paths that do not resolve after `./install.sh`: + +- `python tools/copy-prompt.py output/prompts/midjourney.txt` (`skills/visual-prompt-forge/SKILL.md:128`) +- `python tools/validate_brand_lock.py path/to/file.md`, inside the handoff message the extractor is told to send the user (`skills/brand-lock-extractor/SKILL.md:88`) +- `tools/validate_critique.py` (`skills/visual-asset-critic/SKILL.md:147`, `:149`) + +`tools/shots-to-html.py:25-26` compounds this: `TEMPLATE_DIR` is derived from the script's own parent, so the CLI only functions from inside a repo checkout. + +### F-27 `minor` `install.sh` copies working-tree junk into the skills directory + +`install.sh:104` uses `cp -R "${src}" "${dst}"`. `.DS_Store` files exist at `skills/.DS_Store`, `skills/visual-prompt-forge/.DS_Store`, and `skills/visual-prompt-forge/examples/.DS_Store`. They are gitignored, so they do not ship over git, but they are copied into `~/.claude/skills/` on any local install and into any `.skill` zip built per `docs/claude-ai-workflow.md:16`. + +--- + +## 3. Provenance: what identifies each artifact the pipeline writes + +Confirmed as stated in the brief. `image_ref` and `brand_lock_ref` are filenames. No content hash, no timestamp, no run id, no prompt hash, no generator id, anywhere in any schema. + +| Artifact | Written by | What identifies it | +|---|---|---| +| `storyboard.md` | architect (`SKILL.md:29`) | filename. No version field, no run id. | +| `shots.json` | architect (`SKILL.md:30`) | `version` (schema version, not run version), `project.title`. No run id, no timestamp, no hash. | +| `text-overlays.json` | architect (`SKILL.md:31`) | `version` const `1.0`. Nothing else. No reference back to the `shots.json` it belongs to. | +| `brand-lock.snapshot.md` | architect (`SKILL.md:132-139`) | two HTML comments, unchecked (F-23). No hash of the source brand-pack. | +| `prompts/{generator}.txt` | forge (`SKILL.md:99-117`) | free-text comment header including `# Generated: {timestamp}` and `# Brand-lock: brand-lock.snapshot.md`. Not machine-parsed by anything; `copy-prompt.py:20` reads only `# shot_NN` lines. | +| `prompts/revised-{generator}.txt` | forge (`SKILL.md:153`) | filename plus per-shot prose annotations. No round number. | +| `generated/shot_NN.png` | the operator or their generator | filename equal to the shot id. Nothing else. No sidecar. | +| `critique.json` | critic (`SKILL.md:118-131`) | optional `shot_id`, optional `image_ref`, optional `brand_lock_ref`, all bare paths. | +| `preview.html` | preview skill / `shots-to-html.py` | render-time timestamp, hardcoded brand-lock link. | + +### F-28 `blocking` `critique.json` can be schema-valid, gate-passing, and identify nothing + +`critique.schema.json:7`: `"required": ["version", "verdict", "confidence", "issues"]`. + +`shot_id` (`:11-17`), `brand_lock_ref` (`:18-21`), and `image_ref` (`:22-25`) are all optional. `tools/validate_critique.py:41-55` reads only `verdict` and `issues[].severity`. + +A document consisting of `version`, `verdict: ACCEPT`, `confidence: HIGH`, `issues: []` passes both the schema and the gate. It is a signed approval of nothing in particular. The `--selftest` fixtures at `tools/validate_critique.py:83-100` are themselves exactly this shape: neither carries `shot_id`, `image_ref`, or `brand_lock_ref`, and both pass. + +### F-29 `blocking` No artifact binds an image to the prompt, generator, or brand-lock that produced it + +`critique.schema.json:22-25` records `image_ref` as a path. There is no field for the prompt text, prompt file, prompt hash, generator id, model version, seed, or generation timestamp, and `additionalProperties: false` (`:8`) prevents adding one (F-18). + +`docs/audit-trail-pattern.md:137-143` claims the trail already supports pointing to "The prompt that drove the generation" and "The image that was approved". `docs/audit-trail-pattern.md:106` contradicts that on the same page, listing the mechanism as an unshipped extension: "Render manifests. When images are generated, log which prompt produced which image, with which seed, on which date. A `renders.json` alongside the four files completes the loop from spec to artifact. These are extensions." + +`docs/audit-trail-pattern.md:108` does the same for approvals: `approvals.json` is an extension, not shipped. `README.md:86` states the outcome as delivered: "Six months later, you can still answer 'what brand version was this approved against.'" + +### F-30 `major` Everything in the chain references state by name, and the base of those names is inconsistent + +Name-based references, all unhashed: + +- `shots.schema.json:37-40` `brand_lock_ref`, described as relative "within the output directory". +- `critique.schema.json:18-21` `brand_lock_ref`, base unspecified. +- `critique.schema.json:22-25` `image_ref`, base unspecified. +- `shots.schema.json:128-134` `assets.generated[].path`, base unspecified. +- `shots.schema.json:101-106` `on_screen_text`, an id reference into a separate file with no file reference. +- `text-overlays.schema.json:29-39` `shot_id`, an id reference into a separate file with no file reference. +- `text-overlays.schema.json:41-44` `font`, described as "Must reference a font defined in brand-lock typography", by name, unchecked. +- `shots.schema.json:93-100` `environment_ref` / `lighting_ref`, whose documented default value is the literal string `series_lock.environment`, a hand-written path into the same document. +- `tools/shots-to-html.py:293` hardcodes the link text and href `brand-lock.snapshot.md`, ignoring `shots_data['brand_lock_ref']` entirely. + +The two shipped critique fixtures mix bases inside a single file: `skills/visual-asset-critic/examples/critique.accept.json` sets `brand_lock_ref` to `brand-lock.snapshot.md` (output-relative) and `image_ref` to `output/generated/shot_01.png` (project-root-relative). Both fields pass. + +### F-31 `major` Images are resolved by filename convention that no schema describes + +`tools/shots-to-html.py:76-83`: + +```python +def find_image(generated_dir: Path, shot_id: str) -> Path | None: + for ext in ("png", "jpg", "jpeg", "webp"): + p = generated_dir / f"{shot_id}.{ext}" +``` + +The convention `output/generated/{shot_id}.{ext}` appears in `skills/storyboard-html-preview/SKILL.md:34`, `:51`, `:130-134` and in the two critique fixtures. It is not in any schema. It competes with `shot.assets.generated[].path` (F-17), which is in the schema and has no reader. + +Nothing distinguishes a first draft from a fifth re-roll at that path, and nothing records which of the two conventions a given project used. + +--- + +## 4. State after a 12-shot project through three revision rounds + +Assumes the operator follows the documented conventions exactly: `docs/the-qa-loop.md:69-80` for the by-hand loop, one target generator, the critic writing `output/critique.json` per `skills/visual-asset-critic/SKILL.md:120`, frames landing at `output/generated/shot_NN.png` per `skills/storyboard-html-preview/SKILL.md:130`. + +### What exists on disk + +``` +output/ +├── storyboard.md 1 file, from the architect run +├── shots.json 1 file, 12 shots, no assets block +├── text-overlays.json 1 file +├── brand-lock.snapshot.md 1 file, header unchecked +├── prompts/ +│ ├── {generator}.txt 1 file, round-1 prompts for all 12 shots +│ └── revised-{generator}.txt 1 file, round-3 revisions only +├── generated/ +│ └── shot_01.png … shot_12.png up to 12 files, last surviving frame per shot +├── critique.json 1 file +└── preview.html optional, stamped with the date last rendered +``` + +36 critiques were produced. One file remains: the last one written in round 3. 35 verdicts are gone (F-16). Two revised prompt sets were produced in rounds 1 and 2; both were overwritten by round 3 (F-16). If the operator re-generated a shot in place, earlier frames are gone as well. + +### What a person can reconstruct six months later + +- The storyboard intent: beats, timing, framing, angle, motion, subject, per-shot rationale. `shots.json` and `storyboard.md` are written once and not touched by the loop. +- The on-screen text spec, minus any overlay unreachable from a shot (F-09). +- The brand-lock text the project claims to have been built against, as a document. +- The round-1 prompt set for all 12 shots, including which generator it targeted, from the file name and the `# Generator:` header line. +- The round-3 revised prompt set for whichever shots failed in round 3, and, from the prose annotations at `skills/visual-prompt-forge/SKILL.md:157-159`, what changed and which layer and severity drove it. +- One critique in full: one shot, one verdict, its issues, its severities, its fixes. +- The 12 surviving image files. + +### What is lost + +- 35 of 36 verdicts. Which shots ever failed, on which layer, at what severity, in which round, and why. +- Round 1 and round 2 revised prompts. The prompt that actually produced 11 of the 12 surviving frames is not on disk. +- Which round any given frame came from, and how many attempts it took. +- Whether any frame was ever ACCEPTed. `assets.generated[].accepted` exists (`shots.schema.json:133`) and has no writer (F-17). The surviving `critique.json` covers one shot and may not name it (F-28). +- Which prompt produced which frame. There is no link in either direction. If more than one generator was targeted, `generated/shot_03.png` does not say which one made it. +- Which generator, model version, seed, or settings produced any frame. `_capabilities.json:9` records `model_version` for the matrix as of `matrix_last_reviewed` (`:4`), not per run, and nothing copies it into the output. +- Whether the `brand-lock.snapshot.md` on disk is the one the prompts and frames were built from. `brand_lock_ref` is a name (F-30). Re-running the architect overwrites the snapshot in place at the same path, and `shots.json` still resolves. +- Any post-level compositing obligation. Recorded only in critiques that no longer exist (F-14). +- The original render date of `preview.html`. `tools/shots-to-html.py:241` stamps `datetime.now()`, and `:267` and `:293` write it into the header and the footer. Re-rendering silently replaces the run date with today's. +- Who approved anything, and when. `docs/audit-trail-pattern.md:108` lists `approvals.json` as an unshipped extension. + +### F-32 `major` Re-rendering the preview overwrites the only date on the artifact with a false one + +Verified. Copied `skills/storyboard-architect/examples/shotkit-explainer/` out of the repo and re-ran `tools/shots-to-html.py` against it. The regenerated file is byte-identical to the checked-in `preview.html` except for the timestamp, which appears twice: 8 diff lines total, both hunks timestamp-only. Same byte count, 25904. + +`tools/shots-to-html.py:293` then asserts: "Generated against `brand-lock.snapshot.md` on {timestamp}". The statement is false whenever the preview is regenerated after the run, which is the normal case for a shareable review artifact. + +### F-33 `minor` The preview's version marker is hardcoded and does not track anything + +`tools/shots-to-html.py:261` writes `Storyboard · v1.0` into the header regardless of the `shots.json` `version` field (which may be `1.1`) and regardless of the shotkit version. The template does the same at `skills/storyboard-html-preview/templates/preview.html.tpl:15-16`. + +### F-34 `major` `shots-to-html.py` interpolates model-generated strings into HTML with no escaping + +`tools/shots-to-html.py` builds output with f-strings throughout: `:98` (alt text), `:115` (a `style` attribute delimited by single quotes, taking `overlay["font"]`, `overlay["weight"]`, `overlay["color"]`), `:126` (overlay content inside literal double quotes), `:133` (VO line inside literal double quotes), `:157` (subject), `:160` (rationale), `:279-282` (all four `series_lock` values). + +`font` is an unconstrained string (`text-overlays.schema.json:41-44`). Subject, rationale, and VO are free prose generated by a model. There is no `html.escape` call in the file. + +Failure: a rationale containing `<` or a VO line containing a double quote produces broken markup in the artifact handed to a client. A `font` value containing `'` closes the style attribute. F-01 means no instance validation runs first. + +### F-35 `major` `shots-to-html.py` discards brand typography and depends on undocumented palette role names + +`tools/shots-to-html.py:67-68` hardcodes `display_font` and `body_font` to `"Inter"` in both the parsed and the fallback path. `skills/storyboard-html-preview/SKILL.md:59` requires extracting "Display font and body font names" from the snapshot, and `:212` lists brand appearance in the quality bar. + +`tools/shots-to-html.py:47-58` extracts colors by matching table rows and then substring-matching role names against the literal list `background`, `ink`, `accent (warm)`, `accent`, `muted`, `rule`. Those words match `brand-packs/whystrohm.md:14-19` and `brand-packs/examples/saas-clean.md:16-20`. Nothing documents them as a requirement, no schema constrains a brand-lock palette table, and `tools/validate_brand_lock.py:110-113` only checks that some hex-shaped string exists. Any brand-lock using different role words falls back silently to `#3B82F6` and friends (`:37-43`). + +--- + +## 5. Failure modes + +### F-36 `blocking` Two operators on one project silently destroy each other's verdicts + +All four loop artifacts are fixed paths (F-16), and no schema has a run id, round number, or author field. + +- Operator A critiques shot_03, writes `output/critique.json` with `verdict: REVISE`. Operator B critiques shot_07 thirty seconds later and writes the same path. A's verdict no longer exists. Neither file contained a shot list, so nothing indicates a loss occurred. +- Either operator then runs revision mode. `skills/visual-prompt-forge/SKILL.md:143` reads the surviving critique, re-emits prompts for that one shot, and reports which shots were revised. The report is accurate about the file it read and wrong about the project. +- Both operators write `output/prompts/{generator}.txt` and `output/prompts/revised-{generator}.txt`. Last writer wins. The only distinguishing mark is the `# Generated: {timestamp}` comment (`skills/visual-prompt-forge/SKILL.md:106`), which no tool reads. +- Both write `output/generated/shot_NN.png`. Operator A's accepted frame is replaced by B's re-roll. `tools/shots-to-html.py:76-83` picks up the new file on the next preview with no change to `shots.json` and no change to any critique. +- `docs/audit-trail-pattern.md:118` instructs teams to "Commit all four output files to Git per major revision". Concurrent work therefore lands as merge conflicts on binary PNGs and on a `critique.json` whose content carries no shot identity, so the conflict cannot be resolved by reading it. + +There is no lock file, no per-run output directory, and no `run_id` field in `shots.schema.json`, `text-overlays.schema.json`, or `critique.schema.json`. + +### F-37 `blocking` A frame regenerated without re-running the critic terminates the loop on an unreviewed image + +Replace `output/generated/shot_03.png`. Nothing else changes: + +- `shots.json` is untouched; the architect owns it and the loop never writes it. +- `output/critique.json` still holds whatever verdict was last written, describing the file that used to be at that path. `image_ref` is a name (`critique.schema.json:22-25`), so it still "resolves". +- No content hash exists anywhere, so no tool can detect that the bytes changed. +- `tools/shots-to-html.py:76-83` embeds the new frame under the old rationale, and `:293` stamps it "Generated against brand-lock.snapshot.md on {today}". +- `docs/the-qa-loop.md:88` defines the pipeline stop condition as "no critique.json has a verdict other than ACCEPT". A stale ACCEPT survives the swap, so the loop reports done on a frame no critic has seen. +- `assets.generated[].accepted` would be the field that goes stale here. It has no writer (F-17), so there is not even a stale value to catch. + +The gate that `docs/the-qa-loop.md:55` says you can "branch on `critique.json.verdict` and trust" is trustworthy about severity arithmetic (`tools/validate_critique.py:47-53`) and says nothing about whether the verdict still describes the file on disk. + +### F-38 `blocking` A mid-project brand-lock change silently repoints history + +The one thing that works: editing `brand-packs/whystrohm.md` does not alter an existing `output/brand-lock.snapshot.md`. Everything downstream of that breaks. + +- Re-running the architect re-snapshots to the same path (`skills/storyboard-architect/SKILL.md:132-139`, output set at `:29-33`). The file is overwritten in place. Round-1 prompts and frames now sit beside a snapshot they were not built from. `shots.json:brand_lock_ref` still resolves, so no tool reports a mismatch (F-30). +- The critic is then handed the new snapshot (`skills/visual-asset-critic/SKILL.md:56` lists brand-lock as a recommended input) and judges round-1 frames against rules that did not exist when they were generated. A `blocking` Brand Lock issue forces REJECT (`tools/validate_critique.py:47-50`), so frames get rejected for retroactive violations. The critique records `brand_lock_ref: "brand-lock.snapshot.md"`, a string that is now ambiguous across the project's history. +- Revision mode re-forges those shots against the new brand-lock while reusing the same fixed output paths (F-16), so the round-1 prompt set built against the old brand state is gone. +- A color removed from the brand pack keeps validating in `text-overlays.json` forever, because palette membership is unenforced (F-10). The overlay renders in the retired color, and `docs/brand-lock-anatomy.md:87` says the schema prevents exactly this. +- `tools/shots-to-html.py:293` continues to assert the preview was "Generated against brand-lock.snapshot.md" with today's date, for both the pre-change and post-change frames in the same document. + +### F-39 `major` Determinism is asserted repeatedly and tested nowhere + +`docs/why-this-exists.md:33`: "Determinism, same inputs, same outputs. If two team members run the same brief, they should produce the same storyboard." +`skills/visual-prompt-forge/SKILL.md:186`: "If two consecutive runs produce different prompts for the same shot, the skill is broken. Determinism is the whole point." +`skills/visual-prompt-forge/SKILL.md:149`: "same inputs plus the same critique produce the same revised prompt." +`docs/audit-trail-pattern.md:85`: "Regeneration on demand. Same inputs, same outputs." + +There is no `tests/` directory, no golden-output fixture, and no CI step that runs a skill twice and diffs. Every composition step is model judgment: the five-layer assembly (`skills/visual-prompt-forge/SKILL.md:86-96`), the severity mapping (`skills/visual-asset-critic/SKILL.md:133-139`), and the application of a free-prose `fix` string (`critique.schema.json:60`) to a prompt. + +The forge's own output format defeats byte-level determinism regardless: `skills/visual-prompt-forge/SKILL.md:106` puts `# Generated: {timestamp}` in every prompt file header. + +The one component that is demonstrably deterministic is `tools/shots-to-html.py`, and only modulo its timestamp (F-32). + +--- + +## 6. Drift: CHANGELOG v2.0.0 against the tree + +### Claimed and present + +Verified in the tree: `brand-lock-extractor` (`CHANGELOG.md:11`), `critique.json` output and schema (`:12`), `validate_critique.py` with `--selftest` (`:13`), `_capabilities.json` plus `capabilities.schema.json` (`:14`), `validate_capabilities.py` (`:15`), revision mode (`:16`), the four fal.ai motion adapters (`:17`), the two critique fixtures (`:18`), shots schema v1.1 (`:19`), `docs/the-qa-loop.md` (`:20`), the `install.sh` hardening (`:26`, matching `install.sh:46-54`), the two new CI steps (`:27`), and the `SocialPreview` composition (`:28`, `remotion/src/SocialPreview.tsx:22` reads `v2.0.0`). The `runway-sora` adapter and its capability entry are gone (`:32`). + +### F-40 `major` The v2.0.0 tag is published, the CHANGELOG says Unreleased, and the tag is not what is on main + +`CHANGELOG.md:5`: `## [2.0.0] - Unreleased`, with no date. + +Actual state: `git tag -l` returns `v2.0.0`. `git ls-remote --tags origin` returns it on the remote. The tag object is `1d806fa`, dereferencing to commit `39c2227` ("chore: brand this release v2.0.0"). `main` is at `673ee99`, one commit ahead. `git merge-base --is-ancestor v2.0.0 main` succeeds. + +The commit the tag excludes is `673ee99`, which changed three files: `LICENSE` (+184 lines), `docs/images/demo.gif`, and `remotion/src/ShotkitDemo.tsx`. + +Consequence: `git show v2.0.0:LICENSE` is 17 lines, the Apache short-form notice only. `LICENSE` on main is 201 lines, the full Apache-2.0 text with appendix. Anyone fetching the published v2.0.0 tag gets the pre-LICENSE-completion tree. `README.md:253` and `CHANGELOG.md` both state Apache 2.0. + +Also: `CHANGELOG.md:34` records `[0.1.0], 2026-05-08` and no `v0.1.0` tag exists. + +### F-41 `major` `README.md` lists the release's headline feature as still on the roadmap + +`README.md:219`, under "Still on the roadmap": "**`brand-lock-extractor`**. Upload a brand book (PDF, screenshots, URL), get a `brand-lock.md` back. The cold-start killer." + +The same file ships it at `:50` ("The five skills"), `:54` (table row), `:14` ("all five skills"). `CHANGELOG.md:11` announces it as added in this release. `install.sh:39` installs it. + +### F-42 `major` The schema description claims a consumer that `README.md` says does not exist + +`shots.schema.json:116`: the `assets` block "Lets visual-asset-critic and the HTML preview find images without a schema bump." + +`README.md:221`: "The `shot.assets` field landed in shots schema v1.1; wiring the HTML preview and critic to consume it is the remaining work." + +`CHANGELOG.md:19` states v1.1 was "verified against all bundled examples". No bundled example contains an `assets` block, and no CI step validates any instance (F-01). See also F-17. + +### F-43 `minor` `docs/the-qa-loop.md` describes a four-skill kit + +`docs/the-qa-loop.md:5`: "shotkit's four skills already give you the pieces of a review." + +There are five. `README.md:50`, `docs/claude-code-workflow.md:7`, `:16`, `:86`, and `install.sh:38-44` all say five. + +### F-44 `minor` Adapter count is seven in three places and ten in two + +Ten adapters exist in `skills/visual-prompt-forge/adapters/` and ten entries in `_capabilities.json`. + +- `docs/connecting-to-generators.md:47`: "The seven adapters in `visual-prompt-forge`". +- `skills/visual-prompt-forge/SKILL.md:225`: "`examples/one-shot-all-adapters/` contains a single shot rendered to all seven adapters side-by-side." +- `CHANGELOG.md:70`: "One shot rendered across all 7 generator adapters". + +Against `README.md:56` ("10 generators (6 stills, 4 motion)") and `README.md:87` ("Ten generators, one spec"). + +`skills/visual-prompt-forge/examples/one-shot-all-adapters/` contains seven `.txt` files: flux, gpt-image, ideogram, kling, midjourney, nano-banana, seedream. There is no worked example for veo, seedance, or hailuo, the three adapters added in this release. + +### F-45 `minor` Sora survives in the flagship demo the README points visitors to + +`CHANGELOG.md:32` records the `runway-sora` removal. `CHANGELOG.md:28` discloses the exception: "The demo.gif and explainer videos are unchanged." + +`README.md:18` presents that unchanged asset as the primary demo: "**Watch shotkit explain itself.** The 90-second explainer was made *by* shotkit." + +Surviving references: + +- `skills/storyboard-architect/examples/shotkit-explainer/shots.json:90`: subject text listing "runway sora" as one of seven adapters. +- `skills/storyboard-architect/examples/shotkit-explainer/storyboard.md:51-53`: "Midjourney, Flux, Ideogram, GPT Image, Nano Banana, Seedream, Runway/Sora", plus on-screen text "One shot. Seven generators. One spec." +- `skills/storyboard-architect/examples/shotkit-explainer/storyboard.md:39`: "prompts/ directory with seven adapter files". +- `remotion/src/ShotkitExplainer.tsx:238`, `:393`, `:642`: Sora in the adapter list, the file tree, and the radial diagram. +- `remotion/src/ShotkitExplainer.tsx:977`: version string reads `v0.1.0`. + +`docs/why-this-exists.md:22` and `docs/connecting-to-generators.md:39` both cite the Sora removal as evidence of model agnosticism, in the same repo where the demo still shows it. + +### F-46 `minor` `remotion/package.json` version is 0.1.0 + +`remotion/package.json:3`: `"version": "0.1.0"`. `remotion/src/SocialPreview.tsx:22` and `remotion/src/ShotkitDemo.tsx:393,410` read `v2.0.0`. + +### F-47 `minor` CHANGELOG 0.1.0 entries are stale against the current tree + +- `CHANGELOG.md:54`: "Three validation scripts (frontmatter, JSON schemas, brand-lock structure)". There are five. +- `CHANGELOG.md:83-89`, "Known v2.0.0 work", lists four items. One shipped (`brand-lock-extractor`). The other three are absent from the tree and absent from the 2.0.0 Added list: PDF and PPTX exporters, user-supplied asset folder convention, duration-rescale workflow with beat-aware redistribution. `README.md:220-222` still lists all three as roadmap. + +### F-48 `minor` `tools/README.md` overstates what CI and the tools cover + +- On `shots-to-html.py`: "The output is identical to what the skill produces. Same template, same CSS, same JavaScript." False on the template (F-22). +- On `validate_brand_lock.py`: "Run before committing new brand-pack examples." CI runs it against two fixed paths, neither of which is a brand-pack example or a snapshot (F-23). +- `README.md:236-247` lists five validator commands as the local pre-PR set and states "CI runs all of these on every PR". CI also runs `validate_critique.py` against the two example fixtures, which the README list omits. + +### F-49 `minor` A monthly price is committed in the docs + +`docs/connecting-to-generators.md:44`: "The operated pipeline (running generators, managing rendering, automated publishing) is what WhyStrohm offers commercially at $3,000/month." + +`README.md:156` and `:200` route the same question to whystrohm.com without a figure. This also violates the standing no-prices-in-repo rule in `~/.claude/CLAUDE.md`. + +### F-50 `minor` Internal handoff notes ship in the public repo and contradict the current tree + +`.archive/HANDOFF.md` is tracked. `.archive/README.md:3` frames it as "Build documentation from prior shotkit construction sessions. Kept for transparency." + +`.archive/HANDOFF.md:168-173` discusses an example directory named `one-shot-five-generators` containing two adapter files and debates renaming it, and `:78` describes a reference file by a state that no longer holds. The directory is now `one-shot-all-adapters` with seven files. + +--- + +## Finding index + +| ID | Severity | Title | Primary location | +|---|---|---|---| +| F-01 | blocking | No instance validator for `shots.json` or `text-overlays.json` | `tools/validate_schemas.py:25-51` | +| F-16 | blocking | Every loop artifact uses a fixed path; each write destroys prior state | `skills/visual-asset-critic/SKILL.md:120` | +| F-28 | blocking | A gate-passing `critique.json` can identify nothing | `critique.schema.json:7` | +| F-29 | blocking | No artifact binds an image to its prompt, generator, or brand-lock | `critique.schema.json:8,22-25` | +| F-36 | blocking | Concurrent operators silently destroy each other's verdicts | `skills/visual-asset-critic/SKILL.md:120` | +| F-37 | blocking | A regenerated frame terminates the loop on an unreviewed image | `docs/the-qa-loop.md:88` | +| F-38 | blocking | A mid-project brand-lock change silently repoints history | `skills/storyboard-architect/SKILL.md:132-139` | +| F-02 | major | The critique gate is repo-only and never runs on a real critique | `install.sh:38-44` | +| F-06 | major | Architect documents field names the schema rejects | `skills/storyboard-architect/SKILL.md:103-104` | +| F-07 | major | Documented motion vocabulary missing four of eleven enum values | `skills/storyboard-architect/SKILL.md:101` | +| F-08 | major | Timing invariants stated as a checklist, enforced nowhere | `shots.schema.json:69-70` | +| F-09 | major | Cross-file reference integrity unenforced; schemas disagree on cardinality | `shots.schema.json:101-106` | +| F-10 | major | Palette membership claimed as schema-enforced, is not | `docs/brand-lock-anatomy.md:87` | +| F-11 | major | `.md` versus JSON precedence unchecked; three entries disagree | `tools/validate_capabilities.py:76-81` | +| F-12 | major | nano-banana aspect parameter wrong in the file that wins | `_capabilities.json:82` | +| F-13 | major | Revision mode treats REJECT identically to REVISE | `skills/visual-prompt-forge/SKILL.md:143-144` | +| F-14 | major | `post-level` fixes produce no artifact and no record | `skills/visual-prompt-forge/SKILL.md:148` | +| F-15 | major | Prompt-file format defined only by a regex in a helper | `tools/copy-prompt.py:20` | +| F-17 | major | `assets.generated[].accepted` has no writer, reader, or verdict link | `shots.schema.json:114-137` | +| F-18 | major | `critique.schema.json` forbids adding provenance in band | `critique.schema.json:8` | +| F-21 | major | `copy-prompt.py` cannot read revision-mode output | `tools/copy-prompt.py:20` | +| F-22 | major | Two renderers, no shared template, README claims otherwise | `tools/shots-to-html.py:239-240` | +| F-23 | major | Snapshot header required by the architect, checked by nothing | `tools/validate_brand_lock.py:89-115` | +| F-24 | major | An unfilled template passes as a valid brand-lock | `tools/validate_brand_lock.py:112` | +| F-25 | major | Cross-skill relative path breaks under documented packaging | `skills/visual-prompt-forge/SKILL.md:65` | +| F-26 | major | `tools/` is not installed; tool paths in SKILL.md unresolvable | `install.sh:38-44` | +| F-30 | major | Chain references state by name with inconsistent path bases | `shots.schema.json:37-40` | +| F-31 | major | Images resolved by a filename convention no schema describes | `tools/shots-to-html.py:76-83` | +| F-32 | major | Re-rendering the preview overwrites the run date with a false one | `tools/shots-to-html.py:241,293` | +| F-34 | major | HTML built from model-generated strings with no escaping | `tools/shots-to-html.py:98-160` | +| F-35 | major | Preview discards brand typography, depends on undocumented role names | `tools/shots-to-html.py:47-68` | +| F-39 | major | Determinism asserted repeatedly, tested nowhere | `docs/why-this-exists.md:33` | +| F-40 | major | v2.0.0 tag published, CHANGELOG says Unreleased, tag behind main | `CHANGELOG.md:5` | +| F-41 | major | README lists the release's headline feature as roadmap | `README.md:219` | +| F-42 | major | Schema description claims a consumer README says is unbuilt | `shots.schema.json:116` | +| F-03 | minor | `validate_critique.py` has no directory or glob mode | `tools/validate_critique.py:121-135` | +| F-04 | minor | Parity check globs all markdown in `adapters/` | `tools/validate_capabilities.py:77` | +| F-05 | minor | `validate_skills.py` does not check referenced files exist | `skills/storyboard-html-preview/SKILL.md:220` | +| F-19 | minor | `rack` means two things in one schema | `shots.schema.json:85,90` | +| F-20 | minor | `depth_of_field` in schema and examples, absent from instructions | `skills/storyboard-architect/SKILL.md:96-107` | +| F-27 | minor | `install.sh` copies `.DS_Store` into the skills directory | `install.sh:104` | +| F-33 | minor | Preview version marker hardcoded to v1.0 | `tools/shots-to-html.py:261` | +| F-43 | minor | `docs/the-qa-loop.md` describes a four-skill kit | `docs/the-qa-loop.md:5` | +| F-44 | minor | Adapter count seven in three places, ten in two; three have no example | `docs/connecting-to-generators.md:47` | +| F-45 | minor | Sora survives in the flagship demo the README points to | `remotion/src/ShotkitExplainer.tsx:238` | +| F-46 | minor | `remotion/package.json` version is 0.1.0 | `remotion/package.json:3` | +| F-47 | minor | CHANGELOG 0.1.0 entries stale against the tree | `CHANGELOG.md:54,83-89` | +| F-48 | minor | `tools/README.md` overstates tool and CI coverage | `tools/README.md` | +| F-49 | minor | A monthly price is committed in the docs | `docs/connecting-to-generators.md:44` | +| F-50 | minor | Internal handoff notes ship publicly and contradict the tree | `.archive/HANDOFF.md:168-173` | + +Totals: 7 blocking, 28 major, 15 minor. diff --git a/CHANGELOG.md b/CHANGELOG.md index 389bc71..bde0208 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,170 @@ All notable changes to shotkit are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versioning follows [SemVer](https://semver.org/). -## [2.0.0] - Unreleased +## [3.0.0] - 2026-07-30 + +An audit trail you can check rather than one you have to trust. + +v2.0.0 shipped a QA loop where every artifact had a single fixed path and every reference was a +filename. Both held until someone regenerated a frame, edited a brand-lock mid-project, or ran +the loop at the same time as a colleague. This release fixes the layout and adds the hashes, +then makes validators run against a real project instead of only against this repo. + +**Breaking.** The output directory layout changed, and the layout is the public interface. + +| Before | Now | +|---|---| +| `output/critique.json` | `output/critiques/round-{N}/{shot_id}.critique.json` | +| `output/prompts/{generator}.txt` | `output/prompts/round-{N}/{generator}.txt` | +| `output/prompts/revised-{generator}.txt` | `output/prompts/round-{N}/revised-{generator}.txt` | +| `output/generated/{shot_id}.png` | `output/frames/round-{N}/{shot_id}.png` | + +Existing trees still read: the tools fall back to `generated/` and to a root `critique.json`, +and critique schema `1.0` documents still validate with a warning. Nothing auto-migrates. + +### Added + +- **`run.json`, written once per run.** Records a `run_id`, a `created_at` instant, and a + SHA-256 for `shots.json`, `text-overlays.json`, and `brand-lock.snapshot.md` as written, plus + the generators targeted and a per-round record of prompt files with their hashes. Schema at + `skills/storyboard-architect/templates/run.schema.json`. This is the file that turns a + storyboard's name references into provable ones. +- **`tools/validate_provenance.py`.** Walks an output tree and recomputes every recorded hash. + Catches a frame regenerated after its critique, a brand-lock edited mid-project, a frame with + no critique for its round, two critiques for the same shot in the same round, skipped rounds, + and a critique carrying another run's id. `--require-accept` makes it the pipeline stop + condition; `--json` emits a machine-readable report. Its `--selftest` builds each of those + failures from the bundled worked run and fails if any goes uncaught. +- **`tools/validate_shots.py`.** The first instance validator in the repo. `shots.json` and + `text-overlays.json` had schemas and no way to check a file against them, so every rule lived + in a SKILL.md as a checkbox. It now enforces: `end` after `start`, no duplicate ids, no gaps, + no overlaps, span matching `project.duration_s`, overlay references resolving in both + directions, every overlay reachable from some shot, overlay timing inside its shot window, and + every overlay color present in the brand-lock palette. +- **Critique schema `1.1`.** Adds `run_id`, `round`, `created_at`, `image_sha256`, + `prompt_ref`, `prompt_sha256`, `brand_lock_sha256`, `generator`, `model_version`, `seed`, and a + `meta` passthrough. Every provenance field is required and nullable: `null` records that an + input was unavailable, a missing key records nothing. `additionalProperties: false` with no + `meta` previously made it impossible to add provenance without a schema bump. +- **`tools/validate_prompts.py`.** Validates the prompt files the forge writes: header + completeness, generator id, aspect agreement, the `max_prompt_words` ceiling, shot coverage, + duplicate blocks, and the forge's two hard rules. Rule 1, no on-screen text copy inside a + prompt. Rule 3, `environment` / `lighting` / `color_grade` appearing verbatim, with the + character anchor as a warning since a shot with no person can omit it. + + Rule 3 is the reason this exists. Series consistency depends on those anchors landing + unedited in every prompt, and it is the easiest rule in the kit to break, because + paraphrasing an anchor is what writing good prose feels like. Driving the pipeline through a + real seven-shot brief drifted on it in all seven shots with every other validator green. The + `worked-run` fixture shipped earlier in this release had drifted on it too. +- **`tools/check.sh`.** One entry point for all eighteen checks, called by CI, so a green local + run means a green PR. It preflights `pyyaml` and `jsonschema` and prints one install command, + rather than failing every check with the same message. +- **A `--selftest` on every validator.** Each constructs failing fixtures and fails if the check + does not catch them. `validate_critique.py` now runs fourteen cases, up from two. +- **Worked run example** at `skills/visual-asset-critic/examples/worked-run/`: two shots through + two rounds with real hashes, per-round prompts and frames, one critique per shot per round, and + a rendered preview with verdict badges. The provenance mechanism ships with something to check. +- **`run.json` for all three bundled storyboard examples**, so they model the current standard. +- **Verdict badges in the HTML preview**, read from the critique tree. +- **`tools/_shotkit.py` and `tools/_template.py`**, shared internals for hashing, brand-lock + parsing, output-tree conventions, and the template engine. + +### Changed + +- **The critique gate has a threshold instead of discretion.** Three or more `major` issues now + force `REJECT`. It read "escalate to REJECT at your discretion," which disagreed with + `critique-rubric.md`, which called three hard fails a REJECT outright. +- **Revision mode stops on `REJECT`.** It treated every non-ACCEPT shot identically, so a verdict + meaning "no fix path exists" got a re-emitted prompt with the fix applied. It now lists the + rejected shots and asks. +- **`post-level`-only shots are recorded on disk** in `run.json`'s `post_only_shots`. Saying it in + chat left the compositing obligation nowhere once the critique was overwritten. +- **`tools/shots-to-html.py` renders the actual template.** It built HTML inline while + `tools/README.md` claimed it shared `preview.html.tpl` with the skill. It now renders that + template through `_template.py`. +- **Every interpolated value in the preview is HTML-escaped.** Subjects, rationales, VO lines, + and overlay fonts went in raw; one angle bracket in a rationale broke the page. +- **The preview shows a run date and a render date, separately.** A single "Generated" date meant + re-rendering a preview restamped the run as today, over a footer asserting what brand-lock it + was built against. +- **The preview reads `brand_lock_ref` from `shots.json`** instead of hardcoding + `brand-lock.snapshot.md`, resolves frames from `shot.assets` before falling back to the path + convention, and reads fonts from the brand-lock instead of hardcoding Inter. +- **`shots.json` `on_screen_text` accepts an array** (schema `1.2`). `text-overlays.json` always + allowed several overlays per shot while `shots.json` allowed one id, so the second overlay on a + shot rendered nowhere and nothing reported it. +- **`shot.assets.generated` entries carry `sha256`, `round`, `prompt_ref`, `prompt_sha256`, and + `critique_ref`** (schema `1.2`). `accepted: true` with no `critique_ref` is now an error: an + approval with no source. +- **`tools/validate_capabilities.py` compares the adapter prose to the matrix.** Word budgets have + to sit inside `max_prompt_words`, and each adapter has to document the `aspect_param` the matrix + names. Both rules were stated in all ten adapters and enforced nowhere. +- **`tools/validate_critique.py` accepts a directory** and enforces provenance coupling: a hash + without its path, a null `image_ref`, or `HIGH` confidence with an unidentified prompt now fail. +- **`tools/validate_brand_lock.py` gained `--snapshot` and `--require-configured`.** The snapshot + header that `storyboard-architect` promises to write is now checked, and an unfilled template no + longer passes as a production brand-lock. +- **`tools/copy-prompt.py` reads revision files.** Its regex required the shot id immediately + after `#`, and the documented revision header led with "Revision of", so it found no shot blocks + at all in the one file an operator pastes from most. Comment lines inside a block are now + annotations, shown but never copied. +- **`install.sh` installs `tools/` and `brand-packs/`** to `~/.claude/shotkit-tools/` and + `~/.claude/shotkit-brand-packs/`. The skills cite `tools/validate_critique.py` and + `tools/copy-prompt.py`, and `storyboard-architect` falls back to `brand-packs/_template.md` + when no brand-lock is given. None of those paths existed after an install. `--uninstall` + removes all three. It also stops copying `.DS_Store` into the skills directory, and `--help` + no longer prints a line of shell. +- **CI calls `tools/check.sh`** and re-renders every bundled preview with a pinned timestamp, + failing if a byte moves. That is the determinism claim, tested. +- **`critique-rubric.md` maps pass/soft/hard onto `minor`/`major`/`blocking`** and defers to the + gate table. It graded checks and never mentioned severity, which is the field the gate runs on. +- **`nano-banana` `aspect_param` corrected to `aspectRatio`.** The matrix said `aspect_ratio` + while the adapter documented `aspectRatio`, and the precedence rule meant the wrong one won on + every prompt. +- **`midjourney` `max_prompt_words` raised to 100** to match its own note, and **`gpt-image` to + 300** to match its adapter's stated range. `max_prompt_words` is now documented as a ceiling + with the adapter range as the recommended target. +- **Font names in a brand-lock go in backticks** immediately after the label. Two packs did this + and the extractor's template and example did not, so its own worked example was unreadable to + the tools it claims to feed. +- **Docs corrected against the tree:** `the-qa-loop.md` said four skills, `connecting-to-generators.md` + said seven adapters, `brand-lock-anatomy.md` said the schema enforced palette membership when + nothing did, `audit-trail-pattern.md` claimed the spec-to-artifact link while listing it as an + unshipped extension, and `README.md` listed `brand-lock-extractor` as roadmap in the release + that shipped it. A monthly price committed in `connecting-to-generators.md` now points at the + live offer instead. + +### Fixed + +- **`text_07` in the `shotkit-explainer` example was unreachable.** It attached to `shot_06`, + which pointed at `text_06`, so no renderer showed it. Both are now listed and both render. +- **An off-palette overlay color in the same example.** `#6B6B73` was not in that project's + brand-lock; it is now `#7A7580`, the palette's Muted. +- **The `shotkit-explainer` snapshot header** carried a bare date where the other two carried a + full instant. Normalised to the commit instant the file entered the repo. +- **A broken table row in `critique-rubric.md`** (a stray comma as the aspect-ratio soft-fail cell). +- **`skills/storyboard-html-preview/examples/`** was an empty untracked directory that `SKILL.md` + described as containing generated previews. Removed; the previews are listed where they live. +- **`brand-packs/examples/saas-clean.md` typography** did not parse, which would have silently + rendered its previews in a fallback font. + +### Known gaps + +Named rather than left to be discovered: + +- **No approval log.** `run.json` records what was built and reviewed. Who approved it, and when, + is not recorded anywhere. +- **The explainer video and demo GIF are v0.1.0** and still show Runway/Sora. + `remotion/src/ShotkitExplainer.tsx` is deliberately left matching the artifact it produced; both + are labelled in `remotion/README.md` and `README.md`. +- **No worked example for Veo, Seedance, or Hailuo** in `one-shot-all-adapters/`. Each has a prompt + example in its adapter file. +- **The forge and critic still apply English `fix` strings by judgement.** The file formats no + longer fight determinism, but the edit in between is a model reading a sentence. +- **Nothing auto-migrates a v2.0.0 output tree.** The tools read the old layout; they do not move it. + +## [2.0.0] - 2026-06-18 The QA loop closes: the critic now emits a machine-readable verdict, the prompt-forge can act on it, and the capability matrix is guarded so it can't silently rot. @@ -51,7 +214,7 @@ Initial public release. ### Tooling - One-line install for Claude Code (`install.sh`) -- Three validation scripts (frontmatter, JSON schemas, brand-lock structure) +- Three validation scripts at the time (frontmatter, JSON schemas, brand-lock structure) - Standalone HTML renderer (`tools/shots-to-html.py`) - GitHub Actions workflow runs all validators on every PR @@ -80,7 +243,17 @@ Initial public release. - Works in Claude.ai, Claude Code, Claude API - Compatible with the SKILL.md open standard (Codex, Cursor, Gemini CLI, Antigravity, Windsurf, not officially tested) -### Known v2.0.0 work +## Release notes + +The `v2.0.0` tag points at `39c2227`, one commit behind the branch tip. The commit it excludes, +`673ee99`, completed the Apache-2.0 `LICENSE` text and refreshed `demo.gif`, so `LICENSE` at the +tag is the short-form notice rather than the full text. Fetch `main` rather than the tag for the +complete license. `v0.1.0` was never tagged. + +### Planned after 0.1.0 + +Recorded at the time. Of these, `brand-lock-extractor` shipped in 2.0.0; the other three had +not shipped as of 3.0.0. - `brand-lock-extractor` skill (PDF/image/URL into brand-lock.md) - PDF and PPTX exporters diff --git a/README.md b/README.md index 9f1edb2..09ec38f 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,10 @@ That installs all five skills into `~/.claude/skills/`. Restart your Claude Code **Watch shotkit explain itself.** The 90-second explainer was made *by* shotkit. The storyboard, shots.json, brand-lock snapshot, per-generator prompts, and rendered preview live at [`skills/storyboard-architect/examples/shotkit-explainer/`](skills/storyboard-architect/examples/shotkit-explainer/). Full breakdown at [whystrohm.com/blog/you-dont-have-a-content-problem](https://whystrohm.com/blog/you-dont-have-a-content-problem). +The rendered video and the demo GIF above are from v0.1.0 and have not been re-cut: they show +seven adapters including Runway/Sora, which was discontinued and replaced by the fal.ai motion +lineup. The storyboard files beside them are current. + --- ## What it does @@ -25,11 +29,12 @@ You describe a video. The kit produces a complete pre-production package: ``` output/ +├── run.json # Run id + every input pinned by content hash ├── storyboard.md # Human-readable, shot-by-shot ├── shots.json # Schema-validated, machine-readable ├── text-overlays.json # On-screen text + timing ├── brand-lock.snapshot.md # Frozen brand state at generation time -├── prompts/ # Per-generator prompts, copy-paste ready +├── prompts/round-1/ # Per-generator prompts, copy-paste ready │ ├── midjourney.txt │ ├── flux.txt │ ├── ideogram.txt @@ -40,11 +45,17 @@ output/ │ ├── veo.txt # Motion: dialogue/lipsync + native audio │ ├── seedance.txt # Motion: multi-shot sequences │ └── hailuo.txt # Motion: budget iteration +├── frames/round-1/ # Your generated frames +├── critiques/round-1/ # One verdict per shot, hashing what it reviewed └── preview.html # Single file. Shareable. Printable. Brand-aware. ``` Files. Not panels. Not a SaaS dashboard. Files an editor, agency, or developer can act on without asking follow-up questions. +Everything is addressed by round and shot, so no two writes land on the same path. Two people +can work one project without overwriting each other's verdicts, and round 2 never destroys the +prompt that produced round 1's frames. + --- ## The five skills @@ -83,7 +94,7 @@ Read more in [`docs/why-this-exists.md`](docs/why-this-exists.md). The category isn't empty. It's full of tools that solve the wrong half. -- **Brand-lock snapshots.** Every storyboard freezes brand state at run time. Six months later, you can still answer "what brand version was this approved against." None of the SaaS tools do this. +- **Provenance you can check, not just claim.** Every run freezes brand state and records a content hash for every input, prompt, and reviewed frame. Six months later you can answer "what brand version was this built against" and *prove* the files have not moved since, because `validate_provenance.py` recomputes the hashes. A frame regenerated after its review fails that check instead of passing quietly on a stale ACCEPT. - **Ten generators, one spec.** The same shot data adapts to six stills generators (Midjourney, Flux, Ideogram, GPT Image, Nano Banana, Seedream) and four motion-video models (Kling, Veo, Seedance, Hailuo). Every other storyboard skill on GitHub locks to one generator family. - **Files, not panels.** The output is structured Markdown and JSON an editor, motion designer, or developer can act on. No dashboard, no export step, no platform. - **Methodology over pipeline.** The pack stops at prompts and specs. Generator APIs churn monthly, the methodology stays stable. The pipeline lives where it belongs, in the operator's tooling. @@ -107,14 +118,22 @@ A complete worked example lives at [`skills/storyboard-architect/examples/30s-pa The complete loop, idea to revised image: 1. **Brief** describes the video. -2. **`storyboard-architect`** produces `storyboard.md`, `shots.json`, `text-overlays.json`, `brand-lock.snapshot.md`. -3. **`visual-prompt-forge`** writes a prompt file per generator under `output/prompts/`. +2. **`storyboard-architect`** produces `run.json`, `storyboard.md`, `shots.json`, `text-overlays.json`, `brand-lock.snapshot.md`. +3. **`visual-prompt-forge`** writes a prompt file per generator under `output/prompts/round-1/`. 4. **`tools/copy-prompt.py`** pipes one shot's prompt to the clipboard. Paste into the generator UI. -5. **The generator** returns an image. -6. **`visual-asset-critic`** scores the image against the shot spec and brand-lock. Returns ACCEPT, REVISE, or REJECT with revision notes. -7. Revise the prompt or the shot and re-run. +5. **The generator** returns an image. It goes in `output/frames/round-1/shot_NN.png`. +6. **`visual-asset-critic`** scores the frame against the shot spec and brand-lock, writing `critiques/round-1/shot_NN.critique.json` with the hash of the frame, the prompt, and the brand-lock it judged. +7. **`tools/validate_provenance.py`** re-checks every hash and reports which shots are still open. +8. On REVISE, **`visual-prompt-forge` revision mode** re-emits prompts for only those shots into `round-2/`. On REJECT, it stops and asks: a REJECT means no fix path exists. +9. Repeat from 5 until `--require-accept` exits 0. + +```bash +python tools/validate_provenance.py output/ --require-accept || echo "work remains" +``` -Files at every step. Reproducible end-to-end. +Files at every step. See [`docs/the-qa-loop.md`](docs/the-qa-loop.md) for the full loop and +[`skills/visual-asset-critic/examples/worked-run/`](skills/visual-asset-critic/examples/worked-run/) +for a real two-round output tree with hashes. --- @@ -124,7 +143,7 @@ Four ideas. None negotiable. **1. Five-layer prompt anatomy.** Every image prompt is composed from locked layers: Brand Lock, Series Lock, Shot Spec, Text Layer, Generator Adapter. Change a brand color once. Every prompt updates. See [`docs/the-five-layer-prompt.md`](docs/the-five-layer-prompt.md). -**2. Versioned brand state.** Every storyboard run snapshots the brand-lock file it was built against. Brand changes later? You can see exactly what version this storyboard targeted. Defense-grade audit trail applied to commercial output. See [`docs/audit-trail-pattern.md`](docs/audit-trail-pattern.md). +**2. Versioned brand state, pinned by hash.** Every run snapshots the brand-lock it was built against and records its SHA-256 in `run.json`. Brand changes later? The snapshot stays frozen, and if someone overwrites it, the hash mismatch says so. A filename alone never proved anything, which is the whole reason the hashes are there. See [`docs/audit-trail-pattern.md`](docs/audit-trail-pattern.md). **3. Text never gets baked into images.** On-screen copy is a separate layer with its own timing, font, and animation spec. Always composited after generation. AI text rendering is not production-ready in 2026; treat text as a separate compositing pass. @@ -214,12 +233,16 @@ Tested against Claude Opus 4.7 and Claude Sonnet 4.6. ## Roadmap -v2.0.0 (unreleased) closes the QA loop: structured `critique.json` output, a guarded capability matrix, prompt-forge revision mode, and the fal.ai motion lineup (Kling / Veo / Seedance / Hailuo). See the [changelog](CHANGELOG.md). Still on the roadmap: +v3.0.0 makes the audit trail checkable: content hashes on every input and reviewed frame, an +output tree addressed by round and shot so concurrent work cannot overwrite itself, and +validators that run against a real project instead of only against the repo. See the +[changelog](CHANGELOG.md). Still on the roadmap: -- **`brand-lock-extractor`**. Upload a brand book (PDF, screenshots, URL), get a `brand-lock.md` back. The cold-start killer. - **PDF + PPTX exporters**. Siblings to `storyboard-html-preview` for client review and agency handoff. -- **User-supplied asset folder**. The `shot.assets` field landed in shots schema v1.1; wiring the HTML preview and critic to consume it is the remaining work. +- **Approval log**. `run.json` records what was built and reviewed, not who signed it off. Approver identity and timestamp is the missing link for regulated handoffs. - **Duration rescale workflow**. Change a project from :30 to :60 and have the timing redistribute correctly across the beat framework. +- **Worked examples for the motion adapters**. `one-shot-all-adapters/` covers the six stills generators plus Kling; Veo, Seedance, and Hailuo have prompt examples in their adapter files but no side-by-side entry. +- **A CLI or MCP surface**, so the pipeline runs outside Claude-skill hosts. If any of these are blocking for you, open an issue. Real use cases jump the queue. @@ -233,18 +256,20 @@ PRs welcome for: - New beat frameworks (`skills/storyboard-architect/references/beat-frameworks.md`) - Brand pack examples (`brand-packs/examples/`) -Open an issue first for anything that changes the file schemas. Run validators locally before opening a PR: +Open an issue first for anything that changes the file schemas. Run the checks locally before opening a PR: ```bash pip install pyyaml jsonschema -python tools/validate_skills.py -python tools/validate_schemas.py -python tools/validate_brand_lock.py brand-packs/_template.md -python tools/validate_capabilities.py -python tools/validate_critique.py --selftest +./tools/check.sh ``` -CI runs all of these on every PR. +That is the same entry point CI runs, so green locally means green on the PR. It covers +frontmatter, schemas, capability-to-adapter parity, brand-locks, storyboard instances, the +critique gate, the provenance chain, and both shipped tools. + +New checks need a `--selftest` that constructs a failing fixture and proves the check catches +it. A validator nobody can see fail is a validator nobody should trust. See +[`tools/README.md`](tools/README.md). --- diff --git a/brand-packs/_template.md b/brand-packs/_template.md index 9572464..b3b6054 100644 --- a/brand-packs/_template.md +++ b/brand-packs/_template.md @@ -26,9 +26,13 @@ Add more rows if the brand has more named colors. Don't add more than 8, past th ## Typography -**Display font:** font name, weights used (e.g. `Inter Black 900`) -**Body font:** font name, weights used (e.g. `Inter Medium 500`) -**Mono font (optional):** for code/data +**Display font:** `______` +**Body font:** `______` +**Mono font (optional):** `______` + +Put the font name and weight in backticks immediately after the label, e.g. +`` **Display font:** `Inter Black 900`, headlines and hooks ``. The HTML preview and +the overlay-font check read that backticked value; anything before it is prose. Two fonts max for production work. Three only if one is reserved for code/data. diff --git a/docs/audit-trail-pattern.md b/docs/audit-trail-pattern.md index ee2adf7..53f14a1 100644 --- a/docs/audit-trail-pattern.md +++ b/docs/audit-trail-pattern.md @@ -9,12 +9,13 @@ A storyboard isn't just a creative artifact. It's a decision record. Six months The audit trail is the answer to all of those. -## The four files that make a storyboard auditable +## The five files that make a storyboard auditable -Every storyboard run produces these four files. None are optional. +Every storyboard run produces these five files. None are optional. ``` output/ +├── run.json # Run identity, and every input pinned by content hash ├── storyboard.md # Human-readable spec, with rationale per shot ├── shots.json # Machine-readable, schema-validated ├── text-overlays.json # On-screen text, separated from images @@ -23,6 +24,37 @@ output/ Each file does one job in the audit trail. +### `run.json`, the thing that makes the other four provable + +Reading this answers "are the files next to these frames the files they were built from." + +The other four files are named references to each other. `shots.json` points at +`brand-lock.snapshot.md` by filename. A filename survives its contents being replaced, so +for a long time this pattern could tell you *which file* a storyboard targeted and not +*which version of it*. Re-run the architect against an updated brand-pack and the snapshot +is overwritten in place; every reference still resolves and nothing reports a change. + +`run.json` closes that by recording a SHA-256 for each input alongside a `run_id` and a +`created_at` instant: + +```json +{ + "run_id": "20260730T142300Z-9f2c1ab4", + "created_at": "2026-07-30T14:23:00Z", + "inputs": { + "shots_ref": "shots.json", + "shots_sha256": "e3b0c44298fc1c14...", + "brand_lock_ref": "brand-lock.snapshot.md", + "brand_lock_sha256": "2c26b46b68ffc68f...", + "brand_lock_source": "brand-packs/whystrohm.md", + "brand_lock_configured": true + } +} +``` + +`tools/validate_provenance.py` recomputes those hashes. A mid-project brand-lock edit fails +there instead of silently repointing the project's history. + ### `storyboard.md`, the human-readable record Reading this should answer "what was the intent." It includes: @@ -82,13 +114,20 @@ When a client asks "what version of our brand did this storyboard target," and y **Versioning across time.** Storyboards from before a brand refresh stay valid against their original brand-lock. New storyboards target the new state. Both are explicit. -**Regeneration on demand.** Same inputs, same outputs. If a client wants to re-run a storyboard with a different generator or a different aspect ratio, the JSON makes it a one-command operation. +**Regeneration on demand.** If a client wants to re-run a storyboard with a different generator or a different aspect ratio, the JSON is the input and you do not start over. + +Be precise about what is reproducible, though. The spec files are: same `shots.json` and +brand-lock produce the same prompts, and `tools/shots-to-html.py` re-renders the same +preview byte for byte given a pinned timestamp, which CI checks on every push. The *frames* +are not. Image generation is non-deterministic even at a fixed seed on most services. That +is why the audit trail records the hash of the frame you actually shipped rather than +implying you could conjure it again. **Cross-team handoff.** An editor reading `storyboard.md` knows the intent. A motion designer reading `shots.json` knows the spec. A brand director reading `brand-lock.snapshot.md` knows the constraints. Each role gets what they need without asking. **Quality assurance.** The visual-asset-critic skill compares a generated image against the shot's spec and the brand-lock. Without the snapshot, it can't critique against historical brand state. -**Legal and compliance.** When a regulated industry asks "show us the approval state," the four files are the answer. +**Legal and compliance.** When a regulated industry asks "show us the state this was approved in," the five files plus the critique tree are the answer, and `validate_provenance.py` is how you show the files have not moved since. Approver identity is not in there, so if the question is "who signed this off," that part is still on you. ## What breaks without the audit trail @@ -99,17 +138,37 @@ When a client asks "what version of our brand did this storyboard target," and y These aren't hypothetical. They're the daily friction of running content infrastructure without an audit trail. -## How to extend the pattern - -The four-file output is the minimum. Some teams extend it: +## The spec-to-artifact half -**Render manifests.** When images are generated, log which prompt produced which image, with which seed, on which date. A `renders.json` alongside the four files completes the loop from spec to artifact. +The five files above cover the spec. Generation adds the other half, and it is addressed by +round and shot so nothing overwrites anything: -**Approval logs.** When a stakeholder approves a storyboard, log the approval with timestamp and approver. A `approvals.json` makes the chain auditable end-to-end. - -**Diff outputs.** When a storyboard is revised, generate a diff against the previous version. Helps stakeholders see what changed without re-reading the whole spec. +``` +output/ +├── prompts/round-1/flux.txt the prompt, hashed in run.json +├── frames/round-1/shot_02.png the frame +└── critiques/round-1/shot_02.critique.json the verdict, hashing both of the above +``` -These are extensions. The four files are the core. Start with them, extend as needed. +A critique at schema `1.1` carries `image_sha256`, `prompt_sha256`, `brand_lock_sha256`, +`generator`, `model_version`, and `seed`. That is the link from spec to artifact: given a +frame, you can name the prompt that produced it, the generator and model version that ran, +the brand state it was judged against, and the verdict it received, and you can prove the +frame has not changed since. + +Earlier versions of this document described that link as an optional extension, a +`renders.json` a team might add, while also claiming the pattern already let you point to +"the prompt that drove the generation" and "the image that was approved." Both statements +could not be true. The mechanism is now shipped, so the claim is now safe to make. + +**Still not shipped: approval logs.** Who signed off, and when, is not recorded anywhere. +`accepted: true` on a frame carries a `critique_ref`, so an acceptance traces to a critique, +but a critique is a review and not a human approval. If you need approver identity, that is +yours to add. + +**Also worth adding: diff outputs.** When a storyboard is revised, a diff against the +previous version helps stakeholders see what changed without re-reading the spec. Git does +this well enough that shotkit does not try. ## How to use the audit trail in practice diff --git a/docs/brand-lock-anatomy.md b/docs/brand-lock-anatomy.md index 1c5355f..78210ff 100644 --- a/docs/brand-lock-anatomy.md +++ b/docs/brand-lock-anatomy.md @@ -84,7 +84,37 @@ The shots.schema.json defines text overlay colors as a regex match against `^#[0 This is not pedantry. It is what makes the brand-lock load-bearing. If color was a freeform string, the pack would produce one shot with `color: "deep blue"`, the next with `color: "navy blue"`, the next with `color: "midnight blue"`. Three shots, three slightly different blues, none of which match the brand. Hex precision forces every reference to compose against the same value. -The brand-lock file enforces this at the source. The schema enforces it at the output. The two together produce deterministic color across every run. +The brand-lock file is the source. `tools/validate_shots.py` enforces it at the output: every +overlay color has to appear in the brand-lock palette, and a hex that does not is a build +failure. + +That check is a validator, not the schema. `text-overlays.schema.json` can only see that a +color is six hex digits; it cannot open the brand-lock to find out whether those digits are +allowed. This document used to claim the schema enforced it, and while it did not, an +off-palette gray sat in one of this repo's own shipped examples. + +## The palette role names are load-bearing + +`tools/shots-to-html.py` maps five roles onto the preview's CSS variables: + +| Role | Used for | +|---|---| +| `Background` | page canvas | +| `Ink` | primary text | +| `Accent` | links, emphasis, verdict badges | +| `Muted` | secondary text | +| `Rule` | borders and dividers | + +Match those words in the Role column and the preview renders in the brand. A row named +`Primary` or `Surface` instead does not match, and that slot falls back to a generic default. +Adding extra rows is fine, and a qualifier is fine too: `Accent (warm)` matches `Accent`. + +`validate_brand_lock.py` warns when a role is missing rather than failing, because a +brand-lock is allowed to be unusual. It is a warning you should read. + +Font names have the same requirement: put the name in backticks immediately after the label, +as in `` **Display font:** `Inter Black 900`, headlines ``. The tools read the backticked +value, and prose before it is ignored. ## Why archetype is the most undervalued field diff --git a/docs/claude-ai-workflow.md b/docs/claude-ai-workflow.md index 97d0d8d..93a6de1 100644 --- a/docs/claude-ai-workflow.md +++ b/docs/claude-ai-workflow.md @@ -59,14 +59,32 @@ The methodology is identical across surfaces. The skills produce the same `story ## Downloading output files -When a shotkit skill produces output in Claude.ai, the four core files appear as message attachments in order: +When a shotkit skill produces output in Claude.ai, the five core files appear as message attachments in order: -1. `storyboard.md` -2. `shots.json` -3. `text-overlays.json` -4. `brand-lock.snapshot.md` +1. `run.json` +2. `storyboard.md` +3. `shots.json` +4. `text-overlays.json` +5. `brand-lock.snapshot.md` -If `visual-prompt-forge` ran in the same conversation, the per-generator prompts appear as additional attachments named `prompts-{generator}.txt`. +If `visual-prompt-forge` ran in the same conversation, the per-generator prompts appear as additional attachments named `prompts-round-{N}-{generator}.txt`. Rebuild the directory structure locally when you save them; the round in the name is what keeps a revision pass from overwriting the original. + +### Two limits worth knowing on this surface + +**The validators are not there.** `tools/` is a repo directory, not part of a skill upload, +so nothing in a Claude.ai conversation can run `validate_shots.py` or the critique gate. The +model will check by hand against the schema, which is weaker. For work under real +accountability, save the output locally and run: + +```bash +python tools/validate_shots.py path/to/output/ +python tools/validate_provenance.py path/to/output/ +``` + +**Cross-skill file references break.** Each skill is uploaded as a separate `.skill` zip, so +a path like `../storyboard-architect/templates/shots.schema.json` has no parent to resolve +against. If a skill asks for a schema it cannot reach, paste the schema into the +conversation rather than letting it work from memory of the format. Click each attachment to download. Save them into a local directory matching the project structure documented in [`docs/audit-trail-pattern.md`](./audit-trail-pattern.md). The directory layout is identical regardless of which surface produced the files. diff --git a/docs/claude-code-workflow.md b/docs/claude-code-workflow.md index 7729fd6..0e68f6c 100644 --- a/docs/claude-code-workflow.md +++ b/docs/claude-code-workflow.md @@ -63,6 +63,7 @@ The first time you trigger a shotkit skill in a session, expect Claude to: 2. Ask any clarifying questions the brief left ambiguous 3. Read the relevant brand-lock file from `brand-packs/` 4. Produce the output set into your working directory under `output/` +5. Write `run.json`, pinning each of those files by content hash If a brand-lock is missing or invalid, the skill says so before producing anything. If the brief is missing required information (duration, beat framework, brand), the skill asks for it. @@ -74,7 +75,7 @@ You: 30s pain-proof-promise for WhyStrohm. Use brand-packs/whystrohm.md. Claude: storyboard-architect engaging. Reading brand-packs/whystrohm.md. Brief looks complete. Producing storyboard. -[output/ directory created with 4 files + prompts/ subdirectory] +[output/ directory created with 5 spec files] Claude: Done. Open output/preview.html to review. ``` @@ -100,23 +101,30 @@ Override the path by including a target in the prompt: > "Storyboard a 30-second explainer for WhyStrohm. Output into ./projects/launch-q3/." -The skill creates the target directory and writes the four files plus the prompts subdirectory. +The skill creates the target directory and writes the five spec files. Generation and review +add the round directories underneath. For multi-storyboard projects, name the output directory after the storyboard: ``` projects/launch-q3/ ├── 30s-pain-proof-promise/ +│ ├── run.json │ ├── storyboard.md │ ├── shots.json │ ├── text-overlays.json │ ├── brand-lock.snapshot.md -│ └── prompts/ +│ ├── prompts/round-1/flux.txt +│ ├── frames/round-1/shot_01.png +│ └── critiques/round-1/shot_01.critique.json └── 60s-founder-explainer/ └── ... ``` -The audit-trail pattern (see [`docs/audit-trail-pattern.md`](./audit-trail-pattern.md)) makes this directory structure trivial to manage in Git. +Round-and-shot paths are what let two people work the same project without overwriting each +other. See [`docs/the-qa-loop.md`](./the-qa-loop.md). + +The audit-trail pattern (see [`docs/audit-trail-pattern.md`](./audit-trail-pattern.md)) makes this directory structure trivial to manage in Git. Commit `run.json` with the spec files: it is what proves, later, that the snapshot in the directory is the snapshot the frames were built from. ## Common Claude Code patterns @@ -138,7 +146,7 @@ Triggers `visual-asset-critic`. Compares the image against the shot spec and the > "Generate Flux prompts for ./output/shots.json." -Triggers `visual-prompt-forge` with the Flux adapter. Writes `output/prompts/flux.txt`. +Triggers `visual-prompt-forge` with the Flux adapter. Writes `output/prompts/round-1/flux.txt`. **Update a single shot.** @@ -162,14 +170,23 @@ For day-to-day work, natural-language prompts are sufficient. ## Updating shotkit -When v2.0.0 ships, update the install: - ```bash cd shotkit git pull ./install.sh ``` +`install.sh` also refreshes `~/.claude/shotkit-tools/`, which is where the validators land. +Re-run the checks after an update: + +```bash +./tools/check.sh +``` + +Upgrading from v2.0.0 changes the output layout: critiques, prompts, and frames now live under +`round-N/` directories. Existing trees still read, and nothing is migrated for you. See the +breaking-change table at the top of `CHANGELOG.md`. + The script handles existing installs by replacing the skill directories. No state carries over from the prior version. Brand-packs, output files, and project state live outside the skills directory and are unaffected. For shotkit, the install is idempotent. Running `./install.sh` repeatedly produces the same result. There is no upgrade-migration to manage because the pack owns no persistent state. diff --git a/docs/connecting-to-generators.md b/docs/connecting-to-generators.md index 2ffc437..8830948 100644 --- a/docs/connecting-to-generators.md +++ b/docs/connecting-to-generators.md @@ -13,16 +13,16 @@ This is deliberate. This doc explains why, and how to wire up the generator side storyboard-architect ──▶ shots.json │ ▼ - visual-prompt-forge ──▶ prompts/midjourney.txt - prompts/flux.txt - prompts/ideogram.txt - prompts/gpt-image.txt - prompts/nano-banana.txt - prompts/seedream.txt - prompts/kling.txt - prompts/veo.txt - prompts/seedance.txt - prompts/hailuo.txt + visual-prompt-forge ──▶ prompts/round-1/midjourney.txt + prompts/round-1/flux.txt + prompts/round-1/ideogram.txt + prompts/round-1/gpt-image.txt + prompts/round-1/nano-banana.txt + prompts/round-1/seedream.txt + prompts/round-1/kling.txt + prompts/round-1/veo.txt + prompts/round-1/seedance.txt + prompts/round-1/hailuo.txt ◀────── this is where the skill pack stops ──────▶ @@ -40,15 +40,15 @@ Everything to the left of that line is the methodology. Everything to the right **The methodology survives generator change.** A prompt file produced for Flux today is still a usable prompt for whatever replaces Flux. The shot structure in `shots.json` is generator-agnostic. The brand-lock is generator-agnostic. Only the adapter layer touches generator-specific syntax, and adapters are the easiest layer to update. -**Open methodology, paid pipeline.** This is the WhyStrohm thesis. The methodology is what we publish. The operated pipeline (running generators, managing rendering, automated publishing) is what WhyStrohm offers commercially at $3,000/month. +**Open methodology, paid pipeline.** This is the WhyStrohm thesis. The methodology is what we publish. The operated pipeline (running generators, managing rendering, publishing) is what WhyStrohm offers commercially. Current scope and pricing live at [whystrohm.com](https://whystrohm.com). ## How to wire up image generation -The seven adapters in `visual-prompt-forge` produce prompts in the syntax each generator expects. Here's what each adapter pairs with in production: +The ten adapters in `visual-prompt-forge` produce prompts in the syntax each generator expects, six for stills and four for motion. Here's what each adapter pairs with in production: ### Midjourney -The `prompts/midjourney.txt` file is designed for paste into Discord or the Midjourney web app. Limited API access as of Q2 2026, so most teams use: +The `prompts/round-1/midjourney.txt` file is designed for paste into Discord or the Midjourney web app. Limited API access as of Q2 2026, so most teams use: - **Discord**, paste prompts manually for hero work - **PiAPI** or **useapi.net**, third-party Midjourney API wrappers, accept the same prompt syntax @@ -56,7 +56,7 @@ The `prompts/midjourney.txt` file is designed for paste into Discord or the Midj ### Flux -The `prompts/flux.txt` file works on multiple platforms: +The `prompts/round-1/flux.txt` file works on multiple platforms: - **fal.ai**, fastest for series work, supports all Flux variants - **Replicate**, broader model selection, slightly slower @@ -67,7 +67,7 @@ The prompt syntax is identical across all four surfaces. Pass the params alongsi ### Ideogram -The `prompts/ideogram.txt` file works on: +The `prompts/round-1/ideogram.txt` file works on: - **Ideogram official API**, direct, all features - **fal.ai**, Ideogram v3 with a clean wrapper @@ -77,7 +77,7 @@ For text-in-image work (Mode 2), Ideogram is the right choice. For everything el ### GPT Image -The `prompts/gpt-image.txt` file feeds into: +The `prompts/round-1/gpt-image.txt` file feeds into: - **OpenAI Images API**, standard `images.generate` endpoint - **ChatGPT Plus** UI for one-off work @@ -86,7 +86,7 @@ Strong on prompt accuracy and spatial reasoning. Use for shots where composition ### Nano Banana (Gemini 2.5 Flash Image) -The `prompts/nano-banana.txt` file feeds into: +The `prompts/round-1/nano-banana.txt` file feeds into: - **Gemini API direct**, Google's native surface - **Vertex AI**, for enterprise GCP integration @@ -97,7 +97,7 @@ Strongest for image-to-image work and rapid variation generation. Pair with hero ### Seedream -The `prompts/seedream.txt` file feeds into: +The `prompts/round-1/seedream.txt` file feeds into: - **fal.ai**, `bytedance/seedream-4.5` and `seedream-4.0` - **Replicate**, same models @@ -109,10 +109,10 @@ Use for high-volume series work where cost matters more than peak quality. The motion adapters each write their own per-shot prompt file. All run on fal.ai: -- **Kling 3.0** (`prompts/kling.txt`), the default. Best camera-motion realism per dollar, strong image-to-video. -- **Veo 3** (`prompts/veo.txt`), dialogue and lipsync with synchronised native audio. -- **Seedance 2.0** (`prompts/seedance.txt`), multi-shot sequences in one generation. -- **Hailuo 02 Pro** (`prompts/hailuo.txt`), cheap, fast iteration to find the shot before a final-tier re-roll. +- **Kling 3.0** (`prompts/round-1/kling.txt`), the default. Best camera-motion realism per dollar, strong image-to-video. +- **Veo 3** (`prompts/round-1/veo.txt`), dialogue and lipsync with synchronised native audio. +- **Seedance 2.0** (`prompts/round-1/seedance.txt`), multi-shot sequences in one generation. +- **Hailuo 02 Pro** (`prompts/round-1/hailuo.txt`), cheap, fast iteration to find the shot before a final-tier re-roll. For storyboard-to-video pipelines. Generated clips are raw material: assemble to timing, composite text overlays, grade, and add audio in post. diff --git a/docs/the-qa-loop.md b/docs/the-qa-loop.md index 6638797..4fdb27b 100644 --- a/docs/the-qa-loop.md +++ b/docs/the-qa-loop.md @@ -2,43 +2,69 @@ AI image and video generation has one structural problem: it is non-deterministic, and most teams have no gate between "generated" and "shipped." They generate, glance, accept, and post. Off-brand frames, character drift, six-fingered hands, and wrong framing slip through because the only reviewer is a tired human at the end of a long day. -shotkit's four skills already give you the pieces of a review. This document is how they form a **closed loop** that a person, or a pipeline, can run until every shot passes. +shotkit's five skills give you the pieces of a review. This document is how they form a **closed loop** that a person, or a pipeline, can run until every shot passes, and how the loop survives the three things that used to break it silently: two people working at once, a frame regenerated without a re-review, and a brand-lock that changes mid-project. ## The loop ``` - storyboard-architect ──▶ shots.json + brand-lock.snapshot.md - │ + storyboard-architect ──▶ run.json + shots.json + brand-lock.snapshot.md + │ (run.json hashes all three) ▼ - visual-prompt-forge ───▶ prompts/{generator}.txt + visual-prompt-forge ───▶ prompts/round-1/{generator}.txt │ ▼ - YOU (or your generator API) ──▶ output/generated/shot_NN.png + YOU (or your generator API) ──▶ frames/round-1/shot_NN.png │ ▼ visual-asset-critic ───▶ critique.md (for the human) - output/critique.json (for the machine) + critiques/round-1/shot_NN.critique.json (for the machine) + │ + ├─ ACCEPT ──▶ done. ship the shot. │ - ├─ verdict ACCEPT ──▶ done. ship the shot. + ├─ REVISE + │ │ + │ ▼ + │ visual-prompt-forge (revision mode) + │ re-emits prompts for only the failed shots + │ into prompts/round-2/revised-{generator}.txt + │ │ + │ └──────▶ back to "YOU generate", round 2 │ - └─ verdict REVISE / REJECT - │ - ▼ - visual-prompt-forge (revision mode) - reads critique.json, re-emits prompts - for only the failed shots - │ - └──────▶ back to "YOU generate", repeat + └─ REJECT ──▶ stop. no fix path exists. escalate to the human. ``` The loop runs until every shot is `ACCEPT`, or until you decide a shot is good enough and call it manually. Nothing here calls a generator API, shotkit emits prompts and verdicts; the generation step is yours. That boundary is deliberate (see [`connecting-to-generators.md`](connecting-to-generators.md)). -## What makes it close +## Every artifact is addressed by round and shot + +``` +output/ +├── run.json written once, hashes every input +├── shots.json +├── text-overlays.json +├── brand-lock.snapshot.md +├── prompts/ +│ ├── round-1/flux.txt +│ └── round-2/revised-flux.txt +├── frames/ +│ ├── round-1/shot_01.png +│ └── round-2/shot_02.png +└── critiques/ + ├── round-1/shot_01.critique.json + ├── round-1/shot_02.critique.json + └── round-2/shot_02.critique.json +``` -Two things the v2.0.0 batch added: +This layout is the point, not housekeeping. Previously every one of those artifacts had a +single fixed path: one `critique.json`, one `prompts/{generator}.txt`, one +`revised-{generator}.txt`, one `generated/shot_NN.png`. A 12-shot project through three +rounds produced 36 critiques and kept one, and the surviving prompt was round 3's while most +surviving frames came from round 1. -1. **A machine-readable verdict.** `visual-asset-critic` writes `output/critique.json` next to the markdown critique. Same review, two surfaces. The markdown is for the human; the JSON is for the loop. -2. **A prompt-forge that can read it.** `visual-prompt-forge` revision mode takes `shots.json` + one or more `critique.json` files and re-emits prompts for only the shots that failed. +Now no two writes collide. Two operators reviewing different shots write different files. +Two operators reviewing the *same* shot in the same round produce two files whose contents +both name that shot, which is a conflict a person can resolve by reading it, rather than a +silent overwrite. ## The verdict is derived, not chosen @@ -47,44 +73,90 @@ A critique that says "ACCEPT" while listing a blocking problem is worthless, and | If the issues include... | The verdict must be | |---|---| | any `blocking` | `REJECT` | -| any `major` (no blocking) | `REVISE` (or `REJECT`), never `ACCEPT` | +| three or more `major` | `REJECT` | +| one or two `major` | `REVISE` | | only `minor`, or none | `ACCEPT` (with post notes) | -`tools/validate_critique.py` enforces this. It validates a `critique.json` against the schema **and** checks the gating rule, which JSON Schema alone cannot express. Its `--selftest` constructs a deliberately contradictory document (ACCEPT + a blocking issue) and fails if the gate lets it through, so CI proves the gate fires on every run. - -This is what lets you put the loop in a pipeline: you can branch on `critique.json.verdict` and trust it. - -## How revision mode decides what to re-emit - -For each non-ACCEPT shot, revision mode walks the critique's `issues[]` and branches on `fix_type`: +`tools/validate_critique.py` enforces this. Its `--selftest` builds fourteen documents, +including a deliberately contradictory one (ACCEPT plus a blocking issue), and fails if the +gate lets any wrong one through, so CI proves the gate fires on every run. + +The three-major row used to read "escalate at your discretion," which meant the skill and +`critique-rubric.md` disagreed about the same case. Discretion inside a gate is not a gate. + +## The verdict names the bytes it reviewed + +A verdict is a claim about a specific file. From critique schema `1.1`, it has to prove it: + +```json +{ + "version": "1.1", + "run_id": "20260730T142300Z-9f2c1ab4", + "round": 1, + "shot_id": "shot_02", + "image_ref": "frames/round-1/shot_02.png", + "image_sha256": "e3b0c44298fc1c14...", + "prompt_ref": "prompts/round-1/flux.txt", + "prompt_sha256": "9f86d081884c7d65...", + "brand_lock_ref": "brand-lock.snapshot.md", + "brand_lock_sha256": "2c26b46b68ffc68f...", + "generator": "flux", + "model_version": "2 Pro", + "seed": 481207, + "verdict": "REVISE" +} +``` -| `fix_type` | What revision mode does | -|---|---| -| `prompt-level` | recompose the shot's prompt with the fix applied, re-emit it | -| `re-roll` | keep the prompt identical, flag it for 2-3 fresh samples | -| `post-level` | do **not** re-emit, the fix happens in compositing, not a new generation | +Every provenance field is **required and nullable**. `null` records that an input was +genuinely unavailable; a missing key records nothing at all. A `1.1` critique claiming HIGH +confidence with a null `prompt_ref` fails the gate, because HIGH means all three references +were available and that combination says otherwise. -A shot whose only issues are `post-level` needs no new prompt. A shot that already passed is left alone. You re-generate only what actually needs re-generating, which is where the cost savings live. +Paths alone were never enough. `image_ref` still resolves after the frame behind it is +replaced, which is the whole failure mode. ## Running it by hand ``` -1. Generate frames from prompts/{generator}.txt +1. Generate frames from prompts/round-N/{generator}.txt into frames/round-N/ 2. For each frame, run visual-asset-critic with the shot_id and brand-lock - -> writes output/critique.json -3. python tools/validate_critique.py output/critique.json # sanity gate -4. If any verdict != ACCEPT: - hand shots.json + the critique.json files to visual-prompt-forge - in revision mode -> output/prompts/revised-{generator}.txt -5. Re-generate the revised shots. Go to 2. + -> writes critiques/round-N/shot_NN.critique.json +3. python tools/validate_provenance.py output/ + Recomputes every hash, so a frame swapped after review fails here. +4. If any verdict is REVISE: + point visual-prompt-forge at output/ in revision mode + -> prompts/round-{N+1}/revised-{generator}.txt +5. If any verdict is REJECT: stop and decide. Revision mode will not re-emit it. +6. Re-generate the revised shots into frames/round-{N+1}/. Go to 2. ``` ## Running it in a pipeline The same loop scripts cleanly because every step is a file: -- `critique.json` is schema-valid and gate-checked, so `verdict` is trustworthy to branch on. -- revision mode writes `revised-{generator}.txt`, so your generation step has a stable input. -- a stop condition is just "no critique.json has a verdict other than ACCEPT" or a max-rounds counter. +```bash +# Is the chain intact, and is every shot done? +python tools/validate_provenance.py output/ --require-accept || exit 1 +``` + +Exit 0 means every hash matches and every shot's latest verdict is ACCEPT. Exit 1 means +either the chain is broken or work remains, and the output says which. Add `--json` for a +machine-readable report. + +Three things that used to end the loop quietly, and what now catches them: + +| Failure | What it used to do | What catches it | +|---|---|---| +| Frame regenerated without a re-review | Stale ACCEPT satisfied the stop condition; the loop declared done on an unreviewed image | `image_sha256` mismatch | +| Frame dropped in with no critique at all | Nothing looked for it | frames-without-critiques check | +| Brand-lock edited mid-project | Old frames judged against new rules; every path still resolved | `brand_lock_sha256` mismatch against `run.json` | +| Two operators on one shot in one round | Second write destroyed the first verdict | two files, same shot, same round | + +A max-rounds counter is still worth adding on your side. The loop has no opinion about when +to give up, and neither does the gate. + +## What shotkit still does not do -shotkit stops at the prompt and the verdict. The loop logic and the generator calls live in your tooling, which is exactly where the operator's edge is. +It does not call generators, it does not render video, and it does not decide when a REJECT +is worth fighting. It emits prompts, verdicts, and a chain you can audit. The loop logic and +the generator calls live in your tooling, which is exactly where the operator's edge is. diff --git a/docs/why-this-exists.md b/docs/why-this-exists.md index 197831f..5cfc1ed 100644 --- a/docs/why-this-exists.md +++ b/docs/why-this-exists.md @@ -19,7 +19,7 @@ We're using "production-grade" deliberately. It means: - **Files, not panels.** The storyboard is a directory of structured Markdown and JSON. An editor, motion designer, or developer can act on it without asking the AI a follow-up question. - **Versioned brand state.** Every storyboard run snapshots the brand parameters it was built against. If the brand evolves later, you can still see exactly what version any given piece of content targeted. - **Per-shot rationale.** Every shot has a one-sentence explanation. Why this beat. Why this duration. Why this framing. Decisions are logged so they can be challenged. -- **Model-agnostic.** Same shot data renders to Midjourney, Flux, Ideogram, GPT Image, Kling, different syntax, identical intent. No vendor lock-in. (When Sora was discontinued, swapping the motion lane cost one capability-matrix edit and four adapter files.) +- **Model-agnostic.** Same shot data renders to Midjourney, Flux, Ideogram, GPT Image, Nano Banana, Seedream for stills and Kling, Veo, Seedance, Hailuo for motion, different syntax, identical intent. No vendor lock-in. (When Sora was discontinued, swapping the motion lane cost one capability-matrix edit and four adapter files.) - **Composable.** The storyboard skill stops at the spec. The prompt skill stops at the prompt. The critique skill stops at the critique. Each does one job. They compose because they agree on file formats, not because they import each other. This is how serious teams have always worked. We're just bringing AI generation into the same discipline. @@ -30,7 +30,7 @@ The author spent a decade in defense systems engineering, building motion design **Auditability**, every artifact has a chain back to the source decision. You can answer "why does it look this way?" by reading the file, not asking a person. -**Determinism**, same inputs, same outputs. If two team members run the same brief, they should produce the same storyboard. Vibes don't survive a stop-work order. +**Determinism**, same inputs, same outputs, and the parts that cannot be deterministic say so. Given the same `shots.json` and brand-lock, the prompts and the HTML preview are reproducible, and CI re-renders the bundled previews on every push and fails if a byte moves. Image generation is not reproducible, so instead of pretending otherwise the trail records the hash of the frame you actually shipped. Vibes don't survive a stop-work order; neither do claims you cannot test. **Clean architectural boundaries**, each component has one job. Components don't reach into each other. Changes are surgical. diff --git a/install.sh b/install.sh index 87f7c08..f7af9c7 100755 --- a/install.sh +++ b/install.sh @@ -1,32 +1,51 @@ #!/usr/bin/env bash -# shotkit. Install all five skills into ~/.claude/skills/ -# -# Usage: -# ./install.sh # install to ~/.claude/skills/ (user scope) -# ./install.sh --project # install to ./.claude/skills/ (project scope) -# ./install.sh --dry-run # show what would happen, change nothing -# ./install.sh --force # overwrite existing skills without prompting -# ./install.sh --uninstall # remove the five shotkit skills -# ./install.sh --help # show this help +# shotkit. Install all five skills, plus the tools they reference, into ~/.claude/ +# Help text lives in usage() below, not in this comment block, so the two cannot drift. set -euo pipefail usage() { - sed -n '2,11p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + cat <<'EOF' +shotkit. Install all five skills, plus the tools they reference, into ~/.claude/ + +Three things go in, because the skills reference all three. They cite +tools/validate_shots.py and tools/copy-prompt.py in their workflows, and +storyboard-architect falls back to brand-packs/_template.md when no brand-lock is +given. Installing the skills alone left every one of those paths unresolvable. + + ~/.claude/skills/ the five skills + ~/.claude/shotkit-tools/ the validators and helpers + ~/.claude/shotkit-brand-packs/ the blank template and two examples + +Usage: + ./install.sh # install to ~/.claude/ (user scope) + ./install.sh --project # install to ./.claude/ (project scope) + ./install.sh --skills-only # skip the tools and packs, skills only + ./install.sh --dry-run # show what would happen, change nothing + ./install.sh --force # overwrite existing skills without prompting + ./install.sh --uninstall # remove the skills, the tools, and the packs + ./install.sh --help # show this help + +The validators need two packages: pip install pyyaml jsonschema +EOF } SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" SKILLS_DIR="${SCRIPT_DIR}/skills" +TOOLS_DIR="${SCRIPT_DIR}/tools" +PACKS_DIR="${SCRIPT_DIR}/brand-packs" SCOPE="user" -TARGET="${HOME}/.claude/skills" +ROOT="${HOME}/.claude" DRY_RUN=0 FORCE=0 UNINSTALL=0 +SKILLS_ONLY=0 for arg in "$@"; do case "$arg" in - --project) SCOPE="project"; TARGET="$(pwd)/.claude/skills" ;; + --project) SCOPE="project"; ROOT="$(pwd)/.claude" ;; + --skills-only) SKILLS_ONLY=1 ;; --dry-run) DRY_RUN=1 ;; --force) FORCE=1 ;; --uninstall) UNINSTALL=1 ;; @@ -35,6 +54,10 @@ for arg in "$@"; do esac done +TARGET="${ROOT}/skills" +TOOLS_TARGET="${ROOT}/shotkit-tools" +PACKS_TARGET="${ROOT}/shotkit-brand-packs" + SKILLS=( "brand-lock-extractor" "storyboard-architect" @@ -53,14 +76,40 @@ run() { fi } +# Copy a directory without dragging along editor and Finder droppings. cp -R took +# .DS_Store with it, which then ended up inside every .skill zip. +copy_tree() { + local src="$1" dst="$2" + if command -v rsync >/dev/null 2>&1; then + run rsync -a \ + --exclude '.DS_Store' --exclude 'Thumbs.db' \ + --exclude '__pycache__' --exclude '*.pyc' \ + "${src}/" "${dst}/" + else + run cp -R "${src}" "${dst}" + if [[ "$DRY_RUN" != "1" ]]; then + find "${dst}" \( -name '.DS_Store' -o -name 'Thumbs.db' -o -name '*.pyc' \) -delete + find "${dst}" -name '__pycache__' -type d -exec rm -rf {} + 2>/dev/null || true + fi + fi +} + if [[ "$UNINSTALL" == "1" ]]; then echo "" - echo "Uninstalling shotkit from ${TARGET}" + echo "Uninstalling shotkit from ${ROOT}" for skill in "${SKILLS[@]}"; do dst="${TARGET}/${skill}" if [[ -d "${dst}" ]]; then echo " [remove] ${skill}"; run rm -rf "${dst}" else echo " [skip] ${skill}: not installed"; fi done + for extra in "${TOOLS_TARGET}" "${PACKS_TARGET}"; do + if [[ -d "${extra}" ]]; then + echo " [remove] $(basename "${extra}")" + run rm -rf "${extra}" + else + echo " [skip] $(basename "${extra}"): not installed" + fi + done echo "" echo "Done." exit 0 @@ -75,7 +124,11 @@ fi echo "" echo "Installing shotkit" echo " scope: ${SCOPE}" -echo " target: ${TARGET}" +echo " skills: ${TARGET}" +if [[ "$SKILLS_ONLY" != "1" ]]; then + echo " tools: ${TOOLS_TARGET}" + echo " packs: ${PACKS_TARGET}" +fi [[ "$DRY_RUN" == "1" ]] && echo " mode: dry-run (no changes)" echo "" @@ -101,11 +154,52 @@ for skill in "${SKILLS[@]}"; do echo " [install] ${skill}" fi - run cp -R "${src}" "${dst}" + copy_tree "${src}" "${dst}" done +if [[ "$SKILLS_ONLY" != "1" ]]; then + if [[ ! -d "${TOOLS_DIR}" ]]; then + echo " [skip] tools: source missing" + else + if [[ -d "${TOOLS_TARGET}" ]]; then + echo " [replace] shotkit-tools" + run rm -rf "${TOOLS_TARGET}" + else + echo " [install] shotkit-tools" + fi + copy_tree "${TOOLS_DIR}" "${TOOLS_TARGET}" + fi + + # storyboard-architect's documented fallback is to copy brand-packs/_template.md when + # no brand-lock is given. Shipping the skills without the packs left that path + # pointing at a directory that does not exist after an install. + if [[ ! -d "${PACKS_DIR}" ]]; then + echo " [skip] brand-packs: source missing" + else + if [[ -d "${PACKS_TARGET}" ]]; then + echo " [replace] shotkit-brand-packs" + run rm -rf "${PACKS_TARGET}" + else + echo " [install] shotkit-brand-packs" + fi + copy_tree "${PACKS_DIR}" "${PACKS_TARGET}" + fi +fi + echo "" echo "Done. Restart your Claude Code session to pick up the new skills." +if [[ "$SKILLS_ONLY" != "1" ]]; then + echo "" + echo "The validators need two packages:" + echo " pip install pyyaml jsonschema" + echo "" + echo "Brand packs, including the blank template, are at:" + echo " ${PACKS_TARGET}/" + echo "" + echo "Then, from a project's output directory:" + echo " python ${TOOLS_TARGET}/validate_shots.py output/" + echo " python ${TOOLS_TARGET}/validate_provenance.py output/ --require-accept" +fi echo "" echo 'Try: "30-second founder explainer for your-brand. Pain-reframe-promise structure."' echo "" diff --git a/remotion/README.md b/remotion/README.md index 748fef8..0c202ad 100644 --- a/remotion/README.md +++ b/remotion/README.md @@ -1,8 +1,29 @@ # shotkit/remotion -Remotion composition source for the demo GIF embedded in the project README at `../docs/images/demo.gif`. Ships with the repo so contributors can regenerate the GIF after brand-pack updates. - -This is composition source, not a redistributable Remotion framework. It exists for the demo asset only. +Remotion composition source for the repo's rendered assets: the demo GIF at +`../docs/images/demo.gif`, the social preview card at `../docs/images/social-preview.png`, and +the explainer film at `../docs/images/shotkit-explainer.mp4`. Ships with the repo so +contributors can regenerate them after brand-pack updates. + +This is composition source, not a redistributable Remotion framework. It exists for the repo's +own assets only. + +## Which sources are current + +| Composition | Rendered asset | State | +|---|---|---| +| `SocialPreview.tsx` | `social-preview.png` | Current | +| `ShotkitDemo.tsx` | `demo.gif` | Current | +| `ShotkitExplainer.tsx` | `shotkit-explainer.mp4`, `-vertical.mp4` | v0.1.0, not re-cut | + +`ShotkitExplainer.tsx` still shows seven generator adapters including Runway/Sora, and stamps +`v0.1.0`. Sora was discontinued in 2026 and the motion lane moved to Kling, Veo, Seedance, and +Hailuo. + +The source is left matching the video it produced rather than edited to describe a render that +does not exist. Re-cutting means updating the adapter list, the file tree, and the version +stamp, then re-rendering both cuts and the storyboard example beside them. Until that happens, +source and artifact agree, and both are labelled. ## Install diff --git a/remotion/package.json b/remotion/package.json index 10e75af..94903c7 100644 --- a/remotion/package.json +++ b/remotion/package.json @@ -1,8 +1,8 @@ { "name": "shotkit-remotion", - "version": "0.1.0", + "version": "3.0.0", "private": true, - "description": "Remotion composition for the shotkit demo GIF", + "description": "Remotion compositions for the shotkit demo GIF, social preview, and explainer film", "license": "Apache-2.0", "scripts": { "studio": "remotion studio", diff --git a/skills/brand-lock-extractor/examples/brand-lock.md b/skills/brand-lock-extractor/examples/brand-lock.md index 23bb5a9..fa966c2 100644 --- a/skills/brand-lock-extractor/examples/brand-lock.md +++ b/skills/brand-lock-extractor/examples/brand-lock.md @@ -21,8 +21,8 @@ Every hex value here is allowed. Anything outside this list is not. ## Typography -**Display font:** Tiempos Headline, Semibold (serif) -**Body font:** Söhne, Regular and Medium (sans-serif) +**Display font:** `Tiempos Headline Semibold`, serif +**Body font:** `Söhne Regular`, sans-serif, Medium for emphasis ## Mood adjectives diff --git a/skills/brand-lock-extractor/templates/brand-lock.md.tpl b/skills/brand-lock-extractor/templates/brand-lock.md.tpl index 558eb14..129c3a5 100644 --- a/skills/brand-lock-extractor/templates/brand-lock.md.tpl +++ b/skills/brand-lock-extractor/templates/brand-lock.md.tpl @@ -21,9 +21,12 @@ Every hex value here is allowed. Anything outside this list is not. ## Typography -**Display font:** {{font name + weight, e.g. Inter Black 900}} -**Body font:** {{font name + weight, e.g. Inter Medium 500}} -**Mono font (optional):** {{for code/data, or remove}} +**Display font:** `{{font name + weight, e.g. Inter Black 900}}` +**Body font:** `{{font name + weight, e.g. Inter Medium 500}}` +**Mono font (optional):** `{{for code/data, or remove the line}}` + +The font name goes in backticks, immediately after the label. Downstream tools read +that backticked value; prose before it is not parsed. ## Mood adjectives diff --git a/skills/storyboard-architect/SKILL.md b/skills/storyboard-architect/SKILL.md index 7e65ca8..909d782 100644 --- a/skills/storyboard-architect/SKILL.md +++ b/skills/storyboard-architect/SKILL.md @@ -1,6 +1,6 @@ --- name: storyboard-architect -description: Turn a creative brief into a production-grade storyboard with shot specs, timing, on-screen text, and per-shot rationale. Use when the user describes a video brief, plans a video, references shots or beats, scripts a social video, or hands over a creative concept to break into scenes. Produces storyboard.md, shots.json, text-overlays.json, and brand-lock.snapshot.md. Pairs with visual-prompt-forge, visual-asset-critic, storyboard-html-preview. +description: Turn a creative brief into a production-grade storyboard with shot specs, timing, on-screen text, and per-shot rationale. Use when the user describes a video brief, plans a video, references shots or beats, scripts a social video, or hands over a creative concept to break into scenes. Produces run.json, storyboard.md, shots.json, text-overlays.json, and brand-lock.snapshot.md. Pairs with visual-prompt-forge, visual-asset-critic, storyboard-html-preview. --- # Storyboard Architect @@ -27,12 +27,18 @@ For every storyboard run, create this exact set of files in the working output d ``` output/ +├── run.json # Run identity + every input pinned by content hash ├── storyboard.md # Human-readable, structured per shot ├── shots.json # Machine-readable, schema in templates/shots.schema.json ├── text-overlays.json # On-screen text + timing └── brand-lock.snapshot.md # Frozen copy of the brand-lock used (audit trail) ``` +`run.json` is what makes the rest of the tree auditable later. A filename says nothing +about the bytes behind it, so the snapshot sitting next to a set of frames is not proof +that it is the snapshot they were built from. The hashes in `run.json` are that proof. +Write it once, at the end of the run, and never edit it. + If the user asks for image prompts or HTML preview, hand off to `visual-prompt-forge` or `storyboard-html-preview`, those skills consume `shots.json` directly. Don't try to do their job here. ## Inputs @@ -91,21 +97,32 @@ Don't fight the framework. If the brief and the duration disagree, surface the d ### Step 4. Draft the shot list -Read `references/shot-grammar.md` for controlled vocabulary. Every shot has: +Read `references/shot-grammar.md` for controlled vocabulary. The field names below are +the schema's field names. `templates/shots.schema.json` sets `additionalProperties: +false`, so a near-miss like `environment` instead of `environment_ref` is a validation +failure, not a synonym. - `id`, sequential, zero-padded (`shot_01`, `shot_02`...) - `beat`, which beat this shot serves -- `start` / `end`, timestamps in seconds, decimal allowed +- `start` / `end`, timestamps in seconds, decimal allowed. `end` must be after `start` - `framing`, ECU / CU / MCU / MS / MLS / WS / EWS - `angle`, eye-level / high / low / overhead / dutch -- `motion`, static / push / pull / pan-left / pan-right / handheld / orbit +- `motion`, static / push / pull / pan-left / pan-right / tilt-up / tilt-down / + handheld / orbit / whip / rack. All eleven are legal; the schema enum is the + authority and `references/shot-grammar.md` explains when each earns its keep +- `depth_of_field`, optional, shallow / deep / rack - `subject`, what's in frame, structured -- `environment`, references series-lock language -- `lighting`, references series-lock language -- `on_screen_text`, null OR a text-overlay reference +- `environment_ref`, references series-lock language, default `series_lock.environment` +- `lighting_ref`, references series-lock language, default `series_lock.lighting` +- `on_screen_text`, null, one text-overlay id, OR an array of ids when a shot carries + more than one overlay - `vo`, voiceover line, or null - `rationale`, one sentence explaining *why this shot at this moment* +Note on `rack`: as a `motion` value it means the rack focus is the shot's movement; as a +`depth_of_field` value it means focus shifts mid-shot. Same word, two fields, two +meanings. + ### Step 5. Separate the text layer Every piece of on-screen text becomes an entry in `text-overlays.json`. Never bake text into the visual description. Each overlay has: @@ -118,8 +135,12 @@ Every piece of on-screen text becomes an entry in `text-overlays.json`. Never ba - `size`, `display`, `headline`, `body`, `caption` - `weight`, `regular`, `medium`, `bold`, `black` - `color`, hex (must come from brand-lock palette) -- `enter`, `{ at: seconds, animation: fade-in | slide-up | type-on | hard-cut }` -- `exit`, `{ at: seconds, animation: fade-out | slide-down | hard-cut }` +- `enter`, `{ at: seconds, animation: fade-in | slide-up | slide-down | type-on | hard-cut }` +- `exit`, `{ at: seconds, animation: fade-out | slide-up | slide-down | hard-cut }` + +Enter and exit have different animation vocabularies, and `templates/text-overlays.schema.json` +is the authority on both. A shot may carry more than one overlay; list every id in that +shot's `on_screen_text` array, or the extra overlays render nowhere. ### Step 6. Lock the series @@ -131,14 +152,38 @@ Every shot has a one-sentence rationale. Why this beat. Why this framing. Why th ### Step 8. Snapshot the brand-lock -Copy the brand-lock file (or template) into the output as `brand-lock.snapshot.md`. Add a header line at the top: +Copy the brand-lock file (or template) into the output as `brand-lock.snapshot.md`. Add +these two comments at the very top, in this order: ``` - - + + +``` + +The timestamp is a full UTC instant, `YYYY-MM-DDThh:mm:ssZ`. A bare date cannot +distinguish two runs made on the same day, which is the case that matters. The source is +the path it was copied from, or the literal string `template default` for an +unconfigured run. Extra comments after these two are fine. + +`tools/validate_brand_lock.py --snapshot ` checks both lines. Run it. + +### Step 9. Write run.json + +Last step, after the other four files are final. Fill in +`templates/run.schema.json`: a `run_id`, the `created_at` instant, and the SHA-256 of +`shots.json`, `text-overlays.json`, and `brand-lock.snapshot.md` as written. + +```bash +shasum -a 256 output/shots.json output/text-overlays.json output/brand-lock.snapshot.md ``` -This is what makes the storyboard reproducible later. +`run_id` is the compact UTC timestamp, a dash, then 8 hex characters, e.g. +`20260730T142300Z-9f2c1ab4`. The hex suffix is what keeps two operators starting a run +in the same second from colliding. Set `brand_lock_configured: false` when the snapshot +is an unfilled template. + +Leave `rounds` empty. `visual-prompt-forge` appends a round entry when it writes +prompts. ## Output formats @@ -152,8 +197,8 @@ Must validate against `templates/shots.schema.json`. Read it before writing. The ```json { - "version": "1.0", - "project": { "title": "...", "duration_s": 30, "aspect": "9:16" }, + "version": "1.2", + "project": { "title": "...", "duration_s": 30, "aspect": "9:16", "framework": "..." }, "brand_lock_ref": "brand-lock.snapshot.md", "series_lock": { "character": "...", @@ -170,6 +215,7 @@ Must validate against `templates/shots.schema.json`. Read it before writing. The "framing": "MCU", "angle": "eye-level", "motion": "static", + "depth_of_field": "shallow", "subject": "...", "environment_ref": "series_lock.environment", "lighting_ref": "series_lock.lighting", @@ -181,24 +227,48 @@ Must validate against `templates/shots.schema.json`. Read it before writing. The } ``` +Write `1.2` for new storyboards. `1.0` and `1.1` files stay valid; the array form of +`on_screen_text` and the hashed `assets` block need `1.2`. + ### `text-overlays.json` Must validate against `templates/text-overlays.schema.json`. Read it before writing. ## Quality bar -Before declaring the storyboard complete, verify: +Run the validator. Do not eyeball this list. + +```bash +python tools/validate_shots.py output/ +python tools/validate_brand_lock.py --snapshot output/brand-lock.snapshot.md +python tools/validate_provenance.py output/ +``` + +`validate_shots.py` checks every mechanical rule that used to live here as a checkbox, +because a checkbox is a rule enforced by remembering to look: -- [ ] Every shot has a rationale -- [ ] Total of `(end - start)` across shots equals project duration (within 0.1s) -- [ ] Every `on_screen_text` reference resolves to an entry in `text-overlays.json` -- [ ] Every text overlay color appears in the brand-lock palette -- [ ] `series_lock` is populated (not empty strings) -- [ ] `brand-lock.snapshot.md` exists in output -- [ ] No on-screen text is described inside a shot's `subject` field -- [ ] No specific brand colors are described in shot subjects (those live in series_lock and brand-lock) +- shots.json and text-overlays.json validate against their schemas +- `end` is after `start`, no duplicate ids, no gaps, no overlaps, and the covered span + matches `project.duration_s` within 0.1s +- every `on_screen_text` resolves to an overlay, every `overlay.shot_id` resolves to a + shot, and every overlay is reachable from at least one shot +- every overlay's timing sits inside its shot window, and exit is after enter +- every overlay color appears in the brand-lock palette +- `brand_lock_ref` resolves on disk -If any check fails, fix before declaring done. +It warns, rather than fails, on judgement calls worth a second look: overlay copy +repeated inside a shot subject, a raw hex in a subject, shot ids out of chronological +order, an overlay font the brand-lock does not declare. + +What the validator cannot check, and you still have to: + +- [ ] Every rationale says *why this shot at this moment*, not what the shot contains +- [ ] `series_lock` anchors are specific enough to reproduce (not "a person in a room") +- [ ] The beat structure actually matches the brief's argument +- [ ] `run.json` is written and its hashes are the files as shipped + +If the validator fails, fix it before declaring done. A green validator plus an unread +rationale is not a finished storyboard. ## Reference files @@ -213,12 +283,20 @@ Load these as needed: - `examples/30s-pain-proof-promise/`, full output set for a 30-second conversion ad - `examples/60s-founder-explainer/`, full output set for a founder explainer +- `examples/shotkit-explainer/`, the 90-second explainer, including a shot that carries + two overlays Read these to understand the expected output quality, especially the rationale fields. +All three validate clean under `tools/validate_shots.py --examples`, so they are also +the reference for what a passing file looks like. + +For what the output tree looks like after generation and review, see +`../visual-asset-critic/examples/worked-run/`: two shots through two rounds, with real +hashes, per-round prompts and frames, and one critique per shot per round. ## Handoff -After producing the four files, tell the user what's in `output/` and offer the obvious next steps: +After producing the five files, tell the user what's in `output/` and offer the obvious next steps: - "Want image prompts? I'll run `visual-prompt-forge` on `shots.json`." - "Want a shareable HTML preview? I'll run `storyboard-html-preview`." diff --git a/skills/storyboard-architect/examples/30s-pain-proof-promise/preview.html b/skills/storyboard-architect/examples/30s-pain-proof-promise/preview.html index 5c04f7e..8fb201d 100644 --- a/skills/storyboard-architect/examples/30s-pain-proof-promise/preview.html +++ b/skills/storyboard-architect/examples/30s-pain-proof-promise/preview.html @@ -15,12 +15,12 @@ /* Brand-lock variables, get substituted at compose time */ --sb-color-bg: #F5F0E8; --sb-color-ink: #2A2A32; - --sb-color-accent: #3B82F6; + --sb-color-accent: #D94F3A; --sb-color-muted: #7A7580; --sb-color-rule: #E8E1D4; - --sb-font-display: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - --sb-font-body: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --sb-font-display: Inter Black 900, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --sb-font-body: Inter Medium 500, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; --sb-font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; --sb-radius: 6px; @@ -397,6 +397,47 @@ gap: 16px; } +/* ─── QA verdict ─── */ + +.sb-verdict { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 2px 8px; + border: 1px solid var(--sb-color-rule); + border-radius: 2px; + font-family: var(--sb-font-body); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--sb-color-muted); + white-space: nowrap; +} + +.sb-verdict-accept { border-color: var(--sb-color-ink); color: var(--sb-color-ink); } +.sb-verdict-revise { border-color: var(--sb-color-accent); color: var(--sb-color-accent); } +.sb-verdict-reject { + border-color: var(--sb-color-accent); + background: var(--sb-color-accent); + color: var(--sb-color-bg); +} + +.sb-verdict-round { font-weight: 400; letter-spacing: 0.04em; opacity: 0.75; } + +.sb-provenance { + max-width: 960px; + margin: 0 auto; + font-size: 11px; + line-height: 1.7; + color: var(--sb-color-muted); +} + +.sb-provenance code { + font-size: 10px; + word-break: break-all; +} + /* ─── Responsive ─── */ @media (max-width: 720px) { @@ -596,34 +637,58 @@
-
Storyboard · v1.0
+
Storyboard · shots 1.0

WhyStrohm. The Content Infrastructure Pitch

Duration
30s
Aspect
9:16
Framework
pain-reframe-promise
-
Generated
2026-05-08 12:45 UTC
+
Run
2026-05-07T14:23:00Z
+ +

Series lock

-
Character
founder, mid-thirties, salt-and-pepper hair, navy crewneck, calm posture, working at laptop
-
Environment
minimalist home office, white walls, oak desk, single houseplant, no decor clutter
-
Lighting
soft natural side-light, large window camera-left, warm afternoon golden hour, gentle shadow rolloff
-
Color grade
warm filmic, muted teal shadows, slight grain, cream highlights, deep navy shadows never crushed
+
+
Character
+
founder, mid-thirties, salt-and-pepper hair, navy crewneck, calm posture, working at laptop
+
+
+
Environment
+
minimalist home office, white walls, oak desk, single houseplant, no decor clutter
+
+
+
Lighting
+
soft natural side-light, large window camera-left, warm afternoon golden hour, gentle shadow rolloff
+
+
+
Color grade
+
warm filmic, muted teal shadows, slight grain, cream highlights, deep navy shadows never crushed
+
@@ -632,198 +697,429 @@

Shots

-
01
MCU · eye-level · static
-
Your content feels random.
+ + +
+
01
+
MCU · eye-level · static
+
+ + + +
+ Your content feels random. +
+
+
01 0.0–2.0s hook +
+
Framing
MCU
Angle
eye-level
Motion
static
+
DOF
shallow
+
-

Subject

founder, mid-thirties, salt-and-pepper hair, navy crewneck, looking directly into camera, neutral expression, slightly tired

+ +
+

Subject

+

founder, mid-thirties, salt-and-pepper hair, navy crewneck, looking directly into camera, neutral expression, slightly tired

+
+ -

On-screen text

"Your content feels random."

display · lower-third · enter 0.4s (hard-cut) · exit 2.0s

-

Rationale

Cold open, direct eye contact establishes parasocial trust. Static framing keeps the hook simple, text does the work.

-
-
+ +
+

On-screen text

+ +

"Your content feels random."

+

text_01 · display · lower-third · enter 0.4s (hard-cut) · exit 2.0s (hard-cut)

+ +
+ +
+

Rationale

+

Cold open, direct eye contact establishes parasocial trust. Static framing keeps the hook simple, text does the work.

+
+ + +
-
02
MS · high · static
-
Posting more isn't fixing it.
+ + +
+
02
+
MS · high · static
+
+ + + +
+ Posting more isn't fixing it. +
+
+
02 2.0–7.0s pain +
+
Framing
MS
Angle
high
Motion
static
+
DOF
deep
+
-

Subject

founder at laptop, scrolling social feed, content tiles glowing on screen, slight slump in shoulders, mug of coffee mid-distance

+ +
+

Subject

+

founder at laptop, scrolling social feed, content tiles glowing on screen, slight slump in shoulders, mug of coffee mid-distance

+
+ -

On-screen text

"Posting more isn't fixing it."

headline · right-third · enter 2.4s (fade-in) · exit 6.8s

-

Rationale

High angle subtly diminishes the subject, pain beat. Deep DOF keeps the messy social feed legible. Subject left-weighted, text reserves right two-thirds.

-
-
+ +
+

On-screen text

+ +

"Posting more isn't fixing it."

+

text_02 · headline · right-third · enter 2.4s (fade-in) · exit 6.8s (fade-out)

+ +
+ +
+

Rationale

+

High angle subtly diminishes the subject, pain beat. Deep DOF keeps the messy social feed legible. Subject left-weighted, text reserves right two-thirds.

+
+ + +
-
03
ECU · eye-level · static
+ + +
+
03
+
ECU · eye-level · static
+
+ +
+
03 7.0–11.0s pain +
+
Framing
ECU
Angle
eye-level
Motion
static
+
DOF
shallow
+
-

Subject

close on founder's hand hovering over laptop trackpad, frozen, not moving

+ +
+

Subject

+

close on founder's hand hovering over laptop trackpad, frozen, not moving

+
+ + -

Rationale

Hand frozen above trackpad is the visual symbol of decision paralysis. Wordless beat, let the image carry the pain before the reframe.

+ +
+

Rationale

+

Hand frozen above trackpad is the visual symbol of decision paralysis. Wordless beat, let the image carry the pain before the reframe.

+
- - +
-
04
MCU · eye-level · push
-
You don't have a content problem. -You have an infrastructure problem.
+ + +
+
04
+
MCU · eye-level · push
+
+ + + +
+ You don't have a content problem. +You have an infrastructure problem. +
+
+
04 11.0–16.0s reframe +
+
Framing
MCU
Angle
eye-level
Motion
push
+
DOF
shallow
+
-

Subject

founder, same posture, looking at camera, expression shifts from tired to clear, small almost-smile

+ +
+

Subject

+

founder, same posture, looking at camera, expression shifts from tired to clear, small almost-smile

+
+ -

On-screen text

"You don't have a content problem. -You have an infrastructure problem."

headline · center · enter 11.5s (type-on) · exit 15.8s

-

Rationale

Slow push as the reframe lands. Same character, same environment, only the expression changes. The change is the point.

-
-
+ +
+

On-screen text

+ +

"You don't have a content problem. +You have an infrastructure problem."

+

text_03 · headline · center · enter 11.5s (type-on) · exit 15.8s (fade-out)

+ +
+ +
+

Rationale

+

Slow push as the reframe lands. Same character, same environment, only the expression changes. The change is the point.

+
+ + +
-
05
MS · eye-level · static
-
Voice extracted. Brand locked. System runs.
+ + +
+
05
+
MS · eye-level · static
+
+ + + +
+ Voice extracted. Brand locked. System runs. +
+
+
05 16.0–20.0s proof +
+
Framing
MS
Angle
eye-level
Motion
static
+
DOF
deep
+
-

Subject

founder gestures toward laptop screen, kanban-style content pipeline visible, organized columns, clean structure

+ +
+

Subject

+

founder gestures toward laptop screen, kanban-style content pipeline visible, organized columns, clean structure

+
+ -

On-screen text

"Voice extracted. Brand locked. System runs."

body · upper-third · enter 16.4s (slide-up) · exit 19.8s

-

Rationale

Proof beat, show the system, not the result. The kanban metaphor is recognizable to operators. Subject right-weighted, text upper-third.

-
-
+ +
+

On-screen text

+ +

"Voice extracted. Brand locked. System runs."

+

text_04 · body · upper-third · enter 16.4s (slide-up) · exit 19.8s (fade-out)

+ +
+ +
+

Rationale

+

Proof beat, show the system, not the result. The kanban metaphor is recognizable to operators. Subject right-weighted, text upper-third.

+
+ + +
-
06
MCU · eye-level · static
-
30 minutes a week. -48-hour content cycles.
+ + +
+
06
+
MCU · eye-level · static
+
+ + + +
+ 30 minutes a week. +48-hour content cycles. +
+
+
06 20.0–26.0s promise +
+
Framing
MCU
Angle
eye-level
Motion
static
+
DOF
shallow
+
-

Subject

founder, calm composed posture, fully present, slight smile, hands folded on desk

+ +
+

Subject

+

founder, calm composed posture, fully present, slight smile, hands folded on desk

+
+ -

On-screen text

"30 minutes a week. -48-hour content cycles."

headline · lower-third · enter 20.5s (type-on) · exit 25.8s

-

Rationale

Promise beat, the after-state is the same person, just calmer. Static frame holds the moment. Text carries the specific commitment.

-
-
+ +
+

On-screen text

+ +

"30 minutes a week. +48-hour content cycles."

+

text_05 · headline · lower-third · enter 20.5s (type-on) · exit 25.8s (fade-out)

+ +
+ +
+

Rationale

+

Promise beat, the after-state is the same person, just calmer. Static frame holds the moment. Text carries the specific commitment.

+
+ + +
-
07
MS · eye-level · static
-
whystrohm.com/scan
+ + +
+
07
+
MS · eye-level · static
+
+ + + +
+ whystrohm.com/scan +
+
+
07 26.0–30.0s cta +
+
Framing
MS
Angle
eye-level
Motion
static
+
DOF
deep
+
-

Subject

founder, neutral posture, room visible behind, calm presence, gentle eye contact with camera

+ +
+

Subject

+

founder, neutral posture, room visible behind, calm presence, gentle eye contact with camera

+
+ + + + +
+

On-screen text

+ +

"whystrohm.com/scan"

+

text_06 · headline · lower-third · enter 26.4s (type-on) · exit 29.8s (hard-cut)

+ +
-

On-screen text

"whystrohm.com/scan"

headline · lower-third · enter 26.4s (type-on) · exit 29.8s

-

Rationale

CTA beat. Pulled out to MS to give text the breathing room. Subject centered, text lower-third with URL. No motion, let it land.

+ +
+

Rationale

+

CTA beat. Pulled out to MS to give text the breathing room. Subject centered, text lower-third with URL. No motion, let it land.

+
- +
+
+ + + diff --git a/skills/visual-asset-critic/examples/worked-run/prompts/round-1/flux.txt b/skills/visual-asset-critic/examples/worked-run/prompts/round-1/flux.txt new file mode 100644 index 0000000..d471ac8 --- /dev/null +++ b/skills/visual-asset-critic/examples/worked-run/prompts/round-1/flux.txt @@ -0,0 +1,13 @@ +# Storyboard: Worked Run, Two Shots +# Generator: flux +# Model: 2 Pro +# Aspect: 9:16 +# Brand-lock: brand-lock.snapshot.md +# Run: 20260730T142300Z-9f2c1ab4 +# Round: 1 + +# shot_01, hook, 0.0-3.0s, MCU eye-level static +Medium close-up framing, camera at eye level, static. A founder, mid-thirties, salt-and-pepper hair, navy crewneck, seated at the desk with his hands still on the laptop keys, looking slightly off-camera. He is held in the left third of the frame and the right two thirds are left clear. Minimalist home office, white walls, oak desk, single houseplant. Soft natural side-light from a large window camera-left, warm afternoon golden hour. Shot at f/2.0, shallow depth of field, 50mm prime. Warm filmic, muted teal shadows, slight grain. Calm, considered mood. Photorealistic, natural skin texture, no AI artifacts. + +# shot_02, promise, 3.0-8.0s, MS eye-level push +Medium shot, camera at eye level, pushing slowly toward the subject. A founder, mid-thirties, salt-and-pepper hair, navy crewneck, leaning back from the laptop with his shoulders dropping and a small almost-smile. The desk surface is visible in the lower frame. Minimalist home office, white walls, oak desk, single houseplant. Soft natural side-light from a large window camera-left, warm afternoon golden hour. Shot at f/2.0, shallow depth of field, 50mm prime. Warm filmic, muted teal shadows, slight grain. Calm, considered mood. Photorealistic, natural skin texture, no AI artifacts. diff --git a/skills/visual-asset-critic/examples/worked-run/prompts/round-2/revised-flux.txt b/skills/visual-asset-critic/examples/worked-run/prompts/round-2/revised-flux.txt new file mode 100644 index 0000000..4390ff9 --- /dev/null +++ b/skills/visual-asset-critic/examples/worked-run/prompts/round-2/revised-flux.txt @@ -0,0 +1,13 @@ +# Storyboard: Worked Run, Two Shots +# Generator: flux +# Model: 2 Pro +# Aspect: 9:16 +# Brand-lock: brand-lock.snapshot.md +# Run: 20260730T142300Z-9f2c1ab4 +# Round: 2 +# Revision of round 1. Shots not listed here already passed. + +# shot_02, promise, 3.0-8.0s, MCU eye-level push, revision (was REVISE) +# fix [Shot Spec, major]: framing MS -> MCU, subject read too small in frame +# fix [Technical, re-roll]: left hand was malformed, same prompt, pick a clean sample +Medium close-up framing, camera at eye level, pushing slowly toward the subject. A founder, mid-thirties, salt-and-pepper hair, navy crewneck, leaning back from the laptop with his shoulders dropping and a small almost-smile. His hands rest loosely and are fully visible. Minimalist home office, white walls, oak desk, single houseplant. Soft natural side-light from a large window camera-left, warm afternoon golden hour. Shot at f/2.0, shallow depth of field, 50mm prime. Warm filmic, muted teal shadows, slight grain. Calm, considered mood. Photorealistic, natural skin texture, anatomically correct hands, no AI artifacts. diff --git a/skills/visual-asset-critic/examples/worked-run/run.json b/skills/visual-asset-critic/examples/worked-run/run.json new file mode 100644 index 0000000..c55369a --- /dev/null +++ b/skills/visual-asset-critic/examples/worked-run/run.json @@ -0,0 +1,64 @@ +{ + "version": "1.0", + "run_id": "20260730T142300Z-9f2c1ab4", + "created_at": "2026-07-30T14:23:00Z", + "shotkit_version": "3.0.0", + "operator": "worked-example", + "project": { + "title": "Worked Run, Two Shots", + "duration_s": 8, + "aspect": "9:16", + "framework": "custom" + }, + "inputs": { + "shots_ref": "shots.json", + "shots_sha256": "24e5babc987ea595076aba90316bad23bdcf64a4f1e80ee0d4bef2fd29b55095", + "text_overlays_ref": "text-overlays.json", + "text_overlays_sha256": "5071e111d5a64c3dcfb3548061cabe7e00ec7505ce01314e85a0cd8328f0b89d", + "brand_lock_ref": "brand-lock.snapshot.md", + "brand_lock_sha256": "f364bc71f68d16187d0721fbc092450174545e247f2f563a4c45686b4a877f50", + "brand_lock_source": "brand-packs/whystrohm.md", + "brand_lock_configured": true + }, + "generators": [ + { + "id": "flux", + "model_version": "2 Pro", + "capabilities_sha256": "948776594cafead0455c9b3f12e47bdd58f27f1f9c5d49a93d908c0b08377ece" + } + ], + "rounds": [ + { + "round": 1, + "started_at": "2026-07-30T14:23:00Z", + "reason": "initial", + "prompts": [ + { + "generator": "flux", + "ref": "prompts/round-1/flux.txt", + "sha256": "a0203e0c49cafd75d8fcb86997ba619c5ac82700dc4ab430812a9929aca89328", + "shot_ids": [ + "shot_01", + "shot_02" + ] + } + ] + }, + { + "round": 2, + "started_at": "2026-07-30T15:02:00Z", + "reason": "shot_02 came back REVISE on Shot Spec framing and a Technical hand defect", + "prompts": [ + { + "generator": "flux", + "ref": "prompts/round-2/revised-flux.txt", + "sha256": "f9a807549ff558950dd8a01b8525f89f7e4d480e43d28273ec0182249de83cd7", + "shot_ids": [ + "shot_02" + ] + } + ], + "post_only_shots": [] + } + ] +} diff --git a/skills/visual-asset-critic/examples/worked-run/shots.json b/skills/visual-asset-critic/examples/worked-run/shots.json new file mode 100644 index 0000000..83ad247 --- /dev/null +++ b/skills/visual-asset-critic/examples/worked-run/shots.json @@ -0,0 +1,50 @@ +{ + "version": "1.2", + "project": { + "title": "Worked Run, Two Shots", + "duration_s": 8, + "aspect": "9:16", + "framework": "custom" + }, + "brand_lock_ref": "brand-lock.snapshot.md", + "series_lock": { + "character": "founder, mid-thirties, salt-and-pepper hair, navy crewneck", + "environment": "minimalist home office, white walls, oak desk, single houseplant", + "lighting": "soft natural side-light from a large window camera-left, warm afternoon golden hour", + "color_grade": "warm filmic, muted teal shadows, slight grain" + }, + "shots": [ + { + "id": "shot_01", + "beat": "hook", + "start": 0.0, + "end": 3.0, + "framing": "MCU", + "angle": "eye-level", + "motion": "static", + "depth_of_field": "shallow", + "subject": "founder seated at the desk, hands still on the laptop, looking slightly off-camera, held frame-left with the right two thirds open", + "environment_ref": "series_lock.environment", + "lighting_ref": "series_lock.lighting", + "on_screen_text": "text_01", + "vo": null, + "rationale": "Opens on stillness so the first overlay lands in silence; frame-left placement reserves the space the hook needs." + }, + { + "id": "shot_02", + "beat": "promise", + "start": 3.0, + "end": 8.0, + "framing": "MS", + "angle": "eye-level", + "motion": "push", + "depth_of_field": "shallow", + "subject": "founder leaning back from the laptop, shoulders dropping, a small almost-smile, desk surface visible", + "environment_ref": "series_lock.environment", + "lighting_ref": "series_lock.lighting", + "on_screen_text": "text_02", + "vo": null, + "rationale": "The slow push and the released posture carry the payoff without a line of voiceover." + } + ] +} diff --git a/skills/visual-asset-critic/examples/worked-run/text-overlays.json b/skills/visual-asset-critic/examples/worked-run/text-overlays.json new file mode 100644 index 0000000..b738502 --- /dev/null +++ b/skills/visual-asset-critic/examples/worked-run/text-overlays.json @@ -0,0 +1,29 @@ +{ + "version": "1.0", + "overlays": [ + { + "id": "text_01", + "shot_id": "shot_01", + "content": "You are the bottleneck.", + "font": "Inter Black 900", + "size": "display", + "weight": "black", + "color": "#2A2A32", + "position": "right-third", + "enter": { "at": 0.3, "animation": "fade-in" }, + "exit": { "at": 2.8, "animation": "hard-cut" } + }, + { + "id": "text_02", + "shot_id": "shot_02", + "content": "Build the system instead.", + "font": "Inter Black 900", + "size": "headline", + "weight": "black", + "color": "#D94F3A", + "position": "lower-third", + "enter": { "at": 4.0, "animation": "slide-up" }, + "exit": { "at": 8.0, "animation": "fade-out" } + } + ] +} diff --git a/skills/visual-asset-critic/references/critique-rubric.md b/skills/visual-asset-critic/references/critique-rubric.md index 645b529..688239f 100644 --- a/skills/visual-asset-critic/references/critique-rubric.md +++ b/skills/visual-asset-critic/references/critique-rubric.md @@ -9,7 +9,7 @@ The structured layer-by-layer pass. Use this as the checklist when reviewing a g | Palette | Image colors come from brand-lock palette | Colors close but slightly off | Colors not in palette at all | | Mood adjectives | Image reads as the brand mood | Mood reads as adjacent (e.g. "calm" vs "neutral") | Image reads as a different mood (e.g. "energetic" when brief was "calm") | | "Never" list | None of the items in the never list are present | One item in the never list shows softly | Multiple never-list violations | -| Aspect ratio | Matches `project.aspect` |, | Wrong aspect | +| Aspect ratio | Matches `project.aspect` | Within a crop of the spec | Wrong aspect | Hard fail on Brand Lock = REJECT or REVISE depending on whether prompt fix exists. @@ -69,19 +69,38 @@ Technical hard fails are almost always **re-roll required**. The prompt was prob Continuity hard fails break the storyboard. REVISE with verbatim-anchor checks on the prompt. +## From pass/fail to severity + +The tables above grade each check as pass, soft fail, or hard fail. The JSON critique +records a severity instead, and the verdict is derived from those severities. Map them +this way, and only this way: + +| Rubric result | Severity | Why | +|---|---|---| +| Soft fail on any layer | `minor` | Post can absorb it. `fix_type` is usually `post-level` | +| Hard fail **with** a clear fix path | `major` | A prompt change or a re-roll resolves it | +| Hard fail on Brand Lock or Series Lock **with no** fix path | `blocking` | Nothing downstream recovers this | +| Any defect that makes the asset unusable | `blocking` | Same, regardless of layer | + ## Aggregating verdict -Count hard fails across layers: +The verdict follows from the severities. It is not a separate judgement: -| Hard fails | Verdict | +| Severities present | Verdict | |---|---| -| 0 | ACCEPT | -| 1 (with clear prompt fix) | REVISE | -| 1 (Brand Lock or Series Lock with no prompt fix) | REJECT | -| 2 | REVISE if both have prompt fixes; REJECT otherwise | -| 3+ | REJECT | - -Soft fails are noted but don't change verdict unless they cluster (3+ soft fails = REVISE). +| any `blocking` | REJECT | +| three or more `major` | REJECT | +| one or two `major` | REVISE | +| only `minor`, or none | ACCEPT (with post notes) | + +`tools/validate_critique.py` enforces exactly this table, so a critique that disagrees with +it fails rather than shipping. Earlier versions of this file counted hard fails and called +3+ a REJECT while the skill said "escalate at your discretion." Those two rules disagreed, +and the disagreement is the reason the threshold is now a number. + +Soft fails are noted but don't change the verdict on their own. If you have three or more of +them, look again: a cluster of soft fails usually means one of them is really a hard fail +you talked yourself out of. ## Speed bumps to remember diff --git a/skills/visual-asset-critic/templates/critique.schema.json b/skills/visual-asset-critic/templates/critique.schema.json index 534f602..032cd9f 100644 --- a/skills/visual-asset-critic/templates/critique.schema.json +++ b/skills/visual-asset-critic/templates/critique.schema.json @@ -2,26 +2,115 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://whystrohm.com/schemas/shotkit/critique.schema.json", "title": "Critique", - "description": "Machine-readable verdict for a generated image reviewed against its shot spec and brand-lock. The structured sibling of the markdown critique. Lets the QA loop gate an automated pipeline instead of relying on prose.", + "description": "Machine-readable verdict for one generated frame reviewed against its shot spec and brand-lock. The structured sibling of the markdown critique. Version 1.1 adds run provenance: the frame, prompt, and brand-lock are identified by content hash as well as by path, so a verdict can be proven to describe the bytes that were actually reviewed. Every provenance field is required at 1.1 and explicitly nullable, so an absent input is a recorded decision rather than an omission.", "type": "object", "required": ["version", "verdict", "confidence", "issues"], "additionalProperties": false, "properties": { - "version": { "type": "string", "const": "1.0" }, + "version": { + "type": "string", + "enum": ["1.0", "1.1"], + "description": "1.1 adds run_id, round, created_at, the *_sha256 hashes, prompt_ref, generator, model_version, seed, and the meta passthrough. 1.0 documents remain valid and are treated as legacy: they carry a verdict with no proof of what it reviewed." + }, + "meta": { + "type": "object", + "description": "Forward-compat passthrough for tooling that needs to annotate a critique without a schema bump. Ignored by core skills.", + "additionalProperties": true + }, + "run_id": { + "oneOf": [ + { "type": "null" }, + { "type": "string", "pattern": "^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{8}$" } + ], + "description": "The run this critique belongs to. Matches run.json:run_id. Format: UTC compact timestamp, a dash, then 8 hex characters." + }, + "round": { + "oneOf": [ + { "type": "null" }, + { "type": "integer", "minimum": 1 } + ], + "description": "Revision round, 1-indexed. Round 1 is the first generation pass." + }, + "created_at": { + "oneOf": [ + { "type": "null" }, + { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$" + } + ], + "description": "UTC ISO-8601 instant the critique was written. Second precision, trailing Z required." + }, "shot_id": { "oneOf": [ { "type": "null" }, { "type": "string", "pattern": "^shot_[0-9]{2,3}$" } ], - "description": "Shot this image was generated for. Null when critiquing a standalone image with no storyboard." + "description": "Shot this frame was generated for. Null when critiquing a standalone image with no storyboard." }, "brand_lock_ref": { - "type": "string", - "description": "Relative path to the brand-lock.snapshot.md the image was judged against, if any." + "oneOf": [ + { "type": "null" }, + { "type": "string", "minLength": 1 } + ], + "description": "Path to the brand-lock.snapshot.md the frame was judged against, relative to the output directory root. Null when no brand-lock was available, which caps confidence at MEDIUM." + }, + "brand_lock_sha256": { + "oneOf": [ + { "type": "null" }, + { "type": "string", "pattern": "^[0-9a-f]{64}$" } + ], + "description": "SHA-256 of the brand-lock file as read. Null only when brand_lock_ref is null." }, "image_ref": { - "type": "string", - "description": "Relative path to the image that was critiqued." + "oneOf": [ + { "type": "null" }, + { "type": "string", "minLength": 1 } + ], + "description": "Path to the frame that was critiqued, relative to the output directory root." + }, + "image_sha256": { + "oneOf": [ + { "type": "null" }, + { "type": "string", "pattern": "^[0-9a-f]{64}$" } + ], + "description": "SHA-256 of the frame bytes as reviewed. This is what makes the verdict falsifiable: if the file at image_ref no longer hashes to this value, the verdict does not describe it." + }, + "prompt_ref": { + "oneOf": [ + { "type": "null" }, + { "type": "string", "minLength": 1 } + ], + "description": "Path to the prompt file the frame was generated from, relative to the output directory root. Null when the prompt is unknown, which caps confidence at MEDIUM." + }, + "prompt_sha256": { + "oneOf": [ + { "type": "null" }, + { "type": "string", "pattern": "^[0-9a-f]{64}$" } + ], + "description": "SHA-256 of the prompt file. Null only when prompt_ref is null." + }, + "generator": { + "oneOf": [ + { "type": "null" }, + { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$" } + ], + "description": "Generator id that produced the frame. Must match an id in visual-prompt-forge/adapters/_capabilities.json. Null when unknown." + }, + "model_version": { + "oneOf": [ + { "type": "null" }, + { "type": "string", "minLength": 1 } + ], + "description": "Model version string as of generation, copied from the capability matrix entry. The matrix records what is current; this records what ran." + }, + "seed": { + "oneOf": [ + { "type": "null" }, + { "type": "integer" }, + { "type": "string", "minLength": 1 } + ], + "description": "Generation seed if the generator exposes one. Null when it does not." }, "verdict": { "type": "string", @@ -50,7 +139,7 @@ "severity": { "type": "string", "enum": ["minor", "major", "blocking"], - "description": "blocking on any issue forces REJECT. A major issue forces at most REVISE." + "description": "Any blocking issue forces REJECT. One or two major issues cap the verdict at REVISE. Three or more major issues force REJECT. Enforced by tools/validate_critique.py, not by this schema." }, "note": { "type": "string", "minLength": 1 }, "fix_type": { @@ -61,5 +150,34 @@ } } } - } + }, + "allOf": [ + { + "if": { + "properties": { "version": { "const": "1.1" } }, + "required": ["version"] + }, + "then": { + "required": [ + "version", + "run_id", + "round", + "created_at", + "shot_id", + "brand_lock_ref", + "brand_lock_sha256", + "image_ref", + "image_sha256", + "prompt_ref", + "prompt_sha256", + "generator", + "model_version", + "seed", + "verdict", + "confidence", + "issues" + ] + } + } + ] } diff --git a/skills/visual-prompt-forge/SKILL.md b/skills/visual-prompt-forge/SKILL.md index d32b781..99ebc9a 100644 --- a/skills/visual-prompt-forge/SKILL.md +++ b/skills/visual-prompt-forge/SKILL.md @@ -22,10 +22,11 @@ If the user wants to build a storyboard from scratch (no shots.json yet), use `s ## What you produce -For a given `shots.json` and a list of target generators, produce one file per generator: +For a given `shots.json` and a list of target generators, produce one file per generator, +inside a directory named for the round: ``` -output/prompts/ +output/prompts/round-1/ ├── midjourney.txt # If targeted ├── flux.txt ├── ideogram.txt @@ -38,6 +39,11 @@ output/prompts/ └── hailuo.txt # Motion, budget iteration ``` +Round 1 is the first pass. Revision mode writes `output/prompts/round-2/`, and so on. +The round in the path is not decoration: prompt files used to be written to one fixed +path per generator, so round 2 destroyed round 1 and the prompt that actually produced +most of the surviving frames was gone. + Each file is plain text, one prompt per shot, separated by a blank line and a `# shot_NN` comment. Designed for copy-paste workflows, drop into the generator's UI or pipe into an API. ## The five-layer prompt anatomy @@ -62,7 +68,19 @@ You need: - `brand-lock.snapshot.md` (required), referenced from shots.json - Target generators (required), ask if not specified -If brand-lock is missing or shots.json doesn't validate against `../storyboard-architect/templates/shots.schema.json`, stop and tell the user. Don't try to forge prompts from incomplete data. +Validate before composing: + +```bash +python tools/validate_shots.py output/ +``` + +If the brand-lock is missing or `shots.json` does not validate, stop and tell the user. +Don't try to forge prompts from incomplete data. + +If `tools/` is not on hand (a Claude.ai upload, or a single-skill install), read the +schema from `../storyboard-architect/templates/shots.schema.json` and check by hand. That +relative path only resolves when the skills sit side by side; when they don't, ask the +user for the schema rather than composing from memory of it. ### Step 2. Pick the adapters @@ -81,7 +99,18 @@ For each target generator, read the matching adapter file: Each adapter file documents the prompting style, parameter syntax, and known pitfalls for that generator. You **must** read the adapter before writing prompts for it. Don't guess from training data, image-gen syntax has churned multiple times. -**`adapters/_capabilities.json` is the single source of truth for per-generator limits** (`max_prompt_words`, `supports_text_render`, `supports_motion`, `aspect_param`, and so on). Read it once at the start and respect those values when composing, do not exceed a generator's `max_prompt_words`, and do not target motion on a stills-only generator. Where a number in an adapter `.md` and in `_capabilities.json` disagree, **the JSON wins**; the `.md` files are how-to-prompt guidance, the JSON owns the numbers. +**`adapters/_capabilities.json` is the single source of truth for per-generator limits** (`max_prompt_words`, `supports_text_render`, `supports_motion`, `aspect_param`, and so on). Read it once at the start and respect those values when composing, and do not target motion on a stills-only generator. + +`max_prompt_words` is a ceiling. The range in an adapter `.md` is the recommended target +and always sits inside that ceiling, so a `.md` saying "40 to 70 words" under a ceiling of +120 is guidance, not a conflict. Where a fact in a `.md` and a fact in the JSON genuinely +disagree, **the JSON wins**. + +That rule is now enforced rather than trusted. `tools/validate_capabilities.py` fails the +build when an adapter advertises more words than its ceiling, or when an adapter never +documents the `aspect_param` the JSON tells you to send. The second check exists because +nano-banana's matrix entry said `aspect_ratio` while its adapter said the API expects +`aspectRatio`; the precedence rule meant the wrong one won, silently, on every prompt. ### Step 3. Compose per shot @@ -96,25 +125,46 @@ For each shot in `shots.json`, for each target generator: ### Step 4. Write output files -One file per generator. Format: +One file per generator, into `output/prompts/round-{N}/`. Format: ``` # Storyboard: {project title} # Generator: midjourney +# Model: {model_version from _capabilities.json} # Aspect: 9:16 # Brand-lock: brand-lock.snapshot.md -# Generated: {timestamp} +# Run: {run_id from run.json} +# Round: 1 -# shot_01, hook, 0.0-2.0s. MCU eye-level static +# shot_01, hook, 0.0-2.0s, MCU eye-level static {the prompt} -# shot_02, pain, 2.0-6.0s. MS eye-level push +# shot_02, pain, 2.0-6.0s, MS eye-level push {the prompt} ... ``` -The `#` lines are comments; the user copies just the prompt body. The header gives them context if they paste the file into a script. +The `#` lines are comments; the user copies just the prompt body. `tools/copy-prompt.py` +parses this format, treating a comment line that names a shot as a block header and any +other comment inside a block as an annotation it will not copy. + +Record the run and round in the header, not a wall-clock "Generated" line. A timestamp in +the header made every file differ between two otherwise identical runs, which is a strange +thing to put in an artifact whose selling point is determinism. + +### Step 4b. Append the round to run.json + +After writing the files, append an entry to `run.json`'s `rounds` array: the round number, +`started_at`, a `reason`, and for each file its generator, path, SHA-256, and the shot ids +it covers. + +```bash +shasum -a 256 output/prompts/round-1/*.txt +``` + +This is the only writeable part of `run.json`. Everything else was fixed when the +architect wrote it. ### Step 5. Hand off @@ -125,7 +175,8 @@ Tell the user where the files are. Offer the next step: For paste-into-generator workflows, the user can pipe individual shots to the clipboard with the bundled helper: ```bash -python tools/copy-prompt.py output/prompts/midjourney.txt +python tools/copy-prompt.py output/prompts/round-1/midjourney.txt +python tools/copy-prompt.py output/prompts/round-2/revised-midjourney.txt --shot shot_03 ``` This is optional. The `.txt` files are also directly readable, and the user can copy any block by hand. The helper exists for the case where the operator is bouncing between the terminal and a generator UI repeatedly. @@ -136,30 +187,66 @@ This is what `visual-asset-critic`'s structured output is for. When the user han ### Trigger -The user says "apply the critique", "revise the failed shots", "re-roll what didn't pass", or hands over `critique.json` alongside `shots.json` and the original prompt files. +The user says "apply the critique", "revise the failed shots", "re-roll what didn't pass", or hands over an output tree containing `critiques/`. ### Workflow -1. Read each `critique.json`. Each one is one shot's verdict (`shot_id`, `verdict`, `issues[]`). Skip any with `verdict: ACCEPT`, those are done. -2. For every non-ACCEPT shot, walk its `issues[]` and branch on `fix_type`: - - **`prompt-level`**, recompose that shot's prompt with the change in `fix` applied (e.g. add the missing series_lock anchor). Re-emit it. - - **`re-roll`**, keep the prompt identical; the generation was just a bad sample. Re-emit it with a `# re-roll 2-3x, pick the cleanest` note. - - **`post-level`**, do **not** re-emit. The fix happens in compositing, not in a new generation. Note it in the handoff instead. -3. A shot that has only `post-level` issues needs no new prompt, leave it out of the revised file. -4. Re-apply the five-layer anatomy and the same adapter as the original run. Determinism still holds: same inputs plus the same critique produce the same revised prompt. +1. Read every critique under `output/critiques/round-{N}/`, where N is the highest round + present. Each file is one shot's verdict (`shot_id`, `verdict`, `issues[]`). +2. Skip any with `verdict: ACCEPT`. Those are done. +3. **Stop on `REJECT`.** A REJECT means the critic found a blocking issue, or three or more + major ones: a failure with no clear fix path. Re-emitting a prompt for it pretends + otherwise. List the rejected shots, say what the critic said about them, and ask the + user how to proceed. Common answers are a change to the shot spec, a change to the + brand-lock, or a different generator, and all three are decisions above this skill's + pay grade. +4. For every `REVISE` shot, walk its `issues[]` and branch on `fix_type`: + - **`prompt-level`**, recompose that shot's prompt with the change in `fix` applied + (e.g. add the missing series_lock anchor). Re-emit it. + - **`re-roll`**, keep the prompt identical; the generation was just a bad sample. + Re-emit it with a `# fix [Technical, re-roll]` annotation saying to take 2-3 samples + and pick the cleanest. + - **`post-level`**, do **not** re-emit. The fix happens in compositing, not a new + generation. +5. A shot whose issues are all `post-level` needs no new prompt. Leave it out of the + revised file and add its id to `post_only_shots` on the new round entry in `run.json`. + Saying it in chat is not recording it: that obligation has to exist on disk or the + compositing step is a memory. +6. Re-apply the five-layer anatomy and the same adapter as the original run. ### Output -Write `output/prompts/revised-{generator}.txt` containing only the revised shots. Annotate each with what changed and why, citing the issue: +Write `output/prompts/round-{N+1}/revised-{generator}.txt` containing only the revised +shots, then append the round to `run.json`. Annotate each shot with what changed and why, +citing the issue: ``` -# Revision of shot_03 (was REVISE) +# shot_03, reframe, 11.0-16.0s, MCU eye-level push, revision (was REVISE) # fix [Series Lock, major]: added 'salt-and-pepper hair' to the character anchor (was missing) # fix [Shot Spec, minor]: medium shot -> medium close-up {the revised prompt} ``` -Tell the user which shots were revised, which need only post work, and which were already ACCEPT. Then they generate the revised shots and run `visual-asset-critic` again, the loop runs until every shot is ACCEPT or the user calls it. +The block header leads with the shot id, same as a full pass. `tools/copy-prompt.py` +identifies a block by the shot id near the start of the line, so a header that led with +"Revision of" produced a file the paste helper could not read at all, which is +inconvenient in the one file the operator is about to paste from repeatedly. + +Tell the user which shots were revised, which need only post work, which were rejected, +and which were already ACCEPT. Then they generate the revised shots and run +`visual-asset-critic` again. + +### Determinism, and its limits + +Given the same `shots.json`, the same brand-lock, and the same critique, this should +produce the same revised prompt. Nothing in the file format fights that any more: no +wall-clock stamps, no random ordering. + +What the format cannot guarantee is the judgement in between. Applying a `fix` field means +reading a sentence of English and editing prose, so hold yourself to the narrowest edit +that satisfies the fix and leave every other layer byte-identical. If you find yourself +rewriting a prompt the critique did not ask you to touch, stop: that is drift, and it will +read as a mystery six shots later. ## Hard rules @@ -175,7 +262,33 @@ Colors come from the series_lock color_grade and the brand_lock palette. They ge ### Rule 3. Series_lock anchors are verbatim -The series_lock environment / lighting / character strings flow into every prompt **verbatim**. This is what produces visual consistency across shots. If you paraphrase or vary, shots stop matching each other. +The series_lock `environment`, `lighting`, and `color_grade` strings flow into every +prompt **verbatim**. This is what produces visual consistency across shots. If you +paraphrase or vary, shots stop matching each other. + +Verbatim means the whole string, unedited. Not "the same idea in better prose". The +failure mode is specific and easy to walk into: you are writing fluent descriptive +English, `environment` is "minimalist home office, white walls, oak desk, single +houseplant", and it reads more naturally as "a minimal home office with white walls and +an oak desk". That is drift. The frames stop matching, and it stays invisible until you +line up six shots and see six different rooms. + +Capitalising the first letter to start a sentence is fine, because the check is +case-insensitive. "Minimalist home office, white walls, oak desk, single houseplant." +satisfies the rule and reads like a sentence. Nothing else may change: no reordering, no +dropped clause, no synonym, no inserted adjective. + +`character` is a warning rather than an error, because a shot with no person in it can +legitimately leave it out. When the shot has a person, it is verbatim too. + +This rule is enforced now, not trusted: + +```bash +python tools/validate_prompts.py output/ +``` + +It exists because a careful authoring pass over a seven-shot storyboard drifted on these +anchors in all seven shots while every other validator stayed green. ### Rule 4. Adapters are the source of truth on syntax @@ -210,16 +323,41 @@ One file per generator. Read these on demand, only for the generators being targ ## Quality bar -Before declaring done, verify: +Run the validator. Do not eyeball this list. + +```bash +python tools/validate_prompts.py output/ +python tools/copy-prompt.py output/prompts/round-1/flux.txt --list +``` + +`validate_prompts.py` checks the mechanical half, which is everything that used to be a +checkbox here: + +- the header names the storyboard, generator, aspect, brand-lock, run, and round +- the generator is a real id in `_capabilities.json` +- the header aspect matches `project.aspect` +- no prompt exceeds that generator's `max_prompt_words` ceiling +- a full pass covers every shot; a revision file covers a subset of real shots +- no shot block is duplicated, and none has a header with no body +- **Rule 1**: no on-screen text copy appears in any prompt +- **Rule 3**: `environment`, `lighting`, and `color_grade` appear verbatim, with the + character anchor as a warning + +What it cannot check, and you still have to: - [ ] One output file per requested generator -- [ ] Every shot in shots.json appears in every output file -- [ ] No on-screen text content appears in any image prompt (except Ideogram-with-override) -- [ ] Series_lock strings appear verbatim in every prompt -- [ ] Aspect ratio matches `project.aspect` -- [ ] Generator-specific parameters present (--ar for Midjourney, etc.) -- [ ] Header comment block at top of each file +- [ ] On a revision pass, the shots you left out are accounted for as ACCEPT, post-only, + or rejected, and you said which is which +- [ ] Generator-specific parameters are right for the surface the user will paste into +- [ ] The round is appended to `run.json` with a hash per prompt file +- [ ] The prompt actually describes the shot, which is the part no validator will ever do ## Examples -`examples/one-shot-all-adapters/` contains a single shot rendered to all seven adapters side-by-side. Use this to calibrate output quality. +`examples/one-shot-all-adapters/` contains a single shot rendered across seven adapters +side-by-side: the six stills generators plus Kling. Use it to calibrate output quality. +The three remaining motion adapters (Veo, Seedance, Hailuo) have no worked example yet; +their `.md` files carry a worked prompt each in the meantime. + +For a complete two-round output tree, prompts and frames and critiques together, see +`../visual-asset-critic/examples/worked-run/`. diff --git a/skills/visual-prompt-forge/adapters/_capabilities.json b/skills/visual-prompt-forge/adapters/_capabilities.json index eac029b..295dd23 100644 --- a/skills/visual-prompt-forge/adapters/_capabilities.json +++ b/skills/visual-prompt-forge/adapters/_capabilities.json @@ -1,7 +1,7 @@ { - "$schema_note": "Capability matrix for visual-prompt-forge adapters. Generator models churn monthly. When you re-verify an adapter, bump its model_version and last_verified date here, then reconcile the prose adapter file. This file is the single source of truth for 'what each generator can do'; the .md files are the how-to-prompt guidance.", - "version": "1.0", - "matrix_last_reviewed": "2026-06-18", + "$schema_note": "Capability matrix for visual-prompt-forge adapters. Generator models churn monthly. When you re-verify an adapter, bump its model_version and last_verified date here, then reconcile the prose adapter file. This file is the single source of truth for 'what each generator can do'; the .md files are the how-to-prompt guidance. max_prompt_words is a ceiling, not a target: an adapter .md may recommend a narrower range, but it may never advertise more than the ceiling. tools/validate_capabilities.py enforces that relationship and checks that each .md documents the aspect_param named here.", + "version": "1.1", + "matrix_last_reviewed": "2026-07-30", "generators": [ { "id": "midjourney", @@ -9,7 +9,7 @@ "model_version": "v7", "last_verified": "2026-05-08", "prompt_style": "keyword", - "max_prompt_words": 80, + "max_prompt_words": 100, "supports_text_render": false, "supports_motion": false, "supports_character_ref": true, @@ -17,7 +17,7 @@ "supports_negative_prompt": true, "aspect_param": "--ar", "api_access": "limited", - "notes": "Short high-signal phrases. Over 100 words underperforms. --cref/--sref for series consistency." + "notes": "Short high-signal phrases. 100 words is the ceiling, past which signal drops off; the adapter recommends 40 to 80. --cref/--sref for series consistency." }, { "id": "flux", @@ -57,7 +57,7 @@ "model_version": "1.5/2", "last_verified": "2026-05-08", "prompt_style": "paragraph", - "max_prompt_words": 250, + "max_prompt_words": 300, "supports_text_render": true, "supports_motion": false, "supports_character_ref": true, @@ -79,9 +79,9 @@ "supports_character_ref": true, "supports_style_ref": true, "supports_negative_prompt": false, - "aspect_param": "aspect_ratio", + "aspect_param": "aspectRatio", "api_access": "full", - "notes": "Fast, cheap, strong editing/consistency via reference images. Good for high-volume iteration." + "notes": "Fast, cheap, strong editing/consistency via reference images. Good for high-volume iteration. Parameter name is camelCase here, unlike the fal.ai models; it ignores --ar." }, { "id": "seedream", diff --git a/skills/visual-prompt-forge/adapters/flux.md b/skills/visual-prompt-forge/adapters/flux.md index 321f502..2c1cc16 100644 --- a/skills/visual-prompt-forge/adapters/flux.md +++ b/skills/visual-prompt-forge/adapters/flux.md @@ -1,6 +1,6 @@ # Adapter: Flux (Flux 2 Pro / Flux 1.1 Pro / Flux Dev) -> Capability data (length limits, text/motion support, aspect param) is canonical in `_capabilities.json`. This file is the how-to-prompt guidance. If a number here and in `_capabilities.json` disagree, the JSON wins. +> Capability data (length ceiling, text/motion support, aspect param) is canonical in `_capabilities.json`. This file is the how-to-prompt guidance. `max_prompt_words` there is a ceiling; the range below is the recommended target and has to sit inside it. Where a fact here and a fact in the JSON disagree, the JSON wins, and `tools/validate_capabilities.py` fails the build instead of letting the two drift. Flux rewards natural-language prompts that read like a competent photographer briefing themselves. It interprets full sentences accurately and handles spatial relationships better than Midjourney. It's the photorealism leader as of Q2 2026, choose Flux over Midjourney when the brief calls for "looks like a real photograph" rather than "looks designed." diff --git a/skills/visual-prompt-forge/adapters/gpt-image.md b/skills/visual-prompt-forge/adapters/gpt-image.md index 4f3c075..bab5d99 100644 --- a/skills/visual-prompt-forge/adapters/gpt-image.md +++ b/skills/visual-prompt-forge/adapters/gpt-image.md @@ -1,6 +1,6 @@ # Adapter: GPT Image (1.5 / 2) -> Capability data (length limits, text/motion support, aspect param) is canonical in `_capabilities.json`. This file is the how-to-prompt guidance. If a number here and in `_capabilities.json` disagree, the JSON wins. +> Capability data (length ceiling, text/motion support, aspect param) is canonical in `_capabilities.json`. This file is the how-to-prompt guidance. `max_prompt_words` there is a ceiling; the range below is the recommended target and has to sit inside it. Where a fact here and a fact in the JSON disagree, the JSON wins, and `tools/validate_capabilities.py` fails the build instead of letting the two drift. GPT Image rewards paragraph-form prompts with explicit spatial reasoning and complex composition descriptions. It interprets relational language ("to the left of", "behind", "in the foreground") more accurately than any other generator. Choose GPT Image when the brief requires precise scene composition, multiple objects, or accurate text rendering. diff --git a/skills/visual-prompt-forge/adapters/hailuo.md b/skills/visual-prompt-forge/adapters/hailuo.md index 42139f3..b5cc234 100644 --- a/skills/visual-prompt-forge/adapters/hailuo.md +++ b/skills/visual-prompt-forge/adapters/hailuo.md @@ -1,6 +1,6 @@ # Adapter: Hailuo 02 Pro (motion-aware video, budget iteration) -> Capability data (length limits, motion/text support, aspect param) is canonical in `_capabilities.json`. This file is the how-to-prompt guidance. If a number here and in `_capabilities.json` disagree, the JSON wins. +> Capability data (length ceiling, text/motion support, aspect param) is canonical in `_capabilities.json`. This file is the how-to-prompt guidance. `max_prompt_words` there is a ceiling; the range below is the recommended target and has to sit inside it. Where a fact here and a fact in the JSON disagree, the JSON wins, and `tools/validate_capabilities.py` fails the build instead of letting the two drift. Hailuo 02 Pro is the cheap, fast iteration model. Strong motion response and prompt-following for the price, on fal.ai. Use it to **find the shot**, block out camera move, framing, and timing across many quick drafts, then re-generate the keeper on Kling (motion finals), Veo (dialogue), or Seedance (sequences). Treat Hailuo output as a working draft, not a final asset. diff --git a/skills/visual-prompt-forge/adapters/ideogram.md b/skills/visual-prompt-forge/adapters/ideogram.md index cd0a88a..772d1dc 100644 --- a/skills/visual-prompt-forge/adapters/ideogram.md +++ b/skills/visual-prompt-forge/adapters/ideogram.md @@ -1,6 +1,6 @@ # Adapter: Ideogram (v3) -> Capability data (length limits, text/motion support, aspect param) is canonical in `_capabilities.json`. This file is the how-to-prompt guidance. If a number here and in `_capabilities.json` disagree, the JSON wins. +> Capability data (length ceiling, text/motion support, aspect param) is canonical in `_capabilities.json`. This file is the how-to-prompt guidance. `max_prompt_words` there is a ceiling; the range below is the recommended target and has to sit inside it. Where a fact here and a fact in the JSON disagree, the JSON wins, and `tools/validate_capabilities.py` fails the build instead of letting the two drift. Ideogram is the only generator that reliably renders text inside images. Use it for cases where text-as-image is the deliverable, posters, branded social tiles, signage, packaging mockups. For everything else, default to Flux or Midjourney and composite text separately. @@ -45,6 +45,12 @@ Document in comment block: {prompt} ``` +## Length + +Ideogram handles **60–120 words** comfortably. Text-bearing prompts run shorter still: +the more scene description you stack around a text instruction, the more likely the +render drops or garbles the copy. + ## Composition pattern (Mode 1, composited) Same as Flux pattern, no text in prompt: diff --git a/skills/visual-prompt-forge/adapters/kling.md b/skills/visual-prompt-forge/adapters/kling.md index c233b2a..710bc16 100644 --- a/skills/visual-prompt-forge/adapters/kling.md +++ b/skills/visual-prompt-forge/adapters/kling.md @@ -1,6 +1,6 @@ # Adapter: Kling 3.0 (motion-aware video) -> Capability data (length limits, motion/text support, aspect param) is canonical in `_capabilities.json`. This file is the how-to-prompt guidance. If a number here and in `_capabilities.json` disagree, the JSON wins. +> Capability data (length ceiling, text/motion support, aspect param) is canonical in `_capabilities.json`. This file is the how-to-prompt guidance. `max_prompt_words` there is a ceiling; the range below is the recommended target and has to sit inside it. Where a fact here and a fact in the JSON disagree, the JSON wins, and `tools/validate_capabilities.py` fails the build instead of letting the two drift. Kling 3.0 generates short video clips, not still frames. It is the default motion model: the best camera-motion realism per dollar on fal.ai, and the strongest image-to-video of the four. Reach for it first. Escalate to Veo (dialogue/lipsync), Seedance (multi-shot), or Hailuo (cheap iteration) only when the shot needs what Kling does not do. @@ -28,7 +28,7 @@ Camera motion goes **first**. This is the inverse of image generators where came Document parameters as a comment line above each prompt: ``` -# shot_01. Kling 3.0: duration=5s, ar=9:16, cfg=0.5, start_image=output/generated/shot_01.png +# shot_01. Kling 3.0: duration=5s, ar=9:16, cfg=0.5, start_image=frames/round-1/shot_01.png {prompt} ``` diff --git a/skills/visual-prompt-forge/adapters/midjourney.md b/skills/visual-prompt-forge/adapters/midjourney.md index 9c57f83..15cc050 100644 --- a/skills/visual-prompt-forge/adapters/midjourney.md +++ b/skills/visual-prompt-forge/adapters/midjourney.md @@ -1,6 +1,6 @@ # Adapter: Midjourney (v7) -> Capability data (length limits, text/motion support, aspect param) is canonical in `_capabilities.json`. This file is the how-to-prompt guidance. If a number here and in `_capabilities.json` disagree, the JSON wins. +> Capability data (length ceiling, text/motion support, aspect param) is canonical in `_capabilities.json`. This file is the how-to-prompt guidance. `max_prompt_words` there is a ceiling; the range below is the recommended target and has to sit inside it. Where a fact here and a fact in the JSON disagree, the JSON wins, and `tools/validate_capabilities.py` fails the build instead of letting the two drift. Midjourney rewards short, high-signal prompts with strong adjective stacking and cinematic vocabulary. Long descriptive paragraphs underperform, the model interprets them as competing weights and produces muddled output. diff --git a/skills/visual-prompt-forge/adapters/nano-banana.md b/skills/visual-prompt-forge/adapters/nano-banana.md index 91db8ec..d94d9e7 100644 --- a/skills/visual-prompt-forge/adapters/nano-banana.md +++ b/skills/visual-prompt-forge/adapters/nano-banana.md @@ -1,6 +1,6 @@ # Adapter: Nano Banana (Gemini 2.5 Flash Image) -> Capability data (length limits, text/motion support, aspect param) is canonical in `_capabilities.json`. This file is the how-to-prompt guidance. If a number here and in `_capabilities.json` disagree, the JSON wins. +> Capability data (length ceiling, text/motion support, aspect param) is canonical in `_capabilities.json`. This file is the how-to-prompt guidance. `max_prompt_words` there is a ceiling; the range below is the recommended target and has to sit inside it. Where a fact here and a fact in the JSON disagree, the JSON wins, and `tools/validate_capabilities.py` fails the build instead of letting the two drift. Google's Nano Banana model (`gemini-2.5-flash-image`) is the edit-and-iterate champion. Where other generators are best for first-frame creation, Nano Banana excels at variations, inpainting, and reference-based modification. The 2026 production pattern is to generate hero frames in Midjourney or Flux, then use Nano Banana for variants. diff --git a/skills/visual-prompt-forge/adapters/seedance.md b/skills/visual-prompt-forge/adapters/seedance.md index 34aefc6..4bc1a1d 100644 --- a/skills/visual-prompt-forge/adapters/seedance.md +++ b/skills/visual-prompt-forge/adapters/seedance.md @@ -1,6 +1,6 @@ # Adapter: Seedance 2.0 (motion-aware video, multi-shot) -> Capability data (length limits, motion/text support, aspect param) is canonical in `_capabilities.json`. This file is the how-to-prompt guidance. If a number here and in `_capabilities.json` disagree, the JSON wins. +> Capability data (length ceiling, text/motion support, aspect param) is canonical in `_capabilities.json`. This file is the how-to-prompt guidance. `max_prompt_words` there is a ceiling; the range below is the recommended target and has to sit inside it. Where a fact here and a fact in the JSON disagree, the JSON wins, and `tools/validate_capabilities.py` fails the build instead of letting the two drift. Seedance 2.0 is the multi-shot model. It can generate a **short sequence of cuts in a single generation** while holding subject and environment consistency across them. Reach for it when several consecutive storyboard beats share one subject and you want them to feel like one continuous take, it beats stitching four independent Kling clips that drift apart. For a single isolated shot, Kling is the simpler default. diff --git a/skills/visual-prompt-forge/adapters/seedream.md b/skills/visual-prompt-forge/adapters/seedream.md index 23b65b3..ab2dce6 100644 --- a/skills/visual-prompt-forge/adapters/seedream.md +++ b/skills/visual-prompt-forge/adapters/seedream.md @@ -1,6 +1,6 @@ # Adapter: Seedream (4.5 / 4.0) -> Capability data (length limits, text/motion support, aspect param) is canonical in `_capabilities.json`. This file is the how-to-prompt guidance. If a number here and in `_capabilities.json` disagree, the JSON wins. +> Capability data (length ceiling, text/motion support, aspect param) is canonical in `_capabilities.json`. This file is the how-to-prompt guidance. `max_prompt_words` there is a ceiling; the range below is the recommended target and has to sit inside it. Where a fact here and a fact in the JSON disagree, the JSON wins, and `tools/validate_capabilities.py` fails the build instead of letting the two drift. ByteDance's Seedream models are the high-volume cost-efficient choice. Quality is below Flux 2 Pro and Midjourney v7 but above older mid-tier models, and the cost-per-image is roughly 5–10× lower. Choose Seedream when you're producing **many variations or doing rapid concept iteration**, not for hero/final assets. diff --git a/skills/visual-prompt-forge/adapters/veo.md b/skills/visual-prompt-forge/adapters/veo.md index cfdb86d..ea71951 100644 --- a/skills/visual-prompt-forge/adapters/veo.md +++ b/skills/visual-prompt-forge/adapters/veo.md @@ -1,6 +1,6 @@ # Adapter: Veo 3 (motion-aware video, native audio) -> Capability data (length limits, motion/text support, aspect param) is canonical in `_capabilities.json`. This file is the how-to-prompt guidance. If a number here and in `_capabilities.json` disagree, the JSON wins. +> Capability data (length ceiling, text/motion support, aspect param) is canonical in `_capabilities.json`. This file is the how-to-prompt guidance. `max_prompt_words` there is a ceiling; the range below is the recommended target and has to sit inside it. Where a fact here and a fact in the JSON disagree, the JSON wins, and `tools/validate_capabilities.py` fails the build instead of letting the two drift. Veo 3 is the dialogue and lipsync model. It is the only adapter that generates **synchronised native audio**, speech, ambience, and sound effects, in the same pass as the video. It also has the strongest prompt adherence and physical realism of the four motion models. It is the most expensive, so reserve it for shots that actually need spoken dialogue, lipsync, or audio baked in. For silent B-roll and camera moves, Kling is the cheaper default. diff --git a/tools/README.md b/tools/README.md index a6d42ea..a3ee585 100644 --- a/tools/README.md +++ b/tools/README.md @@ -1,6 +1,7 @@ # Tools -Python helpers that keep the repo honest and let you render previews without invoking Claude. +Python helpers that keep the repo honest, gate a real project's output, and render previews +without invoking Claude. ## Requirements @@ -8,95 +9,235 @@ Python helpers that keep the repo honest and let you render previews without inv pip install pyyaml jsonschema ``` -## `validate_skills.py` +Standard library otherwise. No pandas, no numpy. -Checks every `SKILL.md` in `skills/` has the required YAML frontmatter, a `name` that matches its directory and a substantive `description`. +## Run everything ```bash -python tools/validate_skills.py +./tools/check.sh # every check, with output +./tools/check.sh --quiet # pass/fail lines only +PYTHON=python3.12 ./tools/check.sh ``` -Run by CI on every PR. Use locally before opening a PR. +This is exactly what CI runs, so a green local run means a green PR. + +Every validator below also ships a `--selftest` that constructs failing fixtures and fails if +the check does not catch them. A validator that silently stops catching things is worse than +no validator, and the selftests are how that gets noticed. + +## Repo checks -## `validate_schemas.py` +### `validate_skills.py` -Checks every `*.schema.json` file is itself valid JSON Schema (Draft 2020-12). +Checks every `SKILL.md` in `skills/` has the required YAML frontmatter, a `name` that matches +its directory and a substantive `description`. + +```bash +python tools/validate_skills.py +``` + +### `validate_schemas.py` + +Checks every `*.schema.json` file is itself valid JSON Schema (Draft 2020-12) and carries +`$id`, `title`, and `description`. ```bash python tools/validate_schemas.py ``` -Run by CI on every PR. +It validates schemas, not instances. For instances, see `validate_shots.py`. + +### `validate_capabilities.py` + +Checks the generator capability matrix +(`skills/visual-prompt-forge/adapters/_capabilities.json`) against +`capabilities.schema.json`, then checks it against the adapter prose that defers to it: + +- every generator id has an adapter `.md` and every adapter `.md` has an entry +- no adapter advertises more words than its own `max_prompt_words` ceiling +- every adapter documents the `aspect_param` the matrix says to send +- no `notes` field cites a word count above its own ceiling +- warns when the matrix or an entry is past its 120-day freshness window + +```bash +python tools/validate_capabilities.py +python tools/validate_capabilities.py --selftest +``` + +The prose checks exist because the matrix and the adapters had drifted in three places while +every file repeated the rule that the JSON wins. -## `validate_brand_lock.py` +### `validate_brand_lock.py` -Checks a brand-lock Markdown file has all required sections and Identity fields. +Checks a brand-lock has all required sections and Identity fields, that its palette declares +the five roles the HTML preview maps onto CSS variables, and that its fonts are in the +backticked form the tools read. ```bash python tools/validate_brand_lock.py brand-packs/whystrohm.md -python tools/validate_brand_lock.py brand-packs/_template.md brand-packs/whystrohm.md +python tools/validate_brand_lock.py --require-configured brand-packs/whystrohm.md +python tools/validate_brand_lock.py --snapshot output/brand-lock.snapshot.md +python tools/validate_brand_lock.py --snapshots # every snapshot in the repo +python tools/validate_brand_lock.py --selftest ``` -Run before committing new brand-pack examples. +A brand-pack may be an unfilled template; that is what a template is. A snapshot may not, and +`--snapshot` additionally requires the `` and +`` header, with a full UTC instant preferred over a bare date. -## `validate_capabilities.py` +## Project checks -Checks the generator capability matrix (`skills/visual-prompt-forge/adapters/_capabilities.json`): validates it against `capabilities.schema.json`, enforces parity (every capability id has an adapter `.md` and every adapter has a capability entry), and warns when an entry is past its freshness window. +These run against a project's output directory, not the repo. + +### `validate_shots.py` + +Validates `shots.json` and `text-overlays.json` as instances, plus every cross-field and +cross-file rule JSON Schema cannot express: `end` after `start`, no gaps or overlaps, span +matching `project.duration_s`, overlay references resolving in both directions, every overlay +reachable from some shot, overlay timing inside its shot window, and every overlay color +present in the brand-lock palette. ```bash -python tools/validate_capabilities.py +python tools/validate_shots.py output/ +python tools/validate_shots.py path/to/shots.json +python tools/validate_shots.py --examples # every bundled example +python tools/validate_shots.py --selftest +``` + +Warnings cover the judgement calls: overlay copy repeated in a shot subject, a raw hex in a +subject, shot ids out of chronological order, a font the brand-lock does not declare. + +### `validate_prompts.py` + +Validates the prompt files `visual-prompt-forge` writes, against `shots.json` and the +capability matrix. Header completeness, generator id, aspect agreement, the +`max_prompt_words` ceiling, shot coverage, duplicate blocks, and the forge's two hard +rules: no on-screen text copy inside a prompt, and `environment` / `lighting` / +`color_grade` appearing verbatim. + +```bash +python tools/validate_prompts.py output/ +python tools/validate_prompts.py output/prompts/round-1/flux.txt +python tools/validate_prompts.py --examples +python tools/validate_prompts.py --selftest ``` -Run by CI on every PR. +The verbatim check is the reason this file exists. Series consistency depends on the +series_lock anchors landing unedited in every prompt, and that is the single easiest rule +in the kit to break, because paraphrasing an anchor is what writing good prose feels like. +A careful authoring pass over a seven-shot storyboard drifted on it seven times out of +seven with every other validator green, and so did the worked-run fixture in this repo. + +The character anchor is a warning rather than an error: a shot with no person in it can +legitimately omit it, and the message says so, so you can confirm rather than guess. + +### `validate_critique.py` -## `validate_critique.py` +Validates a critique against `critique.schema.json` **and** the two invariants the schema +cannot hold. -Validates a `critique.json` against `critique.schema.json` **and** the gating invariant JSON Schema can't express: a `blocking` issue forces `REJECT`, a `major` issue forbids `ACCEPT`. +The gate: any `blocking` issue forces `REJECT`, three or more `major` issues force `REJECT`, +one or two `major` forbid `ACCEPT`. + +The provenance rules, from schema version `1.1`: a hash without its path is not a reference, +`image_ref` may not be null, `HIGH` confidence requires the shot, brand-lock, and prompt all +to be identified, and a named generator has to exist in the capability matrix. ```bash -python tools/validate_critique.py output/critique.json -python tools/validate_critique.py --selftest # prove the gate fires +python tools/validate_critique.py output/critiques/round-1/shot_03.critique.json +python tools/validate_critique.py output/ # every critique in the tree +python tools/validate_critique.py --examples +python tools/validate_critique.py --selftest ``` -Run by CI on every PR (selftest, then the bundled example fixtures). +Version `1.0` critiques still pass, with a warning: they carry a verdict and no way to tie it +to the bytes it reviewed. -## `shots-to-html.py` +### `validate_provenance.py` -Standalone CLI version of the `storyboard-html-preview` skill. Renders an `output/` folder into a single `preview.html`. +Walks an output tree and recomputes every recorded hash. This is the tool that answers "does +this verdict still describe the file it reviewed." ```bash -python tools/shots-to-html.py path/to/output-folder -python tools/shots-to-html.py path/to/output-folder --inline-images -python tools/shots-to-html.py path/to/output-folder --out review.html +python tools/validate_provenance.py output/ +python tools/validate_provenance.py output/ --require-accept +python tools/validate_provenance.py output/ --json +python tools/validate_provenance.py --selftest ``` -Useful when you want to render a preview without running the skill, e.g. in CI, in a deploy pipeline, or when sharing the helper with someone who isn't a Claude user. +It catches, with a selftest for each: + +- a frame regenerated after its critique (`image_sha256` no longer matches) +- a brand-lock edited mid-project (`brand_lock_sha256` no longer matches `run.json`) +- a frame on disk with no critique for its round, never reviewed at all +- two critiques for the same shot in the same round, two operators colliding +- rounds that skip a number, or a critique whose `run_id` belongs to another run + +`--require-accept` makes it the pipeline stop condition: exit 0 only when the chain is intact +*and* every shot's latest verdict is ACCEPT. -The output is identical to what the skill produces. Same template, same CSS, same JavaScript. +## Rendering -## `copy-prompt.py` +### `shots-to-html.py` -Pipe a single shot's prompt from a generated prompts file straight to the system clipboard. Lets the user paste into a generator UI without hunting for the right block in the .txt file. +Renders an output folder into a single `preview.html`. ```bash -python tools/copy-prompt.py output/prompts/midjourney.txt -python tools/copy-prompt.py output/prompts/midjourney.txt --shot shot_03 -python tools/copy-prompt.py output/prompts/midjourney.txt --list +python tools/shots-to-html.py output/ +python tools/shots-to-html.py output/ --inline-images +python tools/shots-to-html.py output/ --out review.html +python tools/shots-to-html.py output/ --rendered-at 2026-07-30T00:00:00Z +python tools/shots-to-html.py --selftest ``` -No flags: lists shots and prompts for a numeric selection. -`--shot shot_NN`: copies that shot's prompt directly. -`--list`: prints the shot index and exits without copying. +It renders `skills/storyboard-html-preview/templates/preview.html.tpl`, the same structural +template the skill uses, through the small engine in `_template.py`. It did not always: the +CLI used to build its HTML inline while claiming to share the template, so the two could and +did diverge. -Pure standard library. Uses `pbcopy` on macOS, `xclip` or `xsel` on Linux, `clip` on Windows. The header comment line is stripped, only the prompt body lands in the clipboard. +Everything interpolated is HTML-escaped. Shot subjects and rationales are model-generated +prose, and one angle bracket used to be enough to break the page. -## Adding new tools +`--rendered-at` pins the render timestamp, which makes output reproducible. CI re-renders +every bundled preview with a pinned value and fails if a byte moves. + +The page shows two dates, deliberately: the **run** date from `run.json`, and the **render** +date. A single "Generated" date meant re-rendering a preview restamped the run as today. -If you add a new tool here: +### `copy-prompt.py` + +Pipes one shot's prompt to the clipboard so you can paste into a generator UI without hunting +through the file. + +```bash +python tools/copy-prompt.py output/prompts/round-1/flux.txt +python tools/copy-prompt.py output/prompts/round-2/revised-flux.txt --shot shot_02 +python tools/copy-prompt.py output/prompts/round-1/flux.txt --list +python tools/copy-prompt.py --selftest +``` + +Reads both file shapes the forge writes. Comment lines inside a shot block are annotations and +never land in the clipboard, so a revision file's `# fix [...]` notes are shown but not +copied. Revision files used to be unreadable to this tool entirely, which was awkward given +they are the files an operator pastes from most. + +Pure standard library. `pbcopy` on macOS, `xclip` or `xsel` on Linux, `clip` on Windows. + +## Internal modules + +Not entry points. Imported by the tools above. + +- `_shotkit.py`, hashing, run ids, brand-lock parsing, and the output-tree path conventions. + One copy, so no two tools can disagree about what a palette or a frame path is. +- `_template.py`, the template engine: `{{var}}`, `{{{raw}}}`, `{{#each}}`, `{{#if}}`. Only + the subset `preview.html.tpl` uses, deliberately. + +## Adding new tools - Make it executable (`chmod +x`) - Add a usage docstring at the top of the file +- Give it a `--selftest` that proves the check fires - Document it in this README -- If it's part of CI, wire it into `.github/workflows/validate-skills.yml` +- Wire it into `tools/check.sh`, which is what CI calls -Keep tools dependency-light. PyYAML, jsonschema, and the standard library are the baseline. Don't pull in pandas or numpy for these. +Keep tools dependency-light. PyYAML, jsonschema, and the standard library are the baseline. diff --git a/tools/_shotkit.py b/tools/_shotkit.py new file mode 100644 index 0000000..489438c --- /dev/null +++ b/tools/_shotkit.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python3 +""" +Shared helpers for the shotkit tools. Standard library only. + +Everything here exists because more than one tool needs it. Hashing, brand-lock +parsing, and the capability-id list were duplicated or absent before; keeping one +copy is what stops the tools from disagreeing about what a brand-lock palette is +or which generator ids are real. +""" + +from __future__ import annotations + +import hashlib +import json +import re +from datetime import datetime, timezone +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + +CAPABILITIES_PATH = ( + REPO_ROOT / "skills" / "visual-prompt-forge" / "adapters" / "_capabilities.json" +) + +# Roles tools/shots-to-html.py maps onto CSS variables. Documented in +# docs/brand-lock-anatomy.md so a brand-lock author knows the names are load-bearing. +PALETTE_ROLES = ("background", "ink", "accent", "muted", "rule") + +RUN_ID_RE = re.compile(r"^([0-9]{8}T[0-9]{6}Z)-[0-9a-f]{8}$") +ISO_INSTANT_RE = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$") +SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +SHOT_ID_RE = re.compile(r"^shot_[0-9]{2,3}$") +TEXT_ID_RE = re.compile(r"^text_[0-9]{2,3}$") + +HEX_RE = re.compile(r"#[0-9A-Fa-f]{6}") +PLACEHOLDER_HEX_RE = re.compile(r"#_{6}") + +# | Role | `#RRGGBB` | Use | rows in a brand-lock Palette table. +PALETTE_ROW_RE = re.compile( + r"^\|\s*([A-Za-z][A-Za-z0-9\s()/-]*?)\s*\|\s*`?(#(?:[0-9A-Fa-f]{6}|_{6}))`?\s*\|" +) + +SNAPSHOT_TAKEN_RE = re.compile(r"") +SNAPSHOT_SOURCE_RE = re.compile(r"") + +IMAGE_EXTS = ("png", "jpg", "jpeg", "webp") + + +# -------------------------------------------------------------------------- +# Hashing +# -------------------------------------------------------------------------- + +def sha256_file(path: Path) -> str: + """SHA-256 of a file's bytes, read in chunks so large frames do not load whole.""" + h = hashlib.sha256() + with path.open("rb") as fh: + for chunk in iter(lambda: fh.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def sha256_text(text: str) -> str: + """SHA-256 of a string, UTF-8 encoded.""" + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +# -------------------------------------------------------------------------- +# Run identity +# -------------------------------------------------------------------------- + +def make_run_id(now: datetime | None = None, nonce: str | None = None) -> str: + """Build a run_id. Pass `now` and `nonce` to make it reproducible in tests.""" + now = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + stamp = now.strftime("%Y%m%dT%H%M%SZ") + if nonce is None: + nonce = sha256_text(now.isoformat() + str(id(now)))[:8] + return f"{stamp}-{nonce}" + + +def iso_instant(now: datetime | None = None) -> str: + """UTC ISO-8601 to second precision with a trailing Z.""" + now = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + return now.strftime("%Y-%m-%dT%H:%M:%SZ") + + +def run_id_timestamp(run_id: str) -> str | None: + """Extract the compact timestamp from a run_id, or None if malformed.""" + m = RUN_ID_RE.match(run_id or "") + return m.group(1) if m else None + + +# -------------------------------------------------------------------------- +# JSON loading +# -------------------------------------------------------------------------- + +def load_json(path: Path) -> tuple[dict | list | None, str | None]: + """Return (data, error). Exactly one of the two is None.""" + if not path.exists(): + return (None, f"file does not exist: {path}") + try: + return (json.loads(path.read_text(encoding="utf-8")), None) + except json.JSONDecodeError as e: + return (None, f"invalid JSON: {e}") + except OSError as e: + return (None, f"cannot read: {e}") + + +# -------------------------------------------------------------------------- +# Capability matrix +# -------------------------------------------------------------------------- + +def capability_ids(caps_path: Path | None = None) -> set[str]: + """Generator ids from the capability matrix. Empty set if it cannot be read.""" + data, err = load_json(caps_path or CAPABILITIES_PATH) + if err or not isinstance(data, dict): + return set() + return { + g["id"] + for g in data.get("generators", []) + if isinstance(g, dict) and isinstance(g.get("id"), str) + } + + +def capability_map(caps_path: Path | None = None) -> dict[str, dict]: + """Generator id to its full capability entry.""" + data, err = load_json(caps_path or CAPABILITIES_PATH) + if err or not isinstance(data, dict): + return {} + return { + g["id"]: g + for g in data.get("generators", []) + if isinstance(g, dict) and isinstance(g.get("id"), str) + } + + +# -------------------------------------------------------------------------- +# Brand-lock parsing +# -------------------------------------------------------------------------- + +def parse_palette(text: str) -> dict[str, str]: + """Role (lowercased) to hex value, from a brand-lock Palette table.""" + palette: dict[str, str] = {} + for line in text.splitlines(): + m = PALETTE_ROW_RE.match(line) + if m: + palette[m.group(1).strip().lower()] = m.group(2).strip() + return palette + + +def palette_hexes(text: str) -> set[str]: + """Every real (non-placeholder) hex in a brand-lock, lowercased.""" + return {h.lower() for h in HEX_RE.findall(text)} + + +def is_unconfigured(text: str) -> bool: + """True when the brand-lock still carries template placeholders in its palette.""" + return bool(PLACEHOLDER_HEX_RE.search(text)) + + +def parse_typography(text: str) -> dict[str, str | None]: + """ + Pull font names out of a brand-lock Typography section. + + Matches the documented format: + **Display font:** `Inter Black 900`, headline weight, ... + **Body font:** `Inter Medium 500`, body copy, ... + **Mono font:** `JetBrains Mono Regular`, code, data, ... + + Mono is optional. A brand-lock that declares one has three legal overlay fonts, + not two, which is why the font check reads all three rather than assuming a pair. + """ + out: dict[str, str | None] = { + "display_font": None, + "body_font": None, + "mono_font": None, + } + # The value has to be backticked and sit immediately after the label. That is what + # separates a real declaration from the template's instructional prose + # ("**Display font:** font name, weights used (e.g. `Inter Black 900`)"), which + # would otherwise resolve to the example font. + for key, label in ( + ("display_font", "Display"), + ("body_font", "Body"), + ("mono_font", "Mono"), + ): + pattern = re.compile( + r"^\*\*" + label + r"\s+font[^:*]*:?\*\*[ \t]*`([^`\n]+)`", + re.IGNORECASE | re.MULTILINE, + ) + m = pattern.search(text) + if m: + value = m.group(1).strip() + if value and not value.startswith("_"): + out[key] = value + return out + + +def parse_snapshot_header(text: str) -> dict[str, str | None]: + """The two provenance comments storyboard-architect writes at the top of a snapshot.""" + taken = SNAPSHOT_TAKEN_RE.search(text) + source = SNAPSHOT_SOURCE_RE.search(text) + return { + "snapshot_taken": taken.group(1).strip() if taken else None, + "source": source.group(1).strip() if source else None, + } + + +# -------------------------------------------------------------------------- +# Output-tree conventions +# -------------------------------------------------------------------------- + +def round_dirs(output_dir: Path, kind: str) -> list[tuple[int, Path]]: + """ + Sorted (round_number, path) for output_dir//round-N directories. + + `kind` is 'frames', 'critiques', or 'prompts'. + """ + base = output_dir / kind + if not base.is_dir(): + return [] + found: list[tuple[int, Path]] = [] + for child in base.iterdir(): + if child.is_dir() and child.name.startswith("round-"): + suffix = child.name[len("round-"):] + if suffix.isdigit(): + found.append((int(suffix), child)) + return sorted(found) + + +def find_frame(output_dir: Path, shot_id: str, round_no: int | None = None) -> Path | None: + """ + Locate a frame for a shot by the path convention. + + Prefers the highest-numbered round under frames/, then falls back to the + legacy flat generated/ directory so pre-3.0.0 projects still render. + """ + candidates = round_dirs(output_dir, "frames") + if round_no is not None: + candidates = [(n, p) for n, p in candidates if n == round_no] + for _, directory in reversed(candidates): + for ext in IMAGE_EXTS: + candidate = directory / f"{shot_id}.{ext}" + if candidate.exists(): + return candidate + legacy = output_dir / "generated" + if legacy.is_dir(): + for ext in IMAGE_EXTS: + candidate = legacy / f"{shot_id}.{ext}" + if candidate.exists(): + return candidate + return None + + +def critique_paths(output_dir: Path) -> list[Path]: + """ + Every critique file in an output tree, newest layout first. + + New layout: critiques/round-N/shot_NN.critique.json + Legacy: critique.json at the output root + """ + found: list[Path] = [] + for _, directory in round_dirs(output_dir, "critiques"): + found.extend(sorted(directory.glob("*.critique.json"))) + found.extend(sorted(directory.glob("*.json"))) + legacy = output_dir / "critique.json" + if legacy.exists(): + found.append(legacy) + # Preserve order, drop duplicates from the two globs above. + seen: set[Path] = set() + unique: list[Path] = [] + for path in found: + resolved = path.resolve() + if resolved not in seen: + seen.add(resolved) + unique.append(path) + return unique + + +def as_overlay_ids(value) -> list[str]: + """Normalise shots.json on_screen_text (null, string, or array) to a list.""" + if value is None: + return [] + if isinstance(value, str): + return [value] + if isinstance(value, list): + return [v for v in value if isinstance(v, str)] + return [] + + +def as_shot_ids(value) -> list[str]: + """Normalise text-overlays.json shot_id (string or array) to a list.""" + if isinstance(value, str): + return [value] + if isinstance(value, list): + return [v for v in value if isinstance(v, str)] + return [] diff --git a/tools/_template.py b/tools/_template.py new file mode 100644 index 0000000..849505b --- /dev/null +++ b/tools/_template.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +""" +The small template engine tools/shots-to-html.py renders preview.html.tpl with. + +Only the subset the template actually uses is implemented, deliberately: variable +substitution, a loop, and a truthiness block. It exists so the CLI and the +storyboard-html-preview skill render the same structural template instead of two +hand-maintained copies that drift apart. + +Supported syntax: + {{name}} HTML-escaped substitution + {{{name}}} raw substitution, for pre-built markup such as inlined CSS + {{#each items}} iterate a list of dicts; inside the block, keys resolve + {{/each}} against the item first, then the enclosing context + {{#if name}} render when the value is truthy + {{/if}} + +Unknown names render empty rather than raising, which keeps a partially populated +context from producing a traceback in front of a client. +""" + +from __future__ import annotations + +import html +import re + +BLOCK_RE = re.compile(r"\{\{#(each|if)\s+([A-Za-z0-9_]+)\s*\}\}") +RAW_VAR_RE = re.compile(r"\{\{\{\s*([A-Za-z0-9_]+)\s*\}\}\}") +VAR_RE = re.compile(r"\{\{\s*([A-Za-z0-9_]+)\s*\}\}") + + +def escape(value) -> str: + """HTML-escape a value for text and attribute contexts, including quotes.""" + if value is None or value is False: + return "" + if value is True: + return "true" + return html.escape(str(value), quote=True) + + +def _find_block_end(text: str, kind: str, start: int) -> int: + """Index of the closing tag matching the block opened before `start`.""" + open_re = re.compile(r"\{\{#" + kind + r"\s+[A-Za-z0-9_]+\s*\}\}") + close_tag = "{{/" + kind + "}}" + depth = 1 + pos = start + while depth: + next_close = text.find(close_tag, pos) + if next_close == -1: + raise ValueError(f"unclosed {{{{#{kind}}}}} block") + next_open = open_re.search(text, pos, next_close) + if next_open: + depth += 1 + pos = next_open.end() + continue + depth -= 1 + pos = next_close + len(close_tag) + return pos + + +def render(template: str, context: dict) -> str: + """Render `template` against `context`.""" + out: list[str] = [] + pos = 0 + + while pos < len(template): + block = BLOCK_RE.search(template, pos) + if not block: + out.append(_render_leaf(template[pos:], context)) + break + + out.append(_render_leaf(template[pos : block.start()], context)) + kind, name = block.group(1), block.group(2) + body_start = block.end() + block_end = _find_block_end(template, kind, body_start) + close_len = len("{{/" + kind + "}}") + body = template[body_start : block_end - close_len] + + value = context.get(name) + if kind == "each": + for item in value or []: + scoped = dict(context) + if isinstance(item, dict): + scoped.update(item) + else: + scoped["this"] = item + out.append(render(body, scoped)) + else: + if value: + out.append(render(body, context)) + + pos = block_end + + return "".join(out) + + +def _render_leaf(text: str, context: dict) -> str: + """Substitute variables in a chunk with no block tags left in it.""" + text = RAW_VAR_RE.sub(lambda m: str(context.get(m.group(1), "") or ""), text) + return VAR_RE.sub(lambda m: escape(context.get(m.group(1))), text) diff --git a/tools/check.sh b/tools/check.sh new file mode 100755 index 0000000..4ad8e04 --- /dev/null +++ b/tools/check.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# Run every shotkit check. This is what CI calls, so a green local run means a green PR. +# +# Usage: +# ./tools/check.sh # run everything, report a summary +# ./tools/check.sh --quiet # only print failures and the summary +# +# Requires: pip install pyyaml jsonschema + +set -uo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")/.." || exit 1 + +PYTHON="${PYTHON:-python3}" +QUIET=0 +[[ "${1:-}" == "--quiet" ]] && QUIET=1 + +PASSED=0 +FAILED=0 +FAILED_NAMES=() + +run() { + local label="$1" + shift + local output + if output=$("$PYTHON" "$@" 2>&1); then + PASSED=$((PASSED + 1)) + if [[ "$QUIET" == "0" ]]; then + echo "PASS ${label}" + sed 's/^/ /' <<<"$output" + echo + else + echo "PASS ${label}" + fi + else + FAILED=$((FAILED + 1)) + FAILED_NAMES+=("$label") + echo "FAIL ${label}" + sed 's/^/ /' <<<"$output" + echo + fi +} + +echo "shotkit checks, using $("$PYTHON" --version 2>&1)" +echo + +# Preflight the two dependencies. Without this, a missing package turns one actionable +# line into fourteen identical failures and you have to read all of them to find out. +missing="" +"$PYTHON" -c 'import yaml' 2>/dev/null || missing="pyyaml" +"$PYTHON" -c 'import jsonschema' 2>/dev/null || missing="${missing:+$missing }jsonschema" +if [[ -n "$missing" ]]; then + echo "Missing Python package(s): ${missing}" >&2 + echo >&2 + echo " ${PYTHON} -m pip install ${missing}" >&2 + echo >&2 + echo "Then re-run ./tools/check.sh" >&2 + exit 1 +fi + +# Structure and schemas +run "skills: frontmatter" tools/validate_skills.py +run "schemas: are valid schemas" tools/validate_schemas.py + +# Capability matrix, including prose parity with the adapter files +run "capabilities: selftest" tools/validate_capabilities.py --selftest +run "capabilities: matrix" tools/validate_capabilities.py + +# Brand-locks: packs allow templates, snapshots do not +run "brand-lock: selftest" tools/validate_brand_lock.py --selftest +run "brand-lock: packs" tools/validate_brand_lock.py \ + brand-packs/_template.md \ + brand-packs/whystrohm.md \ + brand-packs/examples/saas-clean.md \ + skills/brand-lock-extractor/examples/brand-lock.md +run "brand-lock: snapshots" tools/validate_brand_lock.py --snapshots + +# Storyboard instances, the rules JSON Schema cannot express +run "shots: selftest" tools/validate_shots.py --selftest +run "shots: bundled examples" tools/validate_shots.py --examples +run "shots: worked run" tools/validate_shots.py \ + skills/visual-asset-critic/examples/worked-run + +# Prompt files: the forge's hard rules, including verbatim series_lock anchors +run "prompts: selftest" tools/validate_prompts.py --selftest +run "prompts: worked run" tools/validate_prompts.py --examples + +# Critique gate +run "critique: selftest" tools/validate_critique.py --selftest +run "critique: fixtures" tools/validate_critique.py --examples + +# Provenance chain +run "provenance: selftest" tools/validate_provenance.py --selftest +run "provenance: worked run" tools/validate_provenance.py --examples --require-accept + +# Tools that ship as part of the workflow +run "preview renderer: selftest" tools/shots-to-html.py --selftest +run "prompt helper: selftest" tools/copy-prompt.py --selftest + +echo "─────────────────────────────────────────" +echo "${PASSED} passed, ${FAILED} failed" +if ((FAILED)); then + printf 'failed: %s\n' "${FAILED_NAMES[@]}" + exit 1 +fi +exit 0 diff --git a/tools/copy-prompt.py b/tools/copy-prompt.py index e6f41ac..377f20c 100755 --- a/tools/copy-prompt.py +++ b/tools/copy-prompt.py @@ -1,10 +1,16 @@ #!/usr/bin/env python3 """Copy a shot prompt from a shotkit prompts file to the system clipboard. +Handles both file shapes the forge writes: a full pass, and a revision file where each +shot block carries `# fix [...]` annotations above the prompt. Comment lines inside a +block are treated as annotations and never land in the clipboard, so what you paste +into a generator is the prompt and nothing else. + Usage: - python tools/copy-prompt.py output/prompts/midjourney.txt - python tools/copy-prompt.py output/prompts/midjourney.txt --shot shot_03 - python tools/copy-prompt.py output/prompts/midjourney.txt --list + python tools/copy-prompt.py output/prompts/round-1/flux.txt + python tools/copy-prompt.py output/prompts/round-2/revised-flux.txt --shot shot_02 + python tools/copy-prompt.py output/prompts/round-1/flux.txt --list + python tools/copy-prompt.py --selftest """ from __future__ import annotations @@ -16,147 +22,276 @@ import sys from pathlib import Path +# A shot block opens with the shot id near the start of a comment line. The forge's +# revision format prefixes it, so both of these are headers: +# # shot_03, reframe, 11.0-16.0s, MCU eye-level push +# # shot_03, promise, 3.0-8.0s, MCU eye-level push, revision (was REVISE) +# # Revision of shot_03 (was REVISE) +SHOT_HEADER = re.compile( + r"^#\s*(?:revision\s+of\s+)?(shot_\d{2,3})\b(.*)$", re.IGNORECASE +) +COMMENT_LINE = re.compile(r"^\s*#") + + +class Block: + __slots__ = ("shot_id", "header", "annotations", "body") + + def __init__(self, shot_id: str, header: str) -> None: + self.shot_id = shot_id + self.header = header + self.annotations: list[str] = [] + self.body: list[str] = [] -SHOT_HEADER = re.compile(r'^#\s*(shot_\d+)\b(.*)$') + def prompt(self) -> str: + return "\n".join(self.body).strip() + + def label(self) -> str: + note = f" [{len(self.annotations)} fix note(s)]" if self.annotations else "" + return f"{self.header}{note} ({len(self.prompt())} chars)" def get_clipboard_command() -> list[str] | None: """Return the platform-appropriate clipboard command, or None if unavailable.""" - if sys.platform == 'darwin': - return ['pbcopy'] - if sys.platform.startswith('linux'): - if shutil.which('xclip'): - return ['xclip', '-selection', 'clipboard'] - if shutil.which('xsel'): - return ['xsel', '--clipboard', '--input'] + if sys.platform == "darwin": + return ["pbcopy"] + if sys.platform.startswith("linux"): + if shutil.which("xclip"): + return ["xclip", "-selection", "clipboard"] + if shutil.which("xsel"): + return ["xsel", "--clipboard", "--input"] return None - if sys.platform == 'win32': - return ['clip'] + if sys.platform == "win32": + return ["clip"] return None -def parse_prompt_file(path: Path) -> list[tuple[str, str, str]]: - """Parse a prompt file into a list of (shot_id, header_line, prompt_body) tuples.""" - text = path.read_text(encoding='utf-8') - shots: list[tuple[str, str, str]] = [] - current_id: str | None = None - current_header: str = '' - current_body: list[str] = [] +def parse_prompt_text(text: str) -> list[Block]: + """Split a prompt file into shot blocks, separating annotations from prompt body.""" + blocks: list[Block] = [] + current: Block | None = None for line in text.splitlines(): - m = SHOT_HEADER.match(line) - if m: - if current_id is not None: - shots.append((current_id, current_header, '\n'.join(current_body).strip())) - current_id = m.group(1) - current_header = line.strip() - current_body = [] - elif current_id is not None: - current_body.append(line) + match = SHOT_HEADER.match(line) + if match: + current = Block(match.group(1), line.strip()) + blocks.append(current) + continue + if current is None: + continue # file-level header comments, before the first shot block + if COMMENT_LINE.match(line): + current.annotations.append(line.strip()) + else: + current.body.append(line) + + return [b for b in blocks if b.prompt()] - if current_id is not None: - shots.append((current_id, current_header, '\n'.join(current_body).strip())) - return shots +def parse_prompt_file(path: Path) -> list[Block]: + return parse_prompt_text(path.read_text(encoding="utf-8")) def copy_to_clipboard(text: str) -> bool: - """Pipe text into the platform clipboard. Returns True on success.""" cmd = get_clipboard_command() if not cmd: return False - proc = subprocess.run(cmd, input=text.encode('utf-8')) + proc = subprocess.run(cmd, input=text.encode("utf-8")) return proc.returncode == 0 def print_clipboard_help() -> None: - if sys.platform.startswith('linux'): - print('No clipboard utility found. Install xclip or xsel.', file=sys.stderr) + if sys.platform.startswith("linux"): + print("No clipboard utility found. Install xclip or xsel.", file=sys.stderr) else: - print('No clipboard utility found on this platform.', file=sys.stderr) + print("No clipboard utility found on this platform.", file=sys.stderr) -def list_shots(shots: list[tuple[str, str, str]]) -> None: - for i, (sid, header, body) in enumerate(shots, 1): - print(f'{i}. {header} ({len(body)} chars)') +def list_blocks(blocks: list[Block]) -> None: + for i, block in enumerate(blocks, 1): + print(f"{i}. {block.label()}") + for note in block.annotations: + print(f" {note}") -def select_interactively(shots: list[tuple[str, str, str]]) -> tuple[str, str, str] | None: - list_shots(shots) +def select_interactively(blocks: list[Block]) -> Block | None: + list_blocks(blocks) try: - raw = input(f'\nWhich shot? (1-{len(shots)}): ').strip() + raw = input(f"\nWhich shot? (1-{len(blocks)}): ").strip() idx = int(raw) - 1 except (ValueError, EOFError): - print('Invalid selection.', file=sys.stderr) + print("Invalid selection.", file=sys.stderr) return None - if not (0 <= idx < len(shots)): - print('Selection out of range.', file=sys.stderr) + if not (0 <= idx < len(blocks)): + print("Selection out of range.", file=sys.stderr) return None - return shots[idx] + return blocks[idx] + + +STANDARD_FIXTURE = """\ +# Storyboard: Fixture +# Generator: flux +# Aspect: 9:16 + +# shot_01, hook, 0.0-2.0s, MCU eye-level static +first prompt body + +# shot_02, pain, 2.0-6.0s, MS eye-level push +second prompt body +""" + +REVISION_FIXTURE = """\ +# Storyboard: Fixture +# Generator: flux +# Round: 2 +# Revision of round 1. Shots not listed here already passed. + +# shot_02, pain, 2.0-6.0s, MCU eye-level push, revision (was REVISE) +# fix [Shot Spec, major]: framing MS -> MCU +# fix [Technical, re-roll]: hand was malformed +revised prompt body +""" + +LEGACY_REVISION_FIXTURE = """\ +# Revision of shot_03 (was REVISE) +# fix [Series Lock, major]: added 'salt-and-pepper hair' to the character anchor +legacy revised prompt body +""" + + +def selftest() -> int: + ok = True + + cases = [ + ("standard file yields both shots", STANDARD_FIXTURE, ["shot_01", "shot_02"]), + ("revision file yields its shot", REVISION_FIXTURE, ["shot_02"]), + ("legacy revision header is tolerated", LEGACY_REVISION_FIXTURE, ["shot_03"]), + ] + for label, fixture, expected in cases: + got = [b.shot_id for b in parse_prompt_text(fixture)] + if got == expected: + print(f" ok selftest: {label}") + else: + print(f" FAIL selftest: {label} -> expected {expected}, got {got}") + ok = False + + blocks = parse_prompt_text(REVISION_FIXTURE) + body = blocks[0].prompt() + if body == "revised prompt body": + print(" ok selftest: fix annotations stay out of the copied prompt") + else: + print(f" FAIL selftest: prompt body was {body!r}") + ok = False + if len(blocks[0].annotations) == 2: + print(" ok selftest: fix annotations are captured for display") + else: + print(f" FAIL selftest: expected 2 annotations, got {blocks[0].annotations}") + ok = False + + header_only = parse_prompt_text("# Storyboard: x\n# Generator: flux\n") + if header_only == []: + print(" ok selftest: a file with no shot blocks yields nothing") + else: + print(f" FAIL selftest: expected no blocks, got {header_only}") + ok = False + + # The shipped worked run must be readable by this tool, both rounds. + repo_root = Path(__file__).resolve().parent.parent + worked = repo_root / "skills" / "visual-asset-critic" / "examples" / "worked-run" + for rel, expected in ( + ("prompts/round-1/flux.txt", ["shot_01", "shot_02"]), + ("prompts/round-2/revised-flux.txt", ["shot_02"]), + ): + path = worked / rel + if not path.exists(): + print(f" FAIL selftest: bundled fixture missing: {rel}") + ok = False + continue + got = [b.shot_id for b in parse_prompt_file(path)] + if got == expected: + print(f" ok selftest: bundled {rel} parses") + else: + print(f" FAIL selftest: bundled {rel} -> expected {expected}, got {got}") + ok = False + + print() + print("Selftest passed." if ok else "Selftest FAILED.") + return 0 if ok else 1 def main() -> int: parser = argparse.ArgumentParser( - description='Copy a shot prompt from a shotkit prompts file to the clipboard.' + description="Copy a shot prompt from a shotkit prompts file to the clipboard." ) parser.add_argument( - 'file', + "file", + nargs="?", type=Path, - help='Path to a prompt file (e.g. output/prompts/midjourney.txt)', + help="Path to a prompt file (e.g. output/prompts/round-1/flux.txt)", + ) + parser.add_argument( + "--shot", help="Shot ID to copy (e.g. shot_03). If omitted, prompts interactively." ) parser.add_argument( - '--shot', - help='Shot ID to copy (e.g. shot_03). If omitted, prompts interactively.', + "--list", action="store_true", help="List shots and exit. Does not copy." ) parser.add_argument( - '--list', - action='store_true', - help='List shots and exit. Does not copy.', + "--selftest", action="store_true", help="Prove both file shapes parse" ) args = parser.parse_args() + if args.selftest: + return selftest() + if args.file is None: + parser.error("file is required unless --selftest is given") + if not args.file.exists(): - print(f'File not found: {args.file}', file=sys.stderr) + print(f"File not found: {args.file}", file=sys.stderr) return 1 - shots = parse_prompt_file(args.file) - if not shots: - print(f'No shot blocks found in {args.file}.', file=sys.stderr) - print('Expected lines like: # shot_03, beat, timing, framing', file=sys.stderr) + blocks = parse_prompt_file(args.file) + if not blocks: + print(f"No shot blocks found in {args.file}.", file=sys.stderr) + print( + "Expected a comment line naming a shot, e.g. " + "'# shot_03, beat, timing, framing'.", + file=sys.stderr, + ) return 1 if args.list: - list_shots(shots) + list_blocks(blocks) return 0 if args.shot: - match = next((s for s in shots if s[0] == args.shot), None) - if not match: + matches = [b for b in blocks if b.shot_id == args.shot] + if not matches: print(f'Shot "{args.shot}" not found in {args.file}.', file=sys.stderr) - print('Available shots:', file=sys.stderr) - for sid, _, _ in shots: - print(f' {sid}', file=sys.stderr) + print("Available shots:", file=sys.stderr) + for block in blocks: + print(f" {block.shot_id}", file=sys.stderr) return 1 - selection = match + if len(matches) > 1: + print( + f'Shot "{args.shot}" appears {len(matches)} times in {args.file}; ' + f"copying the last block.", + file=sys.stderr, + ) + selection = matches[-1] else: - result = select_interactively(shots) + result = select_interactively(blocks) if result is None: return 1 selection = result - sid, _, body = selection - - if not body: - print(f'Shot {sid} has no prompt body to copy.', file=sys.stderr) - return 1 - + body = selection.prompt() if copy_to_clipboard(body): - print(f'Copied {sid} prompt to clipboard ({len(body)} chars).') + print(f"Copied {selection.shot_id} prompt to clipboard ({len(body)} chars).") + for note in selection.annotations: + print(f" {note}") return 0 print_clipboard_help() return 1 -if __name__ == '__main__': +if __name__ == "__main__": raise SystemExit(main()) diff --git a/tools/shots-to-html.py b/tools/shots-to-html.py index 7c72332..f03b5a4 100755 --- a/tools/shots-to-html.py +++ b/tools/shots-to-html.py @@ -1,216 +1,315 @@ #!/usr/bin/env python3 """ -Standalone helper: render shots.json + text-overlays.json + brand-lock.snapshot.md -into a single preview.html. +Standalone helper: render an output folder into a single preview.html. -This is the same logic the storyboard-html-preview skill applies, available -as a CLI for users who want to render previews without invoking Claude. +Renders skills/storyboard-html-preview/templates/preview.html.tpl, the same +structural template the storyboard-html-preview skill uses, so the two cannot drift. +All interpolated content is HTML-escaped: subjects, rationales, and VO lines are +model-generated prose, and one angle bracket in a rationale used to break the page a +client was looking at. + +Two timestamps, deliberately distinct. "Run" is when the storyboard was produced, read +from run.json. "Rendered" is when this page was written. Collapsing them into one +"Generated" date meant re-rendering a preview six months later silently restamped the +run as today. Usage: python tools/shots-to-html.py path/to/output-folder python tools/shots-to-html.py path/to/output-folder --out preview.html python tools/shots-to-html.py path/to/output-folder --inline-images + python tools/shots-to-html.py path/to/output-folder --rendered-at 2026-07-30T00:00:00Z + python tools/shots-to-html.py --selftest """ from __future__ import annotations + import argparse import base64 -import json -import re import sys -from datetime import datetime, timezone from pathlib import Path +from _shotkit import ( + IMAGE_EXTS, + PALETTE_ROLES, + REPO_ROOT, + as_overlay_ids, + find_frame, + iso_instant, + load_json, + parse_palette, + parse_typography, + round_dirs, + sha256_file, +) +from _template import escape, render -REPO_ROOT = Path(__file__).resolve().parent.parent TEMPLATE_DIR = REPO_ROOT / "skills" / "storyboard-html-preview" / "templates" +FALLBACK_BRAND = { + "bg": "#FFFFFF", + "ink": "#0F172A", + "accent": "#3B82F6", + "muted": "#64748B", + "rule": "#E2E8F0", + "display_font": "Inter", + "body_font": "Inter", +} + +FALLBACK_HEX = { + "background": "#FFFFFF", + "ink": "#0F172A", + "accent": "#3B82F6", + "muted": "#64748B", + "rule": "#E2E8F0", +} + def read_template(name: str) -> str: return (TEMPLATE_DIR / name).read_text(encoding="utf-8") -def parse_brand_lock(path: Path) -> dict: - """Extract palette, typography, and a few useful fields from brand-lock.snapshot.md.""" +def parse_brand_lock(path: Path) -> tuple[dict, list[str]]: + """ + Pull palette and typography out of a brand-lock. + + Returns (values, warnings). Warnings name every role that fell back to a generic + default, because a preview that quietly renders in someone else's blue is worse + than one that tells you the palette did not parse. + """ if not path.exists(): - return { - "bg": "#FFFFFF", - "ink": "#0F172A", - "accent": "#3B82F6", - "muted": "#64748B", - "rule": "#E2E8F0", - "display_font": "Inter", - "body_font": "Inter", - } + return (dict(FALLBACK_BRAND), [f"brand-lock not found at {path.name}"]) + text = path.read_text(encoding="utf-8") - palette = {} - for line in text.splitlines(): - m = re.match(r"^\|\s*([A-Za-z][A-Za-z\s]*?)\s*\|\s*`?(#[0-9A-Fa-f]{6})`?\s*\|", line) - if m: - role = m.group(1).strip().lower() - hex_val = m.group(2).strip() - palette[role] = hex_val - - def pick(keys: list[str], default: str) -> str: - for k in keys: - for role, hex_val in palette.items(): - if k in role: - return hex_val - return default - - return { - "bg": pick(["background"], "#FFFFFF"), - "ink": pick(["ink"], "#0F172A"), - "accent": pick(["accent (warm)", "accent"], "#3B82F6"), - "muted": pick(["muted"], "#64748B"), - "rule": pick(["rule"], "#E2E8F0"), - "display_font": "Inter", - "body_font": "Inter", + palette = parse_palette(text) + warnings: list[str] = [] + + def pick(role: str) -> str: + # Exact role first, then any role containing it, e.g. "accent (warm)". + if role in palette and not palette[role].startswith("#_"): + return palette[role] + for name, hex_val in palette.items(): + if role in name and not hex_val.startswith("#_"): + return hex_val + warnings.append( + f"palette role '{role}' not found in the brand-lock, using a generic default" + ) + return FALLBACK_HEX[role] + + values = { + "bg": pick("background"), + "ink": pick("ink"), + "accent": pick("accent"), + "muted": pick("muted"), + "rule": pick("rule"), } + fonts = parse_typography(text) + values["display_font"] = fonts.get("display_font") or FALLBACK_BRAND["display_font"] + values["body_font"] = fonts.get("body_font") or FALLBACK_BRAND["body_font"] + for key in ("display_font", "body_font"): + if not fonts.get(key): + warnings.append( + f"brand-lock declares no {key.replace('_', ' ')}, using " + f"{FALLBACK_BRAND[key]}" + ) -def aspect_class(aspect: str) -> str: - return "aspect-" + aspect.replace(":", "-") + return (values, warnings) -def find_image(generated_dir: Path, shot_id: str) -> Path | None: - if not generated_dir.exists(): - return None - for ext in ("png", "jpg", "jpeg", "webp"): - p = generated_dir / f"{shot_id}.{ext}" - if p.exists(): - return p - return None +def aspect_class(aspect: str) -> str: + return "aspect-" + str(aspect).replace(":", "-") def encode_image_b64(path: Path) -> str: ext = path.suffix.lstrip(".").lower() - mime = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "webp": "image/webp"}.get(ext, "image/png") + mime = { + "png": "image/png", + "jpg": "image/jpeg", + "jpeg": "image/jpeg", + "webp": "image/webp", + }.get(ext, "image/png") data = base64.b64encode(path.read_bytes()).decode("ascii") return f"data:{mime};base64,{data}" -def render_shot_block(shot: dict, overlay: dict | None, aspect: str, image_src: str | None) -> str: - aspect_cls = aspect_class(aspect) - id_short = shot["id"].replace("shot_", "") - - if image_src: - frame_inner = f'{shot[' - else: - frame_inner = ( - f'
' - f'
{id_short}
' - f'
{shot["framing"]} · {shot["angle"]} · {shot["motion"]}
' - f'
' +def frame_for_shot(out_dir: Path, shot: dict) -> tuple[Path | None, str | None]: + """ + Resolve a shot's frame, preferring what the data says over what the tree implies. + + Order: an accepted entry in shot.assets.generated, then the newest entry there, + then the frames/round-N path convention, then the legacy generated/ directory. + Returns (path, note) where note explains a non-obvious pick. + """ + generated = (shot.get("assets") or {}).get("generated") or [] + accepted = [g for g in generated if g.get("accepted") is True and g.get("path")] + pool = accepted or [g for g in generated if g.get("path")] + if pool: + chosen = max(pool, key=lambda g: g.get("round") or 0) + path = out_dir / chosen["path"] + if path.exists(): + note = None + if chosen.get("sha256"): + actual = sha256_file(path) + if actual != chosen["sha256"]: + note = "frame has changed since it was recorded in shots.json" + elif not accepted: + note = "frame is not marked accepted" + return (path, note) + return (None, f"assets names {chosen['path']}, which is missing on disk") + + found = find_frame(out_dir, shot["id"]) + if found is None: + return (None, None) + return (found, None) + + +def latest_verdicts(out_dir: Path) -> dict[str, dict]: + """Newest verdict per shot, read from the critique tree. Empty when there is none.""" + verdicts: dict[str, dict] = {} + for round_no, directory in round_dirs(out_dir, "critiques"): + for path in sorted(directory.glob("*.json")): + data, err = load_json(path) + if err or not isinstance(data, dict): + continue + shot_id = data.get("shot_id") + if not shot_id: + continue + current = verdicts.get(shot_id) + if current is None or round_no >= current["round"]: + verdicts[shot_id] = { + "round": round_no, + "verdict": data.get("verdict"), + } + legacy, err = load_json(out_dir / "critique.json") + if not err and isinstance(legacy, dict) and legacy.get("shot_id"): + verdicts.setdefault( + legacy["shot_id"], + {"round": legacy.get("round") or 1, "verdict": legacy.get("verdict")}, ) + return verdicts - overlay_block = "" - if overlay: - pos = overlay.get("position", "center") - if isinstance(pos, dict): - pos = "center" - overlay_block = ( - f'
' - f'' - f'{overlay["content"]}' - f'
' - ) - text_block = "" - if overlay: - enter = overlay["enter"] - exit_ = overlay["exit"] - text_block = ( - f'

On-screen text

' - f'

"{overlay["content"]}"

' - f'

{overlay["size"]} · {overlay.get("position", "center")} · ' - f'enter {enter["at"]}s ({enter["animation"]}) · exit {exit_["at"]}s

' - ) +def position_class(position) -> str: + """Named positions map to a CSS class; explicit coordinates fall back to center.""" + if isinstance(position, str): + return position + return "center" - vo_block = "" - if shot.get("vo"): - vo_block = f'

VO

"{shot["vo"]}"

' - - dof_block = "" - if shot.get("depth_of_field"): - dof_block = f'
DOF
{shot["depth_of_field"]}
' - - return f""" -
-
- {frame_inner} - {overlay_block} -
-
-
- {id_short} - {shot['start']}–{shot['end']}s - {shot['beat']} -
-
-
Framing
{shot['framing']}
-
Angle
{shot['angle']}
-
Motion
{shot['motion']}
- {dof_block} -
-

Subject

{shot['subject']}

- {vo_block} - {text_block} -

Rationale

{shot['rationale']}

-
-
-""" +def position_label(position) -> str: + if isinstance(position, dict): + return f"x{position.get('x')}% y{position.get('y')}%" + return str(position) -def render_nav(shots: list[dict]) -> str: - items = "\n".join( - f'
  • {s["id"].replace("shot_", "")}
  • ' - for s in shots - ) - return f'
      {items}
    ' +def build_context(out_dir: Path, args) -> tuple[dict, list[str]]: + warnings: list[str] = [] -def main() -> int: - p = argparse.ArgumentParser() - p.add_argument("output_dir", type=Path, help="Directory containing shots.json, text-overlays.json, etc.") - p.add_argument("--out", type=str, default="preview.html", help="Output filename (relative to output_dir)") - p.add_argument("--inline-images", action="store_true", help="Embed generated images as base64 (single-file portable)") - args = p.parse_args() + shots_data, err = load_json(out_dir / "shots.json") + if err: + raise SystemExit(f"ERROR: {err}") - out_dir: Path = args.output_dir - if not out_dir.exists(): - print(f"ERROR: output directory not found: {out_dir}") - return 1 - - shots_path = out_dir / "shots.json" - overlays_path = out_dir / "text-overlays.json" - brand_lock_path = out_dir / "brand-lock.snapshot.md" - generated_dir = out_dir / "generated" - - if not shots_path.exists(): - print(f"ERROR: shots.json not found in {out_dir}") - return 1 - - shots_data = json.loads(shots_path.read_text(encoding="utf-8")) - overlays_data = ( - json.loads(overlays_path.read_text(encoding="utf-8")) - if overlays_path.exists() else {"overlays": []} - ) - overlays_by_id = {o["id"]: o for o in overlays_data.get("overlays", [])} - - brand = parse_brand_lock(brand_lock_path) + overlays_data, ov_err = load_json(out_dir / "text-overlays.json") + if ov_err: + overlays_data = {"overlays": []} + overlays_by_id = { + o["id"]: o for o in (overlays_data or {}).get("overlays", []) if o.get("id") + } project = shots_data["project"] series = shots_data["series_lock"] shots = shots_data["shots"] aspect = project["aspect"] - # Build CSS - css_template = read_template("styles.css.tpl") - print_css = read_template("print.css.tpl") + brand_ref = shots_data.get("brand_lock_ref") or "brand-lock.snapshot.md" + brand_path = out_dir / brand_ref + brand, brand_warnings = parse_brand_lock(brand_path) + warnings.extend(brand_warnings) + + run_doc, run_err = load_json(out_dir / "run.json") + if run_err or not isinstance(run_doc, dict): + run_doc = {} + warnings.append( + "no run.json in this output tree, so the page cannot state when the run " + "happened or which inputs it used" + ) + + verdicts = latest_verdicts(out_dir) + provenance_notes: list[str] = [] + + shot_contexts = [] + for shot in shots: + frame_path, frame_note = frame_for_shot(out_dir, shot) + if frame_note: + provenance_notes.append(f"{shot['id']}: {frame_note}") + warnings.append(f"{shot['id']}: {frame_note}") + + image_path = None + if frame_path is not None: + if args.inline_images: + image_path = encode_image_b64(frame_path) + else: + try: + image_path = str(frame_path.relative_to(out_dir)) + except ValueError: + image_path = frame_path.name + + overlays = [] + for oid in as_overlay_ids(shot.get("on_screen_text")): + overlay = overlays_by_id.get(oid) + if overlay is None: + warnings.append( + f"{shot['id']} references overlay {oid}, which is not in " + f"text-overlays.json" + ) + continue + overlays.append( + { + "id": overlay["id"], + "content": overlay.get("content"), + "font": overlay.get("font"), + "weight": overlay.get("weight"), + "color": overlay.get("color"), + "size": overlay.get("size"), + "position_class": position_class(overlay.get("position")), + "position_label": position_label(overlay.get("position")), + "enter_at": (overlay.get("enter") or {}).get("at"), + "enter_animation": (overlay.get("enter") or {}).get("animation"), + "exit_at": (overlay.get("exit") or {}).get("at"), + "exit_animation": (overlay.get("exit") or {}).get("animation"), + } + ) + + verdict = verdicts.get(shot["id"]) + shot_contexts.append( + { + "id": shot["id"], + "id_short": shot["id"].replace("shot_", ""), + "beat": shot.get("beat"), + "start": shot.get("start"), + "end": shot.get("end"), + "framing": shot.get("framing"), + "angle": shot.get("angle"), + "motion": shot.get("motion"), + "depth_of_field": shot.get("depth_of_field"), + "subject": shot.get("subject"), + "vo": shot.get("vo"), + "rationale": shot.get("rationale"), + "aspect_class": aspect_class(aspect), + "has_image": image_path is not None, + "has_no_image": image_path is None, + "image_path": image_path, + "overlays": overlays, + "has_overlays": bool(overlays), + "has_verdict": verdict is not None, + "verdict": (verdict or {}).get("verdict"), + "verdict_round": (verdict or {}).get("round"), + "verdict_class": str((verdict or {}).get("verdict", "")).lower(), + } + ) + css = ( - css_template + read_template("styles.css.tpl") .replace("{{BG_COLOR}}", brand["bg"]) .replace("{{INK_COLOR}}", brand["ink"]) .replace("{{ACCENT_COLOR}}", brand["accent"]) @@ -218,119 +317,153 @@ def main() -> int: .replace("{{RULE_COLOR}}", brand["rule"]) .replace("{{DISPLAY_FONT}}", brand["display_font"]) .replace("{{BODY_FONT}}", brand["body_font"]) - .replace("{{INLINE_PRINT_CSS}}", print_css) + .replace("{{INLINE_PRINT_CSS}}", read_template("print.css.tpl")) ) - # Build shot blocks - shot_blocks = [] - for shot in shots: - overlay = overlays_by_id.get(shot.get("on_screen_text")) if shot.get("on_screen_text") else None + brand_sha = sha256_file(brand_path) if brand_path.exists() else None + recorded_sha = (run_doc.get("inputs") or {}).get("brand_lock_sha256") + if brand_sha and recorded_sha and brand_sha != recorded_sha: + provenance_notes.append( + "the brand-lock on disk no longer matches the one recorded in run.json" + ) + warnings.append( + "brand-lock has changed since the run; this preview does not show the " + "brand state the frames were produced against" + ) - image_src = None - img_path = find_image(generated_dir, shot["id"]) - if img_path: - if args.inline_images: - image_src = encode_image_b64(img_path) - else: - image_src = f"generated/{img_path.name}" - - shot_blocks.append(render_shot_block(shot, overlay, aspect, image_src)) - - # Assemble final HTML, using a simpler direct render rather than the templated one, - # to avoid full handlebars dependency. Output is equivalent. - timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") - nav = render_nav(shots) - shots_html = "\n".join(shot_blocks) - framework = project.get("framework", " ") - - html = f""" - - - - - -{project['title']} · Storyboard - - - - -
    -
    -
    Storyboard · v1.0
    -

    {project['title']}

    -
    -
    Duration
    {project['duration_s']}s
    -
    Aspect
    {aspect}
    -
    Framework
    {framework}
    -
    Generated
    {timestamp}
    -
    -
    -
    - - - -
    -

    Series lock

    -
    -
    Character
    {series['character']}
    -
    Environment
    {series['environment']}
    -
    Lighting
    {series['lighting']}
    -
    Color grade
    {series['color_grade']}
    -
    -
    - -
    -

    Shots

    - {shots_html} -
    - - - - - - - -""" + context = { + "PROJECT_TITLE": project["title"], + "DURATION": project["duration_s"], + "ASPECT": aspect, + "FRAMEWORK": project.get("framework") or "not specified", + "SHOTS_VERSION": shots_data.get("version", "unknown"), + "BRIEF": None, + "SERIES_CHARACTER": series["character"], + "SERIES_ENVIRONMENT": series["environment"], + "SERIES_LIGHTING": series["lighting"], + "SERIES_COLOR_GRADE": series["color_grade"], + "BRAND_LOCK_REF": brand_ref, + "BRAND_LOCK_SHA_SHORT": brand_sha[:12] if brand_sha else None, + "RUN_ID": run_doc.get("run_id") or "not recorded", + "RUN_CREATED_AT": run_doc.get("created_at") or "not recorded", + "RENDERED_AT": args.rendered_at or iso_instant(), + "PROVENANCE_NOTE": " ".join(provenance_notes) or None, + "INLINE_CSS": css, + "shots": shot_contexts, + } + return (context, warnings) + + +def selftest() -> int: + """Render the bundled worked run twice and prove the output is byte-identical.""" + worked = REPO_ROOT / "skills" / "visual-asset-critic" / "examples" / "worked-run" + ok = True + + class Args: + inline_images = False + rendered_at = "2026-07-30T00:00:00Z" + + first, _ = build_context(worked, Args()) + second, _ = build_context(worked, Args()) + html_a = render(read_template("preview.html.tpl"), first) + html_b = render(read_template("preview.html.tpl"), second) + + if html_a == html_b: + print(" ok selftest: two renders with a pinned timestamp are identical") + else: + print(" FAIL selftest: two renders differed") + ok = False + + # shot_06 of the shotkit-explainer example carries two overlays. A renderer that + # resolves only the first drops the second silently, which is what used to happen. + explainer = ( + REPO_ROOT + / "skills" + / "storyboard-architect" + / "examples" + / "shotkit-explainer" + ) + multi_ctx, _ = build_context(explainer, Args()) + shot_06 = next(s for s in multi_ctx["shots"] if s["id"] == "shot_06") + multi_html = render(read_template("preview.html.tpl"), multi_ctx) + + checks = [ + ("escapes angle brackets", "" not in render( + "{{subject}}", {"subject": ""} + )), + ("escapes quotes inside a style attribute", """ in render( + 'x', {"font": '">