@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.
- package/assets/claude/commands/conductor/creative.md +90 -0
- package/assets/claude/skills/conductor-creative/SKILL.md +129 -51
- package/assets/claude/skills/conductor-creative/references/brand-kit-setup.md +41 -0
- package/assets/cli-manifest.json +38 -3
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +2 -1
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/start.d.ts.map +1 -1
- package/dist/commands/start.js +2 -0
- package/dist/commands/start.js.map +1 -1
- package/dist/creative/assertions.js +22 -11
- package/dist/creative/frame.js +3 -3
- package/dist/creative/job/file-transport.mjs +211 -0
- package/dist/creative/job/render.mjs +84 -2
- package/dist/creative/job/server.mjs +70 -13
- package/dist/lib/creative-render.d.ts +62 -0
- package/dist/lib/creative-render.d.ts.map +1 -1
- package/dist/lib/creative-render.js +51 -0
- package/dist/lib/creative-render.js.map +1 -1
- package/dist/lib/plugin-assets.d.ts +48 -1
- package/dist/lib/plugin-assets.d.ts.map +1 -1
- package/dist/lib/plugin-assets.js +272 -22
- package/dist/lib/plugin-assets.js.map +1 -1
- package/dist/mcp/api.d.ts +18 -1
- package/dist/mcp/api.d.ts.map +1 -1
- package/dist/mcp/api.js +20 -2
- package/dist/mcp/api.js.map +1 -1
- package/dist/mcp/index.d.ts +2414 -471
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +246 -77
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp/tools/creative-drafts.d.ts +134 -0
- package/dist/mcp/tools/creative-drafts.d.ts.map +1 -0
- package/dist/mcp/tools/creative-drafts.js +405 -0
- package/dist/mcp/tools/creative-drafts.js.map +1 -0
- package/dist/mcp/tools/creatives.d.ts +60 -0
- package/dist/mcp/tools/creatives.d.ts.map +1 -1
- package/dist/mcp/tools/creatives.js +219 -23
- package/dist/mcp/tools/creatives.js.map +1 -1
- package/package.json +1 -1
- 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)
|
|
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)
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
190
|
+
fail('accent', 'accent colour did not resolve on the headline');
|
|
180
191
|
} else if (expectedRgb && !sameRgb(emRgb, expectedRgb)) {
|
|
181
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/creative/frame.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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":"
|
|
1
|
+
{"version":3,"file":"creative-render.d.ts","sourceRoot":"","sources":["../../src/lib/creative-render.ts"],"names":[],"mappings":"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"}
|