@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.
- package/assets/claude/skills/conductor-creative/SKILL.md +144 -0
- package/assets/claude/skills/conductor-publisher/SKILL.md +4 -0
- package/assets/cli-manifest.json +100 -0
- package/dist/commands/creative.d.ts +3 -0
- package/dist/commands/creative.d.ts.map +1 -0
- package/dist/commands/creative.js +60 -0
- package/dist/commands/creative.js.map +1 -0
- package/dist/creative/assertions.js +221 -0
- package/dist/creative/font.js +39 -0
- package/dist/creative/frame.css +126 -0
- package/dist/creative/frame.html +29 -0
- package/dist/creative/frame.js +76 -0
- package/dist/creative/job/render.mjs +203 -0
- package/dist/creative/job/server.mjs +72 -0
- package/dist/creative/job/transport.mjs +167 -0
- package/dist/creative/layouts/bleed/layout.css +47 -0
- package/dist/creative/layouts/bleed/layout.json +17 -0
- package/dist/creative/layouts/card/layout.css +24 -0
- package/dist/creative/layouts/card/layout.json +17 -0
- package/dist/creative/layouts/index.js +62 -0
- package/dist/creative/layouts/index.json +1 -0
- package/dist/creative/layouts/split/layout.css +22 -0
- package/dist/creative/layouts/split/layout.json +17 -0
- package/dist/creative/layouts/stacked/layout.css +40 -0
- package/dist/creative/layouts/stacked/layout.json +23 -0
- package/dist/creative/placements.js +200 -0
- package/dist/creative/placements.json +232 -0
- package/dist/creative/render.js +457 -0
- package/dist/creative/sheet.html +27 -0
- package/dist/creative/sheet.js +94 -0
- package/dist/creative/styles.css +314 -0
- package/dist/creative/tokens.css +33 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/creative-browser.d.ts +25 -0
- package/dist/lib/creative-browser.d.ts.map +1 -0
- package/dist/lib/creative-browser.js +36 -0
- package/dist/lib/creative-browser.js.map +1 -0
- package/dist/lib/creative-id.d.ts +8 -0
- package/dist/lib/creative-id.d.ts.map +1 -0
- package/dist/lib/creative-id.js +14 -0
- package/dist/lib/creative-id.js.map +1 -0
- package/dist/lib/creative-render.d.ts +32 -0
- package/dist/lib/creative-render.d.ts.map +1 -0
- package/dist/lib/creative-render.js +71 -0
- package/dist/lib/creative-render.js.map +1 -0
- package/dist/lib/image-dimensions.d.ts +20 -0
- package/dist/lib/image-dimensions.d.ts.map +1 -0
- package/dist/lib/image-dimensions.js +115 -0
- package/dist/lib/image-dimensions.js.map +1 -0
- package/dist/lib/plugin-assets.d.ts.map +1 -1
- package/dist/lib/plugin-assets.js +1 -0
- package/dist/lib/plugin-assets.js.map +1 -1
- package/dist/mcp/index.d.ts +18 -0
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +301 -0
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp/tools/creatives.d.ts +93 -0
- package/dist/mcp/tools/creatives.d.ts.map +1 -0
- package/dist/mcp/tools/creatives.js +291 -0
- package/dist/mcp/tools/creatives.js.map +1 -0
- package/dist/mcp/tools/insights.d.ts +11 -0
- package/dist/mcp/tools/insights.d.ts.map +1 -0
- package/dist/mcp/tools/insights.js +85 -0
- package/dist/mcp/tools/insights.js.map +1 -0
- package/package.json +4 -3
- 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
|
+
}
|