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