@cliangdev/conductor 0.20.6 → 0.22.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 (67) hide show
  1. package/assets/claude/skills/conductor-creative/SKILL.md +144 -0
  2. package/assets/claude/skills/conductor-publisher/SKILL.md +4 -0
  3. package/assets/cli-manifest.json +100 -0
  4. package/dist/commands/creative.d.ts +3 -0
  5. package/dist/commands/creative.d.ts.map +1 -0
  6. package/dist/commands/creative.js +60 -0
  7. package/dist/commands/creative.js.map +1 -0
  8. package/dist/creative/assertions.js +221 -0
  9. package/dist/creative/font.js +39 -0
  10. package/dist/creative/frame.css +126 -0
  11. package/dist/creative/frame.html +29 -0
  12. package/dist/creative/frame.js +76 -0
  13. package/dist/creative/job/render.mjs +203 -0
  14. package/dist/creative/job/server.mjs +72 -0
  15. package/dist/creative/job/transport.mjs +167 -0
  16. package/dist/creative/layouts/bleed/layout.css +47 -0
  17. package/dist/creative/layouts/bleed/layout.json +17 -0
  18. package/dist/creative/layouts/card/layout.css +24 -0
  19. package/dist/creative/layouts/card/layout.json +17 -0
  20. package/dist/creative/layouts/index.js +62 -0
  21. package/dist/creative/layouts/index.json +1 -0
  22. package/dist/creative/layouts/split/layout.css +22 -0
  23. package/dist/creative/layouts/split/layout.json +17 -0
  24. package/dist/creative/layouts/stacked/layout.css +40 -0
  25. package/dist/creative/layouts/stacked/layout.json +23 -0
  26. package/dist/creative/placements.js +200 -0
  27. package/dist/creative/placements.json +232 -0
  28. package/dist/creative/render.js +457 -0
  29. package/dist/creative/sheet.html +27 -0
  30. package/dist/creative/sheet.js +94 -0
  31. package/dist/creative/styles.css +314 -0
  32. package/dist/creative/tokens.css +33 -0
  33. package/dist/index.js +2 -0
  34. package/dist/index.js.map +1 -1
  35. package/dist/lib/creative-browser.d.ts +25 -0
  36. package/dist/lib/creative-browser.d.ts.map +1 -0
  37. package/dist/lib/creative-browser.js +36 -0
  38. package/dist/lib/creative-browser.js.map +1 -0
  39. package/dist/lib/creative-id.d.ts +8 -0
  40. package/dist/lib/creative-id.d.ts.map +1 -0
  41. package/dist/lib/creative-id.js +14 -0
  42. package/dist/lib/creative-id.js.map +1 -0
  43. package/dist/lib/creative-render.d.ts +32 -0
  44. package/dist/lib/creative-render.d.ts.map +1 -0
  45. package/dist/lib/creative-render.js +71 -0
  46. package/dist/lib/creative-render.js.map +1 -0
  47. package/dist/lib/image-dimensions.d.ts +20 -0
  48. package/dist/lib/image-dimensions.d.ts.map +1 -0
  49. package/dist/lib/image-dimensions.js +115 -0
  50. package/dist/lib/image-dimensions.js.map +1 -0
  51. package/dist/lib/plugin-assets.d.ts.map +1 -1
  52. package/dist/lib/plugin-assets.js +1 -0
  53. package/dist/lib/plugin-assets.js.map +1 -1
  54. package/dist/mcp/index.d.ts +18 -0
  55. package/dist/mcp/index.d.ts.map +1 -1
  56. package/dist/mcp/index.js +301 -0
  57. package/dist/mcp/index.js.map +1 -1
  58. package/dist/mcp/tools/creatives.d.ts +93 -0
  59. package/dist/mcp/tools/creatives.d.ts.map +1 -0
  60. package/dist/mcp/tools/creatives.js +291 -0
  61. package/dist/mcp/tools/creatives.js.map +1 -0
  62. package/dist/mcp/tools/insights.d.ts +11 -0
  63. package/dist/mcp/tools/insights.d.ts.map +1 -0
  64. package/dist/mcp/tools/insights.js +85 -0
  65. package/dist/mcp/tools/insights.js.map +1 -0
  66. package/package.json +4 -3
  67. package/scripts/build-creative.mjs +93 -0
@@ -0,0 +1,126 @@
1
+ /* frame.css — the shared creative artboard rules: everything a layout does
2
+ * not own. Ratios and layouts are both data, not hardcoded here.
3
+ *
4
+ * Placements (artboard sizes and per-placement numbers: lockup, body, cta,
5
+ * card padding) live in placements.json / placements.js. render.js reads
6
+ * them and sets CSS custom properties on the board (--cc-board-w,
7
+ * --cc-lockup-gap, --cc-body-size, ...).
8
+ *
9
+ * Layouts (structural families: stacked, bleed, card, split, and any added
10
+ * later) live in layouts/<name>/layout.json + layout.css. This file keeps
11
+ * only what every layout shares: the artboard, the lockup, the headline,
12
+ * the body, the cta row, sequence anchoring, and the safe-zone review
13
+ * guide.
14
+ *
15
+ * NOTHING structural here may be position:absolute. A PNG exporter that
16
+ * screenshots this DOM (the future render job) can drop absolutely
17
+ * positioned layers, so every photo and scrim stays in normal flow: photos
18
+ * are painted as background-image on the artboard or a band, scrims are
19
+ * background-image on the text panel.
20
+ *
21
+ * This file has no @import and no font-face: fonts are brand data (a kit's
22
+ * `fontUrl`), loaded by mount.js's loadFont(), never bundled here.
23
+ */
24
+
25
+ /* ── Artboard ─────────────────────────────────────────────────────────────── */
26
+ .cc-board {
27
+ position: relative;
28
+ overflow: hidden;
29
+ box-sizing: border-box;
30
+ font-family: var(--cc-font);
31
+ background-color: var(--cc-d-bg);
32
+ -webkit-font-smoothing: antialiased;
33
+ flex: none;
34
+ display: flex;
35
+ flex-direction: column;
36
+ width: var(--cc-board-w);
37
+ height: var(--cc-board-h);
38
+ }
39
+ .cc-board--light { background-color: var(--cc-l-bg); }
40
+
41
+ .cc-board__panel {
42
+ position: relative; /* keeps the panel above the glow */
43
+ box-sizing: border-box;
44
+ display: flex; flex-direction: column; align-items: flex-start;
45
+ }
46
+
47
+ /* ── Sequence frames (stories and carousels) ──────────────────────────────── */
48
+ /* Anchored to the bottom on every frame so the eye does not have to re-find the
49
+ copy between taps or swipes. The CTA appears only on the last frame. */
50
+ .cc-board--seq .cc-lockup { position: absolute; top: var(--cc-safe-top, 48px); left: 88px; }
51
+
52
+ /* ── Lockup ───────────────────────────────────────────────────────────────── */
53
+ /* Renders only when the brand kit has a mark and/or a wordmark image; a kit
54
+ with neither renders no lockup at all (see render.js's buildLockup). */
55
+ .cc-lockup { display: inline-flex; align-items: center; flex: none; align-self: flex-start; gap: var(--cc-lockup-gap); }
56
+ .cc-lockup__icon { display: block; width: var(--cc-icon-size); height: var(--cc-icon-size); border-radius: var(--cc-icon-radius); }
57
+ .cc-lockup__mark { display: block; height: var(--cc-mark-height); }
58
+ .cc-lockup--spacer { visibility: hidden; }
59
+
60
+ /* Chip variant: a white pill behind the lockup, for busy photography (ported
61
+ from nexus's schema.json `lockup: "chip"` / render.js). `--cc-l-card` and
62
+ `--cc-elev-overlay` are tokens.css's neutral light-ground surface and
63
+ shadow — brand-agnostic, so a white pill works over any brand's photo. */
64
+ .cc-lockup--chip {
65
+ border-radius: 999px;
66
+ background: var(--cc-l-card);
67
+ box-shadow: var(--cc-elev-overlay);
68
+ padding: var(--cc-chip-pad);
69
+ gap: var(--cc-chip-gap);
70
+ }
71
+ .cc-lockup--chip .cc-lockup__icon {
72
+ width: var(--cc-chip-icon, var(--cc-icon-size));
73
+ height: var(--cc-chip-icon, var(--cc-icon-size));
74
+ border-radius: var(--cc-chip-icon-radius, var(--cc-icon-radius));
75
+ }
76
+ .cc-lockup--chip .cc-lockup__mark { height: var(--cc-chip-mark, var(--cc-mark-height)); }
77
+
78
+ /* ── Headline ─────────────────────────────────────────────────────────────── */
79
+ .cc-headline {
80
+ margin: 0;
81
+ font-size: var(--cc-h-size);
82
+ line-height: var(--cc-h-leading, 1.04);
83
+ font-weight: 800;
84
+ letter-spacing: var(--cc-h-track, -2.3px);
85
+ color: var(--cc-d-ink);
86
+ text-wrap: balance;
87
+ }
88
+ .cc-board--light .cc-headline { color: var(--cc-ink); }
89
+ .cc-headline em { font-style: italic; color: var(--cc-accent); }
90
+
91
+ /* ── Body ─────────────────────────────────────────────────────────────────── */
92
+ .cc-body {
93
+ margin: 0;
94
+ font-weight: 500;
95
+ text-wrap: pretty;
96
+ font-size: var(--cc-body-size);
97
+ line-height: var(--cc-body-lh);
98
+ max-width: var(--cc-body-max-w);
99
+ color: var(--cc-body-color);
100
+ }
101
+ .cc-board--light .cc-body { font-size: var(--cc-light-body-size, var(--cc-body-size)); color: var(--cc-ink-2); }
102
+
103
+ /* ── CTA row ──────────────────────────────────────────────────────────────── */
104
+ /* Renders only when the kit has a badge image and/or a ctaClaim string; a kit
105
+ with neither renders no cta row at all (see render.js's buildCta). */
106
+ .cc-cta { display: flex; align-items: center; flex-wrap: wrap; flex: none; gap: var(--cc-cta-gap); margin-top: var(--cc-cta-margin-top, 0); }
107
+
108
+ /* The badge is brand artwork (a kit's `logos.badge`): sized by height only in
109
+ render.js, its own intrinsic aspect ratio preserved. Nothing here may
110
+ restyle it — no border, no border-radius, no filter, no shadow, no overlay —
111
+ since a store badge (Apple's, Google's, or a kit's own) typically comes with
112
+ guidelines against exactly that. */
113
+ .cc-badge { display: block; flex: none; width: auto; }
114
+
115
+ .cc-cta__note { font-weight: 600; font-size: var(--cc-note-size); color: var(--cc-note-color); }
116
+ .cc-board--light .cc-cta__note { color: var(--cc-ink-2); }
117
+
118
+ /* ── Safe zones (review guide only, never exported) ───────────────────────── */
119
+ .cc-safe { position: absolute; inset: 0; pointer-events: none; }
120
+ .cc-safe__top { position: absolute; top: 0; left: 0; right: 0; height: var(--cc-safe-top);
121
+ background: rgba(74,173,219,0.28); border-bottom: 2px dashed var(--cc-sky); }
122
+ .cc-safe__bottom { position: absolute; bottom: 0; left: 0; right: 0; height: var(--cc-safe-bottom);
123
+ background: rgba(74,173,219,0.28); border-top: 2px dashed var(--cc-sky); }
124
+ .cc-safe__right { position: absolute; bottom: var(--cc-safe-bottom); top: var(--cc-safe-top); right: 0;
125
+ width: var(--cc-safe-right);
126
+ background: rgba(74,173,219,0.20); border-left: 2px dashed var(--cc-sky); }
@@ -0,0 +1,29 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>Creative frame</title>
6
+ <link rel="stylesheet" href="styles.css">
7
+ <style>
8
+ /* No page chrome at all: the render job screenshots .cc-board directly, and
9
+ anything else on the page would only risk bleeding into the capture. */
10
+ html, body { margin: 0; padding: 0; background: #6E6E73; }
11
+ body { display: flex; align-items: flex-start; justify-content: center; }
12
+ </style>
13
+ </head>
14
+ <body>
15
+ <!--
16
+ Single-artboard renderer for the T3 render job (job/render.mjs). Renders
17
+ exactly one placement (or one sequence beat) of one creative at its TRUE
18
+ pixel size, fits its type, runs the in-page assertions, and reports the
19
+ result on window.__RENDER_RESULT / window.__ready.
20
+
21
+ Reads its spec from window.__RENDER_SPEC__, which the job sets via
22
+ Playwright's addInitScript() before navigation (see frame.js) — never a
23
+ query string or a fetch, so an arbitrarily large creative/brand payload
24
+ never hits a URL length limit. Always served over http (job/server.mjs),
25
+ never opened as file://, because `type="module"` imports are blocked there.
26
+ -->
27
+ <script type="module" src="frame.js"></script>
28
+ </body>
29
+ </html>
@@ -0,0 +1,76 @@
1
+ /* frame.js — builds one `.cc-board` from window.__RENDER_SPEC__ at its true
2
+ * pixel size, waits for fonts and images to settle, fits the headline, runs
3
+ * the in-page assertions (assertions.js), and reports the outcome.
4
+ *
5
+ * Spec shape (set by job/render.mjs via page.addInitScript before this page
6
+ * loads):
7
+ * {
8
+ * creative, brand, // see README.md's Creative/Brand shapes
9
+ * placementKey: '9x16' | ..., // required, a key from the placements registry
10
+ * sequenceIndex?: number, // required when creative.sequenceKind is set
11
+ * placements?, layouts?, // registry overrides (default: the shipped ones)
12
+ * }
13
+ *
14
+ * Result contract:
15
+ * window.__RENDER_RESULT = { ok, errors?, warnings?, width, height } | { ok: false, error }
16
+ * window.__ready = true // set exactly once, success or failure, when the
17
+ * // job's page.waitForFunction should stop waiting
18
+ */
19
+ import { resolveAd, resolveSequence, renderBoard, fitBoard } from './render.js';
20
+ import { placements as DEFAULT_PLACEMENTS } from './placements.js';
21
+ import { layouts as DEFAULT_LAYOUTS } from './layouts/index.js';
22
+ import { ensureBrandFont } from './font.js';
23
+ import { runAssertions } from './assertions.js';
24
+
25
+ function finish(result) {
26
+ window.__RENDER_RESULT = result;
27
+ window.__ready = true;
28
+ }
29
+
30
+ async function main() {
31
+ const spec = window.__RENDER_SPEC__;
32
+ if (!spec || !spec.creative || !spec.placementKey) {
33
+ finish({ ok: false, error: 'no render spec provided (window.__RENDER_SPEC__)' });
34
+ return;
35
+ }
36
+
37
+ const placementsReg = spec.placements || DEFAULT_PLACEMENTS;
38
+ const layoutsReg = spec.layouts || DEFAULT_LAYOUTS;
39
+ const placement = placementsReg[spec.placementKey];
40
+ if (!placement) {
41
+ finish({ ok: false, error: `unknown placement "${spec.placementKey}"` });
42
+ return;
43
+ }
44
+
45
+ let ad;
46
+ const isSequence = spec.creative.sequenceKind && Array.isArray(spec.creative.sequence) && spec.creative.sequence.length;
47
+ if (isSequence) {
48
+ const frames = resolveSequence(spec.creative, placementsReg, layoutsReg);
49
+ const idx = spec.sequenceIndex || 0;
50
+ ad = frames[idx];
51
+ if (!ad) {
52
+ finish({ ok: false, error: `no sequence frame at index ${idx} (sequence has ${frames.length})` });
53
+ return;
54
+ }
55
+ } else {
56
+ ad = resolveAd(spec.creative, placementsReg, layoutsReg);
57
+ }
58
+
59
+ let board;
60
+ try {
61
+ await ensureBrandFont(spec.brand);
62
+ board = renderBoard(ad, spec.placementKey, placementsReg, layoutsReg, spec.brand || {});
63
+ document.body.appendChild(board);
64
+
65
+ await document.fonts.ready;
66
+ await Promise.all([...document.images].map((img) => (img.decode ? img.decode().catch(() => {}) : null)));
67
+ fitBoard(board, placementsReg);
68
+
69
+ const { errors, warnings, width, height } = await runAssertions(board, placement, spec.brand || {});
70
+ finish({ ok: errors.length === 0, errors, warnings, width, height });
71
+ } catch (err) {
72
+ finish({ ok: false, error: String((err && err.message) || err) });
73
+ }
74
+ }
75
+
76
+ main();
@@ -0,0 +1,203 @@
1
+ #!/usr/bin/env node
2
+ /* render.mjs — the T3 render job's rendering core, plus a thin CLI entry
3
+ * point at the bottom.
4
+ *
5
+ * `run()` below knows nothing about HTTP, Cloud Run, or a Conductor
6
+ * Workflow — it takes a `transport` object (see transport.mjs) with four
7
+ * methods (getSpec, putFrame, complete, fail) and drives Playwright against
8
+ * this package's own frame.html/sheet.html. That split exists because how
9
+ * this job is *launched and reports back* is expected to change (a direct
10
+ * Cloud Run Job launch talking straight REST vs. sitting behind a Conductor
11
+ * Workflow) while the rendering behavior — what gets rendered, how it is
12
+ * checked, and what "done" or "failed" mean — should not have to change
13
+ * alongside it. Tests exercise `run()` directly with an in-memory fake
14
+ * transport (test/job.test.mjs); transport.mjs's HTTP behavior is tested on
15
+ * its own (test/transport.test.mjs).
16
+ *
17
+ * Exit code (CLI only): 0 on success, 1 on any failure (spec fetch, page
18
+ * load, an assertion, an upload, or a timeout on any of those).
19
+ */
20
+ import { fileURLToPath } from 'node:url';
21
+ import { dirname, join } from 'node:path';
22
+ import { startServer } from './server.mjs';
23
+ import { createApiTransport } from './transport.mjs';
24
+
25
+ const HERE = dirname(fileURLToPath(import.meta.url));
26
+ const PACKAGE_ROOT = join(HERE, '..'); // conductor-creative/, the static root
27
+
28
+ const PAGE_TIMEOUT_MS = 20_000;
29
+
30
+ /* Placement frames are JPEG — Instagram feed images and TikTok photo posts both refuse PNG (see
31
+ * MediaTargetValidator on the backend) — at quality 92, a level export tooling generally treats as
32
+ * visually lossless while still compressing meaningfully smaller than PNG. The `sheet` contact sheet
33
+ * (a preview-only render, never attached to a Post) is a 1x JPEG: it exists to be looked at inside a
34
+ * chat (preview_creative returns it as an image), which caps it near 1 MB, and a 2x PNG grid of every
35
+ * placement runs well past that. Every `.cc-board` paints an explicit
36
+ * `background-color` (frame.css) — dark or light theme — so there is no transparency for JPEG's opaque
37
+ * export to clip. */
38
+ const PLACEMENT_FRAME_SCREENSHOT = { type: 'jpeg', quality: 92 };
39
+ const PLACEMENT_FRAME_CONTENT_TYPE = 'image/jpeg';
40
+ const SHEET_SCREENSHOT = { type: 'jpeg', quality: 85 };
41
+ const SHEET_CONTENT_TYPE = 'image/jpeg';
42
+ const FRAME_SCALE = 2;
43
+ const SHEET_SCALE = 1;
44
+
45
+ async function defaultBrowserFactory() {
46
+ const { chromium } = await import('playwright');
47
+ return chromium.launch();
48
+ }
49
+
50
+ /** True when a local Chromium can actually launch. `playwright` lives only
51
+ * in job/node_modules (see job/package.json's header comment on why it is
52
+ * not a conductor-creative/ dependency), so anything outside job/ — e.g.
53
+ * test/job.test.mjs, one directory up — cannot `import('playwright')`
54
+ * itself and resolve it; it calls this instead, which resolves relative to
55
+ * THIS file. Tests use it to skip cleanly on a machine that never ran
56
+ * `npx playwright install chromium`. */
57
+ export async function isChromiumAvailable() {
58
+ try {
59
+ const browser = await defaultBrowserFactory();
60
+ await browser.close();
61
+ return true;
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ /* Which page + placementKey(s) this render needs, from the spec's own
68
+ * shape — see the internal API contract in transport.mjs's header comment.
69
+ * A story/carousel's beats are separate indexed frames at the sequence's one
70
+ * placement key; a plain creative renders each of its placements once;
71
+ * `previewOnly` always renders exactly one contact-sheet frame, regardless
72
+ * of a sequence, uploaded as placementKey "sheet". */
73
+ export function framesFor(spec) {
74
+ const { creative, placements, previewOnly } = spec;
75
+ if (previewOnly) return [{ page: 'sheet.html', placementKey: 'sheet' }];
76
+ const isSequence = creative.sequenceKind && Array.isArray(creative.sequence) && creative.sequence.length;
77
+ if (!placements || !placements.length) {
78
+ throw new Error('the render spec names no placements to render (check the Brand Kit\'s enabled placements, or the carousel ratio)');
79
+ }
80
+ if (isSequence) {
81
+ const key = placements[0];
82
+ return creative.sequence.map((_, index) => ({ page: 'frame.html', placementKey: key, index }));
83
+ }
84
+ return (placements || []).map((placementKey) => ({ page: 'frame.html', placementKey }));
85
+ }
86
+
87
+ /** Renders every frame a spec calls for, uploads each PNG, and reports
88
+ * complete/fail. Options:
89
+ * transport required. See transport.mjs.
90
+ * packageRoot static root to serve (default: this package's own root).
91
+ * browserFactory () => Promise<Browser> (default: launches real Playwright chromium).
92
+ * log (...args) => void (default: console.log).
93
+ * Resolves `true` on success, `false` on a reported failure (never throws —
94
+ * a thrown error from a truly unexpected place still gets caught and turned
95
+ * into a `transport.fail()` call before resolving `false`, so a caller never
96
+ * has to guess whether `transport.fail` was already called). */
97
+ export async function run({ transport, packageRoot = PACKAGE_ROOT, browserFactory = defaultBrowserFactory, log = console.log }) {
98
+ let spec;
99
+ try {
100
+ log('fetching spec');
101
+ spec = await transport.getSpec();
102
+ } catch (err) {
103
+ log(`FAIL ${err.message}`);
104
+ await reportFailure(transport, err.message, log);
105
+ return false;
106
+ }
107
+
108
+ const { server, origin } = await startServer(packageRoot);
109
+ let browser;
110
+ const warnings = [];
111
+ try {
112
+ browser = await browserFactory();
113
+ const frames = framesFor(spec);
114
+ log(`rendering ${frames.length} frame(s)`);
115
+
116
+ for (const frame of frames) {
117
+ const label = frame.index !== undefined ? `${frame.placementKey}[${frame.index}]` : frame.placementKey;
118
+ const isSheet = frame.page === 'sheet.html';
119
+ const scale = isSheet ? SHEET_SCALE : FRAME_SCALE;
120
+ const page = await browser.newPage({ deviceScaleFactor: scale });
121
+ try {
122
+ await page.addInitScript((s) => {
123
+ window.__RENDER_SPEC__ = s;
124
+ }, {
125
+ creative: spec.creative,
126
+ brand: spec.brand,
127
+ placementKey: frame.placementKey,
128
+ sequenceIndex: frame.index,
129
+ placementKeys: spec.placements,
130
+ });
131
+ await page.goto(`${origin}/${frame.page}`, { waitUntil: 'load', timeout: PAGE_TIMEOUT_MS });
132
+ await page.waitForFunction(() => window.__ready === true, null, { timeout: PAGE_TIMEOUT_MS });
133
+ const result = await page.evaluate(() => window.__RENDER_RESULT);
134
+
135
+ if (!result || !result.ok) {
136
+ const message = (result && (result.error || (result.errors || []).join('; '))) || 'render failed for an unknown reason';
137
+ throw new Error(`${label}: ${message}`);
138
+ }
139
+ for (const message of result.warnings || []) {
140
+ warnings.push({ placementKey: frame.placementKey, index: frame.index, message });
141
+ }
142
+
143
+ const locator = isSheet ? page.locator('#sheet') : page.locator('.cc-board').first();
144
+ const bytes = await locator.screenshot(isSheet ? SHEET_SCREENSHOT : PLACEMENT_FRAME_SCREENSHOT);
145
+ const contentType = isSheet ? SHEET_CONTENT_TYPE : PLACEMENT_FRAME_CONTENT_TYPE;
146
+ const box = await locator.boundingBox();
147
+ const width = Math.round((box && box.width) || 0) * scale;
148
+ const height = Math.round((box && box.height) || 0) * scale;
149
+
150
+ await transport.putFrame(frame.placementKey, { index: frame.index, width, height, bytes, contentType });
151
+ log(`ok ${label} ${width}x${height} ${contentType}`);
152
+ } finally {
153
+ await page.close();
154
+ }
155
+ }
156
+
157
+ await transport.complete(warnings);
158
+ log(`complete (${warnings.length} warning(s))`);
159
+ return true;
160
+ } catch (err) {
161
+ log(`FAIL ${err.message}`);
162
+ await reportFailure(transport, err.message, log);
163
+ return false;
164
+ } finally {
165
+ if (browser) await browser.close().catch(() => {});
166
+ server.close();
167
+ }
168
+ }
169
+
170
+ async function reportFailure(transport, message, log) {
171
+ // A spec fetch the API refused never created a render, so there is nothing to mark failed.
172
+ if (typeof transport.getRenderId === 'function' && !transport.getRenderId()) return;
173
+ try {
174
+ await transport.fail(message);
175
+ } catch (err) {
176
+ log(`also failed to report failure to the backend: ${err.message}`);
177
+ }
178
+ }
179
+
180
+ /* ── CLI entry point ───────────────────────────────────────────────────── */
181
+
182
+ function isMain() {
183
+ return process.argv[1] && import.meta.url === `file://${process.argv[1]}`;
184
+ }
185
+
186
+ if (isMain()) {
187
+ const apiUrl = process.env.CONDUCTOR_API_URL;
188
+ const apiKey = process.env.CONDUCTOR_API_KEY;
189
+ const projectId = process.env.CONDUCTOR_PROJECT_ID;
190
+ const creativeId = process.env.CREATIVE_ID;
191
+ const previewOnly = /^(1|true)$/i.test(process.env.PREVIEW_ONLY || '');
192
+ const renderer = process.env.RENDERER || 'cli';
193
+ const workflowRunId = process.env.WORKFLOW_RUN_ID || undefined;
194
+ const missing = ['CONDUCTOR_API_URL', 'CONDUCTOR_API_KEY', 'CONDUCTOR_PROJECT_ID', 'CREATIVE_ID'].filter((k) => !process.env[k]);
195
+ if (missing.length) {
196
+ console.error(`missing required env var(s): ${missing.join(', ')}`);
197
+ process.exit(1);
198
+ }
199
+
200
+ const transport = createApiTransport({ apiUrl, apiKey, projectId, creativeId, previewOnly, renderer, workflowRunId });
201
+ const log = (...args) => console.log(`[render ${creativeId}]`, ...args);
202
+ run({ transport, log }).then((ok) => process.exit(ok ? 0 : 1));
203
+ }
@@ -0,0 +1,72 @@
1
+ /* server.mjs — tiny static file server scoped to the conductor-creative
2
+ * package root, so frame.html/sheet.html and everything they import
3
+ * (render.js, placements.js, layouts/, styles.css, assertions.js, ...)
4
+ * resolve over http. Dependency-free node:http: the job's only npm
5
+ * dependency is playwright. `type="module"` imports and stylesheet <link>s
6
+ * are both blocked under file://, which is why this exists rather than just
7
+ * pointing Playwright at the files directly.
8
+ *
9
+ * Adapted from nexus-marketing/social/server.mjs; the only change is that
10
+ * the static root is a parameter (this package's root) rather than assumed
11
+ * to be the module's own directory, since this job lives one level down, in
12
+ * job/.
13
+ */
14
+ import { createServer } from 'node:http';
15
+ import { readFile, stat } from 'node:fs/promises';
16
+ import { extname, join, normalize, sep } from 'node:path';
17
+
18
+ const TYPES = {
19
+ '.html': 'text/html; charset=utf-8',
20
+ '.js': 'text/javascript; charset=utf-8',
21
+ '.mjs': 'text/javascript; charset=utf-8',
22
+ '.css': 'text/css; charset=utf-8',
23
+ '.json': 'application/json; charset=utf-8',
24
+ '.png': 'image/png',
25
+ '.webp': 'image/webp',
26
+ '.jpg': 'image/jpeg',
27
+ '.jpeg': 'image/jpeg',
28
+ '.svg': 'image/svg+xml',
29
+ };
30
+
31
+ function makeHandler(root) {
32
+ return async function handler(req, res) {
33
+ const url = new URL(req.url, 'http://localhost');
34
+ let rel = decodeURIComponent(url.pathname);
35
+ if (rel === '/') rel = '/frame.html';
36
+ // Contain every request inside `root`.
37
+ const path = join(root, normalize(rel).replace(/^(\.\.[/\\])+/, ''));
38
+ if (path !== root && !path.startsWith(root + sep)) {
39
+ res.writeHead(403).end('forbidden');
40
+ return;
41
+ }
42
+ try {
43
+ const info = await stat(path);
44
+ if (!info.isFile()) throw new Error('not a file');
45
+ const body = await readFile(path);
46
+ res.writeHead(200, {
47
+ 'content-type': TYPES[extname(path).toLowerCase()] || 'application/octet-stream',
48
+ 'content-length': body.length,
49
+ 'cache-control': 'no-store',
50
+ });
51
+ res.end(body);
52
+ } catch {
53
+ res.writeHead(404).end('not found');
54
+ }
55
+ };
56
+ }
57
+
58
+ /** Starts a static server rooted at `root` on an ephemeral loopback port.
59
+ * Resolves `{ server, origin }`; call `server.close()` when done. */
60
+ export function startServer(root, port = 0) {
61
+ const handle = makeHandler(root);
62
+ return new Promise((resolve) => {
63
+ const server = createServer((req, res) => {
64
+ handle(req, res).catch((err) => {
65
+ res.writeHead(500).end(String((err && err.message) || err));
66
+ });
67
+ });
68
+ server.listen(port, '127.0.0.1', () => {
69
+ resolve({ server, origin: `http://127.0.0.1:${server.address().port}` });
70
+ });
71
+ });
72
+ }
@@ -0,0 +1,167 @@
1
+ /* transport.mjs — the render job's ONLY knowledge of how it talks to the
2
+ * backend. render.mjs's rendering core never touches `fetch` or a
3
+ * `/api/v2/...` path directly; it calls the four methods below. Kept
4
+ * separate because the delivery contract is expected to change (T3's
5
+ * decision log: renders run LOCALLY through the Conductor CLI/MCP server,
6
+ * not as a backend-launched Cloud Run Job) — when it changes again, only
7
+ * this file and its own test change, not the rendering core or its tests.
8
+ *
9
+ * Today's contract (see the T3 render contract doc): the external v2
10
+ * creatives API, `Authorization: Bearer <apiKey>` on every request (the CLI
11
+ * sends the project/user API key — there is no separate render token).
12
+ *
13
+ * POST {apiUrl}/api/v2/projects/{projectId}/marketing/creatives/{creativeId}/renders
14
+ * body: { previewOnly?, renderer?, workflowRunId? }
15
+ * -> 201 CreativeRenderResponse { id, state: "RUNNING", ..., spec: { renderId, previewOnly, creative, brand, placements } }
16
+ * `spec` is only ever returned by this call — getSpec() hands back `response.spec` and
17
+ * remembers `renderId` for the three calls below.
18
+ * PUT {apiUrl}/api/v2/projects/{projectId}/marketing/creatives/{creativeId}/renders/{renderId}/frames/{placementKey}?index&width&height
19
+ * body: raw image bytes, Content-Type: image/jpeg (a placement frame) or image/png (the `sheet`
20
+ * contact sheet) -> 204
21
+ * POST {apiUrl}/api/v2/projects/{projectId}/marketing/creatives/{creativeId}/renders/{renderId}/complete
22
+ * body: { warnings: [{ placementKey, index?, message }] } -> 200 CreativeRenderResponse
23
+ * POST {apiUrl}/api/v2/projects/{projectId}/marketing/creatives/{creativeId}/renders/{renderId}/fail
24
+ * body: { message, log? } -> 200 CreativeRenderResponse
25
+ */
26
+
27
+ const DEFAULT_TIMEOUT_MS = 20_000;
28
+ // A frame is a few hundred KB to a few MB; through a scale-to-zero backend (a cold start, a slow uplink)
29
+ // that can outlast the JSON calls' limit, so uploads get their own, and one retry — the PUT is an upsert.
30
+ const DEFAULT_UPLOAD_TIMEOUT_MS = 90_000;
31
+ const UPLOAD_ATTEMPTS = 2;
32
+
33
+ async function withTimeout(fn, ms, label) {
34
+ const ctrl = new AbortController();
35
+ const timer = setTimeout(() => ctrl.abort(), ms);
36
+ try {
37
+ return await fn(ctrl.signal);
38
+ } catch (err) {
39
+ if (err && err.name === 'AbortError') throw new Error(`${label} timed out after ${ms}ms`);
40
+ throw err;
41
+ } finally {
42
+ clearTimeout(timer);
43
+ }
44
+ }
45
+
46
+ async function safeText(res) {
47
+ try {
48
+ return await res.text();
49
+ } catch {
50
+ return '';
51
+ }
52
+ }
53
+
54
+ /** Builds the transport the real job uses, talking to the Conductor backend over HTTP per today's
55
+ * external v2 `/marketing/creatives/{creativeId}/renders` contract. `timeoutMs` bounds every
56
+ * individual network call, so a hung backend fails the job instead of hanging the caller (a Claude
57
+ * Code/Desktop session, or a self-hosted Workflow daemon) forever.
58
+ *
59
+ * `renderId` is not known until `getSpec()` returns (the backend assigns it on the POST that starts
60
+ * the render), so `putFrame`/`complete`/`fail` all reach for it lazily via `renderBase()` rather than
61
+ * capturing it at construction time. `getRenderId()` is an addition beyond the four-method adapter
62
+ * interface `render.mjs`'s `run()` relies on — callers that need the id for their own reporting (the
63
+ * CLI, the MCP tool) can read it after `getSpec()` without `run()` having to hand it back itself. */
64
+ export function createApiTransport({
65
+ apiUrl,
66
+ apiKey,
67
+ projectId,
68
+ creativeId,
69
+ previewOnly,
70
+ renderer,
71
+ workflowRunId,
72
+ timeoutMs = DEFAULT_TIMEOUT_MS,
73
+ uploadTimeoutMs = DEFAULT_UPLOAD_TIMEOUT_MS,
74
+ fetchImpl = fetch,
75
+ }) {
76
+ const creativeBase = `${apiUrl.replace(/\/+$/, '')}/api/v2/projects/${projectId}/marketing/creatives/${creativeId}`;
77
+ const authHeaders = { Authorization: `Bearer ${apiKey}` };
78
+ let renderId;
79
+
80
+ function renderBase() {
81
+ if (!renderId) throw new Error('renderId is not known yet — getSpec() must succeed first');
82
+ return `${creativeBase}/renders/${renderId}`;
83
+ }
84
+
85
+ async function postJson(url, body) {
86
+ return withTimeout(async (signal) => {
87
+ const res = await fetchImpl(url, {
88
+ method: 'POST',
89
+ headers: { ...authHeaders, 'Content-Type': 'application/json' },
90
+ body: JSON.stringify(body),
91
+ signal,
92
+ });
93
+ if (!res.ok) throw new Error(`POST ${url} failed: ${res.status} ${await safeText(res)}`);
94
+ if (res.status === 204) return undefined;
95
+ try {
96
+ return await res.json();
97
+ } catch {
98
+ return undefined;
99
+ }
100
+ }, timeoutMs, `POST ${url}`);
101
+ }
102
+
103
+ return {
104
+ async getSpec() {
105
+ const body = { previewOnly: !!previewOnly };
106
+ if (renderer) body.renderer = renderer;
107
+ if (workflowRunId) body.workflowRunId = workflowRunId;
108
+ const response = await withTimeout(async (signal) => {
109
+ const res = await fetchImpl(`${creativeBase}/renders`, {
110
+ method: 'POST',
111
+ headers: { ...authHeaders, 'Content-Type': 'application/json' },
112
+ body: JSON.stringify(body),
113
+ signal,
114
+ });
115
+ if (!res.ok) throw new Error(`POST renders failed: ${res.status} ${await safeText(res)}`);
116
+ return res.json();
117
+ }, timeoutMs, 'POST renders');
118
+
119
+ const spec = response && response.spec;
120
+ const id = (spec && spec.renderId) || (response && response.id);
121
+ if (!spec || !id) throw new Error('POST renders response is missing `spec`/a render id');
122
+ renderId = id;
123
+ return spec;
124
+ },
125
+
126
+ async putFrame(placementKey, { index, width, height, bytes, contentType = 'image/jpeg' }) {
127
+ const qs = new URLSearchParams({ width: String(width), height: String(height) });
128
+ if (index !== undefined && index !== null) qs.set('index', String(index));
129
+ const base = renderBase();
130
+ const put = () => withTimeout(async (signal) => {
131
+ const res = await fetchImpl(`${base}/frames/${encodeURIComponent(placementKey)}?${qs}`, {
132
+ method: 'PUT',
133
+ headers: { ...authHeaders, 'Content-Type': contentType },
134
+ body: bytes,
135
+ signal,
136
+ });
137
+ if (res.status !== 204) {
138
+ const err = new Error(`PUT frame ${placementKey} failed: ${res.status} ${await safeText(res)}`);
139
+ err.retryable = res.status >= 500;
140
+ throw err;
141
+ }
142
+ }, uploadTimeoutMs, `PUT frame ${placementKey}`);
143
+ for (let attempt = 1; ; attempt++) {
144
+ try {
145
+ return await put();
146
+ } catch (err) {
147
+ const retryable = err.retryable || /timed out/.test(err.message);
148
+ if (!retryable || attempt >= UPLOAD_ATTEMPTS) throw err;
149
+ }
150
+ }
151
+ },
152
+
153
+ async complete(warnings) {
154
+ return postJson(`${renderBase()}/complete`, { warnings: warnings || [] });
155
+ },
156
+
157
+ async fail(message, log) {
158
+ return postJson(`${renderBase()}/fail`, log ? { message, log } : { message });
159
+ },
160
+
161
+ /** The render id assigned by `getSpec()`'s POST, or `undefined` before it has run. Not part of the
162
+ * `run()` adapter interface — a convenience for callers that report the id themselves. */
163
+ getRenderId() {
164
+ return renderId;
165
+ },
166
+ };
167
+ }