agentic-ads-engine 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,25 +2,26 @@
2
2
 
3
3
  Offline tools for ad drafts. Node 20 or later. Plain JavaScript ESM with JSDoc, no build step, and zero runtime dependencies.
4
4
 
5
- ## Quick start
6
-
7
- Run from this checkout:
5
+ ## Install
8
6
 
9
7
  ```sh
10
- node bin/agentic-ads list-templates
11
- node bin/agentic-ads list-hooks
12
- node bin/agentic-ads lint --copy 'Browse available listings'
13
- node bin/agentic-ads plan --site demo --goal discovery --dry-run
14
- node test/e2e.mjs
8
+ npm install agentic-ads-engine # library and CLI in a project
9
+ npm install -g agentic-ads-engine # the agentic-ads and agentic-ads-mcp commands
15
10
  ```
16
11
 
17
- The package is not published by this task. To install the local package:
12
+ ## Quick start
18
13
 
19
14
  ```sh
20
- npm install /path/to/agentic-ads-engine
21
- npx agentic-ads plan
15
+ npx agentic-ads list-templates
16
+ npx agentic-ads hooks
17
+ npx agentic-ads lint --copy 'Browse available listings'
18
+ npx agentic-ads plan --site demo --goal discovery --dry-run
19
+ npx agentic-ads serve --port 8787 # HTTP API, loopback only unless AGENTIC_ADS_TOKEN is set
20
+ npx agentic-ads-mcp # MCP server over stdio
22
21
  ```
23
22
 
23
+ From a git checkout, use `node bin/agentic-ads ...` and run `node test/e2e.mjs` for the tests.
24
+
24
25
  CLI output is JSON. Exit codes: `0` for a result, `1` for invalid input, and `2` for blocked copy.
25
26
  `lint` requires `--copy`. It accepts `--category housing|none`; the default is `housing`.
26
27
  `plan` accepts `--site`, `--goal`, `--template`, `--ratio`, `--copy`, and `--category`.
@@ -99,10 +100,10 @@ News framing, staging, and urgency also require review. Conservative checks can
99
100
  The package does not inspect actual audience settings or implement current platform policy in full.
100
101
 
101
102
  Keep private data out of inputs. Contact-pattern checks are incomplete; they cannot reliably detect names, addresses, or secrets.
102
- The CLI prints supplied copy in plans. No telemetry, account integration, credentials, or network calls are included.
103
+ The CLI prints supplied copy in plans. No telemetry, account integration, or outbound network calls are included.
103
104
  No source assets or competitor copy are bundled. Research informs the formats and disclosure needs only.
104
105
 
105
- The E2E script spawns both executables and a library integration process.
106
+ The E2E script spawns the CLI, MCP server, HTTP server, and a library integration process.
106
107
  It records assertions in `test/artifacts/e2e.json`, which Git ignores and npm excludes.
107
108
  Run `npm run ci:local` before any future push. Publishing and pushing are separate operator actions.
108
109
 
@@ -115,7 +116,7 @@ node bin/agentic-ads formats show sitcom
115
116
  node bin/agentic-ads plan --format sitcom --site demo
116
117
  ```
117
118
 
118
- Twelve formats cover the eight catalog recipes, lo-fi UGC, sitcom, storytime, and talking objects.
119
+ Fifteen formats cover the eight catalog recipes, lo-fi UGC, sitcom, storytime, talking objects, street interviews, Backrooms dialogue, and horror comedy.
119
120
  MCP adds `list_formats({costClass?, risk?})`, `get_format({id})`, and `plan_format({format, site, goal?, dryRun?})`.
120
121
  These tools return the same JSON as the commands above.
121
122
  The library exports `listFormats`, `getFormat`, and `planFormat` from the package root and `agentic-ads-engine/formats`.
@@ -132,5 +133,116 @@ Lo-fi costs reference reused assets: $0.881 for three videos6 ads and $0.165 for
132
133
  Fresh generation estimates differ. The style catalog did not render its recommended local recipes.
133
134
  No paid calls, current price checks, media QA, or platform approval occur here.
134
135
 
135
- Run `node test/mutate-formats.mjs` to repeat the three deliberate faults and restore the files.
136
- It records failures and the restored pass in `test/artifacts/formats-mutations.json`.
136
+ Run `node test/mutate-formats.mjs` to repeat the twelve deliberate faults and restore the files.
137
+ It records detected faults and the restored run result in `test/artifacts/formats-mutations.json`.
138
+
139
+ ### Audio and caption gates
140
+
141
+ Plans include `gateChecklist` instructions from `src/qa/gates.json`. Each entry starts as `NOT CHECKED`.
142
+ All formats with captions and speech require these four gates:
143
+
144
+ | Gate | Required check |
145
+ | --- | --- |
146
+ | `loudness_master` | Final file: target -14 LUFS, band -16 to -12 LUFS, true peak at or below -1 dBTP. |
147
+ | `caption_ocr` | Final-file OCR character error rate: 0.10 or less for every caption. |
148
+ | `voice_consistency` | OWNER CHECK: pitch spread above 1.25 asks a person to listen. The tool check never fails. |
149
+ | `stem_sfx` | Use separate voice and SFX stems, with laughs left out of SFX. Supply beat times via `--cuts`. Require SFX onsets, with at least 80% within 80 ms of a cut. Cuts without SFX are fine. |
150
+
151
+ Missing stems or voice mappings leave dependent checks `NOT CHECKED`. Voice fit stays an owner check.
152
+ On our own videos, neither pitch spread nor a speaker embedding separated 1 rejected voice from 11 accepted voices.
153
+ This is a small sample; a person must listen with the face.
154
+
155
+ The new estimates describe logged reworks with reused assets. They are upper bounds, not current prices or fresh-production quotes.
156
+ Street interview uses the $0.874 phone-style rework. Backrooms uses the $0.506 VHS rework as its stage plan.
157
+ Its notes also record the $0.128 camcorder and $0.874 street variants. Horror comedy uses the $0.128 scare addition.
158
+ The historical VHS rework has no scare; R25 applies when adding a scare variant.
159
+ Reusing stills, clips and voices costs $0 to $0.13. A new talking clip costs about $0.15; lip sync remains weak.
160
+
161
+ ### Owner feedback checks
162
+
163
+ `agentic-ads qa-rules` lists R1 through R26. Use `agentic-ads qa-rules show R5`
164
+ to read one rule. MCP provides `list_qa_rules` and `get_qa_rule` with an `id` argument.
165
+
166
+ Each format declares `kinds` and `feedbackRules`. A format plan includes
167
+ `reviewChecklist` entries and the six instructions in `costRatchet`.
168
+ Every entry starts as `NOT CHECKED`. This status blocks shipping.
169
+ A reviewer must record PASS or FAIL with a measured value. Voice and sound fit need the owner's ear.
170
+
171
+ The JavaScript API exports `listRules()`, `getRule(id)`, and `checklistFor(format)`.
172
+ Pass a format descriptor from `getFormat(id)` to `checklistFor`.
173
+ Rules use `blocking` severity. `applies` names format kinds; `all` covers every format.
174
+ Story formats include stickman, POV, before-after, lo-fi, sitcom, storytime, and the three new formats.
175
+ Sitcom, street-interview and backrooms-dialogue have dialogue. Horror-comic has one character with speech. Street interviews use R24; horror-comic uses R25, and a Backrooms scare variant is planned as horror-comic.
176
+ Talking-object has visible speech. All 15 formats have captions and TTS.
177
+
178
+ Run `node test/mutate-qa.mjs` to verify five deliberate faults and restore the files.
179
+ Results go to `test/artifacts/qa-mutations.json` and `test/artifacts/e2e.json`.
180
+
181
+ ## Hook catalog
182
+
183
+ ```sh
184
+ node bin/agentic-ads hooks
185
+ node bin/agentic-ads hooks show shocked-face
186
+ node bin/agentic-ads hooks pick --format pov --emotion curiosity --housing
187
+ ```
188
+
189
+ The catalog contains 42 hooks. Each includes format IDs, emotion, kind, risk, a housing flag, and a short rationale.
190
+ The patterns paraphrase the supplied playbook and style catalog. They include the six requested owner feedback openings.
191
+ No research records, personal data, or account IDs are included.
192
+
193
+ MCP exposes `list_hooks({format?, emotion?, housing?})`, `get_hook({id})`, and `pick_hooks({format, emotion?, housing?})`.
194
+ CLI and MCP picks return all matching hooks in catalog order, with cold-start scores.
195
+ The original `list-hooks` command and MCP tool still return the whole catalog.
196
+ Format, emotion, and housing filters combine with AND. Invalid filters fail; no matches return an empty list.
197
+
198
+ The library exports `listHooks`, `getHook`, and `pickHooks(filter, scores = {})`.
199
+ Picks rank matching hooks by descending score. Catalog order resolves ties.
200
+ `selectFor(site, goal, filter = {})` retains its low-risk rule and returns one hook or `null`.
201
+ The original two-argument call still works. Filters apply before score ranking.
202
+ `housing: true` keeps only hooks with `housingSafe: true`. It does not filter by risk level, so medium-risk hooks can still appear.
203
+
204
+ `housingSafe` means the pattern passes housing lint with generic slot values and contains no eligibility claim, invented number, or testimonial.
205
+ It does not approve arbitrary slot values or rendered media. Fill every slot and lint the final copy before use.
206
+ Every result still requires human review. Personal savings, occupancy, interviews, and broken-trust claims remain outside the housing-safe set.
207
+
208
+ Run `node test/mutate-hooks.mjs` to verify three single-line faults and restore the catalog.
209
+ Evidence goes to `test/artifacts/hooks-mutations.json`. E2E results go to `test/artifacts/e2e.json`.
210
+ `test/hook-expectations.json` pins every ID and housing flag; review changes to this file explicitly.
211
+
212
+ ## HTTP API for admin sites
213
+
214
+ Start `node bin/agentic-ads serve`. The defaults are `127.0.0.1:8787`.
215
+ Use `--host H` and `--port N` to change them. Port `0` selects an available port.
216
+ The startup JSON reports the bound port. SIGTERM closes the server and its connections.
217
+
218
+ ```sh
219
+ node bin/agentic-ads serve --port 8787
220
+ curl http://127.0.0.1:8787/health
221
+ curl http://127.0.0.1:8787/v1/formats/sitcom
222
+ curl http://127.0.0.1:8787/v1/qa-rules/R5
223
+ curl -H 'Content-Type: application/json' -d '{"copy":"Browse listings","category":"housing"}' http://127.0.0.1:8787/v1/lint
224
+ curl -H 'Content-Type: application/json' -d '{"format":"sitcom","site":"demo"}' http://127.0.0.1:8787/v1/plan
225
+ ```
226
+
227
+ GET routes: `/health`, `/v1/formats`, `/v1/formats/:id`, `/v1/hooks`, `/v1/templates`,
228
+ `/v1/qa-rules`, and `/v1/qa-rules/:id`. Health returns `{"status":"ok"}`.
229
+ POST routes: `/v1/lint` and `/v1/plan`. Their results match the CLI and MCP tools.
230
+ Blocked lint findings return HTTP 200 with `status: "blocked"`.
231
+ Plans only produce dry-run results. Any `apply` field is refused, including nested fields and query parameters.
232
+ JSON bodies have a 64 KiB limit. Requests have a ten-second timeout.
233
+ Errors return JSON without stacks: 400 invalid input, 401 unauthorized, 403 forbidden host, 404 missing route or item,
234
+ 405 wrong method, 408 timeout, 413 oversized body, and 415 unsupported media type.
235
+
236
+ Set `AGENTIC_ADS_TOKEN` to require `Authorization: Bearer <token>` on every route, including health.
237
+ The token needs 32 or more characters and must not start or end with spaces. Without a token, the server accepts only loopback `Host` values with its own port. This blocks DNS rebinding from a web page.
238
+ A non-loopback host requires this setting. The server never logs bodies or tokens.
239
+ Responses containing the configured token are refused. Use TLS through your admin site's server for remote access.
240
+
241
+ An admin site's backend can call these routes and keep the token in server-side settings.
242
+ Proxy calls through that backend for browser clients. CORS headers are absent by default.
243
+ `AGENTIC_ADS_CORS_ORIGIN=https://admin.example` allows that exact origin in response headers.
244
+ Unauthenticated browser preflight requests still return 401 when a token is set.
245
+ Direct cross-origin browser calls with bearer headers therefore require the backend proxy.
246
+
247
+ Run `node test/mutate-api.mjs` to prove authentication, the Host check, the token length rule, body limits, and apply checks detect deliberate faults.
248
+ The script restores the source and writes `test/artifacts/api-mutations.json`.
package/bin/agentic-ads CHANGED
@@ -2,14 +2,26 @@
2
2
  import { execute, tools } from '../src/index.js';
3
3
  try {
4
4
  let [action, ...argv] = process.argv.slice(2);
5
+ if (action === 'serve') {
6
+ await (await import('../src/http.js')).serve(argv);
7
+ } else {
5
8
  const args = {};
9
+ if (action === 'hooks') {
10
+ action = argv[0] === 'show' ? 'get_hook' : argv[0] === 'pick' ? 'pick_hooks' : 'list_hooks';
11
+ if (action === 'get_hook') { argv.shift(); args.id = argv.shift(); }
12
+ if (action === 'pick_hooks' || argv[0] === 'list') argv.shift();
13
+ }
14
+ if (action === 'qa-rules') {
15
+ action = argv[0] === 'show' ? 'get_qa_rule' : 'list_qa_rules';
16
+ if (action === 'get_qa_rule') { argv.shift(); args.id = argv.shift(); }
17
+ }
6
18
  if (action === 'formats') {
7
19
  action = argv[0] === 'show' ? 'get_format' : 'list_formats';
8
20
  if (action === 'get_format') { argv.shift(); args.id = argv.shift(); }
9
21
  }
10
22
  if (action === 'plan' && argv.some(arg => arg === '--format' || arg.startsWith('--format='))) action = 'plan_format';
11
23
  const schema = tools.find(t => t.name === action)?.inputSchema;
12
- if (!schema) throw new Error('Use formats, list-templates, list-hooks, lint, or plan');
24
+ if (!schema) throw new Error('Use hooks, qa-rules, formats, list-templates, list-hooks, lint, plan, or serve');
13
25
  while (argv.length) {
14
26
  const option = argv.shift(), separator = option.indexOf('=');
15
27
  const flag = separator < 0 ? option : option.slice(0, separator);
@@ -31,6 +43,7 @@ try {
31
43
  const result = execute(action, args);
32
44
  console.log(JSON.stringify(result));
33
45
  if (result.status === 'blocked' || result.policy?.status === 'blocked') process.exitCode = 2;
46
+ }
34
47
  } catch (error) {
35
48
  console.log(JSON.stringify({ error: error.message })); process.exitCode = 1;
36
49
  }
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
2
3
  import { execute, tools } from '../src/index.js';
4
+ const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
3
5
  // MCP stdio uses one JSON-RPC message per line. stdout is protocol-only.
4
6
  const send = message => process.stdout.write(JSON.stringify(message) + '\n');
5
7
  const fail = (id, code, message) => send({ jsonrpc: '2.0', id, error: { code, message } });
@@ -17,7 +19,7 @@ function receive(line) {
17
19
  if (method === 'initialize') {
18
20
  if (typeof params.protocolVersion !== 'string') { fail(id, -32602, 'Missing protocol version'); return; }
19
21
  initialized = true;
20
- result = { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'agentic-ads-engine', version: '0.1.0' } };
22
+ result = { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'agentic-ads-engine', version } };
21
23
  } else if (method === 'ping') result = {};
22
24
  else if (!initialized) { fail(id, -32002, 'Initialize first'); return; }
23
25
  else if (method === 'tools/list') result = { tools };
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "agentic-ads-engine",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Offline ad templates, hook selection, policy lint, and planning tools",
5
5
  "type": "module",
6
6
  "engines": { "node": ">=20" },
7
7
  "license": "MIT",
8
+ "repository": { "type": "git", "url": "git+https://github.com/itsryanarmani/agentic-ads-engine.git" },
9
+ "homepage": "https://github.com/itsryanarmani/agentic-ads-engine#readme",
10
+ "bugs": { "url": "https://github.com/itsryanarmani/agentic-ads-engine/issues" },
8
11
  "exports": { ".": "./src/index.js", "./formats": "./src/formats/index.js", "./templates": "./src/templates/index.js", "./hooks": "./src/hooks/index.js", "./policy": "./src/policy/index.js", "./providers": "./src/providers/index.js" },
9
12
  "bin": { "agentic-ads": "./bin/agentic-ads", "agentic-ads-mcp": "./bin/agentic-ads-mcp" },
10
13
  "files": ["src/", "bin/", "README.md", "LICENSE"],
@@ -1,3 +1,4 @@
1
+ import { checklistFor, gateChecklistFor, costRatchet } from '../qa/index.js';
1
2
  import { readFileSync } from 'node:fs';
2
3
  const library = JSON.parse(readFileSync(new URL('./library.json', import.meta.url), 'utf8'));
3
4
  const costs = ['free', 'cheap', 'mid', 'expensive'], risks = ['low', 'medium', 'high'];
@@ -28,7 +29,8 @@ export function planFormat(input = {}) {
28
29
  const stages = f.stages.map(id => ({ id, provider: f.providers[id], estimatedCostUsd: f.providers[id].estimatedCostUsd }));
29
30
  const sum = key => Math.round(stages.reduce((n, s) => n + s.estimatedCostUsd[key], 0) * 1000) / 1000;
30
31
  return { format: f.id, site: input.site, goal, dryRun: true, applied: false, spendUsd: 0, stages,
32
+ reviewChecklist: checklistFor(f), gateChecklist: gateChecklistFor(f), costRatchet,
31
33
  qaGates: f.qaGates, policyRisk: f.policyRisk, riskNotes: f.riskNotes,
32
34
  total: { low: sum('low'), high: sum('high'), note: f.estimatedCostUsd.note },
33
- warnings: ['Plan only; no providers or QA checks run.', 'Historical source review dates are not current price verification.'] };
35
+ warnings: ['Plan only; no providers or QA checks run.', 'NOT CHECKED blocks shipping. Record PASS or FAIL with a measured value. Voice fit and sound fit need the owner’s ear.', 'Historical source review dates are not current price verification.'] };
34
36
  }
@@ -1,6 +1,8 @@
1
1
  [
2
2
  {
3
3
  "id": "screen-voiceover",
4
+ "kinds": ["captions"],
5
+ "feedbackRules": ["R1", "R6", "R7", "R10", "R12", "R14", "R17", "R18", "R19", "R23"],
4
6
  "name": "Screen recording + voiceover",
5
7
  "description": "Record a real or generic checklist UI with timed narration.",
6
8
  "aspectRatios": ["9:16"],
@@ -12,12 +14,14 @@
12
14
  "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: sum of stage assumptions; styles catalog recipes; excludes labour, compute, and unbounded retries"},
13
15
  "policyRisk": "low",
14
16
  "riskNotes": ["Use the housing special ad category for housing ads.", "No fake testimonials, eligibility claims, or promised savings.", "No real people or brands."],
15
- "qaGates": ["caption_timing", "dead_air", "tail", "shot_length", "mute_test", "orientation"],
17
+ "qaGates": ["caption_timing", "dead_air", "tail", "shot_length", "mute_test", "orientation", "loudness_master", "caption_ocr", "voice_consistency", "stem_sfx"],
16
18
  "learned": ["Record only generic UI; exclude real addresses and prices.", "Cut on measured voice beats; dead air must stay below 0.4 seconds."],
17
19
  "sources": ["styles/out-styles.md", "storyboards/INDEX.md", "editing/eval3/engine-ideas.md"]
18
20
  },
19
21
  {
20
22
  "id": "text-broll",
23
+ "kinds": ["captions"],
24
+ "feedbackRules": ["R1", "R6", "R7", "R10", "R12", "R14", "R17", "R18", "R19", "R23"],
21
25
  "name": "Text over b-roll",
22
26
  "description": "Animate exact caption cards over supplied moving footage.",
23
27
  "aspectRatios": ["9:16"],
@@ -29,12 +33,14 @@
29
33
  "estimatedCostUsd": {"low": 0, "high": 0.02, "note": "estimate: sum of stage assumptions; styles catalog recipes; excludes labour, compute, and unbounded retries"},
30
34
  "policyRisk": "low",
31
35
  "riskNotes": ["Use the housing special ad category for housing ads.", "No fake testimonials, eligibility claims, or promised savings.", "No real people or brands."],
32
- "qaGates": ["caption_timing", "dead_air", "tail", "shot_length", "mute_test", "orientation"],
36
+ "qaGates": ["caption_timing", "dead_air", "tail", "shot_length", "mute_test", "orientation", "loudness_master", "caption_ocr", "voice_consistency", "stem_sfx"],
33
37
  "learned": ["Render text as HTML overlays, never generated pixels.", "Use at most eight words per card; hold the end card for 1.5 seconds."],
34
38
  "sources": ["styles/out-styles.md", "storyboards/INDEX.md", "editing/eval3/engine-ideas.md", "videos6/spend.json", "videos4/spend.json"]
35
39
  },
36
40
  {
37
41
  "id": "whiteboard",
42
+ "kinds": ["captions"],
43
+ "feedbackRules": ["R1", "R6", "R7", "R10", "R12", "R14", "R17", "R18", "R19", "R23"],
38
44
  "name": "Whiteboard doodle",
39
45
  "description": "Draw SVG ideas to narration word times.",
40
46
  "aspectRatios": ["9:16"],
@@ -46,12 +52,14 @@
46
52
  "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: sum of stage assumptions; styles catalog recipes; excludes labour, compute, and unbounded retries"},
47
53
  "policyRisk": "low",
48
54
  "riskNotes": ["Use the housing special ad category for housing ads.", "No fake testimonials, eligibility claims, or promised savings.", "No real people or brands."],
49
- "qaGates": ["caption_timing", "dead_air", "tail", "shot_length", "mute_test", "orientation"],
55
+ "qaGates": ["caption_timing", "dead_air", "tail", "shot_length", "mute_test", "orientation", "loudness_master", "caption_ocr", "voice_consistency", "stem_sfx"],
50
56
  "learned": ["Use a faceless renderer; the whiteboard face recipe needs a face plate.", "Trigger strokes within three frames of their words; avoid static boards over two seconds."],
51
57
  "sources": ["styles/out-styles.md", "storyboards/INDEX.md", "editing/eval3/engine-ideas.md"]
52
58
  },
53
59
  {
54
60
  "id": "stickman",
61
+ "kinds": ["captions", "story", "character"],
62
+ "feedbackRules": ["R1", "R2", "R3", "R4", "R5", "R6", "R7", "R8", "R9", "R10", "R12", "R14", "R15", "R17", "R18", "R19", "R20", "R23", "R26"],
55
63
  "name": "Stickman explainer",
56
64
  "description": "Animate a shared SVG character through a problem and answer.",
57
65
  "aspectRatios": ["9:16"],
@@ -63,12 +71,14 @@
63
71
  "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: sum of stage assumptions; styles catalog recipes; excludes labour, compute, and unbounded retries"},
64
72
  "policyRisk": "low",
65
73
  "riskNotes": ["Use the housing special ad category for housing ads.", "No fake testimonials, eligibility claims, or promised savings.", "No real people or brands."],
66
- "qaGates": ["caption_timing", "dead_air", "tail", "shot_length", "mute_test", "orientation"],
74
+ "qaGates": ["caption_timing", "dead_air", "tail", "shot_length", "mute_test", "orientation", "loudness_master", "caption_ocr", "voice_consistency", "stem_sfx"],
67
75
  "learned": ["Reuse one SVG character asset for consistent proportions.", "Include motion every two seconds; avoid holds over 2.5 seconds."],
68
76
  "sources": ["styles/out-styles.md", "storyboards/INDEX.md", "editing/eval3/engine-ideas.md"]
69
77
  },
70
78
  {
71
79
  "id": "pov",
80
+ "kinds": ["captions", "story", "character"],
81
+ "feedbackRules": ["R1", "R2", "R3", "R4", "R5", "R6", "R7", "R8", "R9", "R10", "R12", "R14", "R15", "R17", "R18", "R19", "R20", "R23", "R26"],
72
82
  "name": "POV discovery",
73
83
  "description": "Use consistent first-person clips with a title and checklist reveal.",
74
84
  "aspectRatios": ["9:16"],
@@ -80,12 +90,14 @@
80
90
  "estimatedCostUsd": {"low": 0.256, "high": 0.611, "note": "estimate: sum of stage assumptions; styles catalog recipes; excludes labour, compute, and unbounded retries"},
81
91
  "policyRisk": "medium",
82
92
  "riskNotes": ["Use the housing special ad category for housing ads.", "No fake testimonials, eligibility claims, or promised savings.", "Use a DRAMATIZATION label for fictional people and skits.", "No real people. Synthetic people only; no real or named person; no children; no likeness of a real person."],
83
- "qaGates": ["caption_timing", "dead_air", "tail", "shot_length", "mute_test", "orientation", "continuity", "words_per_second", "black_corners", "eyes"],
93
+ "qaGates": ["caption_timing", "dead_air", "tail", "shot_length", "mute_test", "orientation", "continuity", "words_per_second", "black_corners", "eyes", "loudness_master", "caption_ocr", "voice_consistency", "stem_sfx"],
84
94
  "learned": ["Chain the final frame into the next shot for continuity.", "Cut on voice beats; keep real listing data out of backgrounds."],
85
95
  "sources": ["styles/out-styles.md", "storyboards/INDEX.md", "editing/eval3/engine-ideas.md", "videos6/spend.json", "videos4/spend.json", "sitcom3/spend.json", "videos5/spend.json"]
86
96
  },
87
97
  {
88
98
  "id": "telestrator",
99
+ "kinds": ["captions"],
100
+ "feedbackRules": ["R1", "R6", "R7", "R10", "R12", "R14", "R17", "R18", "R19", "R23"],
89
101
  "name": "Telestrator annotation",
90
102
  "description": "Circle relevant UI details with word-timed SVG strokes.",
91
103
  "aspectRatios": ["9:16"],
@@ -97,12 +109,14 @@
97
109
  "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: sum of stage assumptions; styles catalog recipes; excludes labour, compute, and unbounded retries"},
98
110
  "policyRisk": "low",
99
111
  "riskNotes": ["Use the housing special ad category for housing ads.", "No fake testimonials, eligibility claims, or promised savings.", "No real people or brands."],
100
- "qaGates": ["caption_timing", "dead_air", "tail", "shot_length", "mute_test", "orientation"],
112
+ "qaGates": ["caption_timing", "dead_air", "tail", "shot_length", "mute_test", "orientation", "loudness_master", "caption_ocr", "voice_consistency", "stem_sfx"],
101
113
  "learned": ["Keep annotations clear of the element they explain.", "Use deterministic strokes to avoid drift between frames."],
102
114
  "sources": ["styles/out-styles.md", "storyboards/INDEX.md", "editing/eval3/engine-ideas.md"]
103
115
  },
104
116
  {
105
117
  "id": "before-after",
118
+ "kinds": ["captions", "story"],
119
+ "feedbackRules": ["R1", "R2", "R5", "R6", "R7", "R9", "R10", "R12", "R14", "R17", "R18", "R19", "R20", "R23", "R26"],
106
120
  "name": "Before and after",
107
121
  "description": "Wipe from confusing paperwork to an actual checklist.",
108
122
  "aspectRatios": ["9:16"],
@@ -114,12 +128,14 @@
114
128
  "estimatedCostUsd": {"low": 0.256, "high": 0.611, "note": "estimate: sum of stage assumptions; styles catalog recipes; excludes labour, compute, and unbounded retries"},
115
129
  "policyRisk": "medium",
116
130
  "riskNotes": ["Use the housing special ad category for housing ads.", "No fake testimonials, eligibility claims, or promised savings.", "Use a DRAMATIZATION label for fictional people and skits.", "No real people. Synthetic people only; no real or named person; no children; no likeness of a real person."],
117
- "qaGates": ["caption_timing", "dead_air", "tail", "shot_length", "mute_test", "orientation", "continuity", "words_per_second", "black_corners", "eyes"],
131
+ "qaGates": ["caption_timing", "dead_air", "tail", "shot_length", "mute_test", "orientation", "continuity", "words_per_second", "black_corners", "eyes", "loudness_master", "caption_ocr", "voice_consistency", "stem_sfx"],
118
132
  "learned": ["Align both views before the wipe; match their colour grade.", "Show an achievable checklist result, never promised savings."],
119
133
  "sources": ["styles/out-styles.md", "storyboards/INDEX.md", "editing/eval3/engine-ideas.md", "videos6/spend.json", "videos4/spend.json", "sitcom3/spend.json", "videos5/spend.json"]
120
134
  },
121
135
  {
122
136
  "id": "news-parody",
137
+ "kinds": ["captions"],
138
+ "feedbackRules": ["R1", "R6", "R7", "R10", "R12", "R14", "R17", "R18", "R19", "R23"],
123
139
  "name": "News parody",
124
140
  "description": "Use branded news cards with sourced facts and narration.",
125
141
  "aspectRatios": ["9:16"],
@@ -131,12 +147,14 @@
131
147
  "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: sum of stage assumptions; styles catalog recipes; excludes labour, compute, and unbounded retries"},
132
148
  "policyRisk": "medium",
133
149
  "riskNotes": ["Use the housing special ad category for housing ads.", "No fake testimonials, eligibility claims, or promised savings.", "Label parody; do not impersonate a real news outlet.", "No real people. Synthetic people only; no real or named person; no children; no likeness of a real person."],
134
- "qaGates": ["caption_timing", "dead_air", "tail", "shot_length", "mute_test", "orientation"],
150
+ "qaGates": ["caption_timing", "dead_air", "tail", "shot_length", "mute_test", "orientation", "loudness_master", "caption_ocr", "voice_consistency", "stem_sfx"],
135
151
  "learned": ["Trace every fact to a source; never imply a real outlet endorsement.", "Use generic branding and make the parody clear."],
136
152
  "sources": ["styles/out-styles.md", "storyboards/INDEX.md", "editing/eval3/engine-ideas.md"]
137
153
  },
138
154
  {
139
155
  "id": "lo-fi",
156
+ "kinds": ["captions", "story", "character", "lo-fi"],
157
+ "feedbackRules": ["R1", "R2", "R3", "R4", "R5", "R6", "R7", "R8", "R9", "R10", "R12", "R14", "R15", "R16", "R17", "R18", "R19", "R20", "R23", "R26"],
140
158
  "name": "Handheld lo-fi UGC",
141
159
  "description": "Use synthetic handheld scenes and narration with fast reaction cuts.",
142
160
  "aspectRatios": ["9:16"],
@@ -148,12 +166,14 @@
148
166
  "estimatedCostUsd": {"low": 0.256, "high": 0.636, "note": "estimate: sum of stage assumptions; styles catalog recipes; excludes labour, compute, and unbounded retries; videos6 $0.881 for three reworked ads with reuse; videos5 $0.165 TTS-only for three edits, not fresh generation"},
149
167
  "policyRisk": "medium",
150
168
  "riskNotes": ["Use the housing special ad category for housing ads.", "No fake testimonials, eligibility claims, or promised savings.", "Use a DRAMATIZATION label for fictional people and skits.", "No real people. Synthetic people only; no real or named person; no children; no likeness of a real person."],
151
- "qaGates": ["caption_timing", "dead_air", "tail", "shot_length", "mute_test", "orientation", "continuity", "words_per_second", "black_corners", "eyes"],
169
+ "qaGates": ["caption_timing", "dead_air", "tail", "shot_length", "mute_test", "orientation", "continuity", "words_per_second", "black_corners", "eyes", "loudness_master", "caption_ocr", "voice_consistency", "stem_sfx"],
152
170
  "learned": ["No still frames as shots; animate source stills before the edit.", "Keep voice at its native pace; check words per second below 3.4.", "Exclude knob-to-lever morphs and fused fingers before cutting."],
153
171
  "sources": ["styles/out-styles.md", "storyboards/INDEX.md", "editing/eval3/engine-ideas.md", "videos6/spend.json", "videos6/voices.md", "videos6/qa.json", "videos5/EDIT_NOTES.md", "videos4/spend.json", "sitcom3/spend.json", "videos5/spend.json"]
154
172
  },
155
173
  {
156
174
  "id": "sitcom",
175
+ "kinds": ["captions", "story", "character", "dialogue"],
176
+ "feedbackRules": ["R1", "R2", "R3", "R4", "R5", "R6", "R7", "R8", "R9", "R10", "R11", "R12", "R13", "R14", "R15", "R17", "R18", "R19", "R20", "R21", "R22", "R23", "R26"],
157
177
  "name": "Multi-camera sitcom",
158
178
  "description": "Cut fictional dialogue between shared sets, angles, and silent reactions.",
159
179
  "aspectRatios": ["9:16"],
@@ -165,12 +185,14 @@
165
185
  "estimatedCostUsd": {"low": 1.756, "high": 2.411, "note": "estimate: sum of stage assumptions; styles catalog recipes; excludes labour, compute, and unbounded retries; sitcom3 actual $2.466 includes trials and one retry, four mouth checks failed"},
166
186
  "policyRisk": "medium",
167
187
  "riskNotes": ["Use the housing special ad category for housing ads.", "No fake testimonials, eligibility claims, or promised savings.", "Use a DRAMATIZATION label for fictional people and skits.", "No real people. Synthetic people only; no real or named person; no children; no likeness of a real person."],
168
- "qaGates": ["caption_timing", "dead_air", "tail", "shot_length", "mute_test", "orientation", "continuity", "words_per_second", "black_corners", "eyes", "mouth_sync"],
188
+ "qaGates": ["caption_timing", "dead_air", "tail", "shot_length", "mute_test", "orientation", "continuity", "words_per_second", "black_corners", "eyes", "mouth_sync", "loudness_master", "caption_ocr", "voice_consistency", "stem_sfx"],
169
189
  "learned": ["Use mediums and close-ups for speech; inspect eyes at full size.", "Cut on action for the door fall: knob, crash, then aftermath.", "No still frames as shots; laughs belong on silent reaction shots.", "Trim talks to line plus 0.1 seconds; time captions from actual words.", "Wan passed only six of ten mouth-sync checks; a retry still failed."],
170
190
  "sources": ["styles/out-styles.md", "storyboards/INDEX.md", "editing/eval3/engine-ideas.md", "sitcom3/NOTES.md", "sitcom3/qa.json", "videos6/spend.json", "videos4/spend.json", "sitcom3/spend.json", "videos5/spend.json"]
171
191
  },
172
192
  {
173
193
  "id": "storytime",
194
+ "kinds": ["captions", "story", "character"],
195
+ "feedbackRules": ["R1", "R2", "R3", "R4", "R5", "R6", "R7", "R8", "R9", "R10", "R12", "R14", "R15", "R17", "R18", "R19", "R20", "R23", "R26"],
174
196
  "name": "Storytime twist",
175
197
  "description": "A fictional narrator moves from confession to checklist payoff.",
176
198
  "aspectRatios": ["9:16"],
@@ -182,12 +204,14 @@
182
204
  "estimatedCostUsd": {"low": 0.256, "high": 0.611, "note": "estimate: sum of stage assumptions; styles catalog recipes; excludes labour, compute, and unbounded retries"},
183
205
  "policyRisk": "medium",
184
206
  "riskNotes": ["Use the housing special ad category for housing ads.", "No fake testimonials, eligibility claims, or promised savings.", "Use a DRAMATIZATION label for fictional people and skits.", "No real people. Synthetic people only; no real or named person; no children; no likeness of a real person."],
185
- "qaGates": ["caption_timing", "dead_air", "tail", "shot_length", "mute_test", "orientation", "continuity", "words_per_second", "black_corners", "eyes"],
207
+ "qaGates": ["caption_timing", "dead_air", "tail", "shot_length", "mute_test", "orientation", "continuity", "words_per_second", "black_corners", "eyes", "loudness_master", "caption_ocr", "voice_consistency", "stem_sfx"],
186
208
  "learned": ["Keep the character bible identical across shots.", "Use a fictional framing and a checklist payoff, not a testimonial."],
187
209
  "sources": ["styles/out-styles.md", "storyboards/INDEX.md", "editing/eval3/engine-ideas.md", "videos6/spend.json", "videos4/spend.json", "sitcom3/spend.json", "videos5/spend.json"]
188
210
  },
189
211
  {
190
212
  "id": "talking-object",
213
+ "kinds": ["captions", "character", "speech"],
214
+ "feedbackRules": ["R1", "R3", "R4", "R6", "R7", "R8", "R10", "R11", "R12", "R14", "R15", "R17", "R18", "R19", "R20", "R23", "R26"],
191
215
  "name": "Talking checklist",
192
216
  "description": "Animate an illustrated object mouth to narration.",
193
217
  "aspectRatios": ["9:16"],
@@ -199,8 +223,65 @@
199
223
  "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: sum of stage assumptions; styles catalog recipes; excludes labour, compute, and unbounded retries"},
200
224
  "policyRisk": "low",
201
225
  "riskNotes": ["Use the housing special ad category for housing ads.", "No fake testimonials, eligibility claims, or promised savings.", "No real people. Synthetic people only; no real or named person; no children; no likeness of a real person."],
202
- "qaGates": ["caption_timing", "dead_air", "tail", "shot_length", "mute_test", "orientation", "mouth_sync"],
226
+ "qaGates": ["caption_timing", "dead_air", "tail", "shot_length", "mute_test", "orientation", "mouth_sync", "loudness_master", "caption_ocr", "voice_consistency", "stem_sfx"],
203
227
  "learned": ["Use an illustrated object with simple mouth flaps.", "Keep all readable text in exact overlays."],
204
228
  "sources": ["styles/out-styles.md", "storyboards/INDEX.md", "editing/eval3/engine-ideas.md"]
229
+ },
230
+ {
231
+ "id": "street-interview",
232
+ "kinds": ["captions", "story", "character", "dialogue", "interview", "lo-fi"],
233
+ "feedbackRules": ["R1", "R2", "R3", "R4", "R5", "R6", "R7", "R8", "R9", "R10", "R11", "R12", "R13", "R14", "R15", "R16", "R17", "R18", "R19", "R20", "R21", "R22", "R23", "R24", "R26"],
234
+ "name": "Street interview",
235
+ "description": "A host with a visible handheld mic questions a guest in a 2010s phone look.",
236
+ "aspectRatios": ["9:16"],
237
+ "durationSeconds": {"min": 16, "max": 22},
238
+ "hookPattern": "A startled host holds the mic on the left; the guest stays on the right in every shot.",
239
+ "stages": ["script", "set", "character", "still", "angle_still", "tts", "image_to_video", "talking_head", "sfx", "captions", "edit", "end_card", "mute_test", "qa"],
240
+ "providers": {"script": {"model": "supplied script / human review", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "Reuse existing assets or local processing; backrooms_2010s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Excludes labour and compute; no fresh-production quote."}}, "set": {"model": "reuse supplied set", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "Reuse existing assets or local processing; backrooms_2010s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Excludes labour and compute; no fresh-production quote."}}, "character": {"model": "reuse character bible", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "Reuse existing assets or local processing; backrooms_2010s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Excludes labour and compute; no fresh-production quote."}}, "still": {"model": "bytedance/seedream-v5.5", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0.124, "high": 0.124, "note": "Logged stage total; still includes all image edits ($0.124 reported). Unit counts imply $0.132; allocation is unreconciled. backrooms_2010s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Excludes labour and compute; no fresh-production quote."}}, "angle_still": {"model": "bytedance/seedream-v5.5/edit", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "Included in still stage; backrooms_2010s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Excludes labour and compute; no fresh-production quote."}}, "tts": {"model": "wavespeed-ai/qwen3-tts/text-to-speech", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "Reuse existing assets or local processing; backrooms_2010s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Excludes labour and compute; no fresh-production quote."}}, "image_to_video": {"model": "wavespeed-ai/wan-2.2/i2v-480p-ultra-fast", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "Reuse existing assets or local processing; backrooms_2010s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Excludes labour and compute; no fresh-production quote."}}, "talking_head": {"model": "wavespeed-ai/wan-2.2/speech-to-video", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0.75, "high": 0.75, "note": "Logged rework allocation; backrooms_2010s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Excludes labour and compute; no fresh-production quote."}}, "sfx": {"model": "ffmpeg lavfi / licensed CC0", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "Reuse existing assets or local processing; backrooms_2010s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Excludes labour and compute; no fresh-production quote."}}, "captions": {"model": "whisper.cpp + HTML", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "Reuse existing assets or local processing; backrooms_2010s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Excludes labour and compute; no fresh-production quote."}}, "edit": {"model": "ffmpeg + SVG", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "Reuse existing assets or local processing; backrooms_2010s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Excludes labour and compute; no fresh-production quote."}}, "end_card": {"model": "HTML overlay", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "Reuse existing assets or local processing; backrooms_2010s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Excludes labour and compute; no fresh-production quote."}}, "mute_test": {"model": "human review of muted render", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "Reuse existing assets or local processing; backrooms_2010s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Excludes labour and compute; no fresh-production quote."}}, "qa": {"model": "ffmpeg + human review", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "Reuse existing assets or local processing; backrooms_2010s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Excludes labour and compute; no fresh-production quote."}}},
241
+ "costClass": "mid",
242
+ "estimatedCostUsd": {"low": 0.874, "high": 0.874, "note": "Logged rework upper bound: backrooms_2010s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Reuse required; excludes labour, compute and further retries."},
243
+ "policyRisk": "medium",
244
+ "riskNotes": ["Use the housing special ad category for housing ads.", "No fake testimonials, eligibility claims, or promised savings.", "Use a DRAMATIZATION label for fictional people and skits.", "No real people. Synthetic people only; no real or named person; no children; no likeness of a real person.", "No invented numbers. The narrator never says the product worked. Keep the offer on the end card."],
245
+ "qaGates": ["caption_timing", "dead_air", "tail", "shot_length", "mute_test", "orientation", "continuity", "words_per_second", "black_corners", "eyes", "mouth_sync", "loudness_master", "caption_ocr", "voice_consistency", "stem_sfx"],
246
+ "learned": ["Screen direction swap: v1 faces clustered at cx 0.50-0.55. Regenerated v2 shots put host at 0.297-0.360 and guest at 0.672-0.705.", "Missing mic: specify a foam handheld mic in host stills and motion prompts. Check every host shot; visibility remains unverified in the notes.", "2010s v2 cost $0.874 versus $1.082 in v1. Reused all six voices and corridor assets; one failed $0.15 retry left host sync at 0.009.", "Reuse of stills, clips and voices cuts a rework to $0 to $0.13. A new talking clip costs about $0.15.", "Lip sync remains the weak point. Sitcom5b passes 7/10 clips; replacing the doorknob improves continuity but leaves three sync failures.", "The 2010s v2 host voice drifted between lines (353 Hz, then 177 Hz, one line tagged child-like) and was fixed by one fixed voice (spread 1.13). Voice drift across TTS lines: earshot measured sC at 2.2x pitch spread and sitcom wife at 216-441 Hz. Reuse one voice id and review flagged lines by ear; pitch alone cannot prove voice fit.", "Jumbled captions: remove highlight scaling, use a fixed word box, equal stroke and explicit spacing. Final-file OCR still finds CER 0.125 failures in 90s and marble5c.", "Door-break artifacts include duplicate doors and melting. Inspect three event frames; replace failed breaks with cracking walls or falling plaster.", "SFX louder than voice: remix measured sitcom active median +4.5 dB from separate stems. Lower SFX 5 dB; remeasure the final mix and keep owner listening checks.", "COST_TABLE.md rounds 5 to 7 contains logged estimates, not reconciled invoices. Round 6 logs exceed the balance change by about $0.70; treat totals as upper bounds."],
247
+ "sources": ["styles/out-styles.md", "COST_TABLE.md", "backrooms_2010s_v2/FINAL_NOTES.md", "backrooms_2010s_v2/COST_NOTES.md", "sitcom5b/FINAL_NOTES.md", "remix/REPORT.md", "earshot/REPORT.md"]
248
+ },
249
+ {
250
+ "id": "backrooms-dialogue",
251
+ "kinds": ["captions", "story", "character", "dialogue", "lo-fi"],
252
+ "feedbackRules": ["R1", "R2", "R3", "R4", "R5", "R6", "R7", "R8", "R9", "R10", "R11", "R12", "R13", "R14", "R15", "R16", "R17", "R18", "R19", "R20", "R21", "R22", "R23", "R26"],
253
+ "name": "Backrooms dialogue",
254
+ "description": "Two characters argue in endless yellow corridors: 1980s VHS, 1990s camcorder, or 2010s street variants.",
255
+ "aspectRatios": ["9:16"],
256
+ "durationSeconds": {"min": 16, "max": 22},
257
+ "hookPattern": "Open on a reaction face, then reveal the corridor argument. For a scare variant, plan it with horror-comic.",
258
+ "stages": ["script", "set", "character", "still", "angle_still", "tts", "image_to_video", "talking_head", "sfx", "captions", "edit", "end_card", "mute_test", "qa"],
259
+ "providers": {"script": {"model": "supplied script / human review", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "Reuse existing assets or local processing; backrooms_80s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Excludes labour and compute; no fresh-production quote."}}, "set": {"model": "reuse supplied set", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "Reuse existing assets or local processing; backrooms_80s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Excludes labour and compute; no fresh-production quote."}}, "character": {"model": "reuse character bible", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "Reuse existing assets or local processing; backrooms_80s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Excludes labour and compute; no fresh-production quote."}}, "still": {"model": "bytedance/seedream-v5.5", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "Reuse existing assets or local processing; backrooms_80s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Excludes labour and compute; no fresh-production quote."}}, "angle_still": {"model": "bytedance/seedream-v5.5/edit", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0.056, "high": 0.056, "note": "Logged rework allocation; backrooms_80s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Excludes labour and compute; no fresh-production quote."}}, "tts": {"model": "wavespeed-ai/qwen3-tts/text-to-speech", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "Reuse existing assets or local processing; backrooms_80s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Excludes labour and compute; no fresh-production quote."}}, "image_to_video": {"model": "wavespeed-ai/wan-2.2/i2v-480p-ultra-fast", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "Reuse existing assets or local processing; backrooms_80s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Excludes labour and compute; no fresh-production quote."}}, "talking_head": {"model": "wavespeed-ai/wan-2.2/speech-to-video", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0.45, "high": 0.45, "note": "Logged rework allocation; backrooms_80s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Excludes labour and compute; no fresh-production quote."}}, "sfx": {"model": "ffmpeg lavfi / licensed CC0", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "Reuse existing assets or local processing; backrooms_80s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Excludes labour and compute; no fresh-production quote."}}, "captions": {"model": "whisper.cpp + HTML", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "Reuse existing assets or local processing; backrooms_80s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Excludes labour and compute; no fresh-production quote."}}, "edit": {"model": "ffmpeg + SVG", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "Reuse existing assets or local processing; backrooms_80s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Excludes labour and compute; no fresh-production quote."}}, "end_card": {"model": "HTML overlay", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "Reuse existing assets or local processing; backrooms_80s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Excludes labour and compute; no fresh-production quote."}}, "mute_test": {"model": "human review of muted render", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "Reuse existing assets or local processing; backrooms_80s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Excludes labour and compute; no fresh-production quote."}}, "qa": {"model": "ffmpeg + human review", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "Reuse existing assets or local processing; backrooms_80s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Excludes labour and compute; no fresh-production quote."}}},
260
+ "costClass": "mid",
261
+ "estimatedCostUsd": {"low": 0.506, "high": 0.506, "note": "Logged rework upper bound: backrooms_80s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Reuse required; excludes labour, compute and further retries."},
262
+ "policyRisk": "medium",
263
+ "riskNotes": ["Use the housing special ad category for housing ads.", "No fake testimonials, eligibility claims, or promised savings.", "Use a DRAMATIZATION label for fictional people and skits.", "No real people. Synthetic people only; no real or named person; no children; no likeness of a real person.", "No invented numbers. The narrator never says the product worked. Keep the offer on the end card."],
264
+ "qaGates": ["caption_timing", "dead_air", "tail", "shot_length", "mute_test", "orientation", "continuity", "words_per_second", "black_corners", "eyes", "mouth_sync", "loudness_master", "caption_ocr", "voice_consistency", "stem_sfx"],
265
+ "learned": ["Era reworks: 1980s VHS $0.506, 1990s camcorder $0.128, 2010s street $0.874. This stage plan uses the 1980s rework.", "Fix eyelines with side-facing stills and small gestures; add the punchline push-in during editing. The 1980s clips still fail lip sync at 0.204, -0.088 and 0.299, and eyeline and gestures were not verified by the workers. The 1980s v2 final measured -16.0 LUFS and -0.2 dBTP before the master step, which fails loudness_master.", "Keep each speaker on one screen side. A scare-beat variant (the 1990s rework) is planned with the horror-comic format, whose R25 checklist applies.", "Reuse of stills, clips and voices cuts a rework to $0 to $0.13. A new talking clip costs about $0.15.", "Lip sync remains the weak point. Sitcom5b passes 7/10 clips; replacing the doorknob improves continuity but leaves three sync failures.", "Voice drift across TTS lines: earshot measured sC at 2.2x pitch spread and sitcom wife at 216-441 Hz. Reuse one voice id and review flagged lines by ear; pitch alone cannot prove voice fit.", "Jumbled captions: remove highlight scaling, use a fixed word box, equal stroke and explicit spacing. Final-file OCR still finds CER 0.125 failures in 90s and marble5c.", "Door-break artifacts include duplicate doors and melting. Inspect three event frames; replace failed breaks with cracking walls or falling plaster.", "SFX louder than voice: remix measured sitcom active median +4.5 dB from separate stems. Lower SFX 5 dB; remeasure the final mix and keep owner listening checks.", "COST_TABLE.md rounds 5 to 7 contains logged estimates, not reconciled invoices. Round 6 logs exceed the balance change by about $0.70; treat totals as upper bounds."],
266
+ "sources": ["styles/out-styles.md", "COST_TABLE.md", "backrooms_80s_v2/FINAL_NOTES.md", "backrooms_90s_v2/FINAL_NOTES.md", "backrooms_2010s_v2/FINAL_NOTES.md", "sitcom5b/FINAL_NOTES.md", "remix/REPORT.md", "earshot/REPORT.md"]
267
+ },
268
+ {
269
+ "id": "horror-comic",
270
+ "kinds": ["captions", "story", "character", "speech", "horror"],
271
+ "feedbackRules": ["R1", "R2", "R3", "R4", "R5", "R6", "R7", "R8", "R9", "R10", "R11", "R12", "R14", "R15", "R17", "R18", "R19", "R20", "R23", "R25", "R26"],
272
+ "name": "Horror comedy",
273
+ "description": "Something moves behind a door, a sting sounds, a character reacts, and the scare resolves as comedy.",
274
+ "aspectRatios": ["9:16"],
275
+ "durationSeconds": {"min": 16, "max": 22},
276
+ "hookPattern": "Open on a startled face. Show movement behind the door, sting within 0.2 s, reaction, then a joke.",
277
+ "stages": ["script", "set", "character", "still", "angle_still", "tts", "image_to_video", "talking_head", "sfx", "captions", "edit", "end_card", "mute_test", "qa"],
278
+ "providers": {"script": {"model": "supplied script / human review", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "Reuse existing assets or local processing; backrooms_90s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Excludes labour and compute; no fresh-production quote."}}, "set": {"model": "reuse supplied set", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "Reuse existing assets or local processing; backrooms_90s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Excludes labour and compute; no fresh-production quote."}}, "character": {"model": "reuse character bible", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "Reuse existing assets or local processing; backrooms_90s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Excludes labour and compute; no fresh-production quote."}}, "still": {"model": "bytedance/seedream-v5.5", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "Reuse existing assets or local processing; backrooms_90s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Excludes labour and compute; no fresh-production quote."}}, "angle_still": {"model": "bytedance/seedream-v5.5/edit", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0.028, "high": 0.028, "note": "Logged rework allocation; backrooms_90s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Excludes labour and compute; no fresh-production quote."}}, "tts": {"model": "wavespeed-ai/qwen3-tts/text-to-speech", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "Reuse existing assets or local processing; backrooms_90s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Excludes labour and compute; no fresh-production quote."}}, "image_to_video": {"model": "wavespeed-ai/wan-2.2/i2v-480p-ultra-fast", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0.1, "high": 0.1, "note": "Logged rework allocation; backrooms_90s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Excludes labour and compute; no fresh-production quote."}}, "talking_head": {"model": "wavespeed-ai/wan-2.2/speech-to-video", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "Reuse existing assets or local processing; backrooms_90s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Excludes labour and compute; no fresh-production quote."}}, "sfx": {"model": "ffmpeg lavfi / licensed CC0", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "Reuse existing assets or local processing; backrooms_90s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Excludes labour and compute; no fresh-production quote."}}, "captions": {"model": "whisper.cpp + HTML", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "Reuse existing assets or local processing; backrooms_90s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Excludes labour and compute; no fresh-production quote."}}, "edit": {"model": "ffmpeg + SVG", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "Reuse existing assets or local processing; backrooms_90s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Excludes labour and compute; no fresh-production quote."}}, "end_card": {"model": "HTML overlay", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "Reuse existing assets or local processing; backrooms_90s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Excludes labour and compute; no fresh-production quote."}}, "mute_test": {"model": "human review of muted render", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "Reuse existing assets or local processing; backrooms_90s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Excludes labour and compute; no fresh-production quote."}}, "qa": {"model": "ffmpeg + human review", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "Reuse existing assets or local processing; backrooms_90s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Excludes labour and compute; no fresh-production quote."}}},
279
+ "costClass": "cheap",
280
+ "estimatedCostUsd": {"low": 0.128, "high": 0.128, "note": "Logged rework upper bound: backrooms_90s_v2/FINAL_NOTES.md; COST_TABLE.md rounds 5 to 7. Reuse required; excludes labour, compute and further retries."},
281
+ "policyRisk": "medium",
282
+ "riskNotes": ["Use the housing special ad category for housing ads.", "No fake testimonials, eligibility claims, or promised savings.", "Use a DRAMATIZATION label for fictional people and skits.", "No real people. Synthetic people only; no real or named person; no children; no likeness of a real person.", "No invented numbers. The narrator never says the product worked. Keep the offer on the end card."],
283
+ "qaGates": ["caption_timing", "dead_air", "tail", "shot_length", "mute_test", "orientation", "continuity", "words_per_second", "black_corners", "eyes", "mouth_sync", "loudness_master", "caption_ocr", "voice_consistency", "stem_sfx"],
284
+ "learned": ["1990s v2 scare addition costs $0.128: one $0.028 reaction still and two $0.05 clips. Voices and talking clips are reused.", "Check the door silhouette and flinch by eye; normal frame motion does not prove correct content. Keep the sting on the scare beat.", "Marble5c re-edit costs $0 with reused stills. Face detection confirms geometry, not identity or whether the folder reads clearly.", "Reuse of stills, clips and voices cuts a rework to $0 to $0.13. A new talking clip costs about $0.15.", "Lip sync remains the weak point. Sitcom5b passes 7/10 clips; replacing the doorknob improves continuity but leaves three sync failures.", "Voice drift across TTS lines: earshot measured sC at 2.2x pitch spread and sitcom wife at 216-441 Hz. Reuse one voice id and review flagged lines by ear; pitch alone cannot prove voice fit.", "Jumbled captions: remove highlight scaling, use a fixed word box, equal stroke and explicit spacing. Final-file OCR still finds CER 0.125 failures in 90s and marble5c.", "Door-break artifacts include duplicate doors and melting. Inspect three event frames; replace failed breaks with cracking walls or falling plaster.", "SFX louder than voice: remix measured sitcom active median +4.5 dB from separate stems. Lower SFX 5 dB; remeasure the final mix and keep owner listening checks.", "COST_TABLE.md rounds 5 to 7 contains logged estimates, not reconciled invoices. Round 6 logs exceed the balance change by about $0.70; treat totals as upper bounds."],
285
+ "sources": ["styles/out-styles.md", "COST_TABLE.md", "backrooms_90s_v2/FINAL_NOTES.md", "marble5c/FINAL_NOTES.md", "sitcom5b/FINAL_NOTES.md", "remix/REPORT.md", "earshot/REPORT.md"]
205
286
  }
206
287
  ]
@@ -1,9 +1,32 @@
1
+ import { getFormat } from '../formats/index.js';
1
2
  import { readFileSync } from 'node:fs';
2
3
  import { mkdir, readFile, open, rename, unlink } from 'node:fs/promises';
3
4
  import { join } from 'node:path';
4
5
  import { randomUUID } from 'node:crypto';
5
6
  export const hooks = Object.freeze(JSON.parse(readFileSync(new URL('./library.json', import.meta.url), 'utf8'))
6
- .map(hook => Object.freeze({ ...hook, tags: Object.freeze(hook.tags) })));
7
+ .map(hook => Object.freeze({ ...hook, tags: Object.freeze(hook.tags), formats: Object.freeze(hook.formats) })));
8
+ export const emotions = Object.freeze(['shock', 'fear', 'curiosity', 'relief', 'humor', 'pride']);
9
+ /** Filters combine with AND; returned descriptors are independent copies. */
10
+ export function listHooks(filter = {}) {
11
+ if (!filter || typeof filter !== 'object' || Array.isArray(filter) ||
12
+ Object.keys(filter).some(k => !['format', 'emotion', 'housing'].includes(k))) throw new Error('Invalid hook filters');
13
+ if (filter.format !== undefined) getFormat(filter.format);
14
+ if (filter.emotion !== undefined && !emotions.includes(filter.emotion)) throw new Error('Invalid emotion');
15
+ if (filter.housing !== undefined && typeof filter.housing !== 'boolean') throw new Error('Invalid housing filter');
16
+ return structuredClone(hooks.filter(h => (filter.format === undefined || h.formats.includes(filter.format)) &&
17
+ (filter.emotion === undefined || h.emotion === filter.emotion) && (!filter.housing || h.housingSafe)));
18
+ }
19
+ export function getHook(id) {
20
+ const hook = hooks.find(h => h.id === id);
21
+ if (!hook) throw new Error('Unknown hook: ' + String(id));
22
+ return structuredClone(hook);
23
+ }
24
+ /** Rank only matching hooks. Catalog order resolves equal scores. */
25
+ export function pickHooks(filter, scores = {}) {
26
+ if (!filter || filter.format === undefined) throw new Error('Missing required argument: format');
27
+ validate(scores);
28
+ return listHooks(filter).sort((a, b) => (scores[b.id] ?? 0) - (scores[a.id] ?? 0));
29
+ }
7
30
  function validate(scores) {
8
31
  if (!scores || typeof scores !== 'object' || Array.isArray(scores)) throw new Error('Invalid scores');
9
32
  for (const [id, value] of Object.entries(scores)) {
@@ -48,11 +71,11 @@ export async function writeScores(directory, site, scores) {
48
71
  * @param {{id:string, scores?:Record<string,number>}} site
49
72
  * @param {string} goal
50
73
  */
51
- export function selectFor(site, goal) {
74
+ export function selectFor(site, goal, filter = {}) {
52
75
  if (!site || typeof site !== 'object' || Array.isArray(site)) throw new Error('Invalid site');
53
76
  pathFor('.', site.id);
54
77
  const scores = validate(site.scores ?? {});
55
- const eligible = hooks.filter(h => h.risk === 'low' && h.tags.includes(goal));
78
+ const eligible = listHooks(filter).filter(h => h.risk === 'low' && h.tags.includes(goal));
56
79
  const ranked = eligible.map((hook, order) => ({ hook, order, score: scores[hook.id] ?? 0 }));
57
80
  ranked.sort((a, b) => b.score - a.score || a.order - b.order);
58
81
  return ranked.length ? structuredClone(ranked[0].hook) : null;
@@ -1,8 +1,44 @@
1
1
  [
2
- {"id":"door","tags":["discovery"],"pattern":"Take a closer look at {{subject}}","risk":"low"},
3
- {"id":"compare","tags":["comparison","discovery"],"pattern":"Compare {{subject}} with the details in view","risk":"low"},
4
- {"id":"help","tags":["discovery","help"],"pattern":"Need help exploring {{subject}}? Start here","risk":"low"},
5
- {"id":"ugc","tags":["experience"],"pattern":"Here is what I observed about {{subject}}","risk":"medium"},
6
- {"id":"before-after","tags":["comparison"],"pattern":"Before and after: {{subject}}. Virtual staging preview.","risk":"medium"},
7
- {"id":"update","tags":["news"],"pattern":"Brand update: {{subject}} — {{source}}","risk":"high"}
2
+ {"id":"door","tags":["discovery"],"pattern":"Take a closer look at {{subject}}","risk":"low","kind":"spoken line","formats":["pov","lo-fi"],"emotion":"curiosity","housingSafe":true,"note":"A direct invitation opens a reveal without making a claim."},
3
+ {"id":"compare","tags":["comparison","discovery"],"pattern":"Compare {{subject}} with the details in view","risk":"low","kind":"caption","formats":["text-broll","telestrator"],"emotion":"curiosity","housingSafe":true,"note":"Visible details give a comparison something concrete to show."},
4
+ {"id":"help","tags":["discovery","help"],"pattern":"Need help exploring {{subject}}? Start here","risk":"low","kind":"spoken line","formats":["screen-voiceover","whiteboard"],"emotion":"relief","housingSafe":true,"note":"A clear question leads directly into the useful step."},
5
+ {"id":"ugc","tags":["experience"],"pattern":"Here is what I observed about {{subject}}","risk":"medium","kind":"spoken line","formats":["lo-fi","storytime"],"emotion":"pride","housingSafe":false,"note":"First-person delivery adds immediacy; observations need verification."},
6
+ {"id":"before-after","tags":["comparison"],"pattern":"Before and after: {{subject}}. Virtual staging preview.","risk":"medium","kind":"first-frame visual","formats":["before-after"],"emotion":"shock","housingSafe":false,"note":"A split view makes the change legible; staging needs review."},
7
+ {"id":"update","tags":["news"],"pattern":"Brand update: {{subject}} — {{source}}","risk":"high","kind":"caption","formats":["news-parody"],"emotion":"curiosity","housingSafe":false,"note":"An update creates a reason to listen; verify the source."},
8
+ {"id":"shocked-face","tags":["discovery"],"pattern":"Open on an illustrated shocked face; cut next to the cause: {{subject}}.","kind":"first-frame visual","formats":["pov","sitcom","storytime"],"emotion":"shock","risk":"medium","housingSafe":true,"note":"Reaction first creates a question that the next shot answers."},
9
+ {"id":"all-savings","tags":["discovery"],"pattern":"I spent all my savings on this house. {{subject}}","kind":"spoken line","formats":["storytime","lo-fi","sitcom"],"emotion":"fear","risk":"high","housingSafe":false,"note":"Personal stakes start a story quickly; do not present invented experience as testimony."},
10
+ {"id":"slick-agent","tags":["discovery"],"pattern":"The slick agent called {{subject}} risk-free. That broke my trust.","kind":"spoken line","formats":["sitcom","storytime"],"emotion":"shock","risk":"high","housingSafe":false,"note":"Broken trust supplies conflict; the quoted promise still fails policy lint."},
11
+ {"id":"someone-lives-there","tags":["discovery"],"pattern":"Wait, someone lives there? Check the occupancy details for {{subject}}.","kind":"spoken line","formats":["pov","storytime","sitcom"],"emotion":"shock","risk":"medium","housingSafe":false,"note":"An unexpected obstacle turns a reveal into a story; verify occupancy first."},
12
+ {"id":"scare-beat","tags":["discovery"],"pattern":"A brief suspense hit lands as {{subject}} appears; cut straight to the explanation.","kind":"sound","formats":["pov","sitcom","stickman"],"emotion":"fear","risk":"medium","housingSafe":true,"note":"A sound accent marks the obstacle without leaving dead air."},
13
+ {"id":"street-interview","tags":["discovery"],"pattern":"Street interview: What would you check first about {{subject}}?","kind":"spoken line","formats":["lo-fi","storytime"],"emotion":"curiosity","risk":"medium","housingSafe":false,"note":"A direct question opens a conversation; synthetic interviews can imply false testimony."},
14
+ {"id":"door-crack","tags":["discovery"],"pattern":"Start with a door already opening onto {{subject}}.","kind":"first-frame visual","formats":["pov","text-broll"],"emotion":"curiosity","risk":"low","housingSafe":true,"note":"Movement starts the reveal before an introduction slows it down."},
15
+ {"id":"key-turn","tags":["discovery"],"pattern":"A key clicks as the view opens onto {{subject}}.","kind":"sound","formats":["pov","text-broll"],"emotion":"curiosity","risk":"low","housingSafe":true,"note":"A sound tied to visible action focuses attention on the reveal."},
16
+ {"id":"caption-cut","tags":["discovery"],"pattern":"Pause here. Look closer at {{subject}}.","kind":"caption","formats":["text-broll"],"emotion":"shock","risk":"low","housingSafe":true,"note":"A short text interruption sets up a concrete visual payoff."},
17
+ {"id":"detail-circle","tags":["discovery"],"pattern":"Start with a pen circling a detail in {{subject}}.","kind":"first-frame visual","formats":["telestrator","whiteboard"],"emotion":"curiosity","risk":"low","housingSafe":true,"note":"An annotation directs attention to the evidence immediately."},
18
+ {"id":"cursor-open","tags":["discovery"],"pattern":"The cursor opens {{subject}} in the first frame.","kind":"first-frame visual","formats":["screen-voiceover"],"emotion":"curiosity","risk":"low","housingSafe":true,"note":"The useful action begins before the viewer has to wait."},
19
+ {"id":"marker-question","tags":["discovery"],"pattern":"Draw a question mark beside {{subject}} as the narration begins.","kind":"first-frame visual","formats":["whiteboard"],"emotion":"curiosity","risk":"low","housingSafe":true,"note":"Drawing and speech share a beat so the opening stays active."},
20
+ {"id":"paper-chaos","tags":["discovery"],"pattern":"Scattered notes slide into a checklist for {{subject}}.","kind":"first-frame visual","formats":["before-after","stickman"],"emotion":"relief","risk":"low","housingSafe":true,"note":"Visible organization supplies a payoff without promising a financial outcome."},
21
+ {"id":"tab-overload","tags":["discovery"],"pattern":"More tabs. Still questions about {{subject}}.","kind":"caption","formats":["text-broll","screen-voiceover"],"emotion":"humor","risk":"low","housingSafe":true,"note":"Dry humor makes a familiar research problem easy to recognize."},
22
+ {"id":"checklist-speaks","tags":["discovery"],"pattern":"I am the checklist. Ask me about {{subject}}.","kind":"spoken line","formats":["talking-object"],"emotion":"humor","risk":"low","housingSafe":true,"note":"A talking prop makes the useful tool part of the scene."},
23
+ {"id":"paper-eyebrow","tags":["discovery"],"pattern":"An illustrated checklist raises an eyebrow beside {{subject}}.","kind":"first-frame visual","formats":["talking-object","stickman"],"emotion":"humor","risk":"low","housingSafe":true,"note":"A small character reaction delivers a visual joke immediately."},
24
+ {"id":"mid-question","tags":["discovery"],"pattern":"And the next question is about {{subject}}.","kind":"spoken line","formats":["lo-fi","storytime"],"emotion":"curiosity","risk":"low","housingSafe":true,"note":"Starting inside a thought avoids a slow introduction."},
25
+ {"id":"look-again","tags":["discovery"],"pattern":"Look again at {{subject}}. What detail stands out?","kind":"spoken line","formats":["pov","telestrator"],"emotion":"curiosity","risk":"low","housingSafe":true,"note":"A focused question creates a reason to inspect the next shot."},
26
+ {"id":"prop-entry","tags":["discovery"],"pattern":"Hands bring a checklist for {{subject}} into the opening frame.","kind":"first-frame visual","formats":["lo-fi","pov"],"emotion":"curiosity","risk":"low","housingSafe":true,"note":"A useful prop creates movement and gives the narration a subject."},
27
+ {"id":"deadpan-paper","tags":["discovery"],"pattern":"{{subject}} brought paperwork to the party.","kind":"spoken line","formats":["sitcom","talking-object"],"emotion":"humor","risk":"low","housingSafe":true,"note":"A dry joke adds character without mocking people or communities."},
28
+ {"id":"question-stack","tags":["discovery"],"pattern":"{{subject}}: what needs a closer look?","kind":"caption","formats":["text-broll","pov"],"emotion":"fear","risk":"low","housingSafe":true,"note":"A question introduces uncertainty without inventing a threat."},
29
+ {"id":"slow-down","tags":["discovery"],"pattern":"Start with the details of {{subject}}.","kind":"spoken line","formats":["whiteboard","screen-voiceover"],"emotion":"relief","risk":"low","housingSafe":true,"note":"A simple next step turns uncertainty into a useful demonstration."},
30
+ {"id":"tick-reveal","tags":["discovery"],"pattern":"A soft tick lands as the checklist for {{subject}} opens.","kind":"sound","formats":["screen-voiceover","whiteboard"],"emotion":"relief","risk":"low","housingSafe":true,"note":"An action sound makes the tool reveal clear."},
31
+ {"id":"wipe-to-guide","tags":["discovery"],"pattern":"Wipe from loose notes to a guide for {{subject}}.","kind":"first-frame visual","formats":["before-after","text-broll"],"emotion":"relief","risk":"low","housingSafe":true,"note":"The contrast shows organization rather than a promised result."},
32
+ {"id":"side-by-side","tags":["discovery"],"pattern":"{{subject}}: put the details side by side.","kind":"caption","formats":["text-broll","telestrator"],"emotion":"curiosity","risk":"low","housingSafe":true,"note":"A comparison creates a clear visual task."},
33
+ {"id":"quiet-reveal","tags":["discovery"],"pattern":"A soft door creak introduces {{subject}}.","kind":"sound","formats":["pov","text-broll"],"emotion":"curiosity","risk":"low","housingSafe":true,"note":"A small physical sound supports the reveal without competing with speech."},
34
+ {"id":"record-scratch","tags":["discovery"],"pattern":"A short record scratch interrupts the gesture toward {{subject}}.","kind":"sound","formats":["sitcom","talking-object"],"emotion":"humor","risk":"low","housingSafe":true,"note":"A timed interruption gives the visual joke a clear beat."},
35
+ {"id":"heartbeat-question","tags":["discovery"],"pattern":"A quiet heartbeat underscores the question about {{subject}}; resolve it with the guide.","kind":"sound","formats":["pov","storytime"],"emotion":"fear","risk":"medium","housingSafe":true,"note":"Suspense leads into an answer rather than unsupported danger."},
36
+ {"id":"ready-notes","tags":["discovery"],"pattern":"Notes ready. Time to explore {{subject}}.","kind":"caption","formats":["text-broll","screen-voiceover"],"emotion":"pride","risk":"low","housingSafe":true,"note":"Preparation offers an achievable action rather than an outcome claim."},
37
+ {"id":"draw-the-route","tags":["discovery"],"pattern":"A drawn path connects the question about {{subject}} to a checklist.","kind":"first-frame visual","formats":["whiteboard","stickman"],"emotion":"relief","risk":"low","housingSafe":true,"note":"The visual explanation moves from problem to next step."},
38
+ {"id":"fictional-aside","tags":["discovery"],"pattern":"Fictional scene: the paperwork for {{subject}} gets its own chair.","kind":"spoken line","formats":["sitcom","storytime"],"emotion":"humor","risk":"medium","housingSafe":true,"note":"An obvious fictional joke gives the scene personality without false testimony."},
39
+ {"id":"envelope-open","tags":["discovery"],"pattern":"Open a generic envelope to reveal a guide to {{subject}}.","kind":"first-frame visual","formats":["pov","text-broll"],"emotion":"curiosity","risk":"medium","housingSafe":true,"note":"The physical reveal adds texture without making an offer claim."},
40
+ {"id":"empty-box","tags":["discovery"],"pattern":"{{subject}}: which detail fills this box?","kind":"caption","formats":["whiteboard","screen-voiceover"],"emotion":"curiosity","risk":"low","housingSafe":true,"note":"An unfinished visual invites attention to the explanation."},
41
+ {"id":"magnifier","tags":["discovery"],"pattern":"A drawn magnifier moves across the details of {{subject}}.","kind":"first-frame visual","formats":["stickman","telestrator"],"emotion":"curiosity","risk":"low","housingSafe":true,"note":"A moving focus point makes the inspection visible."},
42
+ {"id":"question-to-action","tags":["discovery"],"pattern":"Questions about {{subject}}? Open the guide alongside the details.","kind":"spoken line","formats":["screen-voiceover","whiteboard"],"emotion":"relief","risk":"low","housingSafe":true,"note":"A direct bridge connects the opening question to a useful action."},
43
+ {"id":"guide-in-hand","tags":["discovery"],"pattern":"A hand sets a guide beside the notes on {{subject}}.","kind":"first-frame visual","formats":["pov","lo-fi"],"emotion":"pride","risk":"low","housingSafe":true,"note":"An ordinary action shows preparation without claiming a purchase or result."}
8
44
  ]
package/src/http.js ADDED
@@ -0,0 +1,104 @@
1
+ import { createServer } from 'node:http';
2
+ import { createHash, timingSafeEqual } from 'node:crypto';
3
+ import { execute } from './index.js';
4
+ const digest = value => createHash('sha256').update(value).digest();
5
+ const hasApply = value => value && typeof value === 'object' && Object.entries(value).some(([key, item]) => key === 'apply' || hasApply(item));
6
+ export async function serve(argv) {
7
+ const options = { host: '127.0.0.1', port: 8787 }, seen = new Set();
8
+ while (argv.length) {
9
+ const option = argv.shift(), split = option.indexOf('=');
10
+ const key = (split < 0 ? option : option.slice(0, split)).slice(2);
11
+ const value = split < 0 ? argv.shift() : option.slice(split + 1);
12
+ if (!option.startsWith('--') || !['host', 'port'].includes(key) || seen.has(key) || !value) throw new Error('Invalid serve option');
13
+ seen.add(key); options[key] = value;
14
+ }
15
+ if (!/^\d+$/.test(String(options.port)) || Number(options.port) > 65535) throw new Error('Invalid port');
16
+ options.port = Number(options.port);
17
+ const token = process.env.AGENTIC_ADS_TOKEN || '';
18
+ const loopback = ['localhost', '::1', '127.0.0.1'].includes(options.host);
19
+ if (!loopback && !token) throw new Error('Non-loopback host requires AGENTIC_ADS_TOKEN');
20
+ if (token && token !== token.trim()) throw new Error('AGENTIC_ADS_TOKEN must not start or end with spaces');
21
+ if (token && token.length < 32) throw new Error('AGENTIC_ADS_TOKEN needs 32 or more characters');
22
+ let boundPort = 0;
23
+ // Pin localhost to loopback rather than trusting hostname resolution.
24
+ if (options.host === 'localhost') options.host = '127.0.0.1';
25
+ const expected = digest('Bearer ' + token);
26
+ const cors = process.env.AGENTIC_ADS_CORS_ORIGIN;
27
+ if (cors) {
28
+ try { if (!/^https?:$/.test(new URL(cors).protocol) || new URL(cors).origin !== cors) throw new Error(); }
29
+ catch { throw new Error('Invalid CORS origin'); }
30
+ }
31
+ const server = createServer({ requestTimeout: 10000, headersTimeout: 10000, connectionsCheckingInterval: 1000 }, (req, res) => {
32
+ // Without a token, accept only loopback Host values. This blocks DNS rebinding.
33
+ const hostOk = token || [`127.0.0.1:${boundPort}`, `localhost:${boundPort}`, `[::1]:${boundPort}`].includes(String(req.headers.host).toLowerCase());
34
+ const timer = setTimeout(() => { send(408, { error: 'Request timeout' }); req.resume(); }, 10000);
35
+ res.on('close', () => clearTimeout(timer));
36
+ function send(status, value, headers = {}) {
37
+ if (res.writableEnded || res.destroyed) return;
38
+ let body = JSON.stringify(value);
39
+ if (token && (body.includes(token) || body.includes(JSON.stringify(token).slice(1, -1)))) {
40
+ status = 400; body = JSON.stringify({ error: 'Invalid request' });
41
+ }
42
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...(status >= 400 && status !== 413 ? { connection: 'close' } : {}), ...headers });
43
+ res.end(body);
44
+ }
45
+ if (cors && req.headers.origin === cors) {
46
+ res.setHeader('access-control-allow-origin', cors); res.setHeader('vary', 'Origin');
47
+ }
48
+ if (token && !timingSafeEqual(digest(req.headers.authorization ?? ''), expected)) { send(401, { error: 'Unauthorized' }); req.resume(); return; }
49
+ let url;
50
+ if (!hostOk) { send(403, { error: 'Forbidden host' }); req.resume(); return; }
51
+ try { url = new URL(req.url, 'http://localhost'); } catch { send(400, { error: 'Invalid URL' }); return; }
52
+ const routes = { '/health': null, '/v1/formats': 'list_formats', '/v1/hooks': 'list-hooks',
53
+ '/v1/templates': 'list-templates', '/v1/qa-rules': 'list_qa_rules', '/v1/lint': 'lint', '/v1/plan': 'plan_format' };
54
+ const detail = url.pathname.match(/^\/v1\/(formats|qa-rules)\/([^/]+)$/);
55
+ const action = detail ? (detail[1] === 'formats' ? 'get_format' : 'get_qa_rule') : routes[url.pathname];
56
+ if (!detail && !Object.hasOwn(routes, url.pathname)) { send(404, { error: 'Not found' }); req.resume(); return; }
57
+ const method = ['lint', 'plan_format'].includes(action) ? 'POST' : 'GET';
58
+ if (req.method !== method) { send(405, { error: 'Method not allowed' }, { allow: method }); req.resume(); return; }
59
+ if (url.searchParams.has('apply')) { send(400, { error: 'Apply is unavailable' }); req.resume(); return; }
60
+ function run(args) {
61
+ try { send(200, action === null ? { status: 'ok' } : execute(action, args)); }
62
+ catch { send(detail ? 404 : 400, { error: detail ? 'Not found' : 'Invalid arguments' }); }
63
+ }
64
+ if (method === 'GET') {
65
+ try { run(detail ? { id: decodeURIComponent(detail[2]) } : {}); }
66
+ catch { send(400, { error: 'Invalid URL' }); }
67
+ req.resume(); return;
68
+ }
69
+ if (req.headers['content-type']?.split(';')[0].trim().toLowerCase() !== 'application/json') {
70
+ send(415, { error: 'Use application/json' }); req.resume(); return;
71
+ }
72
+ let size = 0, chunks = [], rejected = false;
73
+ req.on('error', () => { rejected = true; chunks = []; });
74
+ req.on('data', chunk => {
75
+ if (rejected) return;
76
+ size += chunk.length;
77
+ if (size > 65536) { rejected = true; chunks = []; send(413, { error: 'Body too large' }); return; }
78
+ chunks.push(chunk);
79
+ });
80
+ req.on('end', () => {
81
+ if (rejected || res.writableEnded) return;
82
+ let args;
83
+ try {
84
+ args = JSON.parse(Buffer.concat(chunks).toString('utf8'));
85
+ if (hasApply(args)) { send(400, { error: 'Apply is unavailable' }); return; }
86
+ } catch { send(400, { error: 'Invalid JSON' }); return; }
87
+ run(args);
88
+ });
89
+ });
90
+ server.on('clientError', (error, socket) => {
91
+ if (!socket.writable) return;
92
+ const status = error.code === 'ERR_HTTP_REQUEST_TIMEOUT' ? '408 Request Timeout' : '400 Bad Request';
93
+ const body = JSON.stringify({ error: 'Invalid or incomplete request' });
94
+ socket.end(`HTTP/1.1 ${status}\r\nContent-Type: application/json\r\nConnection: close\r\nContent-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`);
95
+ });
96
+ await new Promise((resolve, reject) => {
97
+ server.once('error', () => reject(new Error('Unable to start HTTP server')));
98
+ server.listen(options, resolve);
99
+ });
100
+ boundPort = server.address().port;
101
+ console.log(JSON.stringify({ status: 'listening', port: server.address().port }));
102
+ const stop = () => { server.close(); server.closeAllConnections(); };
103
+ process.once('SIGTERM', stop); process.once('SIGINT', stop);
104
+ }
package/src/index.js CHANGED
@@ -1,13 +1,18 @@
1
+ import { listRules, getRule } from './qa/index.js';
2
+ export { listRules, getRule, checklistFor, costRatchet } from './qa/index.js';
1
3
  import { listFormats, getFormat, planFormat } from './formats/index.js';
2
4
  export { listFormats, getFormat, planFormat };
3
5
  import { templates } from './templates/index.js';
4
- import { hooks, selectFor } from './hooks/index.js';
6
+ import { hooks, selectFor, listHooks, getHook, pickHooks, emotions } from './hooks/index.js';
5
7
  import { lint } from './policy/index.js';
6
8
  import { templateProvider, guardCost } from './providers/index.js';
7
- export { templates, hooks, selectFor, lint, templateProvider, guardCost };
9
+ export { listHooks, getHook, pickHooks, templates, hooks, selectFor, lint, templateProvider, guardCost };
8
10
  export { readScores, writeScores } from './hooks/index.js';
9
11
  const string = { type: 'string' };
12
+ const hookFilters = { format: string, emotion: { type: 'string', enum: emotions }, housing: { type: 'boolean' } };
10
13
  const fields = {
14
+ list_hooks: hookFilters, pick_hooks: hookFilters, get_hook: { id: string },
15
+ list_qa_rules: {}, get_qa_rule: { id: string },
11
16
  list_formats: { costClass: { type: 'string', enum: ['free', 'cheap', 'mid', 'expensive'] }, risk: { type: 'string', enum: ['low', 'medium', 'high'] } },
12
17
  get_format: { id: string },
13
18
  plan_format: { format: string, site: string, goal: string, dryRun: { type: 'boolean' } },
@@ -16,8 +21,8 @@ const fields = {
16
21
  plan: { site: string, goal: string, template: string, ratio: { enum: ['4:5', '9:16'], type: 'string' }, copy: string, category: { enum: ['housing', 'none'], type: 'string' }, apply: { type: 'boolean' }, dryRun: { type: 'boolean' } },
17
22
  };
18
23
  export const tools = Object.entries(fields).map(([name, properties]) => ({
19
- name, description: ({ list_formats: 'List video formats with optional cost and risk filters.', get_format: 'Get a video format descriptor.', plan_format: 'Plan video stages offline. Apply is unavailable.', 'list-templates': 'List starter template descriptors.', 'list-hooks': 'List hook patterns and risks.', lint: 'Check ad copy; findings always require review.', plan: 'Preview a zero-cost plan. Apply is unavailable.' })[name],
20
- inputSchema: { type: 'object', properties, additionalProperties: false, ...({ lint: ['copy'], get_format: ['id'], plan_format: ['format', 'site'] }[name] ? { required: { lint: ['copy'], get_format: ['id'], plan_format: ['format', 'site'] }[name] } : {}) },
24
+ name, description: ({ list_hooks: 'List hooks with optional filters.', pick_hooks: 'Pick matching hooks in stable score order.', get_hook: 'Get one hook.', list_qa_rules: 'List owner feedback QA rules.', get_qa_rule: 'Get an owner feedback QA rule.', list_formats: 'List video formats with optional cost and risk filters.', get_format: 'Get a video format descriptor.', plan_format: 'Plan video stages offline. Apply is unavailable.', 'list-templates': 'List starter template descriptors.', 'list-hooks': 'List hook patterns and risks.', lint: 'Check ad copy; findings always require review.', plan: 'Preview a zero-cost plan. Apply is unavailable.' })[name],
25
+ inputSchema: { type: 'object', properties, additionalProperties: false, ...({ pick_hooks: ['format'], get_hook: ['id'], lint: ['copy'], get_qa_rule: ['id'], get_format: ['id'], plan_format: ['format', 'site'] }[name] ? { required: { pick_hooks: ['format'], get_hook: ['id'], lint: ['copy'], get_qa_rule: ['id'], get_format: ['id'], plan_format: ['format', 'site'] }[name] } : {}) },
21
26
  }));
22
27
  /** Shared CLI/MCP dispatch. Unknown inputs fail before any action. */
23
28
  export function execute(action, args = {}) {
@@ -30,6 +35,11 @@ export function execute(action, args = {}) {
30
35
  if (!Object.hasOwn(fields[action], key) || typeof value !== field.type || (field.enum && !field.enum.includes(value))) throw new Error('Invalid argument');
31
36
  if (typeof value === 'string' && value.length > 20000) throw new Error('Argument too long');
32
37
  }
38
+ if (action === 'list_hooks') return listHooks(args);
39
+ if (action === 'pick_hooks') return pickHooks(args);
40
+ if (action === 'get_hook') return getHook(args.id);
41
+ if (action === 'list_qa_rules') return listRules();
42
+ if (action === 'get_qa_rule') return getRule(args.id);
33
43
  if (action === 'list_formats') return listFormats(args);
34
44
  if (action === 'get_format') return getFormat(args.id);
35
45
  if (action === 'plan_format') return planFormat(args);
@@ -0,0 +1,17 @@
1
+ {
2
+ "continuity": "Compare faces, clothes and props across shots; keep each person on the same screen side.",
3
+ "mouth_sync": "Check mouth onset within 100 to 200 ms of speech. Report measured correlation below 0.4 per failing line.",
4
+ "caption_timing": "Compare captions with word timestamps; start error must stay below 0.15 s.",
5
+ "dead_air": "Reject silent stills over 0.3 s.",
6
+ "tail": "Keep the tail after the last sound under 0.3 s.",
7
+ "shot_length": "Measure shot lengths; no shot exceeds 2.5 s.",
8
+ "words_per_second": "Measure speech pace from word timestamps; target 2 to 3 words per second.",
9
+ "mute_test": "Watch the muted file; the story and offer must remain clear.",
10
+ "orientation": "Verify final dimensions match the selected aspect ratio.",
11
+ "black_corners": "Inspect the final file for black corners caused by crops or motion.",
12
+ "eyes": "Inspect eyelines and eye artifacts in each person shot.",
13
+ "loudness_master": "Measure the final file: integrated target -14 LUFS, inclusive band -16 to -12 LUFS; true peak at or below -1 dBTP.",
14
+ "caption_ocr": "OCR captions from the final file; character error rate must be 0.10 or less for each caption.",
15
+ "voice_consistency": "Measure max/min per-line median pitch per character. Spread above 1.25 asks a person to listen; this tool check never fails. Voice stays an OWNER CHECK. On our own videos (1 rejected voice against 11 accepted voices, a small sample), neither pitch spread nor a speaker embedding separated them. Missing line stems or character mapping means NOT CHECKED. Log the voice id per character and listen with the face.",
16
+ "stem_sfx": "Measure sync and level from separate voice and SFX stems. Use a separate SFX stem with laughs left out. A mixed track cannot measure them separately. Missing stems means NOT CHECKED. Fail when there are no SFX onsets or fewer than 80% land within 80 ms of a picture cut. A cut without SFX is fine; voice onsets do not count. Supply beat times via --cuts. R25 stings start within 0.2 s of the beat. Record level relative to voice and request owner listening review."
17
+ }
@@ -0,0 +1,23 @@
1
+ import { readFileSync } from 'node:fs';
2
+ const rules = JSON.parse(readFileSync(new URL('./rules.json', import.meta.url), 'utf8'));
3
+ const gates = JSON.parse(readFileSync(new URL('./gates.json', import.meta.url), 'utf8'));
4
+ export function gateChecklistFor(format) {
5
+ return format.qaGates.map(id => {
6
+ if (!Object.hasOwn(gates, id)) throw new Error('Unknown QA gate: ' + id);
7
+ return { id, check: gates[id], status: 'NOT CHECKED' };
8
+ });
9
+ }
10
+ export function listRules() { return structuredClone(rules); }
11
+ export function getRule(id) {
12
+ const rule = rules.find(r => r.id === id);
13
+ if (!rule) throw new Error('Unknown QA rule: ' + String(id) + '. Use qa-rules to list available IDs.');
14
+ return structuredClone(rule);
15
+ }
16
+ /** Accept a format descriptor. Review status never implies that checks ran. */
17
+ export function checklistFor(format) {
18
+ return format.feedbackRules.map(id => {
19
+ const { title, check } = getRule(id);
20
+ return { id, title, check, status: 'NOT CHECKED' };
21
+ });
22
+ }
23
+ export const costRatchet = "Set a hard cap before you spend. One writer per spend log.\nReuse faces, sets, stills, clips and voice ids from earlier videos first.\nPick one lip-sync model for a format and keep it. Do not repeat a bake-off.\nDo not make the same still twice. Do not pay for an unused line.\nWrite `COST_NOTES.md`. Show cost per stage and against the last video of the format.\nA video that costs more than the last one of its format needs a one-line reason.";
@@ -0,0 +1,28 @@
1
+ [
2
+ {"id": "R1", "title": "Wild emotional hook in the first second.", "check": "Watch 0 to 1 s with sound off. Record what frame 0.0 s shows. It must be a strong emotion or an odd event. In a format with a person, R20 applies.", "origin": "Round 3 hook was \"not intriguing\".", "severity": "blocking", "applies": ["all"]},
3
+ {"id": "R2", "title": "Cause comes before effect on screen. Show the action, then the result, then who reacts. In a format with a person, the reaction face of R20 comes first. Then the order is action, result, reaction, all by 3 s.", "check": "Step through 0 to 3 s. No character appears without a reason. No object is already broken.", "origin": "Sitcom: door fell, people appeared at 0:02.", "severity": "blocking", "applies": ["story"]},
4
+ {"id": "R3", "title": "Change the camera angle, not only the zoom.", "check": "Look at the frames: the scene-cut filter cannot see a zoom. About one new angle or pose every 2.5 s. Cuts differ in height, pose or distance. A side change stays on one side of the 180-degree line (R21).", "origin": "\"Different left and right views, not slow zooms.\"", "severity": "blocking", "applies": ["character"]},
5
+ {"id": "R4", "title": "The same person must show different poses and angles.", "check": "Same face, outfit and hair across all shots. At least 3 poses.", "origin": "Marble lady needed jump cuts.", "severity": "blocking", "applies": ["character"]},
6
+ {"id": "R5", "title": "Show problem, agitation, then solution. Make the agitation hurt and be funny.", "check": "Mark the three beats on the timeline. Agitation has at least 3 quick cuts, one prop each.", "origin": "\"Highlight problem and solution agitation.\"", "severity": "blocking", "applies": ["story"]},
7
+ {"id": "R6", "title": "No dead gaps. No silent still over 0.3 s. Tail after the last sound under 0.3 s.", "check": "`ad-audio-qa` R6_dead_audio is a coarse guard: FAIL on any stretch >= 1.5 s below -60 LUFS (see `ad-video-editing/references/audio-qa.md`). `freezedetect=n=-60dB:d=0.3` crossed with `silencedetect=n=-35dB:d=0.25` for the picture side is the gate.", "origin": "Round 4 had dead gaps.", "severity": "blocking", "applies": ["all"]},
8
+ {"id": "R7", "title": "Speed up the picture, not the voice. Normal speech pace.", "check": "`ad-audio-qa` R7_speech_pace: true WPS <= 3.4 from the whisper transcript is the tool alarm; the hand target stays 2 to 3 words per second (see `ad-video-editing/references/audio-qa.md`). Average shot length: 1.5 to 2.2 s for a lo-fi story (the gate in `qa.md`), 1.2 to 1.5 s only for a fast punch edit. No shot over 2.5 s.", "origin": "\"Sound speed too much, pace better.\"", "severity": "blocking", "applies": ["all"]},
9
+ {"id": "R8", "title": "Voice, face and ethnicity must match.", "check": "OWNER CHECK: a person listens to each voice with the face. No automatic check is reliable: on our own videos (1 rejected voice against 11 accepted, a small sample), neither per-line pitch spread nor a speaker embedding separated them. `ad-audio-qa` only prints R8_voice_consistency OWNER CHECK when a character's line pitch spread is above 1.25 (see `ad-video-editing/references/audio-qa.md`); it never fails. Log the voice id per character and have a person listen.", "origin": "\"Ethnicity of source does not match.\"", "severity": "blocking", "applies": ["character"]},
10
+ {"id": "R9", "title": "A story the viewer can follow. It is personal and coherent, in the first person, as a dramatization. It makes no factual claim.", "check": "Read the captions alone. They tell one story in the first person.", "origin": "Meltdown: \"random words\".", "severity": "blocking", "applies": ["story"]},
11
+ {"id": "R10", "title": "Captions have spaces. At most 3 words. Time them to the audio.", "check": "Measure the pixel gap: at least 14 px of clear gap between words at 1080 px. `ad-audio-qa` R10_caption_sync: median offset <= 300 ms and >= 70% within 300 ms of speech onsets is the tool alarm; the hand target stays 0.15 s (see `ad-video-editing/references/audio-qa.md`).", "origin": "Caption spacing and sync.", "severity": "blocking", "applies": ["captions"]},
12
+ {"id": "R11", "title": "Mouth movement matches speech. Mouths close in laugh breaks.", "check": "Manual spot check until measured: mouth movement starts within 100 to 200 ms of voice onset (see `ad-video-editing` qa.md section 6). Where a lip-landmark tool ran, give its correlation (0.4 or more) and the tool name. List each failing line.", "origin": "Sitcom v2 and v3.", "severity": "blocking", "applies": ["dialogue", "speech"]},
13
+ {"id": "R12", "title": "Never cut to a static image that makes no sense.", "check": "Check each cut. Every shot has motion or a reason to hold.", "origin": "Sitcom v2 feedback.", "severity": "blocking", "applies": ["all"]},
14
+ {"id": "R13", "title": "Back-and-forth dialogue is wanted. Keep it and add more.", "check": "Count speaker changes. At least 4 in a sitcom.", "origin": "\"Love the dialogue.\"", "severity": "blocking", "applies": ["dialogue"]},
15
+ {"id": "R14", "title": "Sound is required. The ad also works muted.", "check": "`ad-audio-qa` R14_sound_and_cuts and sfx_onset_80ms need `--sfx-stem` (a wav of the sfx alone: leave the laugh track out, it does not land on cuts): FAIL \"Sound is required\" when the stem has no onsets, else FAIL when under 80% of the sfx onsets land within 80 ms of a picture cut (see `ad-video-editing/references/audio-qa.md`). A cut with no sfx is fine. A voice onset does not count as a sound event. Pass beat times that are not picture cuts with `--cuts`. sfx_vs_voice needs `--voice-stem` too. pacing.md: sfx only on events, never one per cut. Play with sound off. The story and the offer still read. Ship both files.", "origin": "Round 3 had no sound.", "severity": "blocking", "applies": ["all"]},
16
+ {"id": "R15", "title": "Hold the object identity. A phone stays a phone.", "check": "Compare held objects in frames across the clip.", "origin": "Phone turned into a notepad.", "severity": "blocking", "applies": ["character"]},
17
+ {"id": "R16", "title": "Lo-fi organic look. No logo. No text except captions, the dramatization tag and one end card with the offer.", "check": "Look at the frames. No logo, no studio polish.", "origin": "Polished images were rejected.", "severity": "blocking", "applies": ["lo-fi"]},
18
+ {"id": "R17", "title": "Housing safety. \"dramatization\" label. No invented numbers. No real people. No children.", "check": "Run the policy lint on all text and captions.", "origin": "Housing special ad category.", "severity": "blocking", "applies": ["all"]},
19
+ {"id": "R18", "title": "Cost goes down each time. Report cost per video by stage.", "check": "Compare with the last video of the same format. Explain any rise.", "origin": "Goal: cut cost 50 to 75 percent.", "severity": "blocking", "applies": ["all"]},
20
+ {"id": "R19", "title": "Do not show an event the video model cannot render. Show damage another way.", "check": "Render one clip first. Look at 3 frames of the event. No duplicate objects (two doors), no melting. If it fails, change the beat (cracking wall, falling plaster).", "origin": "\"No crash, two doors, AI artifact.\"", "severity": "blocking", "applies": ["all"]},
21
+ {"id": "R20", "title": "In a format with a person, the first frame is that person's reaction face. The cause follows in the next shot or two (see R2). This replaces the 0.0 s check in R1 for those formats.", "check": "Frame 0.0 s: a face, eyes and mouth readable, emotion matches the hook. The cause is on screen by 3 s.", "origin": "\"Shocked face in the first frame.\"", "severity": "blocking", "applies": ["character"]},
22
+ {"id": "R21", "title": "Keep screen direction. Each person stays on the same side of the frame in every shot. Change the angle on one side of the 180-degree line. Do not cross it.", "check": "List left/right per shot for each person. No swap, including cutaways.", "origin": "Street interview: host moved from right to left.", "severity": "blocking", "applies": ["dialogue"]},
23
+ {"id": "R22", "title": "A conversation looks like one: eyelines, head turns, small hand gestures, a slow push-in on the punchline. Do the push-in in the edit as a crop. Ask the model for small gestures only.", "check": "Per talking shot: where does the person look? Count eyeline-matched reverse shots (at least 1). Note the crop push-in. OWNER CHECK for how natural it looks.", "origin": "\"Talk as if to someone, more hand movement, zoom in on the guy.\"", "severity": "blocking", "applies": ["dialogue"]},
24
+ {"id": "R23", "title": "Captions never look jumbled.", "check": "Compile `swiftc -O ocr.swift` (macOS Vision, `VNRecognizeTextRequest`, accurate, language correction off). Run it on one frame per caption from the final render. Retry once on an `e5rt` crash. Character error rate (edit distance divided by expected length, upper case, spaces collapsed) 0.10 or less. Do not run `swift ocr.swift` (it hangs).", "origin": "\"Some letters get jumbled.\"", "severity": "blocking", "applies": ["captions"]},
25
+ {"id": "R24", "title": "Show the tool of the format. A street interview shows the microphone in the host's hand. Name the mic in the motion prompt and include it in the R15 frame comparison.", "check": "Frame check: mic visible in every host shot, same shape in each.", "origin": "Interview had no visible mic.", "severity": "blocking", "applies": ["interview"]},
26
+ {"id": "R25", "title": "A horror-comic variant needs a scare beat: something moves behind a door, a sting, a reaction. Then it resolves as a joke.", "check": "Mark the beat on the timeline. A sting starts within 0.2 s of the beat, then a reaction shot. OWNER CHECK for how scary it feels.", "origin": "\"Feel a little bit scared.\"", "severity": "blocking", "applies": ["horror"]},
27
+ {"id": "R26", "title": "A personal story has a clear hook: the loss, the place, the trust that broke, the surprise. The offer is on the end card. The narrator never says the product worked. See R9 and R17.", "check": "Caption read: one sentence per beat, in that order. No line claims a result.", "origin": "\"I spent all my savings on this house ... a slick agent gave me a lien ... someone lives there.\"", "severity": "blocking", "applies": ["story"]}
28
+ ]