@cliangdev/conductor 0.23.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.
- package/assets/claude/skills/conductor-creative/SKILL.md +33 -0
- package/dist/commands/creative.d.ts.map +1 -1
- package/dist/commands/creative.js +4 -2
- package/dist/commands/creative.js.map +1 -1
- package/dist/creative/assertions.js +4 -4
- package/dist/creative/frame.css +21 -0
- package/dist/creative/frame.js +39 -0
- package/dist/creative/job/render.mjs +261 -4
- package/dist/creative/job/transport.mjs +47 -13
- package/dist/creative/layouts/bleed/layout.css +6 -2
- package/dist/creative/layouts/card/layout.css +3 -0
- package/dist/creative/layouts/split/layout.css +3 -0
- package/dist/creative/layouts/stacked/layout.css +3 -0
- package/dist/creative/motion.js +347 -0
- package/dist/creative/render.js +29 -0
- package/dist/creative/sheet.html +4 -0
- package/dist/creative/sheet.js +39 -2
- package/dist/creative/styles.css +36 -2
- package/dist/lib/creative-render.d.ts +8 -4
- package/dist/lib/creative-render.d.ts.map +1 -1
- package/dist/lib/creative-render.js +29 -11
- package/dist/lib/creative-render.js.map +1 -1
- package/dist/mcp/index.d.ts +10587 -0
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +58 -4
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp/tools/creatives.d.ts +8 -2
- package/dist/mcp/tools/creatives.d.ts.map +1 -1
- package/dist/mcp/tools/creatives.js +69 -8
- package/dist/mcp/tools/creatives.js.map +1 -1
- package/package.json +1 -1
- package/scripts/build-creative.mjs +7 -2
|
@@ -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
|
+
}
|
package/dist/creative/render.js
CHANGED
|
@@ -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);
|
package/dist/creative/sheet.html
CHANGED
|
@@ -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; }
|
package/dist/creative/sheet.js
CHANGED
|
@@ -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})`;
|
package/dist/creative/styles.css
CHANGED
|
@@ -162,6 +162,27 @@
|
|
|
162
162
|
.cc-cta__note { font-weight: 600; font-size: var(--cc-note-size); color: var(--cc-note-color); }
|
|
163
163
|
.cc-board--light .cc-cta__note { color: var(--cc-ink-2); }
|
|
164
164
|
|
|
165
|
+
/* ── Background video (a MOTION clip background) ──────────────────────────── */
|
|
166
|
+
/* One generic rule for every layout: a band/card layout's photo container is
|
|
167
|
+
already `position:relative; overflow:hidden`, sized to the band/card; a
|
|
168
|
+
bleed layout has no such container, but `.cc-board` itself is
|
|
169
|
+
`position:relative; overflow:hidden` (see above), so the video simply
|
|
170
|
+
covers the whole artboard there instead. render.js always appends this
|
|
171
|
+
AFTER the still photo (a same-frame fallback while the video loads) and
|
|
172
|
+
BEFORE the panel/scrim, so plain DOM order — no z-index anywhere in this
|
|
173
|
+
file — keeps it under the copy and over the photo. `--cc-bg-transform` is
|
|
174
|
+
motion.js's Ken Burns pan/zoom; unset (a STILL board, which never calls
|
|
175
|
+
applyMotion) falls back to no transform at all. */
|
|
176
|
+
.cc-bg-video {
|
|
177
|
+
position: absolute; inset: 0;
|
|
178
|
+
width: 100%; height: 100%;
|
|
179
|
+
object-fit: cover;
|
|
180
|
+
object-position: var(--cc-focal, 50% 50%);
|
|
181
|
+
transform: var(--cc-bg-transform, none);
|
|
182
|
+
transform-origin: center center;
|
|
183
|
+
display: block;
|
|
184
|
+
}
|
|
185
|
+
|
|
165
186
|
/* ── Safe zones (review guide only, never exported) ───────────────────────── */
|
|
166
187
|
.cc-safe { position: absolute; inset: 0; pointer-events: none; }
|
|
167
188
|
.cc-safe__top { position: absolute; top: 0; left: 0; right: 0; height: var(--cc-safe-top);
|
|
@@ -187,6 +208,9 @@
|
|
|
187
208
|
width: 100%; height: 100%;
|
|
188
209
|
object-fit: cover;
|
|
189
210
|
object-position: var(--cc-focal, 50% 30%);
|
|
211
|
+
/* motion.js's Ken Burns pan/zoom (a MOTION creative only); unset for STILL. */
|
|
212
|
+
transform: var(--cc-bg-transform, none);
|
|
213
|
+
transform-origin: center center;
|
|
190
214
|
}
|
|
191
215
|
.cc-board__scrim {
|
|
192
216
|
position: absolute; left: 0; right: 0; bottom: 0; height: 220px;
|
|
@@ -219,8 +243,12 @@
|
|
|
219
243
|
|
|
220
244
|
.cc-board--bleed {
|
|
221
245
|
background-image: var(--cc-photo);
|
|
222
|
-
|
|
223
|
-
|
|
246
|
+
/* motion.js's Ken Burns pan/zoom (a MOTION creative only) sets --cc-bg-size/--cc-bg-pos on the
|
|
247
|
+
board; a background-image has no `transform`, so the zoom/pan is expressed as a growing
|
|
248
|
+
background-size and a shifting background-position instead. Unset for STILL: falls back to the
|
|
249
|
+
original fixed cover/focal. */
|
|
250
|
+
background-size: var(--cc-bg-size, cover);
|
|
251
|
+
background-position: var(--cc-bg-pos, var(--cc-focal, 50% 50%));
|
|
224
252
|
justify-content: flex-end;
|
|
225
253
|
}
|
|
226
254
|
|
|
@@ -282,6 +310,9 @@
|
|
|
282
310
|
width: 100%; height: 100%;
|
|
283
311
|
object-fit: cover;
|
|
284
312
|
object-position: var(--cc-focal, 50% 40%);
|
|
313
|
+
/* motion.js's Ken Burns pan/zoom (a MOTION creative only); unset for STILL. */
|
|
314
|
+
transform: var(--cc-bg-transform, none);
|
|
315
|
+
transform-origin: center center;
|
|
285
316
|
}
|
|
286
317
|
.cc-board--card .cc-board__panel {
|
|
287
318
|
flex: 1 1 auto; min-height: 0;
|
|
@@ -304,6 +335,9 @@
|
|
|
304
335
|
width: 100%; height: 100%;
|
|
305
336
|
object-fit: cover;
|
|
306
337
|
object-position: var(--cc-focal, 50% 30%);
|
|
338
|
+
/* motion.js's Ken Burns pan/zoom (a MOTION creative only); unset for STILL. */
|
|
339
|
+
transform: var(--cc-bg-transform, none);
|
|
340
|
+
transform-origin: center center;
|
|
307
341
|
}
|
|
308
342
|
.cc-board--split .cc-board__panel {
|
|
309
343
|
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
|
|
33
|
-
* the external API, reading back the stored render (frames with signed URLs,
|
|
34
|
-
* not the render itself succeeded — a FAILED render still has an id and an
|
|
35
|
-
* returning.
|
|
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;
|
|
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"}
|