@cliangdev/conductor 0.21.0 → 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 +94 -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 +284 -1
- 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.map +1 -1
- package/dist/mcp/tools/insights.js +14 -0
- package/dist/mcp/tools/insights.js.map +1 -1
- package/package.json +4 -3
- package/scripts/build-creative.mjs +93 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: conductor-creative
|
|
3
|
+
description: Creates on-brand ad creative through the Conductor MCP tools — a photo plus headline rendered at every enabled placement (e.g. TikTok/Reels 9:16, Instagram feed 4:5, Facebook 1:1, plus any opt-in placements) from a workspace's Brand Kit, rendered locally on this machine and checked by eye before it's called done. Use when asked to make, design, write or export a social ad, an ad creative, a promo image, a static ad, a story or carousel sequence, or a new lettered variant (headline test) of one.
|
|
4
|
+
user-invocable: true
|
|
5
|
+
allowed-tools: mcp__conductor__*, AskUserQuestion, Read, Glob, Grep
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Conductor Creative
|
|
9
|
+
|
|
10
|
+
Turns one concept — a photo and a headline — into upload-ready artwork at every placement a
|
|
11
|
+
workspace's Brand Kit enables. This is the front half of Conductor's marketing pillar: what this
|
|
12
|
+
skill produces becomes a Post's media, and `conductor-publisher` takes it from there through review
|
|
13
|
+
and out to each destination. You write the ad here; you do not post it.
|
|
14
|
+
|
|
15
|
+
You are writing the ad, not just running a tool. The copy and the visual language both come from
|
|
16
|
+
things that already exist for this workspace — a Brand Kit (data: tokens, copy rules, approved
|
|
17
|
+
lines) and a Knowledge page (prose: positioning, voice, facts). Read them rather than inventing.
|
|
18
|
+
|
|
19
|
+
Rendering happens locally, through this MCP server, with a browser on this machine — there is no
|
|
20
|
+
separate render service to wait on. `render_creative` typically finishes in well under a minute.
|
|
21
|
+
|
|
22
|
+
## How to make a Creative
|
|
23
|
+
|
|
24
|
+
### 1. Read the brand
|
|
25
|
+
|
|
26
|
+
Call `get_brand_kit` (a project may have more than one — pass `kitId` if the person named a
|
|
27
|
+
specific brand, otherwise the default). It gives you the colour tokens, font, logo/wordmark/badge
|
|
28
|
+
URLs, the CTA claim, the copy rules (each a regex, a message, and which fields it applies to), the
|
|
29
|
+
approved lines, the enabled placements, and `knowledgePagePath` — the wiki page holding this
|
|
30
|
+
brand's positioning, message hierarchy, approved verbatim lines and voice in prose. Read that page
|
|
31
|
+
with `read_knowledge_pages` before writing anything. Quote its approved lines rather than
|
|
32
|
+
paraphrasing them; they were worded and cleared on purpose.
|
|
33
|
+
|
|
34
|
+
### 2. Pick the angle
|
|
35
|
+
|
|
36
|
+
Lead with whatever the brand's own message hierarchy puts first — the job the product does end to
|
|
37
|
+
end, not a secondary feature. A supporting capability is proof, not the headline.
|
|
38
|
+
|
|
39
|
+
### 3. Write the headline
|
|
40
|
+
|
|
41
|
+
Prefer an approved line from the Knowledge page verbatim. Otherwise write one that names something
|
|
42
|
+
real and specific, not a generic claim.
|
|
43
|
+
|
|
44
|
+
If the Brand Kit's `accentPhraseRequired` is true, mark exactly one phrase per headline with
|
|
45
|
+
asterisks: `Every saved link, *finally usable*.` Never zero, never two — `create_creative` and
|
|
46
|
+
`update_creative` refuse a headline that gets this wrong, naming the rule.
|
|
47
|
+
|
|
48
|
+
Keep it short — checked at thumbnail size, not read up close.
|
|
49
|
+
|
|
50
|
+
### 4. Obey the brand's copy rules
|
|
51
|
+
|
|
52
|
+
Every rule in `copyRules` is data (a pattern, a message, and which fields it checks), not something
|
|
53
|
+
to remember — the backend enforces it on every write and hands back the rule's own message on a
|
|
54
|
+
violation, listing every rule the copy tripped. Fix the copy and retry rather than working around
|
|
55
|
+
it. A kit with no rules configured is not a bug; it simply never fails a save on that account.
|
|
56
|
+
|
|
57
|
+
### 5. Choose the photo and look at it
|
|
58
|
+
|
|
59
|
+
Call `list_creatives`/an existing photo library read, or check what photos are already on hand
|
|
60
|
+
before uploading a new one. A photo needs `checked: true` and not `blocked` before a Creative can
|
|
61
|
+
go to `READY` — see the photo library's own listing for that state.
|
|
62
|
+
|
|
63
|
+
A new photo goes through `upload_creative_photo` (local path or URL), which requires `source` (a
|
|
64
|
+
URL or a short provenance note, e.g. "Generated with a named model on a given date") and `licence`
|
|
65
|
+
(e.g. "Own work", "Unsplash Licence", "Generated, house use"). This closes a real legal exposure: an
|
|
66
|
+
unattributed or unlicensed photo running as paid or organic media.
|
|
67
|
+
|
|
68
|
+
**Open the photo and look at it before using it.** A picture can break every copy rule with text,
|
|
69
|
+
a logo or a claim burned into its pixels, and no automated check can see that — this is the first
|
|
70
|
+
of this skill's silent failure modes (the full list is below). A human or a prior pass marks a bad
|
|
71
|
+
photo `blocked`; if you notice one that should be, say so rather than using it anyway.
|
|
72
|
+
|
|
73
|
+
Match the crop to the subject: a layout that lays copy over the photo (e.g. `bleed`) puts a face in
|
|
74
|
+
the top third in collision with the headline — prefer a layout that keeps the photo and copy on
|
|
75
|
+
separate panels (e.g. `stacked`) for those.
|
|
76
|
+
|
|
77
|
+
### 6. Create, then render
|
|
78
|
+
|
|
79
|
+
`create_creative` with the Brand Kit (if not the default), layout, theme, photo, headline, body,
|
|
80
|
+
caption and alt text. To test a headline variant of an existing concept, pass `variantOf` with that
|
|
81
|
+
Creative's id instead of starting fresh — it inherits everything else and gets the next letter.
|
|
82
|
+
Then call `get_creative` to verify what was actually stored.
|
|
83
|
+
|
|
84
|
+
`render_creative` (previewOnly: true first, for a quick contact sheet) runs the render locally and
|
|
85
|
+
returns the render id, state, frame URLs and any warnings. Then `preview_creative` to actually look
|
|
86
|
+
at it — it returns the contact sheet as an image directly, not just a URL to trust.
|
|
87
|
+
|
|
88
|
+
### 7. Look at the result honestly
|
|
89
|
+
|
|
90
|
+
**A successful render proves the frame is structurally correct, not that the ad is good.** Its
|
|
91
|
+
in-page checks catch text spilling out of a safe zone, a font that failed to load, a photo that
|
|
92
|
+
never decoded, or contrast below the accessible minimum — not whether the headline reads at a
|
|
93
|
+
glance, whether the accent phrase falls on the words that carry the meaning, or whether the copy is
|
|
94
|
+
sitting on the subject's face. Judge those yourself from the image `preview_creative` hands back. If
|
|
95
|
+
the copy collides with the photo, switch layout (see step 5) and re-render rather than shrinking
|
|
96
|
+
text until it technically fits.
|
|
97
|
+
|
|
98
|
+
### 8. Check readiness before calling it done
|
|
99
|
+
|
|
100
|
+
`get_creative` includes a `readiness` block: caption, alt text, the photo's checked/blocked state
|
|
101
|
+
and provenance, and the Creative's own state (not `DRAFT`) are blocking; an AI-generation disclosure
|
|
102
|
+
is informational only. Do not call a Creative done while `ready` is false.
|
|
103
|
+
|
|
104
|
+
### 9. Render for real, then hand off
|
|
105
|
+
|
|
106
|
+
Once the preview looks right, `render_creative` again with `previewOnly: false` (or omitted) for the
|
|
107
|
+
full-size placement frames, then `attach_creative_to_post` with the Post's Work Item id — it maps
|
|
108
|
+
each placement to the right destinations (9:16 to TikTok/Reels, 4:5 to Instagram feed, 1:1 to
|
|
109
|
+
Facebook, story to story targets) for every destination that has not set its own custom media, and
|
|
110
|
+
never touches one that has. Verify with `get_post_status`. From here, use the `conductor-publisher`
|
|
111
|
+
skill for scheduling, review and publishing — this skill's job ends at attaching the artwork.
|
|
112
|
+
|
|
113
|
+
## Making a sequence instead of a single ad
|
|
114
|
+
|
|
115
|
+
For a platform surface that's tapped or swiped through, set `sequenceKind` to `story` (2 to 7
|
|
116
|
+
beats, aim for 3 to 5) or `carousel` (2 to 10 cards, 5 to 7 performs best) on `create_creative` or
|
|
117
|
+
`update_creative`, with a `sequence` array. Each beat inherits the Creative and overrides only its
|
|
118
|
+
own `headline`, and optionally `body`, `photoId` or `cta`. Structure a story hook, mechanism,
|
|
119
|
+
payoff — the first beat has to earn the next tap on its own, since that's where the drop-off is
|
|
120
|
+
worst. A logo lockup and CTA claim land on the last beat only by default; `cta: true` on an earlier
|
|
121
|
+
beat overrides that. A carousel renders every card at one shared aspect ratio (`carouselRatio`),
|
|
122
|
+
since the destination platforms require that.
|
|
123
|
+
|
|
124
|
+
## Failures that are silent (carried over from this skill's predecessor)
|
|
125
|
+
|
|
126
|
+
Automated checks are real but incomplete. These are the ones worth remembering to look for by eye:
|
|
127
|
+
|
|
128
|
+
- **A photo with text, a logo or a claim burned into its pixels** passes every copy-rule and
|
|
129
|
+
render check, because none of them look at pixels for meaning — only step 5's "open and look at
|
|
130
|
+
it" catches this.
|
|
131
|
+
- **A structurally valid render is not necessarily a good one.** The render's own assertions check
|
|
132
|
+
spill, safe zones, fonts and contrast — never whether the headline reads at a glance or the copy
|
|
133
|
+
sits on the subject's face. Step 7 exists because of this gap.
|
|
134
|
+
- **A Brand Kit with no copy rules, or no accent-phrase requirement, never fails a save on that
|
|
135
|
+
account.** That is permissive by design, not a bug — but it means the model, not the platform, is
|
|
136
|
+
the only check on brand voice for that workspace until someone configures rules.
|
|
137
|
+
- **A stale write is refused, not silently overwritten.** `update_creative` requires the Creative's
|
|
138
|
+
current `version`; a mismatch comes back as a 409 naming the conflict rather than clobbering a
|
|
139
|
+
concurrent edit — reread with `get_creative` and reapply, don't retry blind.
|
|
140
|
+
|
|
141
|
+
## Related skills
|
|
142
|
+
|
|
143
|
+
- `conductor-publisher`: schedules and tracks the Post this Creative's artwork attaches to, through
|
|
144
|
+
review and out to each destination. This skill hands off to it, never the reverse.
|
|
@@ -17,6 +17,10 @@ are. One instance of it is a MARKETING Post going to Instagram and TikTok; anoth
|
|
|
17
17
|
own lifecycle that publishes somewhere else. Read the rules below as rules about the pipeline, and
|
|
18
18
|
the platform names as examples.
|
|
19
19
|
|
|
20
|
+
If the destination needs artwork that does not exist yet — a photo turned into on-brand ad
|
|
21
|
+
creative, not just an attached file — make it first with the `conductor-creative` skill, then come
|
|
22
|
+
back here with the rendered frames to attach and schedule.
|
|
23
|
+
|
|
20
24
|
## Step 1 — Gather three things, and ask rather than guess
|
|
21
25
|
|
|
22
26
|
1. **The copy** — inline, or a file path you `Read`. Keep it exactly as given.
|
package/assets/cli-manifest.json
CHANGED
|
@@ -127,6 +127,26 @@
|
|
|
127
127
|
"syntax": "conductor config set-url <url>",
|
|
128
128
|
"description": "Update the API base URL. Use when running a self-hosted backend.",
|
|
129
129
|
"category": "config"
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
"name": "creative render",
|
|
133
|
+
"syntax": "conductor creative render <creativeId>",
|
|
134
|
+
"description": "Render a Creative locally with a Playwright-driven browser to upload-ready JPEG frames, or a preview contact sheet. Prints the render id, state and frame URLs; exits non-zero on failure.",
|
|
135
|
+
"category": "marketing",
|
|
136
|
+
"options": [
|
|
137
|
+
{
|
|
138
|
+
"flag": "--preview",
|
|
139
|
+
"description": "Render a preview contact sheet instead of full-size frames"
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
"flag": "--renderer <name>",
|
|
143
|
+
"description": "Attribution tag stored on the render (default: cli)"
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
"flag": "--workflow-run-id <id>",
|
|
147
|
+
"description": "Attribute this render to a Workflow run"
|
|
148
|
+
}
|
|
149
|
+
]
|
|
130
150
|
}
|
|
131
151
|
],
|
|
132
152
|
"claudeIntegration": {
|
|
@@ -168,6 +188,10 @@
|
|
|
168
188
|
{
|
|
169
189
|
"name": "conductor-publisher",
|
|
170
190
|
"description": "Schedules and tracks a Post through the publishing pipeline: gathers copy, destinations and timing, checks content, creates it with create_post, reports the confirmation table, follows it through review and publishing."
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
"name": "conductor-creative",
|
|
194
|
+
"description": "Makes a Creative end to end: reads the Brand Kit and its Knowledge page, picks a photo and an angle, creates the Creative, renders and looks at the result, fixes what's wrong, then hands it to conductor-publisher."
|
|
171
195
|
}
|
|
172
196
|
]
|
|
173
197
|
},
|
|
@@ -695,6 +719,76 @@
|
|
|
695
719
|
"description": "The project's best-performing published destinations ranked by one metric.",
|
|
696
720
|
"category": "marketing",
|
|
697
721
|
"requiredParams": []
|
|
722
|
+
},
|
|
723
|
+
{
|
|
724
|
+
"name": "get_brand_kit",
|
|
725
|
+
"description": "Read a workspace Brand Kit: colour tokens, font, logo/wordmark/badge URLs, CTA claim, copy rules, approved lines, enabled placements, and the Knowledge page path holding its prose context.",
|
|
726
|
+
"category": "marketing"
|
|
727
|
+
},
|
|
728
|
+
{
|
|
729
|
+
"name": "list_creatives",
|
|
730
|
+
"description": "List the project's Creatives (photo + headline + layout), newest display number first. Filter by state or brandKitId.",
|
|
731
|
+
"category": "marketing"
|
|
732
|
+
},
|
|
733
|
+
{
|
|
734
|
+
"name": "get_creative",
|
|
735
|
+
"description": "One Creative in full: its fields, readiness checklist, and the other lettered variants in its family.",
|
|
736
|
+
"category": "marketing",
|
|
737
|
+
"requiredParams": [
|
|
738
|
+
"creativeId"
|
|
739
|
+
]
|
|
740
|
+
},
|
|
741
|
+
{
|
|
742
|
+
"name": "create_creative",
|
|
743
|
+
"description": "Create a Creative, or with variantOf cut a lettered variant of an existing one. Refused with the failing rule's message on a validation or readiness error.",
|
|
744
|
+
"category": "marketing"
|
|
745
|
+
},
|
|
746
|
+
{
|
|
747
|
+
"name": "update_creative",
|
|
748
|
+
"description": "Patch a Creative's fields. version must match the current value or the write is refused with a 409 conflict.",
|
|
749
|
+
"category": "marketing",
|
|
750
|
+
"requiredParams": [
|
|
751
|
+
"creativeId",
|
|
752
|
+
"version"
|
|
753
|
+
]
|
|
754
|
+
},
|
|
755
|
+
{
|
|
756
|
+
"name": "upload_creative_photo",
|
|
757
|
+
"description": "Upload a photo into the project's photo library from a local file or a public URL, recording its provenance for the readiness checklist. Width/height are read from the file.",
|
|
758
|
+
"category": "marketing"
|
|
759
|
+
},
|
|
760
|
+
{
|
|
761
|
+
"name": "render_creative",
|
|
762
|
+
"description": "Render a Creative locally (Playwright on this machine) to upload-ready JPEG frames, or a contact sheet with previewOnly. Runs synchronously and returns the render id, state and frame URLs.",
|
|
763
|
+
"category": "marketing",
|
|
764
|
+
"requiredParams": [
|
|
765
|
+
"creativeId"
|
|
766
|
+
]
|
|
767
|
+
},
|
|
768
|
+
{
|
|
769
|
+
"name": "preview_creative",
|
|
770
|
+
"description": "Look at a Creative: returns its latest contact sheet as an image, rendering a fresh preview first if none exists yet.",
|
|
771
|
+
"category": "marketing",
|
|
772
|
+
"requiredParams": [
|
|
773
|
+
"creativeId"
|
|
774
|
+
]
|
|
775
|
+
},
|
|
776
|
+
{
|
|
777
|
+
"name": "attach_creative_to_post",
|
|
778
|
+
"description": "Attach a Creative's rendered frames to a Post as its media, placement to platform, for every destination without its own custom media. Call get_post_status (postId) to verify.",
|
|
779
|
+
"category": "marketing",
|
|
780
|
+
"requiredParams": [
|
|
781
|
+
"creativeId",
|
|
782
|
+
"postId"
|
|
783
|
+
]
|
|
784
|
+
},
|
|
785
|
+
{
|
|
786
|
+
"name": "create_experiment",
|
|
787
|
+
"description": "Start a hook experiment comparing every lettered variant of a Creative's family; settles itself once every variant has published and reported.",
|
|
788
|
+
"category": "marketing",
|
|
789
|
+
"requiredParams": [
|
|
790
|
+
"creativeId"
|
|
791
|
+
]
|
|
698
792
|
}
|
|
699
793
|
]
|
|
700
794
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"creative.d.ts","sourceRoot":"","sources":["../../src/commands/creative.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAA;AAKnC,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAiEvD"}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { getConfig, resolveProject } from '../mcp/config.js';
|
|
2
|
+
import { resolveCreativeId } from '../lib/creative-id.js';
|
|
3
|
+
import { renderCreative } from '../lib/creative-render.js';
|
|
4
|
+
export function registerCreative(program) {
|
|
5
|
+
const creative = program.command('creative').description('Render Conductor Creatives locally with Playwright');
|
|
6
|
+
creative
|
|
7
|
+
.command('render <creativeId>')
|
|
8
|
+
.description('Render a Creative to upload-ready JPEG frames (or a preview contact sheet) using a local browser')
|
|
9
|
+
.option('--preview', 'Render a preview contact sheet instead of full-size frames')
|
|
10
|
+
.option('--renderer <name>', 'Attribution tag stored on the render (default: cli)')
|
|
11
|
+
.option('--workflow-run-id <id>', 'Attribute this render to a Workflow run')
|
|
12
|
+
.addHelpText('after', `
|
|
13
|
+
Examples:
|
|
14
|
+
conductor creative render 12a
|
|
15
|
+
conductor creative render 12a --preview
|
|
16
|
+
conductor creative render 12a --renderer workflow --workflow-run-id run_123`)
|
|
17
|
+
.action(async (creativeId, options) => {
|
|
18
|
+
let config;
|
|
19
|
+
try {
|
|
20
|
+
config = getConfig();
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
24
|
+
process.exitCode = 1;
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const resolution = resolveProject(config);
|
|
28
|
+
if (resolution.mismatch) {
|
|
29
|
+
console.error(`Refusing to act: the current directory (${process.cwd()}) is a git repository not linked to any ` +
|
|
30
|
+
'configured Conductor project. Run `conductor init` here, or set CONDUCTOR_PROJECT_ID.');
|
|
31
|
+
process.exitCode = 1;
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
config.projectId = resolution.projectId;
|
|
35
|
+
try {
|
|
36
|
+
const result = await renderCreative({
|
|
37
|
+
creativeId: await resolveCreativeId(creativeId, config),
|
|
38
|
+
previewOnly: !!options.preview,
|
|
39
|
+
renderer: options.renderer ?? 'cli',
|
|
40
|
+
workflowRunId: options.workflowRunId,
|
|
41
|
+
}, config, (...args) => console.log(...args));
|
|
42
|
+
console.log(`render ${result.renderId ?? '(none)'}: ${result.state ?? (result.ok ? 'SUCCEEDED' : 'FAILED')}`);
|
|
43
|
+
for (const frame of result.frames) {
|
|
44
|
+
const label = frame.sequenceIndex != null ? `${frame.placementKey}[${frame.sequenceIndex}]` : frame.placementKey;
|
|
45
|
+
const size = frame.width && frame.height ? `${frame.width}x${frame.height}` : '';
|
|
46
|
+
console.log(` ${label} ${size} ${frame.url ?? ''}`.trimEnd());
|
|
47
|
+
}
|
|
48
|
+
if (result.error)
|
|
49
|
+
console.error(result.error);
|
|
50
|
+
if (!result.ok || result.state === 'FAILED') {
|
|
51
|
+
process.exitCode = 1;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
56
|
+
process.exitCode = 1;
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=creative.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"creative.js","sourceRoot":"","sources":["../../src/commands/creative.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/* assertions.js — the in-page checks that refuse a wrong render, ported from
|
|
2
|
+
* nexus-marketing/social/export-png.mjs and made brand-agnostic: the accent
|
|
3
|
+
* check reads brand.tokens.accent (via the board's own resolved --cc-accent
|
|
4
|
+
* custom property) instead of a hardcoded Rexipe coral, and the font check
|
|
5
|
+
* reads brand.fontFamily instead of a hardcoded "DM Sans". A brand kit with
|
|
6
|
+
* neither set still gets a meaningful check — the board's actually-applied
|
|
7
|
+
* value is what is verified, not a specific brand's value.
|
|
8
|
+
*
|
|
9
|
+
* Split in two for testability:
|
|
10
|
+
* - pure math (relativeLuminance, parseRgb, contrastRatio, spillDetect,
|
|
11
|
+
* safeZoneIntrusion): plain data in, plain data out, no DOM. Exercised
|
|
12
|
+
* directly with fixture rects in test/assertions.test.mjs.
|
|
13
|
+
* - runAssertions(board, placement, brand): reads the live DOM
|
|
14
|
+
* (getBoundingClientRect, getComputedStyle, document.fonts, Image
|
|
15
|
+
* loading) and calls the pure functions above. Only runs in a real
|
|
16
|
+
* browser (Playwright); this is what frame.js calls after fitBoard.
|
|
17
|
+
*
|
|
18
|
+
* These are the same checks fitBoard()'s own `fits()` helper in render.js
|
|
19
|
+
* uses to decide whether type fits (spill + the bottom safe zone) — this
|
|
20
|
+
* module reports the same two conditions as errors with a human-readable
|
|
21
|
+
* message, plus checks fits() has no opinion on: fonts loaded, images
|
|
22
|
+
* loaded (including a bleed layout's CSS background photo, invisible to a
|
|
23
|
+
* plain <img> check), headline contrast, the accent colour, and the
|
|
24
|
+
* rendered box size.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/* ── pure math ─────────────────────────────────────────────────────────── */
|
|
28
|
+
|
|
29
|
+
/** WCAG relative luminance of an [r, g, b] (0-255) triplet. */
|
|
30
|
+
export function relativeLuminance([r, g, b]) {
|
|
31
|
+
const [R, G, B] = [r, g, b].map((v) => {
|
|
32
|
+
v /= 255;
|
|
33
|
+
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
|
34
|
+
});
|
|
35
|
+
return 0.2126 * R + 0.7152 * G + 0.0722 * B;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** "rgb(1, 2, 3)" / "rgba(1, 2, 3, 0.5)" -> [1, 2, 3]; anything else -> null. */
|
|
39
|
+
export function parseRgb(str) {
|
|
40
|
+
// Anchored, bounded and limited to digits, dots, commas and spaces, so no input can make it backtrack.
|
|
41
|
+
const s = String(str || '').trim();
|
|
42
|
+
if (s.length > 64) return null;
|
|
43
|
+
const m = /^rgba?\(([0-9., ]+)\)$/.exec(s);
|
|
44
|
+
if (!m) return null;
|
|
45
|
+
const parts = m[1].split(',').map((s) => parseFloat(s));
|
|
46
|
+
return parts.length >= 3 && parts.slice(0, 3).every((n) => !Number.isNaN(n)) ? parts.slice(0, 3) : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** WCAG contrast ratio between two [r, g, b] triplets, rounded to 1dp. 3:1 is
|
|
50
|
+
* the floor for large text, and every headline this engine renders is large. */
|
|
51
|
+
export function contrastRatio(fgRgb, bgRgb) {
|
|
52
|
+
const l1 = relativeLuminance(fgRgb);
|
|
53
|
+
const l2 = relativeLuminance(bgRgb);
|
|
54
|
+
const ratio = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
|
55
|
+
return Math.round(ratio * 10) / 10;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Nodes whose box crosses the artboard's own edges — a wrapped headline or a
|
|
59
|
+
* too-tall CTA row bleeding off the visible artboard. `nodes` is
|
|
60
|
+
* `[{ name, rect: {top,bottom,right} }]`; `boardRect` is the artboard's own
|
|
61
|
+
* `{top,bottom,right}`. Returns human-readable messages, empty when clean. */
|
|
62
|
+
export function spillDetect(boardRect, nodes) {
|
|
63
|
+
const issues = [];
|
|
64
|
+
for (const { name, rect } of nodes) {
|
|
65
|
+
if (rect.top < boardRect.top - 1) issues.push(`${name} over top by ${Math.round(boardRect.top - rect.top)}px`);
|
|
66
|
+
if (rect.bottom > boardRect.bottom + 1) issues.push(`${name} past bottom by ${Math.round(rect.bottom - boardRect.bottom)}px`);
|
|
67
|
+
if (rect.right > boardRect.right + 1) issues.push(`${name} past right by ${Math.round(rect.right - boardRect.right)}px`);
|
|
68
|
+
}
|
|
69
|
+
return issues;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Copy intruding on the placement's reserved bottom safe zone (where a
|
|
73
|
+
* platform's own UI — Instagram's story CTA, TikTok's caption — sits).
|
|
74
|
+
* Mirrors render.js's own `fits()` bottom check exactly, so fitting and this
|
|
75
|
+
* safety net can never disagree. Only the bottom zone is enforced: the top
|
|
76
|
+
* reserve varies far more per platform and is documented, not asserted, the
|
|
77
|
+
* same call nexus-marketing's export-png.mjs made. */
|
|
78
|
+
export function safeZoneIntrusion(boardRect, nodes, safe) {
|
|
79
|
+
const issues = [];
|
|
80
|
+
const bottom = (safe && safe.bottom) || 0;
|
|
81
|
+
if (!bottom) return issues;
|
|
82
|
+
for (const { name, rect } of nodes) {
|
|
83
|
+
const over = rect.bottom - (boardRect.bottom - bottom);
|
|
84
|
+
if (over > 1) issues.push(`${name} intrudes ${Math.round(over)}px into the bottom safe zone (reserves ${bottom}px)`);
|
|
85
|
+
}
|
|
86
|
+
return issues;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/* ── DOM-dependent (real browser only) ────────────────────────────────────── */
|
|
90
|
+
|
|
91
|
+
const CHECKED_SELECTOR = '.cc-headline, .cc-body, .cc-cta, .cc-lockup';
|
|
92
|
+
|
|
93
|
+
function rectOf(node) {
|
|
94
|
+
const r = node.getBoundingClientRect();
|
|
95
|
+
return { top: r.top, bottom: r.bottom, right: r.right };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* Resolves any valid CSS color string (hex, rgb(), a named color, or a
|
|
99
|
+
* var() the browser has already inlined) to an [r,g,b] triplet by letting
|
|
100
|
+
* the browser's own parser do the work via a throwaway element — the only
|
|
101
|
+
* reliable way to normalize an arbitrary brand token value. */
|
|
102
|
+
function resolveCssColor(value) {
|
|
103
|
+
const probe = document.createElement('span');
|
|
104
|
+
probe.style.display = 'none';
|
|
105
|
+
probe.style.color = value;
|
|
106
|
+
document.body.appendChild(probe);
|
|
107
|
+
const rgb = parseRgb(getComputedStyle(probe).color);
|
|
108
|
+
probe.remove();
|
|
109
|
+
return rgb;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function sameRgb(a, b) {
|
|
113
|
+
return Boolean(a && b && a[0] === b[0] && a[1] === b[1] && a[2] === b[2]);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/* Runs every check against a live, fitted `.cc-board` and returns
|
|
117
|
+
* `{ errors, warnings, width, height }`. `placement` is this placement's own
|
|
118
|
+
* registry entry (pixel size, safe zone); `brand` is the same object handed
|
|
119
|
+
* to renderBoard. An empty `errors` array means the frame is safe to ship;
|
|
120
|
+
* `warnings` are non-fatal (e.g. a soft, upscaled photo). */
|
|
121
|
+
export async function runAssertions(board, placement, brand) {
|
|
122
|
+
const errors = [];
|
|
123
|
+
const warnings = [];
|
|
124
|
+
const br = rectOf(board);
|
|
125
|
+
|
|
126
|
+
const nodes = [...board.querySelectorAll(CHECKED_SELECTOR)].map((n) => ({ name: n.className, rect: rectOf(n) }));
|
|
127
|
+
|
|
128
|
+
const spill = spillDetect(br, nodes);
|
|
129
|
+
if (spill.length) errors.push(`text spill: ${spill.join('; ')}`);
|
|
130
|
+
|
|
131
|
+
const safe = {
|
|
132
|
+
top: Number(board.dataset.safeTop) || 0,
|
|
133
|
+
bottom: Number(board.dataset.safeBottom) || 0,
|
|
134
|
+
right: Number(board.dataset.safeRight) || 0,
|
|
135
|
+
};
|
|
136
|
+
const intrusion = safeZoneIntrusion(br, nodes, safe);
|
|
137
|
+
if (intrusion.length) errors.push(`safe-zone intrusion: ${intrusion.join('; ')}`);
|
|
138
|
+
|
|
139
|
+
if (brand && brand.fontFamily) {
|
|
140
|
+
const headlineEl = board.querySelector('.cc-headline');
|
|
141
|
+
const size = Math.round(parseFloat(getComputedStyle(headlineEl).fontSize)) || 16;
|
|
142
|
+
if (!document.fonts.check(`800 ${size}px "${brand.fontFamily}"`)) {
|
|
143
|
+
errors.push(`font "${brand.fontFamily}" did not load (would export in a fallback face)`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const imgs = [...board.querySelectorAll('img')];
|
|
148
|
+
const broken = imgs.filter((i) => !i.complete || i.naturalWidth === 0).map((i) => i.getAttribute('src'));
|
|
149
|
+
if (broken.length) errors.push(`image(s) failed to load: ${broken.join(', ')}`);
|
|
150
|
+
|
|
151
|
+
// A bleed/card layout paints its photo as a CSS background, invisible to
|
|
152
|
+
// the <img> check above — probe it directly, the same way a typo'd path
|
|
153
|
+
// would otherwise export a flat, photo-less board and pass everything else.
|
|
154
|
+
const bgImage = getComputedStyle(board).backgroundImage;
|
|
155
|
+
const bgUrlMatch = /url\("?(.+?)"?\)/.exec(bgImage || '');
|
|
156
|
+
if (bgUrlMatch && bgUrlMatch[1]) {
|
|
157
|
+
const probe = await new Promise((done) => {
|
|
158
|
+
const img = new Image();
|
|
159
|
+
img.onload = () => done({ ok: true, w: img.naturalWidth, h: img.naturalHeight });
|
|
160
|
+
img.onerror = () => done({ ok: false });
|
|
161
|
+
img.src = bgUrlMatch[1];
|
|
162
|
+
});
|
|
163
|
+
if (!probe.ok) {
|
|
164
|
+
errors.push(`background photo failed to load: ${bgUrlMatch[1]}`);
|
|
165
|
+
} else if (placement && probe.w < placement.w * 2 && probe.h < placement.h * 2) {
|
|
166
|
+
// Soft photography check: a source smaller than the 2x frame is
|
|
167
|
+
// upscaled, so the photo goes soft while the text stays crisp.
|
|
168
|
+
warnings.push(`photo is ${probe.w}x${probe.h}, upscaled to fit ${placement.w * 2}x${placement.h * 2}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const headlineEl = board.querySelector('.cc-headline');
|
|
173
|
+
const em = headlineEl && headlineEl.querySelector('em');
|
|
174
|
+
if (em) {
|
|
175
|
+
const emRgb = parseRgb(getComputedStyle(em).color);
|
|
176
|
+
const expectedRaw = getComputedStyle(board).getPropertyValue('--cc-accent').trim();
|
|
177
|
+
const expectedRgb = expectedRaw ? resolveCssColor(expectedRaw) : null;
|
|
178
|
+
if (!emRgb) {
|
|
179
|
+
errors.push('accent colour did not resolve on the headline');
|
|
180
|
+
} else if (expectedRgb && !sameRgb(emRgb, expectedRgb)) {
|
|
181
|
+
errors.push(`accent colour unresolved: headline em is ${getComputedStyle(em).color}, brand accent resolves to ${expectedRaw}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let contrast = null;
|
|
186
|
+
if (headlineEl) {
|
|
187
|
+
const fgRgb = parseRgb(getComputedStyle(headlineEl).color);
|
|
188
|
+
let bgRgb = null;
|
|
189
|
+
if (fgRgb) {
|
|
190
|
+
for (let n = headlineEl; n && n !== document.body; n = n.parentElement) {
|
|
191
|
+
const cs = getComputedStyle(n);
|
|
192
|
+
if (cs.backgroundImage && cs.backgroundImage !== 'none') {
|
|
193
|
+
const stops = (cs.backgroundImage.match(/rgba?\([^)]+\)/g) || []).map(parseRgb).filter(Boolean);
|
|
194
|
+
if (stops.length) {
|
|
195
|
+
// A scrim gradient: its darkest declared stop is the worst case.
|
|
196
|
+
bgRgb = stops.reduce((worst, c) => (relativeLuminance(c) < relativeLuminance(worst) ? c : worst));
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const col = cs.backgroundColor;
|
|
201
|
+
if (col && !/rgba?\(0,\s*0,\s*0,\s*0\)|transparent/.test(col)) {
|
|
202
|
+
const rgb = parseRgb(col);
|
|
203
|
+
if (rgb) { bgRgb = rgb; break; }
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (fgRgb && bgRgb) contrast = contrastRatio(fgRgb, bgRgb);
|
|
208
|
+
}
|
|
209
|
+
if (contrast !== null && contrast < 3) {
|
|
210
|
+
errors.push(`headline contrast is ${contrast}:1 against its background, below the 3:1 floor for large text`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const boxRect = board.getBoundingClientRect();
|
|
214
|
+
const width = Math.round(boxRect.width);
|
|
215
|
+
const height = Math.round(boxRect.height);
|
|
216
|
+
if (placement && (width !== placement.w || height !== placement.h)) {
|
|
217
|
+
errors.push(`artboard is ${width}x${height}, expected ${placement.w}x${placement.h}`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return { errors, warnings, contrast, width, height };
|
|
221
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/* Brand font loading shared by the editor (mount.js) and the headless render
|
|
2
|
+
* pages (frame.js, sheet.js). Browser-only; a no-op outside a document. */
|
|
3
|
+
|
|
4
|
+
/* Loads `brand.fontUrl` as a <link rel="stylesheet"> (a Google Fonts URL or a
|
|
5
|
+
* self-hosted @font-face sheet both work this way) and resolves once the
|
|
6
|
+
* browser has it, or immediately if there is no fontUrl. Idempotent: calling
|
|
7
|
+
* it twice with the same URL does not insert a second <link>. Does not touch
|
|
8
|
+
* `document.fonts.ready` itself — callers that need the font *painted*, not
|
|
9
|
+
* just requested, should await that separately (mountBoard does). */
|
|
10
|
+
const loadedFontUrls = new Set();
|
|
11
|
+
export function loadFont(brand) {
|
|
12
|
+
const href = brand && brand.fontUrl;
|
|
13
|
+
if (!href) return Promise.resolve();
|
|
14
|
+
if (loadedFontUrls.has(href)) return Promise.resolve();
|
|
15
|
+
if (typeof document === 'undefined') return Promise.resolve();
|
|
16
|
+
const existing = Array.from(document.querySelectorAll('link[rel="stylesheet"]')).find((l) => l.href === href);
|
|
17
|
+
if (existing) {
|
|
18
|
+
loadedFontUrls.add(href);
|
|
19
|
+
return Promise.resolve();
|
|
20
|
+
}
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const link = document.createElement('link');
|
|
23
|
+
link.rel = 'stylesheet';
|
|
24
|
+
link.href = href;
|
|
25
|
+
link.onload = () => { loadedFontUrls.add(href); resolve(); };
|
|
26
|
+
link.onerror = () => reject(new Error('failed to load font stylesheet: ' + href));
|
|
27
|
+
document.head.appendChild(link);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/* Loads the brand font and waits until its face is actually available, so
|
|
32
|
+
* fitting measures the real glyphs. Safe when the brand has no font. */
|
|
33
|
+
export async function ensureBrandFont(brand) {
|
|
34
|
+
await loadFont(brand);
|
|
35
|
+
const family = brand && brand.fontFamily;
|
|
36
|
+
if (family && typeof document !== 'undefined' && document.fonts && document.fonts.load) {
|
|
37
|
+
await Promise.all([400, 700].map((w) => document.fonts.load(`${w} 32px "${family}"`).catch(() => [])));
|
|
38
|
+
}
|
|
39
|
+
}
|