@cliangdev/conductor 0.25.0 → 0.27.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.
Files changed (50) hide show
  1. package/assets/claude/agents/conductor-coder.md +25 -0
  2. package/assets/claude/agents/conductor-content-art-director.md +68 -0
  3. package/assets/claude/agents/conductor-content-creative-director.md +56 -0
  4. package/assets/claude/agents/conductor-content-ideator.md +49 -0
  5. package/assets/claude/agents/conductor-content-scriptwriter.md +42 -0
  6. package/assets/claude/agents/conductor-content-strategist.md +47 -0
  7. package/assets/claude/commands/conductor/content.md +64 -0
  8. package/assets/claude/commands/conductor/creative.md +46 -46
  9. package/assets/claude/commands/conductor/implement.md +1 -1
  10. package/assets/claude/skills/conductor-content-studio/SKILL.md +323 -0
  11. package/assets/claude/skills/conductor-content-studio/references/capabilities.md +61 -0
  12. package/assets/claude/skills/conductor-content-studio/references/compliance-checklist.md +120 -0
  13. package/assets/claude/skills/conductor-content-studio/references/idea-template.md +54 -0
  14. package/assets/claude/skills/conductor-content-studio/references/platform-specs.md +59 -0
  15. package/assets/claude/skills/conductor-content-studio/references/publish-pack-template.md +121 -0
  16. package/assets/claude/skills/conductor-content-studio/references/rubric.md +99 -0
  17. package/assets/claude/skills/conductor-content-studio/references/script-template.md +53 -0
  18. package/assets/claude/skills/conductor-content-studio/references/shotlist-template.md +44 -0
  19. package/assets/claude/skills/conductor-creative/SKILL.md +111 -45
  20. package/assets/claude/skills/conductor-creative/references/brand-kit-setup.md +41 -0
  21. package/assets/claude/skills/conductor-publisher/SKILL.md +26 -2
  22. package/assets/cli-manifest.json +48 -3
  23. package/dist/creative/assertions.js +22 -11
  24. package/dist/creative/frame.js +3 -3
  25. package/dist/creative/job/file-transport.mjs +211 -0
  26. package/dist/creative/job/render.mjs +84 -2
  27. package/dist/creative/job/server.mjs +70 -13
  28. package/dist/lib/creative-render.d.ts +62 -0
  29. package/dist/lib/creative-render.d.ts.map +1 -1
  30. package/dist/lib/creative-render.js +51 -0
  31. package/dist/lib/creative-render.js.map +1 -1
  32. package/dist/lib/plugin-assets.d.ts.map +1 -1
  33. package/dist/lib/plugin-assets.js +17 -0
  34. package/dist/lib/plugin-assets.js.map +1 -1
  35. package/dist/mcp/index.d.ts +856 -261
  36. package/dist/mcp/index.d.ts.map +1 -1
  37. package/dist/mcp/index.js +164 -75
  38. package/dist/mcp/index.js.map +1 -1
  39. package/dist/mcp/tools/creative-drafts.d.ts +134 -0
  40. package/dist/mcp/tools/creative-drafts.d.ts.map +1 -0
  41. package/dist/mcp/tools/creative-drafts.js +405 -0
  42. package/dist/mcp/tools/creative-drafts.js.map +1 -0
  43. package/dist/mcp/tools/creatives.d.ts +3 -0
  44. package/dist/mcp/tools/creatives.d.ts.map +1 -1
  45. package/dist/mcp/tools/creatives.js.map +1 -1
  46. package/dist/mcp/tools/issues.d.ts.map +1 -1
  47. package/dist/mcp/tools/issues.js +6 -1
  48. package/dist/mcp/tools/issues.js.map +1 -1
  49. package/package.json +1 -1
  50. package/scripts/build-creative.mjs +1 -0
@@ -34,6 +34,28 @@ back here with the rendered frames to attach and schedule.
34
34
  Also note any media the person handed you: local paths or public URLs, in the order they should
35
35
  appear. Media that must be measured (video) is measured by the tool; you do not need dimensions.
36
36
 
37
+ ### A publish pack, if one exists
38
+
39
+ Some earlier steps hand over a publish pack with the content: per-destination copy and options, plus
40
+ a first comment and notes. The content team (`conductor-content-studio`) writes one as
41
+ `.conductor/content-runs/<run>/publish-pack.json` (and a readable `publish-pack.md`); any other
42
+ producer can leave the same shape. When the content is a Creative, look for
43
+ `.conductor/content-runs/*/publish-pack.json` whose `creativeId` matches it, or use the one the
44
+ person points to. If you find one:
45
+
46
+ - Pre-fill each target's `captionOverride` and `options` (`publishOptions` on `set_publish_targets`)
47
+ from the pack's matching `platforms[]` entry, by platform and format. The pack has no accounts;
48
+ resolve those from `list_publish_targets` as usual.
49
+ - Check every option key against that destination's `optionKeys` and drop one it does not list,
50
+ saying so. Never invent a key the pack did not carry. A cover frame or alt text the pack names
51
+ that needs a Post's own asset (a cover image, per-card alt text) is a note for the person, not a
52
+ guess.
53
+ - Show the pre-filled copy and options in the confirmation table (see Step 4) and let the person
54
+ change any of them before anything is submitted. Their edit wins over the pack.
55
+ - Mention the pack's first comment as a **manual step**: Conductor cannot post comments, so give the
56
+ text to the person to paste once the post is live.
57
+ - Pack or no pack, the normal review applies. The pack settles copy and options; it approves nothing.
58
+
37
59
  ### Formats
38
60
 
39
61
  Some platforms publish more than one surface. Ask which one the person means when it is not
@@ -84,7 +106,8 @@ connectors gain capabilities — so read `optionKeys` off that destination's row
84
106
  platform, and never invent one because it sounds plausible; an unrecognized key is silently
85
107
  dropped rather than acted on. TikTok needs a `privacyLevel` from that account's
86
108
  `privacyLevelOptions` before it will publish at all; nothing else needs an option to go out, so
87
- absent a stated preference, leave every other option unset rather than filling in a default.
109
+ absent a stated preference (a publish pack, below, counts as one), leave every other option unset
110
+ rather than filling in a default.
88
111
 
89
112
  Then run this checklist against the copy, per destination, and **halt on any failure** — report it
90
113
  and ask how to proceed. Do not fix content yourself.
@@ -117,7 +140,8 @@ Read the result before saying anything:
117
140
 
118
141
  ## Step 4 — The confirmation table is the last line of defense
119
142
 
120
- End every successful run with a table, one row per destination:
143
+ End every successful run with a table, one row per destination (add a column for the options
144
+ set, such as a disclosure toggle or an AI label, whenever any were pre-filled or chosen):
121
145
 
122
146
  | Post | Destination | Account | When | State |
123
147
  |---|---|---|---|---|
@@ -171,13 +171,41 @@
171
171
  },
172
172
  {
173
173
  "name": "conductor:creative",
174
- "description": "One entry point for creative work \u2014 checks the workspace brand (offers to set it up if unconfigured), asks what to make (static ad, short video, an existing clip), then hands off to the conductor-creative skill through render, preview and attach."
174
+ "description": "One entry point for creative work \u2014 checks the workspace brand (offers to set it up if unconfigured), asks what to make (static ad, short video, an existing clip), then hands off to the conductor-creative skill through draft, preview, approval, commit and attach."
175
+ },
176
+ {
177
+ "name": "conductor:content",
178
+ "description": "Run a content team: agents brainstorm competing short-form post ideas, pick one with a bias-resistant judge, write the script and direction, and render a local draft. You approve at the preview; only then is the Creative uploaded. Never creates a Post."
175
179
  }
176
180
  ],
177
181
  "agents": [
178
182
  {
179
183
  "name": "conductor-researcher",
180
184
  "description": "Researches unfamiliar technologies, libraries, and APIs for PRD creation. Auto-triggered by conductor:prd when confidence is low."
185
+ },
186
+ {
187
+ "name": "conductor-coder",
188
+ "description": "Implements one task test-first. Spawned in parallel by conductor:implement with the conductor-coder skill preloaded."
189
+ },
190
+ {
191
+ "name": "conductor-content-strategist",
192
+ "description": "Writes the content brief (objective, audience tension, must-say/avoid, assets, what has worked) from a workspace context snapshot. Stage of conductor-content-studio."
193
+ },
194
+ {
195
+ "name": "conductor-content-ideator",
196
+ "description": "Brainstorms distinct short-form post ideas from one assigned angle, tagging each RENDER or FILM. Three run in parallel inside conductor-content-studio."
197
+ },
198
+ {
199
+ "name": "conductor-content-creative-director",
200
+ "description": "Judges ideas with a fixed rubric: scores anonymized ideas, compares finalists pairwise from rendered drafts, argues against its own leader. Stage of conductor-content-studio."
201
+ },
202
+ {
203
+ "name": "conductor-content-scriptwriter",
204
+ "description": "Writes the winning idea's script: hook, timed beats, payoff, CTA and captions per platform. Stage of conductor-content-studio."
205
+ },
206
+ {
207
+ "name": "conductor-content-art-director",
208
+ "description": "Writes visual direction and the creative-spec.json for the draft renderer (or a shoot script for a filming idea). Stage of conductor-content-studio."
181
209
  }
182
210
  ],
183
211
  "skills": [
@@ -195,7 +223,11 @@
195
223
  },
196
224
  {
197
225
  "name": "conductor-creative",
198
- "description": "Makes a Creative end to end, static or short video: sets up the Brand Kit if unconfigured, reads it and its Knowledge page, picks media and an angle, creates the Creative, renders and looks at the result, fixes what's wrong, then hands it to conductor-publisher."
226
+ "description": "Makes a Creative end to end, static or short video: sets up the Brand Kit if unconfigured, reads it and its Knowledge page, picks media and an angle, previews a draft locally and shows it for approval before anything is saved, commits it, fixes what's wrong, then hands it to conductor-publisher."
227
+ },
228
+ {
229
+ "name": "conductor-content-studio",
230
+ "description": "Orchestrates the content team: context snapshot, parallel ideators, anonymized judging, finalist drafts, script and direction, then a local draft preview you approve before anything is uploaded. Ends at a committed Creative or a shoot script; never creates a Post."
199
231
  }
200
232
  ]
201
233
  },
@@ -781,7 +813,7 @@
781
813
  },
782
814
  {
783
815
  "name": "upload_creative_photo",
784
- "description": "Deprecated alias for upload_creative_media — same behavior, including for video/audio.",
816
+ "description": "Deprecated alias for upload_creative_media \u2014 same behavior, including for video/audio.",
785
817
  "category": "marketing"
786
818
  },
787
819
  {
@@ -800,6 +832,19 @@
800
832
  "creativeId"
801
833
  ]
802
834
  },
835
+ {
836
+ "name": "preview_creative_draft",
837
+ "description": "Look at a Creative before anything is saved: renders a draft locally from the create_creative fields plus local files (photoPath, clipPath, audioPath) and returns the contact sheet as an image. Never writes to Conductor; commit only after the person approves.",
838
+ "category": "marketing"
839
+ },
840
+ {
841
+ "name": "commit_creative_draft",
842
+ "description": "Save an approved draft: uploads its local files, creates (or updates) the Creative, and renders the real frames. The first write to Conductor; a draft commits once. Call get_creative to verify.",
843
+ "category": "marketing",
844
+ "requiredParams": [
845
+ "draftDir"
846
+ ]
847
+ },
803
848
  {
804
849
  "name": "attach_creative_to_post",
805
850
  "description": "Attach a Creative's rendered frames to a Post as its media, placement to platform, for every destination without its own custom media. Call get_post_status (postId) to verify.",
@@ -121,12 +121,23 @@ function sameRgb(a, b) {
121
121
  export async function runAssertions(board, placement, brand, { outputScale = 2 } = {}) {
122
122
  const errors = [];
123
123
  const warnings = [];
124
+ // The same findings with a stable `rule` id each, for callers (the draft preview) that report them
125
+ // structured. `errors` / `warnings` stay plain message arrays, exactly as before.
126
+ const checks = [];
127
+ const fail = (rule, message) => {
128
+ errors.push(message);
129
+ checks.push({ rule, severity: 'error', message });
130
+ };
131
+ const warn = (rule, message) => {
132
+ warnings.push(message);
133
+ checks.push({ rule, severity: 'warning', message });
134
+ };
124
135
  const br = rectOf(board);
125
136
 
126
137
  const nodes = [...board.querySelectorAll(CHECKED_SELECTOR)].map((n) => ({ name: n.className, rect: rectOf(n) }));
127
138
 
128
139
  const spill = spillDetect(br, nodes);
129
- if (spill.length) errors.push(`text spill: ${spill.join('; ')}`);
140
+ if (spill.length) fail('spill', `text spill: ${spill.join('; ')}`);
130
141
 
131
142
  const safe = {
132
143
  top: Number(board.dataset.safeTop) || 0,
@@ -134,19 +145,19 @@ export async function runAssertions(board, placement, brand, { outputScale = 2 }
134
145
  right: Number(board.dataset.safeRight) || 0,
135
146
  };
136
147
  const intrusion = safeZoneIntrusion(br, nodes, safe);
137
- if (intrusion.length) errors.push(`safe-zone intrusion: ${intrusion.join('; ')}`);
148
+ if (intrusion.length) fail('safeZone', `safe-zone intrusion: ${intrusion.join('; ')}`);
138
149
 
139
150
  if (brand && brand.fontFamily) {
140
151
  const headlineEl = board.querySelector('.cc-headline');
141
152
  const size = Math.round(parseFloat(getComputedStyle(headlineEl).fontSize)) || 16;
142
153
  if (!document.fonts.check(`800 ${size}px "${brand.fontFamily}"`)) {
143
- errors.push(`font "${brand.fontFamily}" did not load (would export in a fallback face)`);
154
+ fail('font', `font "${brand.fontFamily}" did not load (would export in a fallback face)`);
144
155
  }
145
156
  }
146
157
 
147
158
  const imgs = [...board.querySelectorAll('img')];
148
159
  const broken = imgs.filter((i) => !i.complete || i.naturalWidth === 0).map((i) => i.getAttribute('src'));
149
- if (broken.length) errors.push(`image(s) failed to load: ${broken.join(', ')}`);
160
+ if (broken.length) fail('image', `image(s) failed to load: ${broken.join(', ')}`);
150
161
 
151
162
  // A bleed/card layout paints its photo as a CSS background, invisible to
152
163
  // the <img> check above — probe it directly, the same way a typo'd path
@@ -161,11 +172,11 @@ export async function runAssertions(board, placement, brand, { outputScale = 2 }
161
172
  img.src = bgUrlMatch[1];
162
173
  });
163
174
  if (!probe.ok) {
164
- errors.push(`background photo failed to load: ${bgUrlMatch[1]}`);
175
+ fail('image', `background photo failed to load: ${bgUrlMatch[1]}`);
165
176
  } else if (placement && probe.w < placement.w * outputScale && probe.h < placement.h * outputScale) {
166
177
  // Soft photography check: a source smaller than the output frame (2x for images, 1x for video) is
167
178
  // upscaled, so the photo goes soft while the text stays crisp.
168
- warnings.push(`photo is ${probe.w}x${probe.h}, upscaled to fit ${placement.w * outputScale}x${placement.h * outputScale}`);
179
+ warn('photoResolution', `photo is ${probe.w}x${probe.h}, upscaled to fit ${placement.w * outputScale}x${placement.h * outputScale}`);
169
180
  }
170
181
  }
171
182
 
@@ -176,9 +187,9 @@ export async function runAssertions(board, placement, brand, { outputScale = 2 }
176
187
  const expectedRaw = getComputedStyle(board).getPropertyValue('--cc-accent').trim();
177
188
  const expectedRgb = expectedRaw ? resolveCssColor(expectedRaw) : null;
178
189
  if (!emRgb) {
179
- errors.push('accent colour did not resolve on the headline');
190
+ fail('accent', 'accent colour did not resolve on the headline');
180
191
  } else if (expectedRgb && !sameRgb(emRgb, expectedRgb)) {
181
- errors.push(`accent colour unresolved: headline em is ${getComputedStyle(em).color}, brand accent resolves to ${expectedRaw}`);
192
+ fail('accent', `accent colour unresolved: headline em is ${getComputedStyle(em).color}, brand accent resolves to ${expectedRaw}`);
182
193
  }
183
194
  }
184
195
 
@@ -207,15 +218,15 @@ export async function runAssertions(board, placement, brand, { outputScale = 2 }
207
218
  if (fgRgb && bgRgb) contrast = contrastRatio(fgRgb, bgRgb);
208
219
  }
209
220
  if (contrast !== null && contrast < 3) {
210
- errors.push(`headline contrast is ${contrast}:1 against its background, below the 3:1 floor for large text`);
221
+ fail('contrast', `headline contrast is ${contrast}:1 against its background, below the 3:1 floor for large text`);
211
222
  }
212
223
 
213
224
  const boxRect = board.getBoundingClientRect();
214
225
  const width = Math.round(boxRect.width);
215
226
  const height = Math.round(boxRect.height);
216
227
  if (placement && (width !== placement.w || height !== placement.h)) {
217
- errors.push(`artboard is ${width}x${height}, expected ${placement.w}x${placement.h}`);
228
+ fail('size', `artboard is ${width}x${height}, expected ${placement.w}x${placement.h}`);
218
229
  }
219
230
 
220
- return { errors, warnings, contrast, width, height };
231
+ return { errors, warnings, checks, contrast, width, height };
221
232
  }
@@ -15,7 +15,7 @@
15
15
  * }
16
16
  *
17
17
  * Result contract:
18
- * window.__RENDER_RESULT = { ok, errors?, warnings?, width, height } | { ok: false, error }
18
+ * window.__RENDER_RESULT = { ok, errors?, warnings?, checks?, width, height } | { ok: false, error }
19
19
  * window.__ready = true // set exactly once, success or failure, when the
20
20
  * // job's page.waitForFunction should stop waiting
21
21
  *
@@ -108,8 +108,8 @@ async function main() {
108
108
  return;
109
109
  }
110
110
 
111
- const { errors, warnings, width, height } = await runAssertions(board, placement, spec.brand || {});
112
- finish({ ok: errors.length === 0, errors, warnings, width, height });
111
+ const { errors, warnings, checks, width, height } = await runAssertions(board, placement, spec.brand || {});
112
+ finish({ ok: errors.length === 0, errors, warnings, checks, width, height });
113
113
  } catch (err) {
114
114
  finish({ ok: false, error: String((err && err.message) || err) });
115
115
  }
@@ -0,0 +1,211 @@
1
+ /* file-transport.mjs — a transport (see transport.mjs) that talks to NO backend at all: it renders a
2
+ * DRAFT spec (the backend's `draft-spec` response, which persists nothing) and leaves the frames in a
3
+ * local directory, so a person can look at the artwork before anything is uploaded or saved.
4
+ *
5
+ * It implements the same interface `run()` in render.mjs calls: getSpec / putFrame / putPoster /
6
+ * complete / fail, plus `getRenderId()` (run() asks for it before reporting a failure).
7
+ *
8
+ * getSpec() the draft spec, with every `local:<key>` string replaced by an http URL the headless
9
+ * browser (and ffmpeg, for audio) can load. The local files are served by a small
10
+ * loopback server (job/server.mjs, cors + Range) rooted at a temp dir that holds a
11
+ * symlink (or copy) of each file — the server never exposes anything but those files.
12
+ * putFrame writes `sheet.jpg` (the contact sheet; named by the frame's content type),
13
+ * `<placement>[-<index>].jpg|.mp4`
14
+ * putPoster writes `poster-<placement>[-<index>].jpg`
15
+ * complete writes `manifest.json` { ok: true, frames, warnings } — plus, when run() was asked to
16
+ * check placements, `checks` (the full render's per-placement assertion results) and
17
+ * `passed` (no check has severity "error")
18
+ * fail writes `manifest.json` { ok: false, error, frames, warnings }
19
+ *
20
+ * The asset server is started lazily by getSpec() and closed by complete()/fail()/close() (all
21
+ * idempotent), so a caller that never reaches either still has `close()`.
22
+ */
23
+ import { copyFile, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
24
+ import { tmpdir } from 'node:os';
25
+ import { extname, join, resolve } from 'node:path';
26
+ import { startServer } from './server.mjs';
27
+
28
+ const LOCAL_REF = /^local:([a-z0-9][a-z0-9-]{0,63})$/;
29
+
30
+ const EXT_BY_TYPE = {
31
+ 'image/jpeg': '.jpg',
32
+ 'image/png': '.png',
33
+ 'image/webp': '.webp',
34
+ 'video/mp4': '.mp4',
35
+ };
36
+
37
+ /** A filesystem-safe fragment of a placement key / index. */
38
+ function safe(part) {
39
+ return String(part).replace(/[^A-Za-z0-9._-]/g, '_');
40
+ }
41
+
42
+ /** Returns a copy of `value` with every string of the exact form `local:<key>` replaced by
43
+ * `urlFor(key)`. Walks objects and arrays; leaves every other value untouched. */
44
+ function replaceLocalRefs(value, urlFor) {
45
+ if (typeof value === 'string') {
46
+ const m = LOCAL_REF.exec(value);
47
+ return m ? urlFor(m[1]) : value;
48
+ }
49
+ if (Array.isArray(value)) return value.map((v) => replaceLocalRefs(v, urlFor));
50
+ if (value && typeof value === 'object') {
51
+ const out = {};
52
+ for (const [k, v] of Object.entries(value)) out[k] = replaceLocalRefs(v, urlFor);
53
+ return out;
54
+ }
55
+ return value;
56
+ }
57
+
58
+ /** Collects every `local:<key>` key a spec references. */
59
+ export function localKeysIn(spec) {
60
+ const keys = new Set();
61
+ replaceLocalRefs(spec, (key) => {
62
+ keys.add(key);
63
+ return key;
64
+ });
65
+ return [...keys];
66
+ }
67
+
68
+ /**
69
+ * @param {object} opts
70
+ * @param {object} opts.spec the draft spec (`{renderId, previewOnly, creative, brand, placements}`)
71
+ * @param {Record<string,string>} [opts.localFiles] key -> absolute path of the local file behind `local:<key>`
72
+ * @param {string} opts.outDir directory the frames and manifest.json are written into (created if absent)
73
+ */
74
+ export function createFileTransport({ spec, localFiles = {}, outDir }) {
75
+ if (!spec) throw new Error('createFileTransport: `spec` is required');
76
+ if (!outDir) throw new Error('createFileTransport: `outDir` is required');
77
+ const out = resolve(outDir);
78
+
79
+ let assetDir;
80
+ let assetServer;
81
+ let renderId;
82
+ let closed = false;
83
+ let error;
84
+ const frames = [];
85
+ let warnings = [];
86
+ let checks;
87
+
88
+ async function startAssetServer(keys) {
89
+ assetDir = await mkdtemp(join(tmpdir(), 'cc-draft-assets-'));
90
+ const files = {};
91
+ for (const key of keys) {
92
+ const source = localFiles[key];
93
+ if (!source) {
94
+ throw new Error(`the draft references local:${key} but no local file was given for "${key}"`);
95
+ }
96
+ const name = `${key}${extname(source).toLowerCase()}`;
97
+ const dest = join(assetDir, name);
98
+ try {
99
+ await symlink(resolve(source), dest);
100
+ } catch {
101
+ await copyFile(source, dest); // symlinks can be unavailable (Windows without privileges)
102
+ }
103
+ files[key] = name;
104
+ }
105
+ assetServer = await startServer(assetDir, 0, { cors: true });
106
+ return files;
107
+ }
108
+
109
+ async function close() {
110
+ if (closed) return;
111
+ closed = true;
112
+ if (assetServer) await new Promise((r) => assetServer.server.close(() => r()));
113
+ if (assetDir) await rm(assetDir, { recursive: true, force: true }).catch(() => {});
114
+ }
115
+
116
+ async function writeManifest(extra) {
117
+ await mkdir(out, { recursive: true });
118
+ const manifest = {
119
+ ok: !error,
120
+ ...(error ? { error } : {}),
121
+ renderId: renderId || 'draft',
122
+ previewOnly: !!spec.previewOnly,
123
+ frames,
124
+ warnings,
125
+ ...(checks ? { checks, passed: !checks.some((c) => c.severity === 'error') } : {}),
126
+ ...extra,
127
+ };
128
+ await writeFile(join(out, 'manifest.json'), JSON.stringify(manifest, null, 2));
129
+ return manifest;
130
+ }
131
+
132
+ return {
133
+ async getSpec() {
134
+ let resolved = spec;
135
+ const keys = localKeysIn(spec);
136
+ if (keys.length) {
137
+ try {
138
+ const files = await startAssetServer(keys);
139
+ resolved = replaceLocalRefs(spec, (key) => `${assetServer.origin}/${files[key]}`);
140
+ } catch (err) {
141
+ error = err.message; // run() does not call fail() when the spec never resolved; keep the reason
142
+ await close();
143
+ throw err;
144
+ }
145
+ }
146
+ renderId = spec.renderId || 'draft';
147
+ return resolved;
148
+ },
149
+
150
+ async putFrame(placementKey, { index, width, height, bytes, contentType = 'image/jpeg', durationSeconds, hasAudio } = {}) {
151
+ const ext = EXT_BY_TYPE[contentType] || '.bin';
152
+ const hasIndex = index !== undefined && index !== null;
153
+ const file = `${safe(placementKey)}${hasIndex ? `-${safe(index)}` : ''}${ext}`;
154
+ await mkdir(out, { recursive: true });
155
+ await writeFile(join(out, file), bytes);
156
+ frames.push({
157
+ placementKey,
158
+ ...(hasIndex ? { index } : {}),
159
+ file,
160
+ contentType,
161
+ width,
162
+ height,
163
+ sizeBytes: bytes.length,
164
+ ...(durationSeconds !== undefined && durationSeconds !== null ? { durationSeconds } : {}),
165
+ ...(hasAudio !== undefined && hasAudio !== null ? { hasAudio } : {}),
166
+ });
167
+ },
168
+
169
+ async putPoster(placementKey, bytes, { index } = {}) {
170
+ const hasIndex = index !== undefined && index !== null;
171
+ const file = `poster-${safe(placementKey)}${hasIndex ? `-${safe(index)}` : ''}.jpg`;
172
+ await mkdir(out, { recursive: true });
173
+ await writeFile(join(out, file), bytes);
174
+ const frame = frames.find((f) => f.placementKey === placementKey && (f.index ?? null) === (hasIndex ? index : null));
175
+ if (frame) frame.poster = file;
176
+ },
177
+
178
+ async complete(w, c) {
179
+ warnings = Array.isArray(w) ? w : [];
180
+ checks = Array.isArray(c) ? c : undefined;
181
+ try {
182
+ return await writeManifest();
183
+ } finally {
184
+ await close();
185
+ }
186
+ },
187
+
188
+ async fail(message) {
189
+ error = message || 'render failed';
190
+ try {
191
+ return await writeManifest();
192
+ } finally {
193
+ await close();
194
+ }
195
+ },
196
+
197
+ /** 'draft' once getSpec() has succeeded, `undefined` before — run() uses this to decide whether
198
+ * there is a render to mark failed. */
199
+ getRenderId() {
200
+ return renderId;
201
+ },
202
+
203
+ /** The error `fail()` (or a failed getSpec()) recorded, if any. */
204
+ getError() {
205
+ return error;
206
+ },
207
+
208
+ /** Stops the asset server and removes the temp asset dir. Safe to call more than once. */
209
+ close,
210
+ };
211
+ }
@@ -297,6 +297,73 @@ async function renderMotionPlacement({ browser, origin, spec, placementKey, ffmp
297
297
  }
298
298
  }
299
299
 
300
+ /* ── Draft preview: the full render's per-placement checks, without the frames ───────────────── */
301
+
302
+ /* A draft preview renders only the contact sheet (sheet.html), which never runs the assertions — a
303
+ * bad frame still shows up in a sheet so a human can see what broke. The full render DOES run them
304
+ * (spill, the bottom safe zone, contrast, fonts, images, the artboard size) on every placement's
305
+ * frame.html and fails the render, so a design the person approved from the sheet could still fail
306
+ * afterwards. This runs those very same checks (assertions.js's runAssertions, via frame.html) for
307
+ * each placement the full render would produce, in the one already-open browser against the
308
+ * already-served pages, and throws the screenshot away. A MOTION creative is checked the way its
309
+ * full render checks it: once, on the end-card (final) frame — no video is encoded.
310
+ *
311
+ * Resolves `[{ placementKey, index?, rule, message, severity: 'error' | 'warning' }]`; a board that
312
+ * would not even build (or a page error) is reported as rule "render". */
313
+ async function checkFrame({ browser, origin, spec, frame, fps, log }) {
314
+ const label = frame.index !== undefined ? `${frame.placementKey}[${frame.index}]` : frame.placementKey;
315
+ const tag = (check) => ({
316
+ placementKey: frame.placementKey,
317
+ ...(frame.index !== undefined ? { index: frame.index } : {}),
318
+ rule: check.rule,
319
+ message: check.message,
320
+ severity: check.severity,
321
+ });
322
+ const isMotion = spec.creative && spec.creative.kind === 'MOTION' && spec.creative.motion;
323
+ const page = await browser.newPage({ deviceScaleFactor: 1 });
324
+ try {
325
+ const motion = isMotion ? spec.creative.motion : undefined;
326
+ const durationSec = (motion && motion.durationSec) || 8;
327
+ await page.addInitScript((s) => {
328
+ window.__RENDER_SPEC__ = s;
329
+ }, {
330
+ creative: spec.creative,
331
+ brand: spec.brand,
332
+ placementKey: frame.placementKey,
333
+ sequenceIndex: frame.index,
334
+ ...(isMotion ? { motion, time: 0, clipStartSec: spec.creative.clipStartSec } : {}),
335
+ });
336
+ await page.goto(`${origin}/frame.html`, { waitUntil: 'load', timeout: PAGE_TIMEOUT_MS });
337
+ await page.waitForFunction(() => window.__ready === true, null, { timeout: PAGE_TIMEOUT_MS });
338
+
339
+ if (isMotion) {
340
+ const boot = await page.evaluate(() => window.__RENDER_RESULT);
341
+ if (!boot || !boot.ok) {
342
+ return [tag({ rule: 'render', severity: 'error', message: (boot && boot.error) || 'failed to build the MOTION board' })];
343
+ }
344
+ const frameCount = Math.max(1, Math.round(durationSec * fps));
345
+ const endTime = (frameCount - 1) / fps;
346
+ const seeked = await page.evaluate((t) => window.__seekMotion(t), endTime);
347
+ if (!seeked) return [tag({ rule: 'render', severity: 'error', message: `motion seek failed at t=${endTime.toFixed(2)}s` })];
348
+ const assertion = await page.evaluate(() => window.__assertBoard());
349
+ return ((assertion && assertion.checks) || []).map(tag);
350
+ }
351
+
352
+ const result = await page.evaluate(() => window.__RENDER_RESULT);
353
+ if (!result) return [tag({ rule: 'render', severity: 'error', message: 'render failed for an unknown reason' })];
354
+ if (result.checks) return result.checks.map(tag);
355
+ if (!result.ok) {
356
+ return [tag({ rule: 'render', severity: 'error', message: result.error || (result.errors || []).join('; ') || 'render failed for an unknown reason' })];
357
+ }
358
+ return [];
359
+ } catch (err) {
360
+ log(`check ${label}: ${err.message}`);
361
+ return [tag({ rule: 'render', severity: 'error', message: err.message })];
362
+ } finally {
363
+ await page.close().catch(() => {});
364
+ }
365
+ }
366
+
300
367
  /** Renders every frame a spec calls for, uploads each PNG (or, for a MOTION
301
368
  * creative that is not `previewOnly`, one MP4 + poster per placement — see
302
369
  * renderMotionPlacement above), and reports complete/fail. Options:
@@ -306,12 +373,15 @@ async function renderMotionPlacement({ browser, origin, spec, placementKey, ffmp
306
373
  * ffmpegPath a local ffmpeg binary's path. Required for a MOTION
307
374
  * render (not previewOnly) — ignored otherwise.
308
375
  * fps frames per second for a MOTION render (default 30).
376
+ * checkPlacements true: when the spec is `previewOnly`, also run the full render's per-placement
377
+ * assertions (see checkFrame above) and pass them to `transport.complete(warnings,
378
+ * checks)`. They never fail the run; the caller decides what a failing check means.
309
379
  * log (...args) => void (default: console.log).
310
380
  * Resolves `true` on success, `false` on a reported failure (never throws —
311
381
  * a thrown error from a truly unexpected place still gets caught and turned
312
382
  * into a `transport.fail()` call before resolving `false`, so a caller never
313
383
  * has to guess whether `transport.fail` was already called). */
314
- export async function run({ transport, packageRoot = PACKAGE_ROOT, browserFactory = defaultBrowserFactory, ffmpegPath, fps = DEFAULT_FPS, log = console.log }) {
384
+ export async function run({ transport, packageRoot = PACKAGE_ROOT, browserFactory = defaultBrowserFactory, ffmpegPath, fps = DEFAULT_FPS, checkPlacements = false, log = console.log }) {
315
385
  let spec;
316
386
  try {
317
387
  log('fetching spec');
@@ -408,7 +478,19 @@ export async function run({ transport, packageRoot = PACKAGE_ROOT, browserFactor
408
478
  }
409
479
  }
410
480
 
411
- await transport.complete(warnings);
481
+ if (checkPlacements && spec.previewOnly) {
482
+ const checks = [];
483
+ if ((spec.placements || []).length) {
484
+ for (const frame of framesFor({ ...spec, previewOnly: false })) {
485
+ checks.push(...(await checkFrame({ browser, origin, spec, frame, fps, log })));
486
+ }
487
+ }
488
+ const failed = checks.filter((c) => c.severity === 'error').length;
489
+ log(`checked placements: ${failed} failing, ${checks.length - failed} warning(s)`);
490
+ await transport.complete(warnings, checks);
491
+ } else {
492
+ await transport.complete(warnings);
493
+ }
412
494
  log(`complete (${warnings.length} warning(s))`);
413
495
  return true;
414
496
  } catch (err) {