@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
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;
|
|
@@ -9,6 +9,11 @@ export interface RenderFrame {
|
|
|
9
9
|
height?: number;
|
|
10
10
|
sizeBytes?: number;
|
|
11
11
|
warnings?: string[];
|
|
12
|
+
/** Video-frame fields — only present on a CLIP creative's frames, which reference a video, not a JPEG. */
|
|
13
|
+
durationSeconds?: number;
|
|
14
|
+
hasAudio?: boolean;
|
|
15
|
+
posterUrl?: string;
|
|
16
|
+
contentType?: string;
|
|
12
17
|
}
|
|
13
18
|
export interface RenderCreativeParams {
|
|
14
19
|
creativeId: string;
|
|
@@ -24,9 +29,14 @@ export interface RenderCreativeResult {
|
|
|
24
29
|
frames: RenderFrame[];
|
|
25
30
|
}
|
|
26
31
|
/**
|
|
27
|
-
* Renders a Creative end to end
|
|
28
|
-
* API,
|
|
29
|
-
* render itself succeeded — a FAILED render still has an id and an
|
|
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}.
|
|
30
40
|
*/
|
|
31
41
|
export declare function renderCreative(params: RenderCreativeParams, config: Config, log?: (...args: unknown[]) => void): Promise<RenderCreativeResult>;
|
|
32
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"}
|
|
@@ -10,8 +10,12 @@
|
|
|
10
10
|
import { existsSync } from 'node:fs';
|
|
11
11
|
import { dirname, join } from 'node:path';
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
|
-
import { apiGet } from '../mcp/api.js';
|
|
13
|
+
import { apiGet, apiPost, ApiError } from '../mcp/api.js';
|
|
14
14
|
import { findBrowserFactory } from './creative-browser.js';
|
|
15
|
+
import { resolveFfmpegPath } from './media-probe.js';
|
|
16
|
+
/** Frames per second for a MOTION render's ffmpeg encode — fixed, not user-configurable (see
|
|
17
|
+
* conductor-creative/README.md's job/render.mjs section). */
|
|
18
|
+
const MOTION_FPS = 30;
|
|
15
19
|
/** Where `npm run build` copies `conductor-creative/`'s runtime files, relative to this file's own
|
|
16
20
|
* compiled location (`dist/lib/creative-render.js` -> `dist/creative/job/`). */
|
|
17
21
|
function jobDir() {
|
|
@@ -34,11 +38,47 @@ async function loadRenderCore() {
|
|
|
34
38
|
return { run: renderMod.run, createApiTransport: transportMod.createApiTransport };
|
|
35
39
|
}
|
|
36
40
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
41
|
+
* A CLIP creative has no local browser render path at all — it's a finished video used as-is, and
|
|
42
|
+
* the backend assembles its frames itself synchronously. This just POSTs the render and relays what
|
|
43
|
+
* came back — `ApiError`'s `message` is already the server's own plain-English sentence (an RFC 7807
|
|
44
|
+
* `detail`), so a failure reaches the caller unchanged rather than wrapped in a generic message.
|
|
45
|
+
*/
|
|
46
|
+
async function renderViaApi(params, config) {
|
|
47
|
+
try {
|
|
48
|
+
const detail = await apiPost(`/api/v2/projects/${config.projectId}/marketing/creatives/${params.creativeId}/renders`, { previewOnly: !!params.previewOnly, renderer: params.renderer, workflowRunId: params.workflowRunId }, config);
|
|
49
|
+
return { ok: detail.state === 'SUCCEEDED', renderId: detail.id, state: detail.state, error: detail.error ?? null, frames: detail.frames ?? [] };
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
if (err instanceof ApiError) {
|
|
53
|
+
return { ok: false, error: err.message, frames: [] };
|
|
54
|
+
}
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Renders a Creative end to end. STILL and MOTION creatives both find a local browser and run the
|
|
60
|
+
* render core against the external API, reading back the stored render (frames with signed URLs,
|
|
61
|
+
* final state) whether or not the render itself succeeded — a FAILED render still has an id and an
|
|
62
|
+
* error message worth returning. A MOTION render (not previewOnly) additionally needs a local ffmpeg
|
|
63
|
+
* binary — resolved the same way `upload_creative_media`/`media-probe.ts` finds one (a system binary
|
|
64
|
+
* on PATH first, else the bundled `ffmpeg-static` fallback) — and runs at a fixed 30fps; a previewOnly
|
|
65
|
+
* MOTION render (the key-moments contact sheet) needs neither ffmpeg nor a video encode, so it skips
|
|
66
|
+
* this resolution entirely. CLIP creatives never launch a browser — see {@link renderViaApi}.
|
|
40
67
|
*/
|
|
41
68
|
export async function renderCreative(params, config, log = () => { }) {
|
|
69
|
+
const creative = await apiGet(`/api/v2/projects/${config.projectId}/marketing/creatives/${params.creativeId}`, config);
|
|
70
|
+
if (creative.kind === 'CLIP') {
|
|
71
|
+
return renderViaApi(params, config);
|
|
72
|
+
}
|
|
73
|
+
let ffmpegPath;
|
|
74
|
+
if (creative.kind === 'MOTION' && !params.previewOnly) {
|
|
75
|
+
try {
|
|
76
|
+
ffmpegPath = await resolveFfmpegPath();
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err), frames: [] };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
42
82
|
const { run, createApiTransport } = await loadRenderCore();
|
|
43
83
|
const transport = createApiTransport({
|
|
44
84
|
apiUrl: config.apiUrl,
|
|
@@ -50,7 +90,9 @@ export async function renderCreative(params, config, log = () => { }) {
|
|
|
50
90
|
workflowRunId: params.workflowRunId,
|
|
51
91
|
});
|
|
52
92
|
const browserFactory = await findBrowserFactory(log);
|
|
53
|
-
|
|
93
|
+
// fps only matters on the MOTION encode path (run() ignores it otherwise); passing it
|
|
94
|
+
// unconditionally keeps this call site simple and matches the engine's own default.
|
|
95
|
+
const ok = await run({ transport, browserFactory, log, ffmpegPath, fps: MOTION_FPS });
|
|
54
96
|
const renderId = transport.getRenderId();
|
|
55
97
|
if (!renderId) {
|
|
56
98
|
return { ok: false, error: 'The render was never started — the API rejected the request before a render id was assigned.', frames: [] };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"creative-render.js","sourceRoot":"","sources":["../../src/lib/creative-render.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAA;AACpC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAA;AACzC,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AAExC,OAAO,EAAE,MAAM,EAAE,MAAM,eAAe,CAAA;
|
|
1
|
+
{"version":3,"file":"creative-render.js","sourceRoot":"","sources":["../../src/lib/creative-render.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export type MediaKind = 'IMAGE' | 'VIDEO' | 'AUDIO';
|
|
2
|
+
export interface MediaProbeResult {
|
|
3
|
+
kind: MediaKind;
|
|
4
|
+
contentType: string;
|
|
5
|
+
width?: number;
|
|
6
|
+
height?: number;
|
|
7
|
+
durationSeconds?: number;
|
|
8
|
+
hasAudio?: boolean;
|
|
9
|
+
codec?: string;
|
|
10
|
+
}
|
|
11
|
+
interface FfprobeStream {
|
|
12
|
+
codec_type?: string;
|
|
13
|
+
codec_name?: string;
|
|
14
|
+
width?: number;
|
|
15
|
+
height?: number;
|
|
16
|
+
duration?: string;
|
|
17
|
+
nb_frames?: string;
|
|
18
|
+
disposition?: {
|
|
19
|
+
attached_pic?: number;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
interface FfprobeFormat {
|
|
23
|
+
duration?: string;
|
|
24
|
+
format_name?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface FfprobeJson {
|
|
27
|
+
streams?: FfprobeStream[];
|
|
28
|
+
format?: FfprobeFormat;
|
|
29
|
+
}
|
|
30
|
+
export declare const NO_FFPROBE_HINT: string;
|
|
31
|
+
export declare const NO_FFMPEG_HINT: string;
|
|
32
|
+
/**
|
|
33
|
+
* Pure parsing of an already-fetched `ffprobe -show_streams -show_format` JSON document — split out
|
|
34
|
+
* from `probeMedia` so tests can exercise it against fixture JSON without spawning a real process.
|
|
35
|
+
*
|
|
36
|
+
* A video stream marked `disposition.attached_pic` is cover art embedded in an audio file (common in
|
|
37
|
+
* mp3/m4a), not a picture track — it's excluded from the video-stream check so a song with cover art
|
|
38
|
+
* doesn't get classified as VIDEO. A video stream with no duration and at most one frame is a still
|
|
39
|
+
* image ffprobe happened to be pointed at (format `image2`/`png_pipe`/etc.), not a video.
|
|
40
|
+
*/
|
|
41
|
+
export declare function parseProbeJson(json: FfprobeJson, filePath: string): MediaProbeResult;
|
|
42
|
+
export declare function resolveFfprobePath(): Promise<string>;
|
|
43
|
+
export declare function resolveFfmpegPath(): Promise<string>;
|
|
44
|
+
/** Test-only: binary discovery caches its result for the process lifetime (real launches are slow
|
|
45
|
+
* enough to matter within one CLI/MCP run), so tests that vary system-binary availability need to
|
|
46
|
+
* reset it between cases. */
|
|
47
|
+
export declare function resetMediaBinaryCache(): void;
|
|
48
|
+
/** Probes a local media file with ffprobe and classifies it as IMAGE, VIDEO or AUDIO. Throws
|
|
49
|
+
* `NO_FFPROBE_HINT` when no usable ffprobe binary can be found at all. */
|
|
50
|
+
export declare function probeMedia(filePath: string): Promise<MediaProbeResult>;
|
|
51
|
+
/** Extracts a single JPEG poster frame from a video at `atSeconds` (default 1s). Falls back to 0s
|
|
52
|
+
* when the clip is shorter than that (or the seek otherwise fails) rather than giving up. */
|
|
53
|
+
export declare function extractPoster(videoPath: string, outJpg: string, atSeconds?: number): Promise<void>;
|
|
54
|
+
export {};
|
|
55
|
+
//# sourceMappingURL=media-probe.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"media-probe.d.ts","sourceRoot":"","sources":["../../src/lib/media-probe.ts"],"names":[],"mappings":"AAqBA,MAAM,MAAM,SAAS,GAAG,OAAO,GAAG,OAAO,GAAG,OAAO,CAAA;AAEnD,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,SAAS,CAAA;IACf,WAAW,EAAE,MAAM,CAAA;IACnB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,UAAU,aAAa;IACrB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,WAAW,CAAC,EAAE;QAAE,YAAY,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CACxC;AAED,UAAU,aAAa;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,MAAM,WAAW,WAAW;IAC1B,OAAO,CAAC,EAAE,aAAa,EAAE,CAAA;IACzB,MAAM,CAAC,EAAE,aAAa,CAAA;CACvB;AAED,eAAO,MAAM,eAAe,QAE8D,CAAA;AAC1F,eAAO,MAAM,cAAc,QAEiD,CAAA;AA0B5E;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,GAAG,gBAAgB,CAmCpF;AA8BD,wBAAsB,kBAAkB,IAAI,OAAO,CAAC,MAAM,CAAC,CAiB1D;AAED,wBAAsB,iBAAiB,IAAI,OAAO,CAAC,MAAM,CAAC,CAgBzD;AAED;;6BAE6B;AAC7B,wBAAgB,qBAAqB,IAAI,IAAI,CAG5C;AAwBD;0EAC0E;AAC1E,wBAAsB,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAI5E;AAgBD;6FAC6F;AAC7F,wBAAsB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,SAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAQnG"}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inspects and extracts frames from video/audio Creative media with ffprobe/ffmpeg: a system binary
|
|
3
|
+
* on PATH first (respects whatever the user already has — no bundled Chromium-sized re-download),
|
|
4
|
+
* else the ones bundled by `ffprobe-static`/`ffmpeg-static` (lazy-imported so requiring them doesn't
|
|
5
|
+
* add to MCP server startup cost for sessions that never touch a video/audio Creative). Mirrors the
|
|
6
|
+
* browser discovery in creative-browser.ts.
|
|
7
|
+
*
|
|
8
|
+
* `upload_creative_media` uses `probeMedia` for VIDEO/AUDIO (images keep the existing magic-byte
|
|
9
|
+
* header parse in image-dimensions.ts — no decode needed there); it and `render_creative`/CLIP use
|
|
10
|
+
* `extractPoster` for a VIDEO's poster JPEG.
|
|
11
|
+
*/
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import { existsSync } from 'node:fs';
|
|
14
|
+
import { extname } from 'node:path';
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
// ffprobe-static/ffmpeg-static are plain CJS with no published types; `require` (rather than a typed
|
|
17
|
+
// `import`) keeps them lazy (only resolved the first time a binary is actually needed) without fighting
|
|
18
|
+
// TypeScript over declarations for an untyped module.
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
export const NO_FFPROBE_HINT = 'No ffprobe could be found to inspect this media. Install ffmpeg (it includes ffprobe) system-wide, ' +
|
|
21
|
+
'or reinstall @cliangdev/conductor so its bundled ffprobe-static dependency is present.';
|
|
22
|
+
export const NO_FFMPEG_HINT = 'No ffmpeg could be found to process this media. Install ffmpeg system-wide, or reinstall ' +
|
|
23
|
+
'@cliangdev/conductor so its bundled ffmpeg-static dependency is present.';
|
|
24
|
+
const EXTENSION_CONTENT_TYPES = {
|
|
25
|
+
'.mp4': 'video/mp4',
|
|
26
|
+
'.m4v': 'video/mp4',
|
|
27
|
+
'.mov': 'video/quicktime',
|
|
28
|
+
'.webm': 'video/webm',
|
|
29
|
+
'.mp3': 'audio/mpeg',
|
|
30
|
+
'.m4a': 'audio/mp4',
|
|
31
|
+
'.aac': 'audio/aac',
|
|
32
|
+
'.wav': 'audio/wav',
|
|
33
|
+
'.png': 'image/png',
|
|
34
|
+
'.jpg': 'image/jpeg',
|
|
35
|
+
'.jpeg': 'image/jpeg',
|
|
36
|
+
'.webp': 'image/webp',
|
|
37
|
+
};
|
|
38
|
+
function contentTypeFor(kind, filePath, codecName) {
|
|
39
|
+
const ext = extname(filePath).toLowerCase();
|
|
40
|
+
const byExtension = EXTENSION_CONTENT_TYPES[ext];
|
|
41
|
+
if (byExtension)
|
|
42
|
+
return byExtension;
|
|
43
|
+
if (kind === 'VIDEO')
|
|
44
|
+
return 'video/mp4';
|
|
45
|
+
if (kind === 'AUDIO')
|
|
46
|
+
return codecName === 'mp3' ? 'audio/mpeg' : 'audio/mp4';
|
|
47
|
+
return codecName === 'png' ? 'image/png' : 'image/jpeg';
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Pure parsing of an already-fetched `ffprobe -show_streams -show_format` JSON document — split out
|
|
51
|
+
* from `probeMedia` so tests can exercise it against fixture JSON without spawning a real process.
|
|
52
|
+
*
|
|
53
|
+
* A video stream marked `disposition.attached_pic` is cover art embedded in an audio file (common in
|
|
54
|
+
* mp3/m4a), not a picture track — it's excluded from the video-stream check so a song with cover art
|
|
55
|
+
* doesn't get classified as VIDEO. A video stream with no duration and at most one frame is a still
|
|
56
|
+
* image ffprobe happened to be pointed at (format `image2`/`png_pipe`/etc.), not a video.
|
|
57
|
+
*/
|
|
58
|
+
export function parseProbeJson(json, filePath) {
|
|
59
|
+
const streams = json.streams ?? [];
|
|
60
|
+
const format = json.format ?? {};
|
|
61
|
+
const videoStreams = streams.filter((s) => s.codec_type === 'video' && s.disposition?.attached_pic !== 1);
|
|
62
|
+
const audioStreams = streams.filter((s) => s.codec_type === 'audio');
|
|
63
|
+
if (videoStreams.length > 0) {
|
|
64
|
+
const v = videoStreams[0];
|
|
65
|
+
const durationRaw = format.duration ?? v.duration;
|
|
66
|
+
const duration = durationRaw ? parseFloat(durationRaw) : undefined;
|
|
67
|
+
const nbFrames = v.nb_frames ? parseInt(v.nb_frames, 10) : undefined;
|
|
68
|
+
const looksLikeStillImage = !duration && (nbFrames === undefined || nbFrames <= 1);
|
|
69
|
+
if (looksLikeStillImage && audioStreams.length === 0) {
|
|
70
|
+
return { kind: 'IMAGE', contentType: contentTypeFor('IMAGE', filePath, v.codec_name), width: v.width, height: v.height, codec: v.codec_name };
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
kind: 'VIDEO',
|
|
74
|
+
contentType: contentTypeFor('VIDEO', filePath, v.codec_name),
|
|
75
|
+
width: v.width,
|
|
76
|
+
height: v.height,
|
|
77
|
+
durationSeconds: duration,
|
|
78
|
+
hasAudio: audioStreams.length > 0,
|
|
79
|
+
codec: v.codec_name,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
if (audioStreams.length > 0) {
|
|
83
|
+
const a = audioStreams[0];
|
|
84
|
+
const durationRaw = format.duration ?? a.duration;
|
|
85
|
+
const duration = durationRaw ? parseFloat(durationRaw) : undefined;
|
|
86
|
+
return { kind: 'AUDIO', contentType: contentTypeFor('AUDIO', filePath, a.codec_name), durationSeconds: duration, codec: a.codec_name };
|
|
87
|
+
}
|
|
88
|
+
throw new Error(`ffprobe found no usable video or audio stream in "${filePath}"`);
|
|
89
|
+
}
|
|
90
|
+
let cachedFfprobePath;
|
|
91
|
+
let cachedFfmpegPath;
|
|
92
|
+
/** True once the named command launches at all (any exit code) rather than failing to spawn
|
|
93
|
+
* (ENOENT) — that's enough to know it's a real binary on PATH, without caring what `-version`
|
|
94
|
+
* prints. Bounded by a timeout so a binary that launches but hangs doesn't wedge discovery forever. */
|
|
95
|
+
function systemBinaryAvailable(command) {
|
|
96
|
+
return new Promise((resolve) => {
|
|
97
|
+
let settled = false;
|
|
98
|
+
const finish = (ok) => {
|
|
99
|
+
if (settled)
|
|
100
|
+
return;
|
|
101
|
+
settled = true;
|
|
102
|
+
resolve(ok);
|
|
103
|
+
};
|
|
104
|
+
let child;
|
|
105
|
+
try {
|
|
106
|
+
child = spawn(command, ['-version'], { stdio: 'ignore' });
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
finish(false);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
child.on('error', () => finish(false));
|
|
113
|
+
child.on('exit', (code) => finish(code === 0));
|
|
114
|
+
const timer = setTimeout(() => finish(false), 5000);
|
|
115
|
+
timer.unref?.();
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
export async function resolveFfprobePath() {
|
|
119
|
+
if (cachedFfprobePath)
|
|
120
|
+
return cachedFfprobePath;
|
|
121
|
+
if (await systemBinaryAvailable('ffprobe')) {
|
|
122
|
+
cachedFfprobePath = 'ffprobe';
|
|
123
|
+
return cachedFfprobePath;
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
const mod = require('ffprobe-static');
|
|
127
|
+
const bundledPath = mod.path;
|
|
128
|
+
if (bundledPath && existsSync(bundledPath)) {
|
|
129
|
+
cachedFfprobePath = bundledPath;
|
|
130
|
+
return bundledPath;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// fall through to the error below
|
|
135
|
+
}
|
|
136
|
+
throw new Error(NO_FFPROBE_HINT);
|
|
137
|
+
}
|
|
138
|
+
export async function resolveFfmpegPath() {
|
|
139
|
+
if (cachedFfmpegPath)
|
|
140
|
+
return cachedFfmpegPath;
|
|
141
|
+
if (await systemBinaryAvailable('ffmpeg')) {
|
|
142
|
+
cachedFfmpegPath = 'ffmpeg';
|
|
143
|
+
return cachedFfmpegPath;
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
const bundledPath = require('ffmpeg-static');
|
|
147
|
+
if (bundledPath && existsSync(bundledPath)) {
|
|
148
|
+
cachedFfmpegPath = bundledPath;
|
|
149
|
+
return bundledPath;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// fall through to the error below
|
|
154
|
+
}
|
|
155
|
+
throw new Error(NO_FFMPEG_HINT);
|
|
156
|
+
}
|
|
157
|
+
/** Test-only: binary discovery caches its result for the process lifetime (real launches are slow
|
|
158
|
+
* enough to matter within one CLI/MCP run), so tests that vary system-binary availability need to
|
|
159
|
+
* reset it between cases. */
|
|
160
|
+
export function resetMediaBinaryCache() {
|
|
161
|
+
cachedFfprobePath = undefined;
|
|
162
|
+
cachedFfmpegPath = undefined;
|
|
163
|
+
}
|
|
164
|
+
function runFfprobe(ffprobePath, filePath) {
|
|
165
|
+
return new Promise((resolve, reject) => {
|
|
166
|
+
const child = spawn(ffprobePath, ['-v', 'error', '-print_format', 'json', '-show_streams', '-show_format', filePath]);
|
|
167
|
+
let stdout = '';
|
|
168
|
+
let stderr = '';
|
|
169
|
+
child.stdout.on('data', (d) => (stdout += d));
|
|
170
|
+
child.stderr.on('data', (d) => (stderr += d));
|
|
171
|
+
child.on('error', reject);
|
|
172
|
+
child.on('exit', (code) => {
|
|
173
|
+
if (code !== 0) {
|
|
174
|
+
reject(new Error(`ffprobe failed (exit ${code}) for "${filePath}": ${stderr.trim() || 'no error output'}`));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
resolve(JSON.parse(stdout));
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
reject(new Error(`ffprobe produced unparsable JSON for "${filePath}": ${err instanceof Error ? err.message : String(err)}`));
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
/** Probes a local media file with ffprobe and classifies it as IMAGE, VIDEO or AUDIO. Throws
|
|
187
|
+
* `NO_FFPROBE_HINT` when no usable ffprobe binary can be found at all. */
|
|
188
|
+
export async function probeMedia(filePath) {
|
|
189
|
+
const ffprobePath = await resolveFfprobePath();
|
|
190
|
+
const json = await runFfprobe(ffprobePath, filePath);
|
|
191
|
+
return parseProbeJson(json, filePath);
|
|
192
|
+
}
|
|
193
|
+
function runFfmpegPoster(ffmpegPath, videoPath, outJpg, atSeconds) {
|
|
194
|
+
return new Promise((resolve, reject) => {
|
|
195
|
+
const args = ['-y', '-ss', String(atSeconds), '-i', videoPath, '-frames:v', '1', '-q:v', '2', outJpg];
|
|
196
|
+
const child = spawn(ffmpegPath, args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
197
|
+
let stderr = '';
|
|
198
|
+
child.stderr.on('data', (d) => (stderr += d));
|
|
199
|
+
child.on('error', reject);
|
|
200
|
+
child.on('exit', (code) => {
|
|
201
|
+
if (code === 0 && existsSync(outJpg))
|
|
202
|
+
resolve();
|
|
203
|
+
else
|
|
204
|
+
reject(new Error(`ffmpeg poster extraction failed (exit ${code}) for "${videoPath}": ${stderr.trim().slice(-2000) || 'no error output'}`));
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
/** Extracts a single JPEG poster frame from a video at `atSeconds` (default 1s). Falls back to 0s
|
|
209
|
+
* when the clip is shorter than that (or the seek otherwise fails) rather than giving up. */
|
|
210
|
+
export async function extractPoster(videoPath, outJpg, atSeconds = 1) {
|
|
211
|
+
const ffmpegPath = await resolveFfmpegPath();
|
|
212
|
+
try {
|
|
213
|
+
await runFfmpegPoster(ffmpegPath, videoPath, outJpg, atSeconds);
|
|
214
|
+
}
|
|
215
|
+
catch (err) {
|
|
216
|
+
if (atSeconds === 0)
|
|
217
|
+
throw err;
|
|
218
|
+
await runFfmpegPoster(ffmpegPath, videoPath, outJpg, 0);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
//# sourceMappingURL=media-probe.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"media-probe.js","sourceRoot":"","sources":["../../src/lib/media-probe.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** One `scale=-2:360` per input, then an hstack chaining every scaled output left to right. A single
|
|
2
|
+
* input skips the stack (nothing to stack) and just scales. */
|
|
3
|
+
export declare function buildSheetFilterComplex(count: number): string;
|
|
4
|
+
/**
|
|
5
|
+
* Composes the given local poster JPEGs left to right into one JPEG sheet, each scaled to ~360px
|
|
6
|
+
* high. Throws on any ffmpeg failure (no binary, a bad/corrupt poster, etc.) — callers should catch
|
|
7
|
+
* this and fall back to showing a single poster on its own rather than nothing.
|
|
8
|
+
*/
|
|
9
|
+
export declare function composePosterSheet(posterPaths: string[]): Promise<Buffer>;
|
|
10
|
+
//# sourceMappingURL=poster-sheet.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"poster-sheet.d.ts","sourceRoot":"","sources":["../../src/lib/poster-sheet.ts"],"names":[],"mappings":"AAiBA;+DAC+D;AAC/D,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAK7D;AAED;;;;GAIG;AACH,wBAAsB,kBAAkB,CAAC,WAAW,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAyB/E"}
|