agentic-ads-engine 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 agentic-ads-engine contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,136 @@
1
- # Temporary Holding Version
1
+ # agentic-ads-engine
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Offline tools for ad drafts. Node 20 or later. Plain JavaScript ESM with JSDoc, no build step, and zero runtime dependencies.
4
+
5
+ ## Quick start
6
+
7
+ Run from this checkout:
8
+
9
+ ```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
15
+ ```
16
+
17
+ The package is not published by this task. To install the local package:
18
+
19
+ ```sh
20
+ npm install /path/to/agentic-ads-engine
21
+ npx agentic-ads plan
22
+ ```
23
+
24
+ CLI output is JSON. Exit codes: `0` for a result, `1` for invalid input, and `2` for blocked copy.
25
+ `lint` requires `--copy`. It accepts `--category housing|none`; the default is `housing`.
26
+ `plan` accepts `--site`, `--goal`, `--template`, `--ratio`, `--copy`, and `--category`.
27
+ Use `--option=value` for values that start with `--`: `node bin/agentic-ads lint --copy=--Sale--`.
28
+ This form preserves `=` inside values. Boolean options accept `=true` or `=false`; plans still require dry-run.
29
+ Site names are lowercase slugs. Ratios are `4:5` and `9:16`.
30
+
31
+ Plans default to dry-run. `--apply` always fails because this slice has no spending or launch implementation.
32
+ Plans return required slots and policy findings. They do not render an image.
33
+ The five templates cover door reveals, first-person UGC, before/after views, news frames, and help with timing.
34
+ Their slots and story beats differ. The HTML stub uses a shared text layout.
35
+
36
+ ## Library
37
+
38
+ ```js
39
+ import {
40
+ readScores, writeScores, selectFor, templateProvider, guardCost,
41
+ } from 'agentic-ads-engine';
42
+
43
+ await writeScores('./scores', 'demo', { help: 8 });
44
+ const scores = await readScores('./scores', 'demo');
45
+ const hook = selectFor({ id: 'demo', scores }, 'discovery');
46
+
47
+ const preview = templateProvider.run({
48
+ template: 'door-open-teaser',
49
+ ratio: '4:5',
50
+ brand: { name: 'Demo', primary: '#2455cc' },
51
+ slots: {
52
+ headline: 'Take a closer look',
53
+ detail: 'Review the available listing details',
54
+ cta: 'Browse listings',
55
+ },
56
+ });
57
+ console.log(hook.id, preview.mime); // help text/html
58
+ ```
59
+
60
+ `selectFor(site, goal)` is pure. Supply `{ id, scores }`; it never reads the store itself.
61
+ Only low-risk hooks qualify. Scores rank matching hooks; catalog order resolves ties.
62
+ No match returns `null`. Scores are finite, nonnegative numbers keyed by known hook IDs.
63
+ The caller defines the score metric. This slice does not measure performance or establish sample confidence.
64
+ CLI and MCP plans use cold-start scores. Load saved scores through the library when selecting hooks.
65
+
66
+ The file store writes one JSON snapshot per site. Replacement is atomic; callers must use one writer.
67
+ A missing file returns `{}`. Invalid files fail. Store directories must be trusted local directories.
68
+
69
+ Providers implement `capabilities()`, `estimate(input)`, and `run(input)`.
70
+ The stub returns escaped HTML, dimensions, a content hash, zero cost, and an explicit warning.
71
+ It returns `image: null`. It has no browser, remote asset loading, image generation, or raster conversion.
72
+ Brand colors accept six-digit hex values. The brand name is plain text.
73
+ Use the template descriptor to find required slots before calling the provider.
74
+ Call `lint` on every final slot value before external use. The renderer does not grant policy approval.
75
+
76
+ `guardCost` accepts `costMinor`, `adSpentMinor`, `monthSpentMinor`, `perAdMinor`, `perMonthMinor`, and `apply`.
77
+ All amounts must be nonnegative safe integers in the same currency.
78
+ Positive cost requires `apply: true`. Equal-to-cap amounts are allowed; excess amounts are blocked.
79
+ The caller supplies totals for the current calendar month, including reserved and uncertain charges.
80
+ This pure guard does not persist reservations, handle concurrency, reset monthly totals, or authorize a paid provider.
81
+ A future paid adapter must check and reserve costs atomically before calling a provider.
82
+
83
+ ## MCP
84
+
85
+ Start `node bin/agentic-ads-mcp`, or use the installed `agentic-ads-mcp` executable.
86
+ Configure the client to use this command with stdio transport.
87
+ The server uses newline-delimited JSON-RPC and protocol version `2024-11-05`.
88
+ It supports `initialize`, `ping`, `tools/list`, and `tools/call`.
89
+ The original four tools use the same names and arguments as the CLI; `--dry-run` becomes `dryRun`.
90
+ Stdout contains protocol messages only. Frames are limited to 65,536 characters.
91
+ Tool results contain JSON text. Blocked copy sets `isError: true` and retains its findings.
92
+
93
+ ## Honest limits
94
+
95
+ Lint detects selected English patterns. It cannot prove truth, consent, disclosure quality, or compliance.
96
+ It always returns a human review finding. A `review` status does not mean platform approval.
97
+ Housing is the conservative default. Targeting words, guarantees, superlatives, and below-market percentages are blocked.
98
+ News framing, staging, and urgency also require review. Conservative checks can produce false positives.
99
+ The package does not inspect actual audience settings or implement current platform policy in full.
100
+
101
+ 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
+ No source assets or competitor copy are bundled. Research informs the formats and disclosure needs only.
104
+
105
+ The E2E script spawns both executables and a library integration process.
106
+ It records assertions in `test/artifacts/e2e.json`, which Git ignores and npm excludes.
107
+ Run `npm run ci:local` before any future push. Publishing and pushing are separate operator actions.
108
+
109
+ ## Video formats
110
+
111
+ ```sh
112
+ node bin/agentic-ads formats
113
+ node bin/agentic-ads formats --cost-class cheap --risk medium
114
+ node bin/agentic-ads formats show sitcom
115
+ node bin/agentic-ads plan --format sitcom --site demo
116
+ ```
117
+
118
+ Twelve formats cover the eight catalog recipes, lo-fi UGC, sitcom, storytime, and talking objects.
119
+ MCP adds `list_formats({costClass?, risk?})`, `get_format({id})`, and `plan_format({format, site, goal?, dryRun?})`.
120
+ These tools return the same JSON as the commands above.
121
+ The library exports `listFormats`, `getFormat`, and `planFormat` from the package root and `agentic-ads-engine/formats`.
122
+ Filters combine with AND. Unknown formats fail. Format plans require a site slug and default to dry-run.
123
+ Plans return ordered stages, stage estimates, total estimates, policy notes, QA gates, and zero actual spend.
124
+ Apply remains unavailable. QA gates describe required checks; this planner does not run them.
125
+
126
+ Source paths refer to the supplied `directory/ads-research` archive, reviewed on 2026-11-12.
127
+ Provider `verified` records that source review date. It does not promise current prices or successful renders.
128
+ `unverified: true` marks proposed routes or unresolved quality. Local routes can name tools instead of models.
129
+ Costs exclude labour and compute. Stage notes state asset counts and reuse assumptions.
130
+ The sitcom run cost $2.466; four mouth checks still failed. Its selected speech model preserves that limitation.
131
+ Lo-fi costs reference reused assets: $0.881 for three videos6 ads and $0.165 for three videos5 voice edits.
132
+ Fresh generation estimates differ. The style catalog did not render its recommended local recipes.
133
+ No paid calls, current price checks, media QA, or platform approval occur here.
134
+
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`.
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ import { execute, tools } from '../src/index.js';
3
+ try {
4
+ let [action, ...argv] = process.argv.slice(2);
5
+ const args = {};
6
+ if (action === 'formats') {
7
+ action = argv[0] === 'show' ? 'get_format' : 'list_formats';
8
+ if (action === 'get_format') { argv.shift(); args.id = argv.shift(); }
9
+ }
10
+ if (action === 'plan' && argv.some(arg => arg === '--format' || arg.startsWith('--format='))) action = 'plan_format';
11
+ const schema = tools.find(t => t.name === action)?.inputSchema;
12
+ if (!schema) throw new Error('Use formats, list-templates, list-hooks, lint, or plan');
13
+ while (argv.length) {
14
+ const option = argv.shift(), separator = option.indexOf('=');
15
+ const flag = separator < 0 ? option : option.slice(0, separator);
16
+ const value = separator < 0 ? undefined : option.slice(separator + 1);
17
+ if (!flag.startsWith('--')) throw new Error('Expected an option');
18
+ const key = flag === '--dry-run' ? 'dryRun' : flag === '--cost-class' ? 'costClass' : flag.slice(2);
19
+ if (flag === '--apply' && ['plan', 'plan_format'].includes(action)) throw new Error('Apply is unavailable in this offline slice');
20
+ if (['--costClass', '--dryRun'].includes(flag) || !Object.hasOwn(schema.properties, key) || Object.hasOwn(args, key)) throw new Error('Unknown or repeated option');
21
+ if (schema.properties[key].type === 'boolean') {
22
+ if (value !== undefined && !['true', 'false'].includes(value)) throw new Error('Invalid argument');
23
+ args[key] = value !== 'false';
24
+ }
25
+ else if (value !== undefined) args[key] = value;
26
+ else {
27
+ if (!argv.length || argv[0].startsWith('--')) throw new Error(`Missing option value (use ${flag}=VALUE for a value that starts with --)`);
28
+ args[key] = argv.shift();
29
+ }
30
+ }
31
+ const result = execute(action, args);
32
+ console.log(JSON.stringify(result));
33
+ if (result.status === 'blocked' || result.policy?.status === 'blocked') process.exitCode = 2;
34
+ } catch (error) {
35
+ console.log(JSON.stringify({ error: error.message })); process.exitCode = 1;
36
+ }
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+ import { execute, tools } from '../src/index.js';
3
+ // MCP stdio uses one JSON-RPC message per line. stdout is protocol-only.
4
+ const send = message => process.stdout.write(JSON.stringify(message) + '\n');
5
+ const fail = (id, code, message) => send({ jsonrpc: '2.0', id, error: { code, message } });
6
+ let initialized = false;
7
+ function receive(line) {
8
+ let request;
9
+ try { request = JSON.parse(line); } catch { fail(null, -32700, 'Parse error'); return; }
10
+ const validId = request && (typeof request.id === 'string' || (typeof request.id === 'number' && Number.isFinite(request.id)));
11
+ if (!request || Array.isArray(request) || request.jsonrpc !== '2.0' || typeof request.method !== 'string' ||
12
+ (Object.hasOwn(request, 'id') && !validId)) { fail(null, -32600, 'Invalid request'); return; }
13
+ if (!Object.hasOwn(request, 'id')) return;
14
+ const { id, method, params = {} } = request;
15
+ if (!params || typeof params !== 'object' || Array.isArray(params)) { fail(id, -32602, 'Invalid params'); return; }
16
+ let result;
17
+ if (method === 'initialize') {
18
+ if (typeof params.protocolVersion !== 'string') { fail(id, -32602, 'Missing protocol version'); return; }
19
+ initialized = true;
20
+ result = { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'agentic-ads-engine', version: '0.1.0' } };
21
+ } else if (method === 'ping') result = {};
22
+ else if (!initialized) { fail(id, -32002, 'Initialize first'); return; }
23
+ else if (method === 'tools/list') result = { tools };
24
+ else if (method === 'tools/call') {
25
+ if (typeof params.name !== 'string' || !tools.some(t => t.name === params.name)) { fail(id, -32602, 'Unknown tool'); return; }
26
+ try {
27
+ const value = execute(params.name, params.arguments === undefined ? {} : params.arguments);
28
+ result = { content: [{ type: 'text', text: JSON.stringify(value) }], isError: value.status === 'blocked' || value.policy?.status === 'blocked' };
29
+ } catch (error) { result = { content: [{ type: 'text', text: error.message }], isError: true }; }
30
+ } else { fail(id, -32601, 'Method not found'); return; }
31
+ send({ jsonrpc: '2.0', id, result });
32
+ }
33
+ let buffer = '', dropping = false;
34
+ process.stdin.setEncoding('utf8');
35
+ process.stdin.on('data', chunk => {
36
+ // Bound each frame to prevent unbounded buffering from a malformed client.
37
+ for (const part of chunk.split(/(?<=\n)/)) {
38
+ if (!dropping) buffer += part;
39
+ if (buffer.length > 65536) { buffer = ''; dropping = true; fail(null, -32600, 'Frame too large'); }
40
+ if (part.endsWith('\n')) {
41
+ if (!dropping && buffer.trim()) receive(buffer);
42
+ buffer = ''; dropping = false;
43
+ }
44
+ }
45
+ });
46
+ process.stdin.on('end', () => { if (buffer.trim() && !dropping) receive(buffer); });
package/package.json CHANGED
@@ -1,6 +1,13 @@
1
1
  {
2
2
  "name": "agentic-ads-engine",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Offline ad templates, hook selection, policy lint, and planning tools",
5
+ "type": "module",
6
+ "engines": { "node": ">=20" },
7
+ "license": "MIT",
8
+ "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
+ "bin": { "agentic-ads": "./bin/agentic-ads", "agentic-ads-mcp": "./bin/agentic-ads-mcp" },
10
+ "files": ["src/", "bin/", "README.md", "LICENSE"],
11
+ "scripts": { "test": "node test/e2e.mjs", "ci:local": "node test/e2e.mjs" },
12
+ "publishConfig": { "access": "public" }
13
+ }
@@ -0,0 +1,34 @@
1
+ import { readFileSync } from 'node:fs';
2
+ const library = JSON.parse(readFileSync(new URL('./library.json', import.meta.url), 'utf8'));
3
+ const costs = ['free', 'cheap', 'mid', 'expensive'], risks = ['low', 'medium', 'high'];
4
+ function argumentsFor(value, keys) {
5
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(k => !keys.includes(k))) throw new Error('Invalid format arguments');
6
+ }
7
+ /** Return isolated descriptors. Filters combine with AND. No providers run. */
8
+ export function listFormats(filter = {}) {
9
+ argumentsFor(filter, ['costClass', 'risk']);
10
+ if (filter.costClass !== undefined && !costs.includes(filter.costClass)) throw new Error('Invalid cost class');
11
+ if (filter.risk !== undefined && !risks.includes(filter.risk)) throw new Error('Invalid risk');
12
+ return structuredClone(library.filter(f => (!filter.costClass || f.costClass === filter.costClass) && (!filter.risk || f.policyRisk === filter.risk)));
13
+ }
14
+ export function getFormat(id) {
15
+ const found = library.find(f => f.id === id);
16
+ if (!found) throw new Error('Unknown format: ' + String(id));
17
+ return structuredClone(found);
18
+ }
19
+ /** Costs describe a possible production run, never money spent by this planner. */
20
+ export function planFormat(input = {}) {
21
+ argumentsFor(input, ['format', 'site', 'goal', 'dryRun', 'apply']);
22
+ if (input.apply !== undefined && input.apply !== false) throw new Error('Apply is unavailable');
23
+ if (input.dryRun !== undefined && input.dryRun !== true) throw new Error('Use dry-run; apply is unavailable');
24
+ const f = getFormat(input.format);
25
+ if (typeof input.site !== 'string' || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(input.site) || input.site.length > 80) throw new Error('Site must be a lowercase slug');
26
+ const goal = input.goal ?? 'discovery';
27
+ if (typeof goal !== 'string' || !goal.trim() || goal.length > 200) throw new Error('Invalid goal');
28
+ const stages = f.stages.map(id => ({ id, provider: f.providers[id], estimatedCostUsd: f.providers[id].estimatedCostUsd }));
29
+ const sum = key => Math.round(stages.reduce((n, s) => n + s.estimatedCostUsd[key], 0) * 1000) / 1000;
30
+ return { format: f.id, site: input.site, goal, dryRun: true, applied: false, spendUsd: 0, stages,
31
+ qaGates: f.qaGates, policyRisk: f.policyRisk, riskNotes: f.riskNotes,
32
+ 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.'] };
34
+ }
@@ -0,0 +1,206 @@
1
+ [
2
+ {
3
+ "id": "screen-voiceover",
4
+ "name": "Screen recording + voiceover",
5
+ "description": "Record a real or generic checklist UI with timed narration.",
6
+ "aspectRatios": ["9:16"],
7
+ "durationSeconds": {"min": 15, "max": 25},
8
+ "hookPattern": "Cursor clicks the key step on frame one.",
9
+ "stages": ["script", "still", "tts", "sfx", "captions", "edit", "end_card", "mute_test", "qa"],
10
+ "providers": {"script": {"model": "supplied script / human review", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "still": {"model": "supplied art / SVG", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "tts": {"model": "Kokoro local", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "sfx": {"model": "ffmpeg lavfi / licensed CC0", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "captions": {"model": "whisper.cpp + HTML", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "edit": {"model": "ffmpeg + SVG", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "end_card": {"model": "HTML overlay", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "mute_test": {"model": "human review of muted render", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "qa": {"model": "ffmpeg + human review", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}},
11
+ "costClass": "free",
12
+ "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: sum of stage assumptions; styles catalog recipes; excludes labour, compute, and unbounded retries"},
13
+ "policyRisk": "low",
14
+ "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"],
16
+ "learned": ["Record only generic UI; exclude real addresses and prices.", "Cut on measured voice beats; dead air must stay below 0.4 seconds."],
17
+ "sources": ["styles/out-styles.md", "storyboards/INDEX.md", "editing/eval3/engine-ideas.md"]
18
+ },
19
+ {
20
+ "id": "text-broll",
21
+ "name": "Text over b-roll",
22
+ "description": "Animate exact caption cards over supplied moving footage.",
23
+ "aspectRatios": ["9:16"],
24
+ "durationSeconds": {"min": 15, "max": 25},
25
+ "hookPattern": "Bold hook text appears on frame one.",
26
+ "stages": ["script", "still", "tts", "sfx", "captions", "edit", "end_card", "mute_test", "qa"],
27
+ "providers": {"script": {"model": "supplied script / human review", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "still": {"model": "bytedance/seedream-v5.5", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0.02, "note": "estimate: supplied art or 1 text-to-image still at $0.020; videos6/spend.json and videos4/spend.json"}}, "tts": {"model": "Kokoro local", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "sfx": {"model": "ffmpeg lavfi / licensed CC0", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "captions": {"model": "whisper.cpp + HTML", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "edit": {"model": "ffmpeg + SVG", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "end_card": {"model": "HTML overlay", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "mute_test": {"model": "human review of muted render", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "qa": {"model": "ffmpeg + human review", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}},
28
+ "costClass": "cheap",
29
+ "estimatedCostUsd": {"low": 0, "high": 0.02, "note": "estimate: sum of stage assumptions; styles catalog recipes; excludes labour, compute, and unbounded retries"},
30
+ "policyRisk": "low",
31
+ "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"],
33
+ "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
+ "sources": ["styles/out-styles.md", "storyboards/INDEX.md", "editing/eval3/engine-ideas.md", "videos6/spend.json", "videos4/spend.json"]
35
+ },
36
+ {
37
+ "id": "whiteboard",
38
+ "name": "Whiteboard doodle",
39
+ "description": "Draw SVG ideas to narration word times.",
40
+ "aspectRatios": ["9:16"],
41
+ "durationSeconds": {"min": 15, "max": 25},
42
+ "hookPattern": "The marker draws before the first word ends.",
43
+ "stages": ["script", "still", "tts", "sfx", "captions", "edit", "end_card", "mute_test", "qa"],
44
+ "providers": {"script": {"model": "supplied script / human review", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "still": {"model": "supplied art / SVG", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "tts": {"model": "Kokoro local", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "sfx": {"model": "ffmpeg lavfi / licensed CC0", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "captions": {"model": "whisper.cpp + HTML", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "edit": {"model": "ffmpeg + SVG", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "end_card": {"model": "HTML overlay", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "mute_test": {"model": "human review of muted render", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "qa": {"model": "ffmpeg + human review", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}},
45
+ "costClass": "free",
46
+ "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: sum of stage assumptions; styles catalog recipes; excludes labour, compute, and unbounded retries"},
47
+ "policyRisk": "low",
48
+ "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"],
50
+ "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
+ "sources": ["styles/out-styles.md", "storyboards/INDEX.md", "editing/eval3/engine-ideas.md"]
52
+ },
53
+ {
54
+ "id": "stickman",
55
+ "name": "Stickman explainer",
56
+ "description": "Animate a shared SVG character through a problem and answer.",
57
+ "aspectRatios": ["9:16"],
58
+ "durationSeconds": {"min": 15, "max": 25},
59
+ "hookPattern": "The figure moves under a question mark in the first second.",
60
+ "stages": ["script", "still", "tts", "sfx", "captions", "edit", "end_card", "mute_test", "qa"],
61
+ "providers": {"script": {"model": "supplied script / human review", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "still": {"model": "supplied art / SVG", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "tts": {"model": "Kokoro local", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "sfx": {"model": "ffmpeg lavfi / licensed CC0", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "captions": {"model": "whisper.cpp + HTML", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "edit": {"model": "ffmpeg + SVG", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "end_card": {"model": "HTML overlay", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "mute_test": {"model": "human review of muted render", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "qa": {"model": "ffmpeg + human review", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}},
62
+ "costClass": "free",
63
+ "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: sum of stage assumptions; styles catalog recipes; excludes labour, compute, and unbounded retries"},
64
+ "policyRisk": "low",
65
+ "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"],
67
+ "learned": ["Reuse one SVG character asset for consistent proportions.", "Include motion every two seconds; avoid holds over 2.5 seconds."],
68
+ "sources": ["styles/out-styles.md", "storyboards/INDEX.md", "editing/eval3/engine-ideas.md"]
69
+ },
70
+ {
71
+ "id": "pov",
72
+ "name": "POV discovery",
73
+ "description": "Use consistent first-person clips with a title and checklist reveal.",
74
+ "aspectRatios": ["9:16"],
75
+ "durationSeconds": {"min": 15, "max": 25},
76
+ "hookPattern": "POV title and moving hands appear on frame one.",
77
+ "stages": ["script", "set", "character", "still", "angle_still", "tts", "image_to_video", "sfx", "captions", "edit", "end_card", "mute_test", "qa"],
78
+ "providers": {"script": {"model": "supplied script / human review", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "set": {"model": "reuse supplied set", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "character": {"model": "reuse character bible", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "still": {"model": "bytedance/seedream-v5.5", "verified": "2026-11-12", "unverified": false, "estimatedCostUsd": {"low": 0.02, "high": 0.06, "note": "estimate: 1-3 text-to-image stills at $0.020; videos6/spend.json and videos4/spend.json"}}, "angle_still": {"model": "bytedance/seedream-v5.5/edit", "verified": "2026-11-12", "unverified": false, "estimatedCostUsd": {"low": 0.056, "high": 0.196, "note": "estimate: 2-7 image edits at $0.028; sitcom3/spend.json, videos6/spend.json, videos4/spend.json"}}, "tts": {"model": "wavespeed-ai/qwen3-tts/text-to-speech", "verified": "2026-11-12", "unverified": false, "estimatedCostUsd": {"low": 0.03, "high": 0.055, "note": "estimate: 6-11 calls at $0.005; sitcom3/spend.json, videos5/spend.json, videos4/spend.json"}}, "image_to_video": {"model": "wavespeed-ai/wan-2.2/i2v-480p-ultra-fast", "verified": "2026-11-12", "unverified": false, "estimatedCostUsd": {"low": 0.15, "high": 0.3, "note": "estimate: 3-6 five-second clips at $0.05; sitcom3/spend.json, videos4/spend.json, videos6/spend.json"}}, "sfx": {"model": "ffmpeg lavfi / licensed CC0", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "captions": {"model": "whisper.cpp + HTML", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "edit": {"model": "ffmpeg + SVG", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "end_card": {"model": "HTML overlay", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "mute_test": {"model": "human review of muted render", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "qa": {"model": "ffmpeg + human review", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}},
79
+ "costClass": "mid",
80
+ "estimatedCostUsd": {"low": 0.256, "high": 0.611, "note": "estimate: sum of stage assumptions; styles catalog recipes; excludes labour, compute, and unbounded retries"},
81
+ "policyRisk": "medium",
82
+ "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"],
84
+ "learned": ["Chain the final frame into the next shot for continuity.", "Cut on voice beats; keep real listing data out of backgrounds."],
85
+ "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
+ },
87
+ {
88
+ "id": "telestrator",
89
+ "name": "Telestrator annotation",
90
+ "description": "Circle relevant UI details with word-timed SVG strokes.",
91
+ "aspectRatios": ["9:16"],
92
+ "durationSeconds": {"min": 15, "max": 25},
93
+ "hookPattern": "The pen circles the problem in the first second.",
94
+ "stages": ["script", "still", "tts", "sfx", "captions", "edit", "end_card", "mute_test", "qa"],
95
+ "providers": {"script": {"model": "supplied script / human review", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "still": {"model": "supplied art / SVG", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "tts": {"model": "Kokoro local", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "sfx": {"model": "ffmpeg lavfi / licensed CC0", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "captions": {"model": "whisper.cpp + HTML", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "edit": {"model": "ffmpeg + SVG", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "end_card": {"model": "HTML overlay", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "mute_test": {"model": "human review of muted render", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "qa": {"model": "ffmpeg + human review", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}},
96
+ "costClass": "free",
97
+ "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: sum of stage assumptions; styles catalog recipes; excludes labour, compute, and unbounded retries"},
98
+ "policyRisk": "low",
99
+ "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"],
101
+ "learned": ["Keep annotations clear of the element they explain.", "Use deterministic strokes to avoid drift between frames."],
102
+ "sources": ["styles/out-styles.md", "storyboards/INDEX.md", "editing/eval3/engine-ideas.md"]
103
+ },
104
+ {
105
+ "id": "before-after",
106
+ "name": "Before and after",
107
+ "description": "Wipe from confusing paperwork to an actual checklist.",
108
+ "aspectRatios": ["9:16"],
109
+ "durationSeconds": {"min": 15, "max": 25},
110
+ "hookPattern": "A split wipe begins on frame one.",
111
+ "stages": ["script", "set", "character", "still", "angle_still", "tts", "image_to_video", "sfx", "captions", "edit", "end_card", "mute_test", "qa"],
112
+ "providers": {"script": {"model": "supplied script / human review", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "set": {"model": "reuse supplied set", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "character": {"model": "reuse character bible", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "still": {"model": "bytedance/seedream-v5.5", "verified": "2026-11-12", "unverified": false, "estimatedCostUsd": {"low": 0.02, "high": 0.06, "note": "estimate: 1-3 text-to-image stills at $0.020; videos6/spend.json and videos4/spend.json"}}, "angle_still": {"model": "bytedance/seedream-v5.5/edit", "verified": "2026-11-12", "unverified": false, "estimatedCostUsd": {"low": 0.056, "high": 0.196, "note": "estimate: 2-7 image edits at $0.028; sitcom3/spend.json, videos6/spend.json, videos4/spend.json"}}, "tts": {"model": "wavespeed-ai/qwen3-tts/text-to-speech", "verified": "2026-11-12", "unverified": false, "estimatedCostUsd": {"low": 0.03, "high": 0.055, "note": "estimate: 6-11 calls at $0.005; sitcom3/spend.json, videos5/spend.json, videos4/spend.json"}}, "image_to_video": {"model": "wavespeed-ai/wan-2.2/i2v-480p-ultra-fast", "verified": "2026-11-12", "unverified": false, "estimatedCostUsd": {"low": 0.15, "high": 0.3, "note": "estimate: 3-6 five-second clips at $0.05; sitcom3/spend.json, videos4/spend.json, videos6/spend.json"}}, "sfx": {"model": "ffmpeg lavfi / licensed CC0", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "captions": {"model": "whisper.cpp + HTML", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "edit": {"model": "ffmpeg + SVG", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "end_card": {"model": "HTML overlay", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "mute_test": {"model": "human review of muted render", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "qa": {"model": "ffmpeg + human review", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}},
113
+ "costClass": "mid",
114
+ "estimatedCostUsd": {"low": 0.256, "high": 0.611, "note": "estimate: sum of stage assumptions; styles catalog recipes; excludes labour, compute, and unbounded retries"},
115
+ "policyRisk": "medium",
116
+ "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"],
118
+ "learned": ["Align both views before the wipe; match their colour grade.", "Show an achievable checklist result, never promised savings."],
119
+ "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
+ },
121
+ {
122
+ "id": "news-parody",
123
+ "name": "News parody",
124
+ "description": "Use branded news cards with sourced facts and narration.",
125
+ "aspectRatios": ["9:16"],
126
+ "durationSeconds": {"min": 15, "max": 25},
127
+ "hookPattern": "A sting and branded headline start on frame one.",
128
+ "stages": ["script", "still", "tts", "sfx", "captions", "edit", "end_card", "mute_test", "qa"],
129
+ "providers": {"script": {"model": "supplied script / human review", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "still": {"model": "supplied art / SVG", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "tts": {"model": "Kokoro local", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "sfx": {"model": "ffmpeg lavfi / licensed CC0", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "captions": {"model": "whisper.cpp + HTML", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "edit": {"model": "ffmpeg + SVG", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "end_card": {"model": "HTML overlay", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "mute_test": {"model": "human review of muted render", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "qa": {"model": "ffmpeg + human review", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}},
130
+ "costClass": "free",
131
+ "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: sum of stage assumptions; styles catalog recipes; excludes labour, compute, and unbounded retries"},
132
+ "policyRisk": "medium",
133
+ "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"],
135
+ "learned": ["Trace every fact to a source; never imply a real outlet endorsement.", "Use generic branding and make the parody clear."],
136
+ "sources": ["styles/out-styles.md", "storyboards/INDEX.md", "editing/eval3/engine-ideas.md"]
137
+ },
138
+ {
139
+ "id": "lo-fi",
140
+ "name": "Handheld lo-fi UGC",
141
+ "description": "Use synthetic handheld scenes and narration with fast reaction cuts.",
142
+ "aspectRatios": ["9:16"],
143
+ "durationSeconds": {"min": 15, "max": 25},
144
+ "hookPattern": "A character is already talking or reacting with camera motion.",
145
+ "stages": ["script", "set", "character", "still", "angle_still", "tts", "image_to_video", "sfx", "captions", "edit", "end_card", "mute_test", "qa"],
146
+ "providers": {"script": {"model": "supplied script / human review", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "set": {"model": "reuse supplied set", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "character": {"model": "reuse character bible", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "still": {"model": "bytedance/seedream-v5.5", "verified": "2026-11-12", "unverified": false, "estimatedCostUsd": {"low": 0.02, "high": 0.06, "note": "estimate: 1-3 text-to-image stills at $0.020; videos6/spend.json and videos4/spend.json"}}, "angle_still": {"model": "bytedance/seedream-v5.5/edit", "verified": "2026-11-12", "unverified": false, "estimatedCostUsd": {"low": 0.056, "high": 0.196, "note": "estimate: 2-7 image edits at $0.028; sitcom3/spend.json, videos6/spend.json, videos4/spend.json"}}, "tts": {"model": "wavespeed-ai/qwen3-tts/voice-design", "verified": "2026-11-12", "unverified": false, "estimatedCostUsd": {"low": 0.03, "high": 0.08, "note": "estimate: 6-16 calls at $0.005; videos6/spend.json; includes regenerated lines"}}, "image_to_video": {"model": "wavespeed-ai/wan-2.2/i2v-480p-ultra-fast", "verified": "2026-11-12", "unverified": false, "estimatedCostUsd": {"low": 0.15, "high": 0.3, "note": "estimate: 3-6 five-second clips at $0.05; sitcom3/spend.json, videos4/spend.json, videos6/spend.json"}}, "sfx": {"model": "ffmpeg lavfi / licensed CC0", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "captions": {"model": "whisper.cpp + HTML", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "edit": {"model": "ffmpeg + SVG", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "end_card": {"model": "HTML overlay", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "mute_test": {"model": "human review of muted render", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "qa": {"model": "ffmpeg + human review", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}},
147
+ "costClass": "mid",
148
+ "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
+ "policyRisk": "medium",
150
+ "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"],
152
+ "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
+ "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
+ },
155
+ {
156
+ "id": "sitcom",
157
+ "name": "Multi-camera sitcom",
158
+ "description": "Cut fictional dialogue between shared sets, angles, and silent reactions.",
159
+ "aspectRatios": ["9:16"],
160
+ "durationSeconds": {"min": 18, "max": 22},
161
+ "hookPattern": "A hand turns the knob before the door falls.",
162
+ "stages": ["script", "set", "character", "still", "angle_still", "tts", "image_to_video", "talking_head", "laugh_track", "sfx", "captions", "edit", "end_card", "mute_test", "qa"],
163
+ "providers": {"script": {"model": "supplied script / human review", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "set": {"model": "reuse supplied set", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "character": {"model": "reuse character bible", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "still": {"model": "bytedance/seedream-v5.5", "verified": "2026-11-12", "unverified": false, "estimatedCostUsd": {"low": 0.02, "high": 0.06, "note": "estimate: 1-3 text-to-image stills at $0.020; videos6/spend.json and videos4/spend.json"}}, "angle_still": {"model": "bytedance/seedream-v5.5/edit", "verified": "2026-11-12", "unverified": false, "estimatedCostUsd": {"low": 0.056, "high": 0.196, "note": "estimate: 2-7 image edits at $0.028; sitcom3/spend.json, videos6/spend.json, videos4/spend.json"}}, "tts": {"model": "wavespeed-ai/qwen3-tts/text-to-speech", "verified": "2026-11-12", "unverified": false, "estimatedCostUsd": {"low": 0.03, "high": 0.055, "note": "estimate: 6-11 calls at $0.005; sitcom3/spend.json, videos5/spend.json, videos4/spend.json"}}, "image_to_video": {"model": "wavespeed-ai/wan-2.2/i2v-480p-ultra-fast", "verified": "2026-11-12", "unverified": false, "estimatedCostUsd": {"low": 0.15, "high": 0.3, "note": "estimate: 3-6 five-second clips at $0.05; sitcom3/spend.json, videos4/spend.json, videos6/spend.json"}}, "talking_head": {"model": "wavespeed-ai/wan-2.2/speech-to-video", "verified": "2026-11-12", "unverified": false, "estimatedCostUsd": {"low": 1.5, "high": 1.8, "note": "estimate: 10-12 calls at $0.15 (480p); sitcom3/spend.json; includes test and retry; other endpoint trials excluded"}}, "laugh_track": {"model": "Mixkit laugh/clap + CC0", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "sitcom3: reused licensed sounds at $0"}}, "sfx": {"model": "ffmpeg lavfi / licensed CC0", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "captions": {"model": "whisper.cpp + HTML", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "edit": {"model": "ffmpeg + SVG", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "end_card": {"model": "HTML overlay", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "mute_test": {"model": "human review of muted render", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "qa": {"model": "ffmpeg + human review", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}},
164
+ "costClass": "expensive",
165
+ "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
+ "policyRisk": "medium",
167
+ "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"],
169
+ "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
+ "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
+ },
172
+ {
173
+ "id": "storytime",
174
+ "name": "Storytime twist",
175
+ "description": "A fictional narrator moves from confession to checklist payoff.",
176
+ "aspectRatios": ["9:16"],
177
+ "durationSeconds": {"min": 15, "max": 25},
178
+ "hookPattern": "The narrator starts mid-story in the first second.",
179
+ "stages": ["script", "set", "character", "still", "angle_still", "tts", "image_to_video", "sfx", "captions", "edit", "end_card", "mute_test", "qa"],
180
+ "providers": {"script": {"model": "supplied script / human review", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "set": {"model": "reuse supplied set", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "character": {"model": "reuse character bible", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "still": {"model": "bytedance/seedream-v5.5", "verified": "2026-11-12", "unverified": false, "estimatedCostUsd": {"low": 0.02, "high": 0.06, "note": "estimate: 1-3 text-to-image stills at $0.020; videos6/spend.json and videos4/spend.json"}}, "angle_still": {"model": "bytedance/seedream-v5.5/edit", "verified": "2026-11-12", "unverified": false, "estimatedCostUsd": {"low": 0.056, "high": 0.196, "note": "estimate: 2-7 image edits at $0.028; sitcom3/spend.json, videos6/spend.json, videos4/spend.json"}}, "tts": {"model": "wavespeed-ai/qwen3-tts/text-to-speech", "verified": "2026-11-12", "unverified": false, "estimatedCostUsd": {"low": 0.03, "high": 0.055, "note": "estimate: 6-11 calls at $0.005; sitcom3/spend.json, videos5/spend.json, videos4/spend.json"}}, "image_to_video": {"model": "wavespeed-ai/wan-2.2/i2v-480p-ultra-fast", "verified": "2026-11-12", "unverified": false, "estimatedCostUsd": {"low": 0.15, "high": 0.3, "note": "estimate: 3-6 five-second clips at $0.05; sitcom3/spend.json, videos4/spend.json, videos6/spend.json"}}, "sfx": {"model": "ffmpeg lavfi / licensed CC0", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "captions": {"model": "whisper.cpp + HTML", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "edit": {"model": "ffmpeg + SVG", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "end_card": {"model": "HTML overlay", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "mute_test": {"model": "human review of muted render", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "qa": {"model": "ffmpeg + human review", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}},
181
+ "costClass": "mid",
182
+ "estimatedCostUsd": {"low": 0.256, "high": 0.611, "note": "estimate: sum of stage assumptions; styles catalog recipes; excludes labour, compute, and unbounded retries"},
183
+ "policyRisk": "medium",
184
+ "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"],
186
+ "learned": ["Keep the character bible identical across shots.", "Use a fictional framing and a checklist payoff, not a testimonial."],
187
+ "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
+ },
189
+ {
190
+ "id": "talking-object",
191
+ "name": "Talking checklist",
192
+ "description": "Animate an illustrated object mouth to narration.",
193
+ "aspectRatios": ["9:16"],
194
+ "durationSeconds": {"min": 15, "max": 25},
195
+ "hookPattern": "The paper talks and moves on frame one.",
196
+ "stages": ["script", "still", "tts", "sfx", "captions", "edit", "end_card", "mute_test", "qa"],
197
+ "providers": {"script": {"model": "supplied script / human review", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "still": {"model": "supplied art / SVG", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "tts": {"model": "Kokoro local", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "sfx": {"model": "ffmpeg lavfi / licensed CC0", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "captions": {"model": "whisper.cpp + HTML", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "edit": {"model": "ffmpeg + SVG", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "end_card": {"model": "HTML overlay", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "mute_test": {"model": "human review of muted render", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}, "qa": {"model": "ffmpeg + human review", "verified": "2026-11-12", "unverified": true, "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: supplied assets or local processing; excludes labour and compute"}}},
198
+ "costClass": "free",
199
+ "estimatedCostUsd": {"low": 0, "high": 0, "note": "estimate: sum of stage assumptions; styles catalog recipes; excludes labour, compute, and unbounded retries"},
200
+ "policyRisk": "low",
201
+ "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"],
203
+ "learned": ["Use an illustrated object with simple mouth flaps.", "Keep all readable text in exact overlays."],
204
+ "sources": ["styles/out-styles.md", "storyboards/INDEX.md", "editing/eval3/engine-ideas.md"]
205
+ }
206
+ ]
@@ -0,0 +1,59 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { mkdir, readFile, open, rename, unlink } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ 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
+ function validate(scores) {
8
+ if (!scores || typeof scores !== 'object' || Array.isArray(scores)) throw new Error('Invalid scores');
9
+ for (const [id, value] of Object.entries(scores)) {
10
+ if (!hooks.some(h => h.id === id) || !Number.isFinite(value) || value < 0) throw new Error('Invalid score');
11
+ }
12
+ return scores;
13
+ }
14
+ function pathFor(directory, site) {
15
+ if (typeof site !== 'string' || !/^[a-z0-9][a-z0-9-]{0,63}$/.test(site)) throw new Error('Invalid site slug');
16
+ return join(directory, `${site}.json`);
17
+ }
18
+ /** Read an explicit site snapshot. Missing files are a cold start; corrupt files fail. */
19
+ export async function readScores(directory, site) {
20
+ const path = pathFor(directory, site);
21
+ try { return validate(JSON.parse(await readFile(path, 'utf8'))); }
22
+ catch (error) { if (error.code === 'ENOENT') return {}; throw new Error('Cannot read scores'); }
23
+ }
24
+ /** Replace a site's snapshot atomically. Callers must use a single writer. */
25
+ export async function writeScores(directory, site, scores) {
26
+ const path = pathFor(directory, site);
27
+ const data = JSON.stringify(validate(scores));
28
+ await mkdir(directory, { recursive: true });
29
+ const temp = `${path}.${randomUUID()}.tmp`;
30
+ let handle;
31
+ let created = false;
32
+ try {
33
+ handle = await open(temp, 'wx', 0o600);
34
+ created = true;
35
+ await handle.writeFile(data);
36
+ await handle.sync();
37
+ await handle.close();
38
+ handle = undefined;
39
+ await rename(temp, path);
40
+ } catch (error) {
41
+ // Cleanup must never replace the original write, sync, close, or rename error.
42
+ if (handle) await handle.close().catch(() => {});
43
+ if (created) await unlink(temp).catch(() => {});
44
+ throw error;
45
+ }
46
+ }
47
+ /** Pure selection; scores belong to the supplied site snapshot. No disk, time, or random input.
48
+ * @param {{id:string, scores?:Record<string,number>}} site
49
+ * @param {string} goal
50
+ */
51
+ export function selectFor(site, goal) {
52
+ if (!site || typeof site !== 'object' || Array.isArray(site)) throw new Error('Invalid site');
53
+ pathFor('.', site.id);
54
+ const scores = validate(site.scores ?? {});
55
+ const eligible = hooks.filter(h => h.risk === 'low' && h.tags.includes(goal));
56
+ const ranked = eligible.map((hook, order) => ({ hook, order, score: scores[hook.id] ?? 0 }));
57
+ ranked.sort((a, b) => b.score - a.score || a.order - b.order);
58
+ return ranked.length ? structuredClone(ranked[0].hook) : null;
59
+ }
@@ -0,0 +1,8 @@
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"}
8
+ ]
package/src/index.js ADDED
@@ -0,0 +1,54 @@
1
+ import { listFormats, getFormat, planFormat } from './formats/index.js';
2
+ export { listFormats, getFormat, planFormat };
3
+ import { templates } from './templates/index.js';
4
+ import { hooks, selectFor } from './hooks/index.js';
5
+ import { lint } from './policy/index.js';
6
+ import { templateProvider, guardCost } from './providers/index.js';
7
+ export { templates, hooks, selectFor, lint, templateProvider, guardCost };
8
+ export { readScores, writeScores } from './hooks/index.js';
9
+ const string = { type: 'string' };
10
+ const fields = {
11
+ list_formats: { costClass: { type: 'string', enum: ['free', 'cheap', 'mid', 'expensive'] }, risk: { type: 'string', enum: ['low', 'medium', 'high'] } },
12
+ get_format: { id: string },
13
+ plan_format: { format: string, site: string, goal: string, dryRun: { type: 'boolean' } },
14
+ 'list-templates': {}, 'list-hooks': {},
15
+ lint: { copy: string, category: { enum: ['housing', 'none'], type: 'string' } },
16
+ 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
+ };
18
+ 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] } : {}) },
21
+ }));
22
+ /** Shared CLI/MCP dispatch. Unknown inputs fail before any action. */
23
+ export function execute(action, args = {}) {
24
+ if (!Object.hasOwn(fields, action)) throw new Error('Unknown action');
25
+ if (!args || typeof args !== 'object' || Array.isArray(args)) throw new Error('Invalid arguments');
26
+ if (args.apply === true && ['plan', 'plan_format'].includes(action)) throw new Error('Apply is unavailable in this offline slice');
27
+ for (const key of tools.find(t => t.name === action).inputSchema.required ?? []) if (args[key] === undefined) throw new Error('Missing required argument: ' + key);
28
+ for (const [key, value] of Object.entries(args)) {
29
+ const field = fields[action][key];
30
+ if (!Object.hasOwn(fields[action], key) || typeof value !== field.type || (field.enum && !field.enum.includes(value))) throw new Error('Invalid argument');
31
+ if (typeof value === 'string' && value.length > 20000) throw new Error('Argument too long');
32
+ }
33
+ if (action === 'list_formats') return listFormats(args);
34
+ if (action === 'get_format') return getFormat(args.id);
35
+ if (action === 'plan_format') return planFormat(args);
36
+ if (action === 'list-templates') return structuredClone(templates);
37
+ if (action === 'list-hooks') return structuredClone(hooks);
38
+ if (action === 'lint') return lint(args.copy, args);
39
+ if (args.apply === true) throw new Error('Apply is unavailable in this offline slice');
40
+ if (args.dryRun === false) throw new Error('Use dry-run; apply is unavailable');
41
+ const descriptor = templates.find(t => t.id === (args.template ?? 'door-open-teaser'));
42
+ if (!descriptor) throw new Error('Unknown template');
43
+ const site = args.site ?? 'demo';
44
+ const hook = selectFor({ id: site }, args.goal ?? 'discovery');
45
+ if (!hook) throw new Error('No low-risk hook matches this goal');
46
+ const copy = args.copy ?? hook.pattern.replace('{{subject}}', 'available listings');
47
+ const policy = lint(copy, args);
48
+ const estimate = templateProvider.estimate();
49
+ return { dryRun: true, applied: false, site, template: descriptor.id, ratio: args.ratio ?? '4:5', hook,
50
+ copy, policy, estimate, costGuard: guardCost({ costMinor: 0, adSpentMinor: 0, monthSpentMinor: 0, perAdMinor: 0, perMonthMinor: 0 }),
51
+ requiredSlots: descriptor.slots.map(s => s.name),
52
+ warnings: ['No image is rendered or campaign launched.', 'Supply verified slots and brand tokens to the provider.', ...descriptor.risk === 'high' ? ['High-risk template requires additional review.'] : []],
53
+ };
54
+ }
@@ -0,0 +1,112 @@
1
+ // This lint is a warning tool, not legal advice. It misses cases.
2
+ // Every result still needs a person to review it.
3
+ // Normalize invisible separators and common Latin lookalikes before all checks.
4
+ const lookalikes = { ı: 'i', а: 'a', е: 'e', о: 'o', р: 'p', с: 'c', х: 'x', у: 'y', і: 'i', ј: 'j', ѕ: 's', к: 'k', м: 'm', т: 't', в: 'b', н: 'h', ι: 'i', α: 'a', ε: 'e', ο: 'o', ρ: 'p', χ: 'x', υ: 'y', κ: 'k', τ: 't', ν: 'v' };
5
+ function normalize(copy) {
6
+ return copy.normalize('NFKC').toLowerCase().normalize('NFD').replace(/\p{M}/gu, '')
7
+ .replace(/[\u00ad\u034f\u061c\u180e\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/g, '')
8
+ .replace(/[ıаеорсхуіјѕкмтвнαεορχυκτνι]/g, c => lookalikes[c]);
9
+ }
10
+ function hasContact(text) {
11
+ // Each token/run is scanned once. Do not retry an unbounded suffix at every character.
12
+ if (text.split(/\s+/).some(token => /^[^@]+@[^@.]+\.[^@]+$/.test(token))) return true;
13
+ const withoutDates = text
14
+ .replace(/\b(?:parcel(?:[ -]+id)?|assessor[ -]+parcel)[ :#-]*[\d-]+\b/g, ' ')
15
+ .replace(/\b(?:\d{4}[-/.]\d{1,2}[-/.]\d{1,2}|\d{1,2}[-/.]\d{1,2}[-/.]\d{4})\b/g, ' ');
16
+ for (const match of withoutDates.matchAll(/[+\d][\d\s().-]*/g)) {
17
+ const digits = match[0].replace(/\D/g, '').length;
18
+ if (digits >= 10) return true;
19
+ }
20
+ return false;
21
+ }
22
+ function hasPlaceholder(text) {
23
+ const start = text.indexOf('{{');
24
+ return start !== -1 && text.indexOf('}}', start + 2) !== -1;
25
+ }
26
+ function hasHousing(text) {
27
+ const subject = text.replace(/\b(?:apartment design games?|auction software|art listings?|lease accounting software|flat tires?|flat rates?|home page|home runs?|house music)\b/g, '');
28
+ return /\b(?:homes?|housing|houses?|condos?|condominiums?|apartments?|leases?|leasing|renting|rented|townhouses?|duplex(?:es)?|landlords?|tenants?|bedrooms?|studios?|\d+br|flats?|real estate|foreclosures?|rent(?:al|als)?|propert(?:y|ies)|mortgages?|auctions?|listings?)\b/.test(subject);
29
+ }
30
+ // Bounded connectors keep cues attached to people, without scanning arbitrary prose.
31
+ const gap = '[\\s-]+';
32
+ const spaced = pattern => pattern.replaceAll(' ', gap);
33
+ const people = spaced('(?:(?:tenants?|residents?|applicants?|people|persons?|households?|roommates?|family|families|professionals?|with|or|and|pets|grad|job|on|are|is) ){0,4}');
34
+ const modifier = spaced('(?:(?:a|an) )?(?:(?:young|single|mature|professional|traditional|able bodied) )?');
35
+ const cueBefore = spaced('(?:only|no|not|not for|wanted|prefer|prefers|seeking|seeks|preferred|ideal for|great for|perfect for|must be|must have|must speak|exclude|excluding|not suitable for)');
36
+ const cueAfter = spaced('(?:only|preferred|wanted|required|excluded|exclusive|need not apply|not welcome|not accepted|not allowed|not permitted|prohibited|banned)');
37
+ function preference(subject, welcomeSignals = false) {
38
+ const group = spaced(subject);
39
+ // 'X welcome' is inclusive for income, family, age, marital status and disability. For religion,
40
+ // race, origin and sex it signals a preference, so those rules pass welcomeSignals = true.
41
+ const after = welcomeSignals ? '(?:' + cueAfter + '|welcome)' : cueAfter;
42
+ return new RegExp('\\b(?:' + cueBefore + gap + people + modifier + '(?:' + group + ')|(?:' + group + ')' + gap + people + after + ')\\b');
43
+ }
44
+ // Keep age numbers bounded, including common written ages.
45
+ const ageNumber = '(?:\\d{1,2}|(?:twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety)(?:[ -](?:one|two|three|four|five|six|seven|eight|nine))?|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen)';
46
+ const ageSpan = ageNumber + '\\s*(?:(?:to|thru|through|[-\\u2013])\\s*' + ageNumber + '|\\+|plus)(?!\\w)';
47
+ const ageRange = new RegExp('\\b(?:age[sd]?(?:[ -]+range)?[\\s-]*' + ageSpan + '|must[ -]+be[ -]+(?:under|over|younger than|older than)[ -]+' + ageNumber + '\\b)');
48
+ const childAmenity = new RegExp('\\b(?:kids|children)[ -]+age[sd]?[\\s-]*' + ageSpan + '[ -]+(?:play[ -]+area|playground)\\b', 'g');
49
+ const ageRestriction = {
50
+ // Remove only the amenity phrase. Other restrictions in the copy still apply.
51
+ test: text => ageRange.test(text.replace(childAmenity, ' ')),
52
+ };
53
+ const postalTerm = '(?:(?:zip|postal)(?:[\\s-]*codes?)?|postcodes?)';
54
+ const postalRestriction = new RegExp('\\b(?:' + postalTerm + '[\\s-]+only|(?:only|just)[\\s-]+(?:in[\\s-]+)?(?:these[\\s-]+|the[\\s-]+)?' + postalTerm + '|' + postalTerm + '[\\s-]*(?:targeting|restricted)|' + postalTerm + '[ -]+(?:\\d{5}(?:-\\d{4})?|[a-z]{1,2}\\d[a-z\\d]?[ -]+\\d[a-z]{2})[ -]+(?:residents?[ -]+)?only)\\b');
55
+ const housingRules = [
56
+ ['targeting_age', preference('young(?: professionals?| adults?)?|seniors?|retirees?|millennials?|students?|mature|elderly|teens?|teenagers?|toddlers?|babies|infants?'), 'Remove age or student restrictions and preferences.'],
57
+ ['targeting_familial_status', preference('children|child|kids|kiddos|family|families|parents?|mothers?|fathers?|pregnant'), 'Remove family-status restrictions and preferences.'],
58
+ ['targeting_familial_status', preference('adults?|empty nesters'), 'Remove family-status restrictions and preferences.'],
59
+ ['targeting_marital_status', preference('singles?|bachelors?|couples?|married|unmarried|divorced'), 'Remove marital-status restrictions and preferences.'],
60
+ ['targeting_religion', preference('christians?|muslims?|jewish|jews?|hindus?|buddhists?|catholics?|protestants?|believers|religious', true), 'Remove religious targeting or preferences.'],
61
+ ['targeting_income', preference('section (?:8|eight)|vouchers?|hud|housing assistance|public assistance|income source|job income|welfare|ssi|ssdi|disability income|social security'), 'Remove source-of-income restrictions or preferences.'],
62
+ ['targeting_sex', preference('men|women|males?|females?|boys?|girls?|gentlemen|ladies|sex|gender|gay|straight', true), 'Remove sex or gender restrictions and preferences.'],
63
+ ['targeting_origin', preference('americans?|canadians?|chinese|indians?|mexicans?|british|french|arabs?|hispanics?|latinos?|latinas?|africans?|asians?|(?:us )?citizens?|native speakers?|english(?: speakers?)?|immigrants?|foreigners?|national origin|ethnicity|race|whites?|blacks?|minorities', true), 'Remove race or national-origin restrictions and preferences.'],
64
+ ['targeting_disability', preference('disabled|handicapped|deaf|blind|disability|disabilities|mentally ill|wheelchairs?(?: users?)?|service animals?|able bodied|non disabled|wheelchair accessible'), 'Remove disability restrictions and preferences.'],
65
+ // Indirect occupancy language needs review even without an explicit restriction.
66
+ ['targeting_familial_status', /\b(?:traditional[\s-]+family[\s-]+values[\s-]+neighbou?rhood|adult[\s-]+community|only[\s-]+one[\s-]+child)\b/, 'Review possible family-status or occupancy preferences.', 'warning'],
67
+ ['targeting_religion', /\b(?:christian|muslim|jewish|hindu|buddhist|catholic|protestant)[\s-]+home\b/, 'Review possible religious occupancy preferences.', 'warning'],
68
+ ['targeting_disability', /\bmust[\s-]+be[\s-]+able[\s-]+to[\s-]+climb[\s-]+stairs\b/, 'Review possible disability restrictions in stair-access requirements.', 'warning'],
69
+ // These phrases describe eligibility or preferred occupants, rather than a bare class word.
70
+ ['targeting_age', /\b(?:(?:over|under|older than|younger than)[ -]+\d{1,3}[ -]+(?:community|residents?|tenants?|only)|(?:\d{1,3}|fifty[ -]five)[ -]*(?:\+|plus\b|(?:and|or)[ -]+(?:over|older|up)\b|years?[ -]+(?:old|(?:and|or)[ -]+(?:over|older))\b)|senior living|retirement community)\b/, 'Remove age eligibility language.'],
71
+ ['targeting_age', /\b\d{1,3}\+(?=[ -]+(?:community|residents?|tenants?|only)\b)/, 'Remove age eligibility language.'],
72
+ ['targeting_familial_status', /\b(?:family[ -]friendly|child[ -]free)\b/, 'Remove family-status preferences.'],
73
+ ['targeting_religion', /\b(?:christian|muslim|jewish|hindu|buddhist|catholic|protestant)[ -]+(?:neighbou?rhood|community|building)(?!\s+(?:cent(?:er|re)|hall|school|hospital|museum|library|church|temple|mosque|synagogue))\b/, 'Remove religious neighborhood preferences.'],
74
+ ['targeting_sex', /\b(?:gay|straight)[ -]friendly\b/, 'Remove sex or gender preferences.'],
75
+ ['targeting_area', /\b(?:zip|postal)[ -]+(?:code[ -]+)?\d{1,10}[ -]+only\b|\b(?:target|targeting|exclude|excluding)\s+(?:residents?\s+)?(?:in\s+)?(?:(?:this|that|the|these|specific|selected)\s+)?(?:areas?|neighbou?rhoods?|districts?)\b|\b(?:locals?|area residents?)\s+only\b/, 'Remove postal or area targeting language.'],
76
+ ['targeting_age', ageRestriction, 'Remove age ranges or limits.'],
77
+ ['targeting_area', postalRestriction, 'Remove ZIP-code targeting.'],
78
+ ];
79
+ /** Conservative copy checks, not platform approval or factual verification.
80
+ * @param {string} copy
81
+ * @param {{category?:string}} options
82
+ */
83
+ export function lint(copy, options = {}) {
84
+ if (typeof copy !== 'string' || copy.length > 20000) throw new Error('Copy must be a string of at most 20000 characters');
85
+ if (!options || typeof options !== 'object' || Array.isArray(options)) throw new Error('Invalid lint options');
86
+ const { category = 'housing' } = options;
87
+ if (!['housing', 'none'].includes(category)) throw new Error('Unsupported category');
88
+ const text = normalize(copy);
89
+ // Preserve punctuation for contact and date checks. Normalize it only for targeting.
90
+ const targetingText = text.replace(/\bu\.s\.(?=[\s_.-])/g, 'us').replace(/[_.]/g, ' ');
91
+ const findings = [];
92
+ const add = (code, message, severity = 'error') => findings.push({ code, severity, message });
93
+ if (!text.trim()) add('empty', 'Supply ad copy.');
94
+ const housing = category === 'housing' || hasHousing(text);
95
+ if (category !== 'housing' && housing) add('housing_category', 'Declare the housing Special Ad Category.');
96
+ const rules = [
97
+ ...(housing ? housingRules : []),
98
+ ['deceptive', /\b(guarantee\w*|risk[ -]?free|zero risk|no risk|double your money|instant profit)\b/, 'Remove promises of certain outcomes.'],
99
+ ['superlative', /\b(best(?![ -]street\b)|top(?![ -](?:floor\b|\d+\b|rated\b|notch\b|tier\b))|largest|fastest|cheapest|unbeatable|number one)\b|#\s*1\b/, 'Remove banned superlatives.'],
100
+ ['below_market', /\b(?:below|under|off|less[ -]+than|cheaper[ -]+than|discount(?:ed)?[ -]+(?:from|against|to))[ -]+(?:(?:the|current|local|fair|estimated|average|prevailing|open)[ -]+){0,5}(?:market|appraisal|appraised[ -]+value|comps)\b|\bmarket\s+(?:value|rates?|prices?)\s+(?:minus|less|discount)/, 'A below-market claim needs independent evidence and review.'],
101
+ ['urgency', /\b(only today|ending soon|limited time|act now|last chance|only \d+ left)\b/, 'Verify scarcity and timing before use.'],
102
+ ['news', /\b(breaking(?![ -]down\b)|news alert)\b/, 'News framing needs review and a clear ad disclosure.'],
103
+ ['staging', /\b(before.{0,12}after|virtually staged|virtual staging|ai.staged)\b/, 'Verify the source images and staging disclosure.'],
104
+ ];
105
+ for (const [code, pattern, message, severity] of rules) {
106
+ if (pattern.test(code.startsWith('targeting_') ? targetingText : text)) add(code, message, severity);
107
+ }
108
+ if (hasContact(text)) add('private_data', 'Remove contact data.');
109
+ if (hasPlaceholder(text)) add('placeholder', 'Replace unresolved slots before review.');
110
+ add('human_review', 'This lint is a warning tool, not legal advice. It misses cases. Every result needs a person to review facts, consent, disclosures, and platform rules.', 'warning');
111
+ return { status: findings.some(f => f.severity === 'error') ? 'blocked' : 'review', category, findings };
112
+ }
@@ -0,0 +1,56 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { templates } from '../templates/index.js';
3
+ /** @typedef {{capabilities:Function, estimate:Function, run:Function}} Provider */
4
+ /** Pure cap check in integer minor units of one caller-selected currency.
5
+ * Existing spend must include reservations and uncertain charges.
6
+ */
7
+ export function guardCost(input) {
8
+ if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Invalid cost or cap');
9
+ const values = {};
10
+ for (const key of ['costMinor', 'adSpentMinor', 'monthSpentMinor', 'perAdMinor', 'perMonthMinor']) {
11
+ if (!Object.hasOwn(input, key)) throw new Error(`Invalid cost or cap: ${key} must be an own property`);
12
+ const value = input[key];
13
+ values[key] = value;
14
+ if (!Number.isSafeInteger(value) || value < 0) throw new Error('Invalid cost or cap');
15
+ }
16
+ if ('apply' in input && !Object.hasOwn(input, 'apply')) throw new Error('Invalid cost or cap: apply must be an own property');
17
+ const { costMinor, adSpentMinor, monthSpentMinor, perAdMinor, perMonthMinor } = values;
18
+ const apply = input.apply;
19
+ const reasons = [];
20
+ if (costMinor > 0 && apply !== true) reasons.push('apply_required');
21
+ if (costMinor > perAdMinor - adSpentMinor) reasons.push('per_ad_cap');
22
+ if (costMinor > perMonthMinor - monthSpentMinor) reasons.push('monthly_cap');
23
+ return { ok: reasons.length === 0, reasons };
24
+ }
25
+ const escape = value => value.replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
26
+ /** HTML-to-image adapter stub. It emits deterministic HTML only, never a raster image. */
27
+ export const templateProvider = {
28
+ id: 'html-template-stub',
29
+ capabilities: () => ({ formats: ['text/html'], aspectRatios: ['4:5', '9:16'], imageRendering: false }),
30
+ estimate: () => ({ costMinor: 0, currency: 'USD', confidence: 'exact' }),
31
+ run(input) {
32
+ if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Invalid provider input');
33
+ const { template, ratio = '4:5', slots, brand = {} } = input;
34
+ const descriptor = templates.find(t => t.id === template);
35
+ if (!descriptor || !descriptor.aspectRatios.includes(ratio)) throw new Error('Invalid template or ratio');
36
+ if (!slots || typeof slots !== 'object' || Array.isArray(slots)) throw new Error('Invalid slots');
37
+ if (!brand || typeof brand !== 'object' || Array.isArray(brand)) throw new Error('Invalid brand');
38
+ if (Object.keys(slots).some(k => !descriptor.slots.some(s => s.name === k))) throw new Error('Unknown slot');
39
+ if (Object.keys(brand).some(k => !descriptor.brandTokens.includes(k))) throw new Error('Unknown brand token');
40
+ const colors = { primary: '#2455cc', background: '#ffffff', foreground: '#151515' };
41
+ for (const key of Object.keys(colors)) {
42
+ colors[key] = brand[key] ?? colors[key];
43
+ if (typeof colors[key] !== 'string' || !/^#[0-9a-f]{6}$/i.test(colors[key])) throw new Error('Invalid brand color');
44
+ }
45
+ const name = brand.name ?? 'Demo';
46
+ if (typeof name !== 'string' || !name.trim() || name.length > 80) throw new Error('Invalid brand name');
47
+ const body = descriptor.slots.map(slot => {
48
+ const value = Object.hasOwn(slots, slot.name) ? slots[slot.name] : undefined;
49
+ if (typeof value !== 'string' || !value.trim() || value.length > slot.maxChars) throw new Error('Invalid required slot');
50
+ return `<section data-slot="${slot.name}">${escape(value)}</section>`;
51
+ }).join('');
52
+ const width = 1080, height = ratio === '4:5' ? 1350 : 1920;
53
+ const html = `<!doctype html><html><head><meta charset="utf-8"><title>Ad preview</title><style>body{margin:0;width:${width}px;height:${height}px;box-sizing:border-box;padding:80px;background:${colors.background};color:${colors.foreground};font:48px sans-serif}header{color:${colors.primary}}section{margin:40px 0;overflow-wrap:anywhere}</style></head><body data-template="${descriptor.id}"><header>${escape(name)}</header>${body}</body></html>`;
54
+ return { html, mime: 'text/html', width, height, image: null, sha256: createHash('sha256').update(html).digest('hex'), costMinor: 0, warnings: ['Stub: HTML only; image rendering is unavailable.'] };
55
+ },
56
+ };
@@ -0,0 +1,26 @@
1
+ /** @typedef {{name:string, required:boolean, maxChars:number, source:string}} Slot */
2
+ /** @typedef {{id:string, version:number, name:string, aspectRatios:string[], slots:Slot[], beats:string[], risk:string}} Template */
3
+ const slot = (name, source = 'source') => ({ name, source, required: true, maxChars: 240 });
4
+ /** @type {Template[]} */
5
+ export const templates = [
6
+ ['door-open-teaser', 'Door-open teaser', ['detail'], ['Door reveal', 'Show the actual condition', 'Invite a closer look'], 'low'],
7
+ ['ugc-first-person', 'UGC first-person', ['experience', 'disclosure'], ['First-person introduction', 'Verified experience', 'Ad disclosure and invitation'], 'medium'],
8
+ ['honest-before-after', 'Honest before/after', ['before', 'after', 'disclosure'], ['Same view before', 'Same view after', 'Explain actual or virtual changes'], 'medium'],
9
+ ['breaking-news-frame', 'Breaking-news frame', ['fact', 'source', 'disclosure'], ['Brand update banner', 'Dated and sourced fact', 'Clearly identify this as an ad'], 'high'],
10
+ ['help-urgency', 'Help/urgency', ['problem', 'help', 'timing'], ['Name the task', 'Offer practical help', 'Use only verified timing'], 'medium'],
11
+ ].map(([id, name, extra, beats, risk]) => ({
12
+ id, name, version: 1, aspectRatios: ['4:5', '9:16'], risk, beats,
13
+ slots: [slot('headline', 'hook'), ...extra.map(x => slot(x)), slot('cta', 'cta')],
14
+ brandTokens: ['name', 'primary', 'background', 'foreground'],
15
+ renderer: 'template', costClass: 'free', policy: { textExact: true, imageMayContainText: false },
16
+ }));
17
+
18
+ // Freeze every nested descriptor so callers cannot change rendering limits or risk.
19
+ function deepFreeze(value) {
20
+ if (value && typeof value === 'object') {
21
+ for (const child of Object.values(value)) deepFreeze(child);
22
+ Object.freeze(value);
23
+ }
24
+ return value;
25
+ }
26
+ deepFreeze(templates);