@cliangdev/conductor 0.24.1 → 0.26.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 (41) hide show
  1. package/assets/claude/commands/conductor/creative.md +90 -0
  2. package/assets/claude/skills/conductor-creative/SKILL.md +129 -51
  3. package/assets/claude/skills/conductor-creative/references/brand-kit-setup.md +41 -0
  4. package/assets/cli-manifest.json +38 -3
  5. package/dist/commands/init.d.ts.map +1 -1
  6. package/dist/commands/init.js +2 -1
  7. package/dist/commands/init.js.map +1 -1
  8. package/dist/commands/start.d.ts.map +1 -1
  9. package/dist/commands/start.js +2 -0
  10. package/dist/commands/start.js.map +1 -1
  11. package/dist/creative/assertions.js +22 -11
  12. package/dist/creative/frame.js +3 -3
  13. package/dist/creative/job/file-transport.mjs +211 -0
  14. package/dist/creative/job/render.mjs +84 -2
  15. package/dist/creative/job/server.mjs +70 -13
  16. package/dist/lib/creative-render.d.ts +62 -0
  17. package/dist/lib/creative-render.d.ts.map +1 -1
  18. package/dist/lib/creative-render.js +51 -0
  19. package/dist/lib/creative-render.js.map +1 -1
  20. package/dist/lib/plugin-assets.d.ts +48 -1
  21. package/dist/lib/plugin-assets.d.ts.map +1 -1
  22. package/dist/lib/plugin-assets.js +272 -22
  23. package/dist/lib/plugin-assets.js.map +1 -1
  24. package/dist/mcp/api.d.ts +18 -1
  25. package/dist/mcp/api.d.ts.map +1 -1
  26. package/dist/mcp/api.js +20 -2
  27. package/dist/mcp/api.js.map +1 -1
  28. package/dist/mcp/index.d.ts +2414 -471
  29. package/dist/mcp/index.d.ts.map +1 -1
  30. package/dist/mcp/index.js +246 -77
  31. package/dist/mcp/index.js.map +1 -1
  32. package/dist/mcp/tools/creative-drafts.d.ts +134 -0
  33. package/dist/mcp/tools/creative-drafts.d.ts.map +1 -0
  34. package/dist/mcp/tools/creative-drafts.js +405 -0
  35. package/dist/mcp/tools/creative-drafts.js.map +1 -0
  36. package/dist/mcp/tools/creatives.d.ts +60 -0
  37. package/dist/mcp/tools/creatives.d.ts.map +1 -1
  38. package/dist/mcp/tools/creatives.js +219 -23
  39. package/dist/mcp/tools/creatives.js.map +1 -1
  40. package/package.json +1 -1
  41. package/scripts/build-creative.mjs +1 -0
@@ -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) {
@@ -12,7 +12,8 @@
12
12
  * job/.
13
13
  */
14
14
  import { createServer } from 'node:http';
15
- import { readFile, stat } from 'node:fs/promises';
15
+ import { createReadStream } from 'node:fs';
16
+ import { stat } from 'node:fs/promises';
16
17
  import { extname, join, normalize, sep } from 'node:path';
17
18
 
18
19
  const TYPES = {
@@ -26,9 +27,43 @@ const TYPES = {
26
27
  '.jpg': 'image/jpeg',
27
28
  '.jpeg': 'image/jpeg',
28
29
  '.svg': 'image/svg+xml',
30
+ '.gif': 'image/gif',
31
+ '.mp4': 'video/mp4',
32
+ '.m4v': 'video/mp4',
33
+ '.mov': 'video/quicktime',
34
+ '.webm': 'video/webm',
35
+ '.mp3': 'audio/mpeg',
36
+ '.m4a': 'audio/mp4',
37
+ '.aac': 'audio/aac',
38
+ '.wav': 'audio/wav',
39
+ '.ogg': 'audio/ogg',
29
40
  };
30
41
 
31
- function makeHandler(root) {
42
+ /* Parses a single `Range: bytes=a-b` header against a file of `size` bytes. Returns `{start, end}`
43
+ * (inclusive), `'unsatisfiable'`, or `null` when there is no usable range (serve the whole file). */
44
+ function parseRange(header, size) {
45
+ const m = /^bytes=(\d*)-(\d*)$/.exec(header || '');
46
+ if (!m || (m[1] === '' && m[2] === '')) return null;
47
+ let start;
48
+ let end;
49
+ if (m[1] === '') {
50
+ const suffix = Number(m[2]);
51
+ start = Math.max(0, size - suffix);
52
+ end = size - 1;
53
+ } else {
54
+ start = Number(m[1]);
55
+ end = m[2] === '' ? size - 1 : Math.min(Number(m[2]), size - 1);
56
+ }
57
+ if (start > end || start >= size) return 'unsatisfiable';
58
+ return { start, end };
59
+ }
60
+
61
+ /* `cors` adds a permissive Access-Control-Allow-Origin header. It is for a SECOND server that serves
62
+ * local media (job/file-transport.mjs) from a different loopback port than the page that embeds it:
63
+ * a cross-origin <img>/<video> needs no CORS to display, but a stricter load path (a fetch, a font)
64
+ * does, and the server only ever listens on 127.0.0.1. Range requests are always honoured — a video
65
+ * <video> element cannot seek without them. */
66
+ function makeHandler(root, { cors = false } = {}) {
32
67
  return async function handler(req, res) {
33
68
  const url = new URL(req.url, 'http://localhost');
34
69
  let rel = decodeURIComponent(url.pathname);
@@ -39,26 +74,48 @@ function makeHandler(root) {
39
74
  res.writeHead(403).end('forbidden');
40
75
  return;
41
76
  }
77
+ let info;
42
78
  try {
43
- const info = await stat(path);
79
+ info = await stat(path);
44
80
  if (!info.isFile()) throw new Error('not a file');
45
- const body = await readFile(path);
46
- res.writeHead(200, {
47
- 'content-type': TYPES[extname(path).toLowerCase()] || 'application/octet-stream',
48
- 'content-length': body.length,
49
- 'cache-control': 'no-store',
50
- });
51
- res.end(body);
52
81
  } catch {
53
82
  res.writeHead(404).end('not found');
83
+ return;
84
+ }
85
+ const headers = {
86
+ 'content-type': TYPES[extname(path).toLowerCase()] || 'application/octet-stream',
87
+ 'accept-ranges': 'bytes',
88
+ 'cache-control': 'no-store',
89
+ ...(cors ? { 'access-control-allow-origin': '*' } : {}),
90
+ };
91
+ const range = parseRange(req.headers.range, info.size);
92
+ if (range === 'unsatisfiable') {
93
+ res.writeHead(416, { ...headers, 'content-range': `bytes */${info.size}` }).end();
94
+ return;
95
+ }
96
+ const status = range ? 206 : 200;
97
+ const start = range ? range.start : 0;
98
+ const end = range ? range.end : info.size - 1;
99
+ res.writeHead(status, {
100
+ ...headers,
101
+ 'content-length': info.size === 0 ? 0 : end - start + 1,
102
+ ...(range ? { 'content-range': `bytes ${start}-${end}/${info.size}` } : {}),
103
+ });
104
+ if (req.method === 'HEAD' || info.size === 0) {
105
+ res.end();
106
+ return;
54
107
  }
108
+ const stream = createReadStream(path, { start, end });
109
+ stream.on('error', () => res.destroy());
110
+ stream.pipe(res);
55
111
  };
56
112
  }
57
113
 
58
114
  /** Starts a static server rooted at `root` on an ephemeral loopback port.
59
- * Resolves `{ server, origin }`; call `server.close()` when done. */
60
- export function startServer(root, port = 0) {
61
- const handle = makeHandler(root);
115
+ * Resolves `{ server, origin }`; call `server.close()` when done. `options.cors` adds an
116
+ * `Access-Control-Allow-Origin: *` header (see makeHandler). */
117
+ export function startServer(root, port = 0, options = {}) {
118
+ const handle = makeHandler(root, options);
62
119
  return new Promise((resolve) => {
63
120
  const server = createServer((req, res) => {
64
121
  handle(req, res).catch((err) => {
@@ -39,4 +39,66 @@ export interface RenderCreativeResult {
39
39
  * this resolution entirely. CLIP creatives never launch a browser — see {@link renderViaApi}.
40
40
  */
41
41
  export declare function renderCreative(params: RenderCreativeParams, config: Config, log?: (...args: unknown[]) => void): Promise<RenderCreativeResult>;
42
+ export interface DraftManifestFrame {
43
+ placementKey: string;
44
+ index?: number;
45
+ file: string;
46
+ contentType: string;
47
+ width?: number;
48
+ height?: number;
49
+ sizeBytes?: number;
50
+ durationSeconds?: number;
51
+ hasAudio?: boolean;
52
+ poster?: string;
53
+ }
54
+ /** One finding of the full render's per-placement assertions (spill, safe zone, contrast, fonts, images,
55
+ * artboard size), run on a placement's frame without keeping the frame. */
56
+ export interface DraftCheck {
57
+ placementKey: string;
58
+ index?: number;
59
+ /** spill | safeZone | contrast | font | image | accent | size | photoResolution | render */
60
+ rule: string;
61
+ message: string;
62
+ /** An "error" would fail the full render of that placement; a "warning" would only be reported. */
63
+ severity: 'error' | 'warning';
64
+ }
65
+ export interface DraftManifest {
66
+ ok: boolean;
67
+ error?: string;
68
+ renderId?: string;
69
+ previewOnly?: boolean;
70
+ frames: DraftManifestFrame[];
71
+ warnings: Array<{
72
+ placementKey?: string;
73
+ index?: number;
74
+ message: string;
75
+ }>;
76
+ /** Present on a preview: the per-placement assertion results the full render would produce. */
77
+ checks?: DraftCheck[];
78
+ /** Present with `checks`: true when none of them has severity "error". */
79
+ passed?: boolean;
80
+ }
81
+ export interface RenderDraftResult {
82
+ ok: boolean;
83
+ error?: string;
84
+ manifest?: DraftManifest;
85
+ }
86
+ /**
87
+ * Renders a DRAFT spec (the backend's `draft-spec` response — nothing about it is saved) with the same
88
+ * render core a saved Creative uses, but through the file transport: the local files behind
89
+ * `local:<key>` are served to the headless browser from this machine, and the frames plus a
90
+ * `manifest.json` land in `outDir`. A `previewOnly` spec also gets `manifest.checks` / `manifest.passed`:
91
+ * the full render's per-placement assertions, run without keeping their frames. Never talks to the Conductor API. A MOTION spec that is not
92
+ * `previewOnly` needs ffmpeg, resolved the same way {@link renderCreative} resolves it.
93
+ */
94
+ export declare function renderDraft(params: {
95
+ spec: {
96
+ creative?: {
97
+ kind?: string;
98
+ };
99
+ previewOnly?: boolean;
100
+ };
101
+ localFiles: Record<string, string>;
102
+ outDir: string;
103
+ }, log?: (...args: unknown[]) => void): Promise<RenderDraftResult>;
42
104
  //# sourceMappingURL=creative-render.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"creative-render.d.ts","sourceRoot":"","sources":["../../src/lib/creative-render.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAS9C,MAAM,WAAW,WAAW;IAC1B,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,YAAY,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAA;IACnB,0GAA0G;IAC1G,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,MAAM,WAAW,oBAAoB;IACnC,UAAU,EAAE,MAAM,CAAA;IAClB,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,aAAa,CAAC,EAAE,MAAM,CAAA;CACvB;AAED,MAAM,WAAW,oBAAoB;IACnC,EAAE,EAAE,OAAO,CAAA;IACX,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,MAAM,EAAE,WAAW,EAAE,CAAA;CACtB;AAkFD;;;;;;;;;GASG;AACH,wBAAsB,cAAc,CAClC,MAAM,EAAE,oBAAoB,EAC5B,MAAM,EAAE,MAAM,EACd,GAAG,GAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAe,GAC3C,OAAO,CAAC,oBAAoB,CAAC,CAoD/B"}
1
+ {"version":3,"file":"creative-render.d.ts","sourceRoot":"","sources":["../../src/lib/creative-render.ts"],"names":[],"mappings":"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"}