Releases: addsumtech/slides_maker
Release list
v4.4.0
Two behaviour changes to know about before upgrading.
(1) Section fan-out now starts at ~9 slides, not 15+. A 9–14 slide deck that used to be written
by one author is now authored by parallel section agents against a locked design plan and a shared
style.py. The old threshold was reasoned on TOKEN cost, where one author genuinely wins because
nothing is duplicated — but wall clock was never on the scale, and measured across five real build
sessions the build step is 40–71% of all model-active minutes, generated serially by one agent
whose context reaches ~500k by mid-build. Fan-out spends the same tokens concurrently and hands each
author a fresh ~60k context. Fanning out the BUILD deliberately does not escalate the REVIEW
shape: the sectioned critic panel still belongs to a genuinely large deck (~15+), because one
reviewer reading a 40-slide document reviews it worse — coupling the two thresholds would add
critics to every mid-sized deck and spend the wall clock the fan-out just saved. If you are
optimising for tokens rather than wall clock, the old threshold was correct and is documented as
such.
(2) render_deck.py rejects an unrecognised flag instead of resolving it to the output
directory. render_deck.py deck.pptx --gate-check --briefing used to run the hand-off gate at the
presented legibility floor — a briefing deck, whose budget is ~150 words, held to ~40 — and print
nothing either way.
Why this release exists
A user reported that one deck took ~5 hours to build. Measured across six real build
transcripts, the cause was not the critics, not the scripts and not the user:
- 83–90% of all output tokens are reasoning, not deliverables (tool payloads 9–15%, visible text ~1.5%)
- median context 440–575k per turn, p90 ~900k, hitting the 1M cap and forcing compaction
- ~1,200 tool calls per session at ~1,400 output tokens each
- every deterministic script is irrelevant to this: a full 14-slide render is 5.4s, the whole gate suite ~8s
SKILL.md already stated the governing equation — the cost of a deck is round-trips × context —
and its own measurement of a 12-page build: 122 calls averaging 1.00 tool per round-trip. The
rule was stated once, in the preamble, and never at the two places it binds. It was advisory, and it
was skipped.
Verified by rebuilding the same 14-slide deck on the new pipeline: hard lint findings 11 → 0,
and a real 2-round × 2-lens critic panel ran where the baseline had waived the critic entirely.
Round-trips 512 → 152, output tokens 1,358,258 → 154,154.
Honest bound: the critical path went from ~2.4h to roughly 2h for a 14-slide deck with the full
two-round critic. Most of what remains is subagent stages (planner, art director, section authors,
two critic rounds), which these changes do not touch. Getting under an hour requires cutting a
critic round or reducing scope — both quality trades, and neither is done here.
Fixed — three measurements that could not see what they were measuring
deckkit.measure_textgainedfont=. It never passed a face to the metric, so it always
measured in the deck default. The same command string is 4.04in in Helvetica and 5.44in in Courier
New, so a monospace line came back 26% narrow — enough to report a 9.2in line as fitting an
8.25in box. Nothing downstream can catch that: the box is then BUILT to the wrong size and every
later geometry check agrees with the box.fit_text_sizehad always takenfont=; the asymmetry
was the bug. It cost one measured build a full rebuild.lint_deckhad two definitions of "the title"._find_titleanswers it as "a TITLE
placeholder, else the first text ≥14.5pt in the top 28%". The deck-stats scan answered it
independently: top 20% of the canvas, biggest short box, no size floor at all. A title sitting
between the two bands was invisible to the stats scan, so whatever chrome sat above it won —
reproduced with a 30pt title at 1.30in and a 9.5pt margin word at 0.52in. That is not cosmetic:
title_txtfeeds the title spine, the titles-only test that the coordinator and both critic
lenses read as the deck's argument. One measured deck presented its argument as three pieces of
margin chrome. It also fed INVERTED TYPE HIERARCHY, which then advised that a correct 30pt title
was smaller than its body tier. Zero test coverage before this release.lint_deckmeasured text in a face it refused to look at. It imports deckkit, then dropped
face and weight on the very line that readsr.font, after which every Latin character was charged
a flat 0.52 em. Measured on Helvetica Neue at 26pt: uppercase runs +33% over the estimate and
narrow glyphs and spaces −44% — so it both missed real collisions and invented others. The
invented ones are the expensive half: an author rewrote two correct titles to satisfy a wrong
measurement, which is rework the gate itself caused.code_blocksays when a line will clip. It setsword_wrap=Falseso indentation survives, and
its own comment nominated the docstring's "keep snippets short" as the enforcement. A clip is
invisible to every gate — the height model stays right, the shape stays on canvas, and the end of
the line is simply not on the slide.preflight_check's mono-wrap check stopped crying wolf. It charged the whole paragraph the
monospace advance whenever any run was monospace (~20% over on alabel: <command>line), and
announced "word_wrap is off, so they run off it silently" without having looked atword_wrap.
Three of its findings on one real build were false, and separating them from the true one cost real
time. This precision is load-bearing, not tidiness: the check FAILS a build, and a builder who
learns it cries wolf routes around the one defect it exists for — a shipped install command that
copied as a 404 path.
Added — the cost of a build is measurable, and the repetition is gone
scripts/roundtrip_budget.pymeasures a build from its session transcript: round-trips, the
batching ratio (tool calls per round-trip), median context re-sent, and whether the render
self-check read the slide PNGs in one message or one at a time. Step 6 fills the hand-offcost:
line from it. It refuses to guess when it cannot find the right transcript — it used to fall
back to the newest transcript anywhere on the machine and reported a stranger's session as yours,
including "image reads 0" for a run that made 28.scripts/dispatch_brief.pywrites the deck brief once and prints a pointer prompt for every
dispatch. Measured: nine dispatches cost 41,203 output tokens at ~4,600 each, and almost all of
it was the same interview answers, paths, search cap and CONTRACT CARD retyped nine times. The
generated prompt is ~220 tokens. Second reason: it makes the contract card one artifact instead
of nine reconstructions, whichcritic-panel.mdasks for and has no way to check. It refuses to
emit a prompt while any required section of the brief is unfilled.scripts/slide_index.pyprintsslide N -> file:line functionplus each slide's plan-row
docstring. Section fan-out means the coordinator did not write the code, so a finding on slide 7
otherwise begins by grepping three modules it has never read.- Icons render ~300× faster.
rasterize()had no output cache, and cairocffi cannot find a
Homebrew libcairo, so every icon fell through to headless Chrome: 5.05s → 0.021s cold, 0.003s
warm, output equivalent (ink coverage within 0.0002). That also exposed a latent quality bug —
only the Chrome backend supersampled, so on any machine where cairosvg did load, icons silently
shipped at ⅓ resolution. All three backends now share one supersample constant, andcheck_env
stops reporting the slowest backend as a plain[ok]. - Codex image concurrency scales with the machine (
cores//3, clamped 2–4) instead of a flat 2,
against work that spends its time waiting on a hosted model rather than on a local core. A 4-core
CI box keeps exactly the old behaviour. - Batching restated where it binds. Step 5 now says to read every slide PNG in ONE message and to
apply the whole promoted fix list in ONE message, each with its reason — and the render self-check
gained a required per-slide verdict line, because batching the reads must not blur fourteen
judgments into one impression.
Not done, deliberately
SKILL.md is not slimmed. It is ~44.8k tokens of the ~302k per-call context — the smallest
term — and this repo has a recorded measured regression from exactly that change: a "lossless" split
that scored a perfect content-preservation pass, passed every automated gate, and still shipped a
deck with zero icons and a hand-rolled chart. Context size was also tested directly and dropped as a
lever: latency against context on cache-hit turns regressed at R² = 0.009, and deleting the
entire skill from context has a ceiling of ~2.4 minutes.
v4.3.0
v4.2.0
One behaviour change to know about before upgrading. render_deck.py --gate-check /
--deliverables now require a critic waiver to be CLASSIFIED, not merely written. A
.deck-gates.json carrying {"critic": {"waived": "<any string>"}} used to print one line and
pass; it now needs waived_category from a fixed set (no-dispatch-on-host /
already-reviewed-minor-edit / user-waived / external-deck), a reason of real substance, and
— for no-dispatch-on-host — an explicit inline_ran boolean. Existing scripts that waive the
critic will start failing until they say which kind of skip it is. The verdict: consent path is
untouched. Why: the Codex delivery gate had required a distinct schema-valid review artifact per
lens for a while, and the shared path accepted any sentence — measured, a hand-typed waiver
carried a whole 10-slide deck through all hand-off gates pass with no independent critic ever
involved.
Added — the checklist now has something that reads it
scripts/preflight_check.pydecides the mechanical half of PRE-FLIGHT and prints the other
half as explicitly NOT covered. Eleven of the twelve ticks were self-attested: the model wrote
twelve checkmarks and nothing anywhere read them, which is the exact silent-skip class the
checklist exists to prevent. It decides items 1 (speaker-notes coverage), 2 (build timing vs
--static), 3b (everybuild:docstring having matchingBuild.stepcalls, by AST), 4 (native
charts +equation_native), 7 (an as-of date), 8 (meta-annotations and unfilled
<slot>/{slot}text leaked onto a slide) and 10 (fonts that may not resolve elsewhere) —
and prints 5 / 6 / 6b / 9 / 11 as judgment calls that are still yours, so a tick never looks
covered. Items 2, 7 and 10 are advisory rather than failures by construction: whether builds
were opted in, whether any claim is time-bound, and whether a font exists on the presenter's
machine are all facts absent from the file, and a checker that fails on what it cannot know is
one people learn to ignore. (Item 10 learned this the hard way — it shipped as a failure, and CI
on Ubuntu promptly declared every macOS-authored deck "not ready to render".item10_fontscan
now return PASS / ADVISORY / NOT CHECKABLE and never FAIL, verified by AST over its own return
statements and by stubbing the installed-font list empty.)scripts/check_reference_code.pyresolves everydeckkit.*call taught in the skill's own
prose against the real module — unknown helper, bad keyword, deadreferences/*.mdpointer, and
the silent.fore_color.alpha = ...no-op. Prose that teaches an API is still an API contract,
and nothing used to check it.- CI now runs both, plus the Codex gate's own 437 lines of tests, which shipped wired to nothing
(grep -c codex ci.ymlwas 0). Every new step asserts the suite RAN rather than merely exited 0. tests/test_critic_waiver_gate.py(7 cases) andtests/test_preflight_check.py(11 cases). The
latter asserts both halves: that mechanical defects are caught, and that a clean run still says
the five judgment items are unticked.
Fixed — four wrong API facts were shipping in the shared references
- A scrim recipe that erased the image it was protecting. Two unfenced shared references taught
scrim.fill.fore_color.alpha = int(0.6 * 255). python-pptx'sColorFormathas noalphasetter
and no__slots__, so the assignment raises nothing, writes nothing, and yields a 100% opaque
rectangle. Measured on a real deck: the full-bleed cover plate rendered pure black (brightest
pixel 0/765) and every lint passed green — text is painted last soTEXT NOT VISIBLE/
OCCLUSIONcannot fire, and white-on-pure-black scores ~21:1 soTEXT-ON-IMAGE CONTRASTreported
the best number in the deck. Nowdeckkit.scrim_overlay(), whose gradient carries a real
<a:alpha>on DrawingML's 0–100000 scale (not 0–255, the other half of the original bug). dk.card(...)— no such helper, and it was the checkmarked "Do" example while the crossed-out
"Don't" example was the one that actually worked.icon_tile(tile_color=...)— the parameter is
fill=.references/deckkit-component-guide.md— no such file.role=content-criticdispatch —
appears nowhere in the skill; the real contract is lens-based viaagents/critic.md+
validate_review.py.image-generation.md's "Planning workflow" list was split in half. A 90-line section had been
inserted between steps 2 and 3, orphaning steps 3–6 — including the emphatic "Do NOT pass
--count" rule — under an unrelated heading. Moved verbatim; the list reads 1–6 again.codex-runtime.mdnever namedreview_effort/fast_opt_in/thorough_panel/arbiters,
the keys its own delivery gate enforces — zero matches across every.mdin the skill. Scoped
honestly:review_effortdefaults tostandard, so a standard-tier run was never blocked, and
--initalready emitted most of them. The one real deadlock wasfast, whose required
fast_opt_inappeared in no doc and was missing from the--initskeleton. Both closed.build_example_generic.pypromised a spot-check that eachbuild:docstring has matching
Build.stepcalls, while its ownslide_pipelinedocstring declaredbuild:and implemented
none — the example was teaching a docstring that lies.file-inventory.mdwas missing three scripts and every test suite. An inventory that omits a
script is a navigation failure: the model cannot reach for a tool it cannot see.
Changed
- The Codex / OpenAI adapter now lives on
mainrather than a side branch. One tree serves both
runtimes, separated byCodex only:fences and thereferences/runtime-routing.mdprofiles
(shared= Claude Code / Kimi;codex+openai-gpt-bridged= OpenAI). Routing is
capabilities-first, not provider-inferred. The pre-merge tree is preserved on thecc_4.1.0
branch. - Local install artifacts (
.agents/,.claude/,skills-lock.json,OPTIMIZATION_SUMMARY.md)
are now ignored; a copy of the skill that drifts fromskills/should not ship in the repo.
v4.1.0
One behaviour change to know about before upgrading. render_deck.py --deliverables now also
refuses on DENSITY — more than a third of slides over the text budget stops the hand-off unless
.deck-gates.json carries a written "density": {"waived": "<reason>"}. Scripts that call
--deliverables on text-heavy decks will start failing. Two escapes, both intended: pass the
delivery mode (--selfread / --textheavy / --surface, the same flags lint_deck.py takes) so
the deck is held to its own budget, or record the waiver. Everything else below is additive.
Added — the render decides, not a list of shape types
TEXT NOT VISIBLEasks the one question with a bounded answer — does this line render any
glyphs at all? — straight from the pixels. Every previous occlusion rule enumerated causes (this
shape type, painted then, covering that much), and causes are unbounded: pictures were skipped
because alpha is unknowable from the file, groups and gradients for their own reasons, and anything
assembled from many small parts slipped a per-shape threshold. Three real decks shipped through
those holes with the gate reporting clean.OCCLUSION/RULE THROUGH TEXTnow measure the UNION of everything painted over a text
block, so a 150-tile field erasing a caption and a rule assembled from 40 dashes are caught like
the single shapes they look like.CAPTION NOT ALIGNED— a label must sit on the thing it labels. The panels of a composite
figure have no shape geometry, so captions get laid out on the text grid while the panels sit
where the plotting library put them, at widths that differ whenever the panels keep their own
aspect ratios. Read out of the pixels, so it works whether the panels are one composite or a row
of separate pictures. Ground is estimated per figure crop, not from the page, because a 4% grey
step between a figure's own paper and the page was enough to make the check disqualify itself.- A skipped check is audible. With no renders beside the deck the pixel-backed families disable
themselves and the run says so ([skipped] … NOT checked: …, pluspixel_checksin--json).
0 findingswith that line present is a different sentence from0 findingswithout it. - A CRASHED check is audible too. The per-slide statistics were wrapped in
except Exception: pass; one refactor took TEXT WALL, LAYOUT SAMENESS, UNDERFILLED, FLAT RHYTHM
and the body-size floor off every deck while the report still printed✓ clean. It now prints
[BROKEN] … NOT checked: …and the suite asserts that line is absent on every fixture deck.
Added — hand-off gates that do not depend on what the user feels like accepting
- DENSITY (above). Calibrated against the skill's own reference deck, which runs at a median of
27 words a slide — the budget was never the problem. It exists because the per-slideTEXT WALLwarning was correct and ignored on two consecutive decks (8/12, then 12/12 over budget). render_deck.py <deck>.pptx --gate-checkruns every hand-off gate, renders nothing, finishes
in under a second. The gates used to be reachable only through--deliverables, which Step 6
deliberately makes a decline-able offer — so on every deck where the user said "no PDF", the
strongest gate in the skill never ran, and nothing showed that it hadn't.- The design checkpoint now carries a
density:line as two numbers the gate can be compared
against (planned median load, planned count over 70), so density is decided at plan time instead
of discovered at lint time.
Fixed — the measurement everything else is built on
- Bold text in font COLLECTIONS (.ttc/.otc) measured at regular width, 3.9% short. Every guard
built onmeasure_textsilently passed while the text wrapped anyway: a caption sized for one
line put its second on top of a footer, and the lint agreed with the build because both were
computed from the same wrong number. Fixed by selecting the right face index inside the
collection — and CI now renders real strings and compares the ink against the prediction,
one-sided: the measurement may be conservative, never optimistic.
Changed
- The critic's second round is scoped, and the cost is published. Round 2 was a full fresh
whole-deck re-review; the measured A/B is inreferences/critic-panel.md— including a claim from
an earlier draft that the measurement did not support and that was removed rather than softened. - A third-party assessment does not wear its subject's livery. The LOGO PRINCIPLE situation
table gained the row for decks that evaluate an entity rather than speak for one. - SEARCH BUDGET. Web search is capped per SESSION and shared with every subagent; one research
fan-out (12 agents + 7 verifiers, none told a cap existed) spent all 200, and the bill arrived
hours later when a single lookup for a company's official logo could not run. Small NAMED lookups
go first, each dispatch states a per-agent cap, the round stays under half of what REMAINS (not
half the cap — the budget does not reset between decks), andsearches: planned/spentreaches the
hand-offcost:line. Stated where the fan-out is DISPATCHED, not 1000 lines later.
CI / repository
- The credential scanner was excluded from the repo by the very pattern it scans for (
*_secret*);
check_repo_integrity.pynow fails the build on any ignored source file or untracked CI path. - The regression suite ran ABOVE
pip install, so every run died on a bareModuleNotFoundError
that reads exactly like a lint regression. Moved after the installs, with an audible-skip backstop
so a silently skipped suite cannot pass. - Both READMEs realigned with the shipped skill.
Verification
22 regression assertions (from 18), two-sided: a PASS corpus that must stay clean, a FAIL corpus
where every defect must be caught, and the skill's own reference deck held at baseline. The lint
changes in this release were then attacked by an adversarial audit that required a runnable repro
for every claim — 20 raised, 5 confirmed, all 5 fixed here, including two claims in earlier commit
messages that the audit proved false.
v4.0.0
Why a major bump. render_deck.py --deliverables now REFUSES to run until the deck carries a
.deck-gates.json recording that its quality gates actually ran. Anything scripting --deliverables
breaks until it writes that file (or a written waiver). That is the breaking change; everything else
below is additive.
Added — review effort is a tier the user picks, not one the skill infers
- The rule that scales the loop ("scale the critic to stakes") always existed but was never offered.
Measured on one low-stakes deck: ~32 subagents and ~2M tokens across research + build + two
review rounds, with the user given no visibility and no choice. Step 0 now collects a one-word
review:tier —fast|standard|thorough— sizing BOTH cost centres, the research
fan-out and the review panel (they measured comparable: ~1.02M tokens of research against ~0.95M
of review). standardandthoroughare pure ALIASES for the low-stakes and high-stakes behaviour that
already existed — every cell of the tier table either restates the stakes section or defers to it.
A deck whose user says nothing behaves exactly as before.fastis the one genuinely new band
and is opt-in only, never derived: one generalist critic and one round is a real recall drop,
so it has to be asked for. Purpose decides the tier; SIZE never lowers it.- Floors no tier may move: the build-time geometry gate, the render lint, PRE-FLIGHT 12, at least one
independent critic, the EXISTENCE of the primary-source gate, and.deck-gates.json. Per-section
panels on large decks and corroborated consent on high-stakes decks are shapes, not weight, and are
unaffected by the tier.fastmay not silently ship over arevise— it returns to the user. - The hand-off now carries
review:andcost:lines. A dial whose bill is never shown builds no
intuition, so the next deck's choice is as blind as the last one's. - Pass the approved claim ledger to critics WHOLE, never a summary retyped for the dispatch. The
largest measured saving here: 7 of 8 "unsourced" findings in one round-2 review were briefing
artifacts, and the round they consumed was pure waste. - An earlier draft was rejected by a six-dimension adversarial audit (9 blockers, 27 majors) and
largely rewritten. Two invented optimisations were dropped rather than patched — "triggered
arbitration" atstandard(it contradicted four surviving statements that low-stakes dispatches no
arbiter, includingagents/arbiter.md's own brief) and narrowing round 2 to the changed slides (the
review-validation gate's scope buckets are whole-deck and per-section only, so a scoped round 2
bounces and costs an extra round). Both are recorded incritic-panel.mdas deliberate reversions
with their reasons.
Added — the hand-off gate, and a consent record that is evidence rather than a claim
--deliverablesrefuses without.deck-gates.json: a critic verdict, the design plan's
boldness/signature_move/carried_by/form_ledger, and the provenance pass's per-claim
claimslist (a summary tally is rejected on purpose — a tally is written by the same pass that
would have skipped the refutation). Any gate deliberately skipped is waived in writing, and the
tool prints the reason, so a skip is visible instead of invisible.validate_review.py --record <deck-dir>writes thecriticblock from the validated review
itself — verdict, blocker/major counts, the review file's path and sha256 — and--deliverables
re-reads that artifact. A record the model types at hand-off is self-certification: the model that
skipped the loop writes the same JSON as the model that ran it. Verified by execution across five
adversarial paths (review edited after recording → sha256 mismatch; file moved; record says consent
while the review says revise → "the artifact wins"; a review consenting with a blocker still open;
hand-written record with no source → still passes, labelledSELF-REPORTED). Backward compatible by
design: the old shape is not broken, it is made distinguishable.
Security — credentials are scanned by SHAPE, and the recurring sk-* false positive is documented
- A third-party multi-engine scan reported a possible hardcoded
sk-API key. Audited the full tree
and the full history (1,645 blobs, all refs): no credential exposure and nothing to revoke.
Every hit is a CSS class name — the direction-gate preview names its classes after slide skeletons
(sk-body,sk-split,sk-island,sk-band,sk-rail,sk-statement), andsk-is short for
skeleton. A real key issk-+ ~48 high-entropy characters; these aresk-+ four to nine
lowercase letters. - This was the second time that report had been filed and dismissed by hand (v3.9.0 was the first).
The false positive is not the expensive part — a report that is always wrong teaches everyone to
ignore the scanner, and then the one true positive is ignored too. So the dismissal is now a
command:scripts/scan_secrets.pydiscriminates by minimum length AND a Shannon-entropy floor
rather than by prefix, covering OpenAI, Anthropic, GitHub classic + fine-grained, AWS, GCP, Slack,
private-key blocks and genericsecret = "..."assignments. It never prints a matched value —
only its location and a masked head — because a scanner that echoes what it found has published it
into your CI logs. - It self-tests both directions before it scans (real shapes must be caught, this repo's own
strings must not be), so a rule that silently stops matching fails the build instead of reporting
clean. Wired into CI on every push and PR: selftest → working tree → every blob in every commit
(a key deleted in a later commit is still leaked, so the tree alone is not an answer). .gitignorenow covers.env*,*.key,*.pem,*.p12,id_rsa*,*_secret*and
credentials.json— staying out of version control becomes a mechanism rather than a habit.
image-generation.mdno longer shows any literal afterOPENAI_API_KEY=; the documented pattern
reads from a local key file, because a scanner cannot tell a placeholder from a credential and
neither can a reader skimming a diff. NewSECURITY.mdrecords the audit and the ten-second check.
Added — a CI guard that a SKILL.md slimming layers content instead of deleting it
scripts/check_skill_lossless.py+ a CI step prove every substantive line of a baseline SKILL.md is
still findable somewhere in the skill tree after a refactor. Deliberate deletions go in an allowlist
keyed on a hash of the normalised line, so editing a line revokes its waiver.
Changed — SKILL.md layered into step-scoped references (228KB → 134KB), losslessly
- Working detail moved into eight new step-scoped reference files, each with a live trigger at the
pipeline moment that needs it. Two files were deliberately pulled BACK inline — the deckkit
component catalogue and the render self-check — because their absence makes no noise: no lint fires
when you hand-roll a form the library already has or skip the scan entirely, so a rule whose omission
is silent cannot live behind a read. Verified 2298/2298 lines accounted for.
Fixed — native_chart(value_fmt=...) now rejects the wrong format dialect
value_fmtis written straight into the chart's EXCEL number-format code, whileiso_barstakes the
Python dialect, and neither docstring said so — a{:,.0f}handed tonative_chartwas printed raw
onto a shipped slide. It now raises a namedValueError. Pre-existing, unrelated to the refactor.
Added — TITLE-RULE MONOCULTURE lint (the title-chrome rotation rule is now GATED, not prose-only)
- The render self-check has long said "the title CHROME is not one fixed template repeated on every
slide — rotate 2-3 treatments", but it lived only in prose and got missed (ahead()-style build
helper that stamps one eyebrow+rule on all 12 content slides is exactly how it regresses; a critic
pass didn't catch it either).scripts/lint_deck.pynow detects it deterministically: a per-slide
title_rule_yfeature + a deck-level check that firesTITLE-RULE MONOCULTUREwhen the same thin
rule sits under the title at the same height on >60% of content slides (rules must CLUSTER at one
height, so a deck that rotates rule/tab/rail/ordinal never trips it; filled eyebrow tabs and vertical
accent rails are excluded by geometry). Mirrors the existingBOTTOM-STRIP MONOCULTUREgate. SKILL.md
render self-check "Titles" now references the backstop. Verified: fires 10/10 on an all-rule deck,
clean on a rotated one.
v3.9.0
Since v3.8.0. This release makes the direction gate show real styles and carry the chosen look through the whole deck — and keeps bold, bespoke design first-class.
Design capability, verified: a picked preset fixes only the register; the boldness/signature-move gate still fires, and a bespoke register invented for the content is a first-class peer of a preset. Confirmed by a 12-agent integration workflow (pass) and a security scan.
Changed — the direction gate is PRESET-DRIVEN (real styles, not colour schemes)
- The gate now shows the best-fit named presets rendered with their own DNA (signature motif), not three synthesised palette/font combos — fixing "the options were just different colours".
archetypes_html.preset_directions([names])+_dna_cover()render each preset's motif (Swiss ghost numeral, Ink-wash 印 seal, Blueprint grid, Bauhaus triad, …). - 4 new presets → 18 total:
bauhaus,midcentury,terminal,synthwave(all code-only, full guard/when/image_prompt parity), chosen from a design-movement web sweep.
Added — the chosen style runs through EVERY slide, not just the cover (的风格要走所有页)
_dna_ambient()renders a quiet register signature (corner mark / edge rule / faint grid-scanline / recurring numeral-seal) on every interior slide, behind content. Self-verify item (q) enforces it end-to-end via a newinterior register:contract field + aregister_interiorscritic check + a PRE-FLIGHT tick — a deck styled only on the bookends is now a finding.- The quiet register (repeats every slide = SYSTEM) is distinguished from the loud signature motif (dosed 2–3, never stamped) across every gate, so the new behaviour doesn't trip the block-sameness rules.
Changed — branch-aware gate count + bespoke registers are first-class
- No image tool → 4 rendered directions (3 DNA presets + 1 pure colour-scheme direction); with image tool → 3.
build_directions_htmlauto-letters "describe your own" (D on a 3-up, E on a 4-up). - A direction dict may carry
cover_motif+ambient_motifso a bespoke register renders its OWN real DNA in the preview — presets are the floor you beat, not the menu you satisfy.
Changed — generated imagery must FUSE style AND stay topical
- New 🔴 FUSION gate: every generated hero/plate must be both unmistakably on-style and depict the deck's subject; on-style-but-generic filler fails and is regenerated.
Security
- Hardened a docs API-key placeholder (
sk-...→$(cat ~/.openai_key)/DUMMY_KEY). Investigation confirmed no real key was ever hardcoded or committed — scripts read from env vars; re-scan clean.
v3.8.0
Since v3.7.0. Every claimed symbol was verified to exist in the shipped scripts.
One behaviour change to know: at
boldness: bold/experimental, a surviving timid/sanded distinctiveness verdict is now blocking-until-the-user-waives-it — the waiver moves from the agent to the person who set the dial.conservative/balanced+unchanged.
Added — native 2.5D isometric components (no generated image)
deckkit.iso_bars— a faithful 2.5D bar chart: extrusion height is linear in the value and
zero-based, so the depth never distorts the data (this is why the projection is parallel, not
perspective — a perspective chart would foreshorten the far bars and lie). Rejects negatives and9 bars with an actionable message;
highlight=pops one bar inhi_color=(default the deck's
emphasis hue).deckkit.iso_stack— a layered architecture / disclosure ladder / decision hierarchy: floating
isometric slabs with a label aligned beside each. Caps at ~6 layers (raises, likeorg_tree).deckkit.iso_prism— one extruded isometric block as a hero; returns a cleared apex anchor
above the whole top face so a caller can seat a label without landing it on the rhombus.- Fixed projection (true 30° isometric) and one-light-source face shading (top = base, right ×0.80,
left ×0.55) so every 2.5D element in a deck reads as one system. Text sits beside the geometry —
python-pptx cannot shear text onto a tilted face. Scenario gates live inform-selection.md:
2.5D trades precision for presence (the constant top-face pedestal compresses ratios, an 8×
reads ~4.8×), so it is a pitch/launch/keynote move, never a research/defense results comparison, and
the more the room rewards rigor the worse the trade. Dose like generated imagery — one 2.5D moment
per deck. Complementary to the generated-image branch: native = crisp, editable, data-bearing;
generated = soft, organic, atmospheric.
Added — component_audit.py: did this deck hand-roll a form the library implements?
- Reads the build script (which components were called, via
dk.x(), an alias, or
from deckkit import x) + the finished pptx (geometry signatures: bar row · abutting 100% band ·
tile row · marker row) and names the component whose geometry a hand-roll matches. Advisory,
never a blocker — a bespoke composition is the signature move; what the tool states as fact is
the usage ratio and the specific match. Motivation, measured: across three delivered decks the
build scripts called 3 of 59 form components; everything else was raw box+text, re-inheriting the
geometry bugs the components fix. Run at PRE-FLIGHT 12. - Suppression is derived from deckkit's source (which functions draw rects) intersected with the
form catalogue — a hand-kept list was wrong twice in two edits (columns()returns rects and draws
nothing;table()emits a GraphicFrame), each time silencing real detections deck-wide. A
deck that was never opened now printsNOT CHECKEDand exits 1 rather than reporting clean.
Added — deck-level gates: RULE_THROUGH_TEXT · composition axis · signature proof
RULE_THROUGH_TEXT(build-time lint CRITICAL) — a decorative rule/divider drawn through a
text block's ink, always caused by a hand-pickedythe text later grew into. Neither existing
lint caught it (a thin box over text is not text-on-text, not overflow, not invisible text); it
shipped twice in one delivered deck and was caught by the user. Derive the rule from the block's
measured end, never a coordinate.- The direction gate diverges on COMPOSITION, not just palette — the token set gained
cover
andskeleton(rendered faithfully to the canonical skeleton vocabulary), divergence is now a
pairwise≥2 of {palette · type · density · composition}rule with lock-and-redirect, and a new
directions_diversity.pymeasures it (never auto-kills — rediverge or justify on the gate line).
The picked composition is carried into the built deck (thecovertoken becomes the cover's
layout,skeletonbecomes the rhythm map's plurality), not discarded atstyle.py. - The SIGNATURE PROOF opens Step 4 — author the signature slide first and render just that page
(render_deck.py --slides N) before authoring the rest, so the pixels that honour or sand the
boldness:/signature move:contract arrive when the decision is cheap to change, not after the
whole deck is built.signature move:now carries acarried_by:clause (2–3 slides where the
idea does structural work), verified through to the critic.
Changed — blandness can block, at the user's own dial
- The critic's distinctiveness axis was MAJOR-at-most (a deck asked to be bold could ship
forgettable with a footnote while a 4pt overflow blocked). Atboldness: bold/experimentala
surviving timid/sanded verdict is now blocking-until-the user waives it — the waiver moves
from the agent to the person who set the dial.conservative/balanced+unchanged.
Fixed
render_deck.py --slides N[,M]renders only the named pages (the signature-proof path);
byte-identical to those pages from a full render, leaves no cache, mutually exclusive with--fast
and--deliverables.lint_deck.pyis iso-aware — two textless freeform polygons that overlap are the faces of one
vector drawing (an iso solid, any freeform illustration), not a card collision; a pure-2.5D slide
no longer drowns the linter in self-overlap noise. A text card on a prism still flags.- A long chain of self-inflicted defects found by adversarial review across these features — an
over-suppressing audit list, a green pre-flight for an unopened deck, a composition axis that
stopped at the preview, an iso_prism return that produced invisible labels, iso_stack labels
drifting into the trough beside the next slab — each fixed and locked with a regression. - Whole-skill maturity audit (six lenses, adversarially verified) closed the last integration
gaps from the recent gate additions: a compositionskeletontoken that could crash the
direction gate (a 5-value hard-error set feeding an 8-value rhythm-map vocabulary — now accepts
all eight); a dead rule where the composition tokens were produced and routed to the design
lens but the critic brief never consumed them (now wired producer→consumer→arbiter with a verdict
slot); and reference drift behind the code (small_multiples told the agent to hand-compose what its
own index forbids; the design-gallery catalogue omitted four real components;modboxhad no
when-to-use trigger). Plus disambiguation tie-breakers (hub_spoke/hub_spokes,scorecard/
kpi_card) and every emitted lint code now documented.
5 sections · Added · Added · Added · Changed · Fixed
v3.7.0
Since v3.6.0. Full detail below; every claimed symbol was verified to exist in the shipped scripts.
Two behaviour changes to know before upgrading
render_deck.pyno longer writes<deck>.pdf/viewer.htmlby default — pass--deliverables(alias--final) at hand-off. They went stale the moment the .pptx changed, and a stale PDF is worse than an absent one.OLDSTYLE_FIGURESno longer blocks a build; it warns, and the defect is prevented inside the components instead. A build script that wants it fatal asserts over its own finished file.
Added — design components the form catalogue kept prescribing and could not draw
deckkit.small_multiples— a grid of identical mini native charts on ONE SHARED value axis,
with an optional hero panel. The cheapest correctness win in the set:data-viz.mdand
form-selection.mdboth prescribe shared scales, but composingnative_chartper panel by hand
let PowerPoint auto-scale each one, so a small bump and a huge bump rendered identically — a
silent misreading no geometry lint could see. The regression asserts the axis contract itself.deckkit.position_map— N labelled items on two continuous axes, with greedy label
anti-collision and aValueErrornaming any pair it cannot separate.quadrant()returns four
cells and discards the within-cell position that is usually the whole argument;native_bubble
drops the labels.deckkit.annotated_figure— a real figure + numbered fractional-coordinate markers + a
numbered caption rail + an optional magnified inset (viaPicture.crop_*, no image processing).
The inset picks the first corner that covers no marker. This is the form the skill's own
integral-figure philosophy most insists on and least supported.deckkit.org_tree— tidy hierarchy layout: post-order centroid placement (the part that stops
being hand-placeable at depth 3), horizontal bus connectors, and a hardValueErrorwhen it cannot
fit legibly rather than silently squeezing.image_fx.quiet_region(path)— grid luminance-variance scan returning the image's calmest
single-ink region plus its mean luminance, so title placement and ink colour are measured
rather than eyeballed. Two fixes found by running it on real photographs: the growth threshold
anchors on the image's own variance distribution (one flat cell otherwise made every gradient
neighbour look "busy"), and growth requires luminance coherence — a full-height column spanning
dark sky and light ground averages to a mid-luminance where neither ink is safe.deckkit.design_intent(slide, envelope=, rhyme=, reason=)— the declared-register channel that
three lint messages already promised ("record the quiet-register exception") with nowhere to record
it. Stored as an invisible zero-ink tag shape, so intent travels inside the .pptx to the
render-time lint with no side-channel file. Abuse is audited (INTENT INFLATION).deckkit.pic_alpha(picture, pct)— native picture opacity viaa:alphaModFix. Against the
scrim-overlay approach the A/B is unambiguous: the image keeps its own hues instead of being tinted
toward the scrim colour, and there is no second full-bleed shape.
Added — --fast incremental render
render_deck.py --fastre-renders only the slides that changed. Profiling an 18-slide deck:
build 1.8s, LibreOffice→PDF 9.1s, rasterize 24.4s cold. Rasterization, not the PDF export, is the
larger cost. Measured: 12.3s → 4.7s for a one-slide change, 0.07s when nothing changed;
outputs byte-identical to a full render.- Each slide is fingerprinted (its XML + rels + the bytes of the media it references) and mixed with
a deck-global digest (presentation.xml, theme, masters, layouts). That digest closes a hole
found by testing: flipping the canvas 16:9→4:3 changes no slide XML, so--fastpreviously
reported "no slide changed" and left every PNG at the wrong aspect ratio. - Correctness over speed throughout — a stale PNG is worse than a slow render, because the lint
and the visual critic both trustrender/*.pngto BE the deck. Full-render fallbacks (each with a
printed reason) for: changed slide count, hidden slides, auto slide-number fields, or no cache.
Cache writes are atomic and delete the cache on failure. - Hardened against an adversarial audit (27 candidates → 5 must-fix, all reproduced): hidden slides
broke the page↔slide mapping on the pre-existing full path too; an unresolvable part hashed to a
constant so real edits stopped registering; the subset PDF collided between concurrent runs;
--fast --deliverablesexited 0 having produced no PDF (now rejected at parse time); and media
owned by a layout/master/theme was invisible to the digest.
Added — deck-level design gates (the second design-capability tranche)
ENVELOPE MONOCULTURE— fires when >60% of interior slides end their content at the same
height. A deck can vary every form and still read as one template because every page fills the
same rectangle; this is judged as a distribution, not per slide.REGISTRATION DRIFT— consecutive title tops drifting 0.02–0.12in. Identical is calm and a
deliberate jump is a decision; a sub-visible wobble is neither, and reads as a twitch when
advancing.INHERITED_EFFECT— warns when a shape bypasses the theme-shadow strip (below).DEAD BOTTOM's per-slide floor drops 0.62 → 0.45, so only the genuine accident fires; the
0.45–0.62 band is a legitimate upper envelope and is now judged by the distribution check instead.
Changed — OLDSTYLE_FIGURES is a WARN; the defect is prevented in the components
- v3.6.0 shipped this as a hard build blocker. It is now a WARN, and the defect is prevented at
the source instead:numeral_run_face()resolves a lining face inside every component that emits a
figure, so correct output is the default rather than a rule authors must satisfy. - Why the architecture changed rather than the threshold: a fourth audit round found more
defects than the third (12 non-minor vs 5), three self-inflicted by the previous fix round. As a
blocker the rule has an unbounded false-positive surface — every component × every font × every
string shape — while being structurally blind to where numbers most often live: tables and native
charts reporthas_text_frame=False, so their numeric cells and tick labels were never checked at
all."7"(a single digit, which cannot bob),"10x"and"H1 2026"were blocked; a cover whose
title IS a year had no fix available to its author. - A project that wants it fatal asserts over its own finished file — which is what a build script can
now do in three lines.
Changed — numeral faces resolve from the deck's design (visible change)
big_numeral's default face changed. It was hard-codedserif="Georgia"; it now resolves
throughdeckkit.numeral_face(), which keeps a lining SERIF (Times New Roman) by default. The
register is preserved — an oversized italic serif figure, as before — but the digits no longer
sit at mixed heights. A deck rebuilt from an older build script will render its marker numerals
in a different face.serif=still overrides, except that an old-style face is substituted
(lint_layout would reject it).stat_row,scorecard,kpi_cardandchange_statroute their FIGURE runs through the same
resolver, keeping each component's own default (body font) while guaranteeing lining digits.
Previously these inherited a body/display face and could hard-fail their own output on a deck
whose font is Georgia.- Which faces count as old-style is measured from the installed font rather than read off a
list; the curated set is only a fallback for fonts the machine does not have.
Changed — the PDF and viewer.html are reserved hand-off deliverables
- They were produced on every render and parked at the deck root. A deck is rebuilt each critic
round and then usually hand-edited in PowerPoint, so both went stale the moment the .pptx changed —
and a stale PDF is worse than an absent one, because someone opens it and reviews the wrong deck. render_deck.pynow takes--deliverables(alias--final), off by default. The PDF still
exists as a render intermediate insiderender/; the flag is what promotes it to the deck root and
writes the viewer. The default run prints a one-line reminder of how to produce them at hand-off.
--fastand--deliverablesare mutually exclusive, enforced at flag-parse time.
Security — the metered image rung asks before it bills
- The generated-template branch had three rungs (native imagegen → codex CLI →
OPENAI_API_KEY)
governed by one rule: never block on a choice when a working path is present. Right for the two
subscription rungs, wrong for the third — it treats a present API key as authorisation. On a
machine with no codex CLI but an exported key, the skill would have started billing per image
without asking. - BILLING GATE: rung 3 is metered, an available key is NOT consent, ask before the first paid
call — a red stop explicitly not waived by a per-deck auto directive (delegation covers
preferences, never the user's money) — and on a decline fall back to a native look rather than
spending.agents/asset-prep.md(a subagent brief, which could have spent money with no way to
ask) andgenerate_images_codex.py's own failure message are corrected to match. - Claude Code's real cost is now documented: CC has no native image tool, so the codex CLI IS the
path there — free on the subscription, onecodex login. Q1 probes for a free path before
offering the branch, so a user cannot pick a look nothing can generate.
Fixed — the inherited theme shadow under every generated shape
- python-pptx stamps `<p:style><a:effectRef idx=...
v3.6.0
Four things ship in this release: a security fix for a real vulnerability, a new chart
form, a new component family, and a rule that finally became a gate.
Security — this release fixes an SVG-rasterizer flaw where an attacker-supplied icon could read
local files, or run JS/SSRF, through the headless-Chrome fallback. If you pass local or third-party
SVGs toicon_png(), upgrade.
Added — OLDSTYLE_FIGURES: a documented rule becomes a deterministic gate
deckkit.lint_layoutnow hard-fails on display numerals set in an OLD-STYLE (text) figure
face — Georgia, Palatino, Baskerville, Book Antiqua, Constantia, Hoefler Text, Calluna, Candara.
Those faces set 0/1/2 at x-height, push 6/8 up and drop 3/4/5/7/9 below the baseline, so a hero
number visibly bobs and misaligns with adjacent CJK/Latin. The check names the face, the offending
run and its size, and points at the lining-figure fix.- Threshold-aware, so it is right rather than merely strict: fires at ≥20pt only. Old-style
figures inside running prose are a legitimate typographic choice and stay quiet; the gate targets
display numerals, where the wobble is a defect. - Why this shipped as code and not more prose: the rule was already written in five places —
SKILL.md,design-principles.md,font-guidance.md,multilingual.mdandcritic.md— and was
still missed on a real deck, twice, across two skills. That is precisely the failure mode SKILL.md's
own enforcement invariant warns about: a MUST that lives only in reference prose is advisory in
practice. Geometry linters cannot see it (it is a font property, not a layout fault) and render
thumbnails are too small to reveal it, so nothing downstream caught it either. - Wired end-to-end: the CRITICAL in
lint_layout, a plain-language row introubleshooting-faq.md§4,
the hard-fail list inSKILL.mdupdated from four items to five, and a four-case regression test
insmoke_deckkit.pycovering both directions (Georgia/Palatino display numerals fail; Georgia body
prose and a lining-face numeral pass) — so the gate itself is verified to fire, not merely present.
Added — composition / target / range components (native, editable)
native_chartstacked & area kinds —column_stacked·column_stacked_100·bar_stacked·
bar_stacked_100·area·area_stacked·area_stacked_100: real editable PowerPoint charts for
composition over time (a total AND its component mix — the most common exec chart the roster
couldn't draw). Series-fill themed,zero_baseextended to stacked column/bar, CJK<a:ea>labels
intact; a printed notice fires on negative segments (a stack's height only reads as a sum for
same-sign parts).deckkit.bullet_graph— Stephen Few's 'actual vs TARGET, in context' KPI bar (poor/ok/good bands +
a target tick), one row per metric. Each row scales to its OWN max, so a mixed-unit dashboard reads
right;higher_better=Falsereverses the bands for churn/latency. The dashboard barscorecard/
meter_barcan't give.deckkit.range_bars— a 'football field' (floating min–max ranges per row on a sharedaxis_scale- optional base-case tick); closes form-selection's recipe-only note.
- Wired end-to-end from a 4-lane self-check (integration · decision-taste · agent-workflow cooperation
· correctness): a Composition + Range row in the Concept→Visualization dictionary (§3), a
'stacked chart that misleads' anti-pattern (100%-hides-collapsing-total · negative segments · spaghetti)
indata-viz.md+ critic hooks inreview-rubrics.md/critic.md, a single-highlight carve-out so a
correct stacked chart isn't flagged, and roster/catalogue entries (form-selection.md,data-viz.md,
design-gallery.md,SKILL.md). Correctness fixes: range_bars includes base-case points in its axis;
bullet_graph sorts bands; smoke test added.
Added — choropleth map (value per country / province)
deckkit.choropleth(slide, x, y, w, h, data, mapname)+scripts/maps.py— the high-payoff
form the skill was missing: shade a real map by a value per region.mapname=europe·world·
china(provinces). Built on public-domain geometry (Natural Earth 110m countries · DataV China
provinces — nothing fabricated), rendered with matplotlib (no geopandas): Albers equal-area conic for a
region, equirectangular for the world; light→accentramp (orscale='div'); neutral no-data fill;
thin borders. Keydataby ISO-3166 alpha-2/alpha-3 or English name (countries), or province name/adcode
(china); the Natural EarthISO_A2 = -99quirk is patched (France/Norway…) and unmatched keys are
reported, not dropped.- The map PNG is language-agnostic; the title + gradient legend are native deckkit text, so units
and titles render in any language (CJK included) instead of matplotlib tofu. Geometry is fetched once and
cached like icons (network only on first use;SLIDE_MAKER_CACHEoverride). - Hardened by a 3-lane self-check (code-alignment · decision-taste · adversarial correctness):
scale='div'
is zero-centred (neutral == 0, distinct poles viaaccent2) and the native legend now renders the
diverging ramp with a 0 tick so legend and map agree; NaN/None values are treated as no-data (no
crash); the unmatched-key notice reports the actual bad keys; China's nine-dash line renders as a
visible dashed element. Decision-taste wired everywhere the model looks: a Geography/"where" row in the
Concept→Visualization dictionary (design-intelligence-addendum.md§3 +form-selection.md), a
counts-not-rates anti-pattern indata-viz.md+ a named critic check inreview-rubrics.md, and the
"shade a rate, blank = no data" guidance in the docstring. Smoke covers projection, region matching
(incl. the -99 patch), NaN, div, and a real render.
Security — hardening pass (public-skill audit)
- SVG icon rasterizer no longer executes untrusted content (was the one real vuln).
icon_png()
accepts a local.svg, and whencairosvg/rsvg-convertare absent the headless-Chrome fallback
used to render it with JS +file://access — an attacker-supplied icon could read local files
(exfiltrated into the deck) or run JS/SSRF. Fixed withsanitize_svg()(a backend-agnostic control
run before every rasterizer): strips<script>/<foreignObject>/<iframe>/<image>/<audio>/
<video>/<set>/<animate>(incl. namespaced forms),on*handlers, any non-internal
href/xlink:href/src, and externalurl(...)— while preserving paths,<use href="#..">, and
the gradient refs recolor injects. Chrome also runs--disable-remote-fonts. Comments are stripped
first (no comment-split reassembly), and the input is size/complexity-bounded (>100KB or >600 elements
is refused in ~1ms) so the sanitizer itself can't be turned into a quadratic-time DoS. Verified across
all 13 icon libraries: real icons (flat + gradient + duotone) still render; a 16-payload adversarial
battery is fully neutralized; a ReDoS regression test guards the bound (smoke_deckkit.py). - Icon
nameis validated (fetch_svg) as a strict slug ([A-Za-z0-9._-], no..), closing a
path-traversal into the icon cache and a CDN-URL traversal to arbitrary jsDelivr/npm packages. - LibreOffice render is now time-bounded (
render_deck.py,timeout=300) so a malformed/hostile
.pptxcan't hang the pipeline — matching the timeouts every other soffice/ffmpeg call already had.
v3.5.1
Security — hardening pass (public-skill audit)
- SVG icon rasterizer no longer executes untrusted content (was the one real vuln).
icon_png()
accepts a local.svg, and whencairosvg/rsvg-convertare absent the headless-Chrome fallback
used to render it with JS +file://access — an attacker-supplied icon could read local files
(exfiltrated into the deck) or run JS/SSRF. Fixed withsanitize_svg()(a backend-agnostic control
run before every rasterizer): strips<script>/<foreignObject>/<iframe>/<image>/<audio>/
<video>/<set>/<animate>(incl. namespaced forms),on*handlers, any non-internal
href/xlink:href/src, and externalurl(...)— while preserving paths,<use href="#..">, and
the gradient refs recolor injects. Chrome also runs--disable-remote-fonts. Comments are stripped
first (no comment-split reassembly), and the input is size/complexity-bounded (>100KB or >600 elements
is refused in ~1ms) so the sanitizer itself can't be turned into a quadratic-time DoS. Verified across
all 13 icon libraries: real icons (flat + gradient + duotone) still render; a 16-payload adversarial
battery is fully neutralized; a ReDoS regression test guards the bound (smoke_deckkit.py). - Icon
nameis validated (fetch_svg) as a strict slug ([A-Za-z0-9._-], no..), closing a
path-traversal into the icon cache and a CDN-URL traversal to arbitrary jsDelivr/npm packages. - LibreOffice render is now time-bounded (
render_deck.py,timeout=300) so a malformed/hostile
.pptxcan't hang the pipeline — matching the timeouts every other soffice/ffmpeg call already had.
Full changelog: https://github.com/addsumtech/slides_maker/blob/main/CHANGELOG.md