microsoft/ResearchStudio
GitHub将模糊的研究方向转化为可执行的口头级别研究提案。通过检索文献、诊断瓶颈、生成候选方案及质量评估,提供包含具体方法和证伪计划的创新思路。
安装全部 Skills
npx skills add microsoft/ResearchStudio --all -g -y
更多选项
预览集合内 Skills
npx skills add microsoft/ResearchStudio --list
集合内 Skills (9)
ResearchStudio-Idea/skills/idea_spark/SKILL.md
npx skills add microsoft/ResearchStudio --skill idea-spark -g -y
SKILL.md
Frontmatter
{
"name": "idea-spark",
"description": "Generate ONE reviewer-defensible, implementable research idea with a concrete method and falsification plan from a stated research direction. Use when the user asks for a research idea, novelty analysis, bottleneck diagnosis, or paper-shape suggestion. Skip code review, debugging, and unconstrained brainstorming without research context."
}
Idea Spark Skill
Convert an under-specified research direction into ONE reviewer-defensible Oral-level research proposal — grounded in 1947 ICLR/ICML/NeurIPS papers (2021-2025) — via a 5-phase workflow: retrieve recent literature, diagnose the bottleneck, select + generate a candidate using corpus-derived ideation pattern cards, run it through a quality gauntlet, expand into an idea card.
This file is the operational runbook. Design rationale (the 7 design principles, why each contract is shaped this way, removed-check history) lives in references/design-notes.md — read it only when modifying or evaluating the skill, never needed to run it. First-time installation lives in references/setup.md.
When to use
- "Give me a research idea in {area} I could pursue." / "What's the most impactful next step in this direction?"
- "Help me sharpen this vague direction into an Oral-level proposal."
- "What's the bottleneck of this problem?" / "Run a novelty audit on this idea."
When NOT to use
- Code review, debugging, refactoring. Summarizing one paper. Cross-decade survey writing.
- Free-association brainstorming with no research context. Engineering integration tasks ("ship this feature in our system").
- Pure benchmark / dataset construction work — the 15-pattern vocabulary handles benchmark audit (controlled_diagnostic_design) but not benchmark construction.
Setup (first use only)
Follow references/setup.md. Quick version — set two shell variables, install deps, verify:
SKILL_DIR=<path to this folder> # e.g. ~/.claude/skills/idea-spark (Claude Code), ~/.codex/skills/idea_spark (Codex CLI), or any clone location
RUN_DIR="$PWD/ideaspark_run/<topic-slug>" && mkdir -p "$RUN_DIR" # convention below; any absolute dir works
python3 -m pip install feedparser openreview-py beautifulsoup4 pymupdf
python3 "$SKILL_DIR/scripts/run.py" check_connectors # from the SAME shell you'll run phases from
Credentials go in .env (OpenReview user/pass + Semantic Scholar key — see setup.md); the orchestrator auto-loads it. Optional: xelatex/tectonic for PDF cards.
How to run: the next loop
The canonical way to drive a run is the run-state navigator:
python3 "$SKILL_DIR/scripts/run.py" next --dir "$RUN_DIR" --query "<user's research question>"
Run-dir convention (one run = one directory, named by the host BEFORE the first command): $PWD/ideaspark_run/<topic-slug> — a short kebab-case slug distilled from the user's direction (e.g. ideaspark_run/diffusion-watermark); if the slug is taken, append _2, not a timestamp. NEVER reuse a directory that already contains a phase0/ — every phase writes into $RUN_DIR and would clobber the prior run. The skill itself never names the dir (any absolute path works); this convention exists so runs from different harnesses land in one predictable place instead of each agent improvising.
next inspects the artifacts already on disk and prints EXACTLY one next step — either a Bash command to run verbatim, or an LLM sub-agent spec (system-prompt path + input file paths + output path + the routing signal to report back). It is read-only and idempotent (safe to re-run anytime, including to resume an interrupted run). The host loop is:
- Run
next. - Do what it says (
bash→ run the command;llm_subagent→ execute in an ISOLATED context per the Context discipline rules below). - Run
nextagain. Repeat until it reports a terminal state (DONE,do_not_generate, orphase_3_failed).
next encodes the full phase graph — the mandatory full-text gate, the citation gate, the abandon-retry branch, the falsification re-audit branch, and the correct Phase 4 flags per path — so you do not need to memorize the reference tables below; they exist for deviation and debugging.
If your host exposes a task/todo tool (e.g., TodoWrite), seed it with this checklist and tick phases as next moves past them:
- [ ] Phase 0: Literature grounding → lit_table.md, then Phase 0+ full-text fetch (MANDATORY — Phase 1 hard-gates on it)
- [ ] Phase 1: Bottleneck identification → phase1_output.json (routing: proceed | do_not_generate)
- [ ] Phase 2: Gap×pattern selection + candidate generation (ONE isolated context, TWO output files) → citation gate → coherence gate (dry-run trace, fresh context)
- [ ] Phase 3: Collision retrieval (signature@10mo + alias@48mo) → audit (5 checks) → [revise → merge → re-audit if falsification rewritten] | [abandon → 1 internal retry]
- [ ] Phase 4: skeleton → fill → assemble → implementability audit → validate → render → return 3 cards inline
Three outcomes per run: the rendered idea-card markdown returned inline (LaTeX + per-phase JSON left under $RUN_DIR), a do_not_generate.md (Phase 1 OOD), or a phase_3_failed.md (audit abandons twice). Never ask the user mid-flow — missing intake fields are inferred; revision, falsification re-audit, and the single internal retry all run without user re-invocation.
Invocation contract
No cd is required. scripts/run.py self-locates its skill root, so every orchestrator command can be invoked from ANY working directory by absolute script path: python3 "$SKILL_DIR/scripts/run.py" <subcommand> --out "$RUN_DIR/<phase>/" .... The legacy form cd "$SKILL_DIR" && python3 -m scripts.run <subcommand> ... works identically. Do NOT use relative script or --out paths — CWD is not stable across host-LLM Bash invocations, and the orchestrator rejects a relative --out outright.
Exit codes 10 and 11 are NOT errors — they are sentinel handshakes. When the orchestrator can't call an LLM itself (no NOVELTY_LLM_CLASSIFY_FAST_CMD), it writes a sentinel JSON describing what the host LLM should do, then exits rc=10 (intent / pattern-summary) or rc=11 (signature_terms). Read the sentinel ($RUN_DIR/<phase>/.<step>_pending), read the file at its rubric_file field (absolute path), produce the expected output, re-invoke per its re_invocation field. Do not stop on these codes. (The default Phase 0 flow below avoids the rc=10 intent sentinel entirely by passing --queries up front.)
Context discipline (read BEFORE running any LLM-driven phase)
A full run accumulates ~180-250k tokens of intermediate state. If the host LLM carries that in its own conversation context across phases, the Phase 1 / 2.2 / 4.fill calls routinely hit the backend request timeout ([API Error · Request timed out · Retrying...]) and the retry times out again. Apply ALL three rules on every run:
Rule 1 — Run every LLM-driven phase in an ISOLATED context. Phases 1 / 2 (2.1+2.2) / 2.3 / 3.2 / 3.3 / 4.fill / 4.1.5 each have file-path inputs and one JSON output; no phase needs the conversation that produced an earlier one. Use the FIRST isolation mechanism your harness supports:
- (a) Subprocess LLM — set
NOVELTY_LLM_REASONING_LARGE_CMD/NOVELTY_LLM_CLASSIFY_FAST_CMD(see § Configuration); each phase runs as its own subprocess, fresh context by construction, on any harness. - (b) Sub-agent tool (Claude Code
Agentor equivalent) — spawn one per phase, passing ONLY the file paths the phase prompt lists — not conversation history, not file contents inline. The sub-agent reads from disk,Writes to disk, returns ≤ 250 words (output path + routing signal). Exception by design: Phase 2.1 and 2.2 run in ONE sub-agent writing both output files — both are generation-side; the adversarial separations (3.2 vs 3.3, 4.fill vs 4.1.5) must stay separate calls. - (c) Manual context reset — run inline but clear/compact at the four points in Rule 3.
Whichever mechanism, the parent context stays ≤ ~30k tokens for the whole run because it never holds a phase's structured output.
Rule 2 — Write every phase artifact directly to disk; never paraphrase it into chat. Output convention: $RUN_DIR/<phase>/<phase>_output.json. Use your harness's file-write tool (Claude Code: Write) — no Bash heredocs (permission prompts + silent truncation), no echo, no pasting JSON into replies. Bound tool-result captures from large files to ≤ 4 KB (head -c 4000 / jq / sed); never Read a >10 KB intermediate dump into the parent context — the dump gets cached into every subsequent turn (this exact anti-pattern caused prior timeout runs).
Rule 3 — Compact between phases. Natural compact points: after Phase 0+, after Phase 1, after Phase 2, after Phase 3.2. Every phase re-reads its disk inputs, so compacting loses nothing. With /compact, use it there; Rule 1 mechanisms (a)/(b) achieve the same on their own.
Diagnostic for "Request timed out" mid-phase: inspect your harness's session transcript/log (Claude Code: ~/.claude/projects/<project-slug>/<session-id>.jsonl, look for isApiErrorMessage: true; other harnesses: their session-log equivalent); the prior tool call shows which prompt got too big. The fix is one of the three rules — usually Rule 1.
Phase reference
next prints each of these steps at the right moment with concrete paths; the tables below are the full contract for deviation/debugging.
Orchestrator entry points
| Phase | Entry point (python3 "$SKILL_DIR/scripts/run.py" ..., any CWD) |
|---|---|
| navigator | next --dir "$RUN_DIR" [--query "..."] |
| Phase 0 | phase0 --query "<user text>" --queries "q1|q2|q3|q4" --out $RUN_DIR/phase0/ |
| user-ref registration (title-named anchor papers; BEFORE phase0_fulltext) | add_user_ref --out $RUN_DIR/phase0/ --title "<full title>" [--raw-match "<user phrasing>"] [--id <arxiv/DOI/URL>] |
| Phase 0+ full-text (mandatory, the moment lit_table.md lands) | phase0_fulltext --out $RUN_DIR/phase0/ |
| Phase 1 anchor top-up (optional, when the #1 closest_adjacent fell outside the fulltext pool) | phase1_fulltext_topup --out $RUN_DIR/phase0/ --paper-id <anchor paper_id> |
| Phase 3.1 collision | phase3_collision --idea-json <canonical candidate> --out $RUN_DIR/phase3_collision/ |
| Phase 3.3 merger | phase3_merge_revisions --phase2 <canonical candidate> --revisions <p3.3-patch> --critique <p3.2-report> --out $RUN_DIR/phase3_revise/ |
| Phase 2.3 merger (same tool; only when coherence verdict=patched) | phase3_merge_revisions --phase2 <p2.2-output> --revisions <p2.3-output> --out $RUN_DIR/phase2_coherence/ --out-name refined_candidate.json |
| Phase 4 skeleton | phase4_skeleton --candidate <final_candidate-or-p2.2> --phase1 ... --phase2-select ... --phase3-critique ... [--phase3-revise ...] --phase0-dir $RUN_DIR/phase0/ [--collision ...] --out $RUN_DIR/phase4/ |
| Phase 4 assemble | phase4_assemble --skeleton $RUN_DIR/phase4/phase4_skeleton.json --fill-map $RUN_DIR/phase4/fill_map.json --out $RUN_DIR/phase4/ |
| Phase 4 render | phase4_render --expansion $RUN_DIR/phase4/phase4_expansion.json --out $RUN_DIR/phase4/ |
| Validators | validate --phase2 ... [--phase3 ...] [--phase4 ...] [--phase4-impl ...] |
The LLM-driven phases (1 / 2.1 / 2.2 / 2.3 / 3.2 / 3.3 / 4.fill / 4.1.5 / falsification re-audit) have no orchestrator subcommand (a cat prompt | llm wrapper would add fragility without determinism): read the prompt at references/system-prompts/<phase>.txt, gather the inputs listed at its top, Write the JSON described under Output: to $RUN_DIR/<phase>/<phase>_output.json. Run each under the Context discipline rules — Phase 4.fill is the largest output and the most timeout-prone; never in the parent context.
Phase 0 — Literature grounding
Phase 0 and 3.1 require real external retrieval via the bundled connector scripts (scripts/search_*.py) — never WebSearch or ad-hoc fetch (downstream phases reject unstructured output). Gate sentinel: .lit_grounding_mode = real vs connector_failure (halt with diagnostic; --allow-webfallback exists as a flagged, lower-confidence escape).
Default flow (skips one sentinel round-trip): BEFORE invoking phase0, read references/intent-recognition.md (Map mode) yourself and produce 4-6 search queries — including one ESCAPE-MECHANISM query phrased in solution vocabulary (recalls papers that already fixed the bottleneck and title themselves by their fix; problem-keyed queries miss exactly those). Also apply the OOD short-circuit (intake-routing.md triggers #1 Too-broad / #2 No-anchor → route to do_not_generate instead of retrieving). Then invoke with BOTH flags:
python3 "$SKILL_DIR/scripts/run.py" phase0 --query "<user's research question>" --queries "q1|q2|q3|q4" --out $RUN_DIR/phase0/
The rc=10 sentinel path still exists as fallback when --queries is omitted. The orchestrator: asserts a sane clock; probes 4 connectors and retrieves role-based (arxiv 0-6mo cap 10 / openalex 6-24mo cap 12 published-only / semanticscholar 6-24mo cap 13 published-only / openreview 0-6mo cap 10 in-review; ~40-45 papers; non-overlapping windows; SS-priority dedup); extracts URL/ID user-refs from the query into phase0/user_refs.json; emits .pattern_summary_pending for the host.
Retrieval takes 3-10 min (the openreview connector alone budgets 600s) — set your Bash timeout ≥ 600s or run it in the background.
Pattern tagging (host step): classify each lit_results.json paper per references/pattern-summary-rubric.md into 1-3 of the 15 patterns → write lit_table.md with columns paper_id | year_month | venue | title | ideation pattern tags | bottleneck this paper targets | open issue / unresolved gap | resolves_problem | retrieved_via. Pure classification — it does not need the large reasoning model: if your harness can route an isolated context to a cheaper/faster model tier or a lower reasoning effort, use that (this is the same tier NOVELTY_LLM_CLASSIFY_FAST_CMD names in § Configuration); otherwise just run it isolated on the host model.
Title-named user refs: if the user query names anchor papers by TITLE ("based on the LoRA paper" — anything the URL/ID regex can't catch), register each BEFORE phase0_fulltext via add_user_ref (entry-point table). It does a deterministic dedup-merge into user_refs.json — do NOT hand-edit that file (some harnesses' file-write tools refuse to overwrite a file that was never read, and a malformed edit silently drops the U fetch tier).
Phase 0+ full-text fetch — MANDATORY. The instant lit_table.md lands, run phase0_fulltext (entry-point table) before touching Phase 1. Pool = U (user refs, never capped) + T2 (top-10 published on-topic) + T3 (top-5 arxiv on-topic), ceiling 15 excluding U, method-first ordering, concurrent fetch (HTML path first, pymupdf PDF fallback; per-paper budget so one slow PDF can't stall the step). Output fulltext_cache.json keyed by paper_id ({tier, intro, method, source_used, warning}); fetch failures degrade to abstract + warning. Phase 1 hard-gates on this file (error: fulltext_not_fetched).
Phase 1 — Bottleneck identification
One isolated LLM call. Prompt: references/system-prompts/bottleneck_identify.txt. Inputs: user query + intake, phase0/lit_table.md, phase0/fulltext_cache.json (all-failed cache → continue with fulltext_degraded: true, abstract-level residue confidence), phase0/lit_results.json. Output phase1/phase1_output.json: intake (+_inferred_fields[] — missing fields are inferred, never asked), bottleneck_statement (≥2 paper_id cited inline), closest_adjacent[] ({paper_id, summary_and_residue}), what_phase_0_did_not_address[], state ∈ {proceed, do_not_generate}.
Routing: proceed (literature-groundable, no OOD trigger) or do_not_generate (too-broad / no-anchor OOD, <5 truly-relevant papers, genuinely blank space, or benchmark/system construction) → write do_not_generate.md with concrete remedial steps — terminal.
Phase 2 — Selection + generation (ONE isolated context, TWO outputs)
Run 2.1 and 2.2 back-to-back in one isolated context, writing BOTH output files (they are both generation-side; only adversarial pairs need separate calls):
2.1 — prompt references/system-prompts/ideate_select.txt; inputs phase1_output.json, references/ideation-patterns/overview.md (all 15 patterns' Definition / Operational signature / When to apply — selection at WHAT/WHEN level), references/ideation-patterns/companion-combos.md, lit_table.md. Pick the anchor gap (type-bound to intake.contribution_type), sample 1-3 sibling gaps randomly + coherence-filter (non-cohering siblings → deferred_gaps[]), judge each pattern per gap; record saturation (transparency, not a filter). Output phase2_select/phase2_select_output.json: selected_gaps[] (index 0 = anchor) + coherence_thread_type + pattern_saturation + deferred_gaps[]. Retry mode: when $RUN_DIR/attempt_1/ exists, the prompt's OPTIONAL retry input applies — the archived audit + selection become negative constraints.
2.2 — prompt references/system-prompts/ideate_generate.txt; inputs 2.1 output, phase1_output.json, lit_results.json, plus for each gap ONE picked sub-pattern card from references/ideation-sub-patterns/ (compare when_to_pick_this_one + differentiation_within_parent via its overview.md; then read the picked card's tactical_pattern + Step-by-Step). Output phase2_generate/phase2_generate_output.json — ONE candidate, 12 flat fields: title / hook / core_mechanism / core_mechanism_reasoning / core_mechanism_steps; gap_closure[] ({gap, main_pattern, sub_pattern: "C## (parent pattern name)", how_closed}, mirrors selected_gaps one-for-one); falsification_prediction (single paragraph: minimal experiment + metric-with-direction + ONE named load-bearing variable + negative control on that variable predicting the DOWNSTREAM outcome metric returns to baseline — non-tautological); compute_budget (user-relative, GPU-day line + API-dollar line when the campaign calls paid APIs; default intake envelope = 80GB-class GPUs, ≤8 concurrent, ≈150 GPU-days / 5 months, ~$10k API — overridable per user via IDEASPARK_DEFAULT_COMPUTE, see § Configuration); differentiation_from_lit[] (substantive deltas, not "different pattern"); almost_prior_paper_id + what_step_was_missed; signature_terms[] (own vocabulary — recent collision channel); alias_terms[] (other communities' names for the same mechanism, from parametric knowledge — multi-year alias collision channel). Both kill-switch fields (falsification_prediction, compute_budget) are locked from here on — see Phase 3 for the single audited exception.
Citation gate (deterministic, MANDATORY before Phase 3):
python3 "$SKILL_DIR/scripts/run.py" validate --phase2 $RUN_DIR/phase2_generate/phase2_generate_output.json
Any fail = a sub_pattern citation was guessed from the parent's gist, not read from overview.md. Fix against references/ideation-sub-patterns/overview.md (or regenerate 2.2 with the card open) and re-run until clean — the gate proves parent-consistency only; whether core_mechanism performs the cluster's actual tactic is Phase 3.2's recipe_application_check. (next runs this gate automatically.)
Coherence gate (2.3 — one isolated LLM call, MANDATORY after the citation gate, before 3.1): prompt references/system-prompts/coherence_trace.txt; inputs: the 2.2 candidate + the 2.1 spec; MUST be a FRESH context, never the 2.1+2.2 agent (the context that wrote a logic bug rubber-stamps it). It verifies internal procedural validity by EXECUTION, not review — four trace actions: formalize the dataflow (undefined symbols, missing producers, circular deps), numeric dry-run on one small concrete instance (magnitude/probability absurdities — logic bugs read fluently and only surface when computed), degenerate probes (empty/k=0/ties), and claim→step mapping (asserted properties nobody constructs). Verdict pass | patched; repairs are patch-only via the SAME merger (--out-name refined_candidate.json), scoped to making the written procedure sound (core_mechanism*, how_closed narrative, signature/alias terms when the repair changed what the mechanism is) — novelty surface, pattern bindings, and kill-switch fields are out of scope; unfixable-without-redesign findings go to unrepaired[] for the audit to weigh. Single pass, never abandons, and the 3.2 audit does NOT read its report (judges the repaired candidate blind). When refined_candidate.json exists it is the canonical candidate for every later phase (next wires this automatically). It validates that the algorithm survives on paper — NOT that it works empirically (falsification experiment) or is novel (audit).
Phase 3 — Quality gauntlet
3.1 collision (orchestrator, no LLM): entry-point table. TWO retrieval channels over all 4 connectors, merged into collision_hits.json with a per-hit collision_channel tag: signature — the candidate's signature_terms[] over a 10-month window (contemporaneous scoop risk); alias — the candidate's alias_terms[] (other communities' names for the same mechanism, produced from parametric knowledge at 2.2) over a 48-month window (renamed-ancestor risk — the "goal-conditioned success detector vs goal-image conditioned scorer" blind spot is lexical, not temporal, so widening the signature window alone cannot catch it). Missing signature_terms[] → rc=11 sentinel: produce BOTH term sets per intent-recognition.md Collision mode (terms 3-7 words each — long sentences break URL encoding), edit the candidate JSON, re-invoke. Missing only alias_terms[] → loud warning, alias channel skipped (add the field and re-run to close the blind spot). The audit-facing pool is relevance-truncated per channel (≤120 hits/channel by lexical overlap with the channel's own terms; zero-relevance BM25 noise dropped unconditionally; drops printed; untruncated pool preserved as collision_hits.full.json), so the audit can consume collision_hits.json in a few sequential Read chunks — no jq two-pass triage needed.
3.2 audit (one isolated LLM call): prompt references/system-prompts/critique.txt; inputs: candidate, 2.1 spec, lit_table.md, collision_hits.json, references/anti-patterns.md, and each cited sub-pattern card references/ideation-sub-patterns/<C##>.md (strip the leading code from sub_pattern; typically 1-3 cards, others NOT loaded). Five corpus-anchored checks:
| Check | Question |
|---|---|
| gap_closure_reject_check | does the candidate match a documented Reject lesson in each cited sub-pattern card (## Tactical failure mode + ALL ### Reject lessons bullets)? |
| recipe_application_check | does core_mechanism actually perform the cited C## cluster's ## Tactical pattern signature move, or only the parent's generic idea (bypassed — the leading cause of incremental output)? |
| anti_pattern_check | if the SET of gap_closure[].main_pattern matches a reject-favored composition, is the required mitigation substantively delivered (artifact, not keyword)? |
| paper_pointed_threat | most specific subsuming/competing paper in lit_table ∪ collision_hits (both channels; alias-channel threats are NOT discounted for age); no_threat_found is valid — fabricating a generic threat is forbidden. Side output parametric_family_concern: a named un-retrieved mechanism family from parametric knowledge (family name + query vocabulary, never specific paper cites) — soft signal only, flows to Phase 4 reviewer_concerns as a "scoop-check X first" flag |
| falsification_structure_check | does falsification_prediction name the minimal experiment, the outcome metric + direction, ONE load-bearing variable, and a NON-tautological negative control targeting the downstream metric? |
Verdict is two-layer. Hard floor (LLM cannot override) → abandon: triggered Reject lesson / unmitigatable anti-pattern / exact-mechanism collision. Soft judgment otherwise → advance (only trivial borderlines; concerns surface in Phase 4's reviewer_concerns) or revise with concrete revision_targets[] (scopes: tactical / sub_pattern / falsification). verdict_rationale must cite specific check findings. The audit judges only — it never modifies the candidate.
Routing on verdict:
-
advance → Phase 4 reads the 2.2 candidate directly.
-
revise → 3.3 (one isolated LLM call, prompt references/system-prompts/revise.txt): reads candidate + 2.1 spec + audit; emits patch-only
applied_revisions[]— one entry per revision_target, opsreplace/append_sentence/append_items/swap_sub_pattern/rewrite_falsification, never echoes the candidate, never re-judges the verdict. Then run the merger (entry-point table, WITH--critique) → writesphase3_revise/final_candidate.json+ back-injects it into the patch file. Kill-switch fields are merger-refused with ONE audited exception: ascope=falsificationtarget fromfalsification_structure_checkis applied via the dedicatedrewrite_falsificationop (authorization verified against the audit report via--critique; same experiment/metric/claim, structure repaired; max one per run). When the merger printsfalsification_rewritten, run the falsification re-audit (critique.txt § "Falsification re-audit mode": single check onfinal_candidate.json→phase3_critique/falsification_reaudit.json):advance→ Phase 4;abandon→phase_3_failed.md.compute_budgethas no revision route under any scope. Nocompositionscope — gap-level changes route through the abandon-retry below, never through patches. -
abandon → one internal retry (the one-shot guarantee bars asking the user, not internal regeneration): if
$RUN_DIR/.retry_usedis absent, archive the attempt and regenerate —mkdir -p "$RUN_DIR/attempt_1" && \ mv "$RUN_DIR/phase2_select" "$RUN_DIR/phase2_generate" "$RUN_DIR/phase2_coherence" "$RUN_DIR/phase3_collision" \ "$RUN_DIR/phase3_critique" "$RUN_DIR/phase3_revise" "$RUN_DIR/attempt_1/" 2>/dev/null; \ touch "$RUN_DIR/.retry_used"then re-run Phase 2 in retry mode (archived audit + selection = negative constraints), citation gate, 3.1, 3.2. Phase 0/1 artifacts are reused as-is. A second
abandon→ writephase_3_failed.mdciting BOTH attempts' verdict_rationale + triggering checks + user-side options — terminal.
Phase 4 — Expansion + packaging
Five steps in order (next emits each with the correct flags for the advance vs revise path — on the revise path --candidate is final_candidate.json and --phase3-revise is passed; on advance it's the CANONICAL candidate (refined_candidate.json when 2.3 patched, else the 2.2 output) and the flag is omitted):
- skeleton (orchestrator): populates every mechanical field — kill-switch echoes (byte-identical from the candidate),
differentiation_from_litvenue_years,almost_prior_venue_year,why_prior_stopped[].paper_id/venue_year,domain_landscape(pattern_distribution + candidate_uses),literature_breakdown,reviewer_concerns_and_responses[].attack/severity/fields_changed_to_address(lifted from audit + patch),feasibility_validation.compute(bucketed againstintake.compute) — and marks every prose field<TODO[path]: hint>. - fill (one isolated LLM call, prompt references/system-prompts/expand.txt): author ONLY the TODO paths as one flat
{path: value}map →phase4/fill_map.json(~12 prose fields, ~8k tokens vs ~30 fields/~20k without the skeleton). No calendar projections; no experiment matrix / ablation plan / baseline table — the skill produces IDEA + falsifiability + feasibility judgment, not experimental engineering. - assemble (orchestrator): merges fill_map into skeleton →
phase4_expansion.json; validates every path resolves to a real TODO; refuses kill-switch roots; warns on unfilled TODOs (expansion_completeness will fail them). - implementability audit (4.1.5, one isolated LLM call, default on): prompt references/system-prompts/implementability_audit.txt — fresh skeptical-engineer persona (separate from the 4.fill author) rewrites each method step into a buildable spec:
enriched_steps[](one per step, same ids/order,what_changes+what_to_do_en+what_to_do_zh) +underspecified_points[]({step_id, hole, fill, severity: filled|open}— unfillable holes stay honest asopen). Compute-agnostic by design (resource feasibility is 4.1's job); never adds/removes/renames steps; never carries kill-switch fields. Outputphase4/phase4_implementability.json. - validate + render: run the validators (below), then
phase4_render— templating only, no model call; auto-detects the sibling implementability file and mergesenriched_stepsby step_id into the rendered Method (deterministic; no-op when absent). Writesidea.std.zh.md(plain Chinese, domain-newcomer register) +idea.std.en.md(plain English) +idea.detail.en.md(rigorous English — the novelty + validity defense) +idea.std.{en,zh}.tex(auto-compiled to PDF when xelatex/tectonic is on PATH; skipped with a hint otherwise).
Final response: read all three markdown cards and return them inline under headings 中文版 / English / Reviewer version. Other phase outputs stay on disk for inspection, not echoed.
Validators
# advance path: --phase3 = phase3_critique_output.json; revise path: --phase3 = phase3_revise_output.json
# --phase2 = the CANONICAL candidate (refined_candidate.json when 2.3 patched, else the 2.2 output)
python3 "$SKILL_DIR/scripts/run.py" validate \
--phase2 <canonical candidate file> \
--phase3 <see comment> \
--phase4 $RUN_DIR/phase4/phase4_expansion.json \
--phase4-impl $RUN_DIR/phase4/phase4_implementability.json # optional; enables implementability checks
| Validator | Check | Severity |
|---|---|---|
| subpattern_citation_consistency | each gap_closure[].sub_pattern resolves to a real C## cluster in overview.md whose true parent == the cited main_pattern and whose parenthetical == that cluster's parent display name. Primary use: the Phase 2.2 citation gate; re-runs harmlessly here. |
fail (hard) |
| kill_switch_integrity | falsification_prediction + compute_budget byte-identical along Phase 2.2 → [3.3 final_candidate →] 4. After an audited falsification rewrite (falsification_rewritten marker + matching applied rewrite_falsification entry — disagreement fails), the anchor for falsification_prediction re-bases at the 3.3 final_candidate (3.3 → 4 must match); compute_budget stays full-chain always. |
fail (hard) |
| expansion_completeness | motivation (≥2 why_prior_stopped), method_flow.steps[] (each with linked_component + linked_falsification), feasibility_validation (5 sub-verdicts + overall), non-empty abstract_draft + core_claim + sub_claims[] — missing sections would render as silent blanks. |
fail (hard) |
| implementability_completeness | enriched_steps[] one-per-step (same ids/order, EN+ZH), underspecified_points[] present ([] allowed), NO kill-switch field in the file. |
fail (hard) |
| implementability_readability | std-register fields: no 占位/placeholder leak, no bare English jargon dropped into Chinese prose. |
warn |
Retry budget on fail (cap = 2). Fix only the named contract, re-validate; still failing after the 2nd retry → stop revising, render as-is, and append a short note listing the failing validators (a flagged-imperfect card beats a watchdog-killed run with zero output). Never "fix" kill_switch_integrity or subpattern_citation_consistency by editing a guarded field — surface them as the headline caveat instead.
Configuration
By default every model-driven phase runs on the host LLM. To route phases to a different backend (Gemini, open-weights, custom):
NOVELTY_LLM_REASONING_LARGE_CMD— Phase 1 / 2.1 / 2.2 / 3.2 / 3.3 / 4.fill (needs ≥ 200k context, JSON output)NOVELTY_LLM_CLASSIFY_FAST_CMD— Phase 0 intent extraction + per-paper pattern tagging (smaller context, JSON output)
Each is a CLI taking a stdin prompt (<<SYSTEM>>...<<USER>>...) and emitting JSON on stdout. When unset (the default when running inside any host LLM), the orchestrator emits sentinel files and the host LLM handles those steps natively.
IDEASPARK_DEFAULT_COMPUTE— optional standing compute profile for the user (free text, e.g."8×H100 node, ~300 GPU-days, $50k API budget"). Put it in.env(auto-loaded);nextsurfaces it to Phase 1 as intake context. Precedence: compute stated in the user's query > this value > the factory default (80GB-class GPUs, ≤8 concurrent, ≈150 GPU-days / 5 months, ~$10k API campaign). Use this instead of editing the factory default — the default is the feasibility yardstick for users who state nothing.
ResearchStudio-Idea/skills/paper_search/SKILL.md
npx skills add microsoft/ResearchStudio --skill paper-search -g -y
SKILL.md
Frontmatter
{
"name": "paper-search",
"description": "Search papers across arXiv, DBLP, OpenAlex, OpenReview, Semantic Scholar, and Crossref for a given query and year range, using .\/scripts\/search_papers.py. Use when the user asks to find papers, related work, prior art, or recent publications on a specific topic, especially when they mention a date range or specific venues like NeurIPS, ICLR, or ICML."
}
Paper Search Skill
Unified paper search across arXiv, DBLP, OpenAlex, OpenReview
(NeurIPS / ICLR / ICML), Semantic Scholar, and Crossref using
./scripts/search_papers.py. All sources are searched concurrently (in
parallel threads) by default for maximum speed. Returns results grouped by
source.
When to use
Trigger this skill when the user asks things like:
- "Find papers on X published between 2023 and 2025."
- "Search NeurIPS / ICLR / ICML for work on X."
- "Get arXiv + Semantic Scholar results for X."
- "Show me recent prior art on X."
Inputs (all auto-inferred — NEVER ask the user for confirmation or clarification)
Derive these automatically from the user's message. Run the search immediately without asking for confirmation:
- query: Rephrase the user's question into a focused search phrase.
- start_year (int): If the user gives a year, use it directly. If they say "last 2 years", compute from today. Default: 2 years ago.
- end_year (int): Default: current year.
- max_papers (int): Number of results per source. Default: 10.
- sources: Which sources to query. Default: all 6 API sources plus the
model-knowledge source, in this canonical order (highest-signal first, so
the best results render before the user scrolls):
semantic_scholar open_alex arxiv openreview crossref dblp model_knowledge. Only restrict sources if the user explicitly asks.
How to run
Preferred: call the CLI directly. The script lives at
${CLAUDE_PROJECT_DIR}/skills/paper_search/scripts/search_papers.py — invoke
it by absolute path so the command works regardless of the current working
directory (relying on cd scripts && ... breaks when the model is running
from a different folder, which happens often).
For brevity in the examples below, treat $SEARCH as shorthand for that
absolute path:
SEARCH="${CLAUDE_PROJECT_DIR}/skills/paper_search/scripts/search_papers.py"
Basic search:
python "$SEARCH" \
--query "<QUERY>" \
--start-year <YYYY> \
--end-year <YYYY> \
--max-papers 10
To restrict to specific sources:
python "$SEARCH" \
--query "<QUERY>" \
--start-year 2024 --end-year 2026 \
--sources arxiv semantic_scholar openreview
To disable parallel execution (rarely needed):
python "$SEARCH" \
--query "<QUERY>" \
--start-year 2024 --end-year 2026 \
--no-parallel
Or call the function directly when more control is needed (e.g. consuming the
structured dict rather than CLI text output). This is rarely necessary — see
references/programmatic_api.md for the snippet.
Valid source names
| Source | Key |
|---|---|
| arXiv | arxiv |
| DBLP | dblp |
| OpenAlex | open_alex |
| OpenReview | openreview |
| Semantic Scholar | semantic_scholar |
| Crossref | crossref |
| Model knowledge (LLM recall, no API call) | model_knowledge |
Output schema
search_papers() returns a dict mapping source name to a list of paper dicts:
{
"arxiv": [
{
"title": str,
"authors": [str, ...],
"year": int,
"abstract": str,
"url": str,
"venue": str,
"citation_count": int,
"publication_date": str,
"source": str
}, ...
],
"semantic_scholar": [...],
...
}
The CLI prints results grouped by source with paper count summaries.
Output to the user
After running, display every paper from every source, in this order: Semantic Scholar, OpenAlex, arXiv, OpenReview, Crossref, DBLP, Model Knowledge. Then provide a summary.
Why full recall matters: users invoking this skill are doing literature reviews, related-work surveys, or prior-art checks. The value comes from seeing the complete set of hits — a missed paper can mean a missed citation or a duplicated research effort. Summaries are meant to augment the full tables, not replace them, so don't collapse results into a digest "to save space." The user can skim; they can't un-skip a paper they never saw.
Step 1: Display ALL results from every source
For each source, display every paper in a markdown table with title, date, venue, and citation count.
Format each source as a table grouped under a source heading, e.g.:
### arXiv (N papers)
| # | Title | Date | Venue | Citations |
|-----|-------------|---------|---------|-----------|
| [1](paper url) | Title here | 2024-03 | NeurIPS | 42 |
| [2](paper url) | Title here | 2023-11 | ICLR | 10 |
If a source returned 0 results, note it explicitly (e.g. "### OpenReview (0 papers) — No matches found in this window").
If errors occurred during search, they are printed to stderr by the script — surface them to the user, never hide them.
Step 2: Summary of all searched results
After displaying all papers, provide a comprehensive summary with the following sections, in this exact order:
- Overview: query used, year range, and total number of papers found. One or two sentences framing what the corpus covers.
- Trends: Temporal patterns (e.g. "interest surged in 2024"), dominant venues, methodological shifts, and recurring author groups or labs.
- Key themes: 3–6 main research themes / clusters across all results, each with a one-line description and 2–3 representative paper numbers.
- Keywords frequency: A table of the most frequent technical terms /
concepts extracted from titles and abstracts, with counts. Format:
| Keyword | Count |. Include the top 5. - Most cited by accepted paper: Top 5 most-cited accepted papers across all sources,
ranked by citation count, as a table:
| Rank | Title | Year | Citations |. - Most cited by first author: Top 5 first authors ranked by total citations
accumulated across papers in this result set, as a table:
| Rank | Author | Papers in set | Total citations |. The Author column must contain ONLY the author's name (e.g.Jane Doe). Do not append paper titles, affiliations, venues, or any other information in this column — paper counts and citation totals live in their own columns. - Recommendations for reading: 3–5 papers most relevant and impactful to the user's original query, ordered as a reading path (foundational → recent), each with a one-line justification.
Dependencies & failure modes
- arXiv: stdlib only (uses arXiv API).
- DBLP: uses DBLP API.
- OpenAlex: uses OpenAlex API.
- OpenReview: requires
pip install openreview-py. - Semantic Scholar: uses Semantic Scholar API.
- Crossref: uses Crossref API.
- Model knowledge: no API call. Papers are recalled from the model's own training data — fast and free, but capped by the model's knowledge cutoff and prone to hallucination. See the "Model knowledge source" section below for how to use it responsibly.
If a source fails, search_papers catches the exception, prints the error, and
continues with the remaining sources. Never retry blindly; report errors to the
user.
Model knowledge source
The model_knowledge source is different from the others: it has no API and
no script call. Instead, after the CLI search returns, recall 5–10 additional
papers from your own training data that match the query and year range, and
present them as a separate source in the output.
Why include it
API search is high-precision but low-recall in two predictable cases:
- Foundational older papers that practitioners always cite but that keyword search misses (e.g. the original BERT or ResNet paper when the query is about a recent variant).
- Cross-disciplinary classics that live in venues the APIs index poorly.
Model recall complements the APIs by surfacing the "everyone knows this one" papers that don't always come back from a fresh keyword query.
How to populate it
After the CLI run completes:
- Reflect on what you know about the query topic.
- List up to 10 papers from your training data that fit the query and year range, with: title, primary author(s), year, venue, and a one-line reason it's relevant.
- Deduplicate against the API results — if a paper already appeared in any
API source, do not repeat it under
model_knowledge. - Flag confidence honestly. The model knowledge column has no citation count
and no live URL; if you're not sure a paper exists exactly as you remember
it, mark it
(uncertain — verify)in the table rather than presenting it as confirmed.
Why honesty matters here
Hallucinated paper titles are the classic LLM failure mode for this task. A fake "Smith et al., 2023, NeurIPS" looks identical to a real one in a markdown table, and the user has no way to tell. The point of this source is to surface real papers the APIs missed — not to pad the list. If you can't recall ≥5 papers with reasonable confidence, return fewer; an empty model-knowledge section is fine and honest.
Display format
Use the same table layout as the other sources, but the URL column may link to a search query (e.g. an arXiv or Google Scholar search) rather than a canonical paper URL, since you don't have a verified link:
### Model Knowledge (N papers, may include uncertain entries)
| # | Title | Year | Venue | Notes |
|-----|-------------|------|---------|-------|
| [1](https://scholar.google.com/scholar?q=Title) | Title here | 2018 | NeurIPS | Foundational; often cited by recent work on X |
| [2](...) | Title here | 2024 | ICLR | (uncertain — verify) |
Replace the "Citations" column with "Notes" because you don't have a reliable citation count from memory.
Example
User: "Find papers on diffusion policies for robotics from 2023 to 2024."
Run (using $SEARCH as defined in the "How to run" section):
python "$SEARCH" \
--query "diffusion policy robotics" \
--start-year 2023 --end-year 2024 \
--max-papers 10
To search only specific sources:
python "$SEARCH" \
--query "diffusion policy robotics" \
--start-year 2023 --end-year 2024 \
--sources arxiv openreview semantic_scholar \
--max-papers 10
Then read the output and summarize per the rules above.
Important Notes
- Log the final report. After completing the search, write a single
markdown file to:
${CLAUDE_PROJECT_DIR}/allinone.md- Contents: the full "Display ALL results from every source" tables followed by the "Summary of all searched results" section — in that order, with no truncation.
- Display the full report to the user. Return the complete detailed report inline — every paper, every table, plus the analysis and reasoning. Never collapse the tables into a summary, and never abbreviate results to "save space".
- Never ask for confirmation. All inputs are auto-inferred (see the "Inputs" section). Run the search immediately on the first turn.
- Surface errors verbatim. If a source fails, report the stderr message to the user rather than hiding it or retrying blindly.
ResearchStudio-Reel/skills/paper2reel/SKILL.md
npx skills add microsoft/ResearchStudio --skill paper2reel -g -y
SKILL.md
Frontmatter
{
"name": "paper2reel",
"description": "Build an interactive HTML viewer that aligns paper2poster output with slide\/video deck frames through a sidecar content_alignment.json, without modifying the original poster, PPTX, or blog deliverables."
}
paper2reel - aligned artifact viewer
paper2reel builds a browsing layer over existing artifacts. It does
not replace or mutate paper2poster, ppt-master, paper2blog, or
paper2video outputs.
The key artifact is:
content_alignment.json
It maps the canonical section ids from paper2assets's assets/meta/sections.json to:
- poster DOM targets, usually
[data-section="<id>"]; - slide frame targets, usually one or more slide indexes;
- blog blocks rendered from the same paper2blog content used for final DOCX;
- video clips and subtitles derived from the same paper2video timeline used for final MP4 delivery.
This sidecar lets a wrapper UI switch between Poster / Slides / Blog and jump to the corresponding location without adding visible engineering tags to the deliverables themselves.
Viewer UX Contract
The user-facing viewer must preserve the original poster as the default screen.
When reel.html first opens, it should show only the poster itself:
no permanent wrapper bar, no tab strip, and no visible instructions outside the
poster. The wrapper controls are discoverable through keyboard shortcuts and
section interactions:
vtoggles the top section menu.htoggles keyboard shortcut help.atoggles the poster's existing audio controls.stoggles fullscreen.dtoggles the poster debug/hover controls.- double-clicking a poster section opens a large modal.
- clicking or double-clicking the poster title opens the full-paper modal.
The modal must use the section-media layout: video on the left, blog on the right, EN/中文 switching, optional subtitles, slide thumbnails below the video, and a draggable splitter. Do not replace this with a Poster / Slides / Video / Blog tabbed viewer. A tabbed viewer is a regression because it drops the section-level interaction contract.
Video seekability is part of the contract, not a hosting detail. The modal's
video progress bar and slide thumbnails must actually seek playback. Serve and
test reels with the Range-capable paper2reel server below; do not use
python -m http.server as a passing preview or QA environment because it may
answer MP4 Range requests with 200 OK instead of 206 Partial Content.
The viewer is intentionally template-locked. Treat
assets/section_modal_contract.json as the golden UI contract distilled from
the hand-tuned Attention reel: fixed section-modal template, hidden
topbar by default, v/h/a/s/d shortcuts, debug opacity slider, CC button,
download menu, draggable splitter, slide thumbnails, EN/CN blog panes, and
timeline-backed section clips. Do not ask an agent to redesign this HTML. The
builder may only fill data into the fixed template and copy self-contained
assets.
Inputs And Outputs
For a full bundle, paper2reel must read the same v2 outputs that are delivered to the user:
<poster_outdir>/ # poster.html, poster.pdf/png/pptx, manifest.json, assets/
<blog_outdir>/ # blog_zh.docx, blog_en.docx, manifest.json, assets/
<video_outdir>/ # video*.mp4, manifest.json, assets/
Do not build a reel from stale example files, old exports/ folders, or the
burned-in subtitle final MP4. The viewer's playback video must use the
no-subtitles final render, normally
<video_outdir>/video_no_subtitles.mp4, because
paper2reel provides its own CC/VTT subtitle toggle. The downloadable video
bundle may still include the subtitled <video_outdir>/video.mp4.
Build the user-facing viewer directly in a v2 reel bundle:
<reel_outdir>/
reel.html
content_alignment.json
manifest.json
assets/
poster/
media/
slides/
blog/
downloads/
This directory must be self-contained enough to serve locally: reel.html
and content_alignment.json sit at the bundle root; poster/video/slides/blog
assets sit under assets/. Do not copy only reel.html.
Poster + Slides Base Viewer
From the repo root:
python skills/paper2reel/scripts/build_poster_slides_view.py \
--poster-dir <poster_outdir> \
--slides-dir <slide_png_or_svg_dir> \
--script-json <optional_audio_script_json> \
--section-slide-map <optional_section_slide_map.json> \
--blog-outline-en <blog_outdir>/assets/meta/outline_en.json \
--blog-outline-zh <blog_outdir>/assets/meta/outline_zh.json \
--blog-figures-dir <blog_outdir>/assets/figures \
--download-poster-dir <poster_outdir> \
--download-blog-dir <blog_outdir> \
--download-video-dir <video_outdir> \
--outdir <reel_outdir>
The script writes:
<viewer_outdir>/
reel.html
content_alignment.json
manifest.json
assets/
poster/
poster.html
assets/
figures/
fonts/
logos/
qr/
audio/
slides/
slide_01.png
...
blog/
figures/
...
downloads/
all_final.zip
poster_final.zip
blog_final.zip
video_final.zip
reel.html keeps the poster in an iframe so its own interactions
remain intact. The wrapper only observes section clicks and applies transient
highlighting. Slides are copied as image frames for stable scrolling and
highlighting.
When paper2blog outlines are available, pass both language outlines and the
figure directory. The builder will copy blog figures into blog/figures/ and
store section-level EN/CN blocks in content_alignment.json; the browser gate
requires those blocks.
When download directories are provided, the builder creates top-menu download
links for the exact deliverable bundles shown in the viewer. The menu is still
hidden by default and appears with v.
Timeline-Backed Section Media
When paper2video has produced timeline.json, use it as the source of truth
for modal video clips, slide clips, subtitles, and section timing. Do not cut
section videos from guessed MP4 timestamps. The section clips should be composed
from complete timeline slide clips and end with a short silent freeze tail, so
the modal does not stop mid-motion or mid-thought.
python skills/paper2reel/scripts/build_section_media_from_timeline.py \
--viewer-dir <reel_outdir> \
--timeline <video_outdir>/assets/meta/timeline.json \
--video <video_outdir>/video_no_subtitles.mp4 \
--captions-vtt <video_outdir>/assets/captions/video.vtt \
--section-tail-seconds 0.9
This updates:
<viewer_outdir>/content_alignment.json
<viewer_outdir>/reel.html # inline ALIGNMENT refreshed when present
<viewer_outdir>/assets/media/video.mp4 # raw pre-subtitle playback copy
<viewer_outdir>/assets/media/clips/<section>.mp4
<viewer_outdir>/assets/media/slide_clips/slide-XX.mp4
<viewer_outdir>/assets/media/captions/...
The script refuses <video_outdir>/video.mp4 by default because that file
is normally burned-in with subtitles. Passing it into paper2reel would
double-render text when the user turns on CC.
timeline.json must already contain the explicit section mapping. If the deck
uses slide ids instead of canonical poster ids, build the timeline with
paper2video/scripts/build_timeline.py --section-map ... first.
Required Hard Gate
After building the base viewer and timeline-backed media, run the reel package checker. This is a required gate, not an optional smoke test:
python skills/paper2reel/scripts/check_reel_package.py \
<reel_outdir> \
--browser \
--file-browser \
--screenshot <reel_outdir>/assets/meta/previews/reel_browser_gate.png \
--report <reel_outdir>/assets/meta/reports/reel_qa_report.json
The checker must pass before marking paper2reel complete. It validates
that the delivered viewer is the section-modal UI, not the stale tabbed viewer,
and confirms in a real browser that default poster-only view, shortcuts,
double-click section modal, video clip, subtitle toggle, slide thumbnails, and
blog text work. It also verifies MP4 byte-range support and exercises real
video seeking: clicking a later slide thumbnail must move sectionVideo to the
thumbnail timestamp, and direct progress-bar style seeking must succeed for
both full-video and section-clip playback.
The same final reel.html must also support direct local opening with
file:// or a double-click. In direct-open mode the viewer embeds the copied
poster HTML through iframe.srcdoc, sets the poster base URL to
assets/poster/, localizes poster render resources such as MathJax under
assets/poster/, and uses inline/data-URI captions so the section modal,
poster hover, shortcuts, blog pane, slide-thumbnail seeking, and direct video
seeking remain usable without starting a server. This is a user-facing feature
parity path, not an HTTP protocol replacement: file:// has no HTTP 206 Range
headers, so --browser remains required for Range/seek validation and
--file-browser separately validates the direct-open runtime.
The checker reads assets/section_modal_contract.json. Missing any required
golden-contract feature is a blocking ERROR: shortcut handlers, CC toggle,
debug slider, paper2poster's native debug bbox/size overlay, the golden
download pill with icon and All | Poster | Video | Blog links, section-rail
hover styling, section clip, raw pre-subtitle playback video, toggleable VTT
captions, blog images, or the fixed template/version markers.
If the checker fails, treat it as an agent repair task first: fix the viewer and
rerun the gate. Do not mark the stage PASS and do not ask the user to catch it
by visually inspecting the page.
For human preview, start the same Range-capable server used by the browser gate:
python skills/paper2reel/scripts/serve_reel.py <reel_outdir> --port 8900
Open http://127.0.0.1:8900/reel.html. The preview is not considered faithful
unless video requests return 206 Partial Content for Range requests.
For quick local sharing, users may also double-click <reel_outdir>/reel.html.
Do not remove the assets/ folder or copy only the HTML file; direct-open mode
still depends on the v2 bundle folder structure.
Output Manifest
<reel_outdir>/manifest.json must include "layout": "v2-assets" and
root-relative paths for reel.html, content_alignment.json,
assets/poster/, assets/media/, assets/slides/, assets/blog/, and
assets/downloads/. Keep QA screenshots and reports under
assets/meta/previews/ and assets/meta/reports/.
If required inputs are missing or the viewer cannot load a needed artifact,
record an ERROR in the QA report/manifest and stop. Do not silently build a
partial viewer that hides missing poster, video, slides, or blog content.
Bootstrap From PDF Or Incomplete Bundle
When the user invokes paper2reel with a PDF, arXiv/link input, or an
incomplete v2 bundle, first inspect the shared bundle and decide which upstream
stages are already complete. Missing stages must be completed by their full
skills before the reel is assembled:
paper2assets -> paper2poster -> paper2blog -> paper2video -> paper2reel
Use the bootstrap helper for the inspection and for the deterministic final assembly:
python skills/paper2reel/scripts/build_reel_from_paper.py <paper.pdf-or-bundle> --dry-run
Then follow its stage report:
-
If
paper2assetsis missingpaper_spec.md, run the completepaper2assetsskill. Do not treatextract_pdf.pyoutput alone as a paper2assets package, because Step 4 is model-driven. -
If only
manifest.json,assets/meta/sections.json, orassets/meta/narration.jsonis missing whilepaper_spec.md,text.txt,figures.json, andmetadata.jsonalready exist, the helper may refresh those deterministic package files with--run-missing. -
If
paper2posteris missing, run the completepaper2posterskill, including the measured fill loop, render/export, html2pptx handoff, and deliverable gate. -
If
paper2blogis missing, run the complete bilingualpaper2blogskill, including real EN/CN outlines, DOCX generation, figure embedding, and the blog QA gate. -
If
paper2videois missing, run the completepaper2videoskill, including ppt-master, audio, timeline, captions, visual highlights,video.mp4,video_no_subtitles.mp4,video.pptx, and the video QA gate. -
When all upstream stages pass, let the helper assemble the reel:
python skills/paper2reel/scripts/build_reel_from_paper.py <paper.pdf-or-bundle> --run-missing
The helper builds the viewer in a temporary staging directory and then copies
only the reel deliverables back into the v2 bundle. This avoids the
build_poster_slides_view.py --outdir clean step from deleting existing
poster, blog, video, or paper2assets outputs. It preserves the v2 contract:
reel.html and content_alignment.json at the bundle root, with reel support
assets under assets/poster/, assets/media/, assets/slides/,
assets/blog/, and assets/downloads/.
If the helper reports a blocked stage, stop and complete that named full skill stage. Do not write a simplified poster, video, blog outline, slide image, or HTML page to make the reel checker pass.
Alignment Rules
Prefer explicit section ids from paper2assets:
titleproblemmotivationcontributionmethoddataset-benchmarkkey-resultablation-studyheadline-numberstakeaway- paper-specific custom ids such as
failure-modes-limitations
For slides, pass a script.json when available. The script's sections[*].id
and order provide slide identity without embedding metadata in the PPTX.
If automatic slide matching is weak, pass an override JSON:
{
"method": [4, 5],
"key-result": [7, 9],
"takeaway": [10]
}
Use the override with:
python skills/paper2reel/scripts/build_poster_slides_view.py \
--poster-dir <paper2poster_outdir> \
--slides-dir <slide_png_or_svg_dir> \
--script-json <script.json> \
--section-slide-map <map.json> \
--outdir <viewer_outdir>
ResearchStudio-Idea/evaluation/idea_quality/SKILL.md
npx skills add microsoft/ResearchStudio --skill idea-quality -g -y
SKILL.md
Frontmatter
{
"name": "idea-quality",
"description": "Score the QUALITY of a research idea at the idea stage — given a Markdown file with Title \/ Motivation \/ Method sections (no experiments needed), produce a per-axis quality assessment with cited evidence and an overall 0–100 score plus a verdict; or, given two such idea files, a blind head-to-head comparison. TRIGGER when the user asks \"how good is this idea\", \"rate \/ score \/ grade this research idea or proposal\", \"review my idea .md before I write it up\", \"is this contribution strong enough to pursue\", \"which of these two idea files is stronger\", or hands over an idea Markdown file (Title\/Motivation\/Method) and wants a quality judgment. Use this even when the user does not say the word \"score\" but clearly wants an assessment of how strong an idea is. DO NOT trigger for prior-art \/ overlap \/ \"is this novel vs existing work\" checks (that is a literature-collision task), for full reviews of finished papers that already have results, or for generating the idea itself."
}
Idea Quality
Judge how strong a research idea is at the idea stage — before any experiments exist — and return a quality score with reasons a reviewer would recognize. This skill is self-contained: it judges from first principles on the three axes below. It consults no external corpus, dataset, or other skill, so its judgment is reproducible from the idea text alone.
Input: an idea Markdown file
The idea is a .md file with three sections. Read the file, then map each section to what it feeds:
# Title
<≤ ~15 words: the idea's handle>
## Motivation
<the bottleneck / gap the idea attacks, why it matters, and why it is still open>
## Method
<the proposed contribution as concrete numbered steps>
- Title → the handle.
- Motivation → feeds Axis A (is the problem worth attacking) and gives Axis C the "problem" half.
- Method → feeds Axis B (is the method good) and gives Axis C the "method" half.
If a section is missing or thin, infer the most reasonable reading from the rest and note the assumption in the report — do not stall asking for clarification. If the user passes two files (or one file with two ideas) and wants a comparison, run the pairwise track.
Scope: what this judges, and what it deliberately does not
- No experiments exist. Every axis is a reasoning-level judgment — the kind a reviewer makes from an abstract before seeing results. Never invent or assume experimental results to score with. If a claim's truth would need an experiment, judge the plausibility of its argument, not an imagined outcome.
- Not a prior-art check. "Has someone already done this?" is a separate literature-collision task. Judge whether the idea is good (real gap, deep method, sound + on-target), NOT whether a near-duplicate exists. Judge the contribution's intrinsic ambition, not its novelty against a literature search.
A strong score means the idea is strong; it does not predict acceptance, which also turns on execution this skill cannot see.
The three axes
Score on exactly these three. Each is (a) assessable from the idea alone without experiments, (b) able to discriminate strong from weak, and (c) not a proxy for writing polish.
A — Problem position quality. Is the gap the Motivation identifies real, important, non-obvious, and genuinely open — or shallow, already-solved, or a conveniently easy target?
- Strong: a gap that matters and that the field has not closed; naming it is itself insightful.
- Weak: an obvious / already-handled problem, or a soft target chosen so it can be "solved" cheaply.
- Why it exists: when the author picks their own bottleneck, the cheapest way to look successful is to pick a soft target. This axis is what stops a soft-bottleneck-plus-trivial-fix from scoring well.
B — Method quality. Is the proposed method good in itself? Score it as ONE number, but in the Reason you MUST decompose into three named sub-judgments, because a single number otherwise hides which of them is weak:
- depth — a genuinely new mechanism / construction / reframing vs an incremental tweak (a new schedule, one extra loss term, a hyperparameter).
- soundness — does the "why it should work" argument hold up internally? Are assumptions justified, or is there hand-waving / an unstated condition / a logical gap? Judge the method's own logic, regardless of which problem it aims at — fit is Axis C.
- feasibility — is it buildable in principle, or does it require resources / oracles / data that don't plausibly exist? This means "buildable", NOT "will get good numbers" — do not hallucinate results. This is the softest sub-judgment; let depth dominate B and treat feasibility as a tie-breaker, not an equal third.
- Note: B is about the magnitude and integrity of the method itself, judged intrinsically — NOT whether a duplicate exists in the literature (separate prior-art task).
C — Problem-fit. Does the Method actually target and plausibly resolve the specific gap in the Motivation — rather than an adjacent, easier, or different problem?
- Strong: the method's mechanism clearly bears on the identified gap; if it works, that gap closes.
- Weak: a method aimed at a different problem than the one claimed, or one whose connection to the gap is asserted, not shown.
- Boundary vs B-soundness: B-soundness asks "is the method's own logic coherent?"; C asks "does that logic connect to THIS problem?" A method can be internally sound (B high) yet solve the wrong problem (C low); or on-target (C high) yet hand-wavy (B low). Keep them separate.
Scoring: dual-track
Always run the absolute track. When comparing two ideas, ALSO run the pairwise track — and treat pairwise as the trustworthy signal, because relative judgments are far less noisy than absolute numbers (which bunch around 3/5). The absolute 0–100 is a readable secondary readout, not a calibrated truth.
Per axis, score 1–5 (integer). There are deliberately no fixed level descriptors — instead every score quotes the specific phrase / element of the idea that justifies it. A score with no quoted evidence is not allowed; the evidence requirement, not a rubric table, is what keeps scores honest. Judge substance, not length or fluency — a short crisp idea can score 5; a long polished one can score 2.
Track 1 — Absolute (per idea)
Overall score (equal weight, each axis 1→0 and 5→full):
overall = round(100 * (A + B + C - 3) / 12)
Verdict band: strong ≥ 67 · borderline 34–66 · weak < 34.
A/C gate (overrides the band). A and C are near-necessary, B is not — so:
- If A ≤ 2 OR C ≤ 2, the verdict cannot be
strong(cap atborderlineat best), regardless of the number. Reason: a great method on a trivial problem (low A), or a great problem with a method that doesn't address it (low C), is not a strong idea — and the equal-weight mean would otherwise mislead. State the cap and why in one line. - A low B alone does NOT cap: a simple method that genuinely addresses an important problem (counter-intuitive minimalism) can still be strong.
Track 2 — Pairwise (comparing two ideas)
For each axis state which idea is stronger (Idea 1 / Idea 2 / tie) and why, in one line; for axis B, note per-sub-judgment (depth / soundness / feasibility) which arm wins so the diagnostic isn't lost. Then give an overall winner holistically — but problem-fit (C) is near-necessary: a decisive C loss usually decides the overall, even if that idea wins A and B, because a method that doesn't fit its problem isn't the better idea. Decide each axis independently; do not let one axis sway the others.
Judge blind to source. If two ideas are compared as part of an evaluation, do NOT factor in which system/model produced which — provenance is not evidence of quality. (For this to be fair, the two inputs must share the same Title/Motivation/Method format; flag it if they don't.)
Output format
For a single idea:
## Idea Review — <title>
**Decomposition**
- Problem / gap (from Motivation): …
- Method (the proposed move): …
- Why it should work: …
(assumptions inferred, if any: …)
**Axis scores**
| Axis | Score (1–5) | Evidence (quoted from the idea) | Reason |
|------|-------------|----------------------------------|--------|
| A — Problem position | | | |
| B — Method quality | | | depth: … · soundness: … · feasibility: … |
| C — Problem-fit | | | |
**Overall: NN / 100 · Verdict: strong | borderline | weak**
<if the A/C gate fired: one line naming the cap and why>
**Strongest point:** <one sentence>
**Most fixable weakness:** <one sentence, phrased as what would raise the score>
For a comparison, output the absolute block for each idea, then:
## Pairwise verdict (Idea 1 vs Idea 2)
| Axis | Stronger | Why (one line) |
|------|----------|----------------|
| A — Problem position | 1 / 2 / tie | |
| B — Method quality | 1 / 2 / tie | depth / soundness / feasibility: who wins each |
| C — Problem-fit | 1 / 2 / tie | |
**Overall winner: Idea 1 | Idea 2 | tie** — <one–two sentence rationale; if a decisive C loss drove it, say so>
Anti-bias rules (why they matter)
- Quote evidence for every score. A bare number is a vibe, and vibes don't separate strong ideas from weak ones; tying each score to a specific claim makes the judgment auditable and the comparison fair.
- Judge substance, not length / fluency / formatting. A confident, well-structured write-up can dress up a thin idea — resist rewarding presentation, especially in B-soundness where fluent prose most easily hides an unsound step.
- Stay at the idea stage. Don't imagine experimental results to justify a score. If verifying a claim would need an experiment, score the argument's plausibility, not a hallucinated outcome.
- In comparisons, judge blind to source. Which model or system produced an idea is not evidence about its quality; ignore it.
ResearchStudio-Idea/skills/scoop_check/SKILL.md
npx skills add microsoft/ResearchStudio --skill scoop-check -g -y
SKILL.md
Frontmatter
{
"name": "scoop-check",
"description": "Check whether a proposed research novelty (given a problem statement and claimed idea\/novelty) overlaps with existing published work. TRIGGER when the user asks to verify research novelty, check if an idea is new, compare a proposed contribution to prior art, do a literature\/prior-art search for a specific claim, or assess whether a paper idea has been scooped. DO NOT TRIGGER for general literature reviews unrelated to a specific novelty claim, or for writing related-work sections of an already-validated idea."
}
Scoop Check
Systematically verify whether a proposed research novelty overlaps with existing literature. The goal is to either (a) surface prior work that already covers the claim, or (b) produce a crisp, defensible "delta" statement that distinguishes the proposed contribution.
Inputs
This skill requires exactly two inputs from the user:
- Research problem — the specific issue, challenge, or gap in knowledge being addressed.
- Novelty — the specific claimed contribution (new method, theorem, dataset, framing, or insight) that distinguishes this work from prior art.
If either input is missing, vague, or conflated (e.g., the "novelty" merely restates the problem), infer the most reasonable interpretation and proceed immediately. Do not use AskUserQuestion or pause for confirmation at any point — always proceed directly through every step.
Procedure
Execute the steps below in order. At the start of the procedure, use TaskCreate to register all seven steps as tasks up front, then mark each as in_progress when you begin it and completed when you finish. The procedure is long enough that progress tracking is always worth the small overhead — without it, it's easy to skip a step (especially the per-step logging in the Important Notes) or lose track of which candidates still need a deep dive.
Step 1 — Decompose the Novelty
Break the claimed Novelty into four atomic axes, presented as a labeled list:
- Problem framing — Task definition, inputs, outputs, and evaluation regime.
- Core mechanism — The technical contribution — architecture, algorithm, proof technique, or data construction.
- Key insight — What makes it work; what prior state-of-the-art lacked.
- Application domain — Where it applies and how broadly.
Step 2 — Search and Deduplicate
Read the Research problem and craft three complementary search queries:
- Query 1 — Original-Problem: restate the original research problem.
Example:
score-based generative model fast inference - Query 2 — Broad-Domain: the high-level area, ~3–5 words.
Example:
diffusion model sampling efficiency - Query 3 — Method-Signature: the specific technical move, ~5–8 words.
Example:
consistency model knowledge distillation
Send all three queries to the paper-search skill (located in .claude/skills/). paper-search runs a unified query across multiple sources and returns structured JSON with title, abstract, and metadata.
Because the three queries hit arXiv as separate process invocations, pace them so adjacent arXiv requests are ≥ 4 s apart to avoid triggering the rate limit (HTTP 429) that silently zeroes out the connector. paper-search's arXiv module enforces this automatically via a cross-process file lock (arxiv_search_throttle.lock in the temp dir), so concurrent/back-to-back queries queue and each request fires ≥ 4 s after the previous one — you do not need to add manual sleeps, but do not bypass that module by calling arXiv directly. Override the interval only via the ARXIV_MIN_INTERVAL env var if needed.
Then merge the results across all three queries and deduplicate by title (normalize case and whitespace before comparing).
After the search-based results are merged, augment the set with additional papers recalled from your own training knowledge that are directly relevant to the Research problem or Novelty but did not surface in the search results. The reason for this step: paper-search depends on keyword matches against indexed sources and routinely misses landmark or tangentially-phrased prior work that you nonetheless know about — and missing the canonical reference is the most common way a novelty check fails. Add these recalled papers with the same fields as the searched ones (Title, Date, Source if known) so they flow through the rest of the pipeline identically.
Constraints on recalled papers:
- No overlap. Before adding a recalled paper, check it against the deduplicated search results by normalized title (and by arXiv ID / DOI when available). Skip any that are already present.
- Mark provenance. Tag each recalled entry with
Source: model-recall(in addition to any URL/ID you can supply) so downstream steps know the entry did not come from a live search and may need extra verification in Step 5. - Stay relevant and modest in count. Add only papers you are confident exist and are directly relevant — typically 0–5. If you cannot recall any with confidence, add none; fabricating citations is far worse than a smaller candidate pool.
- Flag uncertainty. If you are unsure about a recalled paper's exact date, venue, or author list, leave those fields blank rather than guessing — the deep dive in Step 5 will resolve them from the actual PDF.
The combined set (search results + non-overlapping recalled papers) is the input to the next step.
Step 3 — Abstract-Level Triage
For every deduplicated paper, read the title and abstract and extract a first-pass record with the following fields:
- Title — Full paper title.
- Date — Publication or preprint date (YYYY-MM).
- Problem framing — The task, inputs, outputs, and evaluation setup the paper targets.
- Core mechanism — The main technical contribution — architecture, algorithm, proof technique, or data construction method.
- Key insight — The central idea that enables the contribution; what prior work lacked.
- Application domain — The field, modality, or setting where the method is applied or evaluated.
- Overlap score (0–4) — Number of the four axes (problem framing, core mechanism, key insight, application domain) that plausibly match the proposed novelty, judging from the abstract alone. This count feeds the 5-level verdict in Step 6 via
level = 5 − (axes matching)(0 axes matching → Level 5 / most novel; 4 axes matching → Level 1 / fully scooped). - Source — URL, arXiv ID, DOI, or other identifier needed to retrieve the full text. The hostname usually encodes the venue well enough for triage (e.g.,
arxiv.org→ preprint,openreview.net→ conference submission, publisher domains → peer-reviewed); the formal venue is captured in Step 5 once it's actually needed.
Abstracts are short and often hide the details that determine whether a paper actually scoops a contribution — methodological assumptions, scope of experiments, and limitations frequently live only in the body. The overlap score is therefore a triage signal, not a final verdict.
Step 4 — Identify High-Potential Candidates
Select the subset of papers most likely to threaten or inform the novelty claim. The input here is the full combined set from Step 3 — both paper-search results and any non-overlapping recalled papers from Step 2's memory-augmentation pass. Apply the criteria below to every paper uniformly; do not auto-promote recalled papers just because you added them, and do not auto-demote them just because their Source is model-recall. They earn their slot the same way as anything else.
A paper is a high-potential candidate if any of the following hold:
- Overlap score ≥ 2 on the abstract pass.
- The abstract matches on core mechanism specifically, even if the application domain or framing differs (mechanism overlap is the most dangerous kind of overlap).
- The paper is from the same narrow subfield and published within the last 24 months, even with a lower overlap score, since recent close-neighborhood work is the most likely to have anticipated the contribution.
- The abstract is ambiguous about the method in a way that could either confirm or rule out overlap — i.e., the paper cannot be safely dismissed from the abstract alone.
Cap the candidate set at the top 3–7 papers after triage — the cap applies to the post-filter selection, not the Step 3 input set, so a large search + recall pool is fine as long as you narrow it here. If more than 7 papers qualify, keep the 7 with the highest overlap score (breaking ties in favor of mechanism matches and recency). If fewer than 3 papers meet the criteria, lower the threshold and include the next-most-similar papers so that the deep-dive in Step 5 still has meaningful coverage. List the candidates explicitly with the reason each was selected.
Step 5 — Full-Paper Deep Dive on Candidates
For each high-potential candidate, retrieve and read the full paper using the recipe below. Don't shortcut by handing the PDF URL to WebFetch directly — it returns a model-written summary that drops the methodological detail this step exists to capture.
Budget per paper. Skim, don't read end-to-end. The deep dive's purpose is to verify the four axes against the body of the paper — not to summarize the whole work. A practical heuristic: once you've quoted or referenced three concrete passages that together pin down the problem setup, the core mechanism, and the scope/assumptions, you have enough to fill in the verified record — stop reading. If the extracted .txt is unusually large (e.g., a long thesis, a survey, or a paper with extensive appendices), restrict the skim to the introduction, method, and conclusion / limitations sections and note the partial coverage in the candidate's record. If fetch_paper.sh fails or produces garbled output, stop early and use the graceful fallback in sub-step 4 below rather than burning the budget on retries.
- Resolve a PDF URL. Start from the source link / ID recorded in Step 3. For arXiv entries, convert
https://arxiv.org/abs/<id>tohttps://arxiv.org/pdf/<id>.pdf. For OpenReview / ACL Anthology / publisher pages, follow the "PDF" link. UseWebFetchfirst only to locate the PDF URL when it is not obvious (e.g., ask it "what is the direct PDF link on this page?"). - Fetch and extract with the bundled script. Pick a
<pdf_name>that identifies the paper (e.g., a slugified title or the arXiv ID like2310_12345) so multiple candidates don't clobber each other, then run:
The script downloads toscripts/fetch_paper.sh "<PDF_URL>" "<pdf_name>"${CLAUDE_PROJECT_DIR}/papers/<pdf_name>.pdf, verifies the result is a real PDF (HTML error pages saved as.pdfare a common failure mode), extracts text withpdftotext -layout, and falls back through plainpdftotext→pdfplumber→pymupdfif needed. On success it printsok: …and the final.txtpath on the last line; on failure it printsFAILED: <reason>and exits non-zero. - Read the extracted text. Use the
Readtool on the printed.txtpath. Focus on the introduction (for claimed contributions), the method section (for the actual mechanism), the experiments section (for scope and assumptions), and the conclusion / limitations. Skim related-work for what the authors themselves consider prior art — this often surfaces additional papers to triage. - Fall back gracefully. If
fetch_paper.shexits non-zero (paywall, 403, dead link, scanned-image PDF that no extractor can handle), tryWebFetchon the abstract / HTML version with a targeted question, and record the limitation explicitly. Do not pretend to have read the full paper when you have not — the deep dive's value comes from grounding judgments in the body of the work.
From the full paper, extract a richer record that supersedes the abstract-level entry:
| Field | What to capture (from full text) |
|---|---|
| Problem framing (verified) | Exact task setup, inputs/outputs, datasets and metrics actually used |
| Core mechanism (verified) | The actual algorithm/architecture/proof — including the specific technical moves, not just the headline name |
| Key insight (verified) | The reasoning the authors give for why the mechanism works; what they claim prior work lacked |
| Application domain (verified) | Concrete domains, modalities, and scales evaluated |
| Venue (verified) | The publication venue or repository where the paper appears (e.g., NeurIPS 2024, ICLR 2023, ACL Findings, TMLR, arXiv preprint, OpenReview submission). Use "arXiv preprint" if the paper has not yet appeared at a peer-reviewed venue, and "unknown" only if the PDF genuinely does not disclose it. |
| Assumptions & scope | Stated assumptions, limitations, and regimes where the method does/does not apply |
| Closest-passage evidence | A short verbatim quote or precise section reference grounding the overlap judgment |
| Refined overlap | Updated per-axis match against the proposed novelty (match / partial / differ), now grounded in the body of the paper |
If the deep dive shows that a paper initially flagged as high-overlap actually targets a different setting or uses a materially different mechanism, downgrade it and note the reason. Conversely, if the body reveals a closer match than the abstract suggested, upgrade it. The point of Step 5 is to correct the unavoidable noise in abstract-level triage before the final comparison.
Step 6 — Compare Against the Proposed Novelty
Build a comparison as a list, starting with the proposed work and followed by each prior work. For every entry, include the same set of fields so the comparison is structured and directly readable.
Use exactly this layout — note that every Field: value pair sits on its own indented line:
- Proposed work
- Title: —
- Date: —
- Source: —
- Problem framing: …
- Core mechanism: …
- Key insight: …
- Application domain: …
- Prior work A
- Title: …
- Date: …
- Source: …
- Problem framing: …
- Core mechanism: …
- Key insight: …
- Application domain: …
- Prior work B
- Title: …
- Date: …
- Source: …
- Problem framing: …
- Core mechanism: …
- Key insight: …
- Application domain: …
Continue the list for every prior work under consideration. For each prior work, count how many of the four axes match the proposed work (0–4) and map that count to one of five novelty levels, where Level 5 is the most novel (no axis overlaps) and Level 1 is the least novel (all four axes match / fully scooped). The four axes give exactly five possible match-counts, so each count corresponds 1:1 to a level: level = 5 − (axes matching).
| Axes matching | Level | Label |
|---|---|---|
| 0 | 5 | No Overlap — most novel |
| 1 | 4 | Low Overlap |
| 2 | 3 | Medium Overlap |
| 3 | 2 | High Overlap |
| 4 | 1 | Full Overlap — fully scooped, least novel |
A higher level means more novel (less overlap with prior work): Level 5 = no overlapping prior work, Level 1 = a prior work matches on all four axes.
The overall verdict is determined by the strongest (most-overlapping) entry: take the worst case — the minimum level across all prior works, since the single closest prior work caps the novelty. If there are no prior works at all, the verdict is Level 5 (No Overlap). This explicit mapping is what the Report's Verdict section consumes — do not introduce a different rule there.
Step 7 — Articulate the Delta
Produce a one-sentence delta statement in this form:
Unlike [closest prior work], which [does X under assumption Y], the proposed work [does X′ / drops Y / extends to Z], yielding [concrete measurable benefit].
The sentence is the artifact reviewers actually care about — it names the closest prior work, the specific axis on which the proposed work diverges, and the resulting benefit. Make those three slots concrete: a generic "extends prior work to a new setting" tells the reader nothing. If the closest prior work is from Step 5's verified records, draw the assumption/mechanism phrasing directly from that record so the comparison is grounded in the body of the paper rather than the abstract.
If no crisp one-sentence delta can be written, the novelty likely overlaps with existing work and the user should reframe — say so explicitly, and identify which prior work is blocking the delta, rather than forcing a sentence that doesn't hold up.
After producing the delta, roll up the per-paper levels from Step 6 into an overall Verdict (take the worst case — the minimum level across all prior works) — exactly one of the five levels:
- Level 5 — No Overlap (most novel) — every entry in Step 6 was all axes differ, or there were no prior works at all. The delta statement stands on its own.
- Level 4 — Low Overlap — the closest entry matched on exactly one axis (three axes differ). Tangential prior work exists; the delta is comfortable.
- Level 3 — Medium Overlap — the closest entry matched on two axes (two axes differ). Related work exists; the delta is defensible but must be stated explicitly in any future write-up.
- Level 2 — High Overlap — the closest entry matched on three axes (one axis differs). Closely competing work exists; the delta hinges on a single distinguishing axis and is fragile — sharpen it or reframe.
- Level 1 — Full Overlap (least novel) — at least one entry matched on all four axes (all axes match). Matching prior work exists on all axes; recommend reframing the contribution.
State the chosen verdict label with its level number (e.g., "Level 3 — Medium Overlap"), then a one-paragraph justification that names the specific papers (by title) driving the decision.
Note: the kind of delta (new territory vs. measurable improvement vs. sideways tradeoff) is already conveyed by the sentence itself and by the verdict, so no separate category label is needed — adding one would just duplicate the verdict on a different axis and invite contradictions (e.g., a "Medium Overlap" verdict paired with a "Strictly new" label).
Report
Render the final report inline in the conversation as a single, self-contained markdown document. Include all of the following sections in this exact order, with no omissions or summarization:
- Verdict — exactly one of the five levels from Step 7, stated with its level number and label: Level 5 — No Overlap (most novel), Level 4 — Low Overlap, Level 3 — Medium Overlap, Level 2 — High Overlap, or Level 1 — Full Overlap (least novel).
- Delta — the one-sentence delta statement from Step 7. If no crisp delta could be written, say so and identify which prior work blocks it.
- Decomposed claim — the four axes from Step 1, presented as a labeled list (Problem framing, Core mechanism, Key insight, Application domain).
- Structured papers — every deduplicated paper from Step 3, rendered as a markdown list. For each paper, include one bullet per field (Title, Date, Problem framing, Core mechanism, Key insight, Application domain, Overlap score, Source), matching the field list defined in Step 3. Separate papers with a blank line or a horizontal rule for readability. Do not truncate the list; if zero papers were found, state this explicitly and list the queries that returned no results.
- Comparison result — the full comparison list from Step 6 (proposed work first, each prior work following), with each entry rendered as a nested bullet list of fields, followed by the per-paper level and label from Step 6's 5-level table — i.e. the axes-matching count, the level (1–5, where 5 = most novel), and the label (all axes differ / three axes differ / two axes differ / one axis differs / all axes match).
Important Notes
- Negative results are valuable. A thorough search that returns nothing is itself evidence of novelty — document the queries used.
- Log every step. After completing each step, write a markdown file to
${CLAUDE_PROJECT_DIR}/step{step_number}.mdcontaining the step number, step name, timestamp, and the full structured result of that step. This produces one file per step, enabling downstream tooling to inspect intermediate results. - Display the full report. Provide the complete detailed report — including all search results, analysis, and reasoning — not just a summary.
ResearchStudio-Reel/skills/paper2blog/SKILL.md
npx skills add microsoft/ResearchStudio --skill paper2blog -g -y
SKILL.md
Frontmatter
{
"name": "paper2blog",
"description": "Use when transforming an academic paper, arXiv\/OpenReview PDF, conference paper, technical report, poster, slide deck, repository README, or related research materials into a bilingual editorial package for an editing team. Produces two articles by default — a Chinese WeChat public-account version (`blog_zh.docx`) and an English research-blog version (`blog_en.docx`). Use for drafting, revising, or packaging article copy, figure placement, captions, image prompts, and the final `.docx` deliverables."
}
Bilingual Paper Editorial
Purpose
Create an editorial package for an editing team from academic paper materials. By default the package has two required deliverables, both .docx:
<blog_outdir>/blog_zh.docx— a Chinese WeChat public-account article.<blog_outdir>/blog_en.docx— an English research-blog article.
Both carry strong content logic, figure-text coordination, captions, and source links. They share one evidence map and one set of extracted/cropped figures — you do the paper analysis and figure prep once, then write the article twice, once per language. When a paper2assets package exists, use it as that shared source of truth so blog, poster, slides, and video agree on the same section claims, figures, numbers, and narration scripts. Visual polish is secondary because editors may re-layout the article.
The two versions are not a mirror translation of each other. They report the same facts, figures, numbers, and claims, but each is voiced for its own audience: the _zh version in the restrained, public-account WeChat register; the _en version in a neutral Western research-blog register (see references/editorial-style.md). Write each natively in its language rather than translating sentence-by-sentence.
The default reader is technically curious but not necessarily expert in the paper's subfield. Preserve academic accuracy while making the contribution understandable and worth reading.
Work autonomously. Read the paper, make the editorial judgment calls yourself, and deliver two finished drafts — don't stop to ask the user to confirm the hook, structure, figures, captions, or titles. The only thing you never guess is a checkable hard fact (paper/code link, DOI, affiliation, acceptance status): if it isn't in the inputs, omit it rather than inventing one. Everything else, decide and move on.
Output Contract
Follow the shared paper2assets v2 layout. The paper2blog bundle top level holds
only deliverable files plus manifest.json; dependencies and build artifacts
live under a single assets/ container:
<blog_outdir>/
blog_zh.docx
blog_en.docx
manifest.json
assets/
figures/
meta/
outline_zh.json
outline_en.json
reports/blog_qa_report.json
previews/blog_qa_preview/
Pick <blog_outdir> (resolve BEFORE any file writes). The bundle directory is shared across every paper2* skill — when paper2assets, paper2blog, paper2poster, and paper2video target the same root, the blog's figures sit next to the poster's figures, the shared narration script, and any other deliverables in one self-contained package. Resolve deterministically:
- An explicit
<blog_outdir>argument from the caller wins — honor it verbatim. The defaults below only fire when no path was passed. - A
paper2assetspackage already exists → reuse its folder verbatim as<blog_outdir>. The canonical detection signal is<dir>/assets/meta/paper_spec.md(the cross-skill source of truth produced bypaper2assetsStep 4);<dir>/manifest.jsonwith"layout": "v2-assets"is a confirming hint when present. Writing into the same bundle means both.docxfiles share the existingassets/figures/, the QA report lands underassets/meta/reports/, and downstream tools readingmanifest.jsonsee the blog deliverables alongside everything else with no path swap. - Otherwise (a bare PDF is the only input) → default to
<input_pdf_dir>/<pdf_stem>/— the directory containing the input PDF, then a subfolder named after the PDF basename (no extension). Example:papers/8008_Ink3D_Sculpting.pdf→<blog_outdir> = papers/8008_Ink3D_Sculpting/. This matches thepaper2assetsdefault convention, so if you invokepaper2assetsin Workflow step 2 below it lands in the same bundle without a later path swap.
# 1. Resolve $BLOG_OUT per the rule above
if [[ -n "$blog_outdir_arg" ]]; then
BLOG_OUT="$blog_outdir_arg" # explicit caller arg wins
elif [[ -f "$paper2assets_dir/assets/meta/paper_spec.md" ]]; then
BLOG_OUT="$paper2assets_dir" # reuse the paper2assets bundle
else
BLOG_OUT="$(dirname "$paper_pdf")/$(basename "$paper_pdf" .pdf)"
fi
# 2. Create the assets/ scaffolding under that root
BLOG_ASSETS=$BLOG_OUT/assets
BLOG_META=$BLOG_ASSETS/meta
mkdir -p "$BLOG_ASSETS/figures" "$BLOG_META/reports" "$BLOG_META/previews"
manifest.json records root-relative paths and includes "layout": "v2-assets".
Legacy final/ / intermedia/ outputs remain readable by the QA checker for old
demos, but new runs must use the v2 bundle shape above.
Load References
- Read
references/editorial-style.mdbefore drafting article copy. - Read
references/output-contract.mdbefore creating the.docx. - Read
references/image-guidelines.mdbefore selecting, cropping, editing, or prompting images — it carries the figure crop-review procedure (scripts/crop_figure.py). - Read
references/review-checklist.mdbefore final delivery. - Read
references/case-study-acl2026.mdonly when a concrete style example is useful.
Workflow
-
Gather inputs:
- Paper PDF or source, title, authors, venue, links, code/repo links.
- Existing
paper2assetspackage files when available:manifest.json,assets/meta/sections.json,assets/meta/narration.json,assets/meta/figures.json,assets/meta/captions.json,assets/meta/text.txt, andassets/figures/. - Existing examples under the current project, especially example input/output pairs.
- Paper figures, tables, poster assets, README summaries, and any user-provided constraints.
- The editing team's required output format. Default to
.docx.
-
Extract figures, text, and captions from the PDF:
-
If the working folder already has a shared
paper2assetspackage, read it first:manifest.jsonfor file locations and counts.assets/meta/sections.jsonfor canonical claims, section ordering, selected figure references, and reusable evidence.assets/meta/figures.json/assets/meta/captions.json/assets/figures/for image selection and captions.assets/meta/text.txtfor source verification and details not captured by the sections.
-
If starting from a PDF and no shared package exists yet, initialize one into
$BLOG_OUTdirectly (resolved per the Output Contract above) — re-using$BLOG_OUTas paper2assets'--outdirkeeps every paper2* skill writing into the same bundle root, so figures/captions/spec/narration that paper2assets produces sit right next to the.docxfiles this skill will later write. From the repo root:python skills/paper2assets/scripts/build_package.py <paper.pdf> --outdir "$BLOG_OUT"After
paper_spec.mdor an equivalent section spec exists, sync it:python skills/paper2assets/scripts/build_package.py <paper.pdf> \ --outdir "$BLOG_OUT" \ --skip-extract \ --paper-spec "$BLOG_OUT/assets/meta/paper_spec.md" -
When the input is a paper PDF, run the bundled extractor instead of hand-collecting figures. It does the tedious, error-prone work — locating each figure on the page, gluing multi-panel sub-figures into one image, and clamping the paper's own caption text off the bottom — far more reliably than eyeballing or screenshotting page regions by hand.
python scripts/extract_pdf.py <paper.pdf> --outdir "$BLOG_OUT"Use this legacy extractor command only as a fallback when
paper2assetsis not available; new cross-skill workflows should initializepaper2assetsfirst.$BLOG_OUTis the value you resolved in the Output Contract above — the same bundle root that will later carryblog_zh.docx/blog_en.docx.outdiris a working folder for this article (e.g. a folder named after the PDF). It produces:assets/meta/text.txt— full paper text (layout-preserving, page breaks kept)assets/figures/— one clean PNG per figureassets/meta/figures.json— manifest with each figure'sfile,page,width,height,caption_label, andcaptionassets/meta/captions.json— every "Figure N: …" / "Table N: …" caption keyed by labelassets/meta/metadata.json— best-effort cover-page metadata (venue, year, emails, code_url, paper_url, arxiv_id, doi); fields may be empty, never fabricated
-
Read
assets/meta/figures.jsonto see what figures exist and what each one shows — the captions are your fastest signal for which figure proves which point. -
If the user supplies loose image files instead of a PDF (or in addition), you can still place those directly; the extractor is for getting clean figures out of a PDF.
-
-
Build an evidence map:
- Core problem and why it matters now.
- Prior work or common practice the paper responds to.
- Main contribution, method, and named components.
- Key experimental results with exact numbers and dataset/model names.
- Limitations, assumptions, and claims that must not be overstated.
- Usable figures and what each figure proves (cross-reference
assets/meta/figures.jsoncaptions).
-
Study examples when available:
- Extract the article structure, title style, opening rhythm, section depth, figure density, and caption style.
- Treat examples as editorial style signals, not as text to imitate mechanically.
- If example output exists as
.docx, inspect its paragraph sequence and media count.
-
Plan the article before writing:
- Choose one reader-facing hook or analogy.
- Decide the section sequence and figure sequence together.
- Select the 3–7 figures that earn their place (see
references/image-guidelines.md); note their filenames fromassets/meta/figures.json. - Place each figure immediately after the paragraph that prepares the reader to understand it.
- Use a table only for compact numeric comparison or benchmark summary.
-
Prepare the selected figures (crop review):
- The extractor is heuristic and usually crops cleanly, but a few defects slip through, and a bad crop is one of the most visible flaws in a finished article — a figure marooned in whitespace, or the paper's raw English "Figure N: …" caption baked into the image right above the Chinese caption you'll write under it. So look at each figure you actually selected (not the whole
figures/folder — unused figures never reach the reader) and clean it with the bundledscripts/crop_figure.py. - For every selected figure, run the two safe automated passes —
decaption(strip any baked-in caption sliver) thenautotrim(strip excess border whitespace) — then judge by eye and fix any neighbor-bleed or thick caption strip with an explicitboxcrop. - Read
references/image-guidelines.mdfor the full crop-review procedure, the four defects to watch for, and the exact commands. Don't skip this — it's the single biggest lever on figure quality.
- The extractor is heuristic and usually crops cleanly, but a few defects slip through, and a bad crop is one of the most visible flaws in a finished article — a figure marooned in whitespace, or the paper's raw English "Figure N: …" caption baked into the image right above the Chinese caption you'll write under it. So look at each figure you actually selected (not the whole
-
Draft both language versions:
- Write from the shared evidence map (step 3) and the shared figures (steps 5–6) — same structure, same figures, same numbers and claims in both versions. What changes is the voice, not the facts.
_zh(Chinese WeChat): main body Chinese; keep necessary English terms, paper names, method names, benchmarks, datasets, and model names. Introduce an English technical term with a Chinese gloss on first use when helpful._en(English research blog): fluent, native English for a Western technical audience — not a sentence-by-sentence translation of the Chinese. The rhythm and phrasing can differ; the substance must match.- Work autonomously. Make the editorial calls — hook, structure, figure choice, captions, titles, how to phrase the contribution — yourself, and deliver one complete draft. Don't pause to ask the user to confirm these choices; they'll edit whatever they want changed.
- For both: prefer clear, restrained, publication-ready prose over marketing copy. Don't invent claims or results, and don't fabricate a checkable hard fact — a paper link, code link, DOI, author affiliation, or acceptance status — since a wrong one ships straight into the editorial pipeline. If such a fact is missing from the inputs, omit it gracefully rather than guessing a specific false value or leaving a "to be confirmed" placeholder. Everything that's editorial judgment, you decide yourself.
- Read
references/editorial-style.mdfor the per-language voice (it carries both the Chinese WeChat register and the English research-blog register).
-
Create the two
.docxfiles:- Output location and names: write final deliverables to
$BLOG_OUT/blog_zh.docxand$BLOG_OUT/blog_en.docx, and keep outlines, cropped figures, previews, and reports under$BLOG_ASSETS/$BLOG_META. The names are fixed regardless of the paper's title or filename — the Chinese title goes inside the_zhdocument, never in the filename. - Keep layout simple and editor-friendly: title, subtitle, body paragraphs, headings, inserted figures, captions, optional result table, source links.
- The two filenames are already ASCII-only by construction (
blog_zh.docx/blog_en.docx); keep any sibling asset filenames (JSON outlines, extracted figures) ASCII-only too. Seereferences/output-contract.mdfor the rationale (downstream CMS/zip/upload tools mangle non-ASCII filenames). - Both documents embed the same cropped figures from
assets/figures/(use stable image paths and embed the images into each document, not links). Each figure gets a caption in that document's language. - If using Python and
python-docxis available,scripts/build_wechat_docx.pycan assemble each document from a JSON outline — run it once per language with its own outline and output path. Pass--lang zhfor the Chinese document and--lang enfor the English one so each gets the right font (_zh→ 微软雅黑 / Microsoft YaHei for Chinese, Arial for Latin;_en→ Arial for Latin text). If--langis omitted the script infers it from theblog_zh.docx/blog_en.docxoutput filename. If an editor requires specific fonts, set an outline-level"fonts"object or pass--latin-font/--east-asia-font; those overrides do not change the article logic. - If platform-specific document tools are available, use them, but the final artifacts must still be two
.docxfiles.
- Output location and names: write final deliverables to
-
Review and iterate:
-
Run the checklist in
references/review-checklist.md. -
Run the machine QA gate before delivery:
python skills/paper2blog/scripts/check_blog_package.py "$BLOG_OUT" --strictThe gate checks that both
.docxfiles exist, embed the same figure set, declare expected fonts, avoid TODO placeholders, and keep bilingual numbers/terms aligned. It also looks for pagination risks: large blank areas before images, underfilled images, and likely orphan tails. -
In strict mode the checker tries to render both DOCX files into PDF/PNG previews with LibreOffice and PyMuPDF, then runs page-level layout checks for non-final bottom whitespace, near-blank pages, and sparse content. If the machine cannot render DOCX directly, render the pages with the available document tool and pass:
python skills/paper2blog/scripts/check_blog_package.py "$BLOG_OUT" \ --strict \ --zh-preview-dir <rendered_cn_page_pngs> \ --en-preview-dir <rendered_en_page_pngs>Strict final delivery must not silently skip preview checks.
-
Write
<blog_outdir>/manifest.jsonwith root-relative paths for both DOCX files,assets/figures/, outlines, and QA reports. Include"layout": "v2-assets". If the QA gate reports any ERROR, stop and fix the blog package unless the user explicitly approves a named degraded path. -
Deliver one strong complete draft rather than stopping to offer the user options or ask which direction to take — decide, draft, and hand over the finished
.docxfiles. The user iterates from a real artifact, not from a list of questions. -
When the user does edit for personal taste, preserve their preferences unless they introduce factual or logical problems.
-
Default Article Shape
Use this as the starting structure for each language version unless the user's example suggests otherwise. Both versions share this shape and the same figures; only the language and voice differ.
- Title and subtitle.
- Two to three lead paragraphs:
- what people usually think or do,
- why the paper's question matters now,
- the paper's main idea.
- First overview figure and caption.
- Source links: paper and code when available.
- Background/problem section.
- Method or contribution section, often split into named components.
- Figure-led evidence sections.
- Compact result table only if it improves scanability.
- Summary section with practical significance and limitations.
Quality Bar
A good output should feel like an editor can send it into the production pipeline after light copy edits. Both the _zh and _en versions should be understandable, technically faithful, and visually navigable even before professional layout — and they must agree on every number, claim, and figure.
Never let formatting polish compensate for weak content. The paragraph logic, figure captions, and claim accuracy matter most.
Tools
scripts/
├── extract_pdf.py ← CLI: paper.pdf → text.txt + figures/ + figures.json + captions.json + metadata.json
├── crop_figure.py ← CLI: clean a selected figure PNG (inspect / decaption / autotrim / box)
├── build_wechat_docx.py ← CLI: JSON outline → editor-friendly .docx
└── check_blog_package.py ← CLI: hard QA gate for bilingual DOCX deliverables
extract_pdf.pyis caption-anchored: it finds each figure by its caption, glues multi-panel sub-figures together, and clamps caption text off the bottom of the crop. Run it once per paper PDF (Workflow step 2).crop_figure.pyis the corrective tool for the crop-review step (Workflow step 6). It always backs up to<file>.png.bakbefore writing and keepsfigures.jsondimensions in sync. Full procedure and command reference live inreferences/image-guidelines.md.build_wechat_docx.pyassembles one.docxfrom a JSON outline (paragraphs, headings, embedded figures, captions, tables). Run it once per language with--lang— a_zhoutline →$BLOG_FINAL/blog_zh.docx --lang zh(font: 微软雅黑 for Chinese, Arial for Latin) and an_enoutline →$BLOG_FINAL/blog_en.docx --lang en(font: Arial for Latin text), both embedding the same figures. The article logic must be planned by the agent; the script is only an assembly aid.check_blog_package.pyvalidates the final bilingual package and rendered layout previews. Run it with--strictbefore delivery and iterate until it passes.- Python package dependencies are listed in
requirements.txt;extract_pdf.pyalso needs the Popplerpdftotextexecutable onPATH.
ResearchStudio-Reel/skills/paper2poster/html2pptx/SKILL.md
npx skills add microsoft/ResearchStudio --skill html2pptx -g -y
SKILL.md
Frontmatter
{
"name": "html2pptx",
"description": "Convert a rendered HTML page (especially A0 conference posters) into a native PowerPoint .pptx with editable text + native shapes — NOT a PNG-in-slide. Walks the live DOM via headless chromium, extracts BLOCK-level text containers (p\/h1-h6\/li\/td) as TextBoxes with inline <strong>\/<em> as mixed-style Runs, <img> as Picture with object-fit:contain respected (plus white-tile decoration under transparent-PNG logos), CSS ::before\/::after generated content (e.g. 'So what →' callouts) as inherited-style runs, SVG via cairosvg rasterization, decorative <div>\/<section> with bg\/border\/gradient\/box-shadow as Rectangle\/RoundedRect with matching fill (solid + linear-gradient + outer shadow). All CSS colors (including color-mix\/oklab\/color()) normalized via canvas. CSS hyphens:auto becomes OOXML soft hyphens via pyphen. CSS line-height absolute Pt for paragraph spacing. Native OOXML bullets with hanging indent. Optional Claude-vision fidelity auditor runs by default (toggle with `--no-vision-audit`); diffs HTML truth vs PPT render → structured 12-category issue report. Supports both direct API (`ANTHROPIC_AUTH_TOKEN`) and base-URL proxy (`ANTHROPIC_BASE_URL`) auth. Targets ~95% visual fidelity with web fonts installed. TRIGGER when user asks: 'HTML to PPT', 'poster to PowerPoint', 'editable PPT from HTML', 'pptx from html', '1:1 PPT clone', or wants an HTML render shipped as an editable .pptx for a non-developer collaborator."
}
html2pptx
Convert a rendered HTML page → native PowerPoint .pptx with editable shapes (not a PNG-in-slide).
When to use
- "Convert this HTML poster to PPT."
- "I need an editable PowerPoint version of my HTML page."
- "1:1 PPT clone of poster.html — fonts/colors/layout."
- "Hand off the poster to a non-developer in PPT."
When NOT to use
- Need a flat PNG slide → use
paper2poster/demo/presentation/posters.pptx. - HTML uses heavy JS animations / video → those don't translate to PPT shapes.
- Math-heavy KaTeX content → math gets rasterized fallback (loses equation editability).
Setup (one-time)
1. Python deps
pip install python-pptx playwright pdf2image lxml Pillow pyphen cairosvg
playwright install chromium # ~110MB headless browser
2. soffice (for verification)
# Linux:
sudo apt install libreoffice-impress
# Mac:
brew install --cask libreoffice
3. Web fonts — CRITICAL for fidelity (Read GOTCHAS.md)
Default = Arial → you can skip this whole section. Since the paper2poster Arial default, posters render in Arial (or another PPT-safe family: Calibri, Aptos, Cambria, Times New Roman, Verdana, Georgia, Trebuchet MS). Those are pre-installed on every Mac + Windows PowerPoint, so you do not need the font installs below and the
.pptxis portable without embedding. The Inter / Source Serif 4 / JetBrains Mono setup below applies only when the poster was rendered with the optional Inter webfont override.
Without Inter / Source Serif 4 / JetBrains Mono installed, soffice falls back to DejaVu Sans (Linux) or Helvetica (Mac) — character widths change, text wraps differently, all positions cascade-shift.
Linux:
# Download static-weight TTFs (variable fonts register under wrong family name)
mkdir -p /tmp/fonts-dl && cd /tmp/fonts-dl
curl -sL "https://github.com/rsms/inter/releases/download/v4.0/Inter-4.0.zip" -o Inter.zip
curl -sL "https://github.com/adobe-fonts/source-serif/releases/download/4.005R/source-serif-4.005_Desktop.zip" -o SourceSerif4.zip
curl -sL "https://github.com/JetBrains/JetBrainsMono/releases/download/v2.304/JetBrainsMono-2.304.zip" -o JetBrainsMono.zip
unzip -qo Inter.zip -d Inter/
unzip -qo SourceSerif4.zip -d SourceSerif4/
unzip -qo JetBrainsMono.zip -d JetBrainsMono/
mkdir -p ~/.local/share/fonts
cp Inter/extras/ttf/Inter-{Regular,Medium,SemiBold,Bold,Italic,SemiBoldItalic,BoldItalic}.ttf ~/.local/share/fonts/
cp SourceSerif4/source-serif-4.005_Desktop/TTF/SourceSerif4-{Regular,Semibold,Bold,It,SemiboldIt,BoldIt}.ttf ~/.local/share/fonts/
cp JetBrainsMono/fonts/ttf/JetBrainsMono-{Regular,Medium,Bold,Italic,BoldItalic}.ttf ~/.local/share/fonts/
fc-cache -f ~/.local/share/fonts/
# Verify: fc-match MUST return the font itself, not a fallback
fc-match "Inter" "Source Serif 4" "JetBrains Mono"
Mac: Same downloads, but copy to ~/Library/Fonts/ and fully quit + reopen PowerPoint (Cmd+Q) to pick up new fonts.
For a poster using the default Arial (or any PPT-safe family), the viewer needs nothing — those fonts ship with PowerPoint, so the pptx is portable as-is. Only for the optional Inter override must the viewer either have Inter installed OR the pptx embed it via the opt-in scripts/font_embedder.py step (embeds the 4 Inter weights into the pptx; ~3 MB).
Quick start — one command
cd skills/paper2poster/html2pptx
python -m scripts.auto_correct_loop --html /path/to/poster.html
Outputs default to the input HTML's parent directory (sibling-co-located convention, 2026-06-12 spec) — e.g. my_paper/poster.html → my_paper/poster.pptx. Successive runs in the same dir overwrite the previous .pptx. Override with --outdir /custom/path/.
Bundle contract (paper2 pipeline).* When invoked as the poster pipeline's final handoff, the caller passes --outdir <bundle>/assets/_pptx_build/ so every html2pptx artifact (DOM json, sanity PNGs, the soffice render) stays under assets/, then promotes the deck to the bundle root — cp <bundle>/assets/_pptx_build/poster.pptx <bundle>/poster.pptx — because poster.pptx is a top-level deliverable (the only html2pptx output that leaves assets/). The input poster.html references its figures / logos / fonts with root-relative assets/… src paths; the converter loads the HTML via file://, so those resolve from the HTML's own on-disk location automatically — no path handling is needed in the converter.
This is the canonical entry point. It:
- Auto-detects canvas from CSS
@page { size: ... }, caps to 55" if poster > 56" (PPT limit) with proportional scale - Auto-detects sibling
poster.pdf(if present): runspdffontson it, builds CSS-name → PDF-font alias map. Writes a fontconfig<match>runtime override so the browser uses the SAME fonts as the reference PDF — wraps match 1:1. Cleaned up viaatexit. - Auto-downloads any other HTML-referenced fonts from Google Fonts (
font_resolver.py) - Extracts DOM (one playwright run, cached as
<name>_dom.json) - Builds pptx → renders via soffice → PNG
- Saves comparison PNGs:
<name>_html_print.png(print viewport, matches PPT canvas) and<name>_html_browser.png(1920×1080 browser viewport, fullPage) - Vision-audit (ON by default): calls Claude vision to diff
<name>_html_print.pngvs<name>.png, writes<name>_audit.jsonwith structured fidelity issues (severity, category, block_idx, where, description). When a prior audit JSON exists, prints a delta (e.g.+/- 3 issues vs previous). Default model: Opus 4.8 (~$0.10/poster; override with--vision-model claude-sonnet-4-6for ~$0.02/poster if budget matters more than catch-rate); ~70s extra runtime.
Model selection (for orchestrators invoking this skill)
The skill exposes two independent model knobs. When a user says "use Opus" / "use Sonnet" / "use the cheaper one" / etc., the orchestrating Claude must translate that to the right flag — the skill scripts have no natural-language parsing.
| User intent | Flag to pass | Applies to |
|---|---|---|
| "Use Opus" / "highest quality" / no model specified (default) | (omit flag — default is Opus 4.8) | both |
| "Use Sonnet" / "cheaper" | --vision-model claude-sonnet-4-6 (and --fix-model claude-sonnet-4-6 if running auto_fix_loop) |
both |
| "Don't audit" / "skip vision" / batch automation | --no-vision-audit |
auto_correct_loop |
| "Vision Opus but fix Sonnet" (rare) | --vision-model claude-opus-4-8 --fix-model claude-sonnet-4-6 |
auto_fix_loop only |
Two separate knobs, two separate scripts:
scripts/auto_correct_loop.py(build + audit) →--vision-model <MODEL>scripts/auto_fix_loop.py(build + audit + autonomous code-fix subagent) →--vision-model <MODEL>AND--fix-model <MODEL>(both default Opus 4.8; the--fix-modelis explicitly passed toclaude -p --modelso the subagent doesn't silently inherit the orchestrator's CLI config)
Reproducibility: both scripts log the model choice at start. The vision-audit logs [vision-audit] calling Claude vision (claude-opus-4-8, auth=...); auto_fix_loop logs [fix-loop] models: vision-audit=..., fixer=.... If a user reports unexpected behavior, the model used is in the run log.
Vision-audit auth
Two paths, either works:
| Mode | Env vars | Use case |
|---|---|---|
| Direct API | ANTHROPIC_AUTH_TOKEN (or ANTHROPIC_API_KEY) |
Personal key, direct to api.anthropic.com |
| Base-URL proxy | ANTHROPIC_AUTH_TOKEN + ANTHROPIC_BASE_URL |
Corporate proxy / Vertex / Bedrock / company gateway. This is the setup on the local dev machine. |
If neither auth is configured, audit silently skips with a one-line hint (so --no-vision-audit isn't required for offline/CI runs). Pass --no-vision-audit to explicitly disable for batch automation (100 posters = ~$2 + ~2hr extra).
3-round revision workflow (dev cycle)
Vision audit replaces the old PIL-based L2 loop with a much more useful signal: structural fidelity diff, not cosmetic font shrinking. Each rerun re-runs the audit and shows +/- N issues vs previous — the dev iteration loop is:
# round 1: baseline (defaults to ./output/<timestamp>/)
python -m scripts.auto_correct_loop --html poster.html
# → look at output/<ts>/poster_audit.json, identify top systemic bug
# round 2: after fixing the bug in html_to_pptx.py
python -m scripts.auto_correct_loop --html poster.html --outdir output/<ts>/
# → output shows e.g. `[vision-audit] 2 issues (-9 vs previous)`
# round 3: fix next bug, target near-zero high-severity
python -m scripts.auto_correct_loop --html poster.html --outdir output/<ts>/
Empirically: 2-3 rounds clears most issues on a new HTML template. Each round costs ~1 minute + ~$0.02.
Cross-poster aggregation: python -m scripts.vision_aggregate <root_dir> walks every <poster>/poster_audit.json and prints category frequencies + per-poster issue counts → reveals SYSTEMIC bugs (same category appearing across N posters) worth fixing in code vs one-off issues.
Autonomous fix loop (auto_fix_loop.py) — fully agentic
For per-poster fidelity fixes you don't want polluting the shipped skill, run:
python -m scripts.auto_fix_loop --html /path/poster.html --max-rounds 3
Each round:
- Builds + audits (via an ISOLATED COPY of
scripts/underoutput/<ts>/_skill_run_copy/) - Picks top actionable issue from the audit (category whitelist filters out vision-prone false positives like
alignment_off) - Spawns
claude -psubagent, sandboxed to the copy (--add-dir <copy>, only Read/Edit/Grep/Glob tools — no Bash, no git, can't escape) - Subagent reads
GOTCHAS.md+ the copy'shtml_to_pptx.py, makes a minimal diff - Rebuild + re-audit
- If issue count dropped → keep change in the copy's internal git
Otherwise →
git reset --hardin the copy → try next issue - Loop terminates: convergence (no actionable issues) OR max-rounds OR consecutive-failures cap
Per-run isolation is the critical design point: your shipped skills/paper2poster/html2pptx/ is NEVER touched. Each run produces:
output/<ts>/<name>.pptx— final patched PPToutput/<ts>/<name>_audit.json— final audit (target: 0 issues)output/<ts>/<name>_run_patches.diff— cumulative subagent diff vs baseline (review me!)output/<ts>/_skill_run_copy/— patched skill copy (rerun-able on the same HTML)output/<ts>/<name>_fix_loop_summary.json— per-round trajectory
If reviewing the .diff shows a fix is genuinely general (not poster-specific), cherry-pick it into the shipped skill via a normal PR. Otherwise the patch stays scoped to that one output dir — other posters / other people's installs unaffected.
Cost: ~$1–3 per converged run with default models (Opus 4.8 for both vision-audit and fix subagent). Override either via --vision-model / --fix-model (e.g. claude-sonnet-4-6) to cut cost by ~5× at the price of lower catch-rate / weaker root-causing. Both model choices are logged at start so the run is fully reproducible.
Auth: same as --vision-audit (ANTHROPIC_AUTH_TOKEN direct or with ANTHROPIC_BASE_URL for corporate proxy).
Removed 2026-06-05: the previous "L2 closed-loop" font-shrinking rounds — PIL-predicted overflow was decoupled from soffice's actual render (audit showed 17→17→17→17 overflows across 4 rounds on paper_4_d), and the shrinker introduced occasional false-positive shrinks on healthy body paragraphs. Replaced with
--vision-audit(default-on) which catches REAL fidelity issues (missing logo, dropped span, wrong wrap) — issues PIL could never see. Wrap fidelity itself is now solved at DOM-extract time (browserextractWrapLineswith CSS line-height threshold), not after- the-fact. The--rounds/--shrink-stepflags still parse (silently no-op) so existing call sites don't break.
Output convention: outputs land in dirname(input_html)/ by default (sibling of the source HTML); pass --outdir <path> to override.
Default slide 47×33.1" (A0 landscape). Override via --width-inch / --height-inch.
Font workflow
The skill resolves fonts in this priority chain:
-
Sibling
poster.pdfexists (or--reference-pdfflag): the skill extracts the PDF's actual fonts viapdffonts, builds an alias map (e.g. CSSInter→ PDFArimo), installs the PDF fonts locally, and writes a fontconfig<match>runtime override forcing the browser to render with the PDF fonts (not the CSS-named ones). PPT XML font names = PDF font names. Best fidelity when matching a coworker's existing PDF rendering whose machine fell back to different fonts than the CSS designer intent. -
No sibling PDF: the skill walks all
font-familydeclarations in the HTML and auto-downloads from Google Fonts any that aren't installed (font_resolver.py). Designer-intent fonts (Inter, Source Serif 4, JetBrains Mono, Roboto, Playfair Display, etc.) all available on Google Fonts get installed automatically. -
CSS / Mac / Win system fonts without a Google Fonts equivalent (Helvetica Neue, SF Pro, Menlo, etc.) are mapped via
FONT_ALIASESinhtml_to_pptx.pyto installed equivalents (Inter, JetBrains Mono, Source Serif 4) for both browser-side fontconfig alias and PPT-side run.font.name.
Verifying output
# Open the last round's PNG; compare with the HTML render PNG:
ls /tmp/uframe_round_*.png
# soffice → PDF → PNG at 96 DPI to match playwright (DPI mismatch = false font-size-look-smaller bug)
Critical: dpi=96 everywhere. Mismatched DPI in comparison PNGs creates visual size discrepancy that looks like a font-size bug but isn't.
soffice render ≠ PowerPoint render. Always sanity-check the final .pptx in actual PowerPoint (Mac/Win). soffice often diverges on shadow blur, font-shaper kerning, and gradient stops.
Architecture
HTML
↓ [playwright chromium] render + DOM walk
↓ getComputedStyle on every visible element
↓ canvas.fillStyle = anyColor → normalized rgba()
DOM extract (cached as <name>_dom.json)
├─ elements → boxes (bg/border/gradient/shadow) + images (with object-fit + decoration tile)
└─ text_blocks → block-level (p/li/h1-h6/figcaption/td/th) with inline runs
(incl. ::before/::after pseudo-element content as inherited-style runs)
↓ [python-pptx + lxml] generate native shapes (Z-order by DOM depth)
PPTX
- Picture (object-fit:contain math → centered, letterboxed inside content area = bbox - padding)
+ optional decoration tile UNDER picture (CSS background-color/border-radius/box-shadow)
+ SVG → PNG via cairosvg before embed
- Rectangle / RoundedRect (CSS border-radius → exact roundRect adj)
+ solid fill / linear gradient (gradFill XML)
+ border (line.color/width)
+ box-shadow (outerShdw effect)
- TextBox per block element
+ multiple inline Runs (one per <strong>/<em>/text node/pseudo-element)
+ CSS padding → tf.margin_*
+ CSS line-height → absolute Pt line_spacing
+ CSS text-transform applied per-run (so ::before uppercase doesn't inherit paragraph 'none')
+ CSS letter-spacing → OOXML rPr spc
+ CSS hyphens:auto → pyphen-inserted U+00AD soft hyphens
+ <li> → native OOXML <a:buChar> with hanging indent + section color marker
+ overflow:hidden + line-clamp → PIL-measured truncation + "…"
+ single-word short text (≤6 chars, no spaces) → word_wrap=False (honors browser-proven fit)
PPTX (final)
↓ [optional: --vision-audit] Claude vision diff (html_print.png vs ppt.png)
↓ structured fidelity report (<name>_audit.json): 12 categories × 3 severities
↓ delta vs previous audit if one exists → dev-cycle smoke test
Capability table
| Capability | Status |
|---|---|
| Position / sizing pixel-perfect | ✓ |
| Computed colors (oklab / color-mix / color() / hsl / named) | ✓ via canvas |
| Alpha compositing (rgba with α<1 → composited over white) | ✓ |
Inline <strong>/<em> as Runs in same paragraph |
✓ |
| Section colored banner (h3 bg + text + numbered marker) | ✓ |
| Linear gradients | ✓ (gradFill) |
| Box-shadow | ✓ (outerShdw) |
| Border-radius (exact CSS px → roundRect adj) | ✓ |
| Per-side borders (border-top/right/bottom/left, asymmetric) | ✓ thin Rect per present side |
| Inline text background → run highlight (Text Highlight Color) | ✓ schema-compliant rPr order |
Superscript / Subscript (<sup>/<sub> or CSS vertical-align) |
✓ baseline offset + readability floor |
| Image object-fit:contain (letterboxed inside bbox) | ✓ |
| CSS padding → textbox margins | ✓ |
| CSS line-height absolute (eliminates trailing whitespace) | (leveraged when needed) |
| CSS hyphens:auto (libhyphen → pyphen soft hyphens) | ✓ |
| Native OOXML bullets with hanging indent + section color | ✓ |
| overflow:hidden + line-clamp truncation | ✓ |
Auto-detect canvas size from @page { size: ... } |
✓ |
| Cap slide to PPT 56" with proportional scale | ✓ |
| Auto-download fonts from Google Fonts | ✓ (via font_resolver) |
| Auto-detect sibling poster.pdf → extract its fonts, alias-map | ✓ when present |
CSS ::before/::after generated content as inherited-style runs |
✓ (catches "So what →" callouts, content-arrow icons) |
| Per-run text-transform override (pseudo-content uppercase != paragraph 'none') | ✓ |
<img> decoration tile (white rounded-rect under transparent-PNG logos) |
✓ (CSS background-color + border-radius + padding) |
| SVG embed (rasterized via cairosvg → PNG) | ✓ |
| Single-word short text → word_wrap=False (badges like ICLR/ICML) | ✓ (≤6 chars, no spaces) |
| Vision-audit fidelity report (Claude vision diff, 12 categories) | ✓ ON by default (~$0.02/poster; silently skipped when no auth) |
| Autonomous code-fixer loop (claude-p subagent in isolated skill copy) | ✓ via scripts/auto_fix_loop.py (~$0.50-1.50/run; shipped skill never modified) |
| Cross-poster systemic-bug aggregation | ✓ via scripts/vision_aggregate.py |
| Closed-loop font shrinking for overflow (L2) | ✗ removed 2026-06-05 (PIL-prediction-vs-soffice decoupling made it a no-op + false-positive risk; replaced by --vision-audit) |
| Match-wrap (force PPT wrap at browser positions) | ✗ default off; opt-in via flag |
| Font embedding in pptx (cross-machine portability) | ✗ (Phase 3 TBD) |
| KaTeX math equations | ✗ (Phase 4 PNG fallback) |
| CSS transforms / filters | ✗ (Phase 4 PNG fallback) |
Scripts
scripts/html_to_pptx.py— DOM extract + pptx build (accepts--correctionslegacy,--match-wrapto force browser wrap positions). Supports::before/::afterpseudo-element content extraction (CSS callouts like "So what →"), per-run text-transform,<img>decoration tiles (white rounded rect under transparent-PNG logos), and SVG rasterization via cairosvg.scripts/auto_correct_loop.py— canonical entry: single-shot DOM-extract → build → render → compare-PNG generator. Handles sibling-PDF font alias + Google Fonts auto-download. Default outdir =dirname(input_html)/(sibling-co-located). With--vision-audit(default on), runs vision diff after render.scripts/auto_fix_loop.py— autonomous fix loop: copies skill scripts to an isolatedoutput/<ts>/_skill_run_copy/, spawnsclaude -pper round to fix the top audit issue in the COPY (sandboxed via--add-dir), rebuilds, re-audits, keeps or rolls back via the copy's internal git. Shipped skill stays untouched; per-run diff exported for human cherry-pick.scripts/vision_audit.py— Claude vision fidelity auditor. Takes (html_truth.png, ppt.png, dom.json) → structured issue list classified into 12 categories (missing_element, wrap_mismatch, text_clipped, color_drift, ...) with severity and block_idx. Replaces the abandoned PIL-based L2 loop.scripts/vision_aggregate.py— cross-poster category frequency rollup. Walks<root>/*/poster_audit.json, prints which bugs recur and how often — surfaces SYSTEMIC issues vs one-off.scripts/font_resolver.py— Google Fonts auto-download + sibling-PDF font detection + fontconfig runtime alias
Why this beats pixel-based PDF→PPTX converters
→ See difference.md — concrete advantages over iLovePDF / Smallpdf-style raster converters: inline text highlight as run property (not floating shape), native list bullets (not orphan glyphs), bold preserves family (not lookalike substitute), plus other native-vs-pixel semantic preservation.
Gotchas
→ See GOTCHAS.md — every non-obvious failure mode + root cause + fix, written after hitting them in production.
Quick links:
- G1: Font name vs family (Inter Variable ≠ Inter)
- G2: soffice ≠ PowerPoint (always sanity-check in PowerPoint)
- G3: DPI consistency for PNG comparison
- G4:
line_spacingonly works with correct fonts - G5: CSS color-mix returns oklab — normalize via canvas
- G6:
isInlineOnlytag-name based, not CSS-display based - G7: h3 numbered markers (skip decoration, recolor text)
- G8: object-fit:contain — manual letterbox math
- G9: hyphens:auto via pyphen + U+00AD
- G10:
::markeris generated content — use OOXML native bullets - G11: Mac PowerPoint needs fonts installed too
- G12: Variable fonts confuse fontconfig — use static weights
- G15:
<img>decoration tile must render under picture (white tile under transparent logos) - G16: Single-word short text → set
word_wrap=False(ICLR/ICML badges) - G17:
::before/::afterinvisible to DOM walkers — fetch viagetComputedStyle(el, '::before') - G18: Vision audit false-positive: LEFT-aligned in wide bbox looks centered
Dependencies
| Package | Purpose |
|---|---|
| playwright | chromium for DOM extract |
| python-pptx | OOXML construction API |
| lxml | direct OOXML when python-pptx lacks (gradients, shadows, bullets) |
| Pillow | image decoding + text measurement |
| pdf2image | PDF → PNG preview |
| pyphen | CSS hyphens:auto soft-hyphen insertion |
| cairosvg | rasterize SVG <img> to PNG before embed (PIL can't decode SVG) |
Optional (for --vision-audit):
| ANTHROPIC_AUTH_TOKEN | Claude API auth for vision diff |
For verification:
| libreoffice | PPTX → PDF via soffice |
| poppler-utils | pdf2image's binary backend (pdftoppm) |
Roadmap
- Phase 3 (TBD): web-font embedding in pptx (
ppt/fonts/*.fntdata+embeddedFontLst). Eliminates the Mac/Win "missing fonts" issue at the cost of ~3 MB per pptx. - Phase 4 (TBD): PNG fallback for unsupported subtrees (KaTeX math, complex SVG, CSS transforms).
ResearchStudio-Reel/skills/paper2poster/SKILL.md
npx skills add microsoft/ResearchStudio --skill paper2poster -g -y
SKILL.md
Frontmatter
{
"name": "paper2poster",
"description": "Render a pre-extracted paper's structured 9-section spec (`paper_spec.md`) into a single-page HTML academic poster, fit the layout to the page via an iterative measured-fill loop, and export it to print-ready PDF + PNG thumbnail. Requires the upstream `paper2assets` skill to have produced the input `<outdir>\/` package (`manifest.json` at the root + an `assets\/` folder holding `meta\/paper_spec.md`, `meta\/text.txt`, `meta\/figures.json`, `meta\/metadata.json`, `figures\/*.png`, `logos\/`, `qr\/`) first. Use when the user wants an HTML poster, PDF\/PNG export, or PPTX from a paper they already have extracted assets for — e.g., \"render the poster\", \"make the poster from this spec\", \"export poster to PDF\", \"paper2poster\". The three skills paper2assets → paper2poster → html2pptx run in sequence, each invokable on its own.",
"allowed-tools": "Bash(*), Read, Write, Edit, Grep, Glob, AskUserQuestion, WebFetch, WebSearch"
}
paper2poster — paper_spec.md → HTML poster → PDF/PNG + editable PPTX
This skill is the rendering stage of a 3-skill pipeline. It assumes paper2assets has already produced the input <outdir>/. Given that outdir, it picks figures, renders an HTML poster, iteratively fills the layout to fit a fixed page (60×36in landscape or 33.1×46.8in A0 portrait), generates per-section narration audio, hands the HTML to the bundled html2pptx sub-skill (vendored at html2pptx/) for an editable .pptx, and exports PDF + PNG. The html2pptx handoff is a standard final step, not optional — one run yields poster.{html,pptx,pdf,png}, because users want the editable deck in the same pass and won't call html2pptx separately.
<outdir>/ (produced by paper2assets)
manifest.json assets/figures/ assets/logos/ assets/qr/ assets/meta/{paper_spec.md, text.txt, figures.json, metadata.json}
│
▼ Step 1 — verify prerequisites
▼ Step 2 — pick figures (Method / optional Motivation / optional Secondary)
▼ Step 2.5 — optional per-figure visual box cut (asymmetric noise the
│ paper2assets deterministic chain couldn't catch — most
│ figures need no further work here)
│
▼ Step 3 — compose (layout × style × header) + substitute (references/compose_poster.py → poster.html)
│ → <outdir>/poster.html (lean: 6 core sections, Necessary only)
│
▼ Step 4 — iterative fill loop (check_poster.py slack + polish)
│ → <outdir>/poster.html (every section FULL, every figure ≥90% on one axis)
│
▼ Step 5 — generate_audio.py → <outdir>/assets/audio/<id>.mp3 (free Edge TTS, from <outdir>/assets/meta/narration.json)
│ → the Listen buttons + Full Listen play these clips by id
│
▼ Step 5.9 — fit_logos.py → pack the header institution logos to fill their zone
│ (browser-measure + greedy shape-pack; baked into poster.html)
│
▼ Step 6 — render_poster.py → <outdir>/poster.pdf + <outdir>/poster.png (applies + bakes the expand into poster.html)
│
▼ Step 7 — html2pptx skill → <outdir>/poster.pptx (reads the baked poster.html; users want the editable pptx in one run)
│ (render FIRST — bakes the expand into poster.html; html2pptx then reads it, isolated under assets/_pptx_build/)
│ then check_poster.py verify-final
│
▼ Step 7.5 — check_poster.py deliverables (MANDATORY final gate)
│
└─→ Step 8 — Report absolute paths (html + pptx + pdf + png)
The canvas is fixed per orientation (landscape 60×36in 5:3 / portrait A0 33.1×46.8in 0.708) in the templates; do not change it.
Mandatory finishing gates (NEVER ship outside these bands)
Two hard requirements gate the final poster. They are NOT warnings, NOT advisory, NOT "polish for next iteration" — a poster that violates either MUST be re-iterated before you call the task done:
-
Every card figure fills 90–100% of its section on at least one axis (width OR height).
check_poster.py polishreports this asFIG/NARROW. A figure painting under 90% on both axes is a small stamp marooned in its card — a visible defect. Stated as one number:fillRatio = max(w_fig / w_section, h_fig / h_section)MUST land in[0.90, 1.00]. The fix order (cap tune → tighten the figure-section's prose → shorten the figcaption → rebalance the column) is inreferences/staged_fill.md. Do not exit the staged-fill loop with any figure below 90%. -
Every section reads
FULL(fullRatio 0.90–1.00).check_poster.py slackreports this.OVERFLOW(>1.10),SPILLAGE(1.00–1.10),SPARSE(0.70–0.90), andEMPTY(<0.70) are all unacceptable finishing states.
Both gates compose: an edit that fixes one but breaks the other must be rolled back. Loop until slack shows every section FULL AND polish reports zero FIG/NARROW warnings — then render PDF/PNG and run verify-final + deliverables.
When to consult which reference
| Step | When you hit it, read |
|---|---|
| Step 3 — compose the template | references/compose_poster.py (assemble layout × style × header → self-contained poster.html; landscape only) |
| Step 3 — substitute placeholders | references/template_substitution.md (placeholder map, theme color randomization, per-section accents, vertical-sizing rule, lean-render policy) |
| Step 3 — content patterns | references/content_patterns.md (catalog of 16 reusable CSS widgets — callouts, key-stat, vs-compare, numbered-steps, timeline ×4 variants, chips, definition, highlight-table, pullquote, bento, equation, banner — to break up wall-of-text monotony in section bodies) |
| Step 3 — polish | references/visual_polish.md (typography, color/contrast, inline emphasis rules, stat grid, figure cap, callouts, arch banners, print hygiene) |
| Step 4 — staged fill | references/staged_fill.md (slack command, the iterative measure→select→apply→review loop, the modification-method catalog, shave-back rules) |
| Step 5.9 — pack header logos | references/fit_logos.py (browser-measure each logo zone + greedy shape-pack so the marks fill it; bakes rows into poster.html — run after the fill loop, before render) |
| Step 5 — audio (generate) | scripts/generate_audio.py synthesizes <outdir>/assets/audio/<id>.mp3 from the <outdir>/assets/meta/narration.json script (produced upstream by paper2assets; free Edge TTS) for the Listen buttons. See references/audio_narration.md. |
Workflow
Parallel-safety (READ FIRST — never violate)
This skill is frequently run on MANY papers at once — a batch driver
launches one claude -p session per paper, each with its own separate
<outdir> (e.g. papers/foo_portrait/, papers/bar_portrait/, …
running concurrently). Therefore:
- NEVER run
ps,pkill,kill,killall, or otherwise inspect or terminate other processes. Any otherclaude/python/chromium/sofficeprocesses you see belong to SIBLING poster jobs on DIFFERENT papers. They are NOT duplicates of you and NOT competing for your files. Killing them corrupts other papers' runs. - Only ever read/write files inside YOUR given
<outdir>. Never touch another paper's directory or the shared template/config dirs. - If
<outdir>/poster.htmlappears to have "changed unexpectedly" between a Read and an Edit, it changed because of YOUR OWN prior tool call (a Write, a substitution script you ran, a prior Edit) — NOT a competing process. Re-Read the file and continue. Do not investigate "who else is modifying it" — nobody else is. - The shared template at
<config>/skills/paper2poster/assets/*.htmlis READ-ONLY input — copy it into your<outdir>, never edit it in place.
Violating any of the above is the single most common cause of a fast-fail (~400s) where the agent rabbit-holes on phantom "process conflicts" instead of building the poster.
Step 0 — Cache check (do this FIRST, before any other work)
Rendering a poster runs Stage 2's iterative staged-fill loop (~15-30
min of Claude tokens) and overwrites <outdir>/poster.html —
destroying any inline edits, manual figure swaps, or layout tweaks the
user may have made. Before starting, check whether the deliverables
already exist:
if [[ -f "$outdir/poster.html" \
&& -f "$outdir/poster.pdf" \
&& -f "$outdir/poster.png" ]]; then
echo "[paper2poster] CACHED in $outdir — poster from prior run, reusing."
echo " poster.html $(stat -c%s "$outdir/poster.html") bytes"
echo " poster.pdf $(stat -c%s "$outdir/poster.pdf") bytes"
echo " poster.png $(stat -c%s "$outdir/poster.png") bytes"
# Report any extras
[[ -f "$outdir/poster.pptx" ]] && echo " poster.pptx (html2pptx output present)"
[[ -d "$outdir/assets/audio" ]] && echo " assets/audio/ (narration present)"
exit 0
fi
If all three core deliverables are present, REPORT and STOP.
Re-render ONLY when:
- one of
poster.{html,pdf,png}is missing → resume from the appropriate step (poster.htmlmissing → start from Step 2 fill loop; onlyposter.pdf/poster.pngmissing → just run Step 6 render) - the user explicitly requests it ("rebuild the poster", "regenerate",
"fresh render", "from scratch", "redo the layout"). In that case,
delete
<outdir>/poster.htmlfirst so the cache check doesn't fire.
Step 1 — Verify paper2assets prerequisites
Required argument: an <outdir>/ path produced by the paper2assets skill, or a path to a source *.pdf. Verify the required files exist before doing any work:
ls <outdir>/assets/meta/paper_spec.md <outdir>/assets/meta/text.txt <outdir>/assets/meta/figures.json <outdir>/assets/meta/metadata.json
ls <outdir>/assets/figures/*.png
If any of the five required files is missing, automatically invoke the paper2assets skill on the source PDF to produce/populate the <outdir>/, then continue.
Optional files <outdir>/assets/logos/ and <outdir>/assets/qr/ may be absent — but DO NOT silently delete the logo/QR HTML blocks just because the directory is missing. Two common reasons the directory is missing are recoverable:
- paper2assets's Step 6 was skipped, or
- paper2assets's Step 6 was called with the wrong CLI flags (the
--spec/--outdir <outdir>/assets/logostraps documented in paper2assets SKILL.md Step 6 caused most papers to land logos at<outdir>/assets/logos/logos/<slug>.pnginstead of<outdir>/assets/logos/<slug>.png).
Recovery procedure when <outdir>/assets/logos/ is missing or empty:
# A. Auto-fix the nested-dir bug if present
if [ -d <outdir>/assets/logos/logos ]; then
mv <outdir>/assets/logos/logos/* <outdir>/assets/logos/ 2>/dev/null
rmdir <outdir>/assets/logos/logos
fi
# B. Retry fetch_logos.py from this skill (it's a paper2assets script
# but safe to call from paper2poster as a recovery step).
python ~/.claude/skills/paper2assets/scripts/fetch_logos.py \
--from-spec <outdir>/assets/meta/paper_spec.md --outdir <outdir>
# C. Same for qr/ if missing
python ~/.claude/skills/paper2assets/scripts/make_qr.py \
--from-metadata <outdir>/assets/meta/metadata.json --outdir <outdir>
Only AFTER the retry, if logos/ is still empty (institute names didn't resolve to any Wikipedia infobox — happens for "Anonymous Institution" submissions and obscure labs), then remove the unused <img class="logo"> elements from the poster HTML as a final fallback. Same for missing QR codes.
Step 2 — Pick figures
Read figures.json + captions.json + paper_spec.md. Pick:
- Method figure — the figure that visualizes the paper's proposed approach. Usually labeled "Figure 1" or "Figure 2" and described in the Method section's caption ("our pipeline", "overview", "architecture"). Record its
width,height, andlayoutfromfigures.json. - Motivation figure (optional) — a figure that motivates the problem (a "failure mode" plot, a side-by-side comparison with prior art, a teaser). Pick only when one of the early figures clearly carries motivational signal. Disjoint from Method. Always rendered as
{column=half}— full-width motivation figures are not supported. - Secondary figure (optional but encouraged) — a Key Result plot, ablation chart, or qualitative samples figure. Disjoint from Method + Motivation. Target ≥2 figures per poster — Method alone leaves the empirical side as a prose-and-numbers wall.
- Figure-rich mode → 3-column layout. If the paper carries more than 3 high-signal figures (multiple result plots, a qualitative-samples gallery, architecture + component diagrams), select them all (soft cap ~6) instead of stopping at 2 — and render with the 3-column layout (
--layout 3col, Step 3) whose wider columns hold bigger figures, the way most author-GT posters do. Distribute the figures across all three columns; the Method figure stays the anchor. Pick only genuinely informative figures — never pad to hit a count.
Cite each picked figure with its file path: assets/figures/<page>_figure<n>.png. Verify the file exists on disk before relying on it.
Step 2.5 — Per-figure visual box cut (optional, for asymmetric noise the deterministic chain couldn't catch)
paper2assets already ran the deterministic cleanup pipeline (top-check → decaption → autotrim) on every figure. Most picked figures need no further work.
If a picked figure has asymmetric noise the deterministic chain couldn't catch — multi-figure-page bleed on a SIDE (a vertical strip of an adjacent panel), an obvious orphan caption line a tight decaption threshold missed, or a region of figure content you want excluded — do one visual box pass:
- Read the figure with the Read tool.
- Decide a tight pixel bbox
(X0, Y0, X1, Y1). - Apply:
This writes a one-timepython ~/.claude/skills/paper2assets/scripts/crop_figure.py box <outdir>/assets/figures/<file>.png --box X0 Y0 X1 Y1<file>.png.bak(or preserves the existing one from paper2assets' earlier work) and updatesfigures.json. - Re-Read the result. Cap at 3 attempts per figure; restore from
.bakwithcpif you over-cut.
For most papers this step is a no-op — skip when the picked figures look clean after paper2assets' deterministic pipeline.
Step 3 — Render the HTML poster
Pick THREE independent axes — layout × style × header — then COMPOSE.
Landscape posters are now assembled from three orthogonal source axes under assets/ (instead of one monolithic file per combination):
assets/layouts/{full,half,3col}.html— STRUCTURE (column grid +.sectioncards + base CSS),assets/styles/{solid,framed,simple}.css— VISUAL style,assets/headers/{v1,v2,v3,v4}.html— the TITLEBAR.
references/compose_poster.py injects the chosen style at {{STYLE_CSS}} and the chosen header at {{HEADER}} into the chosen layout and writes ONE self-contained poster.html — structurally identical to the old monolithic templates, so check_poster.py / render_poster.py / the staged-fill loop all work UNCHANGED. Pick each axis independently — four orthogonal axes (layout × style × header × scan) composed from ~30 small source files, not an N×M×K×J explosion of monolithic templates.
Portrait is now composed (--orientation portrait, assets/layouts_portrait/{full,half}.html) — it takes the STYLE + COLOR + HEADER axes (all 11 styles + 8 themes + 5 A0 title formats pv1–pv5, light-default header). It has no Scan-to-Read section, so it composes those three axes only. Portrait sections must use the content-pattern widgets (references/content_patterns.md) with the same ≥5-distinct-types discipline as landscape — the narrow A0 columns make it easier to collapse to plain bullets/tables, so it needs the widget palette more.
Axis 0 — orientation:
landscape(default) — ICML / NeurIPS / CVPR standard 60×36 in, 5:3 aspect. 4-column outer grid. Always use landscape unless the user has explicitly opted into portrait.portrait— ACL / NAACL / AAAI 2025 standard A0 portrait, 33.1×46.8 in, 0.708 aspect. 2-column outer grid. Use ONLY when the pipeline setsPOSTER_ORIENTATION=portrait. Do NOT auto-detect orientation from the Method figure aspect ratio — figure shape drives the layout axis (full vs half) WITHIN an orientation, not the orientation itself. A tall Method figure in landscape goes in ahalftemplate's single column; it does not flip the whole poster to portrait.
If POSTER_ORIENTATION is unset (or set to landscape), use landscape templates. Period.
Axis 1 — layout (driven by Method figure shape):
-
full(landscape--layout full; portraitlayouts_portrait/full.html) — use when the Method figure is horizontally wide: in landscapeAR ≥ 2.5, in portraitAR ≥ 1.2. Also use when{column=full}infigures.json. In landscape, this picks a 4-col outer grid with the middle two columns merged into a.mid-wideblock. In portrait, this picks a layout with the Method section in a full-width hero band (.method-hero— bullets-left + wide-figure-right side-by-side) above the 2-col body.Portrait pseudo-section for bullets fill: the hero band's LEFT bullets cell is wrapped in
<div class="section method-text" data-section="method-text">…</div>— a visually-transparent pseudo-section that participates in the staged-fill loop. slack.py measures its fill ratio asbullets_content_h / row_h. When the figure stretches the row taller than the bullets need, the verdict surfacesmethod-text SPARSEand the LLM expands bullets until the cell fills. This is why the AR threshold can stay loose at 1.2 instead of the safer 1.8 — the pseudo-section absorbs medium-aspect-figure whitespace automatically. -
half(landscape--layout half; portraitlayouts_portrait/half.html) — default within the orientation. Use when Method figure AR is moderate or tall AND{column=half}in figures.json, OR when Method figure is**Figure:** none. In landscape, Method is a half-width card in 1 of 4 cols. In portrait, Method is a card in 1 of 2 cols.
Read the Method figure's width and height from figures.json, compute AR = width / height, then:
- Landscape:
fullif AR ≥ 2.5 OR{column=full}, elsehalf. - Portrait:
fullif AR ≥ 1.2 OR{column=full}, elsehalf. (The hero band's bullets cell is a pseudo-section that auto-fills via the staged-fill loop; medium-aspect figures are safe.)
Figure-rich override (landscape — takes precedence over full/half). If Step 2 selected more than 3 figures, use the 3-column layout (--layout 3col) regardless of the Method figure's AR — its 3 wide equal columns (1fr 1fr 1fr) hold bigger, more numerous figures, matching the dominant author-GT layout. The Method figure card stays prominent in the middle column; the other figures distribute across columns. Via composition, 3col now combines with any style and any header (the old "solid-only" limit is gone). The full/half choice above applies only when the poster carries ≤ 3 figures.
Method-driven override (OPT-IN — only when the user explicitly asks for a "method-driven" poster). Use --layout methoddriven. The Method owns the wide middle block (.mid-wide, the merged centre columns): a large priority Method figure on top, then the method split into solid rounded subsection cards (.section.msub, data-section="method-1"…"method-N") that organize its sub-parts; the benchmark-style filler cards are dropped (side columns carry Problem/Motivation left, Key-Results/Headline/Takeaway right). This is opt-in — --layout random never selects it, and every other layout/style is unchanged. Rules:
- Colour =
multi-accentby default: same hue as the theme--accent, different depth per card (.msub.d1…d4, theme-derived — tracks the 8-theme axis). Opt into distinct hues by adding classmhueto<body>. - The Method figure is the priority — it fills the column WIDTH (so a wide banner clears the 90% figure-fill gate on width; NO exemption needed) and is bounded only by a generous, fill-loop-tunable height guard
--method-fig-max(default42cqh). Raise/lower--method-fig-maxto trade figure size against card room. The<figcaption>flows below. - The subsection cards adapt to the figure — the fill loop chooses the arrangement per-paper (no fixed 2×2).
.msubsis a span-composable grid; tag individual cards + order them to compose the layout:.msub.wide→ full-width long row (first card = top row, last card = bottom row).msub.tall→ tall card spanning 2 rows (the two normal cards flow the other column)- add
cols-3to.msubs→ a 3-up equal-column row - Examples: tall+2 (one
.tall+ two normal); row-top/bottom+2 (a leading/trailing.wide+ two normal); 2×2 (four normal); 3-up (.cols-3+ three normal). Add/remove cards freely.
- Fit the cards into the room the figure left. Run the
packpre-check; measure every.msubwithslack(each must read FULL 0.90–1.00). If cards are SPARSE/OVERFLOW or unbalanced, re-pick the arrangement (add/remove a.wide/.tall, change card count, switch to.cols-3) or nudge--method-fig-max, rather than only padding prose. In any multi-card row, balance the cards' content or the shorter one trails blank (polish Gate C / CARD-TRAILING).
Axis 2 — style (POSTER_STYLE, default randomize):
The first three are full themes (they retheme the header + page background); styles 4–11 are block-only card treatments that ride the solid theme (accent header, flat white logo chips) and just change the section-card look.
solid— the classic look: solid-filled accent titlebar (accent bg, white text), sectionh2= colored accent text with a thin underline. Sections are quiet warm cards with a top accent stripe.framed— the editorial look: outlined rounded titlebar frame (white bg, accent border + accent title), sectionh2= solid accent-filled banner edge-to-edge; white section cards with a neutral thin frame.simple— the minimal white look: white header + a single thin rule beneath it, near-black plain headings, frameless section cards separated only by a hairline top rule.left-bar— thick accent rail down the card's left edge; plain accent heading.elevated— floating white card with a soft deep shadow, rounded corners.neo-brutal— hard black border + offset accent drop-shadow; uppercase headings.tag— heading rendered as an inline accent pill.underline— bold accent rule hugging the heading.tinted— faint accent-tinted card background.double-rule— centered heading between two thin accent lines.legend-frame— heavy accent border with the centered heading sitting on the top edge, breaking the border (fieldset/legend look).
Default style policy: for landscape, randomize across all 11. Pass --style random so compose_poster.py picks one DETERMINISTICALLY from a hash of the output path — a reproducible spread across a wave. Do NOT ask the model to "pick a random style" (it defaults to solid in headless); use the random keyword. Override a specific one via POSTER_STYLE=<name>. The block-only styles (4–11) ride the solid header theme. Portrait now composes too (--orientation portrait, reads assets/layouts_portrait/): it takes the STYLE + COLOR + HEADER axes, so all 11 styles + 8 themes + 5 A0 title formats (pv1–pv5, assets/headers_portrait/) ride portrait, all on the light-default header. The portrait title formats: pv1 centered-classic, pv2 title-left, pv3 banner masthead (centered title + rule, venue/logos row below), pv4 logo-forward (marks left, title right), pv5 centered stack.
Axis 5 — color/theme (POSTER_THEME, default random): 8 academic accent bundles (blue · teal · green · burgundy · purple · rust · slate · plum) — the palette the paper's gallery recolor used. Each swaps {--accent, --accent-soft} (and the audio --play-highlight-blue); the result-register --callout (crimson) stays fixed across all themes. With the landscape light-default header (--tb-bg: var(--accent-soft), dark title) each theme paints a pale-tint header + colored <h2> text/underlines on white cards — the light look, not a dark filled band. --theme random hash-samples one theme from the output path — truly random and reproducible, replacing the old "model hand-edits :root" step. Defined once in references/apply_theme.py (THEMES); shared by landscape and portrait (both via compose_poster.py).
Axis 3 — header (POSTER_HEADER, default randomize):
v1— venue (left) · title (center) · institution logos (right). Symmetric 3-zone band.v2— mirror of v1: institution logos (left) · title (center) · venue (right).v3— title centered full-width with a single equal-height logo strip below (conference mark + institutions).v4— title (left) · venue + institution logos stacked (right). A common GT layout.v5— classic: venue text badge (left) · title (center) · institution logos + Paper/Code QR tiles (right). The one header that carries the QR in the titlebar. Opt-in only (POSTER_HEADER=v5) — when chosen, suppress the standalonescan-to-readsection (set both{{QR_*}}empty) so the QR is not duplicated.
v1–v4 each render the conference logo when assets/logos/_venue.png exists (Step 6 fetch_conf_logo.py), else a text venue/year fallback in the same chip; v5 uses a text venue badge by design. All work for 2–6 institutions (empty LOGO_n slots auto-hide). Logos are sized to fill their zone (single venue logo + a 2-row institution grid), and the logo chips theme to the chosen style via --tb-chip-bg / --tb-chip-shadow: solid → flat white chip (no shadow) on the accent band; framed → flat white chip on the white card; simple → transparent chip (no frame), logos sit directly on the white header. Default: pass --header random — compose_poster.py picks one DETERMINISTICALLY (output-path hash) from all five (v1–v5); v5 fills its own titlebar QR via {{HDR_QR_*}} (see the QR contract). Override via POSTER_HEADER={v1|v2|v3|v4|v5}.
Default font policy: the poster body font defaults to Arial — a cross-platform-safe family pre-installed on Mac + Windows PowerPoint, so the exported .pptx needs no font embedding and round-trips cleanly. To override, edit the chosen template's --font-latin CSS variable (in the :root block) to any of the 8 PPT-safe families: Calibri | Aptos | Cambria | Arial | "Times New Roman" | Verdana | Georgia | "Trebuchet MS". The optional POSTER_FONT env var, when set, carries the same choice — but the default lives in the templates, not in any external script. To use Inter (the bundled webfont — more editorial, but not pre-installed), flip --font-latin back to Inter, … and run the html2pptx Inter embed step so the .pptx ships the font; the 4 Inter @font-face blocks stay defined (inert) in every template for exactly this one-line override.
Composition catalog (landscape):
| Axis | Choices | Source files | Pick via |
|---|---|---|---|
| layout | full · half · 3col |
assets/layouts/<layout>.html |
Method-figure AR / figure count (Axis 1) → --layout |
| style | solid · framed · simple · left-bar · elevated · neo-brutal · tag · underline · tinted · double-rule · legend-frame (11) |
assets/styles/<style>.css |
POSTER_STYLE (default randomize all 11) → --style |
| header | landscape v1·v2·v3·v4·v5(opt-in) / portrait pv1·pv2·pv3·pv4·pv5 |
assets/headers/<v>.html · assets/headers_portrait/<pv>.html |
POSTER_HEADER (default random) → --header |
| scan | single · dual (group keywords — recommended) · aside(default) · hero · contact · directory · banner · twin · chips |
assets/scan/<variant>.html |
code QR resolves → --scan dual, else --scan single (Axis 4) → --scan |
| color/theme | blue · teal · green · burgundy · purple · rust · slate · plum (8 accents) |
references/apply_theme.py (THEMES) |
POSTER_THEME (default random, deterministic per output-path) → --theme. Applies to landscape (via compose) and portrait (explicit apply_theme.py call). |
compose_poster.py validates each choice and, on a bad name, aborts listing the available options. The old monolithic poster_*_{solid,framed}.html / poster_portrait_*.html templates have been retired — compose_poster.py (layout × style × header × scan × color, --orientation portrait for A0) is the only path; the source now lives entirely in assets/{layouts,layouts_portrait,styles,headers,headers_portrait,scan}/.
Generate poster.html WITHOUT ever emitting its full contents through your output channel — hard requirement. The template is ~100 KB (≈ 30–40k tokens); writing it inline with the Write tool overflows the per-turn output-token cap (CLAUDE_CODE_MAX_OUTPUT_TOKENS, default 32000) and kills the run — and the measured-fill loop would re-pay that cost every round. Generate it indirectly so the bulk template never passes through your output:
- Compose the template (disk-to-disk, zero output tokens — do NOT
Writeit):# landscape — assemble layout × style × header into ONE self-contained poster.html python references/compose_poster.py \ --layout <full|half|3col> --style <solid|framed|simple|left-bar|elevated|neo-brutal|tag|underline|tinted|double-rule|legend-frame> --header <v1|v2|v3|v4|v5> \ --scan <single|dual> --theme <random|blue|teal|green|burgundy|purple|rust|slate|plum> \ --out <outdir>/poster.html # portrait — composed too (STYLE + COLOR + HEADER axes; 5 A0 title formats pv1-pv5, # no scan section): reads assets/layouts_portrait/ + assets/headers_portrait/ python references/compose_poster.py --orientation portrait \ --layout <full|half> --style <solid|framed|simple|left-bar|elevated|neo-brutal|tag|underline|tinted|double-rule|legend-frame> \ --header <random|pv1|pv2|pv3|pv4|pv5> \ --theme <random|blue|teal|green|burgundy|purple|rust|slate|plum> \ --out <outdir>/poster.htmlcompose_poster.pyresolves the STRUCTURAL hooks ({{STYLE_CSS}},{{HEADER}}, and landscape-only{{SCAN_SECTION}}) AND the COLOR axis (rewrites the:rootaccent vars to the resolved theme); every CONTENT{{...}}placeholder survives for the next step.--scan: pass the GROUP keyworddualonly whenmake_qr.pyemitted TWO QR slots (two genuinely distinct URLs survived de-duplication), elsesingle— compose deterministically picks a fitting variant within the group (so a 2-QR layout never lands on a 1-link paper, whose paper/project/code URLs collapse to one QR);--scan randomor an explicit variant name also work. QR-count guard (belt-and-suspenders): if you pass a single-QR context (single, or an explicithero/contact/banner) butmetadata.jsonactually carries two QR files on disk,compose_poster.pyauto-upgrades to thedualgroup so the second (project/code) QR is never silently dropped — still passdualexplicitly when a code QR resolved.--theme: defaultrandompicks one of 8 academic themes DETERMINISTICALLY from the output-path hash (reproducible spread across a wave) — do NOT hand-edit:rootcolors; override a specific one with--theme <name>orPOSTER_THEME.--header: defaultrandom(landscape v1-v5 / portrait pv1-pv5).--math: the math-typesetting engine, defaultkatex(thinner glyphs, posterskill-like) — the ONE place to switch isMATH_ENGINE_DEFAULTincompose_poster.py, or per-run--math mathjax/POSTER_MATH=mathjax. Both engines are bundled offline (assets/katex/,assets/mathjax/) and intercepted by the renderer + html2pptx (whose math pass is engine-agnostic), so flipping it needs no template/pptx change. Injected at the{{MATH_HEAD}}hook in every layout (landscape + portrait). - Substitute placeholders with the
Edittool, one{{...}}token (or one section block) at a time — eachEditemits only the small placeholder plus your paper-specific content, never the surrounding template. Never reconstruct andWritethe whole file.
Equivalent Opus-style alternative when you have many substitutions: copy the ready skeleton at references/build_poster.py (it carries the real placeholder names, a depth-aware optional-section drop for the lean render, and a leftover-{{...}} check), fill its SUBS dict with this paper's content, and run it on the composed poster.html — the template is read from disk at runtime and never enters your output. Either path is fine; the invariant is identical: the full HTML must never appear in a tool call's output (this is the single most common cause of a smaller model aborting on a large poster).
Read references/template_substitution.md now — it carries the full placeholder map, the code-driven theme color (1-of-5, applied by compose / apply_theme.py — do NOT hand-edit :root), per-section accent palette, vertical-sizing convention (grow on the bottom-most section only), and the lean initial render policy: only Necessary of the six core sections; all three optional sections (Contribution, Dataset / Benchmark, Ablation Study) and every Additional paragraph are deliberately withheld at this stage.
Decoupled-header + QR placeholder contract (NEW — edge cases). The composed headers (v1–v4) replace the old titlebar, so the placeholder set changed:
- Venue: the header uses
{{VENUE_NAME}}+{{VENUE_YEAR}}(text) and an optional{{VENUE_LOGO}}. SetVENUE_LOGOtoassets/logos/_venue.pngonly if that file exists, else""— when empty, the header paints the VENUE/YEAR text in the conference chip (and preflight won't flag a dead image). The old{{VENUE}}/{{VENUE_LINK}}/{{VENUE_TAG}}header fields are gone; don't emit them. - Institution logos:
{{LOGO_1}}…{{LOGO_6}}(up to six). Fill each present institution's path; set every unused slot to""(empty/unfilled chips auto-hide). Works for 2–6 institutions. - QR placement depends ONLY on the header — the QR appears in exactly ONE place, and NEVER in the Title Section except for
v5:- Headers
v1/v2/v3/v4→ the Title Section carries NO QR (these headers have no QR slot at all). ALWAYS fill the standalone Scan to Read.section(data-section="scan-to-read", right after Takeaway) via{{QR_PAPER}}/{{QR_CODE}}, and set the header's{{HDR_QR_PAPER}}/{{HDR_QR_CODE}}to"". Institution count is irrelevant — the old "≤ 2 institutions → header QR" rule is RETIRED; the QR never joins the logo row. The section's internal layout is the--scanaxis (Step 3): pass--scan dualwhen a code QR resolves, else--scan single, so a two-QR layout never lands on a one-QR paper. Beyond{{QR_PAPER}}/{{QR_CODE}}, the picked variant may also expose the display-URL placeholders{{URL_PAPER}}/{{URL_CODE}}/{{URL_PROJECT}}(short URL text frommetadata.json, e.g.arxiv.org/abs/2106.09711) and{{CONTACT}}— fill whatever exists and leave the rest""; every one auto-hides when empty. Exception —--layout 3col: the standalone Scan-to-Read section is suppressed in the 3col layout (its 1/3-width column is too wide for the section's small content and reads as empty), so a 3col poster intentionally carries NO QR. When you compose with--layout 3col, leave{{QR_PAPER}}/{{QR_CODE}}(and the other scan content placeholders) empty — they would render into a hidden section anyway. - Header
v5(classic) is the ONLY header with a titlebar QR — withv5, ALWAYS fill{{HDR_QR_PAPER}}/{{HDR_QR_CODE}}and leave the section{{QR_*}}empty, so the QR shows once (in the v5 header) and thescan-to-readsection auto-hides. - Render-time guarantee (CSS — belt-and-suspenders, you do NOT rely on the build filling the right one): every layout hides
.section[data-section="scan-to-read"]whenever the titlebar carries a FILLED QR (body:has(.titlebar img.qr-img[filled]) , body:has(.titlebar .qr-tile .chip.qr img[filled]) -> [data-section="scan-to-read"]{display:none}). So even if BOTH the header QR and the section QR get filled, the standalone Scan-to-Read section is suppressed at render time and the QR can never appear twice. - Every QR placeholder auto-hides when empty or when its
assets/qr/*.pngis absent — set a path only if the file exists.
- Headers
- Logo autotrim:
fetch_logos.py/fetch_conf_logo.pynow rasterize (SVG→PNG) and crop the transparent/near-white border so chips hug the mark — automatic, best-effort, no action needed here.
After substitution, apply the visual polish layer — read references/visual_polish.md for typography, color, the inline-emphasis vocabulary (<strong> / .hi / .num), stat grid, figure cap, callout and arch components, and print hygiene (the canvas locks per orientation: landscape 60×36in / cqw / aspect-ratio: 5 / 3; portrait 33.1×46.8in / cqw / aspect-ratio: 33.1 / 46.8 — never edit).
Read references/content_patterns.md now and BREAK UP THE WALL-OF-TEXT. This is not optional reference material — it is a hard requirement for visual quality. The 16-widget catalog (callouts, key-stat, vs-compare, numbered-steps, timeline ×4, chips, definition, highlight-table, pullquote, bento, equation, banner) exists specifically because plain <p> + <ul> across every section makes the poster read as undifferentiated text. Rules:
- Every section body MUST contain at least ONE pattern widget. Plain
<p>+<ul>alone is a failure mode (verified empirically — without this rule, posters ship with only 1 widget across 9 sections). - Across the full poster, use at least 5 DISTINCT pattern types. A poster that uses
.p-callout-soft9 times still reads as monotonous. Vary the widget across sections so adjacent sections look visually different. - Cap at 2 widgets per section (so a section doesn't stack 3 callouts + a key-stat + a chips strip = different kind of clutter).
- Match widget to content shape: pick from the catalog's "Shape of content it suits" column —
key-statfor sections dominated by one number,vs-comparefor Theirs/Ours sections,numbered-stepsfor pipelines,chipsfor taxonomy/dataset/baseline lists, etc.
The figure/logo/QR assets live under <outdir>/assets/{figures,logos,qr}/ (placed there by paper2assets), and poster.html references them with src="assets/figures/…", src="assets/logos/…", src="assets/qr/…". The path/file values in figures.json, fetch_logos.py, and make_qr.py manifests already carry the assets/ prefix, so dropping them verbatim into src makes the relative paths resolve from the poster's own location without further action.
Step 4 — Iterative fill to exactly fit the page
First, the column-pack pre-check (one calculation, before any fill round). Run python3 scripts/check_poster.py pack <outdir>/poster.html. It flags any column whose figure floors + minimum text already exceed the column height — a negative-slack column is INFEASIBLE, and the fill loop would oscillate there for ~20 rounds (the single biggest time-sink measured: one opus run burned ~15 min on one such column). Re-pack a flagged column before filling — move a text section or the figure to a looser/wider column, or (if TOTAL slack is negative) drop/shrink a figure or cut text — and enter the loop only when every column's slack ≥ 0. Details: references/staged_fill.md → "Column-pack pre-check".
The lean initial render usually leaves some sections under-filled. Grow content with an iterative loop until every section reads FULL (fullRatio 90–100% of the card height, padding included). Each pass: first run check_poster.py autofit <outdir>/poster.html — it deterministically closes the continuous-lever gaps a machine can size exactly (every .grow-card row-gap gap AND the scan-to-read QR height, using the needPx the report already computes, bounded by the column budget) and prints the residual sections that still need YOUR content/figure edits — then measure with check_poster.py slack --with-polish, read the per-section verdicts, pick the one or two modification methods best matched to the current defects, apply them, then re-measure to review and keep-or-rollback. There is no fixed order of methods — the measurement tells you what's wrong and you choose the remedy. Each off-band verdict suggests its remedy:
EMPTY(<70%) → add Additional text or add the optional section for that column.SPARSE(70–90%) → polish to enhance the existing prose (pad with material from the spec'sAdditional) so the card fills.SPILLAGE(100–110%) → polish to reduce content (tighten prose so it fits in fewer lines).OVERFLOW(>110%) → remove Additional text or remove the optional section to claw back vertical space.
When two methods are independent (different columns) you may apply both in one pass; when they touch the same column, apply one at a time so a rollback decision stays unambiguous. When several methods could fill the same gap, prefer the highest-value content (real numbers, the Method figure, named contributions) over filler prose.
Machine-checked exit gate. The loop is done only when this command exits 0:
python ~/.claude/skills/paper2poster/scripts/check_poster.py slack \
<outdir>/poster.html --with-polish --strict
--with-polish runs the fill gate (slack) and the visual-polish gate (FIG/NARROW etc.) on one rendered page — a single browser launch instead of two — and under --strict both must pass, so this one command replaces the old separate slack + polish calls. Do not stop iterating while it exits non-zero. --strict is the same measurement you read each pass, but with a hard exit code — there is no "acceptable SPARSE" or "figure too tight to fix" escape. Keep applying the modification methods (and, for a stubborn figure, the column-width nudge / vertical-room methods in references/staged_fill.md) until the gate passes.
Converge fast, and bound the loop (critical for smaller models). The slack report gives each off-band section a precise needPx delta — e.g. key-result SPARSE grow +50px [+18..+83]. Edit by that number with a continuous CSS lever (margin-bottom / .col gap / figure max-height), don't guess with whole text lines and overshoot the 0.05-wide FULL band. Track recent measurements and switch levers the instant a section ping-pongs SPARSE↔SPILLAGE. And the loop is bounded: if both gates aren't green after ~12 rounds / ~20 min, render the best-measured state, mark the stage DEGRADED with the residual off-band section ids, and move on — never grind indefinitely. This is script-enforced: slack counts every call in <poster_dir>/.fill_budget.json and exits 3 with a CIRCUIT BREAKER banner once it passes --max-iterations (default 80) — an on-disk cap that survives context compaction, so a lost round-count can't make you grind. Treat exit 3 as a hard stop (render best state, mark DEGRADED). The exit gate stays strict; only the iteration count is capped. Full rules: the "Convergence protocol" at the top of references/staged_fill.md.
When the .grow section is persistently EMPTY or SPARSE even after exhausting its own Additional/optional content, don't keep stretching that one section — instead refine the content of the other (non-grow) sections in the same column: lift their Additional paragraphs into the rendered card, promote bullets from concise to expanded form, or fold in a paper-specific custom section. The .grow section then absorbs the residual slack naturally instead of inflating a single card with filler.
When a column is at budget (slackRatio ≈ 0) but lopsided — one card SPARSE while its siblings read FULL, so neither adding nor removing content works — use the rebalance-adjacent-sections method: relocate a reserved, on-topic line from a FULL sibling into the SPARSE card (a net-zero swap that shifts the column's height budget without changing its total). This only moves content that genuinely belongs to the destination section.
Read references/staged_fill.md now — it carries the check_poster.py slack command, the JSON report shape, the measure→select→apply→review loop, the flat catalog of modification methods, and the shave-back order.
Run preflight first (check_poster.py preflight) to catch LaTeX residue / raw < in math / missing images before measuring.
Debug aid. When the fill loop misbehaves — slackRatio says one thing, your eyes see another — write check_poster.py slack --json-out <outdir>/assets/meta/poster_debug.json output, open poster.html in a browser, and press d. Every column/section/figure gets outlined; badges show actual rendered height alongside the estimator's prediction and the delta.
Step 5 — Synthesize narration audio for the Listen buttons
paper2assets produced the narration script (<outdir>/assets/meta/narration.json); paper2poster turns it into the mp3s the Listen buttons play — paper2poster is where TTS happens (paper2assets does NOT):
python ~/.claude/skills/paper2poster/scripts/generate_audio.py \
<outdir>/assets/meta/narration.json --outdir <outdir>/assets/audio
- Backend = free Edge TTS by default — Microsoft Edge online voices via the
edge-ttspackage: no API key, no config file, just network. Default voiceen-US-AndrewNeural;narration.jsoncarries"provider": "edge". Override per run with--provider azure(needs~/.azure/speech.json+AZURE_API_KEY) or--voice <name>. Seereferences/audio_narration.md. - Graceful skip: if
edge-ttsisn't installed or the network is down, the script exits with a clear message and writes nothing — the poster's HTML/PDF still render, only the Listen buttons stay silent. Surface that message; don't fabricate audio. - Keep
PLAYLISTin sync: the template'sPLAYLIST(and the per-sectiondata-sectionids) must match the clip ids in<outdir>/assets/audio/. The Listen buttons + Full Listen playassets/audio/<id>.mp3by id; an id with no file flashes the highlight and falls silent. So if you drop a section at render time (e.g.dataset-benchmark) or inject a custom one, keepPLAYLISTin sync with the audio files present. If<outdir>/assets/audio/is absent entirely, the buttons gracefully no-op and the poster still works visually.
Step 5.9 — Pack the header logos to fill their zone (fit_logos.py)
After the fill loop converges, BEFORE rendering, pack the header's institution logos so they FILL their zone regardless of count or shape:
python references/fit_logos.py --poster <outdir>/poster.html
fit_logos.py opens the poster headless at true canvas scale, measures each logo zone (.logo-grid / .logo-block), greedily searches row partitions to MAXIMISE the single uniform height shared by EVERY institution logo (they enlarge together — never some big, some small); logos are reorderable. It also (a) wires the venue logo into the conference chip (.chip.conf) when assets/logos/_venue.png exists, so the :has(img) CSS fires and the venue year-text auto-hides — it never duplicates the mark; and (b) for v1–v4 headers, pulls any QR out of the titlebar and re-homes it in the standalone Scan-to-Read section (re-creating that section after Takeaway if an older render dropped it) — only v5 keeps a titlebar QR. It rewrites the zone into rows — baked into poster.html disk-to-disk (it never emits the full HTML through your output channel). Every logo renders at the SAME height, as large as the zone allows (one logo fills it; many balance into rows of equal-height marks). The widest mark and the short header band cap that uniform height, so there is no fixed fill target — the packer maximises the shared height. render_poster.py (Step 6) now auto-runs fit_logos.py for you right before it renders (it was routinely skipped when manual), so the exported PDF/PNG always has packed logos; you may still run it standalone to preview.
Step 6 — Render the poster to PDF + PNG (FIRST — applies + bakes the expand)
python ~/.claude/skills/paper2poster/scripts/render_poster.py <outdir>/poster.html
Run this before Step 7 (html2pptx) so the expand is baked into poster.html before html2pptx reads it — the editable poster.pptx then matches the PDF/PNG instead of shipping the pre-expand layout. The script reads @page { size: <W> <H> } from the HTML, mirrors the bundled Inter webfonts into <outdir>/assets/fonts/ (so the poster.html + its assets/fonts/ stay self-contained for sharing across platforms), opens Chromium with print emulation, waits for MathJax to settle, applies the render-time expand, bakes that expand back into poster.html, then writes <outdir>/poster.pdf and <outdir>/poster.png (0.35× scale by default).
Render-time "expand" (automatic, on by default). Right before writing the PDF/PNG, render_poster.py runs one render-time fill pass: for every under-filled card it grows the row-gaps between the card's inner rows until the content reaches POSTER_EXPAND_THRESHOLD (default 0.98). This makes a poster that converged at the 0.90 FULL gate read as visually full — no trailing whitespace — without re-grinding the fill loop to a tighter, ~2× slower gate. It is safe by construction, on two guardrails: (1) figures are never resized — they stay flex:0 0 auto, so a card's <img> keeps its exact pixel dimensions and aspect ratio even when the card it lives in is filled; (2) a card is reverted if filling it would change its column/container height (parent-height guard) — so a flex .grow card absorbs the fill inside its column (column bottom unchanged → fills the trailing column-bottom whitespace), while a grid/content card that would push the fixed-canvas layout taller is left alone. A card also stops at its bottom-padding ceiling (never eats padding → column bottoms stay aligned), so smaller cards finish a bit under 0.98 — that ceiling, 1 − padBot/cardHeight, is their real "full". The expand result is then persisted into poster.html as a single <style id="poster-expand-baked"> block (one row-gap rule per expanded section), so the editable HTML, its D debug overlay, the PDF/PNG, and the downstream html2pptx read all show the same expanded layout — not the pre-expand one. This is responsive-safe (the templates use a fixed internal layout scaled by an outer transform: scale(), so an inline px row-gap renders identically at any view size) and idempotent (a re-render replaces the block). It is written only at this final render, after Step 4's fill loop — so check_poster.py slack/polish during the loop still measure the natural top-aligned layout and the 0.90 FULL gate stays correct.
Two tuning knobs (env vars) — the only layout dials you normally touch:
| Env var | Default | Controls |
|---|---|---|
POSTER_FULL_THRESHOLD |
0.90 | The staged-fill loop's FULL gate (Step 4). The layout is optimized until every section's natural top-aligned fill reaches this. Raise (e.g. 0.94) for a tighter pack at ~2× loop time; the per-element slack report keeps either threshold from oscillating. |
POSTER_EXPAND_THRESHOLD |
0.98 | The render-time expand target (this step). Each card fills toward this, capped by its bottom-padding ceiling and the column-height guard. Set 0 to disable the expand and ship the natural top-aligned layout. |
The pairing is deliberate: 0.90 converges the layout fast, then 0.98 makes the final deliverable read full at render time for free. Both are read from the environment, so a one-off run can override either without editing code.
Useful flags (defaults usually fine):
--thumb-scale 0.5for a larger thumbnail,0.2for smaller.--mathjax-timeout-ms 15000if the poster has heavy LaTeX.
This is a SOFT path. A blocked CDN, MathJax fetch failure, or slow web font produces a stderr warning and a PDF that may show raw $...$ instead of typeset math. Surface warnings verbatim, but do not re-run the script in response — fix upstream.
Prerequisites: playwright + Chromium. If the script exits with ImportError, run the install commands it prints and re-run.
After rendering, run the final dimension gate:
python ~/.claude/skills/paper2poster/scripts/check_poster.py verify-final \
<outdir>/poster.pdf --from-html <outdir>/poster.html
A FAIL means the upstream HTML or render_poster.py invocation is wrong (e.g., @page was edited out). Surface the error and stop — re-running without fixing the HTML produces the same failure.
Step 7 — Convert poster.html → poster.pptx (html2pptx — STANDARD final handoff)
Users almost always want the editable PowerPoint in the same run — do NOT treat this as optional or wait for a separate request. Once Step 6 has rendered and baked the expand into poster.html, hand it to the bundled html2pptx sub-skill (vendored inside this skill at html2pptx/; no longer a separate git submodule). From a Claude session, say:
"Use the html2pptx skill to convert
<outdir>/poster.htmlinto<outdir>/poster.pptx. The default poster font is Arial, pre-installed on Mac + Windows PowerPoint, so no font embedding is needed — skip the embed step. ONLY if this poster was rendered with the optional Inter override, also run the html2pptx Inter embed (its ownscripts/font_embedder.py) so the .pptx ships the font."
The skill extracts the DOM via Playwright, builds a native .pptx (editable text + native shapes + images, NOT a PNG-in-slide), renders a soffice sanity PNG, and runs a Claude-vision fidelity audit (on by default). Run it with --outdir <outdir>/assets/_pptx_build/ so every html2pptx artifact (DOM json, sanity PNGs, the soffice render) stays under assets/, then promote the deck to the deliverable top level: cp <outdir>/assets/_pptx_build/poster.pptx <outdir>/poster.pptx. Final deliverable: <outdir>/poster.pptx (bundle root); build artifacts isolated under <outdir>/assets/_pptx_build/. Because it reads the already-baked poster.html from Step 6, the pptx carries the same expanded layout as the PDF/PNG.
Ordering. html2pptx runs after Step 6's render+bake so it reads the expanded poster.html. Its soffice sanity render writes into <outdir>/assets/_pptx_build/, so it never clobbers the bundle-root poster.pdf / poster.png (which is what made this safe to run last).
Non-fatal. If html2pptx genuinely cannot run (e.g. soffice or a Python dep missing), record a clear WARNING and CONTINUE to Step 7.5 — a pptx failure must never block the core poster.{html,pdf,png}. Report the warning explicitly; never drop the pptx silently.
Step 7.5 — Final deliverable check (MANDATORY before declaring done)
python ~/.claude/skills/paper2poster/scripts/check_poster.py deliverables <outdir>
Deterministic gate, not advisory. It exits 0 only when all four core artifacts exist in <outdir> and meet minimum size thresholds:
paper_spec.md(from paper2assets)poster.html(from Step 3–4)poster.pdf(from Step 6)poster.png(from Step 6)
If exit is non-zero, the command prints which files are missing and the exact command to produce them — run those, then re-run this check. Loop until exit 0. Then confirm poster.pptx (Step 7) is also present; if html2pptx warned out, say so in the report instead of silently dropping it.
FAILURE MODE this gate catches — models routinely exit after Step 4's fill loop passes its slack/polish hard gates and skip the render steps entirely, rationalizing "all gates passed = task done." This is wrong: the fill loop only verifies the HTML; it does NOT produce or verify the PPTX/PDF/PNG. The same Claude model, on the same paper, will sometimes run Steps 6–7 and sometimes skip them — there is no warning sign you can rely on internally. Do not declare done without running this check.
Step 8 — Report
Tell the user the absolute paths of all artifacts:
<outdir>/assets/meta/paper_spec.md(from paper2assets)<outdir>/poster.html<outdir>/poster.pptx(editable PowerPoint, from Step 7 — note it explicitly if html2pptx warned out)<outdir>/poster.pdf<outdir>/poster.png<outdir>/assets/audio/(generated here by Step 5'sgenerate_audio.pyfromnarration.json; absent if edge-tts/network unavailable — Listen buttons then no-op)
Do not dump file contents into the chat — the spec and HTML are long, and the PPTX/PDF/PNG are binaries. For the PNG, inline-display if the chat surface supports image attachments. Mention that the user can open poster.html directly in a browser; the poster auto-fits the browser window (no scrolling needed). Press s for fullscreen, a to toggle Listen buttons, d to toggle a debug overlay.
The full chain is paper2assets → paper2poster (which now ends by producing the editable .pptx via the bundled html2pptx); each skill is still invokable on its own from a Claude session against the previous stage's <outdir>/.
Tools
scripts/
├── check_poster.py ← CLI: slack / preflight / polish / verify-final / deliverables
├── render_poster.py ← CLI: print-emulated PDF + scaled PNG thumbnail (mirrors bundled fonts)
├── generate_audio.py ← CLI: narration.json → assets/audio/<id>.mp3 (free Edge TTS default; --provider azure)
└── utils/ ← internal modules (canvas parser, Playwright + settle, etc.)
check_poster.py slack and render_poster.py both read @page { size: W H } from the input HTML — the templates set this — so the canvas size doesn't need to be passed on the command line.
Note: figure-cropping tools (crop_figure.py, extract_pdf.py, fetch_logos.py, make_qr.py) live in the paper2assets skill. If you need a one-off visual box re-crop on a figure paper2assets' deterministic chain couldn't handle, invoke them via ~/.claude/skills/paper2assets/scripts/ (see Step 2.5).
Templates
All templates live in assets/. They share placeholder tokens, audio markup, keybinding scripts, design tokens; only the canvas size + column grid differ across orientation/layout. Step 3 routes between them by orientation trigger (POSTER_ORIENTATION=portrait → portrait; else landscape — DO NOT auto-detect orientation from figure AR) and then Method-figure shape (landscape: AR ≥ 2.5 OR {column=full} → full, else half; portrait: AR ≥ 1.2 OR {column=full} → full, else half).
Landscape (60×36in, 5:3):
poster_half_<style>.html— 4-column grid for half-width Method figures. Default landscape layout; Method is a half-width card alongside the other sections.poster_full_<style>.html— 4-column outer grid with the middle two columns merged into.mid-widefor wide / full-width Method figures (AR ≥ 2.5 OR{column=full}). The.mid-wideblock spans grid columns 2–3 and stacks Method (full mid-width, with the wide-figure floor enforcing ≥ 75% of available width) above a 2-col.mid-subcarrying Dataset + Key Result.
Portrait (33.1×46.8in A0, 0.708):
layouts_portrait/half.html— 2-column grid for tall / moderate-AR Method figures. Default portrait layout; LEFT col carries Problem / Motivation / Method (with figure); RIGHT col carries Dataset / Key Results / Ablation / Headline Numbers / Takeaway.layouts_portrait/full.html— 5-band magazine sandwich layout for wide Method figures (AR ≥ 1.2 OR{column=full}). Reading order: titlebar → Band 1 (Problem | Motivation, 2-col equal) → Band 2 (.method-hero centerpiece, full-width: vertical-rotated SIDE-TITLE on left + bullets + wide-figure on right — magazine editorial style) → Band 3 (Key Results 1.5fr | Ablation 1fr | Headline Numbers 1fr, asymmetric 3-col data band) → Band 4 (Takeaway full-width punchline). The Method centerpiece sits in the visual middle, not at the top — this matches poster narrative convention (Problem/Motivation set up the WHY, then Method delivers the HOW, then Results/Numbers pay off, then Takeaway lands the WIN). The bottom 3-col band is intentionally unbalanced (1.5/1/1) so the data display reads as hierarchy, not parallelism. Takeaway gets the full canvas width — the eye lands on it as the closing word. The bullets cell inside the method-hero is wrapped in a pseudo-section (<div class="section method-text" data-section="method-text">) that's invisible to viewers but measured byslack.py— when the figure stretches the row taller than the bullets need, the staged-fill loop seesmethod-text SPARSEand the LLM expands bullets until the cell fills, unbinding the "narrow bullets cell next to tall figure → whitespace" trap. Optional sections (Contribution, Dataset/Benchmark) are NOT in the default layout — see the inline-commented recipe at the bottom of the template body to paste them in only when the paper genuinely needs them.
If you add a template: keep it venue-neutral, preserve the {{...}} placeholder vocabulary, preserve data-section attributes and PLAYLIST markup, and keep the canvas / cqw / aspect-ratio lock intact for that orientation.
Bundled in assets/fonts/: Inter Regular / SemiBold / Bold / ExtraBold in both .woff2 (web) and .ttf (pptx-embedding) formats — the HTML templates' @font-face rules use the relative fonts/Inter-*.woff2 path, and render_poster.py mirrors them into each <outdir>/fonts/ so the deliverable is self-contained.
Content guidelines
- Section semantics:
- Problem — the concrete gap or failure mode the paper addresses.
- Motivation — why this matters now; what's broken about prior approaches.
- Contribution — the paper's explicit contributions (usually the bulleted "we contribute" list from the intro): the new artifact, dataset, algorithm, theorem, or insight.
- Method — how the proposed approach works (the technical realization of the contribution).
- Key equation / Formulation — the paper's core formula(s): the objective, loss, governing equation, or the one expression that defines the method. Include at least one key equation on every poster that has meaningful math (most ML / theory papers do), rendered with the
equationwidget (MathJax typesets$…$/$$…$$). The model chooses placement — fold it into Method, or give it its own compact Formulation card when the math is central. Pull the LaTeX frompaper_spec.md(Method'sKey equationsubfield, produced by paper2assets) /text.txt; never fabricate. Skip only for genuinely formula-free papers (pure systems / empirical). - Dataset / Benchmark — the datasets, splits, scale, and any new benchmark the paper introduces. If the paper just consumes standard public benchmarks without elaboration, this section is optional. If the paper introduces a dataset or benchmark, treat it as a first-class contribution.
- Key Result — the headline experimental finding and qualitative takeaway.
- Ablation Study — which components/design choices matter, quantified. Top 1–3 rows (hard cap 3). If the paper has no ablation, state so and omit numbers.
- Headline Numbers — the 1–4 standout quantitative results. The numbers themselves are the visual; no figure. MUST render as the template's hero+supporting layout — a
<div class="headline-hero">containing.hero-val+.hero-label+.hero-noteAND a<div class="supporting">row holding at minimum 2.stat-minitiles (each with.val+.lbl). Bullets here OR a solo hero without supporting tiles = poster failure mode (caught by polish gates HEADLINE/HERO and HEADLINE/SUPPORTING). - Takeaway — the one-sentence "so what".
- Tables vs figures: tables (from
captions.json) are not eligible as Method figures (only PNGs infigures/). Their numbers flow intoHeadline NumbersandKey Resultnecessary text. - Every kept figure carries a one-line caption. A
<figure>(Method, Motivation, or any secondary figure injected during fill) must always have a non-empty<figcaption>drawn fromcaptions.json— a bare, unlabeled figure is a defect (check_poster.py preflightwarns on empty captions). If a figure has no caption source, either write a short factual one-liner from the paper text or drop the figure; never ship it caption-less. - Prefer 3+ column tables. A two-column
Method | Metrictable stretched to the section width reads sparse (wide empty gutter). When the paper reports more than one metric, give the table a column per metric so it holds more and fills its width. Seecontent_patterns.mdP12. - No fabrication. Every number, claim, and figure caption must trace to
text.txt(produced by paper2assets) orfigures.json. When uncertain, prefer omission to invention.
Edge cases
| Situation | Action |
|---|---|
<outdir>/ missing or missing required paper2assets outputs |
Automatically invoke the paper2assets skill on the source PDF to produce/populate the <outdir>/, then continue. |
figures.json is empty |
Proceed; Method's figure is none. |
| Selected figure file missing on disk | Treat as none. |
Method figure none |
Use <orientation>_half_<style>.html, remove the Method <figure> block. |
POSTER_ORIENTATION=portrait set (the ONLY trigger — do not auto-flip on figure AR) |
Use layouts_portrait/{full,half}.html — A0 portrait canvas (33.1×46.8 in), 2-col body. |
| Method figure AR ≥ 2.5 in landscape (horizontally wide) | Use poster_full_<style>.html — wide pipeline / architecture figures get the merged-middle layout regardless of source-column attribute. |
| Method figure AR ≥ 1.2 in portrait | Use layouts_portrait/full.html — 5-band magazine sandwich. Reading order: Problem|Motivation (top 2-col) → .method-hero centerpiece (full-width, vertical-rotated SIDE-TITLE on left + bullets + wide figure on right) → Key Results|Ablation|Headline (asymmetric 3-col 1.5/1/1) → Takeaway full-width punchline. Bullets cell is a pseudo-section that auto-fills the row height. |
Method figure {column=full} in landscape |
Use poster_full_<style>.html (merged-middle layout). |
Method figure {column=full} in portrait |
Use layouts_portrait/full.html (5-band magazine sandwich, Method centerpiece in middle with vertical-rotated SIDE-TITLE). |
Method figure {column=half} and AR < 2.5 in landscape |
Use poster_half_<style>.html. |
| Method figure AR < 1.2 in portrait (tall) | Use layouts_portrait/half.html — figure in a single col with text above, no hero band. |
<outdir>/assets/logos/ empty |
Remove any <img class="logo"> element whose institute didn't resolve — don't leave a literal {{LOGO_N}} token in src. |
<outdir>/assets/qr/code.png missing |
Remove the code QR <img> from the title bar (paper QR alone is fine). |
| Fewer than 4 headline numbers | Remove unused .stat divs (keep the ≥2 supporting tile floor). |
| No clean baseline-vs-ours comparison | Replace <table class="results"> with a <p> carrying Key Result Necessary. |
poster.html already exists |
Overwrite without prompting. |
| Paper has no ablation study | One-line Necessary noting no ablations; empty Additional; remove the Ablation Study .section block and drop "ablation-study" from PLAYLIST. |
| Paper just uses standard public benchmarks (no new dataset) | Dataset / Benchmark is optional. Default = omit the .section block at lean-render time; the staged-fill loop may add a one-line "Standard benchmarks: …" card if a column has slack. Drop "dataset-benchmark" from PLAYLIST if the block is omitted. |
| Paper introduces a new dataset or benchmark | Render the Dataset / Benchmark section in the initial pass (treat as first-class, like Method); keep "dataset-benchmark" in PLAYLIST. |
Key rules
- Never invent numbers. Pull from
text.txt(paper2assets' output). Every stat, delta, and table cell must trace back to the spec. - Never emit the full
poster.htmlthrough your output channel. It's ~100 KB; a full-fileWrite(or any inline emission of the whole template) overflows the per-turn output-token cap (CLAUDE_CODE_MAX_OUTPUT_TOKENS, default 32000) and aborts the run — the single most common way a smaller model fails on a large poster. Generate it indirectly (shellcpthe template + surgicalEdits for placeholders, or abuild_poster.pygenerator — see Step 3), and keep every fill-loop change a partialEdit, never a full rewrite. - Never re-
Readthe wholeposter.htmlduring the fill loop (INPUT-context twin of the rule above). At ~100 KB, re-reading it each round floods a smaller model's context window → auto-compaction → lost fill state → the loop thrashes and never converges. Work from theslackreport — it now prints anEDIT TARGETSblock with the verbatim source of every off-band section, so lift yourEditold_stringstraight from there and never re-read the whole file. Seestaged_fill.mdrule 6. - Lean render first, measured fill second. Don't guess at fit during placeholder substitution. Render the core sections'
Necessaryonly — Problem, Motivation, Method (with the key equation), Key Result, Headline Numbers, Takeaway — then letcheck_poster.py slackdecide what to add. Contribution is dropped by default (keep only when the paper's headline novelty IS its contribution list). Ablation Study, Dataset / Benchmark, and a filler Takeaway are deprioritized — render only when they carry real first-class content (Dataset only when the paper introduces one; Ablation only with real ablation rows). The fill loop reaches for the key equation, real numbers, the Method figure, and secondary/qualitative figures before Ablation, Dataset, or padded prose — and before resurrecting Contribution. - Target ≥2 figures per poster. Method alone leaves the right-side empirical story as a prose-and-numbers wall. Step 2 picks a secondary figure whenever the paper has one — the eye needs at least one empirical visual beyond the Method diagram. 1-figure posters are acceptable only when no remaining figure carries real signal (rare).
- Include the key equation. Every poster for a paper with meaningful math MUST show at least one key equation/formula (objective, loss, or governing expression) via the
equationwidget — integrated into Method or as a compact Formulation card. Rendering zero equations for an equation-driven paper is a quality failure (the single most common gap vs author GT). Pull the LaTeX from the spec /text.txt; never invent symbols. - No contentless section. A section that would render with no real content is omitted, not shown empty — never ship a heading over a placeholder or a lone "N/A". This is the flip side of the FULL fill gate: deprioritized sections (Ablation, a filler Takeaway, Dataset when not introduced) are dropped rather than padded. Every kept section must carry genuine, paper-specific content.
- Strict per-section fit gate. Stop the staged-fill loop only when every section is
FULL(fullRatio 0.90–1.00) and every card figure fills 90–100% of its box on at least one axis (polishreports zeroFIG/NARROW). There is no per-column SPARSE allowance — aSPARSEcard is not done, it is a card you have not finished filling. - Fill-loop pass ≠ task done. Passing the slack/polish gates means the HTML is well-laid-out — it does NOT mean the deliverables are produced. Step 6 (PDF/PNG render) and Step 7 (html2pptx) are separate steps and are routinely skipped by models who mistake "fill loop converged" for "task done." The
check_poster.py deliverablesgate in Step 7.5 is non-negotiable: run it before reporting, loop until exit 0. .growcards are gated, not exempted. A.growcard stretched by flexbox to absorb leftover column space can read as FULL in old tooling yet still show a visible band of trailing whitespace. Apply the same fullRatio gate to it — fill it (Additional / extra bullet) or shift content from its non-grow siblings instead of leaving the whitespace.- Multi-tile content uniformity (RECURRING DEFECT — read carefully). Whenever a section emits a horizontal row of small stat / number tiles, every tile in the row MUST share the same visual shape — same line count for the big number, same line count for the label. Mismatched heights look broken: one tile's value sits high while its neighbor sits low, or the labels show a 2-line / 3-line zig-zag baseline. Applies broadly — not just
.headline-hero .supporting.stat-minitiles, but also.p-stat-stripcells, any ad-hoc number-card row inMotivation/Method/Key Results, and any future tile widget.- The template now structurally backstops this:
.headline-hero .supportingusesalign-items: flex-start(all.valnumbers top-align so they always sit at the same height regardless of label height) and.stat-mini .lblcarriesmin-height: 2.4em(every label reserves 2 lines, so a 1-line and a 2-line label occupy identical vertical space). This means a label that wraps can no longer shove its value up out of line. But the CSS only reserves the slot — you must still keep every label ≤ 2 lines or a 3-line label overflows the reserved 2-line slot and the row breaks again. Do NOT rely on the CSS as an excuse to write uneven labels. - Big numbers: either ALL tiles fit on one line OR ALL tiles wrap to two. Never mix 1-line and 2-line within the same row. Fix by shortening the longest tile's value (e.g.
−84% FLOPs Δ→−84%), or by rephrasing short tiles to wrap too — usually the first option. - Labels: keep all tiles in a row to the SAME line count, ≤ 2 lines each (the reserved slot is exactly 2 lines). Target either all-1-line (short tokens:
pts,params,mAP) or all-2-line, never a mix that reads as a zig-zag. If one label naturally goes to 3 lines while siblings stay at 2, shorten the long one (drop adjectives, prefer abbreviations, use unit shortcuts likeM/B,accfor accuracy) — do NOT pad the shorter ones. - Check before declaring done. Visually scan every multi-tile row in the rendered poster. If values are at different y-positions or labels show a stair-step baseline, the row fails uniformity — fix the content before signing off.
- The template now structurally backstops this:
- Do not edit the canvas lock. The 60×36in /
cqw/aspect-ratio: 5 / 3rules in both templates are what makes the browser preview, print PDF, and PNG thumbnail look like the same layout at different magnifications. - paper2assets owns the figure-cleanup pipeline (top-check → decaption → autotrim). Don't re-run the deterministic chain here — it's already done. Step 2.5's visual
boxcut is for residual asymmetric noise only.
ResearchStudio-Reel/skills/paper2video/SKILL.md
npx skills add microsoft/ResearchStudio --skill paper2video -g -y
SKILL.md
Frontmatter
{
"name": "paper2video",
"description": "Turn a research paper, a paper2assets package, or an existing PPT deck into a narrated MP4 video. Prefer the shared paper2assets package when present so paper2poster, paper2blog, paper2slides, and paper2video use the same section order and narration. Preserve the advanced deck route by delegating slide authoring to the external `hugohe3\/ppt-master` project, then synthesize audio with `skills\/paper2poster\/scripts\/generate_audio.py`, render with `skills\/paper2video\/scripts\/render_video.py`, and burn final subtitles with `skills\/paper2video\/scripts\/add_subtitles.py`."
}
paper2video - paper/assets/deck -> narrated MP4
paper2video is an orchestrator. It does not try to replace poster extraction,
deck authoring, TTS, or ffmpeg compositing. It wires the current best pieces
together:
paper.pdf
-> skills/paper2assets/scripts/build_package.py
-> assets/meta/sections.json + assets/meta/narration.json
-> deck source (ppt-master / paper2slides / existing PPTX)
-> assets/audio/*.mp3 from skills/paper2poster/scripts/generate_audio.py
-> raw MP4 from skills/paper2video/scripts/render_video.py
-> timeline.json from skills/paper2video/scripts/build_timeline.py
-> video.mp4 with burned-in subtitles from add_subtitles.py
The important branch-level contract is this: when a paper2assets package is
available, narration.json is the canonical narration order. Use it for TTS,
video frame/audio pairing, and subtitle timing. This keeps paper2poster,
paper2blog, paper2slides, and paper2video aligned.
Paths
Run commands from the AutoResearch repo root unless noted.
PAPER2POSTER=skills/paper2poster
PAPER2VIDEO=skills/paper2video
PAPER2ASSETS=skills/paper2assets
ppt-master is an external dependency, not a path that every agent has:
git clone https://github.com/hugohe3/ppt-master /path/to/ppt-master
PPT_MASTER_DIR=/path/to/ppt-master
Do not hard-code ~/.claude/skills/.... Users may run this repo from Codex,
Claude Code, a shell, or another agent.
Output Contract
Follow the shared paper2assets v2 layout. The paper2video bundle top level holds
only deliverable files plus manifest.json; all audio, captions, slide decks,
clips, rendered frames, reports, and timeline/cue metadata live under assets/:
<video_outdir>/
video.mp4 # required, burned-in subtitles with translucent caption box
video_no_subtitles.mp4 # required, raw/pre-subtitle playback copy for paper2reel
video.pptx # required, for follow-up editing
manifest.json
assets/
audio/ # script.json, *.mp3, word timings, TTS manifests
captions/ # video.srt, video.vtt
slides/ # slides.pptx, rendered slide frames, ppt-master export copy
clips/ # raw render and optional segment clips
meta/ # duration reports, timeline, visual cues, QA reports
Initialize it before running the route:
Pick <video_outdir> (resolve BEFORE any file writes). The bundle directory is shared across every paper2* skill — when paper2assets, paper2poster, paper2blog, and paper2video target the same root, the video's slides/audio/clips sit next to the poster's HTML, the blog's .docx, and the shared narration script in one self-contained package. Resolve deterministically:
- An explicit
<video_outdir>argument from the caller wins — honor it verbatim. The defaults below only fire when no path was passed. - A
paper2assetspackage already exists → reuse its folder verbatim as<video_outdir>. The canonical detection signal is<dir>/assets/meta/paper_spec.md(the cross-skill source of truth produced bypaper2assetsStep 4);<dir>/manifest.jsonwith"layout": "v2-assets"is a confirming hint when present. Writing into the same bundle means Route A readsassets/meta/narration.jsonfrom the same root it writesassets/audio/*.mp3into, with no path swap — and downstream tools that walkmanifest.jsonsee the video MP4s alongside everything else. - Otherwise (a bare PDF is the only input) → default to
<input_pdf_dir>/<pdf_stem>/— the directory containing the input PDF, then a subfolder named after the PDF basename (no extension). Example:papers/8008_Ink3D_Sculpting.pdf→<video_outdir> = papers/8008_Ink3D_Sculpting/. This matches thepaper2assetsdefault convention, so if Route A invokespaper2assets'sbuild_package.pybelow it lands in the same bundle without a later move.
$VIDEO_OUT and $PAPER_ASSETS are the SAME directory under this rule. The two variable names persist below for readability — $PAPER_ASSETS is used where the snippet emphasizes "I'm reading paper2assets meta", $VIDEO_OUT where it emphasizes "I'm writing the video bundle" — but in practice they always point at one root.
# 1. Resolve $VIDEO_OUT per the rule above. $PAPER_ASSETS aliases the same path.
if [[ -n "$video_outdir_arg" ]]; then
VIDEO_OUT="$video_outdir_arg" # explicit caller arg wins
elif [[ -f "$paper2assets_dir/assets/meta/paper_spec.md" ]]; then
VIDEO_OUT="$paper2assets_dir" # reuse the paper2assets bundle
else
VIDEO_OUT="$(dirname "$paper_pdf")/$(basename "$paper_pdf" .pdf)"
fi
PAPER_ASSETS="$VIDEO_OUT" # one bundle root — Route A reads paper2assets meta from here
# 2. Create the assets/ scaffolding under that root
VIDEO_ASSETS=$VIDEO_OUT/assets
VIDEO_AUDIO=$VIDEO_ASSETS/audio
VIDEO_CAPTIONS=$VIDEO_ASSETS/captions
VIDEO_SLIDES=$VIDEO_ASSETS/slides
VIDEO_CLIPS=$VIDEO_ASSETS/clips
VIDEO_META=$VIDEO_ASSETS/meta
mkdir -p "$VIDEO_AUDIO" "$VIDEO_CAPTIONS" "$VIDEO_SLIDES" "$VIDEO_CLIPS" "$VIDEO_META/reports"
The MP4 produced directly by render_video.py is the raw no-subtitle render.
Keep an audit copy under $VIDEO_CLIPS/video_raw.mp4, and also copy it to
$VIDEO_OUT/video_no_subtitles.mp4 as a required deliverable. The default
playback deliverable with burned-in subtitles is $VIDEO_OUT/video.mp4.
Two Supported Routes
Route A - paper2assets-aligned paper video
Use this when we want the video content to match paper2poster/paper2blog.
- Build or reuse the shared package:
python skills/paper2assets/scripts/build_package.py <paper.pdf> --outdir "$PAPER_ASSETS"
If paper_spec.md already exists, sync structured claims and narration:
python skills/paper2assets/scripts/build_package.py <paper.pdf> \
--outdir "$PAPER_ASSETS" \
--skip-extract \
--paper-spec "$PAPER_ASSETS/assets/meta/paper_spec.md"
- Export video narration inputs from the shared package:
python skills/paper2video/scripts/assets_to_script.py "$PAPER_ASSETS" \
--out "$VIDEO_AUDIO/script.json" \
--notes-dir "$VIDEO_META/notes"
For meeting-style duration targets, shape narration before TTS:
python skills/paper2video/scripts/assets_to_script.py "$PAPER_ASSETS" \
--target-minutes 3 \
--duration-tolerance-seconds 30 \
--out "$VIDEO_AUDIO/script.json" \
--notes-dir "$VIDEO_META/notes" \
--duration-plan-out "$VIDEO_META/duration_plan.json" \
--duration-rewrite-request-out "$VIDEO_META/duration_rewrite_request.json"
When the current narration already fits the target estimate, this writes:
$VIDEO_AUDIO/script.json # generate_audio.py-compatible script
$VIDEO_META/notes/<id>.md # subtitle-friendly notes, same ids/order
$VIDEO_META/duration_plan.json # when --target-minutes is used
When semantic rewriting is required, it writes duration_plan.json and
duration_rewrite_request.json instead of a final script.json. Fill the
request with rewritten prose, then rerun:
python skills/paper2video/scripts/assets_to_script.py "$PAPER_ASSETS" \
--target-minutes 3 \
--duration-rewrite-in "$VIDEO_META/duration_rewrites.json" \
--out "$VIDEO_AUDIO/script.json" \
--notes-dir "$VIDEO_META/notes" \
--duration-plan-out "$VIDEO_META/duration_plan.json"
Duration control is a two-stage contract:
- Plan narration against
--target-minutesbefore TTS. If the current script is clearly too long or too short, the helper writesduration_rewrite_request.jsonand stops. The agent must rewrite each requested section as a complete narration within its word budget; do not truncate by keeping only the first sentences. - After the first audio/video render, run
plan_tts_rate.pyagainst the real duration report. Use the generated rate plan only for small residual timing errors. If the plan saysneeds_script_rewrite, regenerate the script instead of forcing an unnatural speech rate.
python skills/paper2video/scripts/plan_tts_rate.py \
--duration-plan "$VIDEO_META/duration_plan.json" \
--duration-report "$VIDEO_META/video_duration_report.json" \
--target-minutes 3 \
--out "$VIDEO_AUDIO/tts_rate_plan.json"
python skills/paper2video/scripts/generate_edge_audio.py \
"$VIDEO_AUDIO/script.json" \
--outdir "$VIDEO_AUDIO" \
--rate-plan "$VIDEO_AUDIO/tts_rate_plan.json" \
--timings-out "$VIDEO_AUDIO/word_timings.json"
If the video deck has fewer slides than narration.json, pass an explicit
comma-separated order:
python skills/paper2video/scripts/assets_to_script.py "$PAPER_ASSETS" \
--ids title,problem,motivation,method,key-result,takeaway \
--out "$VIDEO_AUDIO/script.json" \
--notes-dir "$VIDEO_META/notes"
- Create or provide a PPTX whose slide order matches the script sections.
This can come from
ppt-master, a futurepaper2slides, or a user-provided deck. The video compositor only requires a PPTX plus matching MP3s, but the default high-quality route isppt-master.
ppt-master discipline:
- An existing
ppt-master/examples/<topic>project is optional reuse, not a prerequisite. If no suitable example exists, start a fresh ppt-master project from the paper/PDF or paper2assets materials and run the full ppt-master skill workflow. - Do not replace ppt-master with handwritten SVG, a local simplified generator, or a copied example deck whose content does not come from the paper.
- Before running ppt-master, read the external
$PPT_MASTER_DIR/skills/ppt-master/SKILL.mdand follow its gates: source conversion, project init/import, Strategist Eight Confirmations, optional image acquisition, sequential page-by-page SVG authoring by the main agent,svg_quality_checker.py,total_md_split.py,finalize_svg.py, andsvg_to_pptx.py. - If ppt-master reaches a blocking confirmation gate and the user has not already approved defaults, stop and ask the user. Do not mark the route as unavailable merely because the deck does not already exist.
- If a machine dependency is missing, record the concrete missing dependency and stop. Do not silently degrade to a different slide-generation method.
When using ppt-master, prepare title-slide utility assets before invoking the
deck workflow:
python skills/paper2assets/scripts/fetch_logos.py \
--outdir "$PAPER_ASSETS" --from-spec "$PAPER_ASSETS/assets/meta/paper_spec.md" || true
python skills/paper2assets/scripts/make_qr.py \
--outdir "$PAPER_ASSETS" --from-metadata "$PAPER_ASSETS/assets/meta/metadata.json" || true
If assets/meta/paper_spec.md is not available yet, skip fetch_logos.py; do
not invent logos. make_qr.py is best-effort and only uses paper_url,
code_url, or the documented arxiv_id paper fallback from
assets/meta/metadata.json.
Add this requirement block to the prompt given to ppt-master:
Title slide assets:
- Use the local institute logo files under <paper2assets_outdir>/assets/logos/ when present.
- Use <paper2assets_outdir>/assets/qr/code.png as a labeled "Code" QR tile when present. If
<paper2assets_outdir>/assets/qr/paper.png is present, it may appear as a smaller labeled
"Paper" tile. Never fabricate a missing code URL or QR.
- Place these assets as a restrained utility cluster on slide 1, preferably in
the right-side title area or lower-right safe zone. Keep the paper title and
main visual hierarchy dominant; logos and QR tiles should be crisp, aligned,
readable at 1080p video size, and visually integrated with the deck palette.
- Omit unavailable assets cleanly. Do not leave broken image placeholders,
literal file paths, or empty boxes.
When the final video will use highlight/cursor attention, also generate cue anchor requirements before invoking ppt-master:
python skills/paper2video/scripts/generate_cue_requirements.py \
"$VIDEO_AUDIO/script.json" \
--out "$VIDEO_META/visual_cue_requirements.json" \
--contract-out "$VIDEO_META/visual_anchor_contract.json" \
--markdown-out "$VIDEO_META/visual_cue_requirements.md"
Add the generated visual_cue_requirements.md to the ppt-master prompt. It
asks the slide authoring pass to place stable semantic anchors in PPTX shape
name/alt text and, when SVG/HTML is produced, in SVG IDs, <title>, <desc>,
or data-cue-label attributes. Those anchors make the post-hoc cue matcher
verify exact contract alignment instead of guessing the intended target from
generic shapes.
Anchor contract for ppt-master:
- Add only a few anchors per slide, normally 2-5, focused on visible content that narration actually discusses.
- Prefer stable PPTX/SVG ids that start with
cue_, for examplecue_s08_c2_multi_head_attention. - Put the same id and narration keywords in PPTX shape name/alt text and in SVG
<title>,<desc>, ordata-cue-label; with--anchor-contract, the cue matcher requires exactanchor_idmatches. - Anchor a specific diagram/card/chart/formula/row, not a whole slide, header, caption, logo, QR tile, or decorative background.
- If strict cue generation fails with low confidence, repair the slide source by adding or tightening anchors before rendering highlighted video.
- Prefer anchors that survive in both
svg_finaland the exported PPTX. The video raster frames are rendered fromsvg_finalwhen available, while the PPTX remains the editable deliverable and geometry audit source.
- Generate audio:
python skills/paper2poster/scripts/generate_audio.py \
"$VIDEO_AUDIO/script.json" \
--outdir "$VIDEO_AUDIO"
- For highlighted video, generate PPTX-backed visual cues before rendering.
Run one cue-planning pass to locate the authored SVG anchors, inject those boxes into PPTX shape metadata, then run the final strict pass that requires PPTX anchors:
python skills/paper2video/scripts/generate_visual_cues.py <ppt_master_project> \
--script-json "$VIDEO_AUDIO/script.json" \
--audio-dir "$VIDEO_AUDIO" \
--pptx <deck.pptx> \
--anchor-contract "$VIDEO_META/visual_anchor_contract.json" \
--timings-json "$VIDEO_AUDIO/word_timings.json" \
--strict-gate \
--require-timestamps \
--out "$VIDEO_META/visual_cues.pre_pptx.json" \
--geometry-report-out "$VIDEO_META/geometry_resolution.pre_pptx.json" \
--candidate-review-out "$VIDEO_META/cue_candidate_review.pre_pptx.html" \
--cue-plan-out "$VIDEO_META/visual_cue_plan.pre_pptx.json"
python skills/paper2video/scripts/inject_pptx_anchors.py \
--pptx <deck.pptx> \
--cue-plan "$VIDEO_META/visual_cue_plan.pre_pptx.json" \
--out <deck.pptx> \
--report "$VIDEO_META/reports/pptx_anchor_injection.json"
python skills/paper2video/scripts/generate_visual_cues.py <ppt_master_project> \
--script-json "$VIDEO_AUDIO/script.json" \
--audio-dir "$VIDEO_AUDIO" \
--pptx <deck.pptx> \
--anchor-contract "$VIDEO_META/visual_anchor_contract.json" \
--require-pptx-anchors \
--timings-json "$VIDEO_AUDIO/word_timings.json" \
--strict-gate \
--require-timestamps \
--out "$VIDEO_META/visual_cues.json" \
--geometry-report-out "$VIDEO_META/geometry_resolution.json" \
--candidate-review-out "$VIDEO_META/cue_candidate_review.html" \
--cue-plan-out "$VIDEO_META/visual_cue_plan.json"
- Render the video, pinning audio order to the script JSON:
python skills/paper2video/scripts/render_video.py "$VIDEO_OUT" \
--pptx <deck.pptx> \
--audio-dir "$VIDEO_AUDIO" \
--script-json "$VIDEO_AUDIO/script.json" \
--attention-mode highlight \
--highlight-style spotlight_laser \
--visual-cues "$VIDEO_META/visual_cues.json" \
--target-minutes 3 \
--duration-report-out "$VIDEO_META/video_duration_report.json" \
--out "$VIDEO_CLIPS/video_raw.mp4" \
--frames-out "$VIDEO_SLIDES/frames"
- Burn final subtitles and place final video/deck in the normalized output.
python skills/paper2video/scripts/add_subtitles.py "$VIDEO_OUT" \
--mp4 "$VIDEO_CLIPS/video_raw.mp4" \
--audio-dir "$VIDEO_AUDIO" \
--script-json "$VIDEO_AUDIO/script.json" \
--srt-out "$VIDEO_CAPTIONS/video.srt" \
--vtt-out "$VIDEO_CAPTIONS/video.vtt" \
--out "$VIDEO_OUT/video.mp4"
The default burned-in subtitle render uses a translucent dark caption box so
narration text stays separate from dense PPT content. Use `--no-subtitle-box`
only for an explicitly approved legacy/plain-caption render.
cp "$VIDEO_CLIPS/video_raw.mp4" "$VIDEO_OUT/video_no_subtitles.mp4"
cp <deck.pptx> "$VIDEO_SLIDES/slides.pptx"
cp <deck.pptx> "$VIDEO_OUT/video.pptx"
$VIDEO_OUT/video.mp4 is the default playback deliverable with burned-in
subtitles. $VIDEO_OUT/video_no_subtitles.mp4 is also a required final
deliverable for paper2reel and downstream editing, where subtitles are controlled
by a separate VTT/CC toggle.
- Build the unified timeline contract.
timeline.json is the canonical mapping between narration chunks, audio
windows, subtitle cues, and visual-highlight targets. Downstream
paper2reel must consume this file instead of guessing section start/end
times from the final MP4.
python skills/paper2video/scripts/build_timeline.py \
--script-json "$VIDEO_AUDIO/script.json" \
--duration-report "$VIDEO_META/video_duration_report.json" \
--visual-cue-plan "$VIDEO_META/visual_cue_plan.json" \
--visual-cues "$VIDEO_META/visual_cues.json" \
--captions-vtt "$VIDEO_CAPTIONS/video.vtt" \
--audio-dir "$VIDEO_AUDIO" \
--video "$VIDEO_OUT/video_no_subtitles.mp4" \
--section-map "$VIDEO_META/section_slide_map.json" \
--out "$VIDEO_META/timeline.json"
--section-map is optional only when the script ids already are the canonical
paper2assets section ids. When a ppt-master deck uses slide-specific ids such
as 03_sequence_evolution, pass an explicit map so poster sections, slides,
blog blocks, audio, subtitles, and visual cues share the same section ids.
Prefer the grouped form when poster sections overlap:
{
"problem": [2, 3, 4],
"motivation": [3, 4],
"headline-numbers": [2],
"method": [5, 6, 7, 8, 9, 10, 11, 12]
}
Route B - existing ppt-master deck video
Use this when ppt-master has already produced a complete project with
notes/, svg_output/, and exports/*.pptx.
<project_path>/
notes/<slide>.md
svg_output/<slide>.svg
exports/<name>.pptx
If notes/*.md is missing but notes/total.md exists, run the external
ppt-master splitter:
python "$PPT_MASTER_DIR/scripts/total_md_split.py" <project_path>
Build TTS script JSON from ppt-master notes:
python skills/paper2video/scripts/notes_to_script.py <project_path> \
--voice alloy \
--target-minutes 3 \
--out <project_path>/audio/script.json
Generate audio:
python skills/paper2poster/scripts/generate_audio.py \
<project_path>/audio/script.json \
--outdir <project_path>/audio
Render:
python skills/paper2video/scripts/render_video.py <project_path> \
--pptx <project_path>/exports/<name>.pptx \
--audio-dir <project_path>/audio \
--script-json <project_path>/audio/script.json \
--attention-mode highlight \
--highlight-style spotlight_laser \
--visual-cues <project_path>/visual_cues.json \
--target-minutes 3 \
--duration-report-out "$VIDEO_META/video_duration_report.json" \
--out "$VIDEO_CLIPS/video_raw.mp4" \
--frames-out "$VIDEO_SLIDES/frames"
Burn final subtitles and copy the deck into the v2 assets bundle:
python skills/paper2video/scripts/add_subtitles.py <project_path> \
--mp4 "$VIDEO_CLIPS/video_raw.mp4" \
--audio-dir <project_path>/audio \
--script-json <project_path>/audio/script.json \
--srt-out "$VIDEO_CAPTIONS/video.srt" \
--vtt-out "$VIDEO_CAPTIONS/video.vtt" \
--out "$VIDEO_OUT/video.mp4"
The default burned-in subtitle render uses a translucent dark caption box so
narration text stays separate from dense PPT content. Use `--no-subtitle-box`
only for an explicitly approved legacy/plain-caption render.
cp "$VIDEO_CLIPS/video_raw.mp4" "$VIDEO_OUT/video_no_subtitles.mp4"
cp <project_path>/exports/<name>.pptx "$VIDEO_SLIDES/slides.pptx"
Final QA Gate
Run the final hard QA gate for either route. This is not a smoke test; it checks
the exact slide frames archived by render_video.py --frames-out, probes
audio/video streams, checks PPTX geometry for text overflow/overlap and
undersized visuals, checks rendered-frame blank space/sparsity, verifies the
final MP4 duration, and rejects unsafe TTS rate plans when duration control is
requested.
python skills/paper2video/scripts/check_video_package.py "$VIDEO_OUT" \
--pptx <deck.pptx> \
--script-json "$VIDEO_AUDIO/script.json" \
--audio-dir "$VIDEO_AUDIO" \
--frames-dir "$VIDEO_SLIDES/frames" \
--mp4 "$VIDEO_OUT/video.mp4" \
--raw-mp4 "$VIDEO_OUT/video_no_subtitles.mp4" \
--subtitle-file "$VIDEO_CAPTIONS/video.vtt" \
--visual-cues "$VIDEO_META/visual_cues.json" \
--cue-plan "$VIDEO_META/visual_cue_plan.json" \
--timeline "$VIDEO_META/timeline.json" \
--rate-plan "$VIDEO_AUDIO/tts_rate_plan.json" \
--target-minutes 3 \
--require-rate-plan \
--require-subtitles \
--require-visual-cues \
--require-cue-plan \
--require-timeline \
--require-word-timings \
--strict \
--out "$VIDEO_META/reports/video_qa_report.json"
For stricter semantic-anchor enforcement, also require the anchor contract and PPTX-backed anchors:
python skills/paper2video/scripts/check_video_package.py "$VIDEO_OUT" \
--pptx <deck.pptx> \
--script-json "$VIDEO_AUDIO/script.json" \
--audio-dir "$VIDEO_AUDIO" \
--frames-dir "$VIDEO_SLIDES/frames" \
--mp4 "$VIDEO_OUT/video.mp4" \
--raw-mp4 "$VIDEO_OUT/video_no_subtitles.mp4" \
--subtitle-file "$VIDEO_CAPTIONS/video.vtt" \
--visual-cues "$VIDEO_META/visual_cues.json" \
--cue-plan "$VIDEO_META/visual_cue_plan.json" \
--anchor-contract "$VIDEO_META/visual_anchor_contract.json" \
--timeline "$VIDEO_META/timeline.json" \
--rate-plan "$VIDEO_AUDIO/tts_rate_plan.json" \
--target-minutes 3 \
--strict \
--strict-attention \
--require-visual-cues \
--require-cue-plan \
--require-anchor-contract \
--require-pptx-anchors \
--require-timeline \
--require-rate-plan \
--require-subtitles \
--require-word-timings \
--out "$VIDEO_META/reports/video_qa_report.json"
The gate must pass before delivery. If it fails, fix the deck/script/audio/cues
and re-render; do not bypass strict mode for final output. Only pass
--allow-missing-attention for an explicitly user-approved degraded/debug run
with no highlight.
Write $VIDEO_OUT/manifest.json with "layout": "v2-assets" and root-relative
paths for both MP4 deliverables plus assets/audio/, assets/captions/,
assets/slides/, assets/clips/, and assets/meta/reports/video_qa_report.json.
If the QA gate exits non-zero, stop and fix the video package. Do not continue
with a simplified or unverified video unless the user explicitly approves a
named degraded path.
Audio Providers
The current shared synthesizer is:
python skills/paper2poster/scripts/generate_audio.py <script.json> --outdir <audio_dir>
It consumes JSON with the same section contract used by
paper2assets's assets/meta/narration.json and by paper2poster's Listen buttons:
{
"provider": "edge",
"voice": null,
"sections": [
{"id": "problem", "heading": "Problem", "text": "..."}
]
}
paper2assets owns the narration text only. It does not synthesize audio.
paper2poster/scripts/generate_audio.py is the shared synthesizer:
edgeis the default free backend (edge-tts, no API key; default voiceen-US-AndrewNeural).azureis opt-in and requires the Azure config/API key expected by paper2poster's script; Azure voices arealloy,echo,fable,onyx,nova,shimmer.
The compositor does not care which provider produced the MP3s. Future provider
support (edge-tts, OpenAI TTS, ElevenLabs, etc.) should only guarantee the same
contract: one <id>.mp3 per script section under the chosen audio/ directory.
When strict visual-attention alignment is required and Edge TTS is acceptable, use the bundled Edge helper because it can write word-boundary timings:
python skills/paper2video/scripts/generate_edge_audio.py \
<project_path>/audio/script.json \
--outdir <project_path>/audio \
--timings-out <project_path>/audio/word_timings.json
Those timings let generate_visual_cues.py --require-timestamps and
check_video_package.py --require-word-timings reject highlight plans that only
use proportional/estimated timing.
Rendering Details
render_video.py does:
- Prefer
svg_final/*.svg-> PNG frames via Playwright/Chrome. If no SVG deck exists, or--frame-source pptxis explicitly set, use the legacy PPTX -> PDF -> PNG path via LibreOffice andpdftoppm. - Copy the exact MP4 frames to
--frames-outwhen provided; final QA should point--frames-dirat that same directory. - MP3 duration probing via
ffprobeor the ffmpeg fallback. - One MP4 segment per slide.
- ffmpeg concat into a final H.264/AAC MP4.
Audio ordering:
- Preferred:
--script-json <script.json>. - Auto-detected fallback:
<audio-dir>/script.json, then<project>/assets/meta/narration.json, then<project>/narration.jsonfor legacy bundles. - Secondary fallback:
<audio-dir>/manifest.json. - Last resort: sorted
*.mp3filenames.
This matters for paper2assets, whose ids are semantic (problem, method,
key-result) rather than numeric (01-intro, 02-method).
ffmpeg selection:
- First honor
PAPER2VIDEO_FFMPEG/PAPER2VIDEO_FFPROBEif set. - Then prefer
imageio_ffmpeg's bundled static ffmpeg when installed. - Then fall back to system
ffmpeg/ffprobe.
This mirrors the ACL26 video prototype: its subtitle burn step used
imageio_ffmpeg's ffmpeg 7.x because the system ffmpeg on this machine is
2.4.x and too old for reliable subtitles/audio filtering.
Useful flags:
| Flag | Purpose |
|---|---|
| `--resolution 720p | 1080p |
| `--frame-source auto | svg |
--svg-dir DIR |
Explicit SVG deck directory |
--frames-out DIR |
Persist the exact frames used by the MP4 for QA/review |
--fps N |
Frame rate (default 30) |
--pad-tail SECONDS |
Trailing silence after each slide (default 0.3) |
--start-pad SECONDS |
Leading silence before slide 1 (default 0.5) |
--target-minutes N |
Write a final duration report against an N-minute target |
| `--attention-mode none | highlight |
| `--highlight-style box | spotlight |
--visual-cues path.json |
Normalized per-slide highlight/cursor cue file |
--allow-missing-visual-cues |
Degraded/debug only; final output should not use it |
--frames-only |
Stop after slide-frame export |
--audio-only-check |
Verify frame/audio count and order |
Duration Control
Use the duration target at script-generation time, before TTS:
python skills/paper2video/scripts/notes_to_script.py <project_path> \
--target-minutes 3 \
--duration-tolerance-seconds 30 \
--out <project_path>/audio/script.json
or for paper2assets:
python skills/paper2video/scripts/assets_to_script.py "$PAPER_ASSETS" \
--target-minutes 3 \
--duration-tolerance-seconds 30 \
--out "$VIDEO_AUDIO/script.json" \
--notes-dir "$VIDEO_META/notes" \
--duration-plan-out "$VIDEO_META/duration_plan.json"
The helper estimates TTS duration from word count and keeps the selected section
count by default. If the estimate is outside tolerance, it writes a
duration_rewrite_request.json with per-section target word budgets. The agent
must rewrite the whole narration for those sections, preserving all key ideas
within the budget, then rerun with --duration-rewrite-in. Pass
--duration-section-mode auto only when the deck can be regenerated to match a
shorter section list; existing PPTX decks should keep the default keep mode.
--allow-extractive-duration-draft is for experiments only and must not be used
for final deliverables.
Then pass the same target to render_video.py. Rendering writes
<out_stem>_duration_report.json with the real MP4 duration after audio exists.
Use that measured duration to produce a conservative TTS rate plan:
python skills/paper2video/scripts/plan_tts_rate.py \
--duration-plan <project_path>/audio/duration_plan.json \
--duration-report <project_path>/exports/video_duration_report.json \
--target-minutes 3 \
--out <project_path>/audio/tts_rate_plan.json
Only within_tolerance, use_rate_adjustment, or
borderline_rate_adjustment may be used for final generation. A
needs_script_rewrite plan is a hard instruction for the agent to go back to
notes_to_script.py or assets_to_script.py, generate/apply a semantic
duration rewrite, and then regenerate TTS, subtitles, video, and timeline. Do
not hide a large mismatch with speech-rate changes.
Visual Attention Cues
To make static slides feel less inert, first create an explicit visual-anchor contract from the final narration script. Give the Markdown to ppt-master while authoring the deck so it labels the few key visual targets used by the video:
python skills/paper2video/scripts/generate_cue_requirements.py \
<project_path>/audio/script.json \
--out <project_path>/cue_requirements.json \
--contract-out <project_path>/visual_anchor_contract.json \
--markdown-out <project_path>/cue_requirements.md
ppt-master should write each anchor_id into the corresponding visible SVG and
PPTX element metadata: SVG id, data-cue-label, <title>, or <desc>, and
PPTX shape name, alt-text title, or alt-text description. SVG anchors remain
useful semantic labels, but final highlighted videos should prefer PPTX
geometry when it can be matched confidently. The cue generator records both:
semantic_* fields describe what narration target was selected, while
geometry_* fields describe the PPTX element or PPTX connected cluster whose
box is actually rendered.
Then build the cue plan from the deck, slide visuals, narration, word-boundary
timings, and the anchor contract. Use a two-pass flow when the deck was authored
through SVG: the first pass reads the SVG anchor boxes, inject_pptx_anchors.py
stores those boxes as invisible PPTX shapes with matching shape metadata, and
the second pass proves the editable PPTX carries the same semantic anchors.
python skills/paper2video/scripts/generate_visual_cues.py <project_path> \
--script-json <project_path>/audio/script.json \
--audio-dir <project_path>/audio \
--pptx <project_path>/exports/<name>.pptx \
--anchor-contract <project_path>/visual_anchor_contract.json \
--timings-json <project_path>/audio/word_timings.json \
--strict-gate \
--require-timestamps \
--out <project_path>/visual_cues.pre_pptx.json \
--geometry-report-out <project_path>/geometry_resolution.pre_pptx.json \
--candidate-review-out <project_path>/cue_candidate_review.pre_pptx.html \
--cue-plan-out <project_path>/visual_cue_plan.pre_pptx.json \
--audit-out <project_path>/cue_audit.pre_pptx.json \
--html-audit-out <project_path>/cue_audit.pre_pptx.html \
--repair-out <project_path>/cue_repair_requests.pre_pptx.json \
--repair-md-out <project_path>/cue_repair_requests.pre_pptx.md
python skills/paper2video/scripts/inject_pptx_anchors.py \
--pptx <project_path>/exports/<name>.pptx \
--cue-plan <project_path>/visual_cue_plan.pre_pptx.json \
--out <project_path>/exports/<name>.pptx \
--report <project_path>/pptx_anchor_injection.json
python skills/paper2video/scripts/generate_visual_cues.py <project_path> \
--script-json <project_path>/audio/script.json \
--audio-dir <project_path>/audio \
--pptx <project_path>/exports/<name>.pptx \
--anchor-contract <project_path>/visual_anchor_contract.json \
--require-pptx-anchors \
--timings-json <project_path>/audio/word_timings.json \
--strict-gate \
--require-timestamps \
--out <project_path>/visual_cues.json \
--geometry-report-out <project_path>/geometry_resolution.json \
--candidate-review-out <project_path>/cue_candidate_review.html \
--cue-plan-out <project_path>/visual_cue_plan.json \
--audit-out <project_path>/cue_audit.json \
--html-audit-out <project_path>/cue_audit.html \
--repair-out <project_path>/cue_repair_requests.json \
--repair-md-out <project_path>/cue_repair_requests.md
Then pass positioned cues at render time:
python skills/paper2video/scripts/render_video.py <project_path> \
--pptx <project_path>/exports/<name>.pptx \
--audio-dir <project_path>/audio \
--script-json <project_path>/audio/script.json \
--attention-mode highlight \
--highlight-style spotlight_laser \
--visual-cues <project_path>/visual_cues.json \
--duration-report-out "$VIDEO_META/video_duration_report.json" \
--out "$VIDEO_CLIPS/video_raw.mp4"
highlight is the default final-delivery mode. If --attention-mode is not
none, render_video.py now requires --visual-cues; missing cues are a
blocking error unless the agent explicitly passes the degraded/debug-only
--allow-missing-visual-cues.
visual_cues.json uses normalized coordinates so it survives 720p/1080p/4K
renders:
{
"slides": [
{
"id": "07_fineweb_accuracy_lift",
"cues": [
{"start": 3.2, "end": 8.5, "type": "highlight", "box": [0.12, 0.28, 0.14, 0.21], "point": [0.19, 0.39]},
{"start": 9.0, "duration": 4.0, "type": "cursor", "point": [0.52, 0.62]}
]
}
]
}
id matches the audio/script section id. index may be used instead for
1-based slide numbers. highlight renders a translucent target box when a
normalized box is available; point remains as a compatibility center point
and point-only cues render as the older soft-dot fallback. For automatic cue
generation, the matcher reads semantic SVG/PPT regions and strongly prefers
explicit cue_ anchors. It then resolves the visible geometry to PPTX when
possible: direct PPTX boxes first, small connected PPTX clusters second, and
semantic fallback only when PPTX geometry is low-confidence. Text-line targets
are promoted to their nearby module/group when that parent is still reasonably
bounded, so final highlights should point at cards, rows, formula blocks, or
figure panels rather than isolated words. Connected PPTX clusters are rejected
when the union box grows too large or crosses unrelated PPTX content. When
--anchor-contract is provided, anchors are no longer just a confidence bonus:
each contracted narration chunk must match its exact anchor_id, and
--require-pptx-anchors requires the match to come from PPTX geometry.
generate_visual_cues.py also writes geometry_resolution.json. Use it with
cue_audit.html and cue_candidate_review.html to inspect whether a cue
rendered from pptx, pptx_cluster, or a semantic fallback. The candidate
review page shows the narration chunk, word-timing match, selected semantic
target, final geometry target, and top rejected semantic/geometry alternatives.
check_video_package.py --strict reports geometry source counts and timing
source counts; it fails if geometry_box is malformed, does not match the
rendered cue box, lies outside the normalized slide canvas, or if required
word-timing alignment is low-confidence.
Highlight presentation styles:
spotlight_laseris the production default: a feathered spotlight plus a small red laser-pointer dot at the accepted cue center.boxrenders a subtle slate fill plus border around the accepted box.cursorrenders only a soft presentation pointer at the cue center.box_cursorcombines the accepted box with the same pointer, useful for reviewer comparisons.spotlight_cursorcombines the feathered spotlight with the same mouse pointer, useful when a visible arrow is preferred over the laser dot.laserrenders only the small red laser-pointer dot at the cue center.- Cursor styles render a mouse-pointer overlay and ease it between consecutive cue points on the same slide instead of teleporting at cue boundaries.
- Laser styles use the same eased movement between consecutive cue points as cursor styles.
spotlight,spotlight_cursor, andspotlight_lasersoftly dim the surrounding slide with a continuous alpha-mask falloff around the accepted box while keeping the target at original brightness. They are tolerance modes and can be substantially slower on full-length videos because each cue adds an extra full-frame overlay mask.
Strict gate repair loop:
- If
generate_visual_cues.py --strict-gateexits non-zero, do not render a highlighted video yet. - Treat
cue_repair_requests.md,cue_audit.html,visual_cue_plan.json, andslide_regions.jsonas agent debugging inputs, not user homework. - Open those files and fix the root cause in the deck or narration: add precise SVG cue labels,
make a huge container into a smaller semantic group, retarget away from
header/caption/chrome, or rewrite/split a narration chunk so it names the
same concept as the slide. If the failure says
anchor_missing, add the requestedanchor_idto the intended PPTX shape metadata and rerun export. - Re-run ppt-master post-processing/export when SVGs changed, rerun the
pre-PPTX cue pass, rerun
inject_pptx_anchors.py, then rerungenerate_visual_cues.py --strict-gate --require-pptx-anchors. - Attempt at least two concrete repair passes before reporting a blocking visual-cue ERROR, unless the failure is a missing dependency or a user decision is required.
- While repairing, record a
REPAIRINGnote in$VIDEO_OUT/manifest.jsonor$VIDEO_META/reports/video_qa_report.json; promote to finalERRORonly after the agent has no viable repair path or needs user/external action.
generate_visual_cues.py writes the audit and repair files even when strict
mode fails, then exits non-zero. That is intentional: diagnostics exist for
repair, while downstream rendering is still blocked until the gate passes.
After a highlighted render, run build_timeline.py with the same
visual_cue_plan.json, visual_cues.json, duration_report.json, script, and
subtitle VTT. This is what binds every spoken chunk to its audio time, subtitle
cue, and visual target. Do not let downstream tools cut video by ad-hoc
timestamps that are not derived from the timeline.
For section-level playback in paper2reel, build section media from
complete slide clips and append a short silent freeze tail. This avoids abrupt
audio/video endings and avoids cutting a slide mid-sentence simply because the
section boundary falls inside a rendered MP4 timestamp. Use
$VIDEO_OUT/video_no_subtitles.mp4 as the paper2reel video source, not
the burned-in subtitle deliverable at $VIDEO_OUT/video.mp4; otherwise
the viewer can show duplicate subtitles when CC is enabled.
Subtitles
add_subtitles.py can use either notes files or script JSON:
- With
--script-json, subtitle order and fallback text come from the JSON. - Without it, the script preserves legacy ppt-master behavior: sorted
notes/*.mdpaired with sortedaudio/*.mp3.
Default mode burns subtitles into the video pixels with a translucent dark
caption box. Pass --soft to mux a toggleable mov_text track instead. Pass
--srt-only to produce just the SRT. Pass --no-subtitle-box only for a
user-approved legacy/plain-caption render.
Sanity Checks
Before calling the video done:
- PPTX slide count equals the number of selected script sections.
- Every selected section has
audio/<id>.mp3. render_video.py --audio-only-checkpasses.ffprobereports a positive duration for the final MP4.check_video_package.py --strictpasses and writesvideo_qa_report.json.- If visual attention is enabled,
check_video_package.py --strict-attentionpasses with--require-visual-cues --require-cue-plan --require-timeline --require-word-timings. timeline.jsonexists and every chunk has the expected audio window, subtitle cues, and accepted visual cue before paper2reel consumes it.- If subtitles are requested,
add_subtitles.pyuses the same--start-pad,--pad-tail, and--script-jsonasrender_video.py.
References
references/script_json_schema.md- narration JSON shape and TTS gotchas.references/render_video.md- compositor internals and ffmpeg debugging.references/visual_cues.md- visual cue JSON schema and examples.


