@cliangdev/conductor 0.21.0 → 0.23.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 (74) hide show
  1. package/assets/claude/skills/conductor-creative/SKILL.md +168 -0
  2. package/assets/claude/skills/conductor-publisher/SKILL.md +4 -0
  3. package/assets/cli-manifest.json +99 -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 +63 -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 +228 -0
  27. package/dist/creative/placements.json +268 -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 +38 -0
  44. package/dist/lib/creative-render.d.ts.map +1 -0
  45. package/dist/lib/creative-render.js +95 -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/media-probe.d.ts +55 -0
  52. package/dist/lib/media-probe.d.ts.map +1 -0
  53. package/dist/lib/media-probe.js +221 -0
  54. package/dist/lib/media-probe.js.map +1 -0
  55. package/dist/lib/plugin-assets.d.ts.map +1 -1
  56. package/dist/lib/plugin-assets.js +1 -0
  57. package/dist/lib/plugin-assets.js.map +1 -1
  58. package/dist/lib/poster-sheet.d.ts +10 -0
  59. package/dist/lib/poster-sheet.d.ts.map +1 -0
  60. package/dist/lib/poster-sheet.js +57 -0
  61. package/dist/lib/poster-sheet.js.map +1 -0
  62. package/dist/mcp/index.d.ts +18 -0
  63. package/dist/mcp/index.d.ts.map +1 -1
  64. package/dist/mcp/index.js +313 -1
  65. package/dist/mcp/index.js.map +1 -1
  66. package/dist/mcp/tools/creatives.d.ts +114 -0
  67. package/dist/mcp/tools/creatives.d.ts.map +1 -0
  68. package/dist/mcp/tools/creatives.js +477 -0
  69. package/dist/mcp/tools/creatives.js.map +1 -0
  70. package/dist/mcp/tools/insights.d.ts.map +1 -1
  71. package/dist/mcp/tools/insights.js +14 -0
  72. package/dist/mcp/tools/insights.js.map +1 -1
  73. package/package.json +6 -3
  74. package/scripts/build-creative.mjs +93 -0
@@ -0,0 +1,457 @@
1
+ /* render.js — builds one artboard from a creative + placement, and fits its
2
+ * type. Brand-agnostic: every color, logo and CTA string comes from a `brand`
3
+ * object handed to renderBoard, never a constant in this file.
4
+ *
5
+ * A creative needs only four things to render: a photo, a headline, a body
6
+ * and a layout. Everything else has a default, and every default can be
7
+ * overridden per creative.
8
+ *
9
+ * { layout: 'stacked' | 'bleed' | 'card' | 'split', theme: 'dark' | 'light',
10
+ * photoUrl, headline: 'Plan the week in *one sentence*.', body: '…' }
11
+ *
12
+ * `*asterisks*` mark the accent italic phrase: exactly one per headline,
13
+ * never two. That is a structural convention of this render engine, not a
14
+ * brand value — the color it takes (`--cc-accent`) IS the brand value.
15
+ *
16
+ * A brand is:
17
+ * {
18
+ * tokens?: { accent, accent2, darkBg, darkInk, lightBg, lightCard, ink, ink2 },
19
+ * fontFamily?: string,
20
+ * fontUrl?: string, // loaded by mount.js's loadFont(), not here
21
+ * logos?: { mark?, wordmarkDark?, wordmarkLight?, badge? }, // URLs
22
+ * ctaClaim?: string,
23
+ * }
24
+ * Every key is optional. A brand with no logos renders no lockup and no
25
+ * badge; a brand with no ctaClaim renders no claim text; a brand with no
26
+ * tokens renders with tokens.css's neutral fallbacks. See applyBrandTokens().
27
+ *
28
+ * Placements (artboard sizes: 9x16, 4x5, 1x1, ...) live in placements.json /
29
+ * placements.js. Layouts (visual structures: stacked, bleed, card, split)
30
+ * live in layouts/<name>/layout.json + layout.css. Neither is hardcoded here:
31
+ * this file reads whichever registries the caller hands it, so a new
32
+ * placement or layout is a data change, not a code change.
33
+ *
34
+ * NOTHING structural may be position:absolute. A DOM-screenshotting exporter
35
+ * (the future headless render job) can drop absolutely positioned layers, so
36
+ * photos are background-image on the artboard or a band, scrims are
37
+ * background-image on the text panel, and everything else stays in normal
38
+ * flow.
39
+ *
40
+ * This module touches `document` only inside functions that build or measure
41
+ * DOM (renderBoard, fitBoard, fitAll, and their private helpers). The pure
42
+ * functions (resolveAd, resolveSequence, layoutFor, enabledPlacements,
43
+ * leadingFor, trackingFor, tokensToCssVars) never do, so they run in plain
44
+ * Node for tests. It fetches nothing at import time.
45
+ */
46
+
47
+ const FOCAL_DEFAULT = '50% 50%';
48
+
49
+ /* Leading and tracking follow the size. Both rules are read off hand-tuned
50
+ * reference frames, where tracking is consistently about -0.03 x size. */
51
+ export function leadingFor(size) {
52
+ return size >= 100 ? 0.96 : size >= 70 ? 1.0 : 1.06;
53
+ }
54
+ export function trackingFor(size) {
55
+ return Math.round(-0.03 * size * 10) / 10;
56
+ }
57
+
58
+ /* ── Brand tokens → CSS custom properties ─────────────────────────────────── */
59
+
60
+ /* Maps a brand's token keys to the CSS custom properties tokens.css and
61
+ * frame.css read. Only the keys a brand actually sets are touched, so a kit
62
+ * missing a token falls back to tokens.css's neutral default rather than an
63
+ * empty string. */
64
+ const TOKEN_CSS_VARS = {
65
+ accent: '--cc-accent',
66
+ accent2: '--cc-accent-2',
67
+ darkBg: '--cc-d-bg',
68
+ darkInk: '--cc-d-ink',
69
+ lightBg: '--cc-l-bg',
70
+ lightCard: '--cc-l-card',
71
+ ink: '--cc-ink',
72
+ ink2: '--cc-ink-2',
73
+ };
74
+
75
+ /* Pure: brand.tokens -> { '--cc-accent': '#...', ... }, only for keys present.
76
+ * Used by tests and by anything that wants the mapping without touching the
77
+ * DOM (e.g. server-side rendering of the CSS for a contact sheet). */
78
+ export function tokensToCssVars(tokens) {
79
+ const out = {};
80
+ if (!tokens) return out;
81
+ for (const [key, cssVar] of Object.entries(TOKEN_CSS_VARS)) {
82
+ if (tokens[key] != null) out[cssVar] = tokens[key];
83
+ }
84
+ return out;
85
+ }
86
+
87
+ /* Applies a brand's tokens (and font family) as inline custom properties on
88
+ * `el` — scoped to that element, never `:root`, so two different brand kits
89
+ * can render on the same page without one overwriting the other's colors. */
90
+ export function applyBrandTokens(el, brand) {
91
+ const vars = tokensToCssVars(brand && brand.tokens);
92
+ for (const [cssVar, value] of Object.entries(vars)) el.style.setProperty(cssVar, value);
93
+ if (brand && brand.fontFamily) {
94
+ el.style.setProperty('--cc-font', `'${brand.fontFamily}', system-ui, -apple-system, sans-serif`);
95
+ }
96
+ }
97
+
98
+ /* ── Resolving a creative into a renderable ad ────────────────────────────── */
99
+
100
+ /* Fills in every default so the rest of the code can assume a complete ad.
101
+ * `raw` mirrors the backend creative shape (camelCase): layout, theme,
102
+ * headline, body, caption, photoUrl, focal (per-placement "x% y%" carried on
103
+ * the photo), focalOverride (per-creative override, wins over focal),
104
+ * layoutOverrides ({band?, padBottom?}, each per-placement pixel overrides),
105
+ * lockup ('plain' | 'chip'), typeOverrides. `placements` and `layouts` are
106
+ * the registries. */
107
+ export function resolveAd(raw, placements, layouts) {
108
+ raw = raw || {};
109
+ const layout = raw.layout || 'stacked';
110
+ const focal = {};
111
+ for (const key of Object.keys(placements)) {
112
+ focal[key] =
113
+ (raw.focalOverride && raw.focalOverride[key]) ||
114
+ (raw.focal && raw.focal[key]) ||
115
+ FOCAL_DEFAULT;
116
+ }
117
+ const layoutBand = (layouts[layout] && layouts[layout].band) || {};
118
+ const padBottomDefault = {};
119
+ for (const key of Object.keys(placements)) padBottomDefault[key] = placements[key].safe.bottom;
120
+ const layoutOverrides = raw.layoutOverrides || {};
121
+ return {
122
+ layout,
123
+ theme: raw.theme || 'dark',
124
+ lockup: raw.lockup || 'plain',
125
+ photoUrl: raw.photoUrl || null,
126
+ headline: raw.headline || '',
127
+ body: raw.body || '',
128
+ caption: raw.caption || '',
129
+ focal,
130
+ band: Object.assign({}, layoutBand, layoutOverrides.band),
131
+ padBottom: Object.assign({}, padBottomDefault, layoutOverrides.padBottom),
132
+ type: raw.typeOverrides || null, // null means auto-fit
133
+ showCta: true,
134
+ };
135
+ }
136
+
137
+ /* A story or carousel is a sequence of frames a viewer taps or swipes
138
+ * through, not one frame doing every job. Each frame inherits the creative
139
+ * (photo, layout, theme) and overrides only what changes, so a three-beat
140
+ * story is three short headlines rather than three whole creatives.
141
+ *
142
+ * The CTA row (logo lockup badge + claim) lands on the LAST frame only by
143
+ * default: most badge/claim artwork is meant to appear once per surface, and
144
+ * a story's real call to action is the platform's own link sticker, so a
145
+ * frame that makes no offer should not carry one. A frame can force it either
146
+ * way with `cta: true | false`.
147
+ *
148
+ * `raw.sequenceKind` is `'story' | 'carousel'` (or unset/null for no
149
+ * sequence); `raw.sequence` is the beat/card array. Both a story and a
150
+ * carousel use this same mechanism — carousel just renders at a feed ratio
151
+ * with every card sharing one aspect. */
152
+ export function resolveSequence(raw, placements, layouts) {
153
+ raw = raw || {};
154
+ const seq = raw.sequence;
155
+ if (!raw.sequenceKind || !Array.isArray(seq) || !seq.length) return [];
156
+ const last = seq.length - 1;
157
+ return seq.map((frame, i) => {
158
+ const merged = resolveAd({ ...raw, ...frame, sequence: undefined, sequenceKind: undefined }, placements, layouts);
159
+ // null and undefined both mean "not set" (the API sends null): the CTA then lands on the last frame only.
160
+ merged.showCta = frame.cta != null ? frame.cta : i === last;
161
+ // Body is per-frame opt-in: a hook frame can be a headline and nothing else.
162
+ merged.body = frame.body !== undefined ? frame.body || '' : '';
163
+ merged.isSequence = true;
164
+ merged.isStory = raw.sequenceKind === 'story';
165
+ merged.isCarousel = raw.sequenceKind === 'carousel';
166
+ merged.seqIndex = i + 1;
167
+ merged.seqTotal = seq.length;
168
+ return merged;
169
+ });
170
+ }
171
+
172
+ /* Which layout structure a creative + placement uses. A layout's own
173
+ * perPlacement map can swap in a different layout (and force a panel
174
+ * variant) for one placement key, which is how "every square is bleed"
175
+ * (stacked and bleed both carry a 1x1 override) works without ratio-specific
176
+ * branches in code. */
177
+ export function layoutFor(ad, placementKey, layouts) {
178
+ const manifest = layouts[ad.layout];
179
+ const override = manifest.perPlacement && manifest.perPlacement[placementKey];
180
+ const kind = (override && override.layout) || ad.layout;
181
+ const panel = (override && override.panel) || layouts[kind].panel;
182
+ return { kind, panel };
183
+ }
184
+
185
+ /* The default set of placements a creative renders at: a brand kit's
186
+ * `enabledPlacements` (falling back to placements.json's `default: true`
187
+ * keys when the kit does not specify any) plus whatever extra placements the
188
+ * creative itself opts into via `creative.placements`. */
189
+ export function enabledPlacements(creative, brandEnabledPlacements, placements) {
190
+ const base =
191
+ Array.isArray(brandEnabledPlacements) && brandEnabledPlacements.length
192
+ ? brandEnabledPlacements
193
+ : Object.keys(placements).filter((k) => placements[k].default);
194
+ const extra = Array.isArray(creative && creative.placements) ? creative.placements : [];
195
+ return [...new Set([...base, ...extra])];
196
+ }
197
+
198
+ /* ── DOM building (browser or a DOM shim such as linkedom) ────────────────── */
199
+
200
+ function el(tag, className, parent) {
201
+ const n = document.createElement(tag);
202
+ if (className) n.className = className;
203
+ if (parent) parent.appendChild(n);
204
+ return n;
205
+ }
206
+
207
+ /* Renders only when the brand has a mark and/or a wordmark image. A brand
208
+ * with neither renders no lockup at all — there is nothing brand-neutral to
209
+ * fall back to, and a placeholder logo would be worse than none.
210
+ *
211
+ * `lockup: 'chip'` puts the lockup on a white pill (frame.css's `.cc-lockup--chip`),
212
+ * for busy photography — ported from nexus's schema.json/render.js. A chip
213
+ * sits on light ground regardless of the artboard's own theme, so it needs
214
+ * the dark-ink wordmark just like a light theme does. */
215
+ function buildLockup(brand, theme, lockup, parent, reserveSpace = false) {
216
+ const logos = (brand && brand.logos) || {};
217
+ const hasMark = Boolean(logos.mark);
218
+ const onLight = theme === 'light' || lockup === 'chip';
219
+ // Dark artboards need the light-ink wordmark; a light artboard (or a chip,
220
+ // which is always light ground) needs the dark-ink one. Either falls back
221
+ // to the other when only one is supplied.
222
+ const wordmarkSrc = onLight
223
+ ? logos.wordmarkDark || logos.wordmarkLight
224
+ : logos.wordmarkLight || logos.wordmarkDark;
225
+ if (!hasMark && !wordmarkSrc) {
226
+ // No logo: on a placement whose top edge is covered by platform UI (9:16, stories), keep the
227
+ // lockup's footprint as an invisible spacer so the headline stays where it would sit under a logo,
228
+ // clear of that zone, instead of rising into it.
229
+ if (!reserveSpace) return null;
230
+ const spacer = el('div', 'cc-lockup cc-lockup--spacer', parent);
231
+ spacer.setAttribute('aria-hidden', 'true');
232
+ el('div', 'cc-lockup__icon', spacer);
233
+ return spacer;
234
+ }
235
+
236
+ const wrap = el('div', 'cc-lockup' + (lockup === 'chip' ? ' cc-lockup--chip' : ''), parent);
237
+ if (hasMark) {
238
+ const icon = el('img', 'cc-lockup__icon', wrap);
239
+ icon.src = logos.mark;
240
+ icon.alt = (brand && brand.name) || '';
241
+ }
242
+ if (wordmarkSrc) {
243
+ const mark = el('img', 'cc-lockup__mark', wrap);
244
+ mark.src = wordmarkSrc;
245
+ mark.alt = (brand && brand.name) || '';
246
+ }
247
+ return wrap;
248
+ }
249
+
250
+ /* 'Plan the week in *one sentence*.' -> text, accent <em>, text */
251
+ function buildHeadline(ad, parent) {
252
+ const h = el('h1', 'cc-headline', parent);
253
+ const m = /^(.*?)\*(.+?)\*(.*)$/s.exec(ad.headline || '');
254
+ if (!m) {
255
+ h.textContent = ad.headline || '';
256
+ return h;
257
+ }
258
+ if (m[1]) h.appendChild(document.createTextNode(m[1]));
259
+ el('em', null, h).textContent = m[2];
260
+ if (m[3]) h.appendChild(document.createTextNode(m[3]));
261
+ return h;
262
+ }
263
+
264
+ /* Renders only when the brand has a badge image and/or a ctaClaim string. */
265
+ function buildCta(brand, parent, placement) {
266
+ const hasBadge = Boolean(brand && brand.logos && brand.logos.badge);
267
+ const hasClaim = Boolean(brand && brand.ctaClaim);
268
+ if (!hasBadge && !hasClaim) return null;
269
+
270
+ const row = el('div', 'cc-cta', parent);
271
+ if (hasBadge) {
272
+ const badge = el('img', 'cc-badge', row);
273
+ badge.src = brand.logos.badge;
274
+ // Decorative: the badge conveys nothing a screen reader needs beyond
275
+ // what the surrounding claim text (or the destination itself) already says.
276
+ badge.alt = '';
277
+ const h = placement.badgeHeight;
278
+ badge.style.height = h + 'px';
279
+ // Width is left to the image's own intrinsic aspect ratio rather than a
280
+ // fixed constant: a brand's badge artwork is not assumed to be any one
281
+ // store's badge. Clear space of one quarter the badge's height is a
282
+ // reasonable default for badge-style artwork in general.
283
+ badge.style.margin = Math.ceil(h / 4) + 'px';
284
+ }
285
+ if (hasClaim) el('span', 'cc-cta__note', row).textContent = brand.ctaClaim;
286
+ return row;
287
+ }
288
+
289
+ /* Applies a headline size plus its derived leading and tracking. */
290
+ function applyType(board, size, leading, tracking) {
291
+ board.style.setProperty('--cc-h-size', size + 'px');
292
+ board.style.setProperty('--cc-h-leading', leading != null ? leading : leadingFor(size));
293
+ board.style.setProperty('--cc-h-track', (tracking != null ? tracking : trackingFor(size)) + 'px');
294
+ }
295
+
296
+ /* True when nothing in the panel crosses the artboard's edges or intrudes on
297
+ * the placement's bottom safe zone. These are the same tests a headless
298
+ * exporter would run before writing a PNG, so fitting and that safety net
299
+ * cannot disagree. */
300
+ function fits(board) {
301
+ const br = board.getBoundingClientRect();
302
+ const safeBottom = Number(board.dataset.safeBottom) || 0;
303
+ for (const n of board.querySelectorAll('.cc-headline, .cc-body, .cc-cta, .cc-lockup')) {
304
+ const r = n.getBoundingClientRect();
305
+ if (r.top < br.top - 1 || r.right > br.right + 1) return false;
306
+ if (r.bottom > br.bottom - safeBottom + 1) return false;
307
+ }
308
+ return true;
309
+ }
310
+
311
+ /* Steps the headline down from the placement's max until the board fits.
312
+ * Must run AFTER the board is in the document (a detached element measures
313
+ * zero) and AFTER document.fonts.ready (or it measures the fallback face).
314
+ * Steps rather than binary-searches because `text-wrap: balance` makes height
315
+ * non-monotonic in size, so a midpoint that fits does not prove the ones
316
+ * below it do. */
317
+ export function fitBoard(board, placements) {
318
+ if (board.dataset.autofit !== 'true') return;
319
+ const { max, min, lines } = placements[board.dataset.ratio].fit;
320
+ const headline = board.querySelector('.cc-headline');
321
+ for (let size = max; size >= min; size -= 2) {
322
+ applyType(board, size);
323
+ const used = Math.round(headline.getBoundingClientRect().height / (size * leadingFor(size)));
324
+ if (used <= lines && fits(board)) return size;
325
+ }
326
+ applyType(board, min);
327
+ return min;
328
+ }
329
+
330
+ /* Fits every board under `root` once fonts are settled. */
331
+ export async function fitAll(root, placements) {
332
+ if (typeof document !== 'undefined' && document.fonts && document.fonts.ready) await document.fonts.ready;
333
+ (root || document).querySelectorAll('.cc-board').forEach((b) => fitBoard(b, placements));
334
+ }
335
+
336
+ /* Sets one CSS custom property per placement.scale entry, so frame.css and
337
+ * the layout stylesheets read `var(--cc-foo)` instead of a per-ratio
338
+ * selector. `px: true` entries are bare numbers that need a unit; the rest
339
+ * (colors, the unitless line-height) are used as written. */
340
+ const SCALE_PROPS = {
341
+ lockupGap: { prop: '--cc-lockup-gap', px: true },
342
+ iconSize: { prop: '--cc-icon-size', px: true },
343
+ iconRadius: { prop: '--cc-icon-radius', px: true },
344
+ markHeight: { prop: '--cc-mark-height', px: true },
345
+ chipPadding: { prop: '--cc-chip-pad' },
346
+ chipGap: { prop: '--cc-chip-gap', px: true },
347
+ chipIcon: { prop: '--cc-chip-icon', px: true },
348
+ chipIconRadius: { prop: '--cc-chip-icon-radius', px: true },
349
+ chipMark: { prop: '--cc-chip-mark', px: true },
350
+ bodySize: { prop: '--cc-body-size', px: true },
351
+ bodyLineHeight: { prop: '--cc-body-lh' },
352
+ bodyMaxWidth: { prop: '--cc-body-max-w', px: true },
353
+ bodyColor: { prop: '--cc-body-color' },
354
+ lightBodySize: { prop: '--cc-light-body-size', px: true },
355
+ ctaGap: { prop: '--cc-cta-gap', px: true },
356
+ ctaMarginTop: { prop: '--cc-cta-margin-top', px: true },
357
+ noteSize: { prop: '--cc-note-size', px: true },
358
+ noteColor: { prop: '--cc-note-color' },
359
+ cardPad: { prop: '--cc-card-pad' },
360
+ cardGap: { prop: '--cc-card-gap', px: true },
361
+ };
362
+ function applyScale(board, scale) {
363
+ for (const [key, { prop, px }] of Object.entries(SCALE_PROPS)) {
364
+ if (scale[key] === undefined) continue;
365
+ board.style.setProperty(prop, scale[key] + (px ? 'px' : ''));
366
+ }
367
+ }
368
+
369
+ /* Returns a finished .cc-board element for one ad at one placement, painted
370
+ * with `brand`'s tokens, logos and CTA claim. `opts.safe` draws the
371
+ * 9x16/story safe-zone guide, a review aid that must never reach an export. */
372
+ export function renderBoard(ad, placementKey, placements, layouts, brand, opts) {
373
+ opts = opts || {};
374
+ const placement = placements[placementKey];
375
+ const { kind, panel: panelVariant } = layoutFor(ad, placementKey, layouts);
376
+ const board = el('div',
377
+ 'cc-board cc-r-' + placementKey + ' cc-board--' + kind +
378
+ (ad.theme === 'light' ? ' cc-board--light' : '') +
379
+ // A sequence is tapped or swiped through, so the copy has to sit in the
380
+ // same place on every frame. Distributing it would make it jump.
381
+ (ad.isSequence ? ' cc-board--seq' : '') +
382
+ (ad.isStory ? ' cc-board--story' : ''));
383
+ board.dataset.ratio = placementKey;
384
+
385
+ applyBrandTokens(board, brand);
386
+ board.style.setProperty('--cc-board-w', placement.w + 'px');
387
+ board.style.setProperty('--cc-board-h', placement.h + 'px');
388
+ board.style.setProperty('--cc-focal', ad.focal[placementKey]);
389
+ applyScale(board, placement.scale);
390
+ if (ad.type && ad.type[placementKey]) {
391
+ const [size, leading, tracking] = ad.type[placementKey];
392
+ applyType(board, size, leading, tracking);
393
+ } else {
394
+ // Provisional size; fitBoard() replaces it once the board is measurable.
395
+ board.dataset.autofit = 'true';
396
+ applyType(board, placement.fit.max);
397
+ }
398
+ const safe = placement.safe;
399
+ board.dataset.safeTop = safe.top;
400
+ board.dataset.safeBottom = safe.bottom;
401
+ board.dataset.safeRight = safe.right;
402
+ board.style.setProperty('--cc-safe-top', safe.top + 'px');
403
+ board.style.setProperty('--cc-safe-bottom', safe.bottom + 'px');
404
+ board.style.setProperty('--cc-safe-right', safe.right + 'px');
405
+ if (ad.band && ad.band[placementKey]) board.style.setProperty('--cc-band-h', ad.band[placementKey] + 'px');
406
+ if (ad.padBottom && ad.padBottom[placementKey]) {
407
+ board.style.setProperty('--cc-pad-b', ad.padBottom[placementKey] + 'px');
408
+ }
409
+
410
+ const photoType = layouts[kind].photo;
411
+ let panel;
412
+ if (photoType === 'background') {
413
+ board.style.setProperty('--cc-photo', ad.photoUrl ? 'url("' + ad.photoUrl + '")' : 'none');
414
+ // The bottom panel variant hugs the bottom; the fill variant spreads its
415
+ // copy over a full-artboard scrim.
416
+ panel = el('div', 'cc-board__panel cc-board__panel--' + panelVariant, board);
417
+ } else if (photoType === 'card') {
418
+ buildLockup(brand, ad.theme, ad.lockup, board, placement.safe.top > 0);
419
+ const card = el('div', 'cc-board__card', board);
420
+ if (ad.photoUrl) {
421
+ const cimg = el('img', null, card);
422
+ cimg.src = ad.photoUrl;
423
+ cimg.alt = '';
424
+ }
425
+ panel = el('div', 'cc-board__panel', board);
426
+ } else {
427
+ const band = el('div', 'cc-board__band', board);
428
+ if (ad.photoUrl) {
429
+ const img = el('img', null, band);
430
+ img.src = ad.photoUrl;
431
+ img.alt = '';
432
+ }
433
+ if (layouts[kind].bandScrim !== false) {
434
+ el('div', 'cc-board__scrim', band);
435
+ el('div', 'cc-board__glow', board);
436
+ }
437
+ panel = el('div', 'cc-board__panel', board);
438
+ }
439
+
440
+ // A layout with its lockup at the top of the page (card) sets
441
+ // `lockupInPanel: false` and builds it above, before the photo.
442
+ if (layouts[kind].lockupInPanel !== false) buildLockup(brand, ad.theme, ad.lockup, panel, placement.safe.top > 0);
443
+ buildHeadline(ad, panel);
444
+ const dropBody = (layouts[kind].dropBody || []).includes(placementKey);
445
+ if (ad.body && !dropBody) {
446
+ el('p', 'cc-body', panel).textContent = ad.body;
447
+ }
448
+ if (ad.showCta !== false) buildCta(brand, panel, placement);
449
+
450
+ if (opts.safe && placement.safe.bottom) {
451
+ const guide = el('div', 'cc-safe', board);
452
+ el('div', 'cc-safe__top', guide);
453
+ el('div', 'cc-safe__bottom', guide);
454
+ el('div', 'cc-safe__right', guide);
455
+ }
456
+ return board;
457
+ }
@@ -0,0 +1,27 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>Creative contact sheet</title>
6
+ <link rel="stylesheet" href="styles.css">
7
+ <style>
8
+ html, body { margin: 0; background: #F2F2F7; font-family: system-ui, -apple-system, sans-serif; }
9
+ #sheet { display: inline-flex; gap: 24px; align-items: flex-start; padding: 24px; }
10
+ .cell { display: flex; flex-direction: column; gap: 8px; align-items: center; }
11
+ .shell { position: relative; overflow: hidden; border-radius: 6px; box-shadow: 0 3px 12px rgba(0,0,0,0.16); }
12
+ .cap { font-size: 13px; font-weight: 700; color: #717171; }
13
+ </style>
14
+ </head>
15
+ <body>
16
+ <!--
17
+ Preview-only contact sheet: every enabled placement of one creative (or
18
+ every beat of a story/carousel), scaled down onto one page, for a quick
19
+ look without downloading full-size PNGs one at a time. Rendered by
20
+ job/render.mjs when a render's `previewOnly` flag is set, and uploaded as
21
+ the single frame placementKey "sheet". Reads its spec from
22
+ window.__RENDER_SPEC__ — see sheet.js.
23
+ -->
24
+ <div id="sheet"></div>
25
+ <script type="module" src="sheet.js"></script>
26
+ </body>
27
+ </html>
@@ -0,0 +1,94 @@
1
+ /* sheet.js — builds a contact sheet: every enabled placement of a creative,
2
+ * or every beat of its story/carousel sequence, scaled down onto one page.
3
+ * Preview only — never screenshotted per-placement, never assertion-checked
4
+ * (a bad frame still shows up in the sheet so a human can see what broke).
5
+ *
6
+ * Spec shape (set by job/render.mjs via page.addInitScript before this page
7
+ * loads):
8
+ * {
9
+ * creative, brand, // see README.md's Creative/Brand shapes
10
+ * placementKeys: [...], // the render's placements (spec.placements from
11
+ * // the backend); ignored when the creative has a
12
+ * // sequence, which always renders every beat
13
+ * placements?, layouts?, // registry overrides (default: the shipped ones)
14
+ * }
15
+ *
16
+ * Result contract: window.__RENDER_RESULT = { ok: true, width, height } |
17
+ * { ok: false, error }; window.__ready = true once the sheet has been built,
18
+ * fitted and scaled.
19
+ */
20
+ import { resolveAd, resolveSequence, renderBoard, fitAll } from './render.js';
21
+ import { placements as DEFAULT_PLACEMENTS } from './placements.js';
22
+ import { layouts as DEFAULT_LAYOUTS } from './layouts/index.js';
23
+ import { ensureBrandFont } from './font.js';
24
+
25
+ const SHEET_SCALE = 0.34;
26
+
27
+ function finish(result) {
28
+ window.__RENDER_RESULT = result;
29
+ window.__ready = true;
30
+ }
31
+
32
+ async function main() {
33
+ const spec = window.__RENDER_SPEC__;
34
+ if (!spec || !spec.creative) {
35
+ finish({ ok: false, error: 'no render spec provided (window.__RENDER_SPEC__)' });
36
+ return;
37
+ }
38
+ const placementsReg = spec.placements || DEFAULT_PLACEMENTS;
39
+ const layoutsReg = spec.layouts || DEFAULT_LAYOUTS;
40
+ const sheet = document.getElementById('sheet');
41
+ const boards = [];
42
+
43
+ function addCell(ad, placementKey, label) {
44
+ const placement = placementsReg[placementKey];
45
+ if (!placement) throw new Error(`unknown placement "${placementKey}"`);
46
+ const cell = document.createElement('div');
47
+ cell.className = 'cell';
48
+ const shell = document.createElement('div');
49
+ shell.className = 'shell';
50
+ shell.style.width = Math.round(placement.w * SHEET_SCALE) + 'px';
51
+ shell.style.height = Math.round(placement.h * SHEET_SCALE) + 'px';
52
+ const board = renderBoard(ad, placementKey, placementsReg, layoutsReg, spec.brand || {});
53
+ shell.appendChild(board);
54
+ cell.appendChild(shell);
55
+ const cap = document.createElement('div');
56
+ cap.className = 'cap';
57
+ cap.textContent = label;
58
+ cell.appendChild(cap);
59
+ sheet.appendChild(cell);
60
+ boards.push(board);
61
+ }
62
+
63
+ try {
64
+ await ensureBrandFont(spec.brand);
65
+ const isSequence = spec.creative.sequenceKind && Array.isArray(spec.creative.sequence) && spec.creative.sequence.length;
66
+ if (isSequence) {
67
+ const frames = resolveSequence(spec.creative, placementsReg, layoutsReg);
68
+ const key = spec.creative.sequenceKind === 'carousel' ? spec.creative.carouselRatio || '4x5' : 'story';
69
+ frames.forEach((frame, i) => addCell(frame, key, `${i + 1} of ${frames.length}`));
70
+ } else {
71
+ const ad = resolveAd(spec.creative, placementsReg, layoutsReg);
72
+ const keys = Array.isArray(spec.placementKeys) && spec.placementKeys.length
73
+ ? spec.placementKeys
74
+ : Object.keys(placementsReg).filter((k) => placementsReg[k].default);
75
+ keys.forEach((key) => addCell(ad, key, (placementsReg[key] && placementsReg[key].label) || key));
76
+ }
77
+
78
+ // Fit every board at its TRUE, unscaled size first (a CSS transform
79
+ // shrinks the measured rect and would throw fitBoard's line-count math
80
+ // off), then apply the display scale — the same order mount.js uses.
81
+ await fitAll(sheet, placementsReg);
82
+ boards.forEach((board) => {
83
+ board.style.transformOrigin = 'top left';
84
+ board.style.transform = `scale(${SHEET_SCALE})`;
85
+ });
86
+
87
+ const rect = sheet.getBoundingClientRect();
88
+ finish({ ok: true, width: Math.round(rect.width), height: Math.round(rect.height) });
89
+ } catch (err) {
90
+ finish({ ok: false, error: String((err && err.message) || err) });
91
+ }
92
+ }
93
+
94
+ main();