@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
@@ -15,9 +15,13 @@
15
15
  * -> 201 CreativeRenderResponse { id, state: "RUNNING", ..., spec: { renderId, previewOnly, creative, brand, placements } }
16
16
  * `spec` is only ever returned by this call — getSpec() hands back `response.spec` and
17
17
  * remembers `renderId` for the three calls below.
18
- * PUT {apiUrl}/api/v2/projects/{projectId}/marketing/creatives/{creativeId}/renders/{renderId}/frames/{placementKey}?index&width&height
19
- * body: raw image bytes, Content-Type: image/jpeg (a placement frame) or image/png (the `sheet`
20
- * contact sheet) -> 204
18
+ * PUT {apiUrl}/api/v2/projects/{projectId}/marketing/creatives/{creativeId}/renders/{renderId}/frames/{placementKey}?index&width&height&durationSeconds&hasAudio
19
+ * body: raw bytes, Content-Type: image/jpeg (a placement frame), image/png (the `sheet` contact
20
+ * sheet), or video/mp4 (a MOTION placement — `durationSeconds`/`hasAudio` are only sent then)
21
+ * -> 204
22
+ * PUT {apiUrl}/api/v2/projects/{projectId}/marketing/creatives/{creativeId}/renders/{renderId}/frames/{placementKey}/poster?index
23
+ * body: raw JPEG bytes, Content-Type: image/jpeg (a MOTION frame's poster — the end-card frame)
24
+ * -> 204
21
25
  * POST {apiUrl}/api/v2/projects/{projectId}/marketing/creatives/{creativeId}/renders/{renderId}/complete
22
26
  * body: { warnings: [{ placementKey, index?, message }] } -> 200 CreativeRenderResponse
23
27
  * POST {apiUrl}/api/v2/projects/{projectId}/marketing/creatives/{creativeId}/renders/{renderId}/fail
@@ -51,6 +55,19 @@ async function safeText(res) {
51
55
  }
52
56
  }
53
57
 
58
+ /** Runs `putOnce` (a zero-arg upload attempt), retrying once on a retryable failure (a 5xx, or the
59
+ * request timing out) — shared by putFrame and putPoster, both upserts, so a retry is always safe. */
60
+ async function uploadWithRetry(putOnce) {
61
+ for (let attempt = 1; ; attempt++) {
62
+ try {
63
+ return await putOnce();
64
+ } catch (err) {
65
+ const retryable = err.retryable || /timed out/.test(err.message);
66
+ if (!retryable || attempt >= UPLOAD_ATTEMPTS) throw err;
67
+ }
68
+ }
69
+ }
70
+
54
71
  /** Builds the transport the real job uses, talking to the Conductor backend over HTTP per today's
55
72
  * external v2 `/marketing/creatives/{creativeId}/renders` contract. `timeoutMs` bounds every
56
73
  * individual network call, so a hung backend fails the job instead of hanging the caller (a Claude
@@ -123,11 +140,13 @@ export function createApiTransport({
123
140
  return spec;
124
141
  },
125
142
 
126
- async putFrame(placementKey, { index, width, height, bytes, contentType = 'image/jpeg' }) {
143
+ async putFrame(placementKey, { index, width, height, bytes, contentType = 'image/jpeg', durationSeconds, hasAudio }) {
127
144
  const qs = new URLSearchParams({ width: String(width), height: String(height) });
128
145
  if (index !== undefined && index !== null) qs.set('index', String(index));
146
+ if (durationSeconds !== undefined && durationSeconds !== null) qs.set('durationSeconds', String(durationSeconds));
147
+ if (hasAudio !== undefined && hasAudio !== null) qs.set('hasAudio', String(hasAudio));
129
148
  const base = renderBase();
130
- const put = () => withTimeout(async (signal) => {
149
+ return uploadWithRetry(() => withTimeout(async (signal) => {
131
150
  const res = await fetchImpl(`${base}/frames/${encodeURIComponent(placementKey)}?${qs}`, {
132
151
  method: 'PUT',
133
152
  headers: { ...authHeaders, 'Content-Type': contentType },
@@ -139,15 +158,30 @@ export function createApiTransport({
139
158
  err.retryable = res.status >= 500;
140
159
  throw err;
141
160
  }
142
- }, uploadTimeoutMs, `PUT frame ${placementKey}`);
143
- for (let attempt = 1; ; attempt++) {
144
- try {
145
- return await put();
146
- } catch (err) {
147
- const retryable = err.retryable || /timed out/.test(err.message);
148
- if (!retryable || attempt >= UPLOAD_ATTEMPTS) throw err;
161
+ }, uploadTimeoutMs, `PUT frame ${placementKey}`));
162
+ },
163
+
164
+ /** A MOTION frame's poster: the end-card JPEG (job/render.mjs). `index` is only meaningful for a
165
+ * sequence frame — MOTION creatives never have one today, but the param is accepted for parity
166
+ * with putFrame. */
167
+ async putPoster(placementKey, bytes, { index } = {}) {
168
+ const qs = new URLSearchParams();
169
+ if (index !== undefined && index !== null) qs.set('index', String(index));
170
+ const query = qs.toString() ? `?${qs}` : '';
171
+ const base = renderBase();
172
+ return uploadWithRetry(() => withTimeout(async (signal) => {
173
+ const res = await fetchImpl(`${base}/frames/${encodeURIComponent(placementKey)}/poster${query}`, {
174
+ method: 'PUT',
175
+ headers: { ...authHeaders, 'Content-Type': 'image/jpeg' },
176
+ body: bytes,
177
+ signal,
178
+ });
179
+ if (res.status !== 204) {
180
+ const err = new Error(`PUT poster ${placementKey} failed: ${res.status} ${await safeText(res)}`);
181
+ err.retryable = res.status >= 500;
182
+ throw err;
149
183
  }
150
- }
184
+ }, uploadTimeoutMs, `PUT poster ${placementKey}`));
151
185
  },
152
186
 
153
187
  async complete(warnings) {
@@ -2,8 +2,12 @@
2
2
 
3
3
  .cc-board--bleed {
4
4
  background-image: var(--cc-photo);
5
- background-size: cover;
6
- background-position: var(--cc-focal, 50% 50%);
5
+ /* motion.js's Ken Burns pan/zoom (a MOTION creative only) sets --cc-bg-size/--cc-bg-pos on the
6
+ board; a background-image has no `transform`, so the zoom/pan is expressed as a growing
7
+ background-size and a shifting background-position instead. Unset for STILL: falls back to the
8
+ original fixed cover/focal. */
9
+ background-size: var(--cc-bg-size, cover);
10
+ background-position: var(--cc-bg-pos, var(--cc-focal, 50% 50%));
7
11
  justify-content: flex-end;
8
12
  }
9
13
 
@@ -16,6 +16,9 @@
16
16
  width: 100%; height: 100%;
17
17
  object-fit: cover;
18
18
  object-position: var(--cc-focal, 50% 40%);
19
+ /* motion.js's Ken Burns pan/zoom (a MOTION creative only); unset for STILL. */
20
+ transform: var(--cc-bg-transform, none);
21
+ transform-origin: center center;
19
22
  }
20
23
  .cc-board--card .cc-board__panel {
21
24
  flex: 1 1 auto; min-height: 0;
@@ -12,6 +12,9 @@
12
12
  width: 100%; height: 100%;
13
13
  object-fit: cover;
14
14
  object-position: var(--cc-focal, 50% 30%);
15
+ /* motion.js's Ken Burns pan/zoom (a MOTION creative only); unset for STILL. */
16
+ transform: var(--cc-bg-transform, none);
17
+ transform-origin: center center;
15
18
  }
16
19
  .cc-board--split .cc-board__panel {
17
20
  flex: 1 1 auto; min-height: 0;
@@ -12,6 +12,9 @@
12
12
  width: 100%; height: 100%;
13
13
  object-fit: cover;
14
14
  object-position: var(--cc-focal, 50% 30%);
15
+ /* motion.js's Ken Burns pan/zoom (a MOTION creative only); unset for STILL. */
16
+ transform: var(--cc-bg-transform, none);
17
+ transform-origin: center center;
15
18
  }
16
19
  .cc-board__scrim {
17
20
  position: absolute; left: 0; right: 0; bottom: 0; height: 220px;
@@ -0,0 +1,347 @@
1
+ /* motion.js — the MOTION creative's animation timeline: a pure function of
2
+ * time. Everything here computes, for a given `board` (a `.cc-board` element
3
+ * `render.js`'s `renderBoard` already built) and a time `tSec`, the exact
4
+ * inline styles/CSS vars that time's frame should show, and writes them.
5
+ * Nothing here uses `setTimeout`, a CSS `@keyframes`/`animation`, or `Date`.
6
+ *
7
+ * That purity is the whole point: the headless render job (`job/render.mjs`)
8
+ * captures a MOTION video by calling `applyMotion(board, motion, t)` for a
9
+ * sequence of `t` values and screenshotting after each call — a "frame-
10
+ * stepped" capture. If `applyMotion` depended on wall-clock time or a running
11
+ * CSS animation, two calls at the same `t` could render two different
12
+ * pixels, and the exported video would not match what the editor's live
13
+ * preview showed. `mount.js`'s `play()` (real-time, via
14
+ * `requestAnimationFrame`) and the render job's frame stepper both end up
15
+ * calling this exact same function — that is what keeps the live preview and
16
+ * the exported MP4 from ever drifting apart, the same guarantee `render.js`
17
+ * already gives the STILL frame vs. the editor's preview.
18
+ *
19
+ * A `motion` object (see README.md and the T-video contract):
20
+ * {
21
+ * preset: 'fade-up' | 'word-by-word' | 'accent-pop' | 'none', // default 'fade-up'
22
+ * durationSec: number, // default 8
23
+ * background: { motion: 'zoom-in'|'zoom-out'|'pan-left'|'pan-right'|'none' }, // default 'zoom-in'
24
+ * endCard: boolean, // default true
25
+ * }
26
+ *
27
+ * Every element's intro is keyed to seconds-from-start, independent of
28
+ * `durationSec` (a 3s and a 60s creative both fade the lockup in over
29
+ * 0.2-0.6s) — except the end card, which is always the LAST 2 seconds,
30
+ * however long the creative is. When `endCard` is true (the default) and
31
+ * `tSec` falls in that final window, every text/lockup/CTA element is forced
32
+ * to its finished, fully-visible state regardless of where its own intro
33
+ * timeline would otherwise put it — a 3s creative's CTA (whose intro window
34
+ * is 1.8-2.3s) still needs to be showing throughout the 1-3s hold. Background
35
+ * photo/clip motion is on its own separate, continuous timeline (the whole
36
+ * `0..durationSec` span) and is never affected by the end-card hold or by
37
+ * `preset` — panning/zooming is what makes a still photo read as video for
38
+ * the full runtime, hold included.
39
+ */
40
+
41
+ /* ── easing + small numeric helpers (exported: pure, used by mount.js/tests) */
42
+
43
+ const clamp01 = (x) => Math.max(0, Math.min(1, x));
44
+ const lerp = (a, b, t) => a + (b - a) * t;
45
+
46
+ /** Standard easeOutCubic: fast start, gentle settle. Used for every fade/
47
+ * transform interpolation in this file, including the background pan/zoom. */
48
+ export function easeOutCubic(x) {
49
+ const t = clamp01(x);
50
+ return 1 - Math.pow(1 - t, 3);
51
+ }
52
+
53
+ /** Eased 0-1 progress of `tSec` through the window `[start, end]`. `end <=
54
+ * start` is treated as an instant switch at `start` (never divides by zero). */
55
+ function windowProgress(tSec, start, end) {
56
+ if (end <= start) return tSec >= start ? 1 : 0;
57
+ return easeOutCubic(clamp01((tSec - start) / (end - start)));
58
+ }
59
+
60
+ /* ── per-element timing windows (seconds from the creative's own start) ──── */
61
+
62
+ const LOCKUP_WINDOW = [0.2, 0.6];
63
+ const HEADLINE_FADEUP_WINDOW = [0.3, 0.9];
64
+ const ACCENT_POP_FADE_WINDOW = [0.3, 0.8];
65
+ const ACCENT_POP_POP_WINDOW = [0.8, 1.1];
66
+ const WORD_WINDOW = [0.3, 1.5];
67
+ const WORD_FADE_DURATION = 0.3; // each word's own fade length within WORD_WINDOW
68
+ const BODY_WINDOW = [1.2, 1.7];
69
+ const CTA_WINDOW = [1.8, 2.3];
70
+ const FADE_UP_PX = 24; // headline/body/cta translateY start offset
71
+ const WORD_UP_PX = 16; // a smaller offset per word reads better than 24px x N words
72
+ const END_CARD_LEAD_SEC = 2; // the hold window is [durationSec - 2, durationSec]
73
+
74
+ /* ── DOM helpers: always set BOTH opacity and transform on every call, never
75
+ * only one — mount.js's live preview scrubs `t` forward and backward, so a
76
+ * value this function does not explicitly set this call is a value left over
77
+ * from a previous call, not "unset". That is the one correctness rule this
78
+ * whole file exists to uphold. */
79
+
80
+ function setFade(el, progress, translateY) {
81
+ if (!el) return;
82
+ el.style.opacity = String(progress);
83
+ if (translateY == null) return;
84
+ const y = lerp(translateY, 0, progress);
85
+ el.style.transform = y === 0 ? 'none' : `translateY(${round2(y)}px)`;
86
+ }
87
+
88
+ function finalState(el) {
89
+ if (!el) return;
90
+ el.style.opacity = '1';
91
+ el.style.transform = 'none';
92
+ }
93
+
94
+ function round2(n) {
95
+ return Math.round(n * 100) / 100;
96
+ }
97
+
98
+ /* ── word-by-word: wrap the headline's own text into <span class="cc-word">,
99
+ * once, idempotently (a rebuilt board from a fresh renderBoard() call always
100
+ * starts unwrapped; the same board is safe to wrap repeatedly across seeks). */
101
+
102
+ function ensureWordSpans(headline) {
103
+ if (headline.querySelector('.cc-word')) return;
104
+ wrapWordsInPlace(headline);
105
+ }
106
+
107
+ function wrapWordsInPlace(container) {
108
+ const nodes = Array.from(container.childNodes);
109
+ const frag = container.ownerDocument.createDocumentFragment();
110
+ for (const node of nodes) {
111
+ if (node.nodeType === 3) {
112
+ // TEXT_NODE: split on whitespace, keeping the whitespace itself as plain text nodes so word
113
+ // spacing/wrapping behaves exactly as it did before splitting.
114
+ const parts = node.textContent.split(/(\s+)/);
115
+ for (const part of parts) {
116
+ if (!part) continue;
117
+ if (/^\s+$/.test(part)) {
118
+ frag.appendChild(container.ownerDocument.createTextNode(part));
119
+ } else {
120
+ const span = container.ownerDocument.createElement('span');
121
+ span.className = 'cc-word';
122
+ span.textContent = part;
123
+ frag.appendChild(span);
124
+ }
125
+ }
126
+ } else if (node.nodeType === 1) {
127
+ // ELEMENT_NODE: the accent <em> — wrap its own words too, keep the <em> wrapper (and its
128
+ // italic/accent-color styling) intact around them.
129
+ wrapWordsInPlace(node);
130
+ frag.appendChild(node);
131
+ } else {
132
+ frag.appendChild(node);
133
+ }
134
+ }
135
+ container.textContent = '';
136
+ container.appendChild(frag);
137
+ }
138
+
139
+ /* ── headline (preset-dependent) ──────────────────────────────────────────── */
140
+
141
+ function applyHeadline(board, preset, tSec, inEndCard) {
142
+ const headline = board.querySelector('.cc-headline');
143
+ if (!headline) return;
144
+
145
+ if (preset === 'word-by-word') {
146
+ ensureWordSpans(headline);
147
+ headline.style.opacity = '1';
148
+ headline.style.transform = 'none';
149
+ const words = headline.querySelectorAll('.cc-word');
150
+ const n = words.length;
151
+ const [start, end] = WORD_WINDOW;
152
+ const span = n > 1 ? Math.max(0, (end - WORD_FADE_DURATION - start) / (n - 1)) : 0;
153
+ words.forEach((word, i) => {
154
+ if (inEndCard) {
155
+ finalState(word);
156
+ return;
157
+ }
158
+ const wordStart = start + i * span;
159
+ const progress = windowProgress(tSec, wordStart, wordStart + WORD_FADE_DURATION);
160
+ setFade(word, progress, WORD_UP_PX);
161
+ });
162
+ return;
163
+ }
164
+
165
+ if (preset === 'none') {
166
+ finalState(headline);
167
+ const em = headline.querySelector('em');
168
+ if (em) {
169
+ em.style.transform = 'none';
170
+ em.style.filter = 'none';
171
+ }
172
+ return;
173
+ }
174
+
175
+ if (preset === 'accent-pop') {
176
+ if (inEndCard) {
177
+ finalState(headline);
178
+ const em = headline.querySelector('em');
179
+ if (em) {
180
+ em.style.transform = 'scale(1)';
181
+ em.style.filter = 'brightness(1)';
182
+ }
183
+ return;
184
+ }
185
+ const fadeProgress = windowProgress(tSec, ACCENT_POP_FADE_WINDOW[0], ACCENT_POP_FADE_WINDOW[1]);
186
+ setFade(headline, fadeProgress, null);
187
+ const em = headline.querySelector('em');
188
+ if (em) {
189
+ const popProgress = windowProgress(tSec, ACCENT_POP_POP_WINDOW[0], ACCENT_POP_POP_WINDOW[1]);
190
+ em.style.transform = `scale(${round2(lerp(1.18, 1, popProgress))})`;
191
+ em.style.filter = `brightness(${round2(lerp(0.9, 1.3, popProgress))})`;
192
+ }
193
+ return;
194
+ }
195
+
196
+ // 'fade-up' (default)
197
+ if (inEndCard) {
198
+ finalState(headline);
199
+ return;
200
+ }
201
+ const progress = windowProgress(tSec, HEADLINE_FADEUP_WINDOW[0], HEADLINE_FADEUP_WINDOW[1]);
202
+ setFade(headline, progress, FADE_UP_PX);
203
+ }
204
+
205
+ /* ── lockup / body / cta: a shared fade(-up) treatment ────────────────────── */
206
+
207
+ function applyFadeElement(board, selector, [start, end], tSec, preset, inEndCard, translateY) {
208
+ const el = board.querySelector(selector);
209
+ if (!el) return;
210
+ if (preset === 'none' || inEndCard) {
211
+ finalState(el);
212
+ return;
213
+ }
214
+ const progress = windowProgress(tSec, start, end);
215
+ setFade(el, progress, translateY);
216
+ }
217
+
218
+ /* ── background photo/clip motion ─────────────────────────────────────────── */
219
+
220
+ const ZOOM_IN = [1, 1.12];
221
+ const ZOOM_OUT = [1.12, 1];
222
+ const PAN_SCALE = 1.08;
223
+ const PAN_OFFSET_PCT = 4;
224
+
225
+ /** Pure: the background's scale + horizontal pan (percent, ± PAN_OFFSET_PCT)
226
+ * at `tSec` of `durationSec`, given `motion.background.motion`. Runs the
227
+ * whole duration, eased, independent of preset/end-card. Exported for tests
228
+ * and for anything that wants the numbers without touching the DOM. */
229
+ export function backgroundMotionState(motion, tSec, durationSec) {
230
+ const kind = (motion && motion.background && motion.background.motion) || 'zoom-in';
231
+ const progress = durationSec > 0 ? easeOutCubic(clamp01(tSec / durationSec)) : 0;
232
+ if (kind === 'zoom-in') return { scale: round2(lerp(ZOOM_IN[0], ZOOM_IN[1], progress)), panPct: 0 };
233
+ if (kind === 'zoom-out') return { scale: round2(lerp(ZOOM_OUT[0], ZOOM_OUT[1], progress)), panPct: 0 };
234
+ if (kind === 'pan-left') return { scale: PAN_SCALE, panPct: round2(lerp(PAN_OFFSET_PCT, -PAN_OFFSET_PCT, progress)) };
235
+ if (kind === 'pan-right') return { scale: PAN_SCALE, panPct: round2(lerp(-PAN_OFFSET_PCT, PAN_OFFSET_PCT, progress)) };
236
+ return { scale: 1, panPct: 0 }; // 'none'
237
+ }
238
+
239
+ /* "50% 30%" + a pan percent -> "54% 30%": keeps the focal point's own Y, only
240
+ * the X drifts (a Ken Burns pan is always horizontal here). */
241
+ function panBackgroundPosition(focal, panPct) {
242
+ const parts = String(focal || '50% 50%').trim().split(/\s+/);
243
+ const y = parts[1] || '50%';
244
+ return `${round2(50 + panPct)}% ${y}`;
245
+ }
246
+
247
+ function applyBackgroundMotion(board, motion, tSec, durationSec) {
248
+ const { scale, panPct } = backgroundMotionState(motion, tSec, durationSec);
249
+ // Consumed two ways, both with a no-op fallback so a STILL board (which
250
+ // never calls applyMotion at all) is untouched:
251
+ // - `.cc-board__band img` / `.cc-board__card img` / `.cc-bg-video`: a real
252
+ // transform (band/card layouts, and any layout's clip background video).
253
+ // - `.cc-board--bleed`'s own CSS `background-image`: background-size/
254
+ // -position, since `transform` does not apply to a background image.
255
+ board.style.setProperty('--cc-bg-transform', `scale(${scale}) translateX(${panPct}%)`);
256
+ board.style.setProperty('--cc-bg-size', `${round2(scale * 100)}%`);
257
+ const focal = board.style.getPropertyValue('--cc-focal') || '50% 50%';
258
+ board.style.setProperty('--cc-bg-pos', panBackgroundPosition(focal, panPct));
259
+ }
260
+
261
+ /* ── the public entry point ───────────────────────────────────────────────── */
262
+
263
+ /** Sets every inline style/CSS var a MOTION board needs to look right at
264
+ * `tSec`, on `board` (a live `.cc-board` from `renderBoard`, already
265
+ * `fitBoard`-ed). `motion` is `creative.motion` (see the header comment);
266
+ * `opts.durationSec` overrides `motion.durationSec` when the caller has
267
+ * already resolved it. Safe to call every frame, in any order of `tSec`
268
+ * (forward playback, scrubbing backward, or the render job's frame-stepped
269
+ * capture) — every call fully determines every value it touches. Returns a
270
+ * small summary (`{ preset, durationSec, inEndCard, background }`), mostly
271
+ * useful for tests. */
272
+ export function applyMotion(board, motion, tSec, opts) {
273
+ if (!board) return null;
274
+ motion = motion || {};
275
+ const durationSec = (opts && opts.durationSec) || motion.durationSec || 8;
276
+ const preset = motion.preset || 'fade-up';
277
+ const endCardEnabled = motion.endCard !== false;
278
+ const inEndCard = endCardEnabled && tSec >= durationSec - END_CARD_LEAD_SEC;
279
+
280
+ applyHeadline(board, preset, tSec, inEndCard);
281
+ applyFadeElement(board, '.cc-lockup', LOCKUP_WINDOW, tSec, preset, inEndCard, null);
282
+ applyFadeElement(board, '.cc-body', BODY_WINDOW, tSec, preset, inEndCard, FADE_UP_PX);
283
+ applyFadeElement(board, '.cc-cta', CTA_WINDOW, tSec, preset, inEndCard, FADE_UP_PX);
284
+
285
+ const background = backgroundMotionState(motion, tSec, durationSec);
286
+ applyBackgroundMotion(board, motion, tSec, durationSec);
287
+
288
+ return { preset, durationSec, inEndCard, background };
289
+ }
290
+
291
+ /** Three representative moments for a `previewOnly` MOTION key-moments sheet:
292
+ * an early beat (past the lockup/headline intro), the midpoint, and a late
293
+ * beat inside the end-card hold. `motion.durationSec` defaults to 8 like
294
+ * everywhere else. */
295
+ export function motionKeyTimes(motion) {
296
+ const durationSec = (motion && motion.durationSec) || 8;
297
+ const early = 0.6;
298
+ const mid = round2(durationSec / 2);
299
+ const late = round2(Math.max(early, durationSec - 0.5));
300
+ return [early, mid, late];
301
+ }
302
+
303
+ /* ── clip background video: shared by mount.js's live-preview seek() and the
304
+ * headless frame.js/job frame-stepped capture, so a clip background is
305
+ * frame-accurate in both. No-ops (resolves immediately) when `board` has no
306
+ * `.cc-bg-video` (a photo background, or a STILL board). */
307
+ export function seekBackgroundVideo(board, tSec, clipStartSec) {
308
+ const video = board && board.querySelector && board.querySelector('.cc-bg-video');
309
+ if (!video) return Promise.resolve();
310
+ const target = Math.max(0, (clipStartSec || 0) + (tSec || 0));
311
+ return new Promise((resolve) => {
312
+ let settled = false;
313
+ const done = () => {
314
+ if (settled) return;
315
+ settled = true;
316
+ video.removeEventListener('seeked', onSeeked);
317
+ resolve();
318
+ };
319
+ const onSeeked = () => done();
320
+ video.addEventListener('seeked', onSeeked);
321
+ // A video that never loads (a bad URL, an unreachable fixture in a test) must not hang the
322
+ // caller forever — a MOTION render with an unreachable clip should fail loudly downstream (a
323
+ // black/frozen frame trips the end-card assertions or an obviously wrong ffmpeg duration), not
324
+ // hang the job or the editor.
325
+ setTimeout(done, 4000);
326
+ try {
327
+ video.currentTime = target;
328
+ } catch {
329
+ done();
330
+ }
331
+ });
332
+ }
333
+
334
+
335
+ /* The API describes a clip background under `motion.background` ({source:'clip', clipUrl, clipStartSec});
336
+ * the engine reads `backgroundVideoUrl`/`clipStartSec` on the creative itself (the editor fills those
337
+ * directly). This bridges the two so a render spec from the API gets its clip background and clip sound. */
338
+ export function normalizeMotionCreative(creative) {
339
+ if (!creative || !creative.motion || !creative.motion.background) return creative;
340
+ const bg = creative.motion.background;
341
+ if (bg.source !== 'clip' || !bg.clipUrl) return creative;
342
+ return {
343
+ ...creative,
344
+ backgroundVideoUrl: creative.backgroundVideoUrl || bg.clipUrl,
345
+ clipStartSec: creative.clipStartSec != null ? creative.clipStartSec : (bg.clipStartSec || 0),
346
+ };
347
+ }
@@ -195,6 +195,34 @@ export const placements = {
195
195
  cardGap: 56,
196
196
  },
197
197
  },
198
+ '16x9': {
199
+ w: 1920,
200
+ h: 1080,
201
+ label: '16:9 YouTube and landscape video',
202
+ platform: 'youtube',
203
+ default: false,
204
+ safe: { top: 0, bottom: 0, right: 0 },
205
+ fit: { max: 72, min: 34, lines: 3 },
206
+ badgeHeight: 56,
207
+ scale: {
208
+ lockupGap: 13,
209
+ iconSize: 44,
210
+ iconRadius: 11,
211
+ markHeight: 27,
212
+ chipPadding: '8px 20px 8px 8px',
213
+ chipGap: 12,
214
+ bodySize: 23,
215
+ bodyLineHeight: 1.3,
216
+ bodyMaxWidth: 880,
217
+ bodyColor: 'rgba(255,255,255,0.75)',
218
+ ctaGap: 24,
219
+ ctaMarginTop: 6,
220
+ noteSize: 21,
221
+ noteColor: 'rgba(255,255,255,0.6)',
222
+ cardPad: '64px 64px 72px 64px',
223
+ cardGap: 40,
224
+ },
225
+ },
198
226
  };
199
227
 
200
228
  export default placements;
@@ -228,5 +228,41 @@
228
228
  "cardPad": "72px 72px 88px 72px",
229
229
  "cardGap": 56
230
230
  }
231
+ },
232
+ "16x9": {
233
+ "w": 1920,
234
+ "h": 1080,
235
+ "label": "16:9 YouTube and landscape video",
236
+ "platform": "youtube",
237
+ "default": false,
238
+ "safe": {
239
+ "top": 0,
240
+ "bottom": 0,
241
+ "right": 0
242
+ },
243
+ "fit": {
244
+ "max": 72,
245
+ "min": 34,
246
+ "lines": 3
247
+ },
248
+ "badgeHeight": 56,
249
+ "scale": {
250
+ "lockupGap": 13,
251
+ "iconSize": 44,
252
+ "iconRadius": 11,
253
+ "markHeight": 27,
254
+ "chipPadding": "8px 20px 8px 8px",
255
+ "chipGap": 12,
256
+ "bodySize": 23,
257
+ "bodyLineHeight": 1.3,
258
+ "bodyMaxWidth": 880,
259
+ "bodyColor": "rgba(255,255,255,0.75)",
260
+ "ctaGap": 24,
261
+ "ctaMarginTop": 6,
262
+ "noteSize": 21,
263
+ "noteColor": "rgba(255,255,255,0.6)",
264
+ "cardPad": "64px 64px 72px 64px",
265
+ "cardGap": 40
266
+ }
231
267
  }
232
268
  }
@@ -123,6 +123,10 @@ export function resolveAd(raw, placements, layouts) {
123
123
  theme: raw.theme || 'dark',
124
124
  lockup: raw.lockup || 'plain',
125
125
  photoUrl: raw.photoUrl || null,
126
+ // A MOTION creative's clip background (see motion.js/README): renderBoard renders a muted
127
+ // <video class="cc-bg-video"> over the photo area when this is set, alongside (in front of, as a
128
+ // fallback while it loads) whatever photoUrl is also set.
129
+ backgroundVideoUrl: raw.backgroundVideoUrl || null,
126
130
  headline: raw.headline || '',
127
131
  body: raw.body || '',
128
132
  caption: raw.caption || '',
@@ -286,6 +290,26 @@ function buildCta(brand, parent, placement) {
286
290
  return row;
287
291
  }
288
292
 
293
+ /* A MOTION creative's clip background: a muted, inline video layered over the photo area, honoring
294
+ * the same focal/object-fit every layout already gives its photo (`.cc-bg-video`'s CSS lives in
295
+ * frame.css — one generic rule for every layout — plus a small addition to each layout's own photo
296
+ * rule for the pan/zoom transform; see motion.js). Placed AFTER the photo element/background (which
297
+ * still renders as a same-frame fallback while the video loads) and BEFORE any scrim/panel, so it
298
+ * layers on top of the photo and under the copy without any explicit z-index. Muted: this element is
299
+ * never the audio source a viewer hears — the render job mixes audio into the exported MP4 itself
300
+ * (job/render.mjs); the browser preview never plays clip/track audio (see README). */
301
+ function buildBgVideo(parent, videoUrl) {
302
+ const video = el('video', 'cc-bg-video', parent);
303
+ video.src = videoUrl;
304
+ video.muted = true;
305
+ video.setAttribute('muted', '');
306
+ video.playsInline = true;
307
+ video.setAttribute('playsinline', '');
308
+ video.preload = 'auto';
309
+ video.setAttribute('preload', 'auto');
310
+ return video;
311
+ }
312
+
289
313
  /* Applies a headline size plus its derived leading and tracking. */
290
314
  function applyType(board, size, leading, tracking) {
291
315
  board.style.setProperty('--cc-h-size', size + 'px');
@@ -411,6 +435,9 @@ export function renderBoard(ad, placementKey, placements, layouts, brand, opts)
411
435
  let panel;
412
436
  if (photoType === 'background') {
413
437
  board.style.setProperty('--cc-photo', ad.photoUrl ? 'url("' + ad.photoUrl + '")' : 'none');
438
+ // The video (when present) must land BEFORE the panel, so the panel's own
439
+ // stacking (position:relative, later in the DOM) renders above it.
440
+ if (ad.backgroundVideoUrl) buildBgVideo(board, ad.backgroundVideoUrl);
414
441
  // The bottom panel variant hugs the bottom; the fill variant spreads its
415
442
  // copy over a full-artboard scrim.
416
443
  panel = el('div', 'cc-board__panel cc-board__panel--' + panelVariant, board);
@@ -422,6 +449,7 @@ export function renderBoard(ad, placementKey, placements, layouts, brand, opts)
422
449
  cimg.src = ad.photoUrl;
423
450
  cimg.alt = '';
424
451
  }
452
+ if (ad.backgroundVideoUrl) buildBgVideo(card, ad.backgroundVideoUrl);
425
453
  panel = el('div', 'cc-board__panel', board);
426
454
  } else {
427
455
  const band = el('div', 'cc-board__band', board);
@@ -430,6 +458,7 @@ export function renderBoard(ad, placementKey, placements, layouts, brand, opts)
430
458
  img.src = ad.photoUrl;
431
459
  img.alt = '';
432
460
  }
461
+ if (ad.backgroundVideoUrl) buildBgVideo(band, ad.backgroundVideoUrl);
433
462
  if (layouts[kind].bandScrim !== false) {
434
463
  el('div', 'cc-board__scrim', band);
435
464
  el('div', 'cc-board__glow', board);
@@ -7,6 +7,10 @@
7
7
  <style>
8
8
  html, body { margin: 0; background: #F2F2F7; font-family: system-ui, -apple-system, sans-serif; }
9
9
  #sheet { display: inline-flex; gap: 24px; align-items: flex-start; padding: 24px; }
10
+ /* A MOTION key-moments preview (sheet.js's `times` support): rows stack vertically, one per key
11
+ moment, each row itself a horizontal strip of that moment's enabled placements. */
12
+ #sheet.sheet--rows { flex-direction: column; align-items: flex-start; }
13
+ .row { display: flex; gap: 24px; align-items: flex-start; }
10
14
  .cell { display: flex; flex-direction: column; gap: 8px; align-items: center; }
11
15
  .shell { position: relative; overflow: hidden; border-radius: 6px; box-shadow: 0 3px 12px rgba(0,0,0,0.16); }
12
16
  .cap { font-size: 13px; font-weight: 700; color: #717171; }