@cliangdev/conductor 0.22.0 → 0.24.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 (43) hide show
  1. package/assets/claude/skills/conductor-creative/SKILL.md +57 -0
  2. package/assets/cli-manifest.json +6 -1
  3. package/dist/commands/creative.d.ts.map +1 -1
  4. package/dist/commands/creative.js +6 -1
  5. package/dist/commands/creative.js.map +1 -1
  6. package/dist/creative/assertions.js +4 -4
  7. package/dist/creative/frame.css +21 -0
  8. package/dist/creative/frame.js +39 -0
  9. package/dist/creative/job/render.mjs +261 -4
  10. package/dist/creative/job/transport.mjs +47 -13
  11. package/dist/creative/layouts/bleed/layout.css +6 -2
  12. package/dist/creative/layouts/card/layout.css +3 -0
  13. package/dist/creative/layouts/split/layout.css +3 -0
  14. package/dist/creative/layouts/stacked/layout.css +3 -0
  15. package/dist/creative/motion.js +347 -0
  16. package/dist/creative/placements.js +28 -0
  17. package/dist/creative/placements.json +36 -0
  18. package/dist/creative/render.js +29 -0
  19. package/dist/creative/sheet.html +4 -0
  20. package/dist/creative/sheet.js +39 -2
  21. package/dist/creative/styles.css +36 -2
  22. package/dist/lib/creative-render.d.ts +13 -3
  23. package/dist/lib/creative-render.d.ts.map +1 -1
  24. package/dist/lib/creative-render.js +47 -5
  25. package/dist/lib/creative-render.js.map +1 -1
  26. package/dist/lib/media-probe.d.ts +55 -0
  27. package/dist/lib/media-probe.d.ts.map +1 -0
  28. package/dist/lib/media-probe.js +221 -0
  29. package/dist/lib/media-probe.js.map +1 -0
  30. package/dist/lib/poster-sheet.d.ts +10 -0
  31. package/dist/lib/poster-sheet.d.ts.map +1 -0
  32. package/dist/lib/poster-sheet.js +57 -0
  33. package/dist/lib/poster-sheet.js.map +1 -0
  34. package/dist/mcp/index.d.ts +10587 -0
  35. package/dist/mcp/index.d.ts.map +1 -1
  36. package/dist/mcp/index.js +93 -10
  37. package/dist/mcp/index.js.map +1 -1
  38. package/dist/mcp/tools/creatives.d.ts +29 -2
  39. package/dist/mcp/tools/creatives.d.ts.map +1 -1
  40. package/dist/mcp/tools/creatives.js +282 -35
  41. package/dist/mcp/tools/creatives.js.map +1 -1
  42. package/package.json +3 -1
  43. package/scripts/build-creative.mjs +7 -2
@@ -121,6 +121,63 @@ worst. A logo lockup and CTA claim land on the last beat only by default; `cta:
121
121
  beat overrides that. A carousel renders every card at one shared aspect ratio (`carouselRatio`),
122
122
  since the destination platforms require that.
123
123
 
124
+ ## Video you already have
125
+
126
+ Not every video needs the brand layout above — a finished clip (a demo recording, an existing edit,
127
+ something a person or another tool already produced) can go up as-is. That's a CLIP Creative: no
128
+ photo, headline or layout, just the video.
129
+
130
+ 1. `upload_creative_media` (local path or URL) — same provenance fields as a photo (`source`,
131
+ `licence`, `aiGenerated`). It detects video vs. audio vs. photo automatically and, for a video,
132
+ extracts a poster frame. Use the returned media's id below.
133
+ 2. `create_creative` with `kind: "CLIP"` and `clipMedia`. One file for every placement:
134
+ `{"default": mediaId}`. A different cut per placement (e.g. a square edit for feed, a vertical one
135
+ for Reels): `{"9x16": mediaId, "4x5": mediaId}` — `default` still covers any placement without its
136
+ own entry. A reel takes exactly one video, so don't split a reel-eligible placement across two.
137
+ 3. `render_creative` — instant for a CLIP (the backend assembles the frames; nothing renders locally
138
+ here the way a STILL creative's browser-driven render does).
139
+ 4. `preview_creative` — returns a small poster sheet across placements instead of the STILL contact
140
+ sheet, plus each placement's duration. Still worth a look: a poster is one frame, not the whole
141
+ cut, so open the actual video before calling a rough or misframed clip done.
142
+ 5. `attach_creative_to_post`, same as any other Creative.
143
+
144
+ Mind each destination's own duration limit (per docs/publishing.md) — a clip that's fine on one
145
+ platform may get rejected or trimmed on another; check before attaching to a Post that fans out
146
+ broadly.
147
+
148
+ ## Short videos from a photo or clip
149
+
150
+ A MOTION Creative animates the same brand layout from steps 1-5 above into a short video, instead
151
+ of a static frame — no separate video tool needed. Use it when a still doesn't hold attention, or a
152
+ destination rewards video over an image.
153
+
154
+ 1. Pick a photo (step 5) or, for a moving background, `upload_creative_media` a video first and
155
+ note its media id.
156
+ 2. `create_creative`/`update_creative` with `kind: "MOTION"` plus:
157
+ - `motion.preset` — how the copy enters: `fade-up` (default), `word-by-word`, `accent-pop`, or
158
+ `none` (everything visible from the first frame).
159
+ - `motion.durationSec` — 3 to 60, default 8.
160
+ - `motion.background` — `source: "photo"` (default; `motion: "zoom-in"` (default), `zoom-out`,
161
+ `pan-left`, `pan-right`, or `none`) or `source: "clip"` with `clipMediaId` (from step 1) and
162
+ `clipStartSec`.
163
+ - `motion.endCard` — default true: the last 2 seconds hold the finished composition, CTA
164
+ included, regardless of how short the preset's own entrance would otherwise leave it.
165
+ - `audio` — `source: "clip"` (the background clip's own recorded sound — the default when there
166
+ is one), `"track"` (a separate library track: `trackId` from `upload_creative_media`, plus
167
+ `volume` and `fadeOutSec`), or `"none"`.
168
+ 3. `render_creative` runs a local, ffmpeg-driven encode — roughly 20 seconds per placement for an
169
+ 8-second video. No ffmpeg on this machine fails with a clear message naming how to get one rather
170
+ than silently producing nothing.
171
+ 4. `preview_creative` renders three key moments across the timeline into one sheet image (not the
172
+ finished video). Judge it the same honest way as step 7 above — does the copy's entrance finish
173
+ before the next beat, does the end card actually hold long enough to read — before spending a
174
+ full render on it.
175
+ 5. `attach_creative_to_post`, same as any Creative — one MP4 per placement.
176
+
177
+ A library music track's `licence` has to be recorded on upload (same as a photo's provenance) — the
178
+ readiness checklist blocks a MOTION Creative on it whenever `audio.source` is `"track"`. A reel or
179
+ other vertical placement takes exactly one video, same as a CLIP.
180
+
124
181
  ## Failures that are silent (carried over from this skill's predecessor)
125
182
 
126
183
  Automated checks are real but incomplete. These are the ones worth remembering to look for by eye:
@@ -752,9 +752,14 @@
752
752
  "version"
753
753
  ]
754
754
  },
755
+ {
756
+ "name": "upload_creative_media",
757
+ "description": "Upload a photo, video or audio file into the project's media library from a local file or a public URL, recording its provenance for the readiness checklist. Kind, dimensions, duration and audio are detected automatically; a video also gets a poster frame.",
758
+ "category": "marketing"
759
+ },
755
760
  {
756
761
  "name": "upload_creative_photo",
757
- "description": "Upload a photo into the project's photo library from a local file or a public URL, recording its provenance for the readiness checklist. Width/height are read from the file.",
762
+ "description": "Deprecated alias for upload_creative_media — same behavior, including for video/audio.",
758
763
  "category": "marketing"
759
764
  },
760
765
  {
@@ -1 +1 @@
1
- {"version":3,"file":"creative.d.ts","sourceRoot":"","sources":["../../src/commands/creative.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAA;AAKnC,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAiEvD"}
1
+ {"version":3,"file":"creative.d.ts","sourceRoot":"","sources":["../../src/commands/creative.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAA;AAKnC,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAqEvD"}
@@ -43,7 +43,12 @@ Examples:
43
43
  for (const frame of result.frames) {
44
44
  const label = frame.sequenceIndex != null ? `${frame.placementKey}[${frame.sequenceIndex}]` : frame.placementKey;
45
45
  const size = frame.width && frame.height ? `${frame.width}x${frame.height}` : '';
46
- console.log(` ${label} ${size} ${frame.url ?? ''}`.trimEnd());
46
+ // CLIP/MOTION frames are videos, not JPEGs — durationSeconds/hasAudio/posterUrl are only ever set on those.
47
+ const duration = frame.durationSeconds != null ? `${frame.durationSeconds}s${frame.hasAudio ? ' audio' : ''}` : '';
48
+ const parts = [label, size, duration, frame.url ?? ''].filter(Boolean);
49
+ console.log(` ${parts.join(' ')}`);
50
+ if (frame.posterUrl)
51
+ console.log(` poster: ${frame.posterUrl}`);
47
52
  }
48
53
  if (result.error)
49
54
  console.error(result.error);
@@ -1 +1 @@
1
- {"version":3,"file":"creative.js","sourceRoot":"","sources":["../../src/commands/creative.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"creative.js","sourceRoot":"","sources":["../../src/commands/creative.ts"],"names":[],"mappings":"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"}
@@ -118,7 +118,7 @@ function sameRgb(a, b) {
118
118
  * registry entry (pixel size, safe zone); `brand` is the same object handed
119
119
  * to renderBoard. An empty `errors` array means the frame is safe to ship;
120
120
  * `warnings` are non-fatal (e.g. a soft, upscaled photo). */
121
- export async function runAssertions(board, placement, brand) {
121
+ export async function runAssertions(board, placement, brand, { outputScale = 2 } = {}) {
122
122
  const errors = [];
123
123
  const warnings = [];
124
124
  const br = rectOf(board);
@@ -162,10 +162,10 @@ export async function runAssertions(board, placement, brand) {
162
162
  });
163
163
  if (!probe.ok) {
164
164
  errors.push(`background photo failed to load: ${bgUrlMatch[1]}`);
165
- } else if (placement && probe.w < placement.w * 2 && probe.h < placement.h * 2) {
166
- // Soft photography check: a source smaller than the 2x frame is
165
+ } else if (placement && probe.w < placement.w * outputScale && probe.h < placement.h * outputScale) {
166
+ // Soft photography check: a source smaller than the output frame (2x for images, 1x for video) is
167
167
  // 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 * 2}x${placement.h * 2}`);
168
+ warnings.push(`photo is ${probe.w}x${probe.h}, upscaled to fit ${placement.w * outputScale}x${placement.h * outputScale}`);
169
169
  }
170
170
  }
171
171
 
@@ -115,6 +115,27 @@
115
115
  .cc-cta__note { font-weight: 600; font-size: var(--cc-note-size); color: var(--cc-note-color); }
116
116
  .cc-board--light .cc-cta__note { color: var(--cc-ink-2); }
117
117
 
118
+ /* ── Background video (a MOTION clip background) ──────────────────────────── */
119
+ /* One generic rule for every layout: a band/card layout's photo container is
120
+ already `position:relative; overflow:hidden`, sized to the band/card; a
121
+ bleed layout has no such container, but `.cc-board` itself is
122
+ `position:relative; overflow:hidden` (see above), so the video simply
123
+ covers the whole artboard there instead. render.js always appends this
124
+ AFTER the still photo (a same-frame fallback while the video loads) and
125
+ BEFORE the panel/scrim, so plain DOM order — no z-index anywhere in this
126
+ file — keeps it under the copy and over the photo. `--cc-bg-transform` is
127
+ motion.js's Ken Burns pan/zoom; unset (a STILL board, which never calls
128
+ applyMotion) falls back to no transform at all. */
129
+ .cc-bg-video {
130
+ position: absolute; inset: 0;
131
+ width: 100%; height: 100%;
132
+ object-fit: cover;
133
+ object-position: var(--cc-focal, 50% 50%);
134
+ transform: var(--cc-bg-transform, none);
135
+ transform-origin: center center;
136
+ display: block;
137
+ }
138
+
118
139
  /* ── Safe zones (review guide only, never exported) ───────────────────────── */
119
140
  .cc-safe { position: absolute; inset: 0; pointer-events: none; }
120
141
  .cc-safe__top { position: absolute; top: 0; left: 0; right: 0; height: var(--cc-safe-top);
@@ -9,18 +9,37 @@
9
9
  * placementKey: '9x16' | ..., // required, a key from the placements registry
10
10
  * sequenceIndex?: number, // required when creative.sequenceKind is set
11
11
  * placements?, layouts?, // registry overrides (default: the shipped ones)
12
+ * motion?, // a MOTION creative's `creative.motion` (see motion.js)
13
+ * time?: number, // applied once at load when `motion` is set (default 0)
14
+ * clipStartSec?: number, // creative.clipStartSec, for a clip background's own seek
12
15
  * }
13
16
  *
14
17
  * Result contract:
15
18
  * window.__RENDER_RESULT = { ok, errors?, warnings?, width, height } | { ok: false, error }
16
19
  * window.__ready = true // set exactly once, success or failure, when the
17
20
  * // job's page.waitForFunction should stop waiting
21
+ *
22
+ * MOTION mode (`spec.motion` set): the job needs many frames off of ONE page
23
+ * load (a fresh navigation per frame would be far too slow — 8s at 30fps is
24
+ * 240 frames), so after applying `spec.time`/`spec.motion` once at load, this
25
+ * page also exposes a small driver API for the job to call directly via
26
+ * repeated `page.evaluate()`s against the SAME already-open page:
27
+ * window.__seekMotion(t) -> Promise<true> // applies motion + seeks the
28
+ * // clip background (if any) to t
29
+ * window.__assertBoard() -> Promise<{errors, warnings, width, height}>
30
+ * // runs the normal assertions once,
31
+ * // on demand (never per seek — see
32
+ * // job/render.mjs, which calls this
33
+ * // only on the final end-card frame)
34
+ * __RENDER_RESULT in this mode reports whether the board itself built (not
35
+ * whether it passes assertions, which __assertBoard reports separately).
18
36
  */
19
37
  import { resolveAd, resolveSequence, renderBoard, fitBoard } from './render.js';
20
38
  import { placements as DEFAULT_PLACEMENTS } from './placements.js';
21
39
  import { layouts as DEFAULT_LAYOUTS } from './layouts/index.js';
22
40
  import { ensureBrandFont } from './font.js';
23
41
  import { runAssertions } from './assertions.js';
42
+ import { applyMotion, seekBackgroundVideo } from './motion.js';
24
43
 
25
44
  function finish(result) {
26
45
  window.__RENDER_RESULT = result;
@@ -66,6 +85,26 @@ async function main() {
66
85
  await Promise.all([...document.images].map((img) => (img.decode ? img.decode().catch(() => {}) : null)));
67
86
  fitBoard(board, placementsReg);
68
87
 
88
+ if (spec.motion) {
89
+ const durationSec = spec.motion.durationSec || 8;
90
+ const t0 = spec.time || 0;
91
+ applyMotion(board, spec.motion, t0, { durationSec });
92
+ await seekBackgroundVideo(board, t0, spec.clipStartSec);
93
+
94
+ // The job's frame-stepped capture driver (see the header comment above): one page load, many
95
+ // in-page seeks. Assertions are NOT run here — only once, on demand, via __assertBoard.
96
+ window.__seekMotion = async (t) => {
97
+ applyMotion(board, spec.motion, t, { durationSec });
98
+ await seekBackgroundVideo(board, t, spec.clipStartSec);
99
+ return true;
100
+ };
101
+ // A motion frame is captured at 1x (video), so the photo only needs to cover the placement itself.
102
+ window.__assertBoard = async () => runAssertions(board, placement, spec.brand || {}, { outputScale: 1 });
103
+
104
+ finish({ ok: true, width: placement.w, height: placement.h });
105
+ return;
106
+ }
107
+
69
108
  const { errors, warnings, width, height } = await runAssertions(board, placement, spec.brand || {});
70
109
  finish({ ok: errors.length === 0, errors, warnings, width, height });
71
110
  } catch (err) {
@@ -19,8 +19,12 @@
19
19
  */
20
20
  import { fileURLToPath } from 'node:url';
21
21
  import { dirname, join } from 'node:path';
22
+ import { spawn } from 'node:child_process';
23
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
24
+ import { tmpdir } from 'node:os';
22
25
  import { startServer } from './server.mjs';
23
26
  import { createApiTransport } from './transport.mjs';
27
+ import { motionKeyTimes, normalizeMotionCreative } from '../motion.js';
24
28
 
25
29
  const HERE = dirname(fileURLToPath(import.meta.url));
26
30
  const PACKAGE_ROOT = join(HERE, '..'); // conductor-creative/, the static root
@@ -42,6 +46,15 @@ const SHEET_CONTENT_TYPE = 'image/jpeg';
42
46
  const FRAME_SCALE = 2;
43
47
  const SHEET_SCALE = 1;
44
48
 
49
+ /* MOTION frames are captured at deviceScaleFactor 1 (the placement's true pixel size) — video, unlike
50
+ * a placement JPEG, is never worth doubling: it would roughly quadruple every per-frame screenshot
51
+ * and the ffmpeg encode, for no visible gain on any surface that plays this file back. Quality 90 (a
52
+ * hair below the placement JPEG's 92) keeps each frame small; H.264's own compression dominates the
53
+ * final file size regardless. */
54
+ const MOTION_FRAME_SCREENSHOT = { type: 'jpeg', quality: 90 };
55
+ const MOTION_DEVICE_SCALE_FACTOR = 1;
56
+ const DEFAULT_FPS = 30;
57
+
45
58
  async function defaultBrowserFactory() {
46
59
  const { chromium } = await import('playwright');
47
60
  return chromium.launch();
@@ -84,21 +97,226 @@ export function framesFor(spec) {
84
97
  return (placements || []).map((placementKey) => ({ page: 'frame.html', placementKey }));
85
98
  }
86
99
 
87
- /** Renders every frame a spec calls for, uploads each PNG, and reports
88
- * complete/fail. Options:
100
+ /* ── MOTION: frame-stepped video capture ──────────────────────────────────── */
101
+
102
+ /* Writes `chunk` to `stream` and resolves once Node has flushed it, which
103
+ * naturally serializes the capture-then-write pipeline (screenshot, write,
104
+ * await, screenshot, write, ...) without any separate backpressure/'drain'
105
+ * handling — the write for frame i+1 never starts until frame i's bytes have
106
+ * actually left the process. */
107
+ function writeAsync(stream, chunk) {
108
+ return new Promise((resolve, reject) => {
109
+ stream.write(chunk, (err) => (err ? reject(err) : resolve()));
110
+ });
111
+ }
112
+
113
+ /* The URL ffmpeg should read audio from, per spec.creative.audio.source (see
114
+ * README's Creative fields / motion.js's header comment): 'clip' reuses the
115
+ * SAME clip already playing as the video background (its own recorded
116
+ * sound); 'track' is a separate library audio file with its own signed URL.
117
+ * `null` for 'none' or a spec with no audio at all. */
118
+ function audioSourceUrl(creative) {
119
+ const audio = creative.audio;
120
+ if (!audio || audio.source === 'none') return null;
121
+ if (audio.source === 'clip') return creative.backgroundVideoUrl || null;
122
+ if (audio.source === 'track') return audio.trackUrl || null;
123
+ return null;
124
+ }
125
+
126
+ async function downloadToTemp(url, dir, name) {
127
+ const res = await fetch(url);
128
+ if (!res.ok) throw new Error(`failed to download audio (${url}): ${res.status}`);
129
+ const buf = Buffer.from(await res.arrayBuffer());
130
+ const path = join(dir, name);
131
+ await writeFile(path, buf);
132
+ return path;
133
+ }
134
+
135
+ /* ffmpeg argv for one MOTION placement: JPEGs piped in via stdin (image2pipe,
136
+ * one concatenated JPEG stream — ffmpeg's own SOI/EOI framing needs no extra
137
+ * delimiting), an optional audio input trimmed to durationSec, H.264/AAC,
138
+ * faststart. `-movflags +faststart` needs a seekable output (it rewrites the
139
+ * moov atom after the first encode pass), so `outPath` is a real temp file,
140
+ * never stdout. */
141
+ function buildFfmpegArgs({ fps, durationSec, audioPath, audioSource, clipStartSec, volume, fadeOutSec, outPath }) {
142
+ const args = ['-f', 'image2pipe', '-framerate', String(fps), '-i', '-'];
143
+ if (audioPath) {
144
+ if (audioSource === 'clip') {
145
+ args.push('-ss', String(clipStartSec || 0), '-t', String(durationSec), '-i', audioPath);
146
+ } else {
147
+ args.push('-t', String(durationSec), '-i', audioPath);
148
+ }
149
+ }
150
+ // The piped JPEGs are full-range (yuvj420p); platforms expect limited-range yuv420p, so convert the range
151
+ // explicitly — `-pix_fmt yuv420p` alone keeps the full-range flag and ffprobe still reports yuvj420p.
152
+ // Map streams explicitly. A clip used for its sound is an MP4 with its own video stream, and ffmpeg's
153
+ // default picks the highest-resolution video among the inputs — for a 4:5 or 1:1 frame that is the
154
+ // clip's 9:16 picture, so the output was the raw clip, not the rendered creative.
155
+ args.push('-map', '0:v:0');
156
+ if (audioPath) args.push('-map', '1:a:0?');
157
+ args.push('-vf', 'scale=in_range=pc:out_range=tv,format=yuv420p', '-color_range', 'tv');
158
+ args.push('-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-crf', '20', '-preset', 'veryfast', '-r', String(fps));
159
+ if (audioPath) {
160
+ // A clip's own sound plays as recorded — untouched. A library track is a music bed layered under
161
+ // the creative, so only IT gets volume control and a fade-out (README/the contract: "volume,
162
+ // afade=t=out..." is listed for 'track', not for 'clip').
163
+ if (audioSource === 'track') {
164
+ const v = volume != null ? volume : 0.8;
165
+ const fadeOut = fadeOutSec != null ? fadeOutSec : 1;
166
+ const filters = [`volume=${v}`];
167
+ if (fadeOut > 0) filters.push(`afade=t=out:st=${Math.max(0, durationSec - fadeOut)}:d=${fadeOut}`);
168
+ args.push('-af', filters.join(','));
169
+ }
170
+ // Bound the output by the video's own length, not `-shortest`: the audio file reads instantly while
171
+ // frames trickle in through the pipe, and `-shortest` could end the file as soon as audio hit EOF
172
+ // (it raced — one placement would finish, the next would be cut off mid-render).
173
+ args.push('-c:a', 'aac', '-b:a', '128k', '-t', String(durationSec));
174
+ }
175
+ args.push('-movflags', '+faststart', '-y', outPath);
176
+ return args;
177
+ }
178
+
179
+ /* Renders ONE placement's MOTION video: one frame.html page load, then N
180
+ * in-page seeks (frame.js's window.__seekMotion — see its header comment for
181
+ * why this is not N page loads), streaming each screenshot into an ffmpeg
182
+ * child process. Runs the layout assertions exactly once, on the final
183
+ * (end-card) frame; a failure throws before any upload happens. Resolves
184
+ * `{ bytes, posterBytes, width, height, durationSeconds, hasAudio, warnings }`. */
185
+ async function renderMotionPlacement({ browser, origin, spec, placementKey, ffmpegPath, fps, log }) {
186
+ const motion = spec.creative.motion || {};
187
+ const durationSec = motion.durationSec || 8;
188
+ const frameCount = Math.max(1, Math.round(durationSec * fps));
189
+ const warnings = [];
190
+
191
+ const tmpDir = await mkdtemp(join(tmpdir(), 'cc-motion-'));
192
+ const page = await browser.newPage({ deviceScaleFactor: MOTION_DEVICE_SCALE_FACTOR });
193
+ let ffmpeg;
194
+ try {
195
+ await page.addInitScript((s) => {
196
+ window.__RENDER_SPEC__ = s;
197
+ }, {
198
+ creative: spec.creative,
199
+ brand: spec.brand,
200
+ placementKey,
201
+ motion: spec.creative.motion,
202
+ time: 0,
203
+ clipStartSec: spec.creative.clipStartSec,
204
+ });
205
+ await page.goto(`${origin}/frame.html`, { waitUntil: 'load', timeout: PAGE_TIMEOUT_MS });
206
+ await page.waitForFunction(() => window.__ready === true, null, { timeout: PAGE_TIMEOUT_MS });
207
+ const boot = await page.evaluate(() => window.__RENDER_RESULT);
208
+ if (!boot || !boot.ok) {
209
+ throw new Error(`${placementKey}: ${(boot && boot.error) || 'failed to build the MOTION board'}`);
210
+ }
211
+ const box = await page.locator('.cc-board').first().boundingBox();
212
+ const width = Math.round((box && box.width) || 0);
213
+ const height = Math.round((box && box.height) || 0);
214
+
215
+ const srcUrl = audioSourceUrl(spec.creative);
216
+ const audioPath = srcUrl ? await downloadToTemp(srcUrl, tmpDir, 'audio-src') : null;
217
+ const audio = spec.creative.audio;
218
+
219
+ const outPath = join(tmpDir, `${placementKey}.mp4`);
220
+ const args = buildFfmpegArgs({
221
+ fps,
222
+ durationSec,
223
+ audioPath,
224
+ audioSource: audio && audio.source,
225
+ clipStartSec: spec.creative.clipStartSec,
226
+ volume: audio && audio.volume,
227
+ fadeOutSec: audio && audio.fadeOutSec,
228
+ outPath,
229
+ });
230
+ ffmpeg = spawn(ffmpegPath, args, { stdio: ['pipe', 'ignore', 'pipe'] });
231
+ // If ffmpeg exits early, the next frame write fails with EPIPE; without a listener that 'error' event
232
+ // is unhandled and kills the whole process (no failure is reported and the render stays RUNNING).
233
+ // The write's own callback rejects too, and the loop below turns that into ffmpeg's real message.
234
+ ffmpeg.stdin.on('error', () => {});
235
+ let ffmpegErr = '';
236
+ ffmpeg.stderr.on('data', (chunk) => {
237
+ ffmpegErr += chunk.toString();
238
+ });
239
+ const ffmpegExit = new Promise((resolve, reject) => {
240
+ ffmpeg.on('error', reject);
241
+ ffmpeg.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`ffmpeg exited ${code}: ${ffmpegErr.slice(-2000)}`))));
242
+ });
243
+ // An end-card assertion failure (below) throws before `await ffmpegExit` is ever reached, and the
244
+ // `finally` block then kills ffmpeg — which rejects this same promise with nothing left awaiting
245
+ // it. Without this, that becomes an unhandled rejection; this does not change what the success
246
+ // path below still awaits and reports.
247
+ ffmpegExit.catch(() => {});
248
+
249
+ let posterBytes = null;
250
+ let lastLoggedPct = -25;
251
+ for (let i = 0; i < frameCount; i += 1) {
252
+ const t = i / fps;
253
+ const seeked = await page.evaluate((tt) => window.__seekMotion(tt), t);
254
+ if (!seeked) throw new Error(`${placementKey}: motion seek failed at t=${t.toFixed(2)}s`);
255
+
256
+ if (i === frameCount - 1) {
257
+ const assertion = await page.evaluate(() => window.__assertBoard());
258
+ if (!assertion || (assertion.errors && assertion.errors.length)) {
259
+ const detail = (assertion && assertion.errors && assertion.errors.join('; ')) || 'end-card assertions failed';
260
+ throw new Error(`${placementKey}: ${detail}`);
261
+ }
262
+ (assertion.warnings || []).forEach((message) => warnings.push(message));
263
+ }
264
+
265
+ const bytes = await page.locator('.cc-board').first().screenshot(MOTION_FRAME_SCREENSHOT);
266
+ if (i === frameCount - 1) posterBytes = bytes;
267
+ try {
268
+ await writeAsync(ffmpeg.stdin, bytes);
269
+ } catch (err) {
270
+ // ffmpeg stopped reading: report why it stopped (its exit message), not the pipe error. Give it a
271
+ // moment to finish exiting so its stderr is complete.
272
+ const exitErr = await Promise.race([
273
+ ffmpegExit.then(() => null, (e) => e),
274
+ new Promise((resolve) => setTimeout(() => resolve(null), 5000)),
275
+ ]);
276
+ throw new Error(`${placementKey}: ${exitErr ? exitErr.message : `ffmpeg stopped reading frames (${err.code || err.message}): ${ffmpegErr.slice(-1500)}`}`);
277
+ }
278
+
279
+ const pct = Math.floor(((i + 1) / frameCount) * 100);
280
+ if (pct >= lastLoggedPct + 25 || i === frameCount - 1) {
281
+ log(`${placementKey}: ${pct}% (${i + 1}/${frameCount} frames)`);
282
+ lastLoggedPct = pct;
283
+ }
284
+ }
285
+ ffmpeg.stdin.end();
286
+ await ffmpegExit;
287
+ ffmpeg = null;
288
+
289
+ const bytes = await readFile(outPath);
290
+ return { bytes, posterBytes, width, height, durationSeconds: durationSec, hasAudio: Boolean(audioPath), warnings };
291
+ } finally {
292
+ if (ffmpeg && ffmpeg.exitCode === null) {
293
+ try { ffmpeg.kill('SIGKILL'); } catch { /* already gone */ }
294
+ }
295
+ await page.close().catch(() => {});
296
+ await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
297
+ }
298
+ }
299
+
300
+ /** Renders every frame a spec calls for, uploads each PNG (or, for a MOTION
301
+ * creative that is not `previewOnly`, one MP4 + poster per placement — see
302
+ * renderMotionPlacement above), and reports complete/fail. Options:
89
303
  * transport required. See transport.mjs.
90
304
  * packageRoot static root to serve (default: this package's own root).
91
305
  * browserFactory () => Promise<Browser> (default: launches real Playwright chromium).
306
+ * ffmpegPath a local ffmpeg binary's path. Required for a MOTION
307
+ * render (not previewOnly) — ignored otherwise.
308
+ * fps frames per second for a MOTION render (default 30).
92
309
  * log (...args) => void (default: console.log).
93
310
  * Resolves `true` on success, `false` on a reported failure (never throws —
94
311
  * a thrown error from a truly unexpected place still gets caught and turned
95
312
  * into a `transport.fail()` call before resolving `false`, so a caller never
96
313
  * has to guess whether `transport.fail` was already called). */
97
- export async function run({ transport, packageRoot = PACKAGE_ROOT, browserFactory = defaultBrowserFactory, log = console.log }) {
314
+ export async function run({ transport, packageRoot = PACKAGE_ROOT, browserFactory = defaultBrowserFactory, ffmpegPath, fps = DEFAULT_FPS, log = console.log }) {
98
315
  let spec;
99
316
  try {
100
317
  log('fetching spec');
101
318
  spec = await transport.getSpec();
319
+ if (spec && spec.creative) spec = { ...spec, creative: normalizeMotionCreative(spec.creative) };
102
320
  } catch (err) {
103
321
  log(`FAIL ${err.message}`);
104
322
  await reportFailure(transport, err.message, log);
@@ -110,6 +328,40 @@ export async function run({ transport, packageRoot = PACKAGE_ROOT, browserFactor
110
328
  const warnings = [];
111
329
  try {
112
330
  browser = await browserFactory();
331
+ const isMotionRender = spec.creative && spec.creative.kind === 'MOTION' && !spec.previewOnly;
332
+
333
+ if (isMotionRender) {
334
+ if (!ffmpegPath) {
335
+ throw new Error(
336
+ 'a MOTION render needs a local ffmpeg binary — pass `ffmpegPath` to run() (e.g. a system '
337
+ + '`ffmpeg` found via `which ffmpeg`, or a bundled ffmpeg-static build; conductor-tools '
338
+ + 'resolves this the same way it resolves Chromium).'
339
+ );
340
+ }
341
+ const placements = spec.placements || [];
342
+ if (!placements.length) {
343
+ throw new Error('the render spec names no placements to render (check the Brand Kit\'s enabled placements)');
344
+ }
345
+ log(`rendering ${placements.length} MOTION placement(s) at ${fps}fps`);
346
+ for (const placementKey of placements) {
347
+ const result = await renderMotionPlacement({ browser, origin, spec, placementKey, ffmpegPath, fps, log });
348
+ for (const message of result.warnings) warnings.push({ placementKey, message });
349
+ await transport.putFrame(placementKey, {
350
+ bytes: result.bytes,
351
+ contentType: 'video/mp4',
352
+ width: result.width,
353
+ height: result.height,
354
+ durationSeconds: result.durationSeconds,
355
+ hasAudio: result.hasAudio,
356
+ });
357
+ await transport.putPoster(placementKey, result.posterBytes);
358
+ log(`ok ${placementKey} video/mp4 ${result.width}x${result.height} ${result.durationSeconds}s audio=${result.hasAudio}`);
359
+ }
360
+ await transport.complete(warnings);
361
+ log(`complete (${warnings.length} warning(s))`);
362
+ return true;
363
+ }
364
+
113
365
  const frames = framesFor(spec);
114
366
  log(`rendering ${frames.length} frame(s)`);
115
367
 
@@ -119,6 +371,7 @@ export async function run({ transport, packageRoot = PACKAGE_ROOT, browserFactor
119
371
  const scale = isSheet ? SHEET_SCALE : FRAME_SCALE;
120
372
  const page = await browser.newPage({ deviceScaleFactor: scale });
121
373
  try {
374
+ const isMotionSheet = isSheet && spec.creative && spec.creative.kind === 'MOTION' && spec.creative.motion;
122
375
  await page.addInitScript((s) => {
123
376
  window.__RENDER_SPEC__ = s;
124
377
  }, {
@@ -127,6 +380,7 @@ export async function run({ transport, packageRoot = PACKAGE_ROOT, browserFactor
127
380
  placementKey: frame.placementKey,
128
381
  sequenceIndex: frame.index,
129
382
  placementKeys: spec.placements,
383
+ ...(isMotionSheet ? { times: motionKeyTimes(spec.creative.motion) } : {}),
130
384
  });
131
385
  await page.goto(`${origin}/${frame.page}`, { waitUntil: 'load', timeout: PAGE_TIMEOUT_MS });
132
386
  await page.waitForFunction(() => window.__ready === true, null, { timeout: PAGE_TIMEOUT_MS });
@@ -191,6 +445,9 @@ if (isMain()) {
191
445
  const previewOnly = /^(1|true)$/i.test(process.env.PREVIEW_ONLY || '');
192
446
  const renderer = process.env.RENDERER || 'cli';
193
447
  const workflowRunId = process.env.WORKFLOW_RUN_ID || undefined;
448
+ // MOTION only; a STILL/CLIP render, or a previewOnly one, never touches ffmpeg.
449
+ const ffmpegPath = process.env.FFMPEG_PATH || undefined;
450
+ const fps = process.env.FPS ? Number(process.env.FPS) : undefined;
194
451
  const missing = ['CONDUCTOR_API_URL', 'CONDUCTOR_API_KEY', 'CONDUCTOR_PROJECT_ID', 'CREATIVE_ID'].filter((k) => !process.env[k]);
195
452
  if (missing.length) {
196
453
  console.error(`missing required env var(s): ${missing.join(', ')}`);
@@ -199,5 +456,5 @@ if (isMain()) {
199
456
 
200
457
  const transport = createApiTransport({ apiUrl, apiKey, projectId, creativeId, previewOnly, renderer, workflowRunId });
201
458
  const log = (...args) => console.log(`[render ${creativeId}]`, ...args);
202
- run({ transport, log }).then((ok) => process.exit(ok ? 0 : 1));
459
+ run({ transport, log, ffmpegPath, ...(fps ? { fps } : {}) }).then((ok) => process.exit(ok ? 0 : 1));
203
460
  }