@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.
Files changed (43) hide show
  1. package/assets/claude/skills/conductor-creative/SKILL.md +57 -0
  2. package/assets/cli-manifest.json +6 -1
  3. package/dist/commands/creative.d.ts.map +1 -1
  4. package/dist/commands/creative.js +6 -1
  5. package/dist/commands/creative.js.map +1 -1
  6. package/dist/creative/assertions.js +4 -4
  7. package/dist/creative/frame.css +21 -0
  8. package/dist/creative/frame.js +39 -0
  9. package/dist/creative/job/render.mjs +261 -4
  10. package/dist/creative/job/transport.mjs +47 -13
  11. package/dist/creative/layouts/bleed/layout.css +6 -2
  12. package/dist/creative/layouts/card/layout.css +3 -0
  13. package/dist/creative/layouts/split/layout.css +3 -0
  14. package/dist/creative/layouts/stacked/layout.css +3 -0
  15. package/dist/creative/motion.js +347 -0
  16. package/dist/creative/placements.js +28 -0
  17. package/dist/creative/placements.json +36 -0
  18. package/dist/creative/render.js +29 -0
  19. package/dist/creative/sheet.html +4 -0
  20. package/dist/creative/sheet.js +39 -2
  21. package/dist/creative/styles.css +36 -2
  22. package/dist/lib/creative-render.d.ts +13 -3
  23. package/dist/lib/creative-render.d.ts.map +1 -1
  24. package/dist/lib/creative-render.js +47 -5
  25. package/dist/lib/creative-render.js.map +1 -1
  26. package/dist/lib/media-probe.d.ts +55 -0
  27. package/dist/lib/media-probe.d.ts.map +1 -0
  28. package/dist/lib/media-probe.js +221 -0
  29. package/dist/lib/media-probe.js.map +1 -0
  30. package/dist/lib/poster-sheet.d.ts +10 -0
  31. package/dist/lib/poster-sheet.d.ts.map +1 -0
  32. package/dist/lib/poster-sheet.js +57 -0
  33. package/dist/lib/poster-sheet.js.map +1 -0
  34. package/dist/mcp/index.d.ts +10587 -0
  35. package/dist/mcp/index.d.ts.map +1 -1
  36. package/dist/mcp/index.js +93 -10
  37. package/dist/mcp/index.js.map +1 -1
  38. package/dist/mcp/tools/creatives.d.ts +29 -2
  39. package/dist/mcp/tools/creatives.d.ts.map +1 -1
  40. package/dist/mcp/tools/creatives.js +282 -35
  41. package/dist/mcp/tools/creatives.js.map +1 -1
  42. package/package.json +3 -1
  43. package/scripts/build-creative.mjs +7 -2
@@ -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})`;
@@ -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
- background-size: cover;
223
- background-position: var(--cc-focal, 50% 50%);
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: finds a local browser, runs the render core against the external
28
- * API, and reads back the stored render (frames with signed URLs, final state) whether or not the
29
- * render itself succeeded — a FAILED render still has an id and an error message worth returning.
32
+ * Renders a Creative end to end. STILL and MOTION creatives both find a local browser and run the
33
+ * render core against the external API, reading back the stored render (frames with signed URLs,
34
+ * final state) whether or not the render itself succeeded — a FAILED render still has an id and an
35
+ * error message worth returning. A MOTION render (not previewOnly) additionally needs a local ffmpeg
36
+ * binary — resolved the same way `upload_creative_media`/`media-probe.ts` finds one (a system binary
37
+ * on PATH first, else the bundled `ffmpeg-static` fallback) — and runs at a fixed 30fps; a previewOnly
38
+ * MOTION render (the key-moments contact sheet) needs neither ffmpeg nor a video encode, so it skips
39
+ * this resolution entirely. CLIP creatives never launch a browser — see {@link renderViaApi}.
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;AAI9C,MAAM,WAAW,WAAW;IAC1B,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,YAAY,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAA;CACpB;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;AAwDD;;;;GAIG;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,CAiC/B"}
1
+ {"version":3,"file":"creative-render.d.ts","sourceRoot":"","sources":["../../src/lib/creative-render.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAS9C,MAAM,WAAW,WAAW;IAC1B,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,YAAY,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAA;IACnB,0GAA0G;IAC1G,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,MAAM,WAAW,oBAAoB;IACnC,UAAU,EAAE,MAAM,CAAA;IAClB,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,aAAa,CAAC,EAAE,MAAM,CAAA;CACvB;AAED,MAAM,WAAW,oBAAoB;IACnC,EAAE,EAAE,OAAO,CAAA;IACX,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,MAAM,EAAE,WAAW,EAAE,CAAA;CACtB;AAkFD;;;;;;;;;GASG;AACH,wBAAsB,cAAc,CAClC,MAAM,EAAE,oBAAoB,EAC5B,MAAM,EAAE,MAAM,EACd,GAAG,GAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAe,GAC3C,OAAO,CAAC,oBAAoB,CAAC,CAoD/B"}
@@ -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
- * Renders a Creative end to end: finds a local browser, runs the render core against the external
38
- * API, and reads back the stored render (frames with signed URLs, final state) whether or not the
39
- * render itself succeeded — a FAILED render still has an id and an error message worth returning.
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
- const ok = await run({ transport, browserFactory, log });
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":"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"}
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"}