Agent Skills › microsoft/ResearchStudio

microsoft/ResearchStudio

GitHub

将模糊的研究方向转化为可执行的口头级别研究提案。通过检索文献、诊断瓶颈、生成候选方案及质量评估,提供包含具体方法和证伪计划的创新思路。

9 个 Skill 1,450

安装全部 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:

  1. Run next.
  2. Do what it says (bash → run the command; llm_subagent → execute in an ISOLATED context per the Context discipline rules below).
  3. Run next again. Repeat until it reports a terminal state (DONE, do_not_generate, or phase_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 Agent or 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.

  • revise3.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, ops replace / 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) → writes phase3_revise/final_candidate.json + back-injects it into the patch file. Kill-switch fields are merger-refused with ONE audited exception: a scope=falsification target from falsification_structure_check is applied via the dedicated rewrite_falsification op (authorization verified against the audit report via --critique; same experiment/metric/claim, structure repaired; max one per run). When the merger prints falsification_rewritten, run the falsification re-audit (critique.txt § "Falsification re-audit mode": single check on final_candidate.jsonphase3_critique/falsification_reaudit.json): advance → Phase 4; abandonphase_3_failed.md. compute_budget has no revision route under any scope. No composition scope — gap-level changes route through the abandon-retry below, never through patches.

  • abandonone internal retry (the one-shot guarantee bars asking the user, not internal regeneration): if $RUN_DIR/.retry_used is 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 → write phase_3_failed.md citing 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):

  1. skeleton (orchestrator): populates every mechanical field — kill-switch echoes (byte-identical from the candidate), differentiation_from_lit venue_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 against intake.compute) — and marks every prose field <TODO[path]: hint>.
  2. 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.
  3. 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).
  4. 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 as open). Compute-agnostic by design (resource feasibility is 4.1's job); never adds/removes/renames steps; never carries kill-switch fields. Output phase4/phase4_implementability.json.
  5. validate + render: run the validators (below), then phase4_render — templating only, no model call; auto-detects the sibling implementability file and merges enriched_steps by step_id into the rendered Method (deterministic; no-op when absent). Writes idea.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); next surfaces 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.
构建交互式HTML查看器,通过sidecar文件对齐论文海报、PPT、博客和视频内容。保持原始文件不变,提供键盘快捷键控制的统一浏览体验,支持视频进度条和章节跳转,确保多模态内容的同步交互。
需要整合展示论文的多媒体成果(海报、幻灯片、视频、博客) 希望在不修改原始输出文件的情况下实现跨媒介的内容导航与同步
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:

  • v toggles the top section menu.
  • h toggles keyboard shortcut help.
  • a toggles the poster's existing audio controls.
  • s toggles fullscreen.
  • d toggles 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 paper2assets is missing paper_spec.md, run the complete paper2assets skill. Do not treat extract_pdf.py output alone as a paper2assets package, because Step 4 is model-driven.

  • If only manifest.json, assets/meta/sections.json, or assets/meta/narration.json is missing while paper_spec.md, text.txt, figures.json, and metadata.json already exist, the helper may refresh those deterministic package files with --run-missing.

  • If paper2poster is missing, run the complete paper2poster skill, including the measured fill loop, render/export, html2pptx handoff, and deliverable gate.

  • If paper2blog is missing, run the complete bilingual paper2blog skill, including real EN/CN outlines, DOCX generation, figure embedding, and the blog QA gate.

  • If paper2video is missing, run the complete paper2video skill, 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:

  • title
  • problem
  • motivation
  • contribution
  • method
  • dataset-benchmark
  • key-result
  • ablation-study
  • headline-numbers
  • takeaway
  • 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>
在实验前评估研究想法的质量。基于标题、动机和方法,从问题定位、方法质量和逻辑一致性三个维度进行推理评分(0-100),支持单文件打分或双文件盲测对比,不依赖外部文献或实验结果。
用户询问想法质量(如'这个想法好不好') 要求对研究提案进行评分或评级 提供包含标题/动机/方法的Markdown文件并寻求质量判断 比较两个研究想法的强弱
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 at borderline at 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.
验证研究新颖性是否被现有文献覆盖。需输入研究问题与 claimed novelty,分解为四个维度,调用 paper-search 搜索并去重,最终确认是否存在撞车或提炼差异化贡献 delta。
验证研究新颖性 检查想法是否为新 对比主张贡献与现有技术 针对特定声明进行文献/先例搜索 评估论文想法是否已被抢先发表
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:

  1. Research problem — the specific issue, challenge, or gap in knowledge being addressed.
  2. 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.

  1. Resolve a PDF URL. Start from the source link / ID recorded in Step 3. For arXiv entries, convert https://arxiv.org/abs/<id> to https://arxiv.org/pdf/<id>.pdf. For OpenReview / ACL Anthology / publisher pages, follow the "PDF" link. Use WebFetch first only to locate the PDF URL when it is not obvious (e.g., ask it "what is the direct PDF link on this page?").
  2. Fetch and extract with the bundled script. Pick a <pdf_name> that identifies the paper (e.g., a slugified title or the arXiv ID like 2310_12345) so multiple candidates don't clobber each other, then run:
    scripts/fetch_paper.sh "<PDF_URL>" "<pdf_name>"
    
    The script downloads to ${CLAUDE_PROJECT_DIR}/papers/<pdf_name>.pdf, verifies the result is a real PDF (HTML error pages saved as .pdf are a common failure mode), extracts text with pdftotext -layout, and falls back through plain pdftotextpdfplumberpymupdf if needed. On success it prints ok: … and the final .txt path on the last line; on failure it prints FAILED: <reason> and exits non-zero.
  3. Read the extracted text. Use the Read tool on the printed .txt path. 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.
  4. Fall back gracefully. If fetch_paper.sh exits non-zero (paywall, 403, dead link, scanned-image PDF that no extractor can handle), try WebFetch on 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:

  1. 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).
  2. 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.
  3. Decomposed claim — the four axes from Step 1, presented as a labeled list (Problem framing, Core mechanism, Key insight, Application domain).
  4. 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.
  5. 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}.md containing 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.
将学术论文或研究材料转化为双语编辑包,自动生成中文微信公众号版和英文研究博客版.docx文件。支持从paper2assets共享资源中提取图表与证据,确保内容一致性与准确性,适配不同受众风格。
需要将学术论文转换为适合微信公众号发布的中文文章 需要生成英文研究博客风格的学术解读文章 已有paper2assets资源包,需同步生成配套博客稿件
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:

  1. An explicit <blog_outdir> argument from the caller wins — honor it verbatim. The defaults below only fire when no path was passed.
  2. A paper2assets package 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 by paper2assets Step 4); <dir>/manifest.json with "layout": "v2-assets" is a confirming hint when present. Writing into the same bundle means both .docx files share the existing assets/figures/, the QA report lands under assets/meta/reports/, and downstream tools reading manifest.json see the blog deliverables alongside everything else with no path swap.
  3. 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 the paper2assets default convention, so if you invoke paper2assets in 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.md before drafting article copy.
  • Read references/output-contract.md before creating the .docx.
  • Read references/image-guidelines.md before selecting, cropping, editing, or prompting images — it carries the figure crop-review procedure (scripts/crop_figure.py).
  • Read references/review-checklist.md before final delivery.
  • Read references/case-study-acl2026.md only when a concrete style example is useful.

Workflow

  1. Gather inputs:

    • Paper PDF or source, title, authors, venue, links, code/repo links.
    • Existing paper2assets package 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, and assets/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.
  2. Extract figures, text, and captions from the PDF:

    • If the working folder already has a shared paper2assets package, read it first:

      • manifest.json for file locations and counts.
      • assets/meta/sections.json for 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.txt for source verification and details not captured by the sections.
    • If starting from a PDF and no shared package exists yet, initialize one into $BLOG_OUT directly (resolved per the Output Contract above) — re-using $BLOG_OUT as paper2assets' --outdir keeps every paper2* skill writing into the same bundle root, so figures/captions/spec/narration that paper2assets produces sit right next to the .docx files this skill will later write. From the repo root:

      python skills/paper2assets/scripts/build_package.py <paper.pdf> --outdir "$BLOG_OUT"
      

      After paper_spec.md or 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 paper2assets is not available; new cross-skill workflows should initialize paper2assets first. $BLOG_OUT is the value you resolved in the Output Contract above — the same bundle root that will later carry blog_zh.docx / blog_en.docx.

      outdir is 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 figure
      • assets/meta/figures.json — manifest with each figure's file, page, width, height, caption_label, and caption
      • assets/meta/captions.json — every "Figure N: …" / "Table N: …" caption keyed by label
      • assets/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.json to 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.

  3. 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.json captions).
  4. 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.
  5. 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 from assets/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.
  6. 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 bundled scripts/crop_figure.py.
    • For every selected figure, run the two safe automated passes — decaption (strip any baked-in caption sliver) then autotrim (strip excess border whitespace) — then judge by eye and fix any neighbor-bleed or thick caption strip with an explicit box crop.
    • Read references/image-guidelines.md for 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.
  7. 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.md for the per-language voice (it carries both the Chinese WeChat register and the English research-blog register).
  8. Create the two .docx files:

    • Output location and names: write final deliverables to $BLOG_OUT/blog_zh.docx and $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 _zh document, 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. See references/output-contract.md for 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-docx is available, scripts/build_wechat_docx.py can assemble each document from a JSON outline — run it once per language with its own outline and output path. Pass --lang zh for the Chinese document and --lang en for the English one so each gets the right font (_zh → 微软雅黑 / Microsoft YaHei for Chinese, Arial for Latin; _en → Arial for Latin text). If --lang is omitted the script infers it from the blog_zh.docx / blog_en.docx output 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 .docx files.
  9. 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" --strict
      

      The gate checks that both .docx files 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.json with 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 .docx files. 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.

  1. Title and subtitle.
  2. Two to three lead paragraphs:
    • what people usually think or do,
    • why the paper's question matters now,
    • the paper's main idea.
  3. First overview figure and caption.
  4. Source links: paper and code when available.
  5. Background/problem section.
  6. Method or contribution section, often split into named components.
  7. Figure-led evidence sections.
  8. Compact result table only if it improves scanability.
  9. 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.py is 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.py is the corrective tool for the crop-review step (Workflow step 6). It always backs up to <file>.png.bak before writing and keeps figures.json dimensions in sync. Full procedure and command reference live in references/image-guidelines.md.
  • build_wechat_docx.py assembles one .docx from a JSON outline (paragraphs, headings, embedded figures, captions, tables). Run it once per language with --lang — a _zh outline → $BLOG_FINAL/blog_zh.docx --lang zh (font: 微软雅黑 for Chinese, Arial for Latin) and an _en outline → $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.py validates the final bilingual package and rendered layout previews. Run it with --strict before delivery and iterate until it passes.
  • Python package dependencies are listed in requirements.txt; extract_pdf.py also needs the Poppler pdftotext executable on PATH.
将HTML页面(特别是A0会议海报)转换为可编辑的PPTX,保留文本、图片和样式。支持通过无头浏览器提取DOM元素并映射为OOXML形状,实现高视觉保真度。
HTML to PPT poster to PowerPoint editable PPT from HTML pptx from html 1:1 PPT clone
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 .pptx is 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.htmlmy_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:

  1. Auto-detects canvas from CSS @page { size: ... }, caps to 55" if poster > 56" (PPT limit) with proportional scale
  2. Auto-detects sibling poster.pdf (if present): runs pdffonts on 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 via atexit.
  3. Auto-downloads any other HTML-referenced fonts from Google Fonts (font_resolver.py)
  4. Extracts DOM (one playwright run, cached as <name>_dom.json)
  5. Builds pptx → renders via soffice → PNG
  6. Saves comparison PNGs: <name>_html_print.png (print viewport, matches PPT canvas) and <name>_html_browser.png (1920×1080 browser viewport, fullPage)
  7. Vision-audit (ON by default): calls Claude vision to diff <name>_html_print.png vs <name>.png, writes <name>_audit.json with 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-6 for ~$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-model is explicitly passed to claude -p --model so 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:

  1. Builds + audits (via an ISOLATED COPY of scripts/ under output/<ts>/_skill_run_copy/)
  2. Picks top actionable issue from the audit (category whitelist filters out vision-prone false positives like alignment_off)
  3. Spawns claude -p subagent, sandboxed to the copy (--add-dir <copy>, only Read/Edit/Grep/Glob tools — no Bash, no git, can't escape)
  4. Subagent reads GOTCHAS.md + the copy's html_to_pptx.py, makes a minimal diff
  5. Rebuild + re-audit
  6. If issue count dropped → keep change in the copy's internal git Otherwise → git reset --hard in the copy → try next issue
  7. 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 PPT
  • output/<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 (browser extractWrapLines with CSS line-height threshold), not after- the-fact. The --rounds / --shrink-step flags 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:

  1. Sibling poster.pdf exists (or --reference-pdf flag): the skill extracts the PDF's actual fonts via pdffonts, builds an alias map (e.g. CSS Inter → PDF Arimo), 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.

  2. No sibling PDF: the skill walks all font-family declarations 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.

  3. CSS / Mac / Win system fonts without a Google Fonts equivalent (Helvetica Neue, SF Pro, Menlo, etc.) are mapped via FONT_ALIASES in html_to_pptx.py to 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 --corrections legacy, --match-wrap to force browser wrap positions). Supports ::before/::after pseudo-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.pycanonical 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.pyautonomous fix loop: copies skill scripts to an isolated output/<ts>/_skill_run_copy/, spawns claude -p per 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_spacing only works with correct fonts
  • G5: CSS color-mix returns oklab — normalize via canvas
  • G6: isInlineOnly tag-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: ::marker is 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/::after invisible to DOM walkers — fetch via getComputedStyle(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).
将论文结构化规格渲染为单页HTML学术海报,经迭代填充适配固定版面后,导出打印级PDF、PNG缩略图及可编辑PPTX。需依赖上游技能生成资产包,自动处理音频与Logo排版。
render the poster make the poster from this spec export poster to PDF paper2poster
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:

  1. Every card figure fills 90–100% of its section on at least one axis (width OR height). check_poster.py polish reports this as FIG/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 in references/staged_fill.md. Do not exit the staged-fill loop with any figure below 90%.

  2. Every section reads FULL (fullRatio 0.90–1.00). check_poster.py slack reports this. OVERFLOW (>1.10), SPILLAGE (1.00–1.10), SPARSE (0.70–0.90), and EMPTY (<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 other claude / python / chromium / soffice processes 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.html appears 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/*.html is 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.html missing → start from Step 2 fill loop; only poster.pdf / poster.png missing → 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.html first 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:

  1. paper2assets's Step 6 was skipped, or
  2. paper2assets's Step 6 was called with the wrong CLI flags (the --spec / --outdir <outdir>/assets/logos traps documented in paper2assets SKILL.md Step 6 caused most papers to land logos at <outdir>/assets/logos/logos/<slug>.png instead 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, and layout from figures.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-checkdecaptionautotrim) 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:

  1. Read the figure with the Read tool.
  2. Decide a tight pixel bbox (X0, Y0, X1, Y1).
  3. Apply:
    python ~/.claude/skills/paper2assets/scripts/crop_figure.py box <outdir>/assets/figures/<file>.png --box X0 Y0 X1 Y1
    
    This writes a one-time <file>.png.bak (or preserves the existing one from paper2assets' earlier work) and updates figures.json.
  4. Re-Read the result. Cap at 3 attempts per figure; restore from .bak with cp if 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}.htmlSTRUCTURE (column grid + .section cards + base CSS),
  • assets/styles/{solid,framed,simple}.cssVISUAL 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 pv1pv5, 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:

  1. 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.
  2. 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 sets POSTER_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 a half template'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):

  1. full (landscape --layout full; portrait layouts_portrait/full.html) — use when the Method figure is horizontally wide: in landscape AR ≥ 2.5, in portrait AR ≥ 1.2. Also use when {column=full} in figures.json. In landscape, this picks a 4-col outer grid with the middle two columns merged into a .mid-wide block. 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 as bullets_content_h / row_h. When the figure stretches the row taller than the bullets need, the verdict surfaces method-text SPARSE and 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.

  2. half (landscape --layout half; portrait layouts_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: full if AR ≥ 2.5 OR {column=full}, else half.
  • Portrait: full if AR ≥ 1.2 OR {column=full}, else half. (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-accent by 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 class mhue to <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 (default 42cqh). Raise/lower --method-fig-max to 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). .msubs is 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.talltall card spanning 2 rows (the two normal cards flow the other column)
    • add cols-3 to .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 pack pre-check; measure every .msub with slack (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.

  1. solid — the classic look: solid-filled accent titlebar (accent bg, white text), section h2 = colored accent text with a thin underline. Sections are quiet warm cards with a top accent stripe.
  2. framed — the editorial look: outlined rounded titlebar frame (white bg, accent border + accent title), section h2 = solid accent-filled banner edge-to-edge; white section cards with a neutral thin frame.
  3. 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.
  4. left-bar — thick accent rail down the card's left edge; plain accent heading.
  5. elevated — floating white card with a soft deep shadow, rounded corners.
  6. neo-brutal — hard black border + offset accent drop-shadow; uppercase headings.
  7. tag — heading rendered as an inline accent pill.
  8. underline — bold accent rule hugging the heading.
  9. tinted — faint accent-tinted card background.
  10. double-rule — centered heading between two thin accent lines.
  11. 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 (pv1pv5, 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):

  1. v1 — venue (left) · title (center) · institution logos (right). Symmetric 3-zone band.
  2. v2 — mirror of v1: institution logos (left) · title (center) · venue (right).
  3. v3 — title centered full-width with a single equal-height logo strip below (conference mark + institutions).
  4. v4 — title (left) · venue + institution logos stacked (right). A common GT layout.
  5. v5classic: 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 standalone scan-to-read section (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 randomcompose_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 retiredcompose_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:

  1. Compose the template (disk-to-disk, zero output tokens — do NOT Write it):
    # 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.html
    
    compose_poster.py resolves the STRUCTURAL hooks ({{STYLE_CSS}}, {{HEADER}}, and landscape-only {{SCAN_SECTION}}) AND the COLOR axis (rewrites the :root accent vars to the resolved theme); every CONTENT {{...}} placeholder survives for the next step. --scan: pass the GROUP keyword dual only when make_qr.py emitted TWO QR slots (two genuinely distinct URLs survived de-duplication), else single — 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 random or an explicit variant name also work. QR-count guard (belt-and-suspenders): if you pass a single-QR context (single, or an explicit hero/contact/banner) but metadata.json actually carries two QR files on disk, compose_poster.py auto-upgrades to the dual group so the second (project/code) QR is never silently dropped — still pass dual explicitly when a code QR resolved. --theme: default random picks one of 8 academic themes DETERMINISTICALLY from the output-path hash (reproducible spread across a wave) — do NOT hand-edit :root colors; override a specific one with --theme <name> or POSTER_THEME. --header: default random (landscape v1-v5 / portrait pv1-pv5). --math: the math-typesetting engine, default katex (thinner glyphs, posterskill-like) — the ONE place to switch is MATH_ENGINE_DEFAULT in compose_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).
  2. Substitute placeholders with the Edit tool, one {{...}} token (or one section block) at a time — each Edit emits only the small placeholder plus your paper-specific content, never the surrounding template. Never reconstruct and Write the 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}}. Set VENUE_LOGO to assets/logos/_venue.png only 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 --scan axis (Step 3): pass --scan dual when 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 from metadata.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 — with v5, ALWAYS fill {{HDR_QR_PAPER}} / {{HDR_QR_CODE}} and leave the section {{QR_*}} empty, so the QR shows once (in the v5 header) and the scan-to-read section 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/*.png is absent — set a path only if the file exists.
  • Logo autotrim: fetch_logos.py / fetch_conf_logo.py now 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.8never 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-soft 9 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-stat for sections dominated by one number, vs-compare for Theirs/Ours sections, numbered-steps for pipelines, chips for 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's Additional) 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 SPARSESPILLAGE. 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-tts package: no API key, no config file, just network. Default voice en-US-AndrewNeural; narration.json carries "provider": "edge". Override per run with --provider azure (needs ~/.azure/speech.json + AZURE_API_KEY) or --voice <name>. See references/audio_narration.md.
  • Graceful skip: if edge-tts isn'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 PLAYLIST in sync: the template's PLAYLIST (and the per-section data-section ids) must match the clip ids in <outdir>/assets/audio/. The Listen buttons + Full Listen play assets/audio/<id>.mp3 by 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, keep PLAYLIST in 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 v1v4 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.5 for a larger thumbnail, 0.2 for smaller.
  • --mathjax-timeout-ms 15000 if 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.html into <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 own scripts/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's generate_audio.py from narration.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 paper2assetspaper2poster (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>.html4-column grid for half-width Method figures. Default landscape layout; Method is a half-width card alongside the other sections.
  • poster_full_<style>.html4-column outer grid with the middle two columns merged into .mid-wide for wide / full-width Method figures (AR ≥ 2.5 OR {column=full}). The .mid-wide block 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-sub carrying Dataset + Key Result.

Portrait (33.1×46.8in A0, 0.708):

  • layouts_portrait/half.html2-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.html5-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 by slack.py — when the figure stretches the row taller than the bullets need, the staged-fill loop sees method-text SPARSE and 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 equation widget (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 from paper_spec.md (Method's Key equation subfield, 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-note AND a <div class="supporting"> row holding at minimum 2 .stat-mini tiles (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 in figures/). Their numbers flow into Headline Numbers and Key Result necessary 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 from captions.json — a bare, unlabeled figure is a defect (check_poster.py preflight warns 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 | Metric table 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. See content_patterns.md P12.
  • No fabrication. Every number, claim, and figure caption must trace to text.txt (produced by paper2assets) or figures.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.html through your output channel. It's ~100 KB; a full-file Write (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 (shell cp the template + surgical Edits for placeholders, or a build_poster.py generator — see Step 3), and keep every fill-loop change a partial Edit, never a full rewrite.
  • Never re-Read the whole poster.html during 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 the slack report — it now prints an EDIT TARGETS block with the verbatim source of every off-band section, so lift your Edit old_string straight from there and never re-read the whole file. See staged_fill.md rule 6.
  • Lean render first, measured fill second. Don't guess at fit during placeholder substitution. Render the core sections' Necessary only — Problem, Motivation, Method (with the key equation), Key Result, Headline Numbers, Takeaway — then let check_poster.py slack decide 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 equation widget — 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 (polish reports zero FIG/NARROW). There is no per-column SPARSE allowance — a SPARSE card 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 deliverables gate in Step 7.5 is non-negotiable: run it before reporting, loop until exit 0.
  • .grow cards are gated, not exempted. A .grow card 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-mini tiles, but also .p-stat-strip cells, any ad-hoc number-card row in Motivation / Method / Key Results, and any future tile widget.
    • The template now structurally backstops this: .headline-hero .supporting uses align-items: flex-start (all .val numbers top-align so they always sit at the same height regardless of label height) and .stat-mini .lbl carries min-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 like M / B, acc for 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.
  • Do not edit the canvas lock. The 60×36in / cqw / aspect-ratio: 5 / 3 rules 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 box cut is for residual asymmetric noise only.
将研究论文、paper2assets包或PPT转换为带旁白和字幕的MP4视频。协调TTS、渲染及字幕生成,确保与paper2poster等工具保持叙述顺序一致,输出包含视频、音频、幻灯片及元数据的完整资产包。
需要将学术论文转换为演示视频 请求生成带旁白的MP4文件 基于现有PPT或论文素材制作视频
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:

  1. An explicit <video_outdir> argument from the caller wins — honor it verbatim. The defaults below only fire when no path was passed.
  2. A paper2assets package 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 by paper2assets Step 4); <dir>/manifest.json with "layout": "v2-assets" is a confirming hint when present. Writing into the same bundle means Route A reads assets/meta/narration.json from the same root it writes assets/audio/*.mp3 into, with no path swap — and downstream tools that walk manifest.json see the video MP4s alongside everything else.
  3. 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 the paper2assets default convention, so if Route A invokes paper2assets's build_package.py below 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.

  1. 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"
  1. 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:

  1. Plan narration against --target-minutes before TTS. If the current script is clearly too long or too short, the helper writes duration_rewrite_request.json and 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.
  2. After the first audio/video render, run plan_tts_rate.py against the real duration report. Use the generated rate plan only for small residual timing errors. If the plan says needs_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"
  1. Create or provide a PPTX whose slide order matches the script sections. This can come from ppt-master, a future paper2slides, or a user-provided deck. The video compositor only requires a PPTX plus matching MP3s, but the default high-quality route is ppt-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.md and 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, and svg_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 example cue_s08_c2_multi_head_attention.
  • Put the same id and narration keywords in PPTX shape name/alt text and in SVG <title>, <desc>, or data-cue-label; with --anchor-contract, the cue matcher requires exact anchor_id matches.
  • 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_final and the exported PPTX. The video raster frames are rendered from svg_final when available, while the PPTX remains the editable deliverable and geometry audit source.
  1. Generate audio:
python skills/paper2poster/scripts/generate_audio.py \
  "$VIDEO_AUDIO/script.json" \
  --outdir "$VIDEO_AUDIO"
  1. 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"
  1. 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"
  1. 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.

  1. 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:

  • edge is the default free backend (edge-tts, no API key; default voice en-US-AndrewNeural).
  • azure is opt-in and requires the Azure config/API key expected by paper2poster's script; Azure voices are alloy, 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:

  1. Prefer svg_final/*.svg -> PNG frames via Playwright/Chrome. If no SVG deck exists, or --frame-source pptx is explicitly set, use the legacy PPTX -> PDF -> PNG path via LibreOffice and pdftoppm.
  2. Copy the exact MP4 frames to --frames-out when provided; final QA should point --frames-dir at that same directory.
  3. MP3 duration probing via ffprobe or the ffmpeg fallback.
  4. One MP4 segment per slide.
  5. 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.json for legacy bundles.
  • Secondary fallback: <audio-dir>/manifest.json.
  • Last resort: sorted *.mp3 filenames.

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_FFPROBE if 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_laser is the production default: a feathered spotlight plus a small red laser-pointer dot at the accepted cue center.
  • box renders a subtle slate fill plus border around the accepted box.
  • cursor renders only a soft presentation pointer at the cue center.
  • box_cursor combines the accepted box with the same pointer, useful for reviewer comparisons.
  • spotlight_cursor combines the feathered spotlight with the same mouse pointer, useful when a visible arrow is preferred over the laser dot.
  • laser renders 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, and spotlight_laser softly 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:

  1. If generate_visual_cues.py --strict-gate exits non-zero, do not render a highlighted video yet.
  2. Treat cue_repair_requests.md, cue_audit.html, visual_cue_plan.json, and slide_regions.json as agent debugging inputs, not user homework.
  3. 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 requested anchor_id to the intended PPTX shape metadata and rerun export.
  4. Re-run ppt-master post-processing/export when SVGs changed, rerun the pre-PPTX cue pass, rerun inject_pptx_anchors.py, then rerun generate_visual_cues.py --strict-gate --require-pptx-anchors.
  5. 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.
  6. While repairing, record a REPAIRING note in $VIDEO_OUT/manifest.json or $VIDEO_META/reports/video_qa_report.json; promote to final ERROR only 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/*.md paired with sorted audio/*.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-check passes.
  • ffprobe reports a positive duration for the final MP4.
  • check_video_package.py --strict passes and writes video_qa_report.json.
  • If visual attention is enabled, check_video_package.py --strict-attention passes with --require-visual-cues --require-cue-plan --require-timeline --require-word-timings.
  • timeline.json exists and every chunk has the expected audio window, subtitle cues, and accepted visual cue before paper2reel consumes it.
  • If subtitles are requested, add_subtitles.py uses the same --start-pad, --pad-tail, and --script-json as render_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.

首页 - Wiki
Copyright © 2011-2026 iteam. Current version is 2.155.2. UTC+08:00, 2026-07-20 14:18
浙ICP备14020137号-1 $访客地图$