@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.
- package/assets/claude/skills/conductor-creative/SKILL.md +57 -0
- package/assets/cli-manifest.json +6 -1
- package/dist/commands/creative.d.ts.map +1 -1
- package/dist/commands/creative.js +6 -1
- 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/placements.js +28 -0
- package/dist/creative/placements.json +36 -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 +13 -3
- package/dist/lib/creative-render.d.ts.map +1 -1
- package/dist/lib/creative-render.js +47 -5
- package/dist/lib/creative-render.js.map +1 -1
- package/dist/lib/media-probe.d.ts +55 -0
- package/dist/lib/media-probe.d.ts.map +1 -0
- package/dist/lib/media-probe.js +221 -0
- package/dist/lib/media-probe.js.map +1 -0
- package/dist/lib/poster-sheet.d.ts +10 -0
- package/dist/lib/poster-sheet.d.ts.map +1 -0
- package/dist/lib/poster-sheet.js +57 -0
- package/dist/lib/poster-sheet.js.map +1 -0
- package/dist/mcp/index.d.ts +10587 -0
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +93 -10
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp/tools/creatives.d.ts +29 -2
- package/dist/mcp/tools/creatives.d.ts.map +1 -1
- package/dist/mcp/tools/creatives.js +282 -35
- package/dist/mcp/tools/creatives.js.map +1 -1
- package/package.json +3 -1
- 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
|
|
20
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
6
|
-
|
|
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
|
}
|
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; }
|