@cliangdev/conductor 0.23.0 → 0.24.1

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.
@@ -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,354 @@
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
+ // Transforms don't apply to inline text, so the accent phrase's words are wrapped in inline-block
177
+ // spans (wrapping between words is unchanged) and each word scales about its own centre.
178
+ const em = headline.querySelector('em');
179
+ if (em && !em.querySelector('.cc-word')) wrapWordsInPlace(em);
180
+ const accentWords = em ? em.querySelectorAll('.cc-word') : [];
181
+ if (inEndCard) {
182
+ finalState(headline);
183
+ accentWords.forEach((w) => {
184
+ w.style.transform = 'none';
185
+ w.style.filter = 'none';
186
+ });
187
+ return;
188
+ }
189
+ const fadeProgress = windowProgress(tSec, ACCENT_POP_FADE_WINDOW[0], ACCENT_POP_FADE_WINDOW[1]);
190
+ setFade(headline, fadeProgress, null);
191
+ const popProgress = windowProgress(tSec, ACCENT_POP_POP_WINDOW[0], ACCENT_POP_POP_WINDOW[1]);
192
+ // Scale settles 1.18 → 1; brightness pulses up and back to 1, so the end card starts with no jump.
193
+ const scale = round2(lerp(1.18, 1, popProgress));
194
+ const brightness = round2(1 + 0.3 * Math.sin(Math.PI * popProgress));
195
+ accentWords.forEach((w) => {
196
+ w.style.transformOrigin = '50% 70%';
197
+ w.style.transform = scale === 1 ? 'none' : `scale(${scale})`;
198
+ w.style.filter = brightness === 1 ? 'none' : `brightness(${brightness})`;
199
+ });
200
+ return;
201
+ }
202
+
203
+ // 'fade-up' (default)
204
+ if (inEndCard) {
205
+ finalState(headline);
206
+ return;
207
+ }
208
+ const progress = windowProgress(tSec, HEADLINE_FADEUP_WINDOW[0], HEADLINE_FADEUP_WINDOW[1]);
209
+ setFade(headline, progress, FADE_UP_PX);
210
+ }
211
+
212
+ /* ── lockup / body / cta: a shared fade(-up) treatment ────────────────────── */
213
+
214
+ function applyFadeElement(board, selector, [start, end], tSec, preset, inEndCard, translateY) {
215
+ const el = board.querySelector(selector);
216
+ if (!el) return;
217
+ if (preset === 'none' || inEndCard) {
218
+ finalState(el);
219
+ return;
220
+ }
221
+ const progress = windowProgress(tSec, start, end);
222
+ setFade(el, progress, translateY);
223
+ }
224
+
225
+ /* ── background photo/clip motion ─────────────────────────────────────────── */
226
+
227
+ const ZOOM_IN = [1, 1.12];
228
+ const ZOOM_OUT = [1.12, 1];
229
+ const PAN_SCALE = 1.08;
230
+ const PAN_OFFSET_PCT = 4;
231
+
232
+ /** Pure: the background's scale + horizontal pan (percent, ± PAN_OFFSET_PCT)
233
+ * at `tSec` of `durationSec`, given `motion.background.motion`. Runs the
234
+ * whole duration, eased, independent of preset/end-card. Exported for tests
235
+ * and for anything that wants the numbers without touching the DOM. */
236
+ export function backgroundMotionState(motion, tSec, durationSec) {
237
+ const kind = (motion && motion.background && motion.background.motion) || 'zoom-in';
238
+ const progress = durationSec > 0 ? easeOutCubic(clamp01(tSec / durationSec)) : 0;
239
+ if (kind === 'zoom-in') return { scale: round2(lerp(ZOOM_IN[0], ZOOM_IN[1], progress)), panPct: 0 };
240
+ if (kind === 'zoom-out') return { scale: round2(lerp(ZOOM_OUT[0], ZOOM_OUT[1], progress)), panPct: 0 };
241
+ if (kind === 'pan-left') return { scale: PAN_SCALE, panPct: round2(lerp(PAN_OFFSET_PCT, -PAN_OFFSET_PCT, progress)) };
242
+ if (kind === 'pan-right') return { scale: PAN_SCALE, panPct: round2(lerp(-PAN_OFFSET_PCT, PAN_OFFSET_PCT, progress)) };
243
+ return { scale: 1, panPct: 0 }; // 'none'
244
+ }
245
+
246
+ /* "50% 30%" + a pan percent -> "54% 30%": keeps the focal point's own Y, only
247
+ * the X drifts (a Ken Burns pan is always horizontal here). */
248
+ function panBackgroundPosition(focal, panPct) {
249
+ const parts = String(focal || '50% 50%').trim().split(/\s+/);
250
+ const y = parts[1] || '50%';
251
+ return `${round2(50 + panPct)}% ${y}`;
252
+ }
253
+
254
+ function applyBackgroundMotion(board, motion, tSec, durationSec) {
255
+ const { scale, panPct } = backgroundMotionState(motion, tSec, durationSec);
256
+ // Consumed two ways, both with a no-op fallback so a STILL board (which
257
+ // never calls applyMotion at all) is untouched:
258
+ // - `.cc-board__band img` / `.cc-board__card img` / `.cc-bg-video`: a real
259
+ // transform (band/card layouts, and any layout's clip background video).
260
+ // - `.cc-board--bleed`'s own CSS `background-image`: background-size/
261
+ // -position, since `transform` does not apply to a background image.
262
+ board.style.setProperty('--cc-bg-transform', `scale(${scale}) translateX(${panPct}%)`);
263
+ board.style.setProperty('--cc-bg-size', `${round2(scale * 100)}%`);
264
+ const focal = board.style.getPropertyValue('--cc-focal') || '50% 50%';
265
+ board.style.setProperty('--cc-bg-pos', panBackgroundPosition(focal, panPct));
266
+ }
267
+
268
+ /* ── the public entry point ───────────────────────────────────────────────── */
269
+
270
+ /** Sets every inline style/CSS var a MOTION board needs to look right at
271
+ * `tSec`, on `board` (a live `.cc-board` from `renderBoard`, already
272
+ * `fitBoard`-ed). `motion` is `creative.motion` (see the header comment);
273
+ * `opts.durationSec` overrides `motion.durationSec` when the caller has
274
+ * already resolved it. Safe to call every frame, in any order of `tSec`
275
+ * (forward playback, scrubbing backward, or the render job's frame-stepped
276
+ * capture) — every call fully determines every value it touches. Returns a
277
+ * small summary (`{ preset, durationSec, inEndCard, background }`), mostly
278
+ * useful for tests. */
279
+ export function applyMotion(board, motion, tSec, opts) {
280
+ if (!board) return null;
281
+ motion = motion || {};
282
+ const durationSec = (opts && opts.durationSec) || motion.durationSec || 8;
283
+ const preset = motion.preset || 'fade-up';
284
+ const endCardEnabled = motion.endCard !== false;
285
+ const inEndCard = endCardEnabled && tSec >= durationSec - END_CARD_LEAD_SEC;
286
+
287
+ applyHeadline(board, preset, tSec, inEndCard);
288
+ applyFadeElement(board, '.cc-lockup', LOCKUP_WINDOW, tSec, preset, inEndCard, null);
289
+ applyFadeElement(board, '.cc-body', BODY_WINDOW, tSec, preset, inEndCard, FADE_UP_PX);
290
+ applyFadeElement(board, '.cc-cta', CTA_WINDOW, tSec, preset, inEndCard, FADE_UP_PX);
291
+
292
+ const background = backgroundMotionState(motion, tSec, durationSec);
293
+ applyBackgroundMotion(board, motion, tSec, durationSec);
294
+
295
+ return { preset, durationSec, inEndCard, background };
296
+ }
297
+
298
+ /** Three representative moments for a `previewOnly` MOTION key-moments sheet:
299
+ * an early beat (past the lockup/headline intro), the midpoint, and a late
300
+ * beat inside the end-card hold. `motion.durationSec` defaults to 8 like
301
+ * everywhere else. */
302
+ export function motionKeyTimes(motion) {
303
+ const durationSec = (motion && motion.durationSec) || 8;
304
+ const early = 0.6;
305
+ const mid = round2(durationSec / 2);
306
+ const late = round2(Math.max(early, durationSec - 0.5));
307
+ return [early, mid, late];
308
+ }
309
+
310
+ /* ── clip background video: shared by mount.js's live-preview seek() and the
311
+ * headless frame.js/job frame-stepped capture, so a clip background is
312
+ * frame-accurate in both. No-ops (resolves immediately) when `board` has no
313
+ * `.cc-bg-video` (a photo background, or a STILL board). */
314
+ export function seekBackgroundVideo(board, tSec, clipStartSec) {
315
+ const video = board && board.querySelector && board.querySelector('.cc-bg-video');
316
+ if (!video) return Promise.resolve();
317
+ const target = Math.max(0, (clipStartSec || 0) + (tSec || 0));
318
+ return new Promise((resolve) => {
319
+ let settled = false;
320
+ const done = () => {
321
+ if (settled) return;
322
+ settled = true;
323
+ video.removeEventListener('seeked', onSeeked);
324
+ resolve();
325
+ };
326
+ const onSeeked = () => done();
327
+ video.addEventListener('seeked', onSeeked);
328
+ // A video that never loads (a bad URL, an unreachable fixture in a test) must not hang the
329
+ // caller forever — a MOTION render with an unreachable clip should fail loudly downstream (a
330
+ // black/frozen frame trips the end-card assertions or an obviously wrong ffmpeg duration), not
331
+ // hang the job or the editor.
332
+ setTimeout(done, 4000);
333
+ try {
334
+ video.currentTime = target;
335
+ } catch {
336
+ done();
337
+ }
338
+ });
339
+ }
340
+
341
+
342
+ /* The API describes a clip background under `motion.background` ({source:'clip', clipUrl, clipStartSec});
343
+ * the engine reads `backgroundVideoUrl`/`clipStartSec` on the creative itself (the editor fills those
344
+ * directly). This bridges the two so a render spec from the API gets its clip background and clip sound. */
345
+ export function normalizeMotionCreative(creative) {
346
+ if (!creative || !creative.motion || !creative.motion.background) return creative;
347
+ const bg = creative.motion.background;
348
+ if (bg.source !== 'clip' || !bg.clipUrl) return creative;
349
+ return {
350
+ ...creative,
351
+ backgroundVideoUrl: creative.backgroundVideoUrl || bg.clipUrl,
352
+ clipStartSec: creative.clipStartSec != null ? creative.clipStartSec : (bg.clipStartSec || 0),
353
+ };
354
+ }
@@ -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; }
@@ -11,6 +11,11 @@
11
11
  * // the backend); ignored when the creative has a
12
12
  * // sequence, which always renders every beat
13
13
  * placements?, layouts?, // registry overrides (default: the shipped ones)
14
+ * times?: number[], // a MOTION creative's key moments (motion.js's
15
+ * // motionKeyTimes()): one ROW per time, each row
16
+ * // holding every placement in placementKeys at
17
+ * // that time. Ignored for a sequence creative
18
+ * // (MOTION creatives never carry a sequence).
14
19
  * }
15
20
  *
16
21
  * Result contract: window.__RENDER_RESULT = { ok: true, width, height } |
@@ -21,6 +26,7 @@ import { resolveAd, resolveSequence, renderBoard, fitAll } from './render.js';
21
26
  import { placements as DEFAULT_PLACEMENTS } from './placements.js';
22
27
  import { layouts as DEFAULT_LAYOUTS } from './layouts/index.js';
23
28
  import { ensureBrandFont } from './font.js';
29
+ import { applyMotion } from './motion.js';
24
30
 
25
31
  const SHEET_SCALE = 0.34;
26
32
 
@@ -39,8 +45,9 @@ async function main() {
39
45
  const layoutsReg = spec.layouts || DEFAULT_LAYOUTS;
40
46
  const sheet = document.getElementById('sheet');
41
47
  const boards = [];
48
+ const motionCells = []; // { board, time }, applied after fitAll (see below)
42
49
 
43
- function addCell(ad, placementKey, label) {
50
+ function addCell(ad, placementKey, label, container) {
44
51
  const placement = placementsReg[placementKey];
45
52
  if (!placement) throw new Error(`unknown placement "${placementKey}"`);
46
53
  const cell = document.createElement('div');
@@ -56,17 +63,40 @@ async function main() {
56
63
  cap.className = 'cap';
57
64
  cap.textContent = label;
58
65
  cell.appendChild(cap);
59
- sheet.appendChild(cell);
66
+ (container || sheet).appendChild(cell);
60
67
  boards.push(board);
68
+ return board;
61
69
  }
62
70
 
63
71
  try {
64
72
  await ensureBrandFont(spec.brand);
65
73
  const isSequence = spec.creative.sequenceKind && Array.isArray(spec.creative.sequence) && spec.creative.sequence.length;
74
+ const motion = spec.creative.motion;
75
+ const hasKeyTimes = !isSequence && Array.isArray(spec.times) && spec.times.length && motion;
76
+
66
77
  if (isSequence) {
67
78
  const frames = resolveSequence(spec.creative, placementsReg, layoutsReg);
68
79
  const key = spec.creative.sequenceKind === 'carousel' ? spec.creative.carouselRatio || '4x5' : 'story';
69
80
  frames.forEach((frame, i) => addCell(frame, key, `${i + 1} of ${frames.length}`));
81
+ } else if (hasKeyTimes) {
82
+ // One row per key moment, each row holding every enabled placement at that time — lets a human
83
+ // see how the motion progresses without downloading the full MP4 (see job/render.mjs's
84
+ // previewOnly MOTION path).
85
+ sheet.classList.add('sheet--rows');
86
+ const ad = resolveAd(spec.creative, placementsReg, layoutsReg);
87
+ const keys = Array.isArray(spec.placementKeys) && spec.placementKeys.length
88
+ ? spec.placementKeys
89
+ : Object.keys(placementsReg).filter((k) => placementsReg[k].default);
90
+ spec.times.forEach((t) => {
91
+ const row = document.createElement('div');
92
+ row.className = 'row';
93
+ sheet.appendChild(row);
94
+ keys.forEach((key) => {
95
+ const label = `${(placementsReg[key] && placementsReg[key].label) || key} · ${t.toFixed(1)}s`;
96
+ const board = addCell(ad, key, label, row);
97
+ motionCells.push({ board, time: t });
98
+ });
99
+ });
70
100
  } else {
71
101
  const ad = resolveAd(spec.creative, placementsReg, layoutsReg);
72
102
  const keys = Array.isArray(spec.placementKeys) && spec.placementKeys.length
@@ -79,6 +109,13 @@ async function main() {
79
109
  // shrinks the measured rect and would throw fitBoard's line-count math
80
110
  // off), then apply the display scale — the same order mount.js uses.
81
111
  await fitAll(sheet, placementsReg);
112
+
113
+ // Only after fitting (motion's translateY offsets are fixed px, independent of the fitted
114
+ // headline size, but applying it before fitBoard risked the word-span wrapping interacting with
115
+ // the auto-fit measurement — see motion.js).
116
+ const durationSec = (motion && motion.durationSec) || 8;
117
+ motionCells.forEach(({ board, time }) => applyMotion(board, motion, time, { durationSec }));
118
+
82
119
  boards.forEach((board) => {
83
120
  board.style.transformOrigin = 'top left';
84
121
  board.style.transform = `scale(${SHEET_SCALE})`;
@@ -134,6 +134,9 @@
134
134
  }
135
135
  .cc-board--light .cc-headline { color: var(--cc-ink); }
136
136
  .cc-headline em { font-style: italic; color: var(--cc-accent); }
137
+ /* Word spans the motion presets animate: inline-block so transforms apply; the spaces between them stay
138
+ * plain text, so lines wrap exactly as they do without the spans. */
139
+ .cc-word { display: inline-block; }
137
140
 
138
141
  /* ── Body ─────────────────────────────────────────────────────────────────── */
139
142
  .cc-body {
@@ -162,6 +165,27 @@
162
165
  .cc-cta__note { font-weight: 600; font-size: var(--cc-note-size); color: var(--cc-note-color); }
163
166
  .cc-board--light .cc-cta__note { color: var(--cc-ink-2); }
164
167
 
168
+ /* ── Background video (a MOTION clip background) ──────────────────────────── */
169
+ /* One generic rule for every layout: a band/card layout's photo container is
170
+ already `position:relative; overflow:hidden`, sized to the band/card; a
171
+ bleed layout has no such container, but `.cc-board` itself is
172
+ `position:relative; overflow:hidden` (see above), so the video simply
173
+ covers the whole artboard there instead. render.js always appends this
174
+ AFTER the still photo (a same-frame fallback while the video loads) and
175
+ BEFORE the panel/scrim, so plain DOM order — no z-index anywhere in this
176
+ file — keeps it under the copy and over the photo. `--cc-bg-transform` is
177
+ motion.js's Ken Burns pan/zoom; unset (a STILL board, which never calls
178
+ applyMotion) falls back to no transform at all. */
179
+ .cc-bg-video {
180
+ position: absolute; inset: 0;
181
+ width: 100%; height: 100%;
182
+ object-fit: cover;
183
+ object-position: var(--cc-focal, 50% 50%);
184
+ transform: var(--cc-bg-transform, none);
185
+ transform-origin: center center;
186
+ display: block;
187
+ }
188
+
165
189
  /* ── Safe zones (review guide only, never exported) ───────────────────────── */
166
190
  .cc-safe { position: absolute; inset: 0; pointer-events: none; }
167
191
  .cc-safe__top { position: absolute; top: 0; left: 0; right: 0; height: var(--cc-safe-top);
@@ -187,6 +211,9 @@
187
211
  width: 100%; height: 100%;
188
212
  object-fit: cover;
189
213
  object-position: var(--cc-focal, 50% 30%);
214
+ /* motion.js's Ken Burns pan/zoom (a MOTION creative only); unset for STILL. */
215
+ transform: var(--cc-bg-transform, none);
216
+ transform-origin: center center;
190
217
  }
191
218
  .cc-board__scrim {
192
219
  position: absolute; left: 0; right: 0; bottom: 0; height: 220px;
@@ -219,8 +246,12 @@
219
246
 
220
247
  .cc-board--bleed {
221
248
  background-image: var(--cc-photo);
222
- background-size: cover;
223
- background-position: var(--cc-focal, 50% 50%);
249
+ /* motion.js's Ken Burns pan/zoom (a MOTION creative only) sets --cc-bg-size/--cc-bg-pos on the
250
+ board; a background-image has no `transform`, so the zoom/pan is expressed as a growing
251
+ background-size and a shifting background-position instead. Unset for STILL: falls back to the
252
+ original fixed cover/focal. */
253
+ background-size: var(--cc-bg-size, cover);
254
+ background-position: var(--cc-bg-pos, var(--cc-focal, 50% 50%));
224
255
  justify-content: flex-end;
225
256
  }
226
257
 
@@ -282,6 +313,9 @@
282
313
  width: 100%; height: 100%;
283
314
  object-fit: cover;
284
315
  object-position: var(--cc-focal, 50% 40%);
316
+ /* motion.js's Ken Burns pan/zoom (a MOTION creative only); unset for STILL. */
317
+ transform: var(--cc-bg-transform, none);
318
+ transform-origin: center center;
285
319
  }
286
320
  .cc-board--card .cc-board__panel {
287
321
  flex: 1 1 auto; min-height: 0;
@@ -304,6 +338,9 @@
304
338
  width: 100%; height: 100%;
305
339
  object-fit: cover;
306
340
  object-position: var(--cc-focal, 50% 30%);
341
+ /* motion.js's Ken Burns pan/zoom (a MOTION creative only); unset for STILL. */
342
+ transform: var(--cc-bg-transform, none);
343
+ transform-origin: center center;
307
344
  }
308
345
  .cc-board--split .cc-board__panel {
309
346
  flex: 1 1 auto; min-height: 0;
@@ -29,10 +29,14 @@ export interface RenderCreativeResult {
29
29
  frames: RenderFrame[];
30
30
  }
31
31
  /**
32
- * Renders a Creative end to end. STILL creatives find a local browser and run the render core against
33
- * the external API, reading back the stored render (frames with signed URLs, final state) whether or
34
- * not the render itself succeeded — a FAILED render still has an id and an error message worth
35
- * returning. CLIP (and MOTION) creatives never launch a browser — see {@link renderViaApi}.
32
+ * Renders a Creative end to end. STILL and MOTION creatives both find a local browser and run the
33
+ * render core against the external API, reading back the stored render (frames with signed URLs,
34
+ * final state) whether or not the render itself succeeded — a FAILED render still has an id and an
35
+ * error message worth returning. A MOTION render (not previewOnly) additionally needs a local ffmpeg
36
+ * binary — resolved the same way `upload_creative_media`/`media-probe.ts` finds one (a system binary
37
+ * on PATH first, else the bundled `ffmpeg-static` fallback) — and runs at a fixed 30fps; a previewOnly
38
+ * MOTION render (the key-moments contact sheet) needs neither ffmpeg nor a video encode, so it skips
39
+ * this resolution entirely. CLIP creatives never launch a browser — see {@link renderViaApi}.
36
40
  */
37
41
  export declare function renderCreative(params: RenderCreativeParams, config: Config, log?: (...args: unknown[]) => void): Promise<RenderCreativeResult>;
38
42
  //# sourceMappingURL=creative-render.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"creative-render.d.ts","sourceRoot":"","sources":["../../src/lib/creative-render.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAI9C,MAAM,WAAW,WAAW;IAC1B,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,YAAY,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAA;IACnB,0GAA0G;IAC1G,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,MAAM,WAAW,oBAAoB;IACnC,UAAU,EAAE,MAAM,CAAA;IAClB,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,aAAa,CAAC,EAAE,MAAM,CAAA;CACvB;AAED,MAAM,WAAW,oBAAoB;IACnC,EAAE,EAAE,OAAO,CAAA;IACX,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,MAAM,EAAE,WAAW,EAAE,CAAA;CACtB;AA+ED;;;;;GAKG;AACH,wBAAsB,cAAc,CAClC,MAAM,EAAE,oBAAoB,EAC5B,MAAM,EAAE,MAAM,EACd,GAAG,GAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAe,GAC3C,OAAO,CAAC,oBAAoB,CAAC,CAyC/B"}
1
+ {"version":3,"file":"creative-render.d.ts","sourceRoot":"","sources":["../../src/lib/creative-render.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAS9C,MAAM,WAAW,WAAW;IAC1B,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,YAAY,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAA;IACnB,0GAA0G;IAC1G,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,MAAM,WAAW,oBAAoB;IACnC,UAAU,EAAE,MAAM,CAAA;IAClB,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,aAAa,CAAC,EAAE,MAAM,CAAA;CACvB;AAED,MAAM,WAAW,oBAAoB;IACnC,EAAE,EAAE,OAAO,CAAA;IACX,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,MAAM,EAAE,WAAW,EAAE,CAAA;CACtB;AAkFD;;;;;;;;;GASG;AACH,wBAAsB,cAAc,CAClC,MAAM,EAAE,oBAAoB,EAC5B,MAAM,EAAE,MAAM,EACd,GAAG,GAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAe,GAC3C,OAAO,CAAC,oBAAoB,CAAC,CAoD/B"}