Carousel
Turns a blog post into a posting-ready carousel: images for Instagram, a PDF document post for LinkedIn, captions, alt text, and an HTML review page for sign-off.
Setup: everything brand-specific lives in
brand.jsonat the skill root. Palette, logo, type, character budgets, and voice rules are all read from there. Fill it in once. Do not edit the scripts to change brand values. See the README for the full field reference.
Load the voice skill first, if you have one
Every word on a slide is customer-facing copy. If your brand has a voice skill, load it before writing any headline, body line, or caption. This skill governs structure and layout; the voice skill governs language. Where they seem to conflict, the voice skill wins on wording.
The voice_rules block in brand.json is the machine-enforceable subset of
that: the rules worth failing a build over. It pairs with a written voice guide
rather than replacing one.
The pipeline
Blog → angle briefing (stop here) → card script → preflight → render → review page.
The card script is the pivot. Everything downstream reads from it, so all editing happens there, never in the rendered output.
Step 1. Read the source and brief the angles. Then stop.
Read the post. Pull out what actually carries a slide: statistics with named sources, case studies, customer quotes, and list structures.
Most posts support several carousels. Propose two or three angles and stop for a decision. Do not pick one silently. For each angle give:
- the hook it opens on
- why it earns attention, in one line
- its weakness, honestly stated
See references/angles.md for how to construct these and a worked example.
Step 2. Write the card script
Once an angle is chosen, write card_script.json. Full schema, the seven card
types, and the per-slot character budgets are in references/card-types.md.
Length is 6 to 8 slides, chosen by topic complexity, not by how much
material exists. A simple topic is 6. A data-heavy one with a case study is 8.
Going longer costs completion rate. The exact range is configurable in
brand.json under deck.
Cutting is normal and expected. Material that does not fit moves into the
caption, where the "read the full post" work belongs anyway. Record what was
cut and why in meta.cut_for_length, so the tradeoff stays visible instead of
looking like an oversight.
Every deck opens on a cover and closes on a cta. Slide 1 is the feed
thumbnail before it is anything else, so the hook has to survive at thumbnail
size.
Step 3. Preflight
python3 scripts/preflight.py card_script.json
This must exit clean before rendering. It enforces the per-slot character
budgets and the voice rules from brand.json, and it catches things the eye
misses: banned constructions, em dashes outside attribution lines, deck length,
undefined or repeated themes, alt-text count, and captions over the platform
limits.
Fix the copy. Never widen a budget to make an error disappear. The budgets are derived from the template, so a bust means the slide would have shrunk its own type to stay inside the box.
Step 4. Render
python3 scripts/render_carousel.py card_script.json out/
Produces the 1080×1350 PNGs and the LinkedIn PDF. Watch stdout for autofit warnings: any slide reporting a scale below 1.00 had copy too long for its slot, and the fix is shorter copy, not smaller type.
Step 5. Build the review page
python3 scripts/build_review.py card_script.json out/ out/carousel-review.html "headlines: DM Sans 800"
One self-contained file with images and fonts embedded, so it works offline and can be sent to a reviewer as a single attachment. It shows the swipeable deck, every caption with a live character count, and the per-slide alt text. Share this for approval rather than a folder of PNGs.
Posting
- LinkedIn — upload the PDF as a document post. Use the LinkedIn caption.
- Instagram — upload the PNGs in filename order. Use the Instagram caption.
- Alt text — one entry per slide, in
card_script.json. Not optional. Preflight fails if the count does not match the deck.
Design system
Set in brand.json. Do not restyle per deck; consistency across the feed is the
point.
Palette. Themes are named background/foreground/accent sets. Alternate slide themes so the swipe has rhythm; preflight warns on two identical backgrounds in a row.
Type. One family throughout by default. headline_family and
headline_scale exist for brands whose display face sets optically larger or
smaller than the body face at the same px value.
Logo. Placed automatically on every slide, including the cover. The variant is computed from background luminance and the contrast of the coloured mark against that background, so full colour is used wherever it is legible and mono is used where it is not. On a slide whose background is the brand accent, the mark would score near 1.0 contrast and disappear, so that slide correctly falls back to mono. Because it is computed rather than a lookup table, adding a new background colour needs no logo work.
Environment
Needs playwright with chromium, img2pdf, and Pillow. Fonts and logos ship
in assets/, so there is no network dependency at render time.
pip install playwright img2pdf Pillow && python3 -m playwright install chromium