diff --git a/ResearchStudio-Reel/skills/paper2video/SKILL.md b/ResearchStudio-Reel/skills/paper2video/SKILL.md index aa219ba..3aa7a37 100644 --- a/ResearchStudio-Reel/skills/paper2video/SKILL.md +++ b/ResearchStudio-Reel/skills/paper2video/SKILL.md @@ -5,8 +5,8 @@ description: > 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 + authoring to the external `hugohe3/ppt-master` project, then synthesize final + captioned audio and word boundaries with `generate_edge_audio.py`, render with `skills/paper2video/scripts/render_video.py`, and burn final subtitles with `skills/paper2video/scripts/add_subtitles.py`. --- @@ -22,7 +22,7 @@ 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 + -> fresh MP3 + word timings from generate_edge_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 @@ -61,7 +61,7 @@ clips, rendered frames, reports, and timeline/cue metadata live under `assets/`: ```text / - video.mp4 # required, burned-in subtitles with translucent caption box + video.mp4 # required, burned-in subtitles in a short appended bottom band video_no_subtitles.mp4 # required, raw/pre-subtitle playback copy for paper2reel video.pptx # required, for follow-up editing manifest.json @@ -70,7 +70,7 @@ clips, rendered frames, reports, and timeline/cue metadata live under `assets/`: 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 + meta/ # duration/timeline/cue/animation manifests and QA reports ``` Initialize it before running the route: @@ -109,7 +109,7 @@ 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 +## Three Supported Routes ### Route A - paper2assets-aligned paper video @@ -247,6 +247,25 @@ 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`. +After ppt-master exports the native animated deck, seed the editable protocol +with the bundled deterministic bootstrap. Work on an output copy so the source +export remains available for audit: + +```bash +python skills/paper2video/scripts/bootstrap_editable_pptx.py \ + \ + --script-json "$VIDEO_AUDIO/script.json" \ + --out "$VIDEO_SLIDES/slides.pptx" \ + --report-out "$VIDEO_META/reports/editable_pptx_bootstrap_report.json" +``` + +This writes concise canonical `## [handle]` Author Notes plus matching compact +shape Alt Text containing only `[handle]` and `Script:`. Script hash and order +provenance stay in shape OOXML; effects and triggers stay in the native +PowerPoint timing tree. Legacy `[ID] handle` and verbose `[Paper2Video]` Alt Text +remain readable but are no longer written. This replaces package-local or +session-specific post-processing scripts. + Add this requirement block to the prompt given to `ppt-master`: ```text @@ -298,14 +317,33 @@ Anchor contract for ppt-master: video raster frames are rendered from `svg_final` when available, while the PPTX remains the editable deliverable and geometry audit source. -4. Generate audio: +4. Generate fresh caption-ready audio and word timings: ```bash -python skills/paper2poster/scripts/generate_audio.py \ +python skills/paper2video/scripts/generate_edge_audio.py \ "$VIDEO_AUDIO/script.json" \ - --outdir "$VIDEO_AUDIO" + --outdir "$VIDEO_AUDIO" \ + --timings-out "$VIDEO_AUDIO/word_timings.json" ``` +When the processed deck carries the named-animation Author Notes protocol, +build the strict video mapping directly from that PPTX and fresh Edge word +timings: + +```bash +python skills/paper2video/scripts/build_animation_manifest.py \ + --pptx "$VIDEO_SLIDES/slides.pptx" \ + --word-timings "$VIDEO_AUDIO/word_timings.json" \ + --protocol-report-out "$VIDEO_META/reports/editable_pptx_protocol.json" \ + --out "$VIDEO_META/animation_manifest.json" +``` + +This mapping reads the native row kind, pane order, shape id, authoritative +Author Notes marker, and normalized compact Alt Text from the current deck. It +derives MP4 start times from Edge word boundaries and records the exact PPTX +SHA-256. See +`references/editable_pptx.md` and `references/animations.md`. + 5. For highlighted video, generate PPTX-backed visual cues before rendering. Run one cue-planning pass to locate the authored SVG anchors, inject those @@ -357,6 +395,11 @@ python skills/paper2video/scripts/render_video.py "$VIDEO_OUT" \ --attention-mode highlight \ --highlight-style spotlight_laser \ --visual-cues "$VIDEO_META/visual_cues.json" \ + --frame-source pptx \ + --animation-source pptx \ + --animation-manifest "$VIDEO_META/animation_manifest.json" \ + --animation-report-out "$VIDEO_META/reports/animation_render_report.json" \ + --require-animations \ --target-minutes 3 \ --duration-report-out "$VIDEO_META/video_duration_report.json" \ --out "$VIDEO_CLIPS/video_raw.mp4" \ @@ -373,15 +416,18 @@ python skills/paper2video/scripts/add_subtitles.py "$VIDEO_OUT" \ --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. Use -`--subtitle-bar` to scale the complete slide above a solid black caption band -when captions must not overlap any PPT content. +The default burned-in subtitle render leaves the slide at its original size, +appends a short solid black band only below it, and places white captions +entirely inside that reserved space. It does not add side bars or cover PPT +content. Use `--subtitle-overlay` only for an explicitly approved legacy +overlay render; combine it with +`--no-subtitle-box` only when plain outlined captions are required. Use `--no-subtitles` when the user disables captions. It still writes SRT/VTT for timeline and QA, but stream-copies only video/audio into `video.mp4`. +```bash cp "$VIDEO_CLIPS/video_raw.mp4" "$VIDEO_OUT/video_no_subtitles.mp4" cp "$VIDEO_SLIDES/slides.pptx" cp "$VIDEO_OUT/video.pptx" @@ -455,12 +501,14 @@ python skills/paper2video/scripts/notes_to_script.py \ --out /audio/script.json ``` -Generate audio: +Generate fresh audio plus the word boundaries required by the default captioned +delivery: ```bash -python skills/paper2poster/scripts/generate_audio.py \ +python skills/paper2video/scripts/generate_edge_audio.py \ /audio/script.json \ - --outdir /audio + --outdir /audio \ + --timings-out /audio/word_timings.json ``` Render: @@ -486,22 +534,46 @@ python skills/paper2video/scripts/add_subtitles.py \ --mp4 "$VIDEO_CLIPS/video_raw.mp4" \ --audio-dir /audio \ --script-json /audio/script.json \ + --word-timings /audio/word_timings.json \ + --require-word-timings \ + --timing-report-out "$VIDEO_META/reports/subtitle_timing_alignment.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. Use -`--subtitle-bar` to scale the complete slide above a solid black caption band -when captions must not overlap any PPT content. +The default burned-in subtitle render leaves the slide at its original size, +appends a short solid black band only below it, and places white captions +entirely inside that reserved space. It does not add side bars or cover PPT +content. Use `--subtitle-overlay` only for an explicitly approved legacy +overlay render; combine it with +`--no-subtitle-box` only when plain outlined captions are required. Use `--no-subtitles` when the user disables captions. It still writes SRT/VTT for timeline and QA, but stream-copies only video/audio into `video.mp4`. +```bash cp "$VIDEO_CLIPS/video_raw.mp4" "$VIDEO_OUT/video_no_subtitles.mp4" cp /exports/.pptx "$VIDEO_SLIDES/slides.pptx" ``` +### Route C - local editable PPTX rerender, no LLM + +Use the embedded [`ppt2video`](ppt2video/SKILL.md) sub-skill after a user edits +the delivered `video.pptx`. Read that sub-skill before rendering or changing +the editable-PPTX protocol. It owns the Author Notes and Alt Text authority +rules, animation mapping, and strict completion gate. + +```bash +python skills/paper2video/scripts/render_edited_pptx.py \ + +``` + +The command delegates to `scripts/render_edited_pptx.py`, regenerates audio and +timings, renders the current PPTX, burns bottom-band subtitles, writes the +timeline and mapping reports, then runs strict QA. It refuses an existing +output bundle, so an old audio/video/cache package cannot be reused silently. +Do not claim success unless it exits 0 and its QA report is clean. + ## Final QA Gate Run the final hard QA gate for either route. This is not a smoke test; it checks @@ -520,6 +592,7 @@ python skills/paper2video/scripts/check_video_package.py "$VIDEO_OUT" \ --mp4 "$VIDEO_OUT/video.mp4" \ --raw-mp4 "$VIDEO_OUT/video_no_subtitles.mp4" \ --subtitle-file "$VIDEO_CAPTIONS/video.vtt" \ + --subtitle-timing-report "$VIDEO_META/reports/subtitle_timing_alignment.json" \ --visual-cues "$VIDEO_META/visual_cues.json" \ --cue-plan "$VIDEO_META/visual_cue_plan.json" \ --timeline "$VIDEO_META/timeline.json" \ @@ -527,6 +600,7 @@ python skills/paper2video/scripts/check_video_package.py "$VIDEO_OUT" \ --target-minutes 3 \ --require-rate-plan \ --require-subtitles \ + --require-subtitle-word-alignment \ --require-visual-cues \ --require-cue-plan \ --require-timeline \ @@ -547,11 +621,14 @@ python skills/paper2video/scripts/check_video_package.py "$VIDEO_OUT" \ --mp4 "$VIDEO_OUT/video.mp4" \ --raw-mp4 "$VIDEO_OUT/video_no_subtitles.mp4" \ --subtitle-file "$VIDEO_CAPTIONS/video.vtt" \ + --subtitle-timing-report "$VIDEO_META/reports/subtitle_timing_alignment.json" \ --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" \ + --animation-manifest "$VIDEO_META/animation_manifest.json" \ + --animation-report "$VIDEO_META/reports/animation_render_report.json" \ --target-minutes 3 \ --strict \ --strict-attention \ @@ -562,7 +639,9 @@ python skills/paper2video/scripts/check_video_package.py "$VIDEO_OUT" \ --require-timeline \ --require-rate-plan \ --require-subtitles \ + --require-subtitle-word-alignment \ --require-word-timings \ + --require-animations \ --out "$VIDEO_META/reports/video_qa_report.json" ``` @@ -585,7 +664,8 @@ named degraded path. ## Audio Providers -The current shared synthesizer is: +The shared paper2poster synthesizer remains available for caption-free or +non-video consumers: ```bash python skills/paper2poster/scripts/generate_audio.py --outdir @@ -613,12 +693,13 @@ It consumes JSON with the same section contract used 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 `.mp3` per script section under the chosen `audio/` directory. +The compositor accepts MP3s from any provider. A final captioned render also +requires trustworthy per-word start/end boundaries in the documented timing +schema; an MP3-only provider is valid only for explicitly caption-free output. -When strict visual-attention alignment is required and Edge TTS is acceptable, -use the bundled Edge helper because it can write word-boundary timings: +For every final captioned render, use the bundled Edge helper because it writes +the word boundaries required for exact subtitle timing. The same timings also +support strict visual-attention alignment: ```bash python skills/paper2video/scripts/generate_edge_audio.py \ @@ -627,9 +708,9 @@ python skills/paper2video/scripts/generate_edge_audio.py \ --timings-out /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. +Those timings let `add_subtitles.py --require-word-timings`, +`generate_visual_cues.py --require-timestamps`, and strict QA reject any +subtitle or highlight plan that uses proportional timing. ## Rendering Details @@ -640,9 +721,14 @@ use proportional/estimated timing. 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. +3. When an editable PPTX animation manifest is present, render cumulative + PowerPoint reveal states and derive each layer from adjacent PPTX pixel + states. The optional SVG authoring route remains available for legacy + packages. +4. MP3 duration probing via `ffprobe` or the ffmpeg fallback. +5. One MP4 segment per slide. +6. ffmpeg concat into a final H.264/AAC MP4 and write the persistent animation + render report when animations are enabled. Audio ordering: @@ -680,6 +766,10 @@ Useful flags: | `--highlight-style box|spotlight|cursor|box_cursor|spotlight_cursor|laser|box_laser|spotlight_laser` | Presentation style for highlight cues; default `spotlight_laser` | | `--visual-cues path.json` | Normalized per-slide highlight/cursor cue file | | `--allow-missing-visual-cues` | Degraded/debug only; final output should not use it | +| `--animation-manifest path.json` | Edge-aligned Author Notes effect/group mapping | +| `--animation-source auto|svg|pptx` | Pixel source for animation layers; editable manifests select `pptx` | +| `--animation-report-out path.json` | Persist rendered effect timing, strategy, and bbox evidence | +| `--require-animations` | Fail unless a non-empty supported animation mapping is rendered | | `--frames-only` | Stop after slide-frame export | | `--audio-only-check` | Verify frame/audio count and order | @@ -933,15 +1023,27 @@ the viewer can show duplicate subtitles when CC is enabled. `add_subtitles.py` can use either notes files or script JSON: - With `--script-json`, subtitle order and fallback text come from the JSON. +- With `--word-timings`, every cue is matched against the complete Edge word + sequence. It appears at its first spoken word and remains through its last + spoken word, including irregular pauses. Punctuation attaches to neighboring + words and receives no estimated interval. +- Pass `--require-word-timings` for every final captioned render. Missing, + mismatched, or partially aligned words fail closed. Character-proportional + cue allocation remains available only for explicit legacy/debug use. - 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. Pass `--subtitle-bar` to preserve -the complete slide above a solid black bottom band and burn white captions -inside that reserved band; this avoids covering dense PPT content. +Default mode leaves the slide at its original size, appends a short solid +black band only below it, and burns white captions entirely inside that band. +The default bar height is `0.08` of the source slide height. Pass `--soft` to +mux a toggleable `mov_text` track instead. Default cue chunking uses a hard +72-character cap so 1080p delivery stays on one subtitle line. Burned subtitle +text scales with the source-slide height by default, about 32 ASS units at 720p +and 48 at 1080p, and remains vertically centered with padding inside the band. +Pass `--font-size` only when an explicit fixed size is required. Pass +`--srt-only` to produce just the SRT. Pass `--subtitle-overlay` for the legacy +over-slide layout, and combine it with `--no-subtitle-box` only for a +user-approved plain-caption render. Pass `--no-subtitles` to keep the public MP4 caption-free while still writing the SRT/VTT timing sidecars required by the internal timeline and QA. @@ -957,13 +1059,26 @@ Before calling the video done: - If visual attention is enabled, `check_video_package.py --strict-attention` passes with `--require-visual-cues --require-cue-plan --require-timeline --require-word-timings`. +- If named Author Notes animations are enabled, strict QA passes with + `--animation-manifest --animation-report --require-animations` and reports + complete mapping plus pixel-motion coverage for every effect. +- For editable `source_kind: pptx`, strict QA also proves the delivered deck, + manifest, and render report share one SHA-256 and exact shape-id mapping. - `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`. + `--pad-tail`, and `--script-json` as `render_video.py`, writes + `subtitle_timing_alignment.json`, and strict QA passes with + `--require-subtitle-word-alignment`. ## 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. +- `references/animations.md` - PPT Master/native/MP4 animation ownership, + supported-effect matrix, timing alignment, and strict QA contract. +- `references/editable_pptx.md` - no-LLM local rerender protocol and verified + add/delete/modify behavior. +- `ppt2video/SKILL.md` - embedded general PPTX-to-video sub-skill and editable + rerender workflow. diff --git a/ResearchStudio-Reel/skills/paper2video/ppt2video/SKILL.md b/ResearchStudio-Reel/skills/paper2video/ppt2video/SKILL.md new file mode 100644 index 0000000..9dbe316 --- /dev/null +++ b/ResearchStudio-Reel/skills/paper2video/ppt2video/SKILL.md @@ -0,0 +1,168 @@ +--- +name: ppt2video +description: Render any local PowerPoint PPTX into a fresh narrated video bundle with native or script-timed object animations, Notes-first and Alt-Text-fallback narration, optional user script overrides, spotlight cues, bottom-band subtitles, editable source delivery, timeline metadata, and strict QA. Use for ordinary presentations as well as Paper2Video decks, when a user has edited a PPTX, needs to rerender an animated deck without an LLM or ppt-master, or needs the Author Notes and Alt Text protocol. +--- + +# Convert an edited PPTX to video + +This is a general-purpose PowerPoint-to-video tool embedded in `paper2video` so +both skills can share one deterministic rendering engine. It is not limited to +research papers or to a previously delivered `video.pptx`. Use any local PPTX +as the visual source. Prefer canonical Author Notes for precise narration and +marker timing. When canonical Notes are absent, read explicit Shape Alt Text +`Script:` fields. Do not reuse audio, video, or cache files from an older +bundle. + +## Authoring contract + +For precise control, write a Notes block whose handle matches a top-level shape +or group: + +```text +## [latency-card] latency-card +[[Fly In]] A new latency card appears. [[Spotlight]] It reports lower latency. +``` + +Use the same bracketed handle as the first Alt Text line. Alt Text stays compact +so a PowerPoint user sees only the editable handle and script: + +```text +[latency-card] +Script: [[Fly In]] A new latency card appears. [[Spotlight]] It reports lower latency. +``` + +The baseline hash and generated ordering provenance live inside the PPTX shape +OOXML. Native effect, target, trigger, and delay data stay in PowerPoint's +`p:timing` tree. They do not appear in Alt Text. Older verbose +`[Paper2Video]` blocks remain readable and migrate to this two-line form on the +next writeback. Keep Author Notes clean. Read +[../references/editable_pptx.md](../references/editable_pptx.md) for the +hidden provenance contract. + +The stable handle resolves the target directly, so Notes order controls spoken +order and does not need to copy Animation Pane order. Marker positions determine +their Edge word-boundary times. A system-generated Notes block is inserted by +row-aware canvas order: top-to-bottom between rows and left-to-right within one +row. A Notes marker may add an MP4 effect even when the shape has no matching +native row. + +For a PPT-native workflow without canonical Notes, put an explicit script in +the shape's Alt Text and use the native Animation Pane for effects: + +```text +[latency-card] +Script: A new latency card appears and reports lower latency. +``` + +Read [../references/animations.md](../references/animations.md) before changing +effect names or resolving a protocol conflict. + +## Authority and conflict rules + +Apply this precedence: + +1. An explicitly selected user `script.json` owns narration for that render. +2. Canonical Author Notes own handles. For narration, compare Notes and Alt Text + with the last system-synchronized script hash stored in shape OOXML. A change + on only one surface wins; if both changed differently, Notes wins and the + authority report records the conflict. +3. Explicit Alt Text `Script:` fields provide narration when Notes are absent or + when their script alone differs from the stored baseline. + A new animated target with only plain pre-protocol Alt Text uses that text + once as its initial script and is normalized to compact `Script:` metadata. +4. Explicit Author Notes order wins. System-generated Notes blocks follow + row-aware spatial order, independent of Animation Pane order. +5. Animation Pane supplies native effects plus `On Click`, `With Previous`, + `After Previous`, and + delay relationships when an explicit Notes marker does not own timing. These + dependencies are recomputed after Notes timing: sequential rows wait for the + prior Notes block's narration and effects, while `With Previous` explicitly + permits overlap. +6. Shape OOXML stores `orderSource` and canonical `orderIndex` beside the script + hash. An explicit Notes reorder promotes the visible sequence to + `author_notes`; otherwise generated blocks remain `geometry`. +7. The PowerPoint canvas owns all visible pixels and geometry. + +When Notes and Alt Text both changed differently, Notes wins and the delivered +PPTX Alt Text is refreshed. When a Notes effect conflicts with a native effect +of the same kind, use the Notes name and time for MP4; preserve non-conflicting native effects. +Fail when a Notes handle cannot be resolved safely or an effect name is +unsupported. + +## One-command render + +```bash +python ResearchStudio-Reel/skills/paper2video/scripts/render_edited_pptx.py \ + path/to/edited.pptx \ + path/to/new_video_bundle \ + --resolution 1080p +``` + +Input and output may be arbitrary local paths. Add +`--script-json path/to/edited-script.json` only when a user-edited external +script should override PPTX narration. This command must: + +1. Normalize compact Alt Text from authoritative Notes when present. +2. Generate handles and compact two-line Alt Text for new animation targets. +3. Backfill canonical Notes when the source has only Alt Text or native rows. +4. Extract narration using user script, Notes, then Alt Text precedence. +5. Generate fresh Edge TTS and word timings, or deterministic silent audio for + a native-only silent slide. +6. Build Notes word timing and Animation Pane trigger/delay mappings. +7. Align every subtitle cue to its actual first and last Edge TTS word + boundaries. Never use proportional timing in a final render. +8. Render PPTX pixels, animations, audio, spotlight, and bottom-band subtitles. +9. Write `timeline.json`, subtitle-alignment evidence, mapping reports, and + strict QA evidence. + +Do not pass `--prebuilt-audio-dir` for a final render. Do not pass `--no-qa`. + +For a change-aware rerender, ordinary users choose whether to keep existing +narration or regenerate only changed elements. The latter requires a previous +PPTX baseline and an OpenAI API key: + +```bash +python ResearchStudio-Reel/skills/paper2video/scripts/render_edited_pptx.py \ + edited.pptx new-bundle \ + --baseline-pptx previous-video.pptx \ + --narration-mode regenerate +``` + +Regenerated scripts are written back into both Author Notes and compact Alt +Text in the delivered PPTX. `script.json` remains an Advanced override, not the +normal editing surface. + +## Ordinary animated PPTX + +If the deck does not yet contain canonical Notes and Alt Text, bootstrap a copy +from an existing narration script, then edit that copy: + +```bash +python ResearchStudio-Reel/skills/paper2video/scripts/bootstrap_editable_pptx.py \ + path/to/animated-source.pptx \ + --script-json path/to/script.json \ + --out path/to/editable-video.pptx \ + --report-out path/to/bootstrap-report.json +``` + +## Completion gate + +Require these deliverables: + +```text +new_video_bundle/ + video.mp4 + video_no_subtitles.mp4 + video.pptx + manifest.json + assets/audio/ + assets/captions/ + assets/meta/timeline.json + assets/meta/reports/author_notes_authority.json + assets/meta/reports/subtitle_timing_alignment.json + assets/meta/reports/video_qa_report.json +``` + +Confirm the subtitle timing report has `status: word_aligned`, then confirm +`video_qa_report.json` has `passed: true`, `error: 0`, and `warning: 0`. +Do not claim completion until the strict renderer exits 0. diff --git a/ResearchStudio-Reel/skills/paper2video/references/animations.md b/ResearchStudio-Reel/skills/paper2video/references/animations.md new file mode 100644 index 0000000..82d0edd --- /dev/null +++ b/ResearchStudio-Reel/skills/paper2video/references/animations.md @@ -0,0 +1,195 @@ +# Author Notes animations in Paper2Video + +Paper2Video uses PowerPoint animation metadata as an authoring contract. The +production editable route renders the current PPTX itself; the legacy/source +authoring route can still render equivalent SVG groups. Neither route records a +PowerPoint playback window. + +## Ownership and timing + +| Layer | Owner | What it stores | +|---|---|---| +| Editable object animation | PPT Master / PPTX OOXML | Shape target, native effect, pane order, duration, trigger | +| Optional authoring overrides | `animations.json` in PPT Master | Per-slide/group effect, order, delay, duration, trigger | +| Named narration mapping | PowerPoint Author Notes | Stable handle, supported MP4 effect name, marker position, transcript block | +| Video timing | `animation_manifest.json` | Stable handle, PPTX shape id or SVG locator, and Edge word-aligned start/duration | +| Render evidence | `animation_render_report.json` | Strategy, global MP4 time, layer bbox, pixel sample times | +| Subtitle timing | SRT/VTT | Caption cues on the same audio clock | + +`svg_to_pptx.py -a auto` writes native object animations into the PPTX. The +optional `animations.json` controls that export but is not a subtitle or video +script. PPT Master's native animation schedule is presentation-oriented; it +does not by itself align every object entrance to a spoken word. + +The Author Notes bridge supplies that missing link. Marker position identifies +the corresponding transcript block and `word_timings.json` supplies Edge TTS +word boundaries. The renderer reconciles Notes blocks with Animation Pane +targets, then writes compact Alt Text containing only the first-line `[handle]` +and `Script:`. The accepted script hash and generated ordering provenance live +in the shape's `p2v:scriptBaseline` OOXML extension, while native animation +target, effect, trigger, delay, and grouping remain in PowerPoint's `p:timing` +tree. Subtitles, spotlight/laser cues, and animations therefore share the same +audio clock without exposing generated metadata in Alt Text or borrowing +timestamps from one another. + +Spotlight has two marker forms: + +| Form | Spoken/captioned text | Video duration | +|---|---|---| +| `[[Spotlight]]` | Text after the marker | Native emphasis duration or 2.4 s default | +| `[[Spotlight] spoken phrase]` | The enclosed phrase | First enclosed Edge word start through last enclosed Edge word end | + +The spoken-span form is preferred when a human editor wants direct duration +control without editing JSON. Its enclosed phrase remains ordinary narration +and subtitle text. It is valid only for `Spotlight`; empty scopes, partial-word +boundaries, and sequence-gated scopes that end before their resolved start fail +closed. The manifest and cue plan record `duration_source: script_scope`, the +scope text, and its resolved word range. Point markers remain backward +compatible. + +For an ordinary user-added animated target that has only plain pre-protocol Alt +Text, normalization promotes that sentence to the target's initial narration +and writes it back as managed `Script:` plus canonical Notes. This gives the +block a real speech window, so the following sequential target cannot begin +after only the short entrance transition. + +After Notes markers are word-aligned, editable rendering recomputes all native +dependencies on that final clock. Each Notes block releases the next `On Click` +or `After Previous` row only after both its narration and effects finish. +`With Previous` remains the explicit overlap control. The pipeline pads the +fresh audio tail when the resolved sequence ends after spoken narration, and +strict QA rejects a sequential effect that starts before its block gate. + +When canonical Notes cover only part of a slide, a user-added native animated +target that is absent from Notes is inserted at its Animation Pane position +relative to the Notes-owned native targets. Explicit Notes blocks still keep +their relative order when Notes and Pane conflict. A new first Pane row +therefore remains first instead of being appended after all existing Notes +blocks. Only scripted targets outside the Animation Pane use top-to-bottom, +left-to-right geometry fallback. + +Author Notes are authoritative for handles, narration, supported MP4 entrance +names, marker positions, and `Spotlight` intent. The Animation Pane is +authoritative for target shapes, target order, and entrance/emphasis row kind. +A supported Notes entrance name may override a different recognized native +entrance name; the protocol report records both. Counts, order, and row kind +remain strict and fail when they cannot be reconciled safely. + +## Support matrix + +PPT Master currently registers 22 native entrance effects. Paper2Video renders +the following strict subset into MP4 pixels: + +| PPT Master key | Author Notes name | PPTX preset | MP4 strategy | Default duration | +|---|---|---|---|---:| +| `appear` | `Appear` | `1 / 0` | instant reveal | 0.12 s | +| `fade` | `Fade In` | `10 / 0` | alpha fade | 0.48 s | +| `fly` | `Fly In` | `2 / 4` | left-to-right motion and fade | 0.56 s | +| `zoom` | `Zoom In` | `23 / 0` | center scale and fade | 0.48 s | +| `wipe` | `Wipe In` | `22 / 1` | left-to-right reveal | 0.52 s | +| `dissolve` | `Dissolve In` | `9 / 0` | alpha dissolve | 0.48 s | +| `circle` | `Circle In` | `6 / 0` | circular mask reveal | 0.52 s | +| `diamond` | `Diamond In` | `8 / 0` | diamond mask reveal | 0.52 s | + +The remaining native presets are recognized but do not have an MP4 strategy: + +| PPT Master key | PowerPoint name | PPTX preset | +|---|---|---| +| `cut` | `Cut In` | `42 / 8` | +| `split` | `Split In` | `16 / 21` | +| `blinds` | `Blinds In` | `3 / 10` | +| `checkerboard` | `Checkerboard In` | `5 / 6` | +| `random_bars` | `Random Bars In` | `14 / 10` | +| `peek` | `Peek In` | `12 / 4` | +| `wheel` | `Wheel In` | `21 / 0` | +| `box` | `Box In` | `4 / 0` | +| `plus` | `Plus In` | `13 / 0` | +| `strips` | `Strips In` | `18 / 12` | +| `wedge` | `Wedge In` | `20 / 0` | +| `stretch` | `Stretch In` | `17 / 0` | +| `expand` | `Expand In` | `50 / 0` | +| `swivel` | `Swivel In` | `19 / 0` | + +An unknown native preset tuple fails during extraction. A recognized native +entrance without an MP4 strategy may be mapped only when Author Notes explicitly +choose one of the eight supported MP4 names; that override is recorded rather +than silently changed to Fade. An unsupported or misspelled Notes name fails. +PPT Master's seven page transitions (`fade`, `push`, `wipe`, `split`, `strips`, +`cover`, `random`) are a separate slide-level layer and are not part of the +object-animation manifest. + +## Build and render + +Editable PPTX route, recommended after a user changes the deck: + +```bash +python skills/paper2video/scripts/build_animation_manifest.py \ + --pptx "$VIDEO_OUT/video.pptx" \ + --word-timings "$VIDEO_AUDIO/word_timings.json" \ + --protocol-report-out "$VIDEO_META/reports/editable_pptx_protocol.json" \ + --out "$VIDEO_META/animation_manifest.json" + +python skills/paper2video/scripts/render_video.py "$VIDEO_OUT" \ + --pptx "$VIDEO_OUT/video.pptx" \ + --audio-dir "$VIDEO_AUDIO" \ + --script-json "$VIDEO_AUDIO/script.json" \ + --frame-source pptx \ + --animation-source pptx \ + --animation-manifest "$VIDEO_META/animation_manifest.json" \ + --animation-report-out "$VIDEO_META/reports/animation_render_report.json" \ + --require-animations \ + --out "$VIDEO_CLIPS/video_raw.mp4" +``` + +The renderer creates cumulative PPTX reveal states with LibreOffice and derives +each animation layer from adjacent pixel states. Text, color, position, image, +style, addition, and deletion edits therefore come from the current deck, not +from an earlier SVG export. The manifest records the PPTX SHA-256; rendering +and strict QA fail if the deck changes after the mapping is built. + +SVG authoring route: + +```bash +python skills/paper2video/scripts/build_animation_manifest.py \ + --author-notes-report "$VIDEO_META/reports/author_notes_report.json" \ + --word-timings "$VIDEO_AUDIO/word_timings.json" \ + --svg-dir "$PPT_MASTER_PROJECT/svg_final" \ + --out "$VIDEO_META/animation_manifest.json" + +python skills/paper2video/scripts/render_video.py "$PPT_MASTER_PROJECT" \ + --pptx "$VIDEO_OUT/video.pptx" \ + --audio-dir "$VIDEO_AUDIO" \ + --script-json "$VIDEO_AUDIO/script.json" \ + --frame-source svg \ + --svg-dir "$PPT_MASTER_PROJECT/svg_final" \ + --animation-manifest "$VIDEO_META/animation_manifest.json" \ + --animation-report-out "$VIDEO_META/reports/animation_render_report.json" \ + --require-animations \ + --out "$VIDEO_CLIPS/video_raw.mp4" +``` + +Final QA must receive the same manifest and report plus the raw MP4: + +```bash +python skills/paper2video/scripts/check_video_package.py "$VIDEO_OUT" \ + ... \ + --raw-mp4 "$VIDEO_OUT/video_no_subtitles.mp4" \ + --animation-manifest "$VIDEO_META/animation_manifest.json" \ + --animation-report "$VIDEO_META/reports/animation_render_report.json" \ + --require-animations \ + --strict +``` + +The strict animation gate checks exact slide/order/locator/name coverage, +Edge timing provenance, strategy mapping, valid layer bboxes, and transition +pixel changes inside every mapped bbox. For `source_kind: pptx`, it also checks +the delivered PPTX, manifest, and render report all carry the same SHA-256 and +shape ids. + +## Is PPT Master required? + +PPT Master is not a runtime dependency after the first native PPTX exists. It +remains the preferred upstream authoring tool because it gives each Group a real +Animation Pane effect. A user can then edit that PPTX and run +`render_edited_pptx.py` locally with no LLM and no ppt-master checkout. See +`editable_pptx.md` for the exact mutation contract. diff --git a/ResearchStudio-Reel/skills/paper2video/references/editable_pptx.md b/ResearchStudio-Reel/skills/paper2video/references/editable_pptx.md new file mode 100644 index 0000000..02a4d52 --- /dev/null +++ b/ResearchStudio-Reel/skills/paper2video/references/editable_pptx.md @@ -0,0 +1,192 @@ +# Editable PPTX local rerender contract + +The editable route uses the delivered PowerPoint as the local authoring file. +The canvas owns visible pixels. Canonical Author Notes own handles. Narration +and marker positions are reconciled against the last system-synchronized script +hash, so a user edit made only in Notes or only in Alt Text is retained. Without +that baseline, Notes remain authoritative except for the documented plain Alt +Text migration. Explicit Notes order is preserved. System-generated Notes +blocks use row-aware canvas order, while Animation Pane supplies native effects, +triggers, delays, and grouping. Rerendering requires no LLM or ppt-master +checkout. + +For compatibility with ordinary PowerPoint editing, a new animated target may +start with one plain Alt Text sentence instead of protocol metadata. When that +target has no Notes block or managed `Script:`, normalization promotes the +plain text to its initial narration, then writes canonical Notes and a managed +`Script:` field. Its sequence block therefore waits for that speech before the +next sequential animation starts. + +## One-time bootstrap + +An ordinary native animated deck can be converted to the protocol without an +LLM or a package-specific script: + +```bash +python skills/paper2video/scripts/bootstrap_editable_pptx.py \ + \ + --script-json \ + --out \ + --report-out +``` + +The bootstrap assigns stable handles to Animation Pane targets, writes concise +canonical named-marker Notes, and writes matching compact Alt Text containing +only the handle and script. It splits each slide narration +deterministically across targets in row-aware spatial order, validates the +result, and preserves the original native timing tree. + +## One-command rerender + +```bash +python skills/paper2video/scripts/render_edited_pptx.py \ + \ + --ids-from-script /assets/audio/script.json +``` + +The command performs these deterministic stages: + +1. Parse slides in `p:sldIdLst` presentation order. +2. Read native entrance and emphasis targets from each slide's `p:timing` tree. +3. Resolve Notes directly by stable shape handle, falling back to Animation + Pane order only when a one-to-one compatibility mapping is safe. +4. Compare Notes and Alt Text with the stored baseline hash, accept the edited + side, and refresh both surfaces from the accepted script. +5. Optionally apply an explicitly selected user-edited `script.json`. +6. Run Edge TTS and collect word boundaries. +7. Build a word-aligned animation manifest from normalized `video.pptx`. +8. Render cumulative reveal states from that exact PPTX with LibreOffice. +9. Align each subtitle cue to the actual first and last Edge word boundary. +10. Encode animation, optional spotlight, audio, and bottom-band subtitles. +11. Write measured duration, subtitle alignment evidence, and the media timeline. +12. Run strict media, timeline, protocol, source-hash, subtitle, and animation QA. + +`--prebuilt-audio-dir` is an offline/test option. Its MP3 names and +`word_timings.json` must exactly match the newly extracted Notes script or the +manifest stage fails. + +## Author Notes and Alt Text + +Keep the human-authored Notes block concise: + +```text +## [result-card] Main result card +[[Fade In]] Accuracy rises by [[Spotlight] twelve points]. +``` + +The renderer writes compact Alt Text on the corresponding animated target: + +```text +[result-card] +Script: [[Fade In]] Accuracy rises by [[Spotlight] twelve points]. +``` + +Authority and validation rules: + +- Notes handles are authoritative. Exact handles target shapes without relying + on Animation Pane position. When an old deck lacks matching handles, pane + order is used only for a complete one-to-one compatibility mapping. +- Explicit Notes blocks keep their relative narration order and may differ from + Animation Pane. A system-generated block is inserted by visual geometry: + rows run top-to-bottom, and elements that substantially overlap vertically + run left-to-right. This makes small y alignment differences harmless. +- A supported Notes entrance name may differ from the native entrance name. + The Notes name controls the MP4 strategy and the conflict report records both. +- A Notes marker can add an MP4-only entrance or spotlight without a native row. + Non-conflicting native effects remain active. +- The last system-synchronized script hash is stored only in shape OOXML. A + Notes-only edit or Alt-Text-only edit wins. If both + changed to the same value, accept it. If both changed differently, Notes wins + and `author_notes_authority.json` reports `conflict: true` with both hashes. +- The authoritative provenance node is + `ppt/slides/slideN.xml` → + `p:cNvPr/a:extLst/a:ext/p2v:scriptBaseline`. Besides `sha256`, it stores + `orderSource` and canonical `orderIndex`. Alt Text does not expose these + generated fields. A user-reordered Notes sequence is promoted to `author_notes`; + unchanged generated blocks remain `geometry` and follow current positions. +- For a legacy PPTX with no baseline, a plain Alt Text replacement on a + Notes-owned animated shape is promoted once as the user-edited script. Other + ambiguous legacy Notes/managed-Alt differences remain Notes-first and are + reported rather than silently discarded. +- Marker position determines its Edge word-boundary start time. The preferred + `[[Spotlight] spoken phrase]` form keeps the phrase in speech and subtitles, + then uses the first and last enclosed Edge word boundaries as the exact cue + interval. Legacy `[[Spotlight]]` remains a point marker with native or + default duration. +- Native emphasis effects map to the deterministic `Spotlight` video cue. +- Unsupported Notes marker names, ambiguous handles, nested targets, and empty + slide narration fail closed. +- Only top-level PowerPoint elements are supported as editable animation + targets. Group related primitives first, then animate and identify the group. +- Older verbose `[Paper2Video]` blocks remain readable. Every writeback migrates + Alt Text to exactly `[handle]` plus `Script:`; all generated details remain in + OOXML or native `p:timing`. +- Final subtitle cues must use `edge_word_boundary` timing. A cue starts at its + first spoken word and ends at its last spoken word. Punctuation attaches to + those words. Missing or mismatched boundaries fail closed instead of falling + back to character-proportional estimates. + +## Add, delete, and modify + +Modify an element: + +1. Change text, image, color, style, size, or position on the PowerPoint canvas. +2. Edit the Notes transcript if the spoken narration should change. +3. Keep the Notes handle stable unless intentionally renaming it. If renamed, + the next render refreshes the Alt Text handle and script. +4. Rerun the command. Both static and animated pixels come from the edited + PPTX, so no SVG regeneration is needed. + +Add an element: + +1. Add a top-level shape or group. +2. Give it a stable first-line Alt Text handle such as `[new-result]`. +3. Optionally give it a native entrance effect. +4. Add `## [new-result] ...` in Notes at the desired narration position. +5. Add the exact supported video marker, such as `[[Zoom In]]`, inside its + transcript. A first-line `[new-result]` Alt Text handle is useful while + editing but optional; the renderer writes the compact Alt Text. + +Delete an element: + +1. Delete the shape from the slide. PowerPoint removes its native animation. +2. Delete the matching Author Notes block or Alt Text `Script:` field. +3. Rerun. An unresolved Notes handle fails instead of targeting another shape. + +Reordering is also explicit. Reorder Notes blocks to change spoken order. +Reorder Animation Pane rows to change native trigger relationships. Move shapes +on the canvas to change system-generated order, or reorder Notes blocks to set +an explicit sequence that overrides geometry. + +## Reproducibility evidence + +`animation_manifest.json` records: + +- `source_kind: "pptx"`; +- the exact PPTX SHA-256; +- stable slide IDs, section IDs, shape IDs, Alt Text handles, native order, + native and Notes-selected effect names, conflict resolution, and Edge-aligned + times. + +`animation_render_report.json` repeats the source hash and records every layer +bbox and MP4 sample window. Strict QA verifies the delivered PPTX hash and +checks an early/late pixel pair for every mapped effect. + +The automated regression suite edits a synthetic PowerPoint in sequence: + +- red card to blue card, proving modification reaches encoded MP4 pixels; +- add a green card, proving manifest and video reveal count increase; +- delete the original card, proving its mapping and pixels disappear; +- stale an Alt Text handle, proving Notes wins and compact Alt Text is refreshed; +- omit Notes for a native target, proving silent native effects remain valid; +- add a native-only target between two existing cards, proving generated Notes + use row-aware left-to-right geometry instead of Animation Pane order; +- reverse Notes versus Animation Pane order, proving Notes controls narration; +- use Alt Text scripts without Notes, proving spatial generation and writeback; +- add a Notes-only effect with no native row, proving script-timed effects; +- add a native emphasis plus `[[Spotlight]]`, proving local attention mapping. + +Previously delivered decks using `[ID] result-card` remain readable for local +rerenders. Bootstrap and all newly rendered decks write `[result-card]` as the +first line and the matching `Script:` as the second line. The handle mirrors +`## [result-card]` in Notes. diff --git a/ResearchStudio-Reel/skills/paper2video/scripts/add_subtitles.py b/ResearchStudio-Reel/skills/paper2video/scripts/add_subtitles.py index 3afbbb2..a463be1 100755 --- a/ResearchStudio-Reel/skills/paper2video/scripts/add_subtitles.py +++ b/ResearchStudio-Reel/skills/paper2video/scripts/add_subtitles.py @@ -19,22 +19,27 @@ 1. Read subtitle text from --script-json sections order when provided (falling back to notes/.md if present), otherwise pair sorted notes/*.md with sorted audio/*.mp3 for legacy ppt-master projects. - 2. Probe each audio/.mp3 to get the spoken duration of that slide. + 2. Probe each audio/.mp3 for slide duration and load Edge word + boundaries when available. 3. Split each slide's text into sentence-level cues (~80 chars each). - 4. Distribute the slide's audio duration across its cues proportional - to character count, snapping cue boundaries inside [start, end]. + 4. Align every cue's normalized text to the exact Edge word sequence. + Each cue starts at its first spoken word and ends at its last spoken + word. Final renders require this alignment and never estimate cue + timing from character count. 5. Walk slides in sorted order, advancing the clock by pad + audio_duration + tail to mirror render_video.py's layout. - 6. Probe the bottom band of each slide for luminance; pick black text - on light slides and white text on dark ones. Add the opposite color - as an outline so the text is legible even when the picker is on - a borderline slide. + 6. In the legacy overlay layout, probe the bottom band of each slide + for luminance; pick black text on light slides and white text on + dark ones. Add the opposite color as an outline so the text remains + legible on a borderline slide. 7. Write the cues to /exports/.srt and .vtt (always — useful as YouTube/archive sidecars and timeline/visualization input). - 8. Default (hardsub): convert cues to an ASS file with a translucent - dark caption box and burn it into the video with ffmpeg's `ass=` - filter. The video is re-encoded (libx264 CRF 20 by default), - audio is stream-copied. Output: + 8. Default (hardsub): append a short solid black band below the + unchanged slide frame, convert cues to an ASS file, and burn white + captions entirely inside that reserved band with ffmpeg's `ass=` + filter. No slide content is scaled, cropped, or covered. The video + is re-encoded (libx264 CRF 20 by default), audio is stream-copied. + Output: /exports/_subbed.mp4 The subtitles are now part of every frame — no player toggle, no font-rendering surprise on phones that don't honor mov_text. @@ -63,7 +68,7 @@ per dialogue line gives us deterministic per-cue color + outline. The SRT is still emitted as a sidecar. -Per-slide text color (white vs. black) for readability: +Per-slide text color (white vs. black) for the legacy overlay layout: Different slides may have different background colors (white hero pages, dark navy cover pages, photographic backgrounds, etc.). To keep the subtitle text legible on every slide we sample one frame per slide at @@ -262,19 +267,23 @@ def _chunk_long_sentence(sentence: str, max_chars: int) -> list[str]: if buf: chunks.append(buf) - # Last-resort word chunking for any chunk still way over budget. + # Last-resort word chunking enforces the requested character cap. This is + # important for the default short subtitle bar, which is intentionally + # sized for one rendered line rather than two wrapped lines. final: list[str] = [] for c in chunks: - if len(c) <= int(max_chars * 1.4): + if len(c) <= max_chars: final.append(c) continue words = c.split() cur: list[str] = [] for w in words: - cur.append(w) - if len(" ".join(cur)) >= max_chars: + candidate = " ".join([*cur, w]) + if cur and len(candidate) > max_chars: final.append(" ".join(cur)) - cur = [] + cur = [w] + else: + cur.append(w) if cur: final.append(" ".join(cur)) return final @@ -301,6 +310,8 @@ def split_into_cues(text: str, max_chars: int) -> list[str]: # and flat-fill backgrounds and degrades well on older players. COLOR_WHITE = "#FFFFFF" COLOR_BLACK = "#000000" +AUTO_FONT_SIZE_RATIO = 0.044 +MIN_AUTO_FONT_SIZE = 18 # Fraction of the frame height where mov_text actually renders. Players vary # (QuickTime ~88%, VLC ~85%, mpv configurable) but the bottom 18%–8% band is @@ -425,6 +436,125 @@ class Cue: color: str = COLOR_WHITE # one of COLOR_WHITE / COLOR_BLACK +class SubtitleTimingError(ValueError): + """Subtitle text cannot be mapped safely to actual spoken word boundaries.""" + + +def _normalized_alignment_text(value: object) -> str: + """Normalize script and Edge tokens without discarding non-Latin text.""" + return "".join(char for char in str(value or "").casefold() if char.isalnum()) + + +def allocate_word_boundary_cues( + cues: list[str], + words: list[dict], + *, + slide_start: float, +) -> tuple[list[tuple[float, float, str]], list[dict]]: + """Map cue chunks to exact Edge word-boundary intervals. + + Punctuation belongs to the neighboring spoken words and therefore does not + receive an estimated duration of its own. Exact normalized-text equality + and cue boundaries between Edge words are required. This fail-closed rule + prevents visually plausible but semantically early subtitle changes. + """ + if not cues: + return [], [] + if not words: + raise SubtitleTimingError("narrated section has no Edge word boundaries") + + usable: list[dict] = [] + previous_start = -1.0 + previous_end = -1.0 + for source_index, word in enumerate(words): + token = _normalized_alignment_text(word.get("text")) + if not token: + continue + try: + start = float(word.get("start")) + end = float(word.get("end")) + except (TypeError, ValueError) as exc: + raise SubtitleTimingError( + f"word boundary {source_index} has non-numeric start/end" + ) from exc + if start < 0.0 or end < start: + raise SubtitleTimingError( + f"word boundary {source_index} has invalid interval {start}..{end}" + ) + if start + 1e-6 < previous_start or end + 1e-6 < previous_end: + raise SubtitleTimingError("Edge word boundaries are not monotonic") + usable.append( + { + "source_index": source_index, + "text": str(word.get("text") or ""), + "normalized": token, + "start": start, + "end": end, + } + ) + previous_start = start + previous_end = end + + cue_tokens = [_normalized_alignment_text(cue) for cue in cues] + if any(not token for token in cue_tokens): + raise SubtitleTimingError("subtitle cue contains no spoken letters or digits") + script_token = "".join(cue_tokens) + edge_token = "".join(str(word["normalized"]) for word in usable) + if script_token != edge_token: + mismatch = next( + ( + index + for index, (left, right) in enumerate(zip(script_token, edge_token)) + if left != right + ), + min(len(script_token), len(edge_token)), + ) + raise SubtitleTimingError( + "subtitle text does not exactly match Edge word boundaries " + f"(normalized mismatch at character {mismatch}, " + f"script={len(script_token)}, edge={len(edge_token)})" + ) + + timed: list[tuple[float, float, str]] = [] + mappings: list[dict] = [] + word_cursor = 0 + normalized_cursor = 0 + for cue_index, (cue, cue_token) in enumerate(zip(cues, cue_tokens), start=1): + target = normalized_cursor + len(cue_token) + first_word = word_cursor + while word_cursor < len(usable) and normalized_cursor < target: + normalized_cursor += len(str(usable[word_cursor]["normalized"])) + word_cursor += 1 + if normalized_cursor != target or word_cursor <= first_word: + raise SubtitleTimingError( + f"cue {cue_index} ends inside an Edge word boundary" + ) + last_word = word_cursor - 1 + first = usable[first_word] + last = usable[last_word] + start = slide_start + float(first["start"]) + end = slide_start + float(last["end"]) + timed.append((start, end, cue)) + mappings.append( + { + "cue_index": cue_index, + "text": cue, + "timing_source": "edge_word_boundary", + "word_start": int(first["source_index"]), + "word_end": int(last["source_index"]), + "first_word": first["text"], + "last_word": last["text"], + "relative_start": round(float(first["start"]), 3), + "relative_end": round(float(last["end"]), 3), + "absolute_start": round(start, 3), + "absolute_end": round(end, 3), + } + ) + if word_cursor != len(usable): + raise SubtitleTimingError("unmapped Edge word boundaries remain after final cue") + return timed, mappings + + def allocate_slide_cues(cues: list[str], audio_duration: float, slide_start: float, min_cue_dur: float, min_gap: float) -> list[tuple[float, float, str]]: @@ -624,6 +754,33 @@ def _ass_timestamp(seconds: float) -> str: return f"{h}:{m:02d}:{s:05.2f}" +def resolve_subtitle_font_size(font_size: int | None, video_h: int) -> int: + """Return an explicit size or a resolution-aware default. + + ASS sizes are expressed in PlayRes units. A fixed value that looks balanced + at 1080p crowds the intentionally short subtitle band at 720p, so the + default tracks the unchanged source-slide height instead of the padded + output height. + """ + if font_size is not None: + if font_size <= 0: + raise ValueError("subtitle font size must be positive") + return font_size + if video_h <= 0: + raise ValueError("video height must be positive") + return max(MIN_AUTO_FONT_SIZE, int(round(video_h * AUTO_FONT_SIZE_RATIO))) + + +def subtitle_bar_vertical_margin(subtitle_bar_height: int) -> int: + """Return the libass bottom margin that visually centers one line.""" + if subtitle_bar_height <= 0: + raise ValueError("subtitle bar height must be positive") + # Alignment=2 positions the ASS line box by its font baseline, not by the + # visible glyph bounds. Frame sampling at 720p and 1080p shows that roughly + # one third of the band gives balanced visible padding above and below. + return max(4, int(round(subtitle_bar_height * 0.34))) + + def write_ass(cues: list[Cue], path: Path, *, video_w: int, video_h: int, font_name: str, font_size: int, @@ -634,10 +791,10 @@ def write_ass(cues: list[Cue], path: Path, *, """Emit an ASS subtitle file with per-event color overrides. The style block sets sensible defaults (font, size, outline/background). - By default paper2video uses a translucent dark box so burned-in subtitles - do not visually merge with PPT text. Bottom-bar mode reserves a solid black - band below a proportionally scaled slide; legacy no-box mode keeps the - per-event outline fallback for users who explicitly want plain captions. + By default paper2video appends a solid black band below the unchanged slide. + Legacy overlay mode can use a translucent dark box, while legacy no-box + mode keeps the per-event outline fallback for users who explicitly want + plain captions. PlayResX/PlayResY MUST match the output frame size or libass renders at the wrong scale (text either tiny or oversized). We pass video_w/h @@ -652,7 +809,7 @@ def write_ass(cues: list[Cue], path: Path, *, style_primary = _hex_to_ass_color(COLOR_WHITE) style_outline_color = _hex_to_ass_color(COLOR_BLACK) style_back_color = _hex_to_ass_color(COLOR_BLACK) - margin_v = max(20, int(round(subtitle_bar_height * 0.34))) + margin_v = subtitle_bar_vertical_margin(subtitle_bar_height) elif subtitle_box: border_style = 3 style_outline = max(0.0, float(box_padding)) @@ -740,24 +897,21 @@ def probe_video_dimensions(mp4: Path, ffmpeg: str) -> tuple[int, int]: def subtitle_bar_geometry( video_w: int, video_h: int, bar_height: int, ) -> tuple[int, int, int, int]: - """Return an even, aspect-preserving slide frame above a bottom bar. + """Return the unchanged slide frame plus an even appended bottom bar. - The final frame keeps the input resolution. The full slide is scaled - proportionally into the space above the bar, centered horizontally, and - never cropped or covered by captions. + The source slide keeps its exact width, height, and pixels at the top of + the output. The final canvas grows downward by ``actual_bar`` pixels, so + captions never create side bars, distort the slide, or cover its content. """ if video_w < 2 or video_h < 2: raise ValueError("video dimensions must be positive") if bar_height <= 0 or bar_height >= video_h: - raise ValueError("subtitle bar height must fit inside the video frame") - content_h = max(2, video_h - bar_height) - content_h -= content_h % 2 - content_w = max(2, int(video_w * (content_h / video_h))) - content_w -= content_w % 2 - content_w = min(video_w - (video_w % 2), content_w) - x = max(0, (video_w - content_w) // 2) - return content_w, content_h, x, video_h - content_h + raise ValueError("subtitle bar height must be smaller than the video frame") + actual_bar = max(2, int(bar_height)) + if (video_h + actual_bar) % 2: + actual_bar += 1 + return video_w, video_h, 0, actual_bar def burn_subtitles(mp4: Path, ass: Path, out: Path, ffmpeg: str, *, @@ -770,10 +924,9 @@ def burn_subtitles(mp4: Path, ass: Path, out: Path, ffmpeg: str, *, on Windows ffmpeg requires backslash-escaped drive letters (`C\\:`). We use the resolved absolute path so the filter doesn't depend on CWD. - Bottom-bar mode first scales the complete slide proportionally into the - upper region and pads the remaining frame black. Audio is stream-copied — - there's no reason to re-encode the AAC track a second time and it would - only add drift. + Bottom-bar mode leaves the slide pixels unchanged and extends the canvas + downward with a solid black band. Audio is stream-copied because there is + no reason to re-encode the AAC track a second time and add drift. """ out.parent.mkdir(parents=True, exist_ok=True) ass_abs = str(ass.resolve()) @@ -783,13 +936,12 @@ def burn_subtitles(mp4: Path, ass: Path, out: Path, ffmpeg: str, *, # POSIX path passes through cleanly. filters: list[str] = [] if subtitle_bar_height: - content_w, content_h, x, _ = subtitle_bar_geometry( + _, _, _, actual_bar = subtitle_bar_geometry( video_w, video_h, subtitle_bar_height, ) - filters.extend([ - f"scale={content_w}:{content_h}:flags=lanczos", - f"pad={video_w}:{video_h}:{x}:0:color=black", - ]) + filters.append( + f"pad={video_w}:{video_h + actual_bar}:0:0:color=black" + ) filters.append(f"ass={ass_abs}") cmd = [ ffmpeg, "-y", @@ -882,6 +1034,41 @@ def autodetect_script_json(project_path: Path, audio_dir: Path) -> Path | None: return None +def load_word_timings(path: Path) -> dict[str, list[dict]]: + """Return the actual Edge word-boundary rows keyed by section id.""" + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + sys.exit(f"[add_subtitles] invalid word timings {path}: {exc}") + timings: dict[str, list[dict]] = {} + for section in payload.get("sections") or []: + section_id = str(section.get("id") or "").strip() + if not section_id: + sys.exit(f"[add_subtitles] word timing section has no id in {path}") + if section_id in timings: + sys.exit( + f"[add_subtitles] duplicate word timing section {section_id!r} in {path}" + ) + words = section.get("words") or [] + if not isinstance(words, list): + sys.exit( + f"[add_subtitles] word timing section {section_id!r} has invalid words" + ) + timings[section_id] = words + return timings + + +def load_spoken_durations(path: Path) -> dict[str, float]: + """Backward-compatible helper returning each section's final spoken end.""" + return { + section_id: max( + (float(word.get("end") or 0.0) for word in words), + default=0.0, + ) + for section_id, words in load_word_timings(path).items() + } + + def collect_audio(audio_dir: Path) -> list[Path]: if not audio_dir.is_dir(): sys.exit(f"[add_subtitles] audio dir not found: {audio_dir}") @@ -938,11 +1125,10 @@ def collect_timed_inputs( if not mp3.is_file(): missing_audio.append(mp3.name) continue + text = str(sec.get("text") or "") note_md = notes_dir / f"{sid}.md" - if note_md.is_file(): + if not text.strip() and note_md.is_file(): text = note_md.read_text(encoding="utf-8") - else: - text = str(sec.get("text") or "") rows.append((sid, text, mp3)) if missing_audio: @@ -971,7 +1157,7 @@ def autodetect_mp4(exports_dir: Path) -> Path: # Main # --------------------------------------------------------------------------- -def main() -> int: +def build_argument_parser() -> argparse.ArgumentParser: ap = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0]) ap.add_argument("project_path", help="ppt-master project root (contains notes/, audio/, exports/)") ap.add_argument("--mp4", default=None, @@ -982,6 +1168,14 @@ def main() -> int: help="Narration script JSON whose sections order defines subtitle/audio order. " "Defaults to /script.json, then /assets/meta/narration.json, then /narration.json, " "then sorted notes/audio filenames.") + ap.add_argument("--word-timings", default=None, + help="Edge word_timings.json used to align every cue to its actual words. " + "Defaults to /word_timings.json when present.") + ap.add_argument("--require-word-timings", action="store_true", + help="Fail unless every narrated cue aligns exactly to Edge word boundaries. " + "Required for final pptx2video renders.") + ap.add_argument("--timing-report-out", default=None, + help="Write subtitle word-alignment evidence as JSON.") ap.add_argument("--out", default=None, help="Output MP4 path (default: /exports/_subbed.mp4)") ap.add_argument("--srt-out", default=None, @@ -992,8 +1186,9 @@ def main() -> int: help="Lead-in silence used by render_video.py (default: 0.5s — MUST match)") ap.add_argument("--pad-tail", type=float, default=0.3, help="Per-slide trailing silence used by render_video.py (default: 0.3s — MUST match)") - ap.add_argument("--max-chars-per-cue", type=int, default=85, - help="Soft cap on cue length before clause-level chunking (default: 85)") + ap.add_argument("--max-chars-per-cue", type=int, default=72, + help="Hard cap on cue length before clause/word chunking (default: 72, " + "keeps the default short subtitle bar to one line at 1080p)") ap.add_argument("--min-cue-duration", type=float, default=1.2, help="Floor on per-cue screen time (default: 1.2s)") ap.add_argument("--min-cue-gap", type=float, default=0.08, @@ -1015,23 +1210,28 @@ def main() -> int: ap.add_argument("--font", default="DejaVu Sans", help="Font for burned-in subtitles (default: DejaVu Sans — present " "on most Linux installs; pick a system font you actually have).") - ap.add_argument("--font-size", type=int, default=44, - help="Burn-in font size in ASS units (default: 44 — readable at 1080p).") + ap.add_argument("--font-size", type=int, default=None, + help="Burn-in font size in ASS units. By default it scales to 4.4%% " + "of the source-slide height (about 32 at 720p and 48 at 1080p).") ap.add_argument("--outline-width", type=float, default=2.0, help="Stroke width around burn-in text (default: 2.0).") ap.add_argument("--shadow-depth", type=float, default=0.5, help="Drop-shadow depth for burn-in text (default: 0.5).") ap.add_argument("--subtitle-box", dest="subtitle_box", action="store_true", default=True, - help="Burn subtitles with a translucent dark background box (default).") + help="In --subtitle-overlay mode, use a translucent dark background box.") ap.add_argument("--no-subtitle-box", dest="subtitle_box", action="store_false", - help="Legacy mode: burn plain text with outline/shadow and no background box.") - ap.add_argument("--subtitle-bar", action="store_true", - help="Burn white captions into a solid black bottom band. The complete " - "slide is scaled proportionally above the band so PPT content is " - "never covered or cropped.") - ap.add_argument("--subtitle-bar-height", type=float, default=0.16, - help="Bottom band height as a fraction of the output frame, 0.10..0.30 " - "(default: 0.16). Used only with --subtitle-bar.") + help="With --subtitle-overlay, burn plain text with outline/shadow and no box.") + layout = ap.add_mutually_exclusive_group() + layout.add_argument("--subtitle-bar", dest="subtitle_bar", action="store_true", default=True, + help="Burn white captions into a solid black bottom band (default). " + "The band is appended below the unchanged slide, so PPT content " + "is never scaled, covered, or cropped and no side bars are added.") + layout.add_argument("--subtitle-overlay", dest="subtitle_bar", action="store_false", + help="Legacy layout: burn captions over the bottom of the slide. Use " + "--subtitle-box or --no-subtitle-box to control its background.") + ap.add_argument("--subtitle-bar-height", type=float, default=0.08, + help="Appended bottom-band height as a fraction of the source slide " + "height, 0.05..0.20 (default: 0.08). Used only in subtitle-bar mode.") ap.add_argument("--subtitle-box-opacity", type=float, default=0.62, help="Opacity for the subtitle background box, 0..1 (default: 0.62).") ap.add_argument("--subtitle-box-padding", type=float, default=10.0, @@ -1043,14 +1243,19 @@ def main() -> int: help="x264 preset for the burn-in re-encode (default: medium).") ap.add_argument("--srt-only", action="store_true", help="Write the .srt and exit (skip muxing/burning).") + return ap + + +def main() -> int: + ap = build_argument_parser() args = ap.parse_args() delivery_modes = sum(bool(value) for value in ( - args.soft, args.subtitle_bar, args.no_subtitles, + args.soft, args.no_subtitles, )) if delivery_modes > 1: - ap.error("--soft, --subtitle-bar, and --no-subtitles are mutually exclusive") - if not 0.10 <= args.subtitle_bar_height <= 0.30: - ap.error("--subtitle-bar-height must be between 0.10 and 0.30") + ap.error("--soft and --no-subtitles are mutually exclusive") + if not 0.05 <= args.subtitle_bar_height <= 0.20: + ap.error("--subtitle-bar-height must be between 0.05 and 0.20") project_path = Path(args.project_path).resolve() if not project_path.is_dir(): @@ -1064,6 +1269,20 @@ def main() -> int: audio_dir = Path(args.audio_dir).resolve() if args.audio_dir else project_path / "audio" script_json = Path(args.script_json).resolve() if args.script_json else None timed_inputs = collect_timed_inputs(project_path, audio_dir, script_json) + word_timings_path = ( + Path(args.word_timings).resolve() + if args.word_timings + else audio_dir / "word_timings.json" + ) + word_timings = ( + load_word_timings(word_timings_path) + if word_timings_path.is_file() + else {} + ) + if args.require_word_timings and not word_timings_path.is_file(): + sys.exit( + f"[add_subtitles] required Edge word timings not found: {word_timings_path}" + ) ffmpeg, ffprobe = find_ffmpeg_pair() @@ -1076,6 +1295,7 @@ def main() -> int: t = max(args.start_pad, 0.0) next_index = 1 pending: list[tuple[int, list[Cue], float]] = [] # (slide_idx, cues, midpoint) + timing_sections: list[dict] = [] for slide_idx, (sid, text, mp3) in enumerate(timed_inputs, start=1): duration = probe_duration(mp3, ffprobe, ffmpeg) @@ -1083,14 +1303,53 @@ def main() -> int: if not cues: # Skip silent / empty notes but still advance the clock. + timing_sections.append( + { + "id": sid, + "slide_index": slide_idx, + "slide_start": round(t, 3), + "audio_duration": round(duration, 3), + "timing_source": "silent", + "word_count": len(word_timings.get(sid) or []), + "cue_count": 0, + "cues": [], + } + ) t += duration + args.pad_tail continue - timed = allocate_slide_cues( - cues, duration, slide_start=t, - min_cue_dur=args.min_cue_duration, - min_gap=args.min_cue_gap, - ) + section_words = word_timings.get(sid) + if section_words is not None: + try: + timed, cue_mappings = allocate_word_boundary_cues( + cues, + section_words, + slide_start=t, + ) + except SubtitleTimingError as exc: + sys.exit(f"[add_subtitles] section {sid!r} word alignment failed: {exc}") + timing_source = "edge_word_boundary" + else: + if args.require_word_timings: + sys.exit( + f"[add_subtitles] narrated section {sid!r} has no Edge word boundaries" + ) + timed = allocate_slide_cues( + cues, duration, slide_start=t, + min_cue_dur=args.min_cue_duration, + min_gap=args.min_cue_gap, + ) + cue_mappings = [ + { + "cue_index": index, + "text": cue_text, + "timing_source": "duration_proportional", + "absolute_start": round(start, 3), + "absolute_end": round(end, 3), + } + for index, (start, end, cue_text) in enumerate(timed, start=1) + ] + timing_source = "duration_proportional" slide_cues: list[Cue] = [] for start, end, txt in timed: slide_cues.append(Cue(index=next_index, start=start, end=end, text=txt)) @@ -1098,12 +1357,70 @@ def main() -> int: midpoint = t + duration / 2.0 pending.append((slide_idx, slide_cues, midpoint)) + timing_sections.append( + { + "id": sid, + "slide_index": slide_idx, + "slide_start": round(t, 3), + "audio_duration": round(duration, 3), + "timing_source": timing_source, + "word_count": len(section_words or []), + "cue_count": len(cue_mappings), + "cues": cue_mappings, + } + ) # Advance past this slide's audio + the trailing silence pad. t += duration + args.pad_tail if not pending: - sys.exit("[add_subtitles] no cues generated — every note file was empty.") + srt_path = ( + Path(args.srt_out).resolve() + if args.srt_out + else exports_dir / f"{mp4_path.stem}.srt" + ) + vtt_path = ( + Path(args.vtt_out).resolve() + if args.vtt_out + else exports_dir / f"{mp4_path.stem}.vtt" + ) + srt_path.parent.mkdir(parents=True, exist_ok=True) + vtt_path.parent.mkdir(parents=True, exist_ok=True) + write_srt([], srt_path) + write_vtt([], vtt_path) + if args.timing_report_out: + timing_report_path = Path(args.timing_report_out).resolve() + timing_report_path.parent.mkdir(parents=True, exist_ok=True) + timing_report_path.write_text( + json.dumps( + { + "schema_version": "paper2video_subtitle_word_alignment.v1", + "word_timings": ( + str(word_timings_path) if word_timings_path.is_file() else None + ), + "required": bool(args.require_word_timings), + "status": "silent", + "section_count": len(timing_sections), + "cue_count": 0, + "sections": timing_sections, + }, + ensure_ascii=False, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + print("[add_subtitles] no narration cues; wrote empty SRT/VTT sidecars") + if args.srt_only: + return 0 + out_path = ( + Path(args.out).resolve() + if args.out + else exports_dir / f"{mp4_path.stem}_subbed.mp4" + ) + copy_without_subtitles(mp4_path, out_path, ffmpeg) + print(f"[add_subtitles] copied silent video/audio to {out_path}") + return 0 # Per-slide color decision. `--color white|black` forces a single color # across the whole deck and skips the probe pass entirely. @@ -1121,6 +1438,32 @@ def main() -> int: c.color = color all_cues.append(c) + timing_report = { + "schema_version": "paper2video_subtitle_word_alignment.v1", + "word_timings": str(word_timings_path) if word_timings_path.is_file() else None, + "required": bool(args.require_word_timings), + "status": ( + "word_aligned" + if any(int(section["cue_count"]) > 0 for section in timing_sections) + and all( + section["timing_source"] in {"edge_word_boundary", "silent"} + for section in timing_sections + ) + else "estimated" + ), + "section_count": len(timing_sections), + "cue_count": sum(int(section["cue_count"]) for section in timing_sections), + "sections": timing_sections, + } + if args.timing_report_out: + timing_report_path = Path(args.timing_report_out).resolve() + timing_report_path.parent.mkdir(parents=True, exist_ok=True) + timing_report_path.write_text( + json.dumps(timing_report, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + print(f"[add_subtitles] wrote timing evidence to {timing_report_path}") + if not all_cues: sys.exit("[add_subtitles] no cues generated — every note file was empty.") @@ -1159,16 +1502,23 @@ def main() -> int: # user means by "part of the video file" — there is no toggle, every # player on every device shows the captions because they are pixels now. video_w, video_h = probe_video_dimensions(mp4_path, ffmpeg) + font_size = resolve_subtitle_font_size(args.font_size, video_h) subtitle_bar_height = ( max(2, int(round(video_h * args.subtitle_bar_height))) if args.subtitle_bar else 0 ) + output_h = video_h + if subtitle_bar_height: + _, _, _, subtitle_bar_height = subtitle_bar_geometry( + video_w, video_h, subtitle_bar_height, + ) + output_h += subtitle_bar_height ass_path = exports_dir / f"{mp4_path.stem}.ass" write_ass( all_cues, ass_path, - video_w=video_w, video_h=video_h, - font_name=args.font, font_size=args.font_size, + video_w=video_w, video_h=output_h, + font_name=args.font, font_size=font_size, outline_width=args.outline_width, shadow_depth=args.shadow_depth, subtitle_box=args.subtitle_box and not args.subtitle_bar, subtitle_bar=args.subtitle_bar, @@ -1176,7 +1526,7 @@ def main() -> int: box_opacity=args.subtitle_box_opacity, box_padding=args.subtitle_box_padding, ) - print(f"[add_subtitles] wrote ASS at {video_w}×{video_h} → {ass_path}") + print(f"[add_subtitles] wrote ASS at {video_w}×{output_h} with font size {font_size} → {ass_path}") print(f"[add_subtitles] burning subtitles into pixels (libx264 crf={args.crf} preset={args.preset})…") burn_subtitles( mp4_path, ass_path, out_path, ffmpeg, diff --git a/ResearchStudio-Reel/skills/paper2video/scripts/bootstrap_editable_pptx.py b/ResearchStudio-Reel/skills/paper2video/scripts/bootstrap_editable_pptx.py new file mode 100755 index 0000000..1d1ae19 --- /dev/null +++ b/ResearchStudio-Reel/skills/paper2video/scripts/bootstrap_editable_pptx.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Seed concise ``[handle]`` Notes and compact Alt Text into an animated PPTX.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from editable_pptx import ProtocolError, bootstrap_protocol, write_json + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("pptx", type=Path) + parser.add_argument("--script-json", type=Path, required=True) + parser.add_argument("--out", type=Path, required=True) + parser.add_argument("--report-out", type=Path, required=True) + args = parser.parse_args() + try: + report = bootstrap_protocol(args.pptx, args.script_json, args.out) + except (OSError, ProtocolError) as exc: + sys.exit(f"[bootstrap_editable_pptx] {exc}") + write_json(args.report_out, report) + print( + f"[bootstrap_editable_pptx] wrote {args.out} " + f"({report['slide_count']} slides, {report['effect_count']} effects)" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ResearchStudio-Reel/skills/paper2video/scripts/build_animation_manifest.py b/ResearchStudio-Reel/skills/paper2video/scripts/build_animation_manifest.py new file mode 100755 index 0000000..f170c4f --- /dev/null +++ b/ResearchStudio-Reel/skills/paper2video/scripts/build_animation_manifest.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +"""Build word-aligned animation beats from an editable PPTX or legacy SVG report.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from datetime import datetime, timezone +from pathlib import Path +from xml.etree import ElementTree as ET + +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +from editable_pptx import ( + ProtocolError, + build_pptx_animation_manifest, + extract_protocol, + write_json, +) + + +SCHEMA_VERSION = "paper2video_animation_manifest.v1" +DEFAULT_EFFECT_SECONDS = { + "Appear": 0.12, + "Fade In": 0.48, + "Dissolve In": 0.48, + "Fly In": 0.56, + "Wipe In": 0.52, + "Zoom In": 0.48, + "Circle In": 0.52, + "Diamond In": 0.52, +} + + +def _utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _normalized_chars(text: str) -> str: + return "".join(re.findall(r"[a-z0-9]+", text.lower())) + + +def _top_level_ids(svg_path: Path) -> set[str]: + root = ET.parse(svg_path).getroot() + return { + str(child.attrib.get("id") or "").strip() + for child in list(root) + if str(child.attrib.get("id") or "").strip() + } + + +def _align_block( + words: list[dict[str, object]], cursor: int, transcript: str, +) -> tuple[int, int]: + target = _normalized_chars(transcript) + if not target: + raise ValueError("animation transcript block is empty") + combined = "" + end = cursor + while end < len(words) and len(combined) < len(target): + combined += _normalized_chars(str(words[end].get("text") or "")) + end += 1 + if combined != target: + raise ValueError( + f"could not align animation transcript {transcript!r} at word index {cursor}; " + f"normalized timing text was {combined!r}, expected {target!r}" + ) + return cursor, end + + +def build_manifest( + author_notes_report: Path, + word_timings: Path, + svg_dir: Path, +) -> dict[str, object]: + notes = json.loads(author_notes_report.read_text(encoding="utf-8")) + timings = json.loads(word_timings.read_text(encoding="utf-8")) + note_slides = notes.get("slides") or [] + timing_sections = timings.get("sections") or [] + svg_paths = sorted(svg_dir.glob("*.svg")) + if not note_slides: + raise ValueError("author notes report has no slides") + if len(note_slides) != len(timing_sections): + raise ValueError( + f"author notes slide count {len(note_slides)} != timing section count " + f"{len(timing_sections)}" + ) + if len(note_slides) != len(svg_paths): + raise ValueError( + f"author notes slide count {len(note_slides)} != SVG count {len(svg_paths)}" + ) + + manifest_slides: list[dict[str, object]] = [] + effect_count = 0 + for index, (slide, section, svg_path) in enumerate( + zip(note_slides, timing_sections, svg_paths), start=1 + ): + slide_id = str(slide.get("section_id") or "") + timing_id = str(section.get("id") or "") + if slide_id != timing_id: + raise ValueError( + f"slide {index} id {slide_id!r} != timing section id {timing_id!r}" + ) + words = section.get("words") or [] + if not isinstance(words, list) or not words: + raise ValueError(f"slide {index} has no word timings") + available_ids = _top_level_ids(svg_path) + cursor = 0 + effects_out: list[dict[str, object]] = [] + for block_index, block in enumerate(slide.get("blocks") or [], start=1): + locator = str(block.get("locator") or "").strip() + if not locator or locator not in available_ids: + raise ValueError( + f"slide {index} animation locator {locator!r} is not a top-level SVG id" + ) + start_index, end_index = _align_block( + words, cursor, str(block.get("transcript") or "") + ) + cursor = end_index + word_start = float(words[start_index].get("start") or 0.0) + word_end = float(words[end_index - 1].get("end") or word_start) + names = block.get("effects") or [] + if not names: + raise ValueError(f"slide {index} block {block_index} has no named effects") + for effect_index, raw_name in enumerate(names): + name = str(raw_name) + if name not in DEFAULT_EFFECT_SECONDS: + raise ValueError( + f"slide {index} block {block_index} uses unsupported video " + f"animation {name!r}" + ) + duration = DEFAULT_EFFECT_SECONDS[name] + start = word_start + effect_index * 0.12 + effects_out.append( + { + "order": len(effects_out) + 1, + "shape_id": str(block.get("shape_id") or ""), + "handle": str(block.get("handle") or ""), + "locator": locator, + "name": name, + "start": round(start, 3), + "duration": duration, + "word_start": start_index, + "word_end": end_index - 1, + "spoken_end": round(word_end, 3), + "timing_source": "edge_word_alignment", + } + ) + effect_count += 1 + if cursor != len(words): + raise ValueError( + f"slide {index} animation blocks consumed {cursor}/{len(words)} timing words" + ) + manifest_slides.append( + { + "index": index, + "id": slide_id, + "svg": str(svg_path.resolve()), + "effect_count": len(effects_out), + "effects": effects_out, + } + ) + + return { + "schema_version": SCHEMA_VERSION, + "created_at": _utc_now(), + "author_notes_report": str(author_notes_report.resolve()), + "word_timings": str(word_timings.resolve()), + "svg_dir": str(svg_dir.resolve()), + "slide_count": len(manifest_slides), + "effect_count": effect_count, + "timing_source": "edge_word_alignment", + "slides": manifest_slides, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--author-notes-report", type=Path) + source.add_argument( + "--pptx", + type=Path, + help="Editable source deck carrying native animations, authoritative Notes, and compact Alt Text.", + ) + parser.add_argument("--word-timings", type=Path, required=True) + parser.add_argument( + "--svg-dir", + type=Path, + help="Required with --author-notes-report; not used by the editable PPTX route.", + ) + parser.add_argument( + "--protocol-report-out", + type=Path, + help="Optional strict PPTX protocol report written by the editable route.", + ) + parser.add_argument("--out", type=Path, required=True) + args = parser.parse_args() + try: + if args.pptx is not None: + timing_payload = json.loads(args.word_timings.read_text(encoding="utf-8")) + section_ids = [ + str(section.get("id") or "") + for section in timing_payload.get("sections") or [] + ] + if not section_ids or any(not section_id for section_id in section_ids): + raise ProtocolError("word timings contain missing or empty section IDs") + protocol = extract_protocol(args.pptx, section_ids=section_ids) + manifest = build_pptx_animation_manifest( + protocol, + args.word_timings.resolve(), + ) + if args.protocol_report_out is not None: + write_json(args.protocol_report_out, protocol) + else: + if args.svg_dir is None: + raise ValueError("--svg-dir is required with --author-notes-report") + if args.protocol_report_out is not None: + raise ValueError("--protocol-report-out is only valid with --pptx") + manifest = build_manifest( + args.author_notes_report.resolve(), + args.word_timings.resolve(), + args.svg_dir.resolve(), + ) + except (OSError, ValueError, ProtocolError, json.JSONDecodeError, ET.ParseError) as exc: + sys.exit(f"[build_animation_manifest] {exc}") + write_json(args.out, manifest) + print( + f"[build_animation_manifest] wrote {args.out} " + f"({manifest['slide_count']} slides, {manifest['effect_count']} effects)" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ResearchStudio-Reel/skills/paper2video/scripts/check_video_package.py b/ResearchStudio-Reel/skills/paper2video/scripts/check_video_package.py index 8778932..8cb56cb 100755 --- a/ResearchStudio-Reel/skills/paper2video/scripts/check_video_package.py +++ b/ResearchStudio-Reel/skills/paper2video/scripts/check_video_package.py @@ -49,6 +49,20 @@ "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships", } SCHEMA_VERSION = "paper2video_qa.v1" +ANIMATION_MANIFEST_SCHEMA_VERSION = "paper2video_animation_manifest.v1" +ANIMATION_RENDER_REPORT_SCHEMA_VERSION = "paper2video_animation_render.v1" +ANIMATION_MIN_MEAN_ABS_DELTA = 0.12 +ANIMATION_MIN_CHANGED_FRACTION = 0.002 +ANIMATION_RENDER_STRATEGIES = { + "Appear": "appear", + "Fade In": "alpha_fade", + "Dissolve In": "alpha_fade", + "Fly In": "fly_from_left", + "Wipe In": "wipe_from_left", + "Zoom In": "zoom_in", + "Circle In": "circle_reveal", + "Diamond In": "diamond_reveal", +} NON_BLOCKING_WARNING_CODES = frozenset({"audio_extra_files"}) @@ -917,8 +931,8 @@ def check_visual_cues( continue if not cues: empty_slides += 1 - severity = "error" if required else "warning" - add(findings, severity, "visual_cues_empty_slide", "A slide has no visual attention cues.", location=str(path), section_id=sid) + if required or strict_attention: + add(findings, "error", "visual_cues_empty_slide", "A slide has no visual attention cues.", location=str(path), section_id=sid) elif sid: slides_with_cues.add(sid) max_duration = duration_by_id.get(sid, 0) + pad_tail @@ -1385,8 +1399,8 @@ def check_timeline( add(findings, "error", "timeline_visual_target_missing", "Timeline visual cue is missing a semantic target id.", location=str(path), chunk_id=cid) else: missing_visuals += 1 - severity = "error" if strict_attention else "warning" - add(findings, severity, "timeline_chunk_no_visual_cue", "Timeline chunk has no accepted visual cue.", location=str(path), chunk_id=cid) + if strict_attention: + add(findings, "error", "timeline_chunk_no_visual_cue", "Timeline chunk has no accepted visual cue.", location=str(path), chunk_id=cid) slide_indices: set[int] = set() for slide in slides: @@ -1602,6 +1616,640 @@ def check_subtitle_delivery( return report +def check_subtitle_word_alignment( + path: Path | None, + *, + required: bool, + pad_tail: float, + findings: list[Finding], +) -> dict[str, Any]: + """Validate fail-closed subtitle evidence from actual Edge word boundaries.""" + if path is None: + if required: + add( + findings, + "error", + "subtitle_word_alignment_required", + "Strict subtitle delivery requires a word-alignment timing report.", + ) + return {"checked": False} + if not path.is_file(): + add( + findings, + "error", + "subtitle_word_alignment_missing", + "Subtitle word-alignment timing report is missing.", + location=str(path), + ) + return {"checked": False} + payload = read_json(path) + if not isinstance(payload, dict): + add( + findings, + "error", + "subtitle_word_alignment_schema", + "Subtitle timing report must be a JSON object.", + location=str(path), + ) + return {"checked": False} + if payload.get("schema_version") != "paper2video_subtitle_word_alignment.v1": + add( + findings, + "error", + "subtitle_word_alignment_schema_version", + "Subtitle timing report has an unsupported schema version.", + location=str(path), + ) + status = str(payload.get("status") or "") + if required and status != "word_aligned": + add( + findings, + "error", + "subtitle_timing_estimated", + "Final subtitles are not fully aligned to actual Edge word boundaries.", + location=str(path), + status=status, + ) + sections = payload.get("sections") or [] + if not isinstance(sections, list): + add( + findings, + "error", + "subtitle_word_alignment_sections_schema", + "Subtitle timing report sections must be an array.", + location=str(path), + ) + sections = [] + cue_count = 0 + previous_slide_end: float | None = None + for section_index, section in enumerate(sections, start=1): + if not isinstance(section, dict): + add( + findings, + "error", + "subtitle_word_alignment_section_schema", + "Subtitle timing section must be an object.", + location=str(path), + section_index=section_index, + ) + continue + source = str(section.get("timing_source") or "") + cues = section.get("cues") or [] + try: + slide_start = float(section.get("slide_start")) + audio_duration = float(section.get("audio_duration")) + except (TypeError, ValueError): + add( + findings, + "error", + "subtitle_word_alignment_section_time", + "Subtitle timing section has invalid slide/audio times.", + location=str(path), + section_index=section_index, + ) + continue + if previous_slide_end is not None and abs(slide_start - previous_slide_end) > 0.003: + add( + findings, + "error", + "subtitle_slide_offset_mismatch", + "Subtitle page offset does not match prior audio duration plus pad tail.", + location=str(path), + section_index=section_index, + expected=round(previous_slide_end, 3), + actual=round(slide_start, 3), + ) + previous_slide_end = slide_start + audio_duration + max(0.0, pad_tail) + if source == "silent": + if cues: + add( + findings, + "error", + "subtitle_silent_section_has_cues", + "A silent subtitle section unexpectedly contains cues.", + location=str(path), + section_index=section_index, + ) + continue + if source != "edge_word_boundary": + add( + findings, + "error" if required else "warning", + "subtitle_section_estimated_timing", + "Subtitle section uses estimated timing instead of Edge word boundaries.", + location=str(path), + section_index=section_index, + timing_source=source, + ) + if not isinstance(cues, list) or not cues: + add( + findings, + "error", + "subtitle_word_alignment_cues_missing", + "Narrated subtitle section has no alignment cues.", + location=str(path), + section_index=section_index, + ) + continue + previous_cue_end = -1.0 + for cue_index, cue in enumerate(cues, start=1): + cue_count += 1 + if not isinstance(cue, dict): + add( + findings, + "error", + "subtitle_word_alignment_cue_schema", + "Subtitle timing cue must be an object.", + location=str(path), + section_index=section_index, + cue_index=cue_index, + ) + continue + try: + relative_start = float(cue.get("relative_start")) + relative_end = float(cue.get("relative_end")) + absolute_start = float(cue.get("absolute_start")) + absolute_end = float(cue.get("absolute_end")) + word_start = int(cue.get("word_start")) + word_end = int(cue.get("word_end")) + except (TypeError, ValueError): + add( + findings, + "error", + "subtitle_word_alignment_cue_time", + "Subtitle timing cue lacks valid word/time boundaries.", + location=str(path), + section_index=section_index, + cue_index=cue_index, + ) + continue + if ( + cue.get("timing_source") != "edge_word_boundary" + or relative_start < 0.0 + or relative_end < relative_start + or word_end < word_start + or relative_start + 1e-6 < previous_cue_end + or abs(absolute_start - (slide_start + relative_start)) > 0.003 + or abs(absolute_end - (slide_start + relative_end)) > 0.003 + or relative_end > audio_duration + 0.05 + ): + add( + findings, + "error", + "subtitle_word_alignment_cue_mismatch", + "Subtitle cue is not an exact monotonic Edge word interval.", + location=str(path), + section_index=section_index, + cue_index=cue_index, + ) + previous_cue_end = relative_end + try: + declared_cues = int(payload.get("cue_count") or 0) + except (TypeError, ValueError): + declared_cues = -1 + if cue_count != declared_cues: + add( + findings, + "error", + "subtitle_word_alignment_cue_count", + "Subtitle timing report cue count does not match its sections.", + location=str(path), + expected=declared_cues, + actual=cue_count, + ) + return { + "checked": True, + "status": status, + "section_count": len(sections), + "cue_count": cue_count, + } + + +def _animation_effects( + payload: dict[str, Any], + *, + findings: list[Finding], + path: Path, + label: str, +) -> dict[tuple[int, int], dict[str, Any]]: + out: dict[tuple[int, int], dict[str, Any]] = {} + slides = payload.get("slides") or [] + if not isinstance(slides, list): + add(findings, "error", f"{label}_slides_schema", f"{label} slides must be an array.", location=str(path)) + return out + for slide in slides: + if not isinstance(slide, dict): + add(findings, "error", f"{label}_slide_schema", f"{label} slide entries must be objects.", location=str(path)) + continue + try: + slide_index = int(slide.get("index")) + except (TypeError, ValueError): + add(findings, "error", f"{label}_slide_index_bad", f"{label} slide index must be an integer.", location=str(path)) + continue + effects = slide.get("effects") or [] + if not isinstance(effects, list): + add(findings, "error", f"{label}_effects_schema", f"{label} effects must be an array.", location=str(path), slide_index=slide_index) + continue + for effect in effects: + if not isinstance(effect, dict): + add(findings, "error", f"{label}_effect_schema", f"{label} effect entries must be objects.", location=str(path), slide_index=slide_index) + continue + try: + order = int(effect.get("order")) + except (TypeError, ValueError): + add(findings, "error", f"{label}_effect_order_bad", f"{label} effect order must be an integer.", location=str(path), slide_index=slide_index) + continue + key = (slide_index, order) + if key in out: + add(findings, "error", f"{label}_effect_duplicate", f"{label} contains a duplicate slide/order effect key.", location=str(path), slide_index=slide_index, order=order) + continue + out[key] = effect + return out + + +def _extract_animation_sample_frames( + raw_mp4: Path, + frame_numbers: list[int], + ffmpeg: str, + output_dir: Path, +) -> dict[int, Path]: + unique = sorted(set(frame_numbers)) + if not unique: + return {} + select_expr = "+".join(f"eq(n\\,{number})" for number in unique) + pattern = output_dir / "sample-%04d.png" + cmd = [ + ffmpeg, + "-v", "error", + "-i", str(raw_mp4), + "-vf", f"select={select_expr}", + "-vsync", "0", + str(pattern), + ] + proc = subprocess.run(cmd, capture_output=True, text=True) + if proc.returncode != 0: + raise RuntimeError(proc.stderr[-2000:] or "ffmpeg sample extraction failed") + paths = sorted(output_dir.glob("sample-*.png")) + if len(paths) != len(unique): + raise RuntimeError( + f"ffmpeg extracted {len(paths)} animation samples, expected {len(unique)}" + ) + return dict(zip(unique, paths)) + + +def _animation_pixel_delta( + early_path: Path, + late_path: Path, + bbox: list[int], +) -> tuple[float, float]: + if Image is None: + raise RuntimeError("Pillow is required for animation pixel QA") + with Image.open(early_path) as early_source, Image.open(late_path) as late_source: + early = early_source.convert("RGB") + late = late_source.convert("RGB") + if early.size != late.size: + raise RuntimeError("animation sample frames have mismatched dimensions") + x, y, width, height = bbox + x0 = max(0, min(early.width - 1, x)) + y0 = max(0, min(early.height - 1, y)) + x1 = max(x0 + 1, min(early.width, x + width)) + y1 = max(y0 + 1, min(early.height, y + height)) + early_crop = early.crop((x0, y0, x1, y1)) + late_crop = late.crop((x0, y0, x1, y1)) + if np is not None: + early_array = np.asarray(early_crop, dtype=np.int16) + late_array = np.asarray(late_crop, dtype=np.int16) + delta = np.abs(late_array - early_array) + mean_abs = float(delta.mean()) + changed_fraction = float((delta.max(axis=2) >= 8).mean()) + return mean_abs, changed_fraction + + from PIL import ImageChops # type: ignore + + diff = ImageChops.difference(early_crop, late_crop) + mean_abs = float(sum(ImageStat.Stat(diff).mean) / 3.0) + gray = diff.convert("L") + histogram = gray.histogram() + pixels = max(1, gray.width * gray.height) + changed_fraction = float(sum(histogram[8:]) / pixels) + return mean_abs, changed_fraction + + +def _check_pptx_sequence_schedule( + manifest: dict[str, Any], + *, + path: Path, + findings: list[Finding], +) -> dict[str, Any]: + """Reject the mixed absolute clocks that previously allowed early overlap.""" + checked_blocks = 0 + checked_effects = 0 + gate_violations = 0 + for slide in manifest.get("slides") or []: + slide_index = int(slide.get("index") or 0) + if slide.get("schedule_policy") != "author_notes_block_sequence_v1": + add(findings, "error", "animation_sequence_policy_missing", "Editable PPTX animation manifest is missing the unified Notes/Pane sequence policy.", location=str(path), slide_index=slide_index) + continue + blocks = slide.get("sequence_blocks") or [] + prior_release = 0.0 + for expected_index, block in enumerate(blocks, start=1): + checked_blocks += 1 + try: + block_index = int(block.get("index")) + release_before = float(block.get("release_before")) + spoken_end = float(block.get("spoken_end")) + release = float(block.get("release")) + except (TypeError, ValueError): + add(findings, "error", "animation_sequence_block_bad", "Animation sequence block has invalid timing fields.", location=str(path), slide_index=slide_index, block=block) + continue + if block_index != expected_index: + add(findings, "error", "animation_sequence_block_order", "Animation sequence block indices are not contiguous.", location=str(path), slide_index=slide_index, expected=expected_index, actual=block_index) + if abs(release_before - prior_release) > 0.002: + add(findings, "error", "animation_sequence_release_chain", "Animation sequence block does not start from the prior block release.", location=str(path), slide_index=slide_index, block_index=block_index, expected=prior_release, actual=release_before) + required_release = max(release_before, spoken_end) + for effect in block.get("effects") or []: + checked_effects += 1 + try: + start = float(effect.get("start")) + end = float(effect.get("end")) + gate = float(effect.get("sequence_gate")) + except (TypeError, ValueError): + add(findings, "error", "animation_sequence_effect_bad", "Animation sequence effect has invalid timing fields.", location=str(path), slide_index=slide_index, block_index=block_index, effect=effect) + continue + if end <= start: + add(findings, "error", "animation_sequence_effect_window", "Animation sequence effect has a non-positive window.", location=str(path), slide_index=slide_index, block_index=block_index, effect=effect) + if abs(gate - release_before) > 0.002: + add(findings, "error", "animation_sequence_gate_mismatch", "Animation effect sequence gate differs from its block release gate.", location=str(path), slide_index=slide_index, block_index=block_index, expected=release_before, actual=gate) + trigger = str(effect.get("pane_trigger") or "").lower() + timing_source = str(effect.get("timing_source") or "") + overlap_allowed = timing_source == "animation_pane" and trigger == "witheffect" + if not overlap_allowed and start + 0.002 < release_before: + gate_violations += 1 + add(findings, "error", "animation_sequence_gate_violated", "A sequential animation starts before the prior Notes block narration/effects release.", location=str(path), slide_index=slide_index, block_index=block_index, handle=block.get("handle"), start=start, required_start=release_before) + required_release = max(required_release, end) + if release + 0.002 < required_release: + add(findings, "error", "animation_sequence_release_early", "Animation block releases the following block before its narration/effects finish.", location=str(path), slide_index=slide_index, block_index=block_index, release=release, required_release=required_release) + prior_release = release + try: + schedule_end = float(slide.get("schedule_end")) + except (TypeError, ValueError): + add(findings, "error", "animation_sequence_end_bad", "Animation sequence has no valid schedule_end.", location=str(path), slide_index=slide_index) + else: + if abs(schedule_end - prior_release) > 0.002: + add(findings, "error", "animation_sequence_end_mismatch", "Animation sequence end differs from the final block release.", location=str(path), slide_index=slide_index, expected=prior_release, actual=schedule_end) + return { + "schedule_policy": "author_notes_block_sequence_v1", + "checked_blocks": checked_blocks, + "checked_effects": checked_effects, + "gate_violations": gate_violations, + } + + +def check_animation_delivery( + *, + manifest_path: Path | None, + render_report_path: Path | None, + raw_mp4: Path | None, + pptx_path: Path | None = None, + required: bool, + ffmpeg: str | None, + findings: list[Finding], +) -> dict[str, Any]: + """Cross-check animation mappings and prove each transition changes MP4 pixels.""" + if manifest_path is None and render_report_path is None: + if required: + add(findings, "error", "animations_required", "Strict animation QA requires --animation-manifest and --animation-report.") + return {"checked": False} + if manifest_path is None: + add(findings, "error", "animation_manifest_required", "--animation-manifest is required when checking animations.") + return {"checked": False} + if render_report_path is None: + add(findings, "error", "animation_report_required", "--animation-report is required when checking animations.") + return {"checked": False} + if not manifest_path.is_file(): + add(findings, "error", "animation_manifest_missing", "Animation manifest is missing.", location=str(manifest_path)) + return {"checked": False} + if not render_report_path.is_file(): + add(findings, "error", "animation_report_missing", "Animation render report is missing.", location=str(render_report_path)) + return {"checked": False} + + manifest = read_json(manifest_path) + render_report = read_json(render_report_path) + if not isinstance(manifest, dict) or manifest.get("schema_version") != ANIMATION_MANIFEST_SCHEMA_VERSION: + add(findings, "error", "animation_manifest_schema", "Animation manifest has an unsupported schema.", location=str(manifest_path), schema_version=manifest.get("schema_version") if isinstance(manifest, dict) else None) + return {"checked": True} + if not isinstance(render_report, dict) or render_report.get("schema_version") != ANIMATION_RENDER_REPORT_SCHEMA_VERSION: + add(findings, "error", "animation_report_schema", "Animation render report has an unsupported schema.", location=str(render_report_path), schema_version=render_report.get("schema_version") if isinstance(render_report, dict) else None) + return {"checked": True} + + source_kind = str(manifest.get("source_kind") or "svg") + report_source_kind = str(render_report.get("source_kind") or "svg") + if source_kind not in {"svg", "pptx"}: + add(findings, "error", "animation_source_kind_bad", "Animation manifest has an unsupported source_kind.", location=str(manifest_path), source_kind=source_kind) + if report_source_kind != source_kind: + add(findings, "error", "animation_source_kind_mismatch", "Animation render source_kind does not match the manifest.", location=str(render_report_path), manifest_source_kind=source_kind, report_source_kind=report_source_kind) + if source_kind == "pptx": + sequence_schedule = _check_pptx_sequence_schedule( + manifest, + path=manifest_path, + findings=findings, + ) + manifest_sha = str(manifest.get("source_sha256") or "") + report_sha = str(render_report.get("source_sha256") or "") + if not manifest_sha or report_sha != manifest_sha: + add(findings, "error", "animation_pptx_hash_mismatch", "Editable PPTX manifest and render report do not name the same source hash.", location=str(render_report_path), manifest_sha256=manifest_sha, report_sha256=report_sha) + if pptx_path is None or not pptx_path.is_file(): + add(findings, "error", "animation_pptx_required", "Editable PPTX animation QA requires the delivered --pptx source.", location=str(pptx_path) if pptx_path else None) + elif manifest_sha and sha256_file(pptx_path) != manifest_sha: + add(findings, "error", "animation_pptx_delivery_stale", "Delivered PPTX bytes differ from the deck used to build and render animations.", location=str(pptx_path), expected_sha256=manifest_sha, actual_sha256=sha256_file(pptx_path)) + + else: + sequence_schedule = {"checked_blocks": 0, "checked_effects": 0, "gate_violations": 0} + + manifest_effects = _animation_effects(manifest, findings=findings, path=manifest_path, label="animation_manifest") + rendered_effects = _animation_effects(render_report, findings=findings, path=render_report_path, label="animation_report") + manifest_slides = manifest.get("slides") or [] + rendered_slides = render_report.get("slides") or [] + manifest_declared = int(manifest.get("effect_count") or 0) + rendered_declared = int(render_report.get("effect_count") or 0) + if int(manifest.get("slide_count") or 0) != len(manifest_slides): + add(findings, "error", "animation_manifest_slide_count", "Animation manifest slide_count does not match its slides array.", location=str(manifest_path)) + if int(render_report.get("slide_count") or 0) != len(rendered_slides): + add(findings, "error", "animation_report_slide_count", "Animation render slide_count does not match its slides array.", location=str(render_report_path)) + if manifest_declared != len(manifest_effects): + add(findings, "error", "animation_manifest_effect_count", "Animation manifest effect_count does not match its effects.", location=str(manifest_path), declared=manifest_declared, actual=len(manifest_effects)) + if rendered_declared != len(rendered_effects): + add(findings, "error", "animation_report_effect_count", "Animation render effect_count does not match its effects.", location=str(render_report_path), declared=rendered_declared, actual=len(rendered_effects)) + if set(manifest_effects) != set(rendered_effects): + missing = sorted(set(manifest_effects) - set(rendered_effects)) + extra = sorted(set(rendered_effects) - set(manifest_effects)) + add(findings, "error", "animation_effect_coverage", "Rendered animation effect keys do not exactly cover the manifest.", location=str(render_report_path), missing=missing, extra=extra) + + resolution = render_report.get("resolution") or {} + try: + render_width = int(resolution.get("width")) + render_height = int(resolution.get("height")) + fps = float(render_report.get("fps")) + except (TypeError, ValueError): + render_width = render_height = 0 + fps = 0.0 + if render_width <= 0 or render_height <= 0 or fps <= 0: + add(findings, "error", "animation_report_video_geometry", "Animation render report has invalid resolution or fps.", location=str(render_report_path)) + + valid_effects: list[tuple[tuple[int, int], dict[str, Any]]] = [] + for key, expected in manifest_effects.items(): + actual = rendered_effects.get(key) + if actual is None: + continue + locator = str(expected.get("locator") or "").strip() + name = str(expected.get("name") or "").strip() + if str(actual.get("locator") or "").strip() != locator or str(actual.get("name") or "").strip() != name: + add(findings, "error", "animation_effect_mapping_mismatch", "Rendered animation locator/name does not match the manifest.", location=str(render_report_path), slide_index=key[0], order=key[1], expected_locator=locator, actual_locator=actual.get("locator"), expected_name=name, actual_name=actual.get("name")) + continue + if source_kind == "pptx": + expected_shape_id = str(expected.get("shape_id") or "").strip() + actual_shape_id = str(actual.get("shape_id") or "").strip() + if not expected_shape_id or actual_shape_id != expected_shape_id: + add(findings, "error", "animation_shape_mapping_mismatch", "Rendered editable PPTX shape_id does not match the manifest.", location=str(render_report_path), slide_index=key[0], order=key[1], locator=locator, expected_shape_id=expected_shape_id, actual_shape_id=actual_shape_id) + continue + expected_strategy = ANIMATION_RENDER_STRATEGIES.get(name) + if expected_strategy is None: + add(findings, "error", "animation_effect_unsupported", "Animation manifest names an effect unsupported by the video renderer.", location=str(manifest_path), slide_index=key[0], order=key[1], locator=locator, name=name) + continue + if str(actual.get("strategy") or "") != expected_strategy: + add(findings, "error", "animation_strategy_mismatch", "Animation render strategy does not match the named Author Notes effect.", location=str(render_report_path), slide_index=key[0], order=key[1], locator=locator, name=name, expected_strategy=expected_strategy, actual_strategy=actual.get("strategy")) + continue + expected_timing_source = str(expected.get("timing_source") or "") + actual_timing_source = str(actual.get("timing_source") or "") + if expected_timing_source not in {"edge_word_alignment", "animation_pane"}: + add(findings, "error", "animation_timing_source_unsupported", "Animation effect has an unsupported timing source.", location=str(render_report_path), slide_index=key[0], order=key[1], locator=locator, timing_source=expected_timing_source) + elif actual_timing_source != expected_timing_source: + add(findings, "error", "animation_timing_source_mismatch", "Rendered animation timing source does not match the manifest.", location=str(render_report_path), slide_index=key[0], order=key[1], locator=locator, expected=expected_timing_source, actual=actual_timing_source) + try: + expected_start = float(expected.get("start")) + expected_duration = float(expected.get("duration")) + actual_start = float(actual.get("start")) + actual_duration = float(actual.get("duration")) + except (TypeError, ValueError): + add(findings, "error", "animation_effect_timing_bad", "Animation effect timing fields must be numeric.", location=str(render_report_path), slide_index=key[0], order=key[1], locator=locator) + continue + if abs(expected_start - actual_start) > 0.002 or abs(expected_duration - actual_duration) > 0.002: + add(findings, "error", "animation_effect_timing_mismatch", "Rendered animation timing does not match the manifest.", location=str(render_report_path), slide_index=key[0], order=key[1], locator=locator, expected=[expected_start, expected_duration], actual=[actual_start, actual_duration]) + bbox = actual.get("bbox") + bbox_valid = isinstance(bbox, list) and len(bbox) == 4 + if bbox_valid: + try: + bbox = [int(round(float(value))) for value in bbox] + except (TypeError, ValueError): + bbox_valid = False + if not bbox_valid or bbox[2] <= 0 or bbox[3] <= 0 or bbox[0] < 0 or bbox[1] < 0 or bbox[0] + bbox[2] > render_width or bbox[1] + bbox[3] > render_height: + add(findings, "error", "animation_effect_bbox_bad", "Rendered animation bbox is invalid or outside the video canvas.", location=str(render_report_path), slide_index=key[0], order=key[1], locator=locator, bbox=actual.get("bbox")) + continue + if not actual.get("rendered"): + add(findings, "error", "animation_effect_not_rendered", "Animation report does not mark the effect as rendered.", location=str(render_report_path), slide_index=key[0], order=key[1], locator=locator) + continue + try: + global_start = float(actual.get("global_start")) + global_end = float(actual.get("global_end")) + sample_early = float(actual.get("sample_early")) + sample_late = float(actual.get("sample_late")) + except (TypeError, ValueError): + add(findings, "error", "animation_sample_time_bad", "Animation render report sample and global times must be numeric.", location=str(render_report_path), slide_index=key[0], order=key[1], locator=locator) + continue + if expected_strategy == "appear": + sample_window_valid = sample_early < global_start < sample_late + else: + sample_window_valid = ( + global_start <= sample_early < sample_late <= global_end + ) + if not sample_window_valid: + add(findings, "error", "animation_sample_window_bad", "Animation pixel-QA samples do not cover the named effect transition.", location=str(render_report_path), slide_index=key[0], order=key[1], locator=locator, strategy=expected_strategy, global_window=[global_start, global_end], sample_window=[sample_early, sample_late]) + continue + valid_effects.append((key, actual)) + + pixel_checked = 0 + pixel_changed = 0 + pixel_metrics: list[dict[str, Any]] = [] + if valid_effects: + if raw_mp4 is None or not raw_mp4.is_file(): + add(findings, "error", "animation_raw_video_required", "Raw no-subtitle MP4 is required for animation pixel QA.", location=str(raw_mp4) if raw_mp4 else None) + elif not ffmpeg: + add(findings, "error", "animation_ffmpeg_required", "ffmpeg is required for animation pixel QA.") + elif Image is None: + add(findings, "error", "animation_pillow_required", "Pillow is required for animation pixel QA.") + elif fps > 0: + frame_pairs: dict[tuple[int, int], tuple[int, int]] = {} + frame_numbers: list[int] = [] + for key, effect in valid_effects: + try: + early_number = max(0, int(round(float(effect.get("sample_early")) * fps))) + late_number = max(0, int(round(float(effect.get("sample_late")) * fps))) + except (TypeError, ValueError): + add(findings, "error", "animation_sample_time_bad", "Animation sample times must be numeric.", location=str(render_report_path), slide_index=key[0], order=key[1]) + continue + if late_number <= early_number: + late_number = early_number + 1 + frame_pairs[key] = (early_number, late_number) + frame_numbers.extend((early_number, late_number)) + try: + with tempfile.TemporaryDirectory(prefix="paper2video_animation_qa_") as temp_dir: + frame_paths = _extract_animation_sample_frames(raw_mp4, frame_numbers, ffmpeg, Path(temp_dir)) + for key, effect in valid_effects: + if key not in frame_pairs: + continue + early_number, late_number = frame_pairs[key] + bbox = [int(round(float(value))) for value in effect["bbox"]] + mean_abs, changed_fraction = _animation_pixel_delta( + frame_paths[early_number], frame_paths[late_number], bbox + ) + pixel_checked += 1 + # Sparse full-width groups such as a one-pixel grid can + # be visibly animated while changing well under 1% of + # their bbox. Keep both an intensity and coverage gate. + changed = ( + mean_abs >= ANIMATION_MIN_MEAN_ABS_DELTA + or changed_fraction >= ANIMATION_MIN_CHANGED_FRACTION + ) + if changed: + pixel_changed += 1 + else: + add(findings, "error", "animation_pixel_motion_missing", "Animation transition frames do not show enough pixel change inside the mapped SVG layer.", location=str(raw_mp4), slide_index=key[0], order=key[1], locator=effect.get("locator"), name=effect.get("name"), mean_abs_delta=round(mean_abs, 4), changed_fraction=round(changed_fraction, 6)) + pixel_metrics.append( + { + "slide_index": key[0], + "order": key[1], + "locator": effect.get("locator"), + "name": effect.get("name"), + "early_frame": early_number, + "late_frame": late_number, + "mean_abs_delta": round(mean_abs, 4), + "changed_fraction": round(changed_fraction, 6), + "changed": changed, + } + ) + except (OSError, RuntimeError, subprocess.SubprocessError) as exc: + add(findings, "error", "animation_pixel_check_failed", f"Could not extract or compare animation transition frames: {exc}", location=str(raw_mp4)) + + if required and (not manifest_effects or len(manifest_effects) != len(rendered_effects)): + add(findings, "error", "animation_delivery_incomplete", "Required animation delivery has no effects or incomplete effect coverage.", location=str(render_report_path)) + if required and pixel_checked != len(manifest_effects): + add(findings, "error", "animation_pixel_coverage_incomplete", "Pixel QA did not check every required animation effect.", location=str(raw_mp4) if raw_mp4 else None, expected=len(manifest_effects), checked=pixel_checked) + + return { + "checked": True, + "source_kind": source_kind, + "manifest_slide_count": len(manifest_slides), + "rendered_slide_count": len(rendered_slides), + "manifest_effect_count": len(manifest_effects), + "rendered_effect_count": len(rendered_effects), + "pixel_checked_effects": pixel_checked, + "pixel_changed_effects": pixel_changed, + "timing_source": render_report.get("timing_source"), + "sequence_schedule": sequence_schedule, + "pixel_metrics": pixel_metrics, + } + + def write_report(path: Path, report: dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") @@ -1640,6 +2288,10 @@ def maybe_write_manifest(project_dir: Path, args: argparse.Namespace, report_pat files["visual_cues"] = rel_to(args.visual_cues.resolve(), project_dir) if args.cue_plan: files["visual_cue_plan"] = rel_to(args.cue_plan.resolve(), project_dir) + if args.animation_manifest: + files["animation_manifest"] = rel_to(args.animation_manifest.resolve(), project_dir) + if args.animation_report: + files["animation_render_report"] = rel_to(args.animation_report.resolve(), project_dir) manifest = { "schema_version": "paper2video.v1", @@ -1674,6 +2326,9 @@ def main() -> None: parser.add_argument("--mp4", type=Path) parser.add_argument("--raw-mp4", type=Path, help="Raw MP4 before add_subtitles.py; used to verify final subtitle delivery.") parser.add_argument("--subtitle-file", type=Path, help="SRT/VTT sidecar written by add_subtitles.py.") + parser.add_argument("--subtitle-timing-report", type=Path, help="Exact Edge word-alignment evidence written by add_subtitles.py.") + parser.add_argument("--animation-manifest", type=Path, help="Author Notes animation manifest used by render_video.py.") + parser.add_argument("--animation-report", type=Path, help="Persistent animation render report written by render_video.py.") parser.add_argument("--target-minutes", type=float) parser.add_argument("--duration-tolerance-seconds", type=float, default=30.0) parser.add_argument("--pad-tail", type=float, default=0.3) @@ -1686,7 +2341,9 @@ def main() -> None: parser.add_argument("--require-timeline", action="store_true", help="Fail when timeline.json is omitted or invalid.") parser.add_argument("--require-rate-plan", action="store_true", help="Fail when tts_rate_plan.json is omitted for duration-controlled video.") parser.add_argument("--require-subtitles", action="store_true", help="Fail unless subtitle sidecar exists and final MP4 differs from the raw pre-subtitle render.") + parser.add_argument("--require-subtitle-word-alignment", action="store_true", help="Fail unless every narrated subtitle cue uses exact Edge word-boundary timing.") parser.add_argument("--require-word-timings", action="store_true", help="Fail if cue timings are proportional estimates rather than word-boundary timings.") + parser.add_argument("--require-animations", action="store_true", help="Fail unless every mapped Author Notes effect was rendered and passes pixel-motion QA.") parser.add_argument("--strict-attention", action="store_true", help="Promote cue-plan semantic-alignment risks to hard failures.") parser.add_argument("--allow-missing-attention", action="store_true", help="Degraded/debug only: allow --strict without visual cues/cue plan/timeline gates.") parser.add_argument("--require-pptx-anchors", action="store_true", help="Require strict visual anchors to resolve to PPTX geometry.") @@ -1778,6 +2435,21 @@ def main() -> None: ffmpeg=ffmpeg, findings=findings, ) + subtitle_timing_report = check_subtitle_word_alignment( + args.subtitle_timing_report.resolve() if args.subtitle_timing_report else None, + required=args.require_subtitle_word_alignment, + pad_tail=args.pad_tail, + findings=findings, + ) + animation_report = check_animation_delivery( + manifest_path=args.animation_manifest.resolve() if args.animation_manifest else None, + render_report_path=args.animation_report.resolve() if args.animation_report else None, + raw_mp4=args.raw_mp4.resolve() if args.raw_mp4 else None, + pptx_path=args.pptx.resolve() if args.pptx else None, + required=args.require_animations, + ffmpeg=ffmpeg, + findings=findings, + ) counts = { "error": sum(1 for f in findings if f.severity == "error"), @@ -1803,6 +2475,9 @@ def main() -> None: "mp4": str(args.mp4.resolve()) if args.mp4 else None, "raw_mp4": str(args.raw_mp4.resolve()) if args.raw_mp4 else None, "subtitle_file": str(args.subtitle_file.resolve()) if args.subtitle_file else None, + "subtitle_timing_report": str(args.subtitle_timing_report.resolve()) if args.subtitle_timing_report else None, + "animation_manifest": str(args.animation_manifest.resolve()) if args.animation_manifest else None, + "animation_report": str(args.animation_report.resolve()) if args.animation_report else None, }, "options": { "strict": args.strict, @@ -1811,11 +2486,13 @@ def main() -> None: "require_mp4": require_mp4, "require_visual_cues": args.require_visual_cues, "require_cue_plan": args.require_cue_plan, + "require_subtitle_word_alignment": args.require_subtitle_word_alignment, "require_anchor_contract": args.require_anchor_contract, "require_timeline": args.require_timeline, "require_rate_plan": args.require_rate_plan, "require_subtitles": args.require_subtitles, "require_word_timings": args.require_word_timings, + "require_animations": args.require_animations, "strict_attention": strict_attention_required, "allow_missing_attention": args.allow_missing_attention, "require_pptx_anchors": args.require_pptx_anchors, @@ -1836,6 +2513,8 @@ def main() -> None: "tts_rate_plan": rate_plan_report, "video": video_report, "subtitles": subtitle_report, + "subtitle_timing": subtitle_timing_report, + "animations": animation_report, "findings": [f.__dict__ for f in findings], } out_path = args.out or default_report_path(project_dir) diff --git a/ResearchStudio-Reel/skills/paper2video/scripts/editable_pptx.py b/ResearchStudio-Reel/skills/paper2video/scripts/editable_pptx.py new file mode 100644 index 0000000..3d1cc02 --- /dev/null +++ b/ResearchStudio-Reel/skills/paper2video/scripts/editable_pptx.py @@ -0,0 +1,3319 @@ +#!/usr/bin/env python3 +"""Deterministic PowerPoint Author Notes and Alt Text protocol utilities. + +This module treats the PPTX as the editable source. It reads native PowerPoint +entrance and emphasis rows, reconciles their targets with authoritative Author +Notes, generates compact Alt Text, and produces narration and animation metadata +without an LLM or a ppt-master project. +""" + +from __future__ import annotations + +import hashlib +import json +import posixpath +import re +import shutil +import tempfile +from collections import OrderedDict +from copy import deepcopy +from datetime import datetime, timezone +from pathlib import Path +from typing import Iterable +from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo + +from lxml import etree + + +P_NS = "http://schemas.openxmlformats.org/presentationml/2006/main" +A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main" +R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" +PKG_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships" +NS = {"p": P_NS, "a": A_NS, "r": R_NS} + +PROTOCOL_SCHEMA_VERSION = "paper2video_editable_pptx.v2" +MANIFEST_SCHEMA_VERSION = "paper2video_animation_manifest.v1" +ALT_HANDLE_RE = re.compile(r"^\s*\[([^\]\n]+)\]\s*$") +LEGACY_ALT_ID_RE = re.compile(r"^\s*\[ID\]\s+(.+?)\s*$", re.IGNORECASE) +BLOCK_RE = re.compile(r"^\s{0,3}##\s+\[([^\]\n]+)\](?:\s+(.*\S))?\s*$") +# Match both the legacy point marker ``[[Name]]`` and the spoken-span marker +# ``[[Spotlight] words that remain in narration]``. The second capture is +# intentionally restricted to one line and one bracket-delimited span so a +# malformed protocol fails closed instead of swallowing adjacent Notes text. +MARKER_RE = re.compile(r"\[\[\s*([^\]\n]+?)\s*\](?:\s*([^\]\n]+?)\s*)?\]") +ALT_FIELD_RE = re.compile( + r"^\s*(Animations|Script|Shape|Script-Baseline-SHA256)\s*:\s*(.*?)\s*$", + re.IGNORECASE, +) +ALT_METADATA_START = "[Paper2Video]" +ALT_METADATA_END = "[/Paper2Video]" +SCRIPT_PROVENANCE_NS = ( + "https://github.com/microsoft/ResearchStudio/paper2video/" + "script-provenance/2026" +) +SCRIPT_PROVENANCE_EXT_URI = SCRIPT_PROVENANCE_NS +SCRIPT_HASH_RE = re.compile(r"^[0-9a-f]{64}$") +ORDER_SOURCES = {"author_notes", "animation_pane", "geometry"} + +etree.register_namespace("p2v", SCRIPT_PROVENANCE_NS) + +# PPT Master's established presetID/presetSubtype compatibility contract. +EFFECT_NAMES = { + ("1", "0"): "Appear", + ("10", "0"): "Fade In", + ("2", "4"): "Fly In", + ("42", "8"): "Cut In", + ("23", "0"): "Zoom In", + ("22", "1"): "Wipe In", + ("16", "21"): "Split In", + ("3", "10"): "Blinds In", + ("5", "6"): "Checkerboard In", + ("9", "0"): "Dissolve In", + ("14", "10"): "Random Bars In", + ("12", "4"): "Peek In", + ("21", "0"): "Wheel In", + ("4", "0"): "Box In", + ("6", "0"): "Circle In", + ("8", "0"): "Diamond In", + ("13", "0"): "Plus In", + ("18", "12"): "Strips In", + ("20", "0"): "Wedge In", + ("17", "0"): "Stretch In", + ("50", "0"): "Expand In", + ("19", "0"): "Swivel In", +} + +VIDEO_EFFECT_SECONDS = { + "Appear": 0.12, + "Fade In": 0.48, + "Dissolve In": 0.48, + "Fly In": 0.56, + "Wipe In": 0.52, + "Zoom In": 0.48, + "Circle In": 0.52, + "Diamond In": 0.52, +} + + +class ProtocolError(ValueError): + """The editable PPTX protocol is incomplete, ambiguous, or inconsistent.""" + + +def _utc_now() -> str: + return ( + datetime.now(timezone.utc) + .replace(microsecond=0) + .isoformat() + .replace("+00:00", "Z") + ) + + +def file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _oneline(value: object) -> str: + return " ".join(str(value or "").split()) + + +def _normalized_chars(value: object) -> str: + return "".join(re.findall(r"[a-z0-9]+", str(value or "").lower())) + + +def normalize_script_for_hash(raw_script: object) -> str: + """Return the stable marker-bearing script representation used for provenance.""" + return _oneline(raw_script) + + +def script_sha256(raw_script: object) -> str: + """Hash narration without losing named animation markers or their order.""" + return hashlib.sha256( + normalize_script_for_hash(raw_script).encode("utf-8") + ).hexdigest() + + +def _script_provenance_from_cnvpr( + node: etree._Element, +) -> dict[str, object] | None: + ext_lst = node.find(f"{{{A_NS}}}extLst") + if ext_lst is None: + return None + baselines: list[etree._Element] = [] + for extension in ext_lst.findall(f"{{{A_NS}}}ext"): + if extension.get("uri") != SCRIPT_PROVENANCE_EXT_URI: + continue + baselines.extend( + extension.findall(f"{{{SCRIPT_PROVENANCE_NS}}}scriptBaseline") + ) + if not baselines: + return None + if len(baselines) != 1: + raise ProtocolError("shape has duplicate Paper2Video script baseline metadata") + value = str(baselines[0].get("sha256") or "").lower() + if not SCRIPT_HASH_RE.fullmatch(value): + raise ProtocolError("shape has an invalid Paper2Video script baseline SHA-256") + provenance: dict[str, object] = {"sha256": value} + order_source = _oneline(baselines[0].get("orderSource")).lower().replace( + "-", "_" + ) + if order_source: + if order_source not in ORDER_SOURCES: + raise ProtocolError( + "shape has an invalid Paper2Video orderSource provenance value" + ) + provenance["order_source"] = order_source + raw_order_index = _oneline(baselines[0].get("orderIndex")) + if raw_order_index: + try: + order_index = int(raw_order_index) + except ValueError as exc: + raise ProtocolError( + "shape has an invalid Paper2Video orderIndex provenance value" + ) from exc + if order_index < 0: + raise ProtocolError( + "shape has a negative Paper2Video orderIndex provenance value" + ) + provenance["order_index"] = order_index + return provenance + + +def _script_baseline_from_cnvpr(node: etree._Element) -> str | None: + provenance = _script_provenance_from_cnvpr(node) + return str(provenance["sha256"]) if provenance is not None else None + + +def _set_script_baseline_on_cnvpr( + node: etree._Element, + raw_script: object, + *, + order_source: str | None = None, + order_index: int | None = None, +) -> str: + baseline_hash = script_sha256(raw_script) + previous = _script_provenance_from_cnvpr(node) or {} + effective_order_source = ( + _oneline(order_source).lower().replace("-", "_") + if order_source is not None + else str(previous.get("order_source") or "") + ) + if effective_order_source and effective_order_source not in ORDER_SOURCES: + raise ProtocolError( + f"invalid Paper2Video order source {effective_order_source!r}" + ) + effective_order_index = ( + int(order_index) + if order_index is not None + else previous.get("order_index") + ) + if effective_order_index is not None and int(effective_order_index) < 0: + raise ProtocolError("Paper2Video order index must not be negative") + ext_lst = node.find(f"{{{A_NS}}}extLst") + if ext_lst is None: + ext_lst = etree.SubElement(node, f"{{{A_NS}}}extLst") + for extension in list(ext_lst.findall(f"{{{A_NS}}}ext")): + if extension.get("uri") == SCRIPT_PROVENANCE_EXT_URI: + ext_lst.remove(extension) + extension = etree.SubElement( + ext_lst, + f"{{{A_NS}}}ext", + uri=SCRIPT_PROVENANCE_EXT_URI, + ) + baseline = etree.SubElement( + extension, + f"{{{SCRIPT_PROVENANCE_NS}}}scriptBaseline", + ) + baseline.set("algorithm", "sha256") + baseline.set("normalization", "oneline-v1") + baseline.set("sha256", baseline_hash) + if effective_order_source: + baseline.set("orderSource", effective_order_source) + if effective_order_index is not None: + baseline.set("orderIndex", str(int(effective_order_index))) + return baseline_hash + + +def parse_alt_id(description: str | None) -> str | None: + """Return the handle from canonical ``[handle]`` Alt Text. + + ``[ID] handle`` remains readable so previously delivered decks continue to + rerender, but every writer in this module emits the unified bracket form. + """ + if not description: + return None + first = description.splitlines()[0] + match = ALT_HANDLE_RE.fullmatch(first) + if match is None: + match = LEGACY_ALT_ID_RE.fullmatch(first) + if not match: + return None + handle = _oneline(match.group(1)) + if not handle: + raise ProtocolError("Alt Text handle must not be empty") + return handle + + +def _managed_alt_order_source(description: str | None) -> str | None: + inside_managed = False + for line in (description or "").splitlines()[1:]: + marker = line.strip() + if marker == ALT_METADATA_START: + inside_managed = True + continue + if marker == ALT_METADATA_END: + inside_managed = False + continue + if not inside_managed: + continue + match = re.fullmatch(r"\s*Order-Source\s*:\s*(.*?)\s*", line, re.IGNORECASE) + if match is None: + continue + source = _oneline(match.group(1)).lower().replace("-", "_") + if source in {"author_notes", "animation_pane", "geometry"}: + return source + return None + + +def build_system_alt_text( + *, + handle: str, + animation_names: Iterable[str], + raw_script: str, + shape_name: str, + shape_id: str, + slide_index: int, + existing_description: str | None = None, + order_source: str = "author_notes", + baseline_hash: str | None = None, +) -> str: + """Build the compact, user-editable Alt Text surface. + + Older decks may contain a verbose ``[Paper2Video]`` block. Readers remain + backward compatible with that format, but every writeback intentionally + migrates it to exactly two fields. Script provenance and ordering live in + the shape's ``p2v:scriptBaseline`` OOXML extension, while native animation + details remain in PowerPoint's ``p:timing`` tree. + """ + clean_handle = _oneline(handle) + if not clean_handle or "]" in clean_handle or "\n" in clean_handle: + raise ProtocolError(f"invalid Alt Text handle: {handle!r}") + return f"[{clean_handle}]\nScript: {_oneline(raw_script)}" + + +def parse_marked_transcript(raw: str) -> tuple[str, list[dict[str, object]]]: + """Strip point markers while retaining optional Spotlight spoken spans.""" + pieces: list[str] = [] + markers: list[dict[str, object]] = [] + cursor = 0 + for match in MARKER_RE.finditer(raw or ""): + pieces.append(raw[cursor:match.start()]) + spoken_prefix = "".join(pieces) + name = _oneline(match.group(1)) + if not name: + raise ProtocolError("animation marker name must not be empty") + marker = { + "name": name, + "word": len(spoken_prefix.split()), + "normalized_char": len(_normalized_chars(spoken_prefix)), + } + if match.group(2) is not None: + scope_text = _oneline(match.group(2)) + if not scope_text or not _normalized_chars(scope_text): + raise ProtocolError("Spotlight spoken scope must not be empty") + if name != "Spotlight": + raise ProtocolError( + f"spoken-span syntax is supported only for Spotlight, not {name!r}" + ) + marker["scope_text"] = scope_text + marker["normalized_end_char"] = ( + int(marker["normalized_char"]) + len(_normalized_chars(scope_text)) + ) + pieces.append(scope_text) + markers.append(marker) + cursor = match.end() + pieces.append((raw or "")[cursor:]) + clean = _oneline("".join(pieces)) + return clean, markers + + +def parse_alt_protocol(description: str | None) -> dict[str, object] | None: + """Read the explicit narration and animation fields from Shape Alt Text. + + Both the generated ``[Paper2Video]`` block and a compact user-authored + ``Script: ...`` line are accepted. Arbitrary accessibility prose is never + treated as narration unless it is explicitly prefixed with ``Script:``. + """ + handle = parse_alt_id(description) + if not handle: + return None + fields: dict[str, str] = {} + managed_fields: set[str] = set() + inside_managed = False + for line in (description or "").splitlines()[1:]: + marker = line.strip() + if marker == ALT_METADATA_START: + inside_managed = True + continue + if marker == ALT_METADATA_END: + inside_managed = False + continue + match = ALT_FIELD_RE.fullmatch(line) + if match: + key = match.group(1).lower() + fields[key] = match.group(2).strip() + if inside_managed: + managed_fields.add(key) + if "script" not in fields: + return None + raw_script = fields.get("script", "") + if not raw_script: + return None + transcript, markers = parse_marked_transcript(raw_script) + animation_names = [] + if "animations" not in managed_fields: + animation_names = [ + _oneline(name) + for name in re.split(r"\s*;\s*", fields.get("animations", "")) + if _oneline(name) + ] + return { + "handle": handle, + "semantic": _oneline(fields.get("shape")), + "raw_transcript": raw_script, + "transcript": transcript, + "markers": markers, + "animation_names": animation_names, + "baseline_hash_mirror": fields.get("script-baseline-sha256") or None, + } + + +def _legacy_plain_alt_script(description: str | None) -> str: + """Return narration from a pre-protocol plain Alt Text description. + + This compatibility path is used only while normalizing an animated target + that has neither canonical Notes nor an explicit managed ``Script:``. Once + consumed, the text is written back as a real Script field and canonical + Notes, so later renders no longer depend on this heuristic. + """ + text = str(description or "").strip() + if not text or parse_alt_id(text) is not None or ALT_METADATA_START in text: + return "" + if any(ALT_FIELD_RE.fullmatch(line) for line in text.splitlines()): + return "" + return _oneline(text) + + +def parse_notes_blocks(notes: str) -> list[dict[str, object]]: + """Parse the canonical ``## [handle]`` Author Notes grammar.""" + blocks: list[dict[str, object]] = [] + current: dict[str, object] | None = None + preamble: list[str] = [] + for line_number, line in enumerate((notes or "").splitlines(), start=1): + match = BLOCK_RE.fullmatch(line) + if match: + handle = _oneline(match.group(1)) + if not handle: + raise ProtocolError(f"line {line_number}: block handle must not be empty") + current = { + "handle": handle, + "semantic": _oneline(match.group(2)), + "lines": [], + } + blocks.append(current) + elif current is None: + if line.strip(): + preamble.append(line.strip()) + else: + current["lines"].append(line) # type: ignore[index,union-attr] + if preamble: + raise ProtocolError("Author Notes contain text before the first ## [handle] block") + if not blocks: + raise ProtocolError("Author Notes contain no ## [handle] blocks") + + seen: set[str] = set() + parsed: list[dict[str, object]] = [] + for block in blocks: + handle = str(block["handle"]) + if handle in seen: + raise ProtocolError(f"duplicate Author Notes handle: {handle!r}") + seen.add(handle) + raw = "\n".join(block["lines"]).strip() # type: ignore[arg-type] + transcript, markers = parse_marked_transcript(raw) + parsed.append( + { + "handle": handle, + "semantic": block["semantic"], + "raw_transcript": raw, + "transcript": transcript, + "markers": markers, + } + ) + return parsed + + +def _resolve_part(source_part: str, target: str) -> str: + if target.startswith("/"): + return target.lstrip("/") + return posixpath.normpath(posixpath.join(posixpath.dirname(source_part), target)) + + +def _rels_part(part: str) -> str: + directory, name = posixpath.split(part) + return posixpath.join(directory, "_rels", f"{name}.rels") + + +def _relationships(archive: ZipFile, source_part: str) -> dict[str, tuple[str, str]]: + rels_name = _rels_part(source_part) + if rels_name not in archive.namelist(): + return {} + root = etree.fromstring(archive.read(rels_name)) + result: dict[str, tuple[str, str]] = {} + for rel in root.findall(f"{{{PKG_REL_NS}}}Relationship"): + rid = str(rel.get("Id") or "") + target = str(rel.get("Target") or "") + rel_type = str(rel.get("Type") or "") + if rid and target: + result[rid] = (_resolve_part(source_part, target), rel_type) + return result + + +def presentation_slides(archive: ZipFile) -> list[dict[str, object]]: + """Return slide parts in presentation order with stable ``p:sldId`` IDs.""" + presentation_part = "ppt/presentation.xml" + root = etree.fromstring(archive.read(presentation_part)) + rels = _relationships(archive, presentation_part) + slides: list[dict[str, object]] = [] + for index, node in enumerate(root.xpath("./p:sldIdLst/p:sldId", namespaces=NS), start=1): + rid = str(node.get(f"{{{R_NS}}}id") or "") + relation = rels.get(rid) + if relation is None: + raise ProtocolError(f"presentation slide relationship {rid!r} is missing") + slides.append( + { + "index": index, + "stable_id": str(node.get("id") or index), + "part": relation[0], + } + ) + if not slides: + raise ProtocolError("PPTX presentation contains no slides") + return slides + + +def presentation_size(archive: ZipFile) -> tuple[int, int]: + root = etree.fromstring(archive.read("ppt/presentation.xml")) + nodes = root.xpath("./p:sldSz", namespaces=NS) + if len(nodes) != 1: + raise ProtocolError("PPTX presentation must contain one p:sldSz") + try: + width = int(nodes[0].get("cx")) + height = int(nodes[0].get("cy")) + except (TypeError, ValueError) as exc: + raise ProtocolError("PPTX presentation has invalid slide dimensions") from exc + if width <= 0 or height <= 0: + raise ProtocolError("PPTX presentation has non-positive slide dimensions") + return width, height + + +def _notes_part_optional(archive: ZipFile, slide_part: str) -> str | None: + notes_suffix = "/notesSlide" + for target, rel_type in _relationships(archive, slide_part).values(): + if rel_type.endswith(notes_suffix): + return target + return None + + +def _notes_part(archive: ZipFile, slide_part: str) -> str: + notes_part = _notes_part_optional(archive, slide_part) + if notes_part is not None: + return notes_part + raise ProtocolError(f"{slide_part} has no Author Notes part") + + +def _notes_text(archive: ZipFile, slide_part: str) -> str: + notes_part = _notes_part(archive, slide_part) + root = etree.fromstring(archive.read(notes_part)) + bodies = root.xpath( + './/p:sp[p:nvSpPr/p:nvPr/p:ph[@type="body"]]', namespaces=NS + ) + if len(bodies) != 1: + raise ProtocolError( + f"{notes_part} must contain exactly one notes body placeholder, found {len(bodies)}" + ) + paragraphs = bodies[0].xpath("./p:txBody/a:p", namespaces=NS) + return "\n".join("".join(p.xpath(".//a:t/text()", namespaces=NS)) for p in paragraphs) + + +def _notes_text_optional(archive: ZipFile, slide_part: str) -> str: + notes_part = _notes_part_optional(archive, slide_part) + if notes_part is None: + return "" + root = etree.fromstring(archive.read(notes_part)) + bodies = root.xpath( + './/p:sp[p:nvSpPr/p:nvPr/p:ph[@type="body"]]', namespaces=NS + ) + if not bodies: + return "" + if len(bodies) != 1: + raise ProtocolError( + f"{notes_part} must contain at most one notes body placeholder, found {len(bodies)}" + ) + paragraphs = bodies[0].xpath("./p:txBody/a:p", namespaces=NS) + return "\n".join("".join(p.xpath(".//a:t/text()", namespaces=NS)) for p in paragraphs) + + +def _canonical_notes_blocks(notes: str) -> list[dict[str, object]]: + """Return canonical blocks, treating ordinary presenter notes as absent.""" + if not any(BLOCK_RE.fullmatch(line) for line in (notes or "").splitlines()): + return [] + return parse_notes_blocks(notes) + + +def _native_effects(slide_root: etree._Element) -> list[dict[str, object]]: + effects: list[dict[str, object]] = [] + for ctn in slide_root.xpath( + './/p:cTn[@presetClass="entr" or @presetClass="emph"]', namespaces=NS + ): + spids = list(OrderedDict.fromkeys(ctn.xpath(".//p:spTgt/@spid", namespaces=NS))) + if len(spids) != 1: + raise ProtocolError( + "each native entrance effect must resolve to exactly one shape target" + ) + effect_kind = str(ctn.get("presetClass") or "") + preset_id = str(ctn.get("presetID") or "") + subtype = str(ctn.get("presetSubtype") or "0") + if effect_kind == "entr": + name = EFFECT_NAMES.get((preset_id, subtype)) + if name is None: + raise ProtocolError( + f"unsupported native PowerPoint entrance tuple {(preset_id, subtype)!r}" + ) + kind = "entrance" + else: + name = "Spotlight" + kind = "emphasis" + durations = [ + int(value) + for value in ctn.xpath(".//p:cTn/@dur", namespaces=NS) + if str(value).isdigit() + ] + raw_delays = ctn.xpath("./p:stCondLst/p:cond/@delay", namespaces=NS) + delay_ms = next( + (int(value) for value in raw_delays if str(value).isdigit()), + 0, + ) + effects.append( + { + "native_order": len(effects) + 1, + "shape_id": spids[0], + "name": name, + "kind": kind, + "preset_id": preset_id, + "preset_subtype": subtype, + "trigger": str(ctn.get("nodeType") or ""), + "delay_seconds": round(delay_ms / 1000.0, 3), + "duration_seconds": ( + round(max(durations) / 1000.0, 3) + if durations + else (2.4 if kind == "emphasis" else None) + ), + } + ) + + timeline_end = 0.0 + previous_start = 0.0 + previous_end = 0.0 + click_group = 0 + simultaneous_group = 0 + for index, effect in enumerate(effects): + trigger = str(effect.get("trigger") or "").lower() + delay = float(effect.get("delay_seconds") or 0.0) + duration = float( + effect.get("duration_seconds") + or ( + 2.4 + if str(effect.get("kind") or "") == "emphasis" + else VIDEO_EFFECT_SECONDS.get(str(effect.get("name") or ""), 0.48) + ) + ) + if index == 0: + click_group = 1 + simultaneous_group = 1 + start = delay + elif trigger == "witheffect": + start = previous_start + delay + elif trigger == "aftereffect": + simultaneous_group += 1 + start = previous_end + delay + else: + click_group += 1 + simultaneous_group += 1 + start = timeline_end + delay + end = start + max(0.0, duration) + effect["pane_start_seconds"] = round(start, 3) + effect["pane_end_seconds"] = round(end, 3) + effect["click_group"] = click_group + effect["simultaneous_group"] = simultaneous_group + previous_start = start + previous_end = end + timeline_end = max(timeline_end, end) + return effects + + +def _shape_maps( + slide_root: etree._Element, + *, + slide_width: int, + slide_height: int, +) -> tuple[dict[str, dict[str, object]], dict[str, dict[str, object]]]: + by_id: dict[str, dict[str, object]] = {} + by_handle: dict[str, dict[str, object]] = {} + for node in slide_root.xpath(".//p:spTree//p:cNvPr", namespaces=NS): + shape_id = str(node.get("id") or "") + if not shape_id: + continue + top = node + while top.getparent() is not None and top.getparent().tag != f"{{{P_NS}}}spTree": + top = top.getparent() + top_id_nodes = top.xpath( + "./p:nvSpPr/p:cNvPr | ./p:nvPicPr/p:cNvPr | " + "./p:nvGraphicFramePr/p:cNvPr | ./p:nvGrpSpPr/p:cNvPr", + namespaces=NS, + ) + top_id = str(top_id_nodes[0].get("id") or "") if top_id_nodes else "" + xfrms = top.xpath( + "./p:spPr/a:xfrm | ./p:grpSpPr/a:xfrm | ./p:xfrm", + namespaces=NS, + ) + bbox: list[float] | None = None + if xfrms: + offsets = xfrms[0].xpath("./a:off", namespaces=NS) + extents = xfrms[0].xpath("./a:ext", namespaces=NS) + if offsets and extents: + try: + x = int(offsets[0].get("x")) + y = int(offsets[0].get("y")) + width = int(extents[0].get("cx")) + height = int(extents[0].get("cy")) + bbox = [ + round(x / slide_width, 6), + round(y / slide_height, 6), + round(width / slide_width, 6), + round(height / slide_height, 6), + ] + except (TypeError, ValueError): + bbox = None + provenance = _script_provenance_from_cnvpr(node) + info: dict[str, object] = { + "shape_id": shape_id, + "shape_name": str(node.get("name") or ""), + "description": str(node.get("descr") or ""), + "script_baseline_sha256": ( + str(provenance["sha256"]) if provenance is not None else None + ), + "order_source": ( + str(provenance.get("order_source") or "") + if provenance is not None + else None + ), + "order_index": ( + int(provenance["order_index"]) + if provenance is not None and "order_index" in provenance + else None + ), + "top_level": top_id == shape_id, + "bbox": bbox, + } + if shape_id in by_id: + raise ProtocolError(f"duplicate shape id {shape_id!r} on one slide") + by_id[shape_id] = info + handle = parse_alt_id(info["description"]) + if handle: + if handle in by_handle: + raise ProtocolError(f"duplicate Alt Text handle {handle!r} on one slide") + info["handle"] = handle + by_handle[handle] = info + return by_id, by_handle + + +def _section_ids_from_script(path: Path) -> list[str]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ProtocolError(f"could not read section IDs from {path}: {exc}") from exc + sections = payload.get("sections") or [] + ids = [str(section.get("id") or "").strip() for section in sections] + if not ids or any(not item for item in ids): + raise ProtocolError(f"{path} has missing or empty section IDs") + return ids + + +def _effect_kind(name: str, *, context: str) -> str: + if name == "Spotlight": + return "emphasis" + if name in VIDEO_EFFECT_SECONDS: + return "entrance" + raise ProtocolError(f"{context} requests unsupported video effect {name!r}") + + +def _default_marker(name: str, source: str) -> dict[str, object]: + return { + "name": name, + "word": 0, + "normalized_char": 0, + "source": source, + } + + +def _merge_effect_intents( + *, + shape_id: str, + handle: str, + native_effects: list[dict[str, object]], + markers: list[dict[str, object]], + animation_names: list[str], + authority: str, + synthetic_order_base: int, +) -> tuple[list[dict[str, object]], list[dict[str, object]], list[dict[str, object]]]: + """Merge script-timed intents with native rows, preserving non-conflicts. + + Explicit Notes or Alt Text intents consume one native row of the same kind. + The explicit name and timing win that conflict. Native rows of another kind + remain playable, and an explicit intent without a native row becomes a + deterministic MP4-only effect on the resolved shape. + """ + explicit_markers = [dict(marker) for marker in markers] + if not explicit_markers and animation_names: + explicit_markers = [ + _default_marker(name, f"{authority}_default") for name in animation_names + ] + unused = list(range(len(native_effects))) + markers_out: list[dict[str, object]] = [] + effects_out: list[dict[str, object]] = [] + conflicts: list[dict[str, object]] = [] + synthetic_index = 0 + + for marker in explicit_markers: + requested_name = str(marker["name"]) + kind = _effect_kind( + requested_name, + context=f"{authority} handle {handle!r}", + ) + matching_index = next( + ( + index + for index in unused + if str(native_effects[index].get("kind") or "entrance") == kind + ), + None, + ) + marker_out = dict(marker) + marker_out["source"] = authority + if matching_index is None: + synthetic_index += 1 + effect = { + "native_order": synthetic_order_base + synthetic_index, + "shape_id": shape_id, + "name": requested_name, + "native_name": None, + "kind": kind, + "preset_id": "", + "preset_subtype": "", + "trigger": authority, + "duration_seconds": ( + 2.4 if kind == "emphasis" else VIDEO_EFFECT_SECONDS[requested_name] + ), + "authority": authority, + "native_present": False, + } + else: + unused.remove(matching_index) + native = native_effects[matching_index] + native_name = str(native["name"]) + effect = { + **native, + "name": requested_name, + "native_name": native_name, + "authority": authority if requested_name != native_name else "matched", + "native_present": True, + } + if requested_name != native_name: + conflict = { + "handle": handle, + "shape_id": shape_id, + "kind": kind, + "native_name": native_name, + "requested_name": requested_name, + "resolution": authority, + } + conflict[f"{authority}_name"] = requested_name + conflicts.append(conflict) + markers_out.append(marker_out) + effects_out.append(effect) + + for index in unused: + native = native_effects[index] + native_name = str(native["name"]) + markers_out.append(_default_marker(native_name, "animation_pane_default")) + effects_out.append( + { + **native, + "native_name": native_name, + "authority": "animation_pane", + "native_present": True, + } + ) + return markers_out, effects_out, conflicts + + +def _spatially_ordered( + items: Iterable[dict[str, object]], +) -> list[dict[str, object]]: + """Order targets in visual reading rows, then left-to-right within a row. + + A raw ``(y, x)`` key is too sensitive to small alignment differences. Cards + that visibly share a row often have slightly different top coordinates, so + cluster them by substantial vertical overlap before comparing their x + positions. Targets without usable geometry remain deterministic at the end. + """ + valid: list[dict[str, object]] = [] + invalid: list[dict[str, object]] = [] + for item in items: + bbox = item.get("bbox") + if not isinstance(bbox, list) or len(bbox) != 4: + invalid.append(item) + continue + try: + x, y, width, height = (float(value) for value in bbox) + except (TypeError, ValueError): + invalid.append(item) + continue + if width <= 0 or height <= 0: + invalid.append(item) + continue + copy = dict(item) + copy["_spatial_box"] = (x, y, width, height) + valid.append(copy) + + valid.sort( + key=lambda item: ( + float(item["_spatial_box"][1]) + + float(item["_spatial_box"][3]) / 2.0, + float(item["_spatial_box"][0]), + str(item.get("shape_id") or item.get("handle") or ""), + ) + ) + rows: list[dict[str, object]] = [] + for item in valid: + x, y, width, height = item["_spatial_box"] + center = y + height / 2.0 + candidates: list[tuple[float, int]] = [] + for row_index, row in enumerate(rows): + row_center = float(row["center"]) + row_height = float(row["height"]) + row_top = row_center - row_height / 2.0 + row_bottom = row_center + row_height / 2.0 + overlap = max(0.0, min(y + height, row_bottom) - max(y, row_top)) + overlap_ratio = overlap / min(height, row_height) + center_distance = abs(center - row_center) + if overlap_ratio >= 0.35 or center_distance <= 0.35 * max( + height, row_height + ): + candidates.append((center_distance, row_index)) + if candidates: + _, row_index = min(candidates) + row = rows[row_index] + row_items = row["items"] + assert isinstance(row_items, list) + row_items.append(item) + centers = sorted( + float(member["_spatial_box"][1]) + + float(member["_spatial_box"][3]) / 2.0 + for member in row_items + ) + heights = sorted(float(member["_spatial_box"][3]) for member in row_items) + row["center"] = centers[len(centers) // 2] + row["height"] = heights[len(heights) // 2] + else: + rows.append({"center": center, "height": height, "items": [item]}) + + rows.sort(key=lambda row: float(row["center"]) - float(row["height"]) / 2.0) + ordered: list[dict[str, object]] = [] + for row in rows: + row_items = row["items"] + assert isinstance(row_items, list) + row_items.sort( + key=lambda item: ( + float(item["_spatial_box"][0]), + float(item["_spatial_box"][1]), + str(item.get("shape_id") or item.get("handle") or ""), + ) + ) + for item in row_items: + item.pop("_spatial_box", None) + ordered.append(item) + ordered.extend( + sorted( + invalid, + key=lambda item: str(item.get("shape_id") or item.get("handle") or ""), + ) + ) + return ordered + + +def _notes_block_order_source( + block: dict[str, object], + shape: dict[str, object], + *, + handle_resolution: str, + user_reordered: bool = False, +) -> str: + """Resolve whether Notes order is explicit or was generated by the system.""" + if user_reordered: + return "author_notes" + stored_source = _oneline(shape.get("order_source")).lower().replace("-", "_") + if stored_source in ORDER_SOURCES: + return "geometry" if stored_source != "author_notes" else stored_source + alt_source = _managed_alt_order_source(str(shape.get("description") or "")) + if alt_source in ORDER_SOURCES: + return "geometry" if alt_source != "author_notes" else alt_source + + # One-time migration for pre-provenance decks. The old normalizer generated + # a Notes handle from the PowerPoint shape name, then a user could replace + # the whole Alt Text description with plain narration. That exact legacy + # signature is automatic ordering even though the visible Notes block now + # contains speech. + description = str(shape.get("description") or "") + if ( + shape.get("script_baseline_sha256") is None + and handle_resolution == "shape_name" + and _oneline(block.get("handle")) == _oneline(shape.get("shape_name")) + and bool(_legacy_plain_alt_script(description)) + ): + return "geometry" + return "author_notes" + + +def _notes_sequence_was_reordered( + blocks: list[dict[str, object]], + resolved: dict[int, tuple[dict[str, object], str]], +) -> bool: + """Detect an explicit Notes reorder against stored canonical order indices.""" + indexed: list[int] = [] + for block_index in range(len(blocks)): + shape_and_resolution = resolved.get(block_index) + if shape_and_resolution is None: + continue + shape, _ = shape_and_resolution + order_index = shape.get("order_index") + if order_index is not None: + indexed.append(int(order_index)) + return len(indexed) >= 2 and indexed != sorted(indexed) + + +def _records_in_notes_order( + records: list[dict[str, object]], +) -> list[dict[str, object]]: + proxies: list[dict[str, object]] = [] + for record in records: + block = record["block"] + shape = record["shape"] + assert isinstance(block, dict) and isinstance(shape, dict) + proxies.append( + { + **record, + "handle": block.get("handle"), + "shape_id": shape.get("shape_id"), + "bbox": shape.get("bbox"), + "order_source": record.get("order_source"), + } + ) + return _merge_partial_notes_order(proxies) + + +def _fallback_handle(shape: dict[str, object], used: set[str]) -> str: + base = ( + parse_alt_id(str(shape.get("description") or "")) + or _oneline(shape.get("shape_name")) + or f"Shape {shape['shape_id']}" + ) + handle = base + if handle in used: + handle = f"{base} #{shape['shape_id']}" + if handle in used: + raise ProtocolError(f"could not create a unique handle for shape {shape['shape_id']}") + return handle + + +def _first_native_order(block: dict[str, object]) -> int | None: + orders = [ + int(effect["native_order"]) + for effect in block.get("effects") or [] + if effect.get("native_present") and effect.get("native_order") is not None + ] + return min(orders) if orders else None + + +def _merge_partial_notes_order( + blocks: list[dict[str, object]], +) -> list[dict[str, object]]: + """Insert system-ordered targets spatially around explicit Notes blocks. + + Explicit Author Notes retain their relative order. Every block whose order + came from automation is placed by row-aware canvas position: rows run from + top to bottom and targets inside one row run from left to right. This keeps + a newly added middle card between existing left and right cards even when + their top coordinates differ slightly or the Animation Pane uses another + order. + """ + notes_blocks = [ + block + for block in blocks + if str(block.get("order_source") or "author_notes") == "author_notes" + ] + if not notes_blocks: + return _spatially_ordered(blocks) + + automatic_blocks = [ + block + for block in blocks + if str(block.get("order_source") or "author_notes") != "author_notes" + ] + if not automatic_blocks: + return notes_blocks + + spatial = _spatially_ordered(blocks) + ranks = { + str(block.get("shape_id") or block.get("handle")): index + for index, block in enumerate(spatial) + } + buckets: list[list[dict[str, object]]] = [ + [] for _ in range(len(notes_blocks) + 1) + ] + for block in _spatially_ordered(automatic_blocks): + block_rank = ranks[str(block.get("shape_id") or block.get("handle"))] + insertion = len(notes_blocks) + for note_index, note_block in enumerate(notes_blocks): + note_rank = ranks[ + str(note_block.get("shape_id") or note_block.get("handle")) + ] + if block_rank < note_rank: + insertion = note_index + break + buckets[insertion].append(block) + + merged: list[dict[str, object]] = [] + for note_index, note_block in enumerate(notes_blocks): + merged.extend(buckets[note_index]) + merged.append(note_block) + merged.extend(buckets[-1]) + return merged + + +def extract_protocol( + pptx_path: Path, + *, + section_ids: Iterable[str] | None = None, + ids_from_script: Path | None = None, +) -> dict[str, object]: + """Extract Notes-first narration and animation intent from an editable PPTX. + + Canonical Author Notes are authoritative when present. Otherwise explicit + Shape Alt Text ``Script:`` fields provide narration. Explicit Notes order is + preserved, while system-generated blocks follow row-aware canvas order. + """ + pptx_path = pptx_path.resolve() + explicit_ids = list(section_ids) if section_ids is not None else None + if ids_from_script is not None: + if explicit_ids is not None: + raise ProtocolError("section_ids and ids_from_script are mutually exclusive") + explicit_ids = _section_ids_from_script(ids_from_script.resolve()) + + with ZipFile(pptx_path) as archive: + slide_refs = presentation_slides(archive) + slide_width, slide_height = presentation_size(archive) + if explicit_ids is not None and len(explicit_ids) != len(slide_refs): + raise ProtocolError( + f"section ID count {len(explicit_ids)} != PPTX slide count {len(slide_refs)}" + ) + slides: list[dict[str, object]] = [] + represented_effect_count = 0 + represented_native_effect_count = 0 + protocol_sources: set[str] = set() + for ref in slide_refs: + index = int(ref["index"]) + stable_id = str(ref["stable_id"]) + section_id = ( + str(explicit_ids[index - 1]) + if explicit_ids is not None + else f"slide-{stable_id}" + ) + slide_root = etree.fromstring(archive.read(str(ref["part"]))) + effects = _native_effects(slide_root) + by_shape, by_handle = _shape_maps( + slide_root, + slide_width=slide_width, + slide_height=slide_height, + ) + notes = _notes_text_optional(archive, str(ref["part"])) + notes_blocks = _canonical_notes_blocks(notes) + + effects_by_shape: dict[str, list[dict[str, object]]] = OrderedDict() + for effect in effects: + shape_id = str(effect["shape_id"]) + if shape_id not in by_shape: + raise ProtocolError( + f"slide {index} native animation targets missing shape id {shape_id!r}" + ) + effects_by_shape.setdefault(shape_id, []).append(effect) + ordered_shape_ids = list(effects_by_shape) + + resolved_blocks: list[dict[str, object]] = [] + represented_shape_ids: set[str] = set() + effect_conflicts: list[dict[str, object]] = [] + used_handles: set[str] = set() + alt_by_shape: dict[str, dict[str, object]] = {} + for shape_id, shape in by_shape.items(): + if not shape.get("top_level"): + continue + alt = parse_alt_protocol(str(shape.get("description") or "")) + if alt is not None: + alt_by_shape[shape_id] = alt + + def append_block( + block: dict[str, object], + shape: dict[str, object], + *, + source: str, + handle_resolution: str, + order_source: str, + ) -> None: + shape_id = str(shape["shape_id"]) + if not shape.get("top_level"): + raise ProtocolError( + f"slide {index} handle {block['handle']!r} targets a nested shape; " + "editable video targets must be top-level PowerPoint elements" + ) + if shape_id in represented_shape_ids: + raise ProtocolError( + f"slide {index} shape {shape_id} is represented by more than one script block" + ) + handle = str(block["handle"]) + if handle in used_handles: + raise ProtocolError(f"slide {index} has duplicate resolved handle {handle!r}") + represented_shape_ids.add(shape_id) + used_handles.add(handle) + native = effects_by_shape.get(shape_id) or [] + animation_names = [str(name) for name in block.get("animation_names") or []] + markers, effective_effects, conflicts = _merge_effect_intents( + shape_id=shape_id, + handle=handle, + native_effects=native, + markers=list(block.get("markers") or []), + animation_names=animation_names, + authority=source, + synthetic_order_base=1_000_000 + index * 10_000 + len(resolved_blocks) * 100, + ) + effect_conflicts.extend(conflicts) + resolved_blocks.append( + { + **block, + "markers": markers, + "shape_id": shape_id, + "shape_name": shape["shape_name"], + "bbox": shape["bbox"], + "script_source": source, + "order_source": order_source, + "handle_resolution": handle_resolution, + "alt_text_handle": parse_alt_id(str(shape.get("description") or "")), + "effects": effective_effects, + } + ) + + if notes_blocks: + protocol_sources.add("author_notes") + shapes_by_name: dict[str, list[dict[str, object]]] = {} + for candidate in by_shape.values(): + if candidate.get("top_level"): + shapes_by_name.setdefault( + _oneline(candidate.get("shape_name")), [] + ).append(candidate) + pre_resolved: dict[int, tuple[dict[str, object], str]] = {} + claimed_shape_ids: set[str] = set() + unresolved_block_indexes: list[int] = [] + for block_index, block in enumerate(notes_blocks): + handle = str(block["handle"]) + shape = by_handle.get(handle) + handle_resolution = "alt_text_handle" + if shape is None: + name_matches = shapes_by_name.get(_oneline(handle)) or [] + if len(name_matches) == 1: + shape = name_matches[0] + handle_resolution = "shape_name" + if shape is None: + unresolved_block_indexes.append(block_index) + continue + shape_id = str(shape["shape_id"]) + if shape_id in claimed_shape_ids: + raise ProtocolError( + f"slide {index} Notes handles resolve more than once to shape {shape_id!r}" + ) + claimed_shape_ids.add(shape_id) + pre_resolved[block_index] = (shape, handle_resolution) + + remaining_ordered_shape_ids = [ + shape_id + for shape_id in ordered_shape_ids + if shape_id not in claimed_shape_ids + ] + if unresolved_block_indexes and ( + len(unresolved_block_indexes) != len(remaining_ordered_shape_ids) + ): + missing = [ + str(notes_blocks[item]["handle"]) + for item in unresolved_block_indexes + ] + raise ProtocolError( + f"slide {index} Author Notes handles {missing!r} have no matching " + "shape Alt Text or unique shape name, and cannot be reconciled " + f"one-to-one with {len(remaining_ordered_shape_ids)} remaining animated targets" + ) + for block_index, shape_id in zip( + unresolved_block_indexes, remaining_ordered_shape_ids + ): + pre_resolved[block_index] = ( + by_shape[shape_id], + "animation_pane_order", + ) + + user_reordered = _notes_sequence_was_reordered( + notes_blocks, pre_resolved + ) + for block_index, block in enumerate(notes_blocks): + shape, handle_resolution = pre_resolved[block_index] + append_block( + block, + shape, + source="author_notes", + handle_resolution=handle_resolution, + order_source=_notes_block_order_source( + block, + shape, + handle_resolution=handle_resolution, + user_reordered=user_reordered, + ), + ) + + for shape_id in ordered_shape_ids: + if shape_id in represented_shape_ids: + continue + shape = by_shape[shape_id] + alt = alt_by_shape.get(shape_id) + if alt is not None: + protocol_sources.add("alt_text") + append_block( + alt, + shape, + source="alt_text", + handle_resolution="alt_text_handle", + order_source="geometry", + ) + continue + handle = _fallback_handle(shape, used_handles) + append_block( + { + "handle": handle, + "semantic": _oneline(shape.get("shape_name")), + "raw_transcript": "", + "transcript": "", + "markers": [], + "animation_names": [], + }, + shape, + source="animation_pane", + handle_resolution="animation_pane", + order_source="geometry", + ) + + remaining_alt_shapes = _spatially_ordered( + [ + by_shape[shape_id] + for shape_id in alt_by_shape + if shape_id not in represented_shape_ids + ] + ) + for shape in remaining_alt_shapes: + shape_id = str(shape["shape_id"]) + protocol_sources.add("alt_text") + append_block( + alt_by_shape[shape_id], + shape, + source="alt_text", + handle_resolution="geometry_order", + order_source="geometry", + ) + + resolved_blocks = _merge_partial_notes_order(resolved_blocks) + + transcript = _oneline(" ".join(str(block["transcript"]) for block in resolved_blocks)) + slide_effect_count = sum(len(block["effects"]) for block in resolved_blocks) + if not transcript and not slide_effect_count: + raise ProtocolError( + f"slide {index} has neither narration nor a supported native animation" + ) + represented_effect_count += slide_effect_count + represented_native_effect_count += len(effects) + slide_sources = list( + OrderedDict( + (str(block["script_source"]), None) + for block in resolved_blocks + if str(block.get("transcript") or "") + ) + ) + slides.append( + { + "index": index, + "stable_slide_id": stable_id, + "section_id": section_id, + "slide_part": ref["part"], + "notes_part": _notes_part_optional(archive, str(ref["part"])), + "transcript": transcript, + "script_sources": slide_sources, + "block_count": len(resolved_blocks), + "effect_count": slide_effect_count, + "native_effect_count": len(effects), + "animation_duration_seconds": round( + max( + ( + float(effect.get("pane_end_seconds") or 0.0) + for effect in effects + ), + default=0.0, + ), + 3, + ), + "effect_conflicts": effect_conflicts, + "blocks": resolved_blocks, + } + ) + + return { + "schema_version": PROTOCOL_SCHEMA_VERSION, + "created_at": _utc_now(), + "source_pptx": str(pptx_path), + "source_sha256": file_sha256(pptx_path), + "slide_count": len(slides), + "effect_count": represented_effect_count, + "native_effect_count": represented_native_effect_count, + "script_sources": sorted(protocol_sources), + "slides": slides, + } + + +def script_from_protocol( + protocol: dict[str, object], + *, + voice: str | None = None, +) -> dict[str, object]: + sections = [] + for slide in protocol.get("slides") or []: + sections.append( + { + "id": slide["section_id"], + "heading": slide["section_id"], + "text": slide["transcript"], + "duration_seconds": max( + 1.0, + round(float(slide.get("animation_duration_seconds") or 0.0) + 0.35, 3), + ), + } + ) + payload: dict[str, object] = {"provider": "edge", "sections": sections} + if voice: + payload["edge_voice"] = voice + return payload + + +def _native_rows_from_block(block: dict[str, object]) -> list[dict[str, object]]: + rows: list[dict[str, object]] = [] + for effect in block.get("effects") or []: + if not effect.get("native_present"): + continue + native_name = str(effect.get("native_name") or effect.get("name") or "") + rows.append( + { + **effect, + "name": native_name, + "native_name": native_name, + "authority": "animation_pane", + "native_present": True, + } + ) + rows.sort(key=lambda effect: int(effect.get("native_order") or 0)) + return rows + + +def apply_user_script( + protocol: dict[str, object], + script_json: Path, +) -> tuple[dict[str, object], dict[str, object]]: + """Apply an explicit user-edited script to an extracted PPTX protocol. + + Standard section-level ``text`` replaces slide narration and preserves all + resolved effects at deterministic block-start timing. For precise per-item + timing, a section may provide ``elements`` entries with ``handle`` and + marker-bearing ``script`` fields. + """ + script_json = script_json.resolve() + try: + payload = json.loads(script_json.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ProtocolError(f"could not read user script {script_json}: {exc}") from exc + sections = payload.get("sections") or [] + slides = protocol.get("slides") or [] + if len(sections) != len(slides): + raise ProtocolError( + f"user script section count {len(sections)} != PPTX slide count {len(slides)}" + ) + + updated = deepcopy(protocol) + report_slides: list[dict[str, object]] = [] + for slide, section in zip(updated.get("slides") or [], sections): + section_id = str(section.get("id") or slide["section_id"]) + if section_id != str(slide["section_id"]): + raise ProtocolError( + f"user script section {section_id!r} != PPTX section {slide['section_id']!r}" + ) + blocks = list(slide.get("blocks") or []) + has_element_override = "elements" in section or "blocks" in section + elements = section.get("elements") or section.get("blocks") or [] + overridden_handles: list[str] = [] + mode = "elements" if has_element_override else "section_text" + if has_element_override: + by_handle = {str(block["handle"]): block for block in blocks} + seen: set[str] = set() + for element in elements: + handle = _oneline(element.get("handle")) + if not handle or handle in seen: + raise ProtocolError( + f"user script section {section_id!r} has a missing or duplicate element handle" + ) + seen.add(handle) + block = by_handle.get(handle) + if block is None: + raise ProtocolError( + f"user script section {section_id!r} references unknown handle {handle!r}" + ) + raw_script = str(element.get("script") or element.get("text") or "").strip() + transcript, markers = parse_marked_transcript(raw_script) + if not transcript: + raise ProtocolError( + f"user script section {section_id!r} handle {handle!r} has empty narration" + ) + block["raw_transcript"] = raw_script + block["transcript"] = transcript + block["script_source"] = "user_script" + if markers: + merged_markers, merged_effects, conflicts = _merge_effect_intents( + shape_id=str(block["shape_id"]), + handle=handle, + native_effects=_native_rows_from_block(block), + markers=markers, + animation_names=[], + authority="user_script", + synthetic_order_base=( + 2_000_000 + int(slide["index"]) * 10_000 + len(overridden_handles) * 100 + ), + ) + block["markers"] = merged_markers + block["effects"] = merged_effects + slide.setdefault("effect_conflicts", []).extend(conflicts) + else: + block["markers"] = [ + _default_marker(str(effect["name"]), "user_script_default") + for effect in block.get("effects") or [] + ] + overridden_handles.append(handle) + else: + text = _oneline(section.get("text")) + if not text: + raise ProtocolError(f"user script section {section_id!r} has empty text") + clean_text, section_markers = parse_marked_transcript(text) + if section_markers: + raise ProtocolError( + f"user script section {section_id!r} has animation markers in slide-level " + "text; use handle-addressed elements for precise marker targets" + ) + text = clean_text + if not blocks: + raise ProtocolError(f"PPTX section {section_id!r} has no protocol blocks") + for block_index, block in enumerate(blocks): + block["raw_transcript"] = text if block_index == 0 else "" + block["transcript"] = text if block_index == 0 else "" + block["script_source"] = "user_script" + block["markers"] = [ + _default_marker(str(effect["name"]), "user_script_default") + for effect in block.get("effects") or [] + ] + overridden_handles = [str(blocks[0]["handle"])] + + transcript = _oneline(" ".join(str(block.get("transcript") or "") for block in blocks)) + slide_effect_count = sum(len(block.get("effects") or []) for block in blocks) + if not transcript and not slide_effect_count: + raise ProtocolError(f"user script left PPTX section {section_id!r} empty") + slide["transcript"] = transcript + slide["script_sources"] = list( + OrderedDict( + (str(block["script_source"]), None) + for block in blocks + if str(block.get("transcript") or "") + ) + ) + slide["effect_count"] = slide_effect_count + report_slides.append( + { + "index": int(slide["index"]), + "id": section_id, + "mode": mode, + "overridden_handles": overridden_handles, + "transcript": transcript, + } + ) + + updated["script_sources"] = sorted( + { + str(source) + for slide in updated.get("slides") or [] + for source in slide.get("script_sources") or [] + } + ) + updated["effect_count"] = sum( + int(slide.get("effect_count") or 0) for slide in updated.get("slides") or [] + ) + updated["user_script"] = str(script_json) + report = { + "schema_version": "paper2video_user_script_authority.v1", + "created_at": _utc_now(), + "script_json": str(script_json), + "resolution": "user_script", + "slide_count": len(report_slides), + "slides": report_slides, + } + return updated, report + + +def write_json(path: Path, payload: object) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def _ensure_notes_parts(source_pptx: Path, directory: Path) -> tuple[Path, Path | None]: + """Create missing notes parts through python-pptx while preserving slide XML.""" + with ZipFile(source_pptx) as archive: + missing = any( + _notes_part_optional(archive, str(ref["part"])) is None + for ref in presentation_slides(archive) + ) + if not missing: + return source_pptx, None + try: + from pptx import Presentation + except ImportError as exc: # pragma: no cover - installer provides python-pptx + raise ProtocolError( + "python-pptx is required to create missing Author Notes parts" + ) from exc + with tempfile.NamedTemporaryFile( + prefix=source_pptx.stem + ".notes.", + suffix=".pptx", + dir=directory, + delete=False, + ) as temporary: + seeded = Path(temporary.name) + presentation = Presentation(source_pptx) + for slide in presentation.slides: + _ = slide.notes_slide + presentation.save(seeded) + return seeded, seeded + + +def _script_fields_from( + notes_block: dict[str, object], + script_block: dict[str, object], +) -> dict[str, object]: + """Keep the Notes handle/locator while accepting another script version.""" + resolved = dict(notes_block) + for field in ("raw_transcript", "transcript", "markers", "animation_names"): + resolved[field] = script_block.get(field, [] if field.endswith("s") else "") + if not _oneline(resolved.get("semantic")): + resolved["semantic"] = script_block.get("semantic") or "" + return resolved + + +def _resolve_notes_alt_script( + notes_block: dict[str, object], + alt_block: dict[str, object] | None, + *, + baseline_hash: str | None, + alt_kind: str | None, +) -> tuple[dict[str, object], dict[str, object]]: + """Resolve Notes and Alt Text against the last system-synchronized hash.""" + notes_hash = script_sha256(notes_block.get("raw_transcript") or "") + alt_hash = ( + script_sha256(alt_block.get("raw_transcript") or "") + if alt_block is not None + else None + ) + notes_changed: bool | None = ( + notes_hash != baseline_hash if baseline_hash is not None else None + ) + alt_changed: bool | None = ( + alt_hash != baseline_hash + if baseline_hash is not None and alt_hash is not None + else None + ) + selected = notes_block + selected_source = "author_notes" + conflict = False + + if baseline_hash is None: + if alt_block is None: + resolution = "legacy_notes_only" + elif alt_hash == notes_hash: + resolution = "legacy_equal_versions" + elif alt_kind == "plain": + selected = _script_fields_from(notes_block, alt_block) + selected_source = "alt_text" + resolution = "legacy_plain_alt_text_user_edit" + else: + resolution = "legacy_conflict_notes_wins" + conflict = True + elif alt_block is None: + resolution = "notes_user_edit" if notes_changed else "baseline_notes_only" + elif not notes_changed and not alt_changed: + resolution = "baseline_unchanged" + elif notes_changed and not alt_changed: + resolution = "notes_user_edit" + elif not notes_changed and alt_changed: + selected = _script_fields_from(notes_block, alt_block) + selected_source = "alt_text" + resolution = "alt_text_user_edit" + elif notes_hash == alt_hash: + resolution = "both_same_user_edit" + else: + resolution = "conflict_notes_wins" + conflict = True + + selected_hash = script_sha256(selected.get("raw_transcript") or "") + return selected, { + "baseline_hash": baseline_hash, + "notes_hash": notes_hash, + "alt_text_hash": alt_hash, + "notes_changed": notes_changed, + "alt_text_changed": alt_changed, + "selected_hash": selected_hash, + "selected_source": selected_source, + "resolution": resolution, + "conflict": conflict, + "legacy_alt_text_kind": alt_kind if baseline_hash is None else None, + } + + +def _single_source_script_resolution( + block: dict[str, object], + *, + source_name: str, + baseline_hash: str | None, + notes_present: bool, +) -> dict[str, object]: + selected_hash = script_sha256(block.get("raw_transcript") or "") + if source_name == "author_notes": + notes_hash = selected_hash + alt_hash = None + elif source_name == "alt_text": + notes_hash = None + alt_hash = selected_hash + else: + notes_hash = None + alt_hash = None + return { + "baseline_hash": baseline_hash, + "notes_hash": notes_hash, + "alt_text_hash": alt_hash, + "notes_changed": ( + notes_hash != baseline_hash + if baseline_hash is not None and notes_hash is not None + else None + ), + "alt_text_changed": ( + alt_hash != baseline_hash + if baseline_hash is not None and alt_hash is not None + else None + ), + "selected_hash": selected_hash, + "selected_source": source_name, + "resolution": ( + f"{source_name}_only" + if baseline_hash is not None + else ( + ( + "legacy_alt_text_insert" + if notes_present + else "legacy_alt_text_backfill" + ) + if source_name == "alt_text" + else "legacy_animation_pane_backfill" + ) + ), + "conflict": False, + "legacy_alt_text_kind": None, + } + + +def normalize_author_notes_authority( + source_pptx: Path, + output_pptx: Path, +) -> dict[str, object]: + """Normalize handles, compact Alt Text, and canonical Notes in one PPTX copy.""" + source_pptx = source_pptx.resolve() + output_pptx = output_pptx.resolve() + output_pptx.parent.mkdir(parents=True, exist_ok=True) + working_source, seeded_source = _ensure_notes_parts(source_pptx, output_pptx.parent) + replacements: dict[str, bytes] = {} + slides_out: list[dict[str, object]] = [] + try: + with ZipFile(working_source) as source: + slide_refs = presentation_slides(source) + slide_width, slide_height = presentation_size(source) + for ref in slide_refs: + index = int(ref["index"]) + slide_part = str(ref["part"]) + slide_root = etree.fromstring(source.read(slide_part)) + effects = _native_effects(slide_root) + effects_by_shape: dict[str, list[dict[str, object]]] = OrderedDict() + for effect in effects: + effects_by_shape.setdefault(str(effect["shape_id"]), []).append(effect) + ordered_shape_ids = list(effects_by_shape) + by_shape, by_handle = _shape_maps( + slide_root, + slide_width=slide_width, + slide_height=slide_height, + ) + blocks = _canonical_notes_blocks( + _notes_text_optional(source, slide_part) + ) + input_had_notes = bool(blocks) + alt_by_shape: dict[str, dict[str, object]] = {} + for shape_id, shape in by_shape.items(): + if not shape.get("top_level"): + continue + alt = parse_alt_protocol(str(shape.get("description") or "")) + if alt is not None: + alt_by_shape[shape_id] = alt + + records: list[dict[str, object]] = [] + represented: set[str] = set() + used_handles: set[str] = set() + + def add_record( + block: dict[str, object], + shape: dict[str, object], + *, + source_name: str, + shape_resolution: str, + order_source: str, + script_resolution: dict[str, object], + ) -> None: + shape_id = str(shape["shape_id"]) + handle = str(block["handle"]) + if not shape.get("top_level"): + raise ProtocolError( + f"slide {index} handle {handle!r} targets a nested shape" + ) + if shape_id in represented or handle in used_handles: + raise ProtocolError( + f"slide {index} has a duplicate shape or handle for {handle!r}" + ) + represented.add(shape_id) + used_handles.add(handle) + records.append( + { + "block": block, + "shape": shape, + "source": source_name, + "shape_resolution": shape_resolution, + "order_source": order_source, + "script_resolution": script_resolution, + } + ) + + shapes_by_name: dict[str, list[dict[str, object]]] = {} + for candidate in by_shape.values(): + if candidate.get("top_level"): + shapes_by_name.setdefault( + _oneline(candidate.get("shape_name")), [] + ).append(candidate) + pre_resolved: dict[int, tuple[dict[str, object], str]] = {} + claimed_shape_ids: set[str] = set() + unresolved_block_indexes: list[int] = [] + for block_index, block in enumerate(blocks): + handle = str(block["handle"]) + shape = by_handle.get(handle) + resolution = "alt_text_handle" + if shape is None: + name_matches = shapes_by_name.get(_oneline(handle)) or [] + if len(name_matches) == 1: + shape = name_matches[0] + resolution = "shape_name" + if shape is None: + unresolved_block_indexes.append(block_index) + continue + shape_id = str(shape["shape_id"]) + if shape_id in claimed_shape_ids: + raise ProtocolError( + f"slide {index} Notes handles resolve more than once to shape {shape_id!r}" + ) + claimed_shape_ids.add(shape_id) + pre_resolved[block_index] = (shape, resolution) + + remaining_ordered_shape_ids = [ + shape_id + for shape_id in ordered_shape_ids + if shape_id not in claimed_shape_ids + ] + if unresolved_block_indexes and ( + len(unresolved_block_indexes) != len(remaining_ordered_shape_ids) + ): + missing = [str(blocks[item]["handle"]) for item in unresolved_block_indexes] + raise ProtocolError( + f"slide {index} Author Notes handles {missing!r} have no matching " + "shape Alt Text or unique shape name, and cannot be reconciled " + f"one-to-one with {len(remaining_ordered_shape_ids)} remaining animated targets" + ) + for block_index, shape_id in zip( + unresolved_block_indexes, remaining_ordered_shape_ids + ): + pre_resolved[block_index] = ( + by_shape[shape_id], + "animation_pane_order", + ) + + user_reordered = _notes_sequence_was_reordered(blocks, pre_resolved) + for block_index, block in enumerate(blocks): + handle = str(block["handle"]) + shape, resolution = pre_resolved[block_index] + description = str(shape.get("description") or "") + managed_alt = alt_by_shape.get(str(shape["shape_id"])) + plain_alt_script = ( + _legacy_plain_alt_script(description) + if managed_alt is None + else "" + ) + if managed_alt is not None: + alt_candidate = managed_alt + alt_kind = "managed" + elif plain_alt_script: + transcript, markers = parse_marked_transcript(plain_alt_script) + alt_candidate = { + "handle": handle, + "semantic": _oneline(shape.get("shape_name")), + "raw_transcript": plain_alt_script, + "transcript": transcript, + "markers": markers, + "animation_names": [], + } + alt_kind = "plain" + else: + alt_candidate = None + alt_kind = None + resolved_block, script_resolution = _resolve_notes_alt_script( + block, + alt_candidate, + baseline_hash=( + str(shape["script_baseline_sha256"]) + if shape.get("script_baseline_sha256") + else None + ), + alt_kind=alt_kind, + ) + add_record( + resolved_block, + shape, + source_name=str(script_resolution["selected_source"]), + shape_resolution=resolution, + order_source=_notes_block_order_source( + resolved_block, + shape, + handle_resolution=resolution, + user_reordered=user_reordered, + ), + script_resolution=script_resolution, + ) + + for shape_id in ordered_shape_ids: + if shape_id in represented: + continue + shape = by_shape.get(shape_id) + if shape is None: + raise ProtocolError( + f"slide {index} native animation targets missing shape {shape_id!r}" + ) + alt = alt_by_shape.get(shape_id) + if alt is None: + handle = _fallback_handle(shape, used_handles) + plain_script = _legacy_plain_alt_script( + str(shape.get("description") or "") + ) + transcript, markers = parse_marked_transcript(plain_script) + alt = { + "handle": handle, + "semantic": _oneline(shape.get("shape_name")), + "raw_transcript": plain_script, + "transcript": transcript, + "markers": markers, + "animation_names": [], + } + source_name = "alt_text" if plain_script else "animation_pane" + resolution = "generated_handle" + else: + source_name = "alt_text" + resolution = "alt_text_handle" + add_record( + alt, + shape, + source_name=source_name, + shape_resolution=resolution, + order_source="geometry", + script_resolution=_single_source_script_resolution( + alt, + source_name=source_name, + baseline_hash=( + str(shape["script_baseline_sha256"]) + if shape.get("script_baseline_sha256") + else None + ), + notes_present=input_had_notes, + ), + ) + + remaining_alt = _spatially_ordered( + [ + by_shape[shape_id] + for shape_id in alt_by_shape + if shape_id not in represented + ] + ) + for shape in remaining_alt: + shape_id = str(shape["shape_id"]) + add_record( + alt_by_shape[shape_id], + shape, + source_name="alt_text", + shape_resolution="alt_text_handle", + order_source="geometry", + script_resolution=_single_source_script_resolution( + alt_by_shape[shape_id], + source_name="alt_text", + baseline_hash=( + str(shape["script_baseline_sha256"]) + if shape.get("script_baseline_sha256") + else None + ), + notes_present=input_had_notes, + ), + ) + + records = _records_in_notes_order(records) + changes: list[dict[str, object]] = [] + for record_index, record in enumerate(records): + block = record["block"] + shape = record["shape"] + assert isinstance(block, dict) and isinstance(shape, dict) + shape_id = str(shape["shape_id"]) + handle = str(block["handle"]) + source_name = str(record["source"]) + matches = slide_root.xpath( + f'.//p:spTree//p:cNvPr[@id="{shape_id}"]', namespaces=NS + ) + if len(matches) != 1: + raise ProtocolError( + f"slide {index} shape {shape_id!r} resolved to {len(matches)} nodes" + ) + previous_description = str(matches[0].get("descr") or "") + previous_baseline_hash = _script_baseline_from_cnvpr(matches[0]) + script_resolution = record["script_resolution"] + assert isinstance(script_resolution, dict) + selected_baseline_hash = str(script_resolution["selected_hash"]) + _, effective_effects, _ = _merge_effect_intents( + shape_id=shape_id, + handle=handle, + native_effects=effects_by_shape.get(shape_id) or [], + markers=list(block.get("markers") or []), + animation_names=[ + str(name) for name in block.get("animation_names") or [] + ], + authority=source_name, + synthetic_order_base=( + 1_000_000 + index * 10_000 + record_index * 100 + ), + ) + generated = build_system_alt_text( + handle=handle, + animation_names=[ + str(effect["name"]) for effect in effective_effects + ], + raw_script=str(block.get("raw_transcript") or ""), + shape_name=str(shape.get("shape_name") or handle), + shape_id=shape_id, + slide_index=index, + existing_description=previous_description, + order_source=str(record["order_source"]), + baseline_hash=selected_baseline_hash, + ) + matches[0].set("descr", generated) + written_baseline_hash = _set_script_baseline_on_cnvpr( + matches[0], + block.get("raw_transcript") or "", + order_source=str(record["order_source"]), + order_index=record_index, + ) + if written_baseline_hash != selected_baseline_hash: + raise ProtocolError( + f"slide {index} shape {shape_id!r} script baseline write mismatch" + ) + if ( + previous_description != generated + or previous_baseline_hash != written_baseline_hash + ): + changes.append( + { + "shape_id": shape_id, + "shape_name": shape["shape_name"], + "previous_handle": parse_alt_id(previous_description), + "alt_text_handle": handle, + "metadata_refreshed": True, + "shape_resolution": record["shape_resolution"], + "order_source": record["order_source"], + "resolution": source_name, + **script_resolution, + } + ) + + if records: + replacements[slide_part] = etree.tostring( + slide_root, + xml_declaration=True, + encoding="UTF-8", + standalone=True, + ) + + notes_backfilled = not input_had_notes and bool(records) + notes_records = records + notes_lines: list[str] = [] + for record_index, record in enumerate(notes_records): + block = record["block"] + shape = record["shape"] + assert isinstance(block, dict) and isinstance(shape, dict) + if record_index: + notes_lines.append("") + semantic = _oneline(block.get("semantic")) or _oneline( + shape.get("shape_name") + ) + header = f"## [{block['handle']}]" + if semantic: + header += f" {semantic}" + notes_lines.append(header) + raw_script = str(block.get("raw_transcript") or "").strip() + if raw_script: + notes_lines.extend(raw_script.splitlines()) + canonical_notes = "\n".join(notes_lines) + original_handles = [str(block["handle"]) for block in blocks] + canonical_handles = [ + str(record["block"]["handle"]) for record in notes_records + ] + notes_order_changed = canonical_handles != original_handles + notes_sync_requested = notes_backfilled or notes_order_changed or any( + str(record["script_resolution"]["resolution"]) + in { + "alt_text_user_edit", + "legacy_plain_alt_text_user_edit", + } + for record in notes_records + ) + notes_rewritten = notes_sync_requested and ( + _notes_text_optional(source, slide_part).strip() != canonical_notes.strip() + ) + if notes_rewritten: + notes_part = _notes_part_optional(source, slide_part) + if notes_part is None: + raise ProtocolError( + f"slide {index} could not create an Author Notes part" + ) + notes_root = etree.fromstring(source.read(notes_part)) + _replace_notes_body(notes_root, notes_lines) + replacements[notes_part] = etree.tostring( + notes_root, + xml_declaration=True, + encoding="UTF-8", + standalone=True, + ) + + slides_out.append( + { + "index": index, + "target_count": len(ordered_shape_ids), + "notes_block_count": len(blocks), + "notes_backfilled": notes_backfilled, + "notes_rewritten": notes_rewritten, + "generated_notes_block_count": max( + 0, len(notes_records) - len(blocks) + ), + "notes_order_changed": notes_order_changed, + "script_source": ( + "author_notes" + if input_had_notes + else ("alt_text" if alt_by_shape else "animation_pane") + ), + "alt_text_changes": changes, + "script_resolutions": [ + { + "shape_id": str(record["shape"]["shape_id"]), + "handle": str(record["block"]["handle"]), + **record["script_resolution"], + } + for record in records + ], + } + ) + + with tempfile.NamedTemporaryFile( + prefix=output_pptx.stem + ".", + suffix=".pptx", + dir=output_pptx.parent, + delete=False, + ) as temporary: + temporary_path = Path(temporary.name) + try: + with ZipFile(temporary_path, "w") as destination: + for info in source.infolist(): + data = replacements.get(info.filename, source.read(info.filename)) + destination.writestr(_copy_zipinfo(info), data) + shutil.move(temporary_path, output_pptx) + finally: + temporary_path.unlink(missing_ok=True) + finally: + if seeded_source is not None: + seeded_source.unlink(missing_ok=True) + + return { + "schema_version": "paper2video_author_notes_authority.v4", + "created_at": _utc_now(), + "source_pptx": str(source_pptx), + "source_sha256": file_sha256(source_pptx), + "output_pptx": str(output_pptx), + "output_sha256": file_sha256(output_pptx), + "slide_count": len(slides_out), + "alt_text_change_count": sum( + len(slide["alt_text_changes"]) for slide in slides_out + ), + "notes_backfill_count": sum( + 1 for slide in slides_out if slide["notes_backfilled"] + ), + "notes_rewrite_count": sum( + 1 for slide in slides_out if slide["notes_rewritten"] + ), + "script_conflict_count": sum( + 1 + for slide in slides_out + for resolution in slide["script_resolutions"] + if resolution["conflict"] + ), + "slides": slides_out, + } + + +def _shape_change_inventory(pptx_path: Path) -> dict[tuple[str, str], dict[str, object]]: + inventory: dict[tuple[str, str], dict[str, object]] = {} + with ZipFile(pptx_path) as archive: + slide_width, slide_height = presentation_size(archive) + for ref in presentation_slides(archive): + slide_root = etree.fromstring(archive.read(str(ref["part"]))) + effects = _native_effects(slide_root) + effects_by_shape: dict[str, list[dict[str, object]]] = OrderedDict() + for effect in effects: + effects_by_shape.setdefault(str(effect["shape_id"]), []).append(effect) + by_shape, _ = _shape_maps( + slide_root, + slide_width=slide_width, + slide_height=slide_height, + ) + for shape_id, shape in by_shape.items(): + if not shape.get("top_level"): + continue + nodes = slide_root.xpath( + f'.//p:spTree/*[p:nvSpPr/p:cNvPr[@id="{shape_id}"] ' + f'or p:nvPicPr/p:cNvPr[@id="{shape_id}"] ' + f'or p:nvGraphicFramePr/p:cNvPr[@id="{shape_id}"] ' + f'or p:nvGrpSpPr/p:cNvPr[@id="{shape_id}"]]', + namespaces=NS, + ) + if len(nodes) != 1: + continue + canonical = deepcopy(nodes[0]) + for non_visual in canonical.xpath(".//p:cNvPr", namespaces=NS): + non_visual.attrib.pop("descr", None) + shape_effects = [ + { + "name": effect.get("name"), + "kind": effect.get("kind"), + "trigger": effect.get("trigger"), + "delay_seconds": effect.get("delay_seconds"), + "duration_seconds": effect.get("duration_seconds"), + "native_order": effect.get("native_order"), + } + for effect in effects_by_shape.get(shape_id) or [] + ] + digest = hashlib.sha256() + digest.update(etree.tostring(canonical, method="c14n")) + digest.update( + json.dumps(shape_effects, sort_keys=True).encode("utf-8") + ) + handle = parse_alt_id(str(shape.get("description") or "")) + inventory[(str(ref["stable_id"]), shape_id)] = { + "slide_index": int(ref["index"]), + "stable_slide_id": str(ref["stable_id"]), + "shape_id": shape_id, + "handle": handle, + "shape_name": str(shape.get("shape_name") or ""), + "text": _oneline(" ".join(nodes[0].xpath(".//a:t/text()", namespaces=NS))), + "bbox": shape.get("bbox"), + "animations": [str(effect.get("name") or "") for effect in shape_effects], + "fingerprint": digest.hexdigest(), + } + return inventory + + +def detect_pptx_changes( + baseline_pptx: Path, + edited_pptx: Path, +) -> dict[str, object]: + """Find user-visible shape and native-animation changes between two decks.""" + baseline_pptx = baseline_pptx.resolve() + edited_pptx = edited_pptx.resolve() + before = _shape_change_inventory(baseline_pptx) + after = _shape_change_inventory(edited_pptx) + changes: list[dict[str, object]] = [] + for key in sorted(set(before) | set(after)): + old = before.get(key) + new = after.get(key) + if old is None: + kind = "added" + elif new is None: + kind = "removed" + elif old["fingerprint"] != new["fingerprint"]: + kind = "modified" + else: + continue + current = new or old + assert current is not None + changes.append( + { + "kind": kind, + "slide_index": current["slide_index"], + "stable_slide_id": current["stable_slide_id"], + "shape_id": current["shape_id"], + "handle": (new or {}).get("handle") or (old or {}).get("handle"), + "before": old, + "after": new, + } + ) + return { + "schema_version": "paper2video_pptx_changes.v1", + "created_at": _utc_now(), + "baseline_pptx": str(baseline_pptx), + "baseline_sha256": file_sha256(baseline_pptx), + "edited_pptx": str(edited_pptx), + "edited_sha256": file_sha256(edited_pptx), + "change_count": len(changes), + "changes": changes, + } + + +def write_protocol_to_pptx( + source_pptx: Path, + protocol: dict[str, object], + output_pptx: Path, +) -> dict[str, object]: + """Persist narration into Notes, compact Alt Text, and hidden OOXML provenance.""" + source_pptx = source_pptx.resolve() + output_pptx = output_pptx.resolve() + output_pptx.parent.mkdir(parents=True, exist_ok=True) + replacements: dict[str, bytes] = {} + slides_out: list[dict[str, object]] = [] + with ZipFile(source_pptx) as source: + refs = presentation_slides(source) + slides = list(protocol.get("slides") or []) + if len(refs) != len(slides): + raise ProtocolError( + f"protocol slide count {len(slides)} != PPTX slide count {len(refs)}" + ) + for ref, slide in zip(refs, slides): + index = int(ref["index"]) + if int(slide.get("index") or 0) != index: + raise ProtocolError(f"protocol slide order mismatch at slide {index}") + slide_part = str(ref["part"]) + slide_root = etree.fromstring(source.read(slide_part)) + notes_part = _notes_part_optional(source, slide_part) + if notes_part is None: + raise ProtocolError(f"slide {index} has no Author Notes part") + notes_root = etree.fromstring(source.read(notes_part)) + notes_lines: list[str] = [] + alt_change_count = 0 + for block_index, block in enumerate(slide.get("blocks") or []): + if block_index: + notes_lines.append("") + semantic = _oneline(block.get("semantic")) or _oneline( + block.get("shape_name") + ) + header = f"## [{block['handle']}]" + if semantic: + header += f" {semantic}" + notes_lines.append(header) + raw_script = str(block.get("raw_transcript") or "").strip() + if raw_script: + notes_lines.extend(raw_script.splitlines()) + + shape_id = str(block["shape_id"]) + nodes = slide_root.xpath( + f'.//p:spTree//p:cNvPr[@id="{shape_id}"]', namespaces=NS + ) + if len(nodes) != 1: + raise ProtocolError( + f"slide {index} protocol shape {shape_id!r} resolved to {len(nodes)} nodes" + ) + previous = str(nodes[0].get("descr") or "") + resolution = str(block.get("handle_resolution") or "") + order_source = str(block.get("order_source") or "") + if order_source not in {"author_notes", "animation_pane", "geometry"}: + if str(block.get("script_source") or "") in { + "author_notes", + "user_script", + "llm_regeneration", + }: + order_source = "author_notes" + elif resolution == "geometry_order": + order_source = "geometry" + else: + order_source = "animation_pane" + generated = build_system_alt_text( + handle=str(block["handle"]), + animation_names=[ + str(effect["name"]) for effect in block.get("effects") or [] + ], + raw_script=raw_script, + shape_name=str(block.get("shape_name") or block["handle"]), + shape_id=shape_id, + slide_index=index, + existing_description=previous, + order_source=order_source, + ) + nodes[0].set("descr", generated) + _set_script_baseline_on_cnvpr( + nodes[0], + raw_script, + order_source=order_source, + order_index=block_index, + ) + if previous != generated: + alt_change_count += 1 + _replace_notes_body(notes_root, notes_lines) + replacements[slide_part] = etree.tostring( + slide_root, + xml_declaration=True, + encoding="UTF-8", + standalone=True, + ) + replacements[notes_part] = etree.tostring( + notes_root, + xml_declaration=True, + encoding="UTF-8", + standalone=True, + ) + slides_out.append( + { + "index": index, + "block_count": len(slide.get("blocks") or []), + "alt_text_change_count": alt_change_count, + } + ) + with tempfile.NamedTemporaryFile( + prefix=output_pptx.stem + ".protocol.", + suffix=".pptx", + dir=output_pptx.parent, + delete=False, + ) as temporary: + temporary_path = Path(temporary.name) + try: + with ZipFile(temporary_path, "w") as destination: + for info in source.infolist(): + destination.writestr( + _copy_zipinfo(info), + replacements.get(info.filename, source.read(info.filename)), + ) + shutil.move(temporary_path, output_pptx) + finally: + temporary_path.unlink(missing_ok=True) + return { + "schema_version": "paper2video_protocol_writeback.v1", + "created_at": _utc_now(), + "source_pptx": str(source_pptx), + "output_pptx": str(output_pptx), + "output_sha256": file_sha256(output_pptx), + "slides": slides_out, + } + + +def _split_words(text: str, count: int) -> list[str]: + words = _oneline(text).split() + if count <= 0: + raise ProtocolError("cannot split narration across zero animated elements") + if len(words) < count: + raise ProtocolError( + f"cannot split {len(words)} narration words across {count} animated elements" + ) + chunks: list[str] = [] + cursor = 0 + for index in range(count): + remaining_words = len(words) - cursor + remaining_parts = count - index + width = (remaining_words + remaining_parts - 1) // remaining_parts + chunks.append(" ".join(words[cursor:cursor + width])) + cursor += width + return chunks + + +def _replace_notes_body(notes_root: etree._Element, lines: list[str]) -> None: + bodies = notes_root.xpath( + './/p:sp[p:nvSpPr/p:nvPr/p:ph[@type="body"]]', namespaces=NS + ) + if len(bodies) != 1: + raise ProtocolError( + f"notes slide must contain one body placeholder, found {len(bodies)}" + ) + text_body = bodies[0].find(f"{{{P_NS}}}txBody") + if text_body is None: + raise ProtocolError("notes body placeholder has no p:txBody") + for paragraph in list(text_body.findall(f"{{{A_NS}}}p")): + text_body.remove(paragraph) + for line in lines: + paragraph = etree.SubElement(text_body, f"{{{A_NS}}}p") + if line: + run = etree.SubElement(paragraph, f"{{{A_NS}}}r") + etree.SubElement(run, f"{{{A_NS}}}rPr", lang="en-US", dirty="0") + text = etree.SubElement(run, f"{{{A_NS}}}t") + text.text = line + etree.SubElement(paragraph, f"{{{A_NS}}}endParaRPr", lang="en-US", dirty="0") + + +def bootstrap_protocol( + source_pptx: Path, + script_json: Path, + output_pptx: Path, +) -> dict[str, object]: + """Seed canonical Notes and Alt Text from native animations and narration. + + This is a one-time deterministic bridge for an ordinary animated deck. It + preserves slide timing trees and visible content, assigns stable handles to + animated top-level elements, and divides each slide narration across those + elements in row-aware canvas order while preserving each target's native + Animation Pane effects and trigger metadata. + """ + try: + script_payload = json.loads(script_json.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ProtocolError(f"could not read script {script_json}: {exc}") from exc + sections = script_payload.get("sections") or [] + if not isinstance(sections, list) or not sections: + raise ProtocolError("bootstrap script has no sections") + + source_pptx = source_pptx.resolve() + with ZipFile(source_pptx) as source: + slide_refs = presentation_slides(source) + slide_width, slide_height = presentation_size(source) + if len(slide_refs) != len(sections): + raise ProtocolError( + f"script section count {len(sections)} != PPTX slide count {len(slide_refs)}" + ) + replacements: dict[str, bytes] = {} + report_slides: list[dict[str, object]] = [] + for ref, section in zip(slide_refs, sections): + index = int(ref["index"]) + slide_part = str(ref["part"]) + notes_part = _notes_part(source, slide_part) + slide_root = etree.fromstring(source.read(slide_part)) + notes_root = etree.fromstring(source.read(notes_part)) + effects = _native_effects(slide_root) + entrance_effects = [ + effect for effect in effects if effect.get("kind") == "entrance" + ] + if not entrance_effects: + raise ProtocolError(f"slide {index} has no native entrance effects to bootstrap") + unsupported = [ + effect["name"] + for effect in entrance_effects + if effect["name"] not in VIDEO_EFFECT_SECONDS + ] + if unsupported: + raise ProtocolError( + f"slide {index} uses video-unsupported native effects {unsupported!r}" + ) + by_shape, _ = _shape_maps( + slide_root, + slide_width=slide_width, + slide_height=slide_height, + ) + grouped: OrderedDict[str, list[dict[str, object]]] = OrderedDict() + for effect in effects: + grouped.setdefault(str(effect["shape_id"]), []).append(effect) + spatial_targets = _spatially_ordered( + [ + { + **by_shape[shape_id], + "shape_effects": shape_effects, + } + for shape_id, shape_effects in grouped.items() + ] + ) + chunks = _split_words( + str(section.get("text") or ""), len(spatial_targets) + ) + used_handles: set[str] = set() + lines: list[str] = [] + blocks: list[dict[str, object]] = [] + for block_index, (target, transcript) in enumerate( + zip(spatial_targets, chunks) + ): + shape_id = str(target["shape_id"]) + shape_effects = target["shape_effects"] + assert isinstance(shape_effects, list) + shape = by_shape.get(shape_id) + if shape is None: + raise ProtocolError( + f"slide {index} animation target {shape_id!r} has no shape" + ) + if not shape.get("top_level"): + raise ProtocolError( + f"slide {index} animation target {shape_id!r} is nested; group it first" + ) + existing = parse_alt_id(str(shape.get("description") or "")) + base = existing or _oneline(shape.get("shape_name")) or f"Shape {shape_id}" + handle = base + if handle in used_handles: + handle = f"{base} #{shape_id}" + if handle in used_handles: + raise ProtocolError( + f"slide {index} could not create a unique handle for shape {shape_id}" + ) + used_handles.add(handle) + matches = slide_root.xpath( + f'.//p:spTree//p:cNvPr[@id="{shape_id}"]', namespaces=NS + ) + if len(matches) != 1: + raise ProtocolError( + f"slide {index} shape {shape_id!r} resolved to {len(matches)} nodes" + ) + if block_index: + lines.append("") + lines.append(f"## [{handle}] {_oneline(shape.get('shape_name')) or handle}") + markers = " ".join(f"[[{effect['name']}]]" for effect in shape_effects) + raw_script = f"{markers} {transcript}" + lines.append(raw_script) + matches[0].set( + "descr", + build_system_alt_text( + handle=handle, + animation_names=[str(effect["name"]) for effect in shape_effects], + raw_script=raw_script, + shape_name=str(shape.get("shape_name") or handle), + shape_id=shape_id, + slide_index=index, + existing_description=str(shape.get("description") or ""), + order_source="geometry", + ), + ) + _set_script_baseline_on_cnvpr( + matches[0], + raw_script, + order_source="geometry", + order_index=block_index, + ) + blocks.append( + { + "handle": handle, + "shape_id": shape_id, + "effects": [effect["name"] for effect in shape_effects], + "transcript": transcript, + } + ) + _replace_notes_body(notes_root, lines) + replacements[slide_part] = etree.tostring( + slide_root, + xml_declaration=True, + encoding="UTF-8", + standalone=True, + ) + replacements[notes_part] = etree.tostring( + notes_root, + xml_declaration=True, + encoding="UTF-8", + standalone=True, + ) + report_slides.append( + { + "index": index, + "section_id": str(section.get("id") or f"slide-{ref['stable_id']}"), + "block_count": len(blocks), + "effect_count": sum(len(block["effects"]) for block in blocks), + "blocks": blocks, + } + ) + + output_pptx.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + prefix=output_pptx.stem + ".", + suffix=".pptx", + dir=output_pptx.parent, + delete=False, + ) as temporary: + temporary_path = Path(temporary.name) + try: + with ZipFile(temporary_path, "w") as destination: + for info in source.infolist(): + data = ( + replacements[info.filename] + if info.filename in replacements + else source.read(info.filename) + ) + destination.writestr(_copy_zipinfo(info), data) + shutil.move(temporary_path, output_pptx) + finally: + temporary_path.unlink(missing_ok=True) + + validated = extract_protocol( + output_pptx, + section_ids=[str(section["section_id"]) for section in report_slides], + ) + return { + "schema_version": "paper2video_editable_pptx_bootstrap.v1", + "created_at": _utc_now(), + "source_pptx": str(source_pptx), + "output_pptx": str(output_pptx.resolve()), + "slide_count": len(report_slides), + "effect_count": validated["effect_count"], + "slides": report_slides, + "validated_source_sha256": validated["source_sha256"], + } + + +def _align_block( + words: list[dict[str, object]], cursor: int, transcript: str +) -> tuple[int, int]: + target = _normalized_chars(transcript) + if not target: + raise ProtocolError("animation transcript block is empty") + combined = "" + end = cursor + while end < len(words) and len(combined) < len(target): + combined += _normalized_chars(words[end].get("text")) + end += 1 + if combined != target: + raise ProtocolError( + f"could not align transcript {transcript!r} at timing word {cursor}; " + f"received {combined!r}, expected {target!r}" + ) + return cursor, end + + +def _marker_timing( + words: list[dict[str, object]], + start: int, + end: int, + normalized_char: int, +) -> tuple[float, int]: + if normalized_char == 0: + return float(words[start].get("start") or 0.0), start + consumed = 0 + for index in range(start, end): + token_length = len(_normalized_chars(words[index].get("text"))) + if consumed == normalized_char: + return float(words[index].get("start") or 0.0), index + consumed += token_length + if consumed > normalized_char: + raise ProtocolError("animation marker is not positioned on a spoken word boundary") + if consumed == normalized_char: + return float(words[end - 1].get("end") or 0.0), end - 1 + raise ProtocolError("animation marker position exceeds its transcript block") + + +def _marker_end_timing( + words: list[dict[str, object]], + start: int, + end: int, + normalized_end_char: int, +) -> tuple[float, int]: + """Resolve a spoken span's exclusive character end to a word end boundary.""" + if normalized_end_char <= 0: + raise ProtocolError("Spotlight spoken scope end must follow its start") + consumed = 0 + for index in range(start, end): + consumed += len(_normalized_chars(words[index].get("text"))) + if consumed == normalized_end_char: + return float(words[index].get("end") or 0.0), index + if consumed > normalized_end_char: + raise ProtocolError( + "Spotlight spoken scope does not end on a spoken word boundary" + ) + raise ProtocolError("Spotlight spoken scope end exceeds its transcript block") + + +def _schedule_slide_effects( + slide: dict[str, object], + words: list[dict[str, object]], +) -> tuple[list[dict[str, object]], list[dict[str, object]], float]: + """Resolve Notes timing and Animation Pane dependencies on one clock. + + Notes block order is the human-facing sequence. A block releases the next + sequential trigger only after both its narration and its effects finish. + ``With Previous`` remains the explicit opt-in for overlap. This prevents a + native-only target from retaining an old absolute Pane time after a Notes + marker moved an earlier effect onto the narration clock. + """ + if str(slide.get("transcript") or "") and not words: + raise ProtocolError( + f"timing section {slide['section_id']!r} has narration but no word boundaries" + ) + + cursor = 0 + sequence_release = 0.0 + previous_effect: dict[str, object] | None = None + scheduled: list[dict[str, object]] = [] + sequence_blocks: list[dict[str, object]] = [] + for block_index, block in enumerate(slide.get("blocks") or [], start=1): + block_transcript = str(block.get("transcript") or "") + if block_transcript: + word_start, word_end = _align_block(words, cursor, block_transcript) + cursor = word_end + narration_start = float(words[word_start].get("start") or 0.0) + spoken_end = float(words[word_end - 1].get("end") or narration_start) + else: + word_start = word_end = cursor + narration_start = sequence_release + spoken_end = sequence_release + + release_before = sequence_release + block_effects: list[dict[str, object]] = [] + effects = list(block.get("effects") or []) + markers = list(block.get("markers") or []) + if len(effects) != len(markers): + raise ProtocolError( + f"slide {slide['index']} handle {block['handle']!r} has " + f"{len(effects)} effects but {len(markers)} timing markers" + ) + for marker, effect in zip(markers, effects): + name = str(effect["name"]) + kind = str(effect.get("kind") or "entrance") + if kind == "entrance": + if name not in VIDEO_EFFECT_SECONDS: + raise ProtocolError( + f"slide {slide['index']} uses Author Notes effect {name!r} " + "without a video strategy" + ) + duration = float(VIDEO_EFFECT_SECONDS[name]) + duration_source = "renderer_default" + else: + duration = max(0.2, float(effect.get("duration_seconds") or 2.4)) + duration_source = "native_or_default" + + marker_source = str(marker.get("source") or "") + trigger = str(effect.get("trigger") or "") + trigger_key = trigger.lower() + delay = float(effect.get("delay_seconds") or 0.0) + pane_start = float(effect.get("pane_start_seconds") or 0.0) + if marker_source != "animation_pane_default" and block_transcript: + requested_start, timing_word = _marker_timing( + words, + word_start, + word_end, + int(marker["normalized_char"]), + ) + start_time = max(requested_start, release_before) + timing_source = "edge_word_alignment" + timing_resolution = ( + "author_notes_marker" + if start_time <= requested_start + 0.0005 + else "author_notes_marker_after_sequence_gate" + ) + else: + requested_start = pane_start + timing_word = word_start + if previous_effect is not None and trigger_key == "witheffect": + start_time = float(previous_effect["start"]) + delay + timing_resolution = "with_previous_resolved" + elif previous_effect is not None and trigger_key == "aftereffect": + start_time = max( + float(previous_effect["end"]), + release_before, + narration_start, + ) + delay + timing_resolution = "after_previous_resolved" + else: + start_time = max(release_before, narration_start) + delay + timing_resolution = "on_click_sequence_resolved" + timing_source = "animation_pane" + + scope_text = _oneline(marker.get("scope_text")) + scope_word_start: int | None = None + scope_word_end: int | None = None + if "normalized_end_char" in marker: + if kind != "emphasis" or name != "Spotlight": + raise ProtocolError( + "spoken-span timing is supported only for Spotlight emphasis" + ) + if not block_transcript or marker_source == "animation_pane_default": + raise ProtocolError( + "Spotlight spoken-span timing requires an explicit narrated marker" + ) + scope_end, scope_word_end = _marker_end_timing( + words, + word_start, + word_end, + int(marker["normalized_end_char"]), + ) + scope_word_start = timing_word + if scope_end <= start_time + 0.0005: + raise ProtocolError( + f"slide {slide['index']} Spotlight scope for " + f"{block['handle']!r} ends before its sequence-gated start" + ) + duration = scope_end - start_time + end_time = scope_end + duration_source = "script_scope" + else: + end_time = start_time + duration + + scheduled_effect = { + "block_index": block_index, + "native_order": int(effect["native_order"]), + "shape_id": str(block["shape_id"]), + "handle": str(block["handle"]), + "name": name, + "kind": kind, + "start": round(start_time, 3), + "end": round(end_time, 3), + "duration": round(duration, 3), + "duration_source": duration_source, + "pane_start": round(pane_start, 3), + "pane_trigger": trigger, + "pane_delay": round(delay, 3), + "sequence_gate": round(release_before, 3), + "requested_start": round(requested_start, 3), + "timing_source": timing_source, + "timing_resolution": timing_resolution, + "intent_source": marker.get("source"), + "word_start": timing_word, + "word_end": max(0, word_end - 1), + "scope_text": scope_text or None, + "scope_word_start": scope_word_start, + "scope_word_end": scope_word_end, + "spoken_end": round(spoken_end, 3), + "simultaneous_group": int( + effect.get("simultaneous_group") or effect["native_order"] + ), + "click_group": int( + effect.get("click_group") or effect["native_order"] + ), + } + scheduled.append(scheduled_effect) + block_effects.append(scheduled_effect) + previous_effect = scheduled_effect + + sequence_release = max( + release_before, + spoken_end, + max((float(effect["end"]) for effect in block_effects), default=0.0), + ) + sequence_blocks.append( + { + "index": block_index, + "handle": str(block["handle"]), + "shape_id": str(block["shape_id"]), + "word_start": word_start, + "word_end": max(0, word_end - 1), + "narration_start": round(narration_start, 3), + "spoken_end": round(spoken_end, 3), + "release_before": round(release_before, 3), + "release": round(sequence_release, 3), + "effect_count": len(block_effects), + "effects": [ + { + key: effect[key] + for key in ( + "name", + "kind", + "start", + "end", + "pane_trigger", + "pane_delay", + "sequence_gate", + "timing_source", + "timing_resolution", + "duration_source", + ) + } + for effect in block_effects + ], + } + ) + + if cursor != len(words): + raise ProtocolError( + f"slide {slide['index']} protocol consumed {cursor}/{len(words)} timing words" + ) + return scheduled, sequence_blocks, round(sequence_release, 3) + + +def build_pptx_animation_manifest( + protocol: dict[str, object], + word_timings: Path, +) -> dict[str, object]: + """Map the reconciled PPTX protocol to Edge word-aligned render effects.""" + try: + timing_payload = json.loads(word_timings.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ProtocolError(f"could not read word timings {word_timings}: {exc}") from exc + timing_sections = timing_payload.get("sections") or [] + slides = protocol.get("slides") or [] + if len(timing_sections) != len(slides): + raise ProtocolError( + f"word timing section count {len(timing_sections)} != slide count {len(slides)}" + ) + + manifest_slides: list[dict[str, object]] = [] + effect_count = 0 + for slide, section in zip(slides, timing_sections): + section_id = str(slide["section_id"]) + if str(section.get("id") or "") != section_id: + raise ProtocolError( + f"timing section {section.get('id')!r} != PPTX section {section_id!r}" + ) + words = section.get("words") or [] + if not isinstance(words, list): + raise ProtocolError(f"timing section {section_id!r} words must be an array") + scheduled, sequence_blocks, schedule_end = _schedule_slide_effects(slide, words) + effects_out: list[dict[str, object]] = [] + for effect in scheduled: + if effect["kind"] != "entrance": + continue + effects_out.append( + { + **effect, + "order": len(effects_out) + 1, + "locator": effect["handle"], + } + ) + effect_count += 1 + manifest_slides.append( + { + "index": int(slide["index"]), + "id": section_id, + "stable_slide_id": slide["stable_slide_id"], + "schedule_policy": "author_notes_block_sequence_v1", + "schedule_end": schedule_end, + "sequence_blocks": sequence_blocks, + "effect_count": len(effects_out), + "effects": effects_out, + } + ) + + return { + "schema_version": MANIFEST_SCHEMA_VERSION, + "created_at": _utc_now(), + "source_kind": "pptx", + "source_pptx": protocol["source_pptx"], + "source_sha256": protocol["source_sha256"], + "protocol_schema_version": protocol["schema_version"], + "word_timings": str(word_timings.resolve()), + "slide_count": len(manifest_slides), + "effect_count": effect_count, + "timing_source": "author_notes_or_animation_pane", + "slides": manifest_slides, + } + + +def build_pptx_visual_cues( + protocol: dict[str, object], + word_timings: Path, +) -> dict[str, object]: + """Build deterministic spotlight cues from native emphasis markers.""" + try: + timing_payload = json.loads(word_timings.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ProtocolError(f"could not read word timings {word_timings}: {exc}") from exc + timing_sections = timing_payload.get("sections") or [] + slides = protocol.get("slides") or [] + if len(timing_sections) != len(slides): + raise ProtocolError( + f"word timing section count {len(timing_sections)} != slide count {len(slides)}" + ) + + cue_slides: list[dict[str, object]] = [] + cue_count = 0 + for slide, section in zip(slides, timing_sections): + section_id = str(slide["section_id"]) + if str(section.get("id") or "") != section_id: + raise ProtocolError( + f"timing section {section.get('id')!r} != PPTX section {section_id!r}" + ) + words = section.get("words") or [] + if not isinstance(words, list): + raise ProtocolError(f"timing section {section_id!r} words must be an array") + scheduled, _, _ = _schedule_slide_effects(slide, words) + blocks_by_index = { + index: block + for index, block in enumerate(slide.get("blocks") or [], start=1) + } + cues: list[dict[str, object]] = [] + for scheduled_effect in scheduled: + if scheduled_effect["kind"] != "emphasis": + continue + block = blocks_by_index[int(scheduled_effect["block_index"])] + bbox = block.get("bbox") + if not isinstance(bbox, list) or len(bbox) != 4: + raise ProtocolError( + f"slide {slide['index']} spotlight target {block['handle']!r} " + "has no usable top-level PowerPoint geometry" + ) + point = [ + round(float(bbox[0]) + float(bbox[2]) / 2.0, 6), + round(float(bbox[1]) + float(bbox[3]) / 2.0, 6), + ] + target = f"pptx:s{int(slide['index']):02d}_sh{block['shape_id']}" + cues.append( + { + "start": scheduled_effect["start"], + "end": scheduled_effect["end"], + "type": "highlight", + "box": [round(float(value), 6) for value in bbox], + "point": point, + "target": target, + "target_role": "content", + "target_source": "pptx", + "semantic_target": target, + "semantic_source": "pptx", + "semantic_box": [round(float(value), 6) for value in bbox], + "geometry_target": target, + "geometry_source": "pptx", + "geometry_box": [round(float(value), 6) for value in bbox], + "geometry_matched": True, + "geometry_match_iou": 1.0, + "confidence": 1.0, + "timing_source": scheduled_effect["timing_source"], + "timing_resolution": scheduled_effect["timing_resolution"], + "duration_source": scheduled_effect["duration_source"], + "intent_source": scheduled_effect["intent_source"], + "text": scheduled_effect.get("scope_text") or block["transcript"], + "scope_text": scheduled_effect.get("scope_text"), + "scope_word_start": scheduled_effect.get("scope_word_start"), + "scope_word_end": scheduled_effect.get("scope_word_end"), + "marker_name": "Spotlight", + "handle": block["handle"], + "shape_id": block["shape_id"], + } + ) + cue_count += 1 + cue_slides.append( + { + "index": int(slide["index"]), + "id": section_id, + "cues": cues, + } + ) + return { + "schema_version": "paper2video_visual_cues.v3", + "cue_shape": "semantic_box", + "source_kind": "pptx_protocol", + "source_sha256": protocol["source_sha256"], + "cue_count": cue_count, + "slides": cue_slides, + } + + +def build_pptx_visual_cue_plan( + visual_cues: dict[str, object], +) -> dict[str, object]: + """Build a deterministic cue-plan sidecar for native emphasis effects. + + The semantic cue planner is intentionally not a runtime dependency of the + editable PPTX route. Native emphasis rows already identify an exact PPTX + shape, while Author Notes and Edge boundaries already identify the exact + time. This adapter records that fully resolved mapping in the same plan + shape consumed by ``build_timeline.py`` and strict QA. + """ + slides_out: list[dict[str, object]] = [] + cue_count = 0 + for slide in visual_cues.get("slides") or []: + chunks: list[dict[str, object]] = [] + for chunk_index, cue in enumerate(slide.get("cues") or [], start=1): + start = float(cue["start"]) + end = float(cue["end"]) + timing_source = str(cue.get("timing_source") or "edge_word_alignment") + target = str(cue.get("target") or "") + box = cue.get("box") + point = cue.get("point") + chunks.append( + { + "chunk_index": chunk_index, + "chunk_id": f"s{int(slide['index']):02d}_c{chunk_index:02d}", + "text": str(cue.get("text") or ""), + "start": round(start, 3), + "end": round(end, 3), + "seconds": round(end - start, 3), + "timing_source": timing_source, + "duration_source": cue.get("duration_source"), + "scope_text": cue.get("scope_text"), + "scope_word_start": cue.get("scope_word_start"), + "scope_word_end": cue.get("scope_word_end"), + "timing": { + "method": timing_source, + "score": 1.0, + "start": round(start, 3), + "end": round(end, 3), + }, + "accepted": True, + "confidence": 1.0, + "reason": "native_pptx_emphasis", + "anchor_required": False, + "anchor_matched": True, + "target": target, + "target_role": cue.get("target_role") or "content", + "target_source": "pptx", + "semantic_target": cue.get("semantic_target") or target, + "semantic_role": cue.get("target_role") or "content", + "semantic_source": "pptx", + "semantic_box": cue.get("semantic_box") or box, + "geometry_target": cue.get("geometry_target") or target, + "geometry_role": cue.get("target_role") or "content", + "geometry_source": "pptx", + "geometry_box": cue.get("geometry_box") or box, + "geometry_matched": True, + "geometry_match_iou": 1.0, + "region_box": box, + "point": point, + "handle": cue.get("handle"), + "shape_id": cue.get("shape_id"), + } + ) + cue_count += 1 + slides_out.append( + { + "index": int(slide["index"]), + "id": str(slide["id"]), + "timing_source": ( + chunks[0]["timing_source"] if chunks else "edge_word_alignment" + ), + "cue_count": len(chunks), + "chunks": chunks, + } + ) + return { + "schema_version": "paper2video_visual_cue_plan.v1", + "source_kind": "pptx_protocol", + "source_sha256": visual_cues.get("source_sha256"), + "min_confidence": 1.0, + "strict_gate": False, + "cue_count": cue_count, + "slides": slides_out, + } + + +def _copy_zipinfo(info: ZipInfo) -> ZipInfo: + copied = ZipInfo(info.filename, date_time=info.date_time) + copied.compress_type = ZIP_DEFLATED + copied.comment = info.comment + copied.extra = info.extra + copied.create_system = info.create_system + copied.create_version = info.create_version + copied.extract_version = info.extract_version + copied.flag_bits = info.flag_bits + copied.volume = info.volume + copied.internal_attr = info.internal_attr + copied.external_attr = info.external_attr + return copied + + +def write_reveal_variant( + source_pptx: Path, + slide_shape_ids: list[list[str]], + reveal_count: int, + output_pptx: Path, +) -> None: + """Write a temporary PPTX where each slide reveals its first N targets.""" + source_pptx = source_pptx.resolve() + with ZipFile(source_pptx) as source: + slide_refs = presentation_slides(source) + if len(slide_refs) != len(slide_shape_ids): + raise ProtocolError( + f"reveal plan has {len(slide_shape_ids)} slides, PPTX has {len(slide_refs)}" + ) + replacements: dict[str, bytes] = {} + for ref, targets in zip(slide_refs, slide_shape_ids): + part = str(ref["part"]) + root = etree.fromstring(source.read(part)) + remove_ids = set(targets[max(0, reveal_count):]) + for shape_id in remove_ids: + matches = root.xpath( + f'.//p:spTree//p:cNvPr[@id="{shape_id}"]', namespaces=NS + ) + if len(matches) != 1: + raise ProtocolError( + f"slide {ref['index']} reveal target {shape_id!r} resolved " + f"to {len(matches)} shapes" + ) + top = matches[0] + while top.getparent() is not None and top.getparent().tag != f"{{{P_NS}}}spTree": + top = top.getparent() + if top.getparent() is None: + raise ProtocolError( + f"slide {ref['index']} reveal target {shape_id!r} is not under p:spTree" + ) + top.getparent().remove(top) + for timing in root.findall(f"{{{P_NS}}}timing"): + root.remove(timing) + replacements[part] = etree.tostring( + root, + xml_declaration=True, + encoding="UTF-8", + standalone=True, + ) + + output_pptx.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + prefix=output_pptx.stem + ".", + suffix=".pptx", + dir=output_pptx.parent, + delete=False, + ) as temporary: + temporary_path = Path(temporary.name) + try: + with ZipFile(temporary_path, "w") as destination: + for info in source.infolist(): + destination.writestr( + _copy_zipinfo(info), + replacements.get(info.filename, source.read(info.filename)), + ) + shutil.move(temporary_path, output_pptx) + finally: + temporary_path.unlink(missing_ok=True) diff --git a/ResearchStudio-Reel/skills/paper2video/scripts/extract_editable_pptx.py b/ResearchStudio-Reel/skills/paper2video/scripts/extract_editable_pptx.py new file mode 100755 index 0000000..34474ec --- /dev/null +++ b/ResearchStudio-Reel/skills/paper2video/scripts/extract_editable_pptx.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Extract a no-LLM narration script and strict protocol report from a PPTX.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from editable_pptx import ProtocolError, extract_protocol, script_from_protocol, write_json + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("pptx", type=Path) + parser.add_argument("--ids-from-script", type=Path, default=None) + parser.add_argument("--voice", default=None) + parser.add_argument("--script-out", type=Path, required=True) + parser.add_argument("--report-out", type=Path, required=True) + args = parser.parse_args() + try: + protocol = extract_protocol( + args.pptx, + ids_from_script=args.ids_from_script, + ) + script = script_from_protocol(protocol, voice=args.voice) + except (OSError, ProtocolError) as exc: + sys.exit(f"[extract_editable_pptx] {exc}") + write_json(args.report_out, protocol) + write_json(args.script_out, script) + print( + f"[extract_editable_pptx] wrote {args.script_out} and {args.report_out} " + f"({protocol['slide_count']} slides, {protocol['effect_count']} effects)" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ResearchStudio-Reel/skills/paper2video/scripts/generate_edge_audio.py b/ResearchStudio-Reel/skills/paper2video/scripts/generate_edge_audio.py index bd2e7d6..aa63a6d 100644 --- a/ResearchStudio-Reel/skills/paper2video/scripts/generate_edge_audio.py +++ b/ResearchStudio-Reel/skills/paper2video/scripts/generate_edge_audio.py @@ -11,7 +11,10 @@ import argparse import asyncio import json +import shutil +import subprocess import sys +import tempfile from pathlib import Path try: @@ -101,6 +104,74 @@ async def synthesize_section_with_timings( return words +def probe_audio_duration(path: Path) -> float | None: + """Return the decoded clip duration when ffprobe is available.""" + ffprobe = shutil.which("ffprobe") + if ffprobe is None: + return None + probe = subprocess.run( + [ + ffprobe, + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "default=noprint_wrappers=1:nokey=1", + str(path), + ], + capture_output=True, + text=True, + check=False, + ) + try: + return float(probe.stdout.strip()) if probe.returncode == 0 else None + except ValueError: + return None + + +def ensure_minimum_audio_duration(path: Path, minimum_seconds: float) -> bool: + """Pad a spoken clip with silence when native animation timing runs longer.""" + if minimum_seconds <= 0: + return False + ffmpeg = shutil.which("ffmpeg") + current = probe_audio_duration(path) + if ffmpeg is None or current is None: + return False + if current + 0.02 >= minimum_seconds: + return False + with tempfile.NamedTemporaryFile( + prefix=path.stem + ".padded.", + suffix=".mp3", + dir=path.parent, + delete=False, + ) as temporary: + padded = Path(temporary.name) + try: + subprocess.run( + [ + ffmpeg, + "-y", + "-v", + "error", + "-i", + str(path), + "-af", + "apad", + "-t", + str(minimum_seconds), + "-c:a", + "libmp3lame", + str(padded), + ], + check=True, + ) + padded.replace(path) + finally: + padded.unlink(missing_ok=True) + return True + + async def synthesize_all( sections: list[dict], *, @@ -117,32 +188,73 @@ async def synthesize_all( text = str(sec.get("text") or "").strip() if not sid: raise ValueError("every script section must have an id") - if not text: - raise ValueError(f"section {sid} has empty text") out_path = outdir / f"{sid}.mp3" - print(f"[edge-tts] {sid} ({len(text)} chars, voice={voice}, rate={rate}) -> {out_path}") words: list[dict] = [] - if collect_timings: - words = await synthesize_section_with_timings( - text, - voice=voice, - rate=rate, - pitch=pitch, - out_path=out_path, + provider = "edge-tts" + if text: + print( + f"[edge-tts] {sid} ({len(text)} chars, voice={voice}, rate={rate}) " + f"-> {out_path}" ) + if collect_timings: + words = await synthesize_section_with_timings( + text, + voice=voice, + rate=rate, + pitch=pitch, + out_path=out_path, + ) + else: + await synthesize_section( + text, + voice=voice, + rate=rate, + pitch=pitch, + out_path=out_path, + ) else: - await synthesize_section(text, voice=voice, rate=rate, pitch=pitch, out_path=out_path) + duration = max(1.0, float(sec.get("duration_seconds") or 1.0)) + ffmpeg = shutil.which("ffmpeg") + if ffmpeg is None: + raise ValueError( + f"section {sid} is silent and ffmpeg is required to create its audio track" + ) + print(f"[edge-tts] {sid} (silent, {duration:.3f}s) -> {out_path}") + subprocess.run( + [ + ffmpeg, + "-y", + "-v", + "error", + "-f", + "lavfi", + "-i", + "anullsrc=r=44100:cl=stereo", + "-t", + str(duration), + "-c:a", + "libmp3lame", + str(out_path), + ], + check=True, + ) + provider = "generated-silence" manifest.append({ "id": sid, "heading": sec.get("heading", sid), "file": out_path.name, "bytes": out_path.stat().st_size, - "provider": "edge-tts", + "provider": provider, "voice": voice, "rate": rate, "pitch": pitch, "word_boundaries": len(words), }) + ensure_minimum_audio_duration( + out_path, + max(0.0, float(sec.get("duration_seconds") or 0.0)), + ) + manifest[-1]["bytes"] = out_path.stat().st_size if collect_timings: timing_sections.append({ "id": sid, diff --git a/ResearchStudio-Reel/skills/paper2video/scripts/generate_visual_cues.py b/ResearchStudio-Reel/skills/paper2video/scripts/generate_visual_cues.py index d4e86d2..90fc639 100644 --- a/ResearchStudio-Reel/skills/paper2video/scripts/generate_visual_cues.py +++ b/ResearchStudio-Reel/skills/paper2video/scripts/generate_visual_cues.py @@ -591,6 +591,93 @@ def geometry_text_overlap(chunk: str, semantic_region: Region, geometry_region: return len((semantic_tokens | chunk_tokens) & geometry_tokens) +# --- Wrapped-line grouping ------------------------------------------------- +# One logical text (a title or tagline) is frequently authored/exported as +# SEVERAL per-line boxes under a single group. A cue that matches one line +# should spotlight the whole wrapped run, so the box handed to the renderer is +# the intended unit — the renderer's ink-tighten then hugs all the lines. This +# unions ONLY genuine single-line continuations (same left edge, same line +# height, nearly touching, each clearly one line = wide:short), so multi-line +# PARAGRAPH blocks inside a card — which must keep their own focus for the +# script's progression — are never merged. +GROUP_WRAPPED_LINES = os.environ.get( + "VIDEO_CUE_GROUP_WRAPPED_LINES", "1").strip().lower() not in ("0", "off", "false", "no") +WRAP_X_EPS = float(os.environ.get("VIDEO_CUE_WRAP_X_EPS", "0.02")) # same left edge +WRAP_MIN_ASPECT = float(os.environ.get("VIDEO_CUE_WRAP_MIN_ASPECT", "2.5")) # w/h => one line +WRAP_GAP_MAX_FRAC = float(os.environ.get("VIDEO_CUE_WRAP_GAP_FRAC", "0.15")) # gap <= frac*line-h +WRAP_H_LO, WRAP_H_HI = 0.6, 1.6 # line-height ratio band + + +def _region_is_single_line(region: Region) -> bool: + _, _, w, h = region.box + return h > 0 and (w / h) >= WRAP_MIN_ASPECT + + +def _wrapped_adjacent(a: Region, b: Region) -> bool: + """True when b is a wrapped continuation directly below a: same column, same + line height, nearly touching, both clearly single lines.""" + ax, ay, aw, ah = a.box + bx, by, bw, bh = b.box + if abs(ax - bx) > WRAP_X_EPS: + return False + if ah <= 0 or bh <= 0 or not (WRAP_H_LO <= bh / ah <= WRAP_H_HI): + return False + if not (_region_is_single_line(a) and _region_is_single_line(b)): + return False + gap = by - (ay + ah) + minh = min(ah, bh) + return -0.5 * minh <= gap <= WRAP_GAP_MAX_FRAC * minh + + +def _wrapped_run(target: Region, siblings: list[Region]) -> list[Region]: + order = sorted(siblings, key=lambda r: r.box[1]) + try: + idx = order.index(target) + except ValueError: + return [target] + run = [target] + i = idx + while i + 1 < len(order) and _wrapped_adjacent(order[i], order[i + 1]): + run.append(order[i + 1]) + i += 1 + i = idx + while i - 1 >= 0 and _wrapped_adjacent(order[i - 1], order[i]): + run.insert(0, order[i - 1]) + i -= 1 + return run + + +def union_wrapped_line_cues(cue_entries: list[dict], regions: list[Region]) -> None: + """In-place: expand each single-line-fragment cue to its full wrapped run so + the box is the whole title/tagline, not one line. Only pptx single-element + text cues are touched; clusters and multi-line blocks are left as-is.""" + if not GROUP_WRAPPED_LINES: + return + by_id = {r.region_id: r for r in regions if r.source == "pptx"} + for cue in cue_entries: + if cue.get("geometry_source") != "pptx": + continue + target = by_id.get(cue.get("geometry_target")) + if target is None or target.shape_type != "TEXT_BOX" or not target.parent_id: + continue + if not _region_is_single_line(target): + continue + sibs = [r for r in by_id.values() + if r.parent_id == target.parent_id and r.shape_type == "TEXT_BOX"] + if len(sibs) < 2: + continue + run = _wrapped_run(target, sibs) + if len(run) < 2: + continue + ub = union_region_boxes(run) + if not ub: + continue + cue["box"] = round_list(ub) + cue["point"] = round_list(point_from_box(ub)) + cue["geometry_box"] = round_list(ub) + cue["grouped_wrapped_lines"] = [r.region_id for r in run] + + def geometry_match_score(chunk: str, semantic_region: Region, geometry_region: Region) -> tuple[float, list[str], float, float, float]: semantic_box = semantic_region.box geometry_box = geometry_region.box @@ -2094,6 +2181,10 @@ def generate(project: Path, *, svg_dir: Path, sections: list[Section], "seconds": round(end - start, 3), }) + # Union single-line title/tagline fragments so the cue box is the whole + # wrapped unit (renderer's ink-tighten then hugs all the lines). Multi-line + # paragraph blocks are never merged, preserving the script's progression. + union_wrapped_line_cues(cue_entries, regions) cues_payload["slides"].append({ "index": sec.index, "id": sec.sid, diff --git a/ResearchStudio-Reel/skills/paper2video/scripts/narration_regeneration.py b/ResearchStudio-Reel/skills/paper2video/scripts/narration_regeneration.py new file mode 100644 index 0000000..f7c9129 --- /dev/null +++ b/ResearchStudio-Reel/skills/paper2video/scripts/narration_regeneration.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +"""Change-aware optional narration regeneration for editable PPTX video.""" + +from __future__ import annotations + +import json +import os +import tempfile +from pathlib import Path +from typing import Callable + +from editable_pptx import ProtocolError, apply_user_script + + +DEFAULT_MODEL = "gpt-5.6-sol" + + +def _regeneration_targets( + protocol: dict[str, object], + change_report: dict[str, object], +) -> list[dict[str, object]]: + blocks_by_key: dict[tuple[int, str], dict[str, object]] = {} + for slide in protocol.get("slides") or []: + slide_index = int(slide["index"]) + for block in slide.get("blocks") or []: + blocks_by_key[(slide_index, str(block["handle"]))] = block + + targets: list[dict[str, object]] = [] + seen: set[tuple[int, str]] = set() + for change in change_report.get("changes") or []: + if str(change.get("kind") or "") == "removed": + continue + after = change.get("after") or {} + slide_index = int(change.get("slide_index") or 0) + handle = str(change.get("handle") or after.get("handle") or "") + key = (slide_index, handle) + block = blocks_by_key.get(key) + if block is None or key in seen: + continue + seen.add(key) + targets.append( + { + "slide_index": slide_index, + "handle": handle, + "change_kind": change.get("kind"), + "shape_name": after.get("shape_name") or block.get("shape_name"), + "shape_text": after.get("text") or "", + "animations": [ + str(effect.get("name") or "") for effect in block.get("effects") or [] + ], + "existing_script": str(block.get("raw_transcript") or ""), + } + ) + return targets + + +def _openai_responder( + targets: list[dict[str, object]], + *, + model: str, +) -> list[dict[str, object]]: + if not os.environ.get("OPENAI_API_KEY"): + raise ProtocolError( + "OPENAI_API_KEY is required only when --narration-mode regenerate is selected" + ) + try: + from openai import OpenAI + except ImportError as exc: # pragma: no cover - installer supplies openai + raise ProtocolError( + "the openai Python package is required for narration regeneration" + ) from exc + + schema = { + "type": "object", + "properties": { + "updates": { + "type": "array", + "items": { + "type": "object", + "properties": { + "slide_index": {"type": "integer"}, + "handle": {"type": "string"}, + "script": {"type": "string"}, + }, + "required": ["slide_index", "handle", "script"], + "additionalProperties": False, + }, + } + }, + "required": ["updates"], + "additionalProperties": False, + } + client = OpenAI() + response = client.responses.create( + model=model, + reasoning={"effort": "low"}, + instructions=( + "Rewrite narration only for the supplied changed PowerPoint elements. " + "Keep each script concise and factual. Preserve a [[Animation Name]] marker " + "only when it corresponds to an animation listed for that element. Return " + "exactly one update per supplied target and do not rename handles." + ), + input=json.dumps({"targets": targets}, ensure_ascii=False), + text={ + "format": { + "type": "json_schema", + "name": "pptx_narration_updates", + "strict": True, + "schema": schema, + } + }, + ) + try: + payload = json.loads(response.output_text) + except (AttributeError, json.JSONDecodeError) as exc: + raise ProtocolError("OpenAI returned invalid narration update JSON") from exc + return list(payload.get("updates") or []) + + +def regenerate_changed_narration( + protocol: dict[str, object], + change_report: dict[str, object], + *, + model: str = DEFAULT_MODEL, + responder: Callable[[list[dict[str, object]]], list[dict[str, object]]] | None = None, +) -> tuple[dict[str, object], dict[str, object]]: + """Regenerate only changed current handles and leave every other script intact.""" + targets = _regeneration_targets(protocol, change_report) + if not targets: + return protocol, { + "schema_version": "paper2video_narration_regeneration.v1", + "model": model, + "target_count": 0, + "updated_count": 0, + "updates": [], + } + updates = ( + responder(targets) + if responder is not None + else _openai_responder(targets, model=model) + ) + expected = {(int(item["slide_index"]), str(item["handle"])) for item in targets} + received: dict[tuple[int, str], str] = {} + for update in updates: + key = (int(update.get("slide_index") or 0), str(update.get("handle") or "")) + script = str(update.get("script") or "").strip() + if key not in expected or key in received or not script: + raise ProtocolError( + "narration regeneration returned an unknown, duplicate, or empty update" + ) + received[key] = script + if set(received) != expected: + missing = sorted(expected - set(received)) + raise ProtocolError(f"narration regeneration omitted changed handles: {missing}") + + sections: list[dict[str, object]] = [] + for slide in protocol.get("slides") or []: + slide_index = int(slide["index"]) + elements = [ + {"handle": handle, "script": script} + for (index, handle), script in received.items() + if index == slide_index + ] + sections.append({"id": slide["section_id"], "elements": elements}) + with tempfile.NamedTemporaryFile( + prefix="paper2video-regenerated-", + suffix=".json", + mode="w", + encoding="utf-8", + delete=False, + ) as temporary: + temporary.write(json.dumps({"sections": sections}, ensure_ascii=False)) + script_path = Path(temporary.name) + try: + updated, _ = apply_user_script(protocol, script_path) + finally: + script_path.unlink(missing_ok=True) + for slide in updated.get("slides") or []: + for block in slide.get("blocks") or []: + if (int(slide["index"]), str(block["handle"])) in received: + block["script_source"] = "llm_regeneration" + slide["script_sources"] = list( + dict.fromkeys( + str(block["script_source"]) + for block in slide.get("blocks") or [] + if str(block.get("transcript") or "") + ) + ) + updated["script_sources"] = sorted( + { + str(source) + for slide in updated.get("slides") or [] + for source in slide.get("script_sources") or [] + } + ) + return updated, { + "schema_version": "paper2video_narration_regeneration.v1", + "model": model, + "target_count": len(targets), + "updated_count": len(received), + "targets": targets, + "updates": [ + {"slide_index": index, "handle": handle, "script": script} + for (index, handle), script in received.items() + ], + } diff --git a/ResearchStudio-Reel/skills/paper2video/scripts/render_edited_pptx.py b/ResearchStudio-Reel/skills/paper2video/scripts/render_edited_pptx.py new file mode 100755 index 0000000..7c41731 --- /dev/null +++ b/ResearchStudio-Reel/skills/paper2video/scripts/render_edited_pptx.py @@ -0,0 +1,550 @@ +#!/usr/bin/env python3 +"""Render an edited protocol PPTX to a strictly checked video without an LLM.""" + +from __future__ import annotations + +import argparse +import json +import shutil +import subprocess +import sys +from pathlib import Path + +from editable_pptx import ( + ProtocolError, + apply_user_script, + build_pptx_animation_manifest, + build_pptx_visual_cue_plan, + build_pptx_visual_cues, + detect_pptx_changes, + extract_protocol, + normalize_author_notes_authority, + script_from_protocol, + write_protocol_to_pptx, + write_json, +) +from narration_regeneration import DEFAULT_MODEL, regenerate_changed_narration +from generate_edge_audio import ensure_minimum_audio_duration, probe_audio_duration + + +SCRIPT_DIR = Path(__file__).resolve().parent + + +def _run(command: list[str]) -> None: + print("[render_edited_pptx] $ " + " ".join(command), flush=True) + subprocess.run(command, check=True) + + +def _copy_audio_bundle(source: Path, destination: Path, section_ids: list[str]) -> None: + source = source.resolve() + destination.mkdir(parents=True, exist_ok=True) + for section_id in section_ids: + source_mp3 = source / f"{section_id}.mp3" + if not source_mp3.is_file(): + raise ProtocolError(f"prebuilt audio is missing {source_mp3}") + destination_mp3 = destination / source_mp3.name + if source_mp3.resolve() != destination_mp3.resolve(): + shutil.copy2(source_mp3, destination_mp3) + timings = source / "word_timings.json" + if not timings.is_file(): + raise ProtocolError(f"prebuilt audio is missing {timings}") + if timings.resolve() != (destination / timings.name).resolve(): + shutil.copy2(timings, destination / timings.name) + manifest = source / "manifest.json" + if manifest.is_file() and manifest.resolve() != (destination / manifest.name).resolve(): + shutil.copy2(manifest, destination / manifest.name) + + +def _prune_orphan_audio(audio_dir: Path, section_ids: set[str]) -> None: + for mp3 in audio_dir.glob("*.mp3"): + if mp3.stem not in section_ids: + mp3.unlink() + + +def _pad_audio_for_sequence( + manifest: dict[str, object], + audio_dir: Path, +) -> dict[str, object]: + """Keep the rendered segment alive through the resolved animation schedule.""" + entries: list[dict[str, object]] = [] + for slide in manifest.get("slides") or []: + section_id = str(slide["id"]) + audio_path = audio_dir / f"{section_id}.mp3" + minimum = round(float(slide.get("schedule_end") or 0.0) + 0.05, 3) + before = probe_audio_duration(audio_path) + padded = ensure_minimum_audio_duration(audio_path, minimum) + after = probe_audio_duration(audio_path) + if after is None or after + 0.02 < minimum: + raise ProtocolError( + f"audio {audio_path} ends at {after!r}s but the resolved animation " + f"sequence requires at least {minimum:.3f}s" + ) + entries.append( + { + "id": section_id, + "schedule_end": slide.get("schedule_end"), + "minimum_audio_seconds": minimum, + "before_seconds": round(before, 3) if before is not None else None, + "after_seconds": round(after, 3), + "padded": padded, + } + ) + + audio_manifest_path = audio_dir / "manifest.json" + if audio_manifest_path.is_file(): + try: + audio_manifest = json.loads(audio_manifest_path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + raise ProtocolError(f"could not refresh {audio_manifest_path}: {exc}") from exc + by_id = {str(entry["id"]): entry for entry in entries} + for item in audio_manifest: + section_id = str(item.get("id") or "") + if section_id not in by_id: + continue + item["bytes"] = (audio_dir / f"{section_id}.mp3").stat().st_size + item["sequence_minimum_seconds"] = by_id[section_id]["minimum_audio_seconds"] + item["sequence_padding_applied"] = by_id[section_id]["padded"] + audio_manifest_path.write_text( + json.dumps(audio_manifest, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + return { + "schema_version": "paper2video_animation_sequence_audio.v1", + "slide_count": len(entries), + "padded_count": sum(1 for entry in entries if entry["padded"]), + "slides": entries, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("pptx", type=Path, help="Edited PPTX source of truth") + parser.add_argument("outdir", type=Path, help="Paper2Video v2 output bundle") + parser.add_argument( + "--ids-from-script", + type=Path, + default=None, + help="Preserve existing semantic slide IDs while rebuilding narration from the PPTX protocol.", + ) + parser.add_argument( + "--script-json", + type=Path, + default=None, + help=( + "Use a user-edited script.json as narration authority. Section text replaces " + "slide narration; optional per-section elements preserve precise handle timing." + ), + ) + parser.add_argument( + "--baseline-pptx", + type=Path, + default=None, + help="Previous editable PPTX used to identify which elements changed.", + ) + parser.add_argument( + "--narration-mode", + choices=("keep", "regenerate"), + default="keep", + help="Keep PPTX narration, or regenerate narration only for changed elements.", + ) + parser.add_argument( + "--regeneration-model", + default=DEFAULT_MODEL, + help="OpenAI model used only with --narration-mode regenerate.", + ) + parser.add_argument("--voice", default=None, help="Edge TTS voice") + parser.add_argument("--rate", default="+0%", help="Edge TTS rate") + parser.add_argument( + "--prebuilt-audio-dir", + type=Path, + default=None, + help="Offline/test mode: use matching MP3s and word_timings.json instead of calling Edge TTS.", + ) + parser.add_argument("--resolution", choices=("720p", "1080p", "1440p", "4k"), default="1080p") + parser.add_argument("--fps", type=int, default=30) + parser.add_argument("--start-pad", type=float, default=0.5) + parser.add_argument("--pad-tail", type=float, default=0.3) + parser.add_argument("--visual-cues", type=Path, default=None) + parser.add_argument( + "--visual-cue-plan", + type=Path, + default=None, + help="Optional matching visual_cue_plan.json for an externally supplied --visual-cues file.", + ) + parser.add_argument( + "--highlight-style", + default="spotlight_laser", + choices=( + "box", "spotlight", "cursor", "box_cursor", "spotlight_cursor", + "laser", "box_laser", "spotlight_laser", + ), + ) + parser.add_argument("--no-subtitles", action="store_true") + parser.add_argument("--keep-temp", action="store_true") + parser.add_argument( + "--no-qa", + action="store_true", + help="Debug only: skip the final strict package gate.", + ) + args = parser.parse_args() + + source_pptx = args.pptx.resolve() + outdir = args.outdir.resolve() + if not source_pptx.is_file(): + sys.exit(f"[render_edited_pptx] PPTX not found: {source_pptx}") + if outdir.exists(): + sys.exit( + f"[render_edited_pptx] output bundle already exists; choose a fresh path: {outdir}" + ) + if args.fps <= 0 or args.start_pad < 0 or args.pad_tail < 0: + sys.exit("[render_edited_pptx] fps must be positive and padding must be non-negative") + + audio_dir = outdir / "assets" / "audio" + captions_dir = outdir / "assets" / "captions" + slides_dir = outdir / "assets" / "slides" + clips_dir = outdir / "assets" / "clips" + meta_dir = outdir / "assets" / "meta" + reports_dir = meta_dir / "reports" + frames_dir = slides_dir / "frames" + for directory in ( + audio_dir, + captions_dir, + slides_dir, + clips_dir, + reports_dir, + ): + directory.mkdir(parents=True, exist_ok=True) + + script_path = audio_dir / "script.json" + protocol_path = reports_dir / "editable_pptx_protocol.json" + timings_path = audio_dir / "word_timings.json" + animation_manifest_path = meta_dir / "animation_manifest.json" + animation_report_path = reports_dir / "animation_render_report.json" + author_cues_path = meta_dir / "editable_pptx_visual_cues.json" + author_cue_plan_path = meta_dir / "editable_pptx_visual_cue_plan.json" + duration_report_path = meta_dir / "video_duration_report.json" + timeline_path = meta_dir / "timeline.json" + raw_path = clips_dir / "video_raw.mp4" + raw_delivery = outdir / "video_no_subtitles.mp4" + final_path = outdir / "video.mp4" + srt_path = captions_dir / "video.srt" + vtt_path = captions_dir / "video.vtt" + qa_path = reports_dir / "video_qa_report.json" + authority_report_path = reports_dir / "author_notes_authority.json" + script_authority_path = reports_dir / "script_authority.json" + changes_report_path = reports_dir / "pptx_changes.json" + regeneration_report_path = reports_dir / "narration_regeneration.json" + sequence_audio_report_path = reports_dir / "animation_sequence_audio.json" + protocol_writeback_path = reports_dir / "protocol_writeback.json" + subtitle_timing_report_path = reports_dir / "subtitle_timing_alignment.json" + delivered_pptx = outdir / "video.pptx" + try: + if args.narration_mode == "regenerate" and args.baseline_pptx is None: + raise ProtocolError( + "--narration-mode regenerate requires --baseline-pptx" + ) + if args.narration_mode == "regenerate" and args.script_json is not None: + raise ProtocolError( + "--narration-mode regenerate and --script-json are mutually exclusive" + ) + authority_report = normalize_author_notes_authority(source_pptx, delivered_pptx) + write_json(authority_report_path, authority_report) + protocol = extract_protocol( + delivered_pptx, + ids_from_script=args.ids_from_script or args.script_json, + ) + change_report = None + if args.baseline_pptx is not None: + baseline_pptx = args.baseline_pptx.resolve() + if not baseline_pptx.is_file(): + raise ProtocolError(f"baseline PPTX not found: {baseline_pptx}") + change_report = detect_pptx_changes(baseline_pptx, delivered_pptx) + write_json(changes_report_path, change_report) + if args.narration_mode == "regenerate": + assert change_report is not None + protocol, regeneration_report = regenerate_changed_narration( + protocol, + change_report, + model=args.regeneration_model, + ) + write_json(regeneration_report_path, regeneration_report) + write_protocol_to_pptx(delivered_pptx, protocol, delivered_pptx) + protocol = extract_protocol( + delivered_pptx, + ids_from_script=args.ids_from_script, + ) + script_authority = { + "schema_version": "paper2video_user_script_authority.v1", + "script_json": None, + "resolution": "llm_regeneration", + "model": args.regeneration_model, + "changed_target_count": regeneration_report["target_count"], + "updated_count": regeneration_report["updated_count"], + "slide_count": protocol["slide_count"], + } + if args.script_json is not None: + protocol, script_authority = apply_user_script(protocol, args.script_json) + write_protocol_to_pptx(delivered_pptx, protocol, delivered_pptx) + protocol = extract_protocol(delivered_pptx, ids_from_script=args.script_json) + elif args.narration_mode == "keep": + script_authority = { + "schema_version": "paper2video_user_script_authority.v1", + "script_json": None, + "resolution": "pptx_protocol", + "script_sources": protocol.get("script_sources") or [], + "slide_count": protocol["slide_count"], + "baseline_pptx": ( + str(args.baseline_pptx.resolve()) if args.baseline_pptx else None + ), + "detected_change_count": ( + int(change_report["change_count"]) if change_report else None + ), + } + protocol_writeback = write_protocol_to_pptx( + delivered_pptx, + protocol, + delivered_pptx, + ) + write_json(protocol_writeback_path, protocol_writeback) + protocol = extract_protocol( + delivered_pptx, + ids_from_script=args.ids_from_script or args.script_json, + ) + script = script_from_protocol(protocol, voice=args.voice) + except (OSError, ProtocolError) as exc: + sys.exit(f"[render_edited_pptx] PPTX protocol reconciliation failed: {exc}") + write_json(script_path, script) + write_json(protocol_path, protocol) + write_json(script_authority_path, script_authority) + section_ids = [str(section["id"]) for section in script["sections"]] + has_narration = any(str(section.get("text") or "").strip() for section in script["sections"]) + effective_no_subtitles = args.no_subtitles or not has_narration + _prune_orphan_audio(audio_dir, set(section_ids)) + + try: + if args.prebuilt_audio_dir is not None: + _copy_audio_bundle(args.prebuilt_audio_dir, audio_dir, section_ids) + else: + tts_command = [ + sys.executable, + str(SCRIPT_DIR / "generate_edge_audio.py"), + str(script_path), + "--outdir", + str(audio_dir), + "--rate", + args.rate, + "--timings-out", + str(timings_path), + ] + if args.voice: + tts_command.extend(["--voice", args.voice]) + _run(tts_command) + + manifest = build_pptx_animation_manifest(protocol, timings_path) + write_json(animation_manifest_path, manifest) + author_cues = build_pptx_visual_cues(protocol, timings_path) + write_json(author_cues_path, author_cues) + author_cue_plan = build_pptx_visual_cue_plan(author_cues) + write_json(author_cue_plan_path, author_cue_plan) + write_json( + sequence_audio_report_path, + _pad_audio_for_sequence(manifest, audio_dir), + ) + except (OSError, ProtocolError, subprocess.CalledProcessError) as exc: + sys.exit(f"[render_edited_pptx] audio/manifest stage failed: {exc}") + + render_command = [ + sys.executable, + str(SCRIPT_DIR / "render_video.py"), + str(outdir), + "--pptx", + str(delivered_pptx), + "--audio-dir", + str(audio_dir), + "--script-json", + str(script_path), + "--frame-source", + "pptx", + "--animation-source", + "pptx", + "--animation-manifest", + str(animation_manifest_path), + "--animation-report-out", + str(animation_report_path), + "--duration-report-out", + str(duration_report_path), + "--resolution", + args.resolution, + "--fps", + str(args.fps), + "--start-pad", + str(args.start_pad), + "--pad-tail", + str(args.pad_tail), + "--frames-out", + str(frames_dir), + "--out", + str(raw_path), + ] + if int(manifest.get("effect_count") or 0) > 0: + render_command.append("--require-animations") + effective_visual_cues = ( + args.visual_cues.resolve() + if args.visual_cues is not None + else (author_cues_path if int(author_cues.get("cue_count") or 0) else None) + ) + using_native_emphasis_cues = ( + args.visual_cues is None and effective_visual_cues == author_cues_path + ) + effective_cue_plan = ( + args.visual_cue_plan.resolve() + if args.visual_cue_plan is not None + else (author_cue_plan_path if using_native_emphasis_cues else None) + ) + if args.visual_cue_plan is not None and args.visual_cues is None: + sys.exit("[render_edited_pptx] --visual-cue-plan requires --visual-cues") + if effective_visual_cues is not None: + render_command.extend( + [ + "--attention-mode", + "highlight", + "--highlight-style", + args.highlight_style, + "--visual-cues", + str(effective_visual_cues), + ] + ) + else: + render_command.extend(["--attention-mode", "none"]) + if args.keep_temp: + render_command.append("--keep-temp") + + try: + _run(render_command) + shutil.copy2(raw_path, raw_delivery) + subtitle_command = [ + sys.executable, + str(SCRIPT_DIR / "add_subtitles.py"), + str(outdir), + "--mp4", + str(raw_path), + "--audio-dir", + str(audio_dir), + "--script-json", + str(script_path), + "--word-timings", + str(timings_path), + "--require-word-timings", + "--timing-report-out", + str(subtitle_timing_report_path), + "--start-pad", + str(args.start_pad), + "--pad-tail", + str(args.pad_tail), + "--srt-out", + str(srt_path), + "--vtt-out", + str(vtt_path), + "--out", + str(final_path), + ] + if effective_no_subtitles: + subtitle_command.append("--no-subtitles") + _run(subtitle_command) + shutil.copy2(delivered_pptx, slides_dir / "slides.pptx") + + timeline_command = [ + sys.executable, + str(SCRIPT_DIR / "build_timeline.py"), + "--script-json", + str(script_path), + "--duration-report", + str(duration_report_path), + "--captions-vtt", + str(vtt_path), + "--audio-dir", + str(audio_dir), + "--video", + str(raw_delivery), + "--out", + str(timeline_path), + ] + if effective_visual_cues is not None: + timeline_command.extend(["--visual-cues", str(effective_visual_cues)]) + if effective_cue_plan is not None: + timeline_command.extend(["--visual-cue-plan", str(effective_cue_plan)]) + _run(timeline_command) + except (OSError, subprocess.CalledProcessError) as exc: + sys.exit(f"[render_edited_pptx] render stage failed: {exc}") + + if not args.no_qa: + qa_command = [ + sys.executable, + str(SCRIPT_DIR / "check_video_package.py"), + str(outdir), + "--pptx", + str(outdir / "video.pptx"), + "--script-json", + str(script_path), + "--audio-dir", + str(audio_dir), + "--frames-dir", + str(frames_dir), + "--mp4", + str(final_path), + "--raw-mp4", + str(raw_delivery), + "--subtitle-file", + str(vtt_path), + "--subtitle-timing-report", + str(subtitle_timing_report_path), + "--animation-manifest", + str(animation_manifest_path), + "--animation-report", + str(animation_report_path), + "--timeline", + str(timeline_path), + "--require-word-timings", + "--require-timeline", + "--strict", + "--out", + str(qa_path), + ] + if int(manifest.get("effect_count") or 0) > 0: + qa_command.append("--require-animations") + if not effective_no_subtitles: + qa_command.extend( + ["--require-subtitles", "--require-subtitle-word-alignment"] + ) + if effective_visual_cues is not None: + qa_command.extend( + [ + "--visual-cues", + str(effective_visual_cues), + ] + ) + if effective_cue_plan is not None: + qa_command.extend(["--cue-plan", str(effective_cue_plan)]) + if args.visual_cues is not None and effective_cue_plan is not None: + qa_command.extend( + ["--strict-attention", "--require-visual-cues", "--require-cue-plan"] + ) + else: + # Native emphasis is optional. It is still rendered and audited + # when present, but an editable deck is not required to spotlight + # every narration chunk merely to pass media/protocol QA. + qa_command.append("--allow-missing-attention") + try: + _run(qa_command) + except subprocess.CalledProcessError as exc: + sys.exit(f"[render_edited_pptx] strict QA failed with exit {exc.returncode}") + + print( + f"[render_edited_pptx] DONE: {final_path} " + f"({protocol['slide_count']} slides, {protocol['effect_count']} effects)" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ResearchStudio-Reel/skills/paper2video/scripts/render_video.py b/ResearchStudio-Reel/skills/paper2video/scripts/render_video.py index c38b5be..24ca0c6 100755 --- a/ResearchStudio-Reel/skills/paper2video/scripts/render_video.py +++ b/ResearchStudio-Reel/skills/paper2video/scripts/render_video.py @@ -10,22 +10,22 @@ --out : MP4 destination path Steps: - 1. Prefer ppt-master's final SVG frames (svg_final/*.svg) → PNG/slide - via a browser renderer; fall back to PPTX → PDF → PNG only when SVG - frames are unavailable or explicitly disabled. + 1. Use the current PPTX for editable animation manifests. Otherwise, + prefer ppt-master's final SVG frames and fall back to PPTX/PDF. 2. Pair each slide PNG with its matching MP3 (by script order) 3. Probe each MP3's duration with ffprobe 4. Build a per-slide concat segment, optionally pad trailing silence 5. Concat into a single H.264 / AAC MP4 with ffmpeg's concat demuxer 6. Verify the output plays and report duration -Why prefer svg_final over PPTX → LibreOffice → PDF: +Why the non-editable authoring route prefers svg_final over PPTX/PDF: ppt-master authors and previews slides as SVG before exporting the PPTX. LibreOffice can reflow text and vector geometry differently from PowerPoint/Keynote, producing video frames that no longer match the deck the user inspected. The final SVGs are the same 16:9 visual source used before - PPTX export, including expanded icon paths, so they are the safest source - for the video raster frames. The PPTX remains a required deliverable. + PPTX export. In the editable route, this preference is intentionally + reversed: the current PPTX supplies all static and animated pixels so user + changes cannot be hidden by an older SVG export. ffmpeg fallback: If system ffmpeg/ffprobe aren't on PATH, we fall back to imageio_ffmpeg's @@ -52,6 +52,12 @@ from datetime import datetime, timezone from pathlib import Path +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +from editable_pptx import ProtocolError, file_sha256, write_reveal_variant + RESOLUTIONS = { "720p": (1280, 720), "1080p": (1920, 1080), @@ -60,15 +66,43 @@ } DURATION_REPORT_SCHEMA_VERSION = "paper2video_duration_report.v1" +ANIMATION_RENDER_REPORT_SCHEMA_VERSION = "paper2video_animation_render.v1" +SUPPORTED_ANIMATION_NAMES = frozenset( + { + "Appear", + "Fade In", + "Dissolve In", + "Fly In", + "Wipe In", + "Zoom In", + "Circle In", + "Diamond In", + } +) HIGHLIGHT_BORDER_ALPHA = 0.68 HIGHLIGHT_BOX_EXPAND_MULTIPLIER = 1.0 SPOTLIGHT_DIM_COLOR = "0x000000" SPOTLIGHT_BORDER_ALPHA = 0.34 -SPOTLIGHT_MAX_ALPHA = 0.24 -SPOTLIGHT_FEATHER_RATIO = 0.052 -SPOTLIGHT_MIN_FEATHER_PX = 56 -SPOTLIGHT_FEATHER_THICKNESS_MULTIPLIER = 8 +SPOTLIGHT_MAX_ALPHA = float(os.environ.get("VIDEO_SPOTLIGHT_DIM", "0.24")) +SPOTLIGHT_FEATHER_RATIO = float(os.environ.get("VIDEO_SPOTLIGHT_FEATHER_RATIO", "0.052")) +SPOTLIGHT_MIN_FEATHER_PX = int(os.environ.get("VIDEO_SPOTLIGHT_FEATHER_PX", "56")) +SPOTLIGHT_FEATHER_THICKNESS_MULTIPLIER = int(os.environ.get("VIDEO_SPOTLIGHT_FEATHER_THICK_MULT", "8")) SPOTLIGHT_INNER_PAD_MULTIPLIER = 1.0 +# Ink-tighten: shrink a highlight box to the actually-painted (ink) pixels inside +# it before building the spotlight mask. The upstream cue box is the shape's +# DECLARED geometry (PPTX off/ext) or a semantic estimate, so a loose text box +# spotlights a lot of empty leading/padding. Default on; needs Pillow+numpy and +# degrades to the declared box when they are absent or confidence is low. +INK_TIGHTEN = os.environ.get("VIDEO_SPOTLIGHT_INK_TIGHTEN", "1").strip().lower() not in ("0", "off", "false", "no") +INK_TIGHTEN_PAD_FRAC = float(os.environ.get("VIDEO_SPOTLIGHT_INK_PAD", "0.012")) +# Card/panel awareness: a highlight box whose border ring is mostly ONE fill +# colour (>= CARD_UNIFORM of the ring hugs its median) that differs from the +# SLIDE background by more than CARD_DELTA (summed RGB) sits on a FILLED +# card/panel — its fill (including an accent bar and padding) is part of the +# visual unit, so ink-tighten keeps the whole box instead of hugging the inner +# glyphs (which would dim the card and leave a "hole"/short frame). +INK_TIGHTEN_CARD_DELTA = float(os.environ.get("VIDEO_SPOTLIGHT_CARD_DELTA", "24")) +INK_TIGHTEN_CARD_UNIFORM = float(os.environ.get("VIDEO_SPOTLIGHT_CARD_UNIFORM", "0.6")) CURSOR_MOVE_SECONDS = 0.55 CURSOR_POINTER_FILL = "0x1E293B" CURSOR_POINTER_BORDER = "0xF8FAFC" @@ -247,7 +281,9 @@ def _resolve_svg_asset_href(raw: str, *, svg_path: Path, project_path: Path) -> return raw -def _inline_svg_html(svg_path: Path, project_path: Path) -> str: +def _inline_svg_html( + svg_path: Path, project_path: Path, *, transparent: bool = False, +) -> str: text = svg_path.read_text(encoding="utf-8") text = re.sub(r"^\s*<\?xml[^>]*>\s*", "", text) @@ -258,11 +294,12 @@ def replace_href(match: re.Match[str]) -> str: text = re.sub(r"((?:xlink:)?href)=(['\"])([^'\"]+)\2", replace_href, text) base_uri = svg_path.parent.resolve().as_uri() + "/" + page_background = "transparent" if transparent else "white" return ( "" f"" "" f"{text}" @@ -411,6 +448,479 @@ class VisualCue: border: int = 5 size: int | None = None style: str = "spotlight_laser" + # When True the box is already the intended LOGICAL unit (a grouped + # multi-line label or a filled card) and must be used verbatim — skip + # ink-tighten, which would hug the inner glyphs and leave a fill-only + # card's background padding dimmed (the "hole in the card" defect). + no_ink_tighten: bool = False + + +@dataclass +class AnimationEffect: + order: int + locator: str + name: str + start: float + duration: float + timing_source: str + shape_id: str | None = None + + +@dataclass +class AnimationSlide: + index: int + slide_id: str + effects: list[AnimationEffect] + + +@dataclass +class AnimationLayer: + effect: AnimationEffect + path: Path + x: int + y: int + width: int + height: int + + +def animation_manifest_metadata(path: Path | None) -> dict[str, str]: + if path is None: + return {"source_kind": "svg"} + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + sys.exit(f"[render_video] invalid animation manifest {path}: {exc}") + source_kind = str(payload.get("source_kind") or "svg") + if source_kind not in {"svg", "pptx"}: + sys.exit( + f"[render_video] animation manifest has unsupported source_kind {source_kind!r}" + ) + return { + "source_kind": source_kind, + "source_sha256": str(payload.get("source_sha256") or ""), + "source_pptx": str(payload.get("source_pptx") or ""), + } + + +@dataclass +class AnimationSlideAssets: + index: int + base_frame: Path + layers: list[AnimationLayer] + + +def load_animation_manifest( + path: Path | None, + pairs: list[SlidePair], + *, + pad_tail: float, +) -> dict[int, AnimationSlide]: + if path is None: + return {} + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + sys.exit(f"[render_video] invalid animation manifest {path}: {exc}") + if payload.get("schema_version") != "paper2video_animation_manifest.v1": + sys.exit(f"[render_video] unsupported animation manifest schema in {path}") + source_kind = str(payload.get("source_kind") or "svg") + if source_kind not in {"svg", "pptx"}: + sys.exit( + f"[render_video] animation manifest has unsupported source_kind {source_kind!r}" + ) + slides = payload.get("slides") or [] + if len(slides) != len(pairs): + sys.exit( + f"[render_video] animation manifest has {len(slides)} slides, " + f"expected {len(pairs)}" + ) + result: dict[int, AnimationSlide] = {} + total_effects = 0 + for pair, raw_slide in zip(pairs, slides): + index = int(raw_slide.get("index") or 0) + if index != pair.index: + sys.exit( + f"[render_video] animation slide index {index} != expected {pair.index}" + ) + slide_id = str(raw_slide.get("id") or "").strip() + if slide_id != pair.audio.stem: + sys.exit( + f"[render_video] animation slide {index} id {slide_id!r} does not " + f"match audio/script id {pair.audio.stem!r}" + ) + effects: list[AnimationEffect] = [] + for raw in raw_slide.get("effects") or []: + start = float(raw.get("start") or 0.0) + duration = float(raw.get("duration") or 0.0) + locator = str(raw.get("locator") or "").strip() + name = str(raw.get("name") or "").strip() + timing_source = str(raw.get("timing_source") or "") + if not locator or not name: + sys.exit(f"[render_video] slide {index} has an animation without locator/name") + shape_id = str(raw.get("shape_id") or "").strip() or None + if source_kind == "pptx" and shape_id is None: + sys.exit( + f"[render_video] slide {index} PPTX animation {locator!r} " + "is missing shape_id" + ) + if name not in SUPPORTED_ANIMATION_NAMES: + sys.exit( + f"[render_video] slide {index} animation {locator!r} has unsupported " + f"effect name {name!r}" + ) + if start < 0 or duration <= 0 or start + duration > pair.duration + pad_tail + 0.01: + sys.exit( + f"[render_video] slide {index} animation {locator!r} has invalid timing " + f"start={start}, duration={duration}, segment={pair.duration + pad_tail:.3f}" + ) + if timing_source not in {"edge_word_alignment", "animation_pane"}: + sys.exit( + f"[render_video] slide {index} animation {locator!r} has " + f"unsupported timing source {timing_source!r}" + ) + order = int(raw.get("order") or len(effects) + 1) + if order != len(effects) + 1: + sys.exit( + f"[render_video] slide {index} animation order {order} is not " + f"the expected contiguous order {len(effects) + 1}" + ) + effects.append( + AnimationEffect( + order=order, + locator=locator, + name=name, + start=start, + duration=duration, + timing_source=timing_source, + shape_id=shape_id, + ) + ) + result[index] = AnimationSlide( + index=index, + slide_id=slide_id, + effects=effects, + ) + total_effects += len(effects) + declared = int(payload.get("effect_count") or 0) + if declared != total_effects: + sys.exit( + f"[render_video] animation manifest effect_count {declared} != {total_effects}" + ) + return result + + +def render_svg_animation_assets( + svgs: list[Path], + animation_map: dict[int, AnimationSlide], + out_dir: Path, + *, + project_path: Path, + width: int, + height: int, + browser_executable: str | None = None, +) -> dict[int, AnimationSlideAssets]: + """Rasterize one static base plus transparent SVG-group layers per slide.""" + if not animation_map: + return {} + try: + from PIL import Image # type: ignore + from playwright.sync_api import sync_playwright # type: ignore + except Exception: + sys.exit( + "[render_video] animated SVG rendering requires Pillow and Playwright." + ) + if len(svgs) != len(animation_map): + sys.exit( + f"[render_video] animation SVG count {len(svgs)} != manifest slide count " + f"{len(animation_map)}" + ) + if out_dir.exists(): + shutil.rmtree(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + html_dir = out_dir / "html" + html_dir.mkdir(parents=True, exist_ok=True) + browser_path = browser_executable or find_chrome() + launch_kwargs: dict[str, object] = {"headless": True} + if browser_path: + launch_kwargs["executable_path"] = browser_path + assets: dict[int, AnimationSlideAssets] = {} + try: + with sync_playwright() as playwright: + browser = playwright.chromium.launch(**launch_kwargs) + page = browser.new_page( + viewport={"width": width, "height": height}, device_scale_factor=1, + ) + for index, svg_path in enumerate(svgs, start=1): + slide = animation_map[index] + slide_dir = out_dir / f"slide-{index:02d}" + slide_dir.mkdir(parents=True, exist_ok=True) + html_path = html_dir / f"slide-{index:02d}.html" + html_path.write_text( + _inline_svg_html(svg_path, project_path, transparent=True), + encoding="utf-8", + ) + uri = html_path.resolve().as_uri() + locators = [effect.locator for effect in slide.effects] + page.goto(uri, wait_until="networkidle", timeout=60000) + missing = page.evaluate( + """ids => ids.filter(id => !Array.from( + document.querySelector('svg').children + ).some(el => el.id === id))""", + locators, + ) + if missing: + sys.exit( + f"[render_video] slide {index} animation SVG locators missing: {missing}" + ) + page.evaluate( + """ids => { + const root = document.querySelector('svg'); + for (const id of ids) { + const el = Array.from(root.children).find(node => node.id === id); + if (el) el.style.display = 'none'; + } + }""", + locators, + ) + base_path = slide_dir / "base.png" + page.screenshot( + path=str(base_path), full_page=False, omit_background=True, + ) + + layer_cache: dict[str, tuple[Path, int, int, int, int]] = {} + layers: list[AnimationLayer] = [] + for effect in slide.effects: + if effect.locator not in layer_cache: + page.goto(uri, wait_until="networkidle", timeout=60000) + page.evaluate( + """target => { + const root = document.querySelector('svg'); + for (const el of Array.from(root.children)) { + const tag = el.tagName.toLowerCase(); + if (tag !== 'defs' && el.id !== target) { + el.style.display = 'none'; + } + } + }""", + effect.locator, + ) + full_path = slide_dir / f"layer-{len(layer_cache) + 1:02d}-full.png" + page.screenshot( + path=str(full_path), full_page=False, omit_background=True, + ) + with Image.open(full_path) as source: + image = source.convert("RGBA") + bbox = image.getchannel("A").getbbox() + if bbox is None: + sys.exit( + f"[render_video] slide {index} animation layer " + f"{effect.locator!r} rendered empty" + ) + x0, y0, x1, y1 = bbox + cropped = image.crop(bbox) + layer_path = slide_dir / f"layer-{len(layer_cache) + 1:02d}.png" + cropped.save(layer_path) + full_path.unlink(missing_ok=True) + layer_cache[effect.locator] = ( + layer_path, x0, y0, x1 - x0, y1 - y0, + ) + layer_path, x, y, layer_w, layer_h = layer_cache[effect.locator] + layers.append( + AnimationLayer( + effect=effect, + path=layer_path, + x=x, + y=y, + width=layer_w, + height=layer_h, + ) + ) + assets[index] = AnimationSlideAssets( + index=index, base_frame=base_path, layers=layers, + ) + browser.close() + except SystemExit: + raise + except Exception as exc: + sys.exit(f"[render_video] animated SVG layer render failed: {exc}") + return assets + + +def _pdf_to_scaled_pngs( + pdf_path: Path, + out_dir: Path, + *, + width: int, + height: int, + pdftoppm: str, +) -> list[Path]: + out_dir.mkdir(parents=True, exist_ok=True) + prefix = out_dir / "slide" + cmd = [ + pdftoppm, + "-png", + "-r", + "144", + "-scale-to-x", + str(width), + "-scale-to-y", + str(height), + str(pdf_path), + str(prefix), + ] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=600) + if proc.returncode != 0: + sys.exit(f"[render_video] pdftoppm animation-state render failed:\n{proc.stderr}") + frames = sorted(out_dir.glob("slide-*.png"), key=natural_key) + if not frames: + sys.exit(f"[render_video] no animation-state frames produced under {out_dir}") + return frames + + +def _pptx_diff_layer( + before_path: Path, + after_path: Path, + output_path: Path, +) -> tuple[int, int, int, int]: + """Create a transparent layer from two deterministic PPTX reveal states.""" + try: + import numpy as np # type: ignore + from PIL import Image, ImageFilter # type: ignore + except Exception: + sys.exit("[render_video] editable PPTX animation rendering requires Pillow and numpy") + with Image.open(before_path) as before_source, Image.open(after_path) as after_source: + before = before_source.convert("RGB") + after = after_source.convert("RGB") + if before.size != after.size: + sys.exit( + "[render_video] editable PPTX reveal states have mismatched dimensions " + f"{before.size} != {after.size}" + ) + before_array = np.asarray(before, dtype=np.int16) + after_array = np.asarray(after, dtype=np.int16) + delta = np.abs(after_array - before_array).max(axis=2).astype(np.float32) + visible = delta >= 2.0 + ys, xs = np.nonzero(visible) + if len(xs) == 0 or len(ys) == 0: + sys.exit( + "[render_video] editable PPTX animation target produced no visible pixel change; " + "check that the shape is visible and has a real entrance effect" + ) + pad = 2 + x0 = max(0, int(xs.min()) - pad) + y0 = max(0, int(ys.min()) - pad) + x1 = min(after.width, int(xs.max()) + pad + 1) + y1 = min(after.height, int(ys.max()) + pad + 1) + alpha = np.clip((delta - 1.0) * 18.0, 0.0, 255.0).astype(np.uint8) + alpha[~visible] = 0 + alpha_image = Image.fromarray(alpha, mode="L").filter(ImageFilter.GaussianBlur(0.45)) + cropped = after.crop((x0, y0, x1, y1)).convert("RGBA") + cropped.putalpha(alpha_image.crop((x0, y0, x1, y1))) + output_path.parent.mkdir(parents=True, exist_ok=True) + cropped.save(output_path) + return x0, y0, x1 - x0, y1 - y0 + + +def render_pptx_animation_assets( + pptx_path: Path, + animation_map: dict[int, AnimationSlide], + out_dir: Path, + *, + width: int, + height: int, + libreoffice: str, + pdftoppm: str, +) -> dict[int, AnimationSlideAssets]: + """Render Author Notes animations from the current editable PPTX pixels. + + The function creates cumulative reveal variants. State 0 removes all + protocol targets; state N keeps the first N targets on every slide. A + layer is the pixel delta between adjacent states, so PowerPoint text, + color, geometry, image, add, delete, and style edits all flow into video. + """ + if not animation_map: + return {} + slide_shape_ids: list[list[str]] = [] + max_effects = 0 + for index in range(1, len(animation_map) + 1): + slide = animation_map[index] + shape_ids = [str(effect.shape_id or "") for effect in slide.effects] + if any(not shape_id for shape_id in shape_ids): + sys.exit(f"[render_video] slide {index} editable PPTX effect is missing shape_id") + if len(set(shape_ids)) != len(shape_ids): + sys.exit( + f"[render_video] slide {index} has multiple entrance effects for one shape; " + "the editable renderer currently requires one entrance per target" + ) + slide_shape_ids.append(shape_ids) + max_effects = max(max_effects, len(shape_ids)) + + if out_dir.exists(): + shutil.rmtree(out_dir) + variants_dir = out_dir / "variants" + states_dir = out_dir / "states" + variants_dir.mkdir(parents=True, exist_ok=True) + states_dir.mkdir(parents=True, exist_ok=True) + + state_frames: dict[int, list[Path]] = {} + for reveal_count in range(max_effects + 1): + variant = variants_dir / f"state-{reveal_count:02d}.pptx" + try: + write_reveal_variant( + pptx_path, + slide_shape_ids, + reveal_count, + variant, + ) + except ProtocolError as exc: + sys.exit(f"[render_video] could not build editable PPTX reveal state: {exc}") + state_root = states_dir / f"state-{reveal_count:02d}" + pdf = pptx_to_pdf(variant, state_root / "pdf", libreoffice) + frames = _pdf_to_scaled_pngs( + pdf, + state_root / "frames", + width=width, + height=height, + pdftoppm=pdftoppm, + ) + if len(frames) != len(animation_map): + sys.exit( + f"[render_video] reveal state {reveal_count} rendered {len(frames)} slides, " + f"expected {len(animation_map)}" + ) + state_frames[reveal_count] = frames + + assets: dict[int, AnimationSlideAssets] = {} + for index in range(1, len(animation_map) + 1): + slide = animation_map[index] + slide_dir = out_dir / f"slide-{index:02d}" + slide_dir.mkdir(parents=True, exist_ok=True) + base_path = slide_dir / "base.png" + shutil.copy2(state_frames[0][index - 1], base_path) + layers: list[AnimationLayer] = [] + for effect_index, effect in enumerate(slide.effects, start=1): + layer_path = slide_dir / f"layer-{effect_index:02d}.png" + x, y, layer_width, layer_height = _pptx_diff_layer( + state_frames[effect_index - 1][index - 1], + state_frames[effect_index][index - 1], + layer_path, + ) + layers.append( + AnimationLayer( + effect=effect, + path=layer_path, + x=x, + y=y, + width=layer_width, + height=layer_height, + ) + ) + assets[index] = AnimationSlideAssets( + index=index, + base_frame=base_path, + layers=layers, + ) + return assets def _load_script_order(script_json: Path) -> list[str]: @@ -731,7 +1241,8 @@ def load_visual_cues( sys.exit(f"[render_video] highlight cue on slide {pair.index} needs either point or box") style = str(raw.get("style") or highlight_style).strip() cue = VisualCue(cue_type=cue_type, start=start, end=end, box=box, point=point, - color=color, opacity=opacity, border=border, size=size, style=style) + color=color, opacity=opacity, border=border, size=size, style=style, + no_ink_tighten=bool(raw.get("no_ink_tighten", False))) else: point_vals = _as_float_list(raw.get("point"), length=2, field="point") point = (_clamp01(point_vals[0]), _clamp01(point_vals[1])) @@ -1199,6 +1710,83 @@ def _smoothstep(value: float) -> float: return value * value * (3.0 - 2.0 * value) +def _ink_tighten_box(frame_path, box, *, width: int, height: int, + pad_frac: float = INK_TIGHTEN_PAD_FRAC): + """Shrink a normalized (x, y, w, h) highlight box to the painted-pixel (ink) + bounds of the content inside it, measured from the rendered slide frame. + + Samples the pixels inside the box, reads the local background from the box's + border ring, and tightens to the non-background (inked) content, so a loose + text box spotlights the glyphs rather than the leading/padding. Composes with + grouping: hand it a UNIONED multi-line box and it hugs all the lines. It is + CARD-AWARE — a box sitting on a filled card/panel (ring bg differs from the + slide bg) is kept verbatim so the card reads as one lit unit (no "hole"), + while transparent text is hugged to its glyphs. Best-effort: needs + Pillow+numpy; returns the ORIGINAL box on any error, low ink confidence, a + filled card, or when it would not meaningfully shrink (already tight).""" + try: + import numpy as np + from PIL import Image + except Exception: + return box + try: + x, y, w, h = box + img = Image.open(frame_path).convert("RGB") + iw, ih = img.size + # replicate the ffmpeg scale(decrease)+pad(black) that letterboxes the + # slide onto the video frame, so the normalized box maps to real pixels. + s = min(width / iw, height / ih) + nw, nh = max(1, round(iw * s)), max(1, round(ih * s)) + canvas = Image.new("RGB", (width, height), (0, 0, 0)) + canvas.paste(img.resize((nw, nh), Image.LANCZOS), ((width - nw) // 2, (height - nh) // 2)) + px0 = max(0, min(width - 1, int(round(x * width)))) + py0 = max(0, min(height - 1, int(round(y * height)))) + pw = max(2, min(width - px0, int(round(w * width)))) + ph = max(2, min(height - py0, int(round(h * height)))) + crop = np.asarray(canvas.crop((px0, py0, px0 + pw, py0 + ph)), dtype=np.int16) + ring = np.concatenate([ + crop[:2].reshape(-1, 3), crop[-2:].reshape(-1, 3), + crop[:, :2].reshape(-1, 3), crop[:, -2:].reshape(-1, 3)]) + bg = np.median(ring, axis=0) + # Card/panel guard: keep the whole box when it sits on a FILLED card. + # page_bg = the dominant colour over a coarse grid of the SOURCE slide + # (robust to a corner accent bar, unlike sampling the four corners). The + # box is a filled panel when most of its border ring hugs one fill colour + # (close_frac, which survives a thin accent bar on one edge) AND that fill + # differs from the page bg. Then keep the box verbatim (its fill + accent + # + padding is one lit unit); otherwise fall through and hug the glyphs. + src = np.asarray(img, dtype=np.int16) + gy = max(1, ih // 24) + gx = max(1, iw // 24) + grid = ((src[::gy, ::gx].reshape(-1, 3)) // 12) * 12 + uv, uc = np.unique(grid, axis=0, return_counts=True) + page_bg = uv[uc.argmax()].astype(np.int16) + close_frac = float((np.abs(ring - bg).sum(axis=1) < 30).mean()) + if close_frac > INK_TIGHTEN_CARD_UNIFORM and float(np.abs(bg - page_bg).sum()) > INK_TIGHTEN_CARD_DELTA: + return box + mask = np.abs(crop - bg).sum(axis=2) > 40 + if int(mask.sum()) < 0.002 * mask.size: # too little ink -> not confident + return box + # Tight bbox of all inked pixels: trims the box's outer whitespace (leading, + # internal padding, top/middle anchor) down to the visible glyphs. This hugs + # whatever ink is in the box, so the box must already be the intended LOGICAL + # unit. Grouping fragments that belong together (e.g. a title split across two + # overlapping boxes) is the caller's / upstream's job — paper2video's + # generate_visual_cues unions same-group text fragments into the cue box; + # ink-tighten does not decide WHAT to highlight, only HOW tightly. + ys, xs = np.where(mask) + ix0, iy0, ix1, iy1 = int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1 + pad = int(round(pad_frac * min(width, height))) + ix0 = max(0, ix0 - pad); iy0 = max(0, iy0 - pad) + ix1 = min(pw, ix1 + pad); iy1 = min(ph, iy1 + pad) + if (ix1 - ix0) * (iy1 - iy0) > 0.985 * pw * ph: # already tight -> keep declared box + return box + return ((px0 + ix0) / width, (py0 + iy0) / height, + (ix1 - ix0) / width, (iy1 - iy0) / height) + except Exception: + return box + + def _write_spotlight_mask_png( path: Path, *, @@ -1519,9 +2107,207 @@ def _attention_filters( # Stage C — encode each slide as an MP4 segment, then concat # --------------------------------------------------------------------------- +def _animation_strategy(name: str) -> str: + if name == "Appear": + return "appear" + if name in {"Fade In", "Dissolve In"}: + return "alpha_fade" + if name == "Fly In": + return "fly_from_left" + if name == "Wipe In": + return "wipe_from_left" + if name == "Zoom In": + return "zoom_in" + if name == "Circle In": + return "circle_reveal" + if name == "Diamond In": + return "diamond_reveal" + raise ValueError(f"unsupported animation effect: {name}") + + +def _animation_sample_times( + strategy: str, + *, + global_start: float, + duration: float, + segment_start: float, + fps: int, +) -> tuple[float, float]: + """Choose pixel-QA samples that straddle an instant Appear transition.""" + if strategy == "appear": + frame_step = 1.25 / max(1, fps) + return ( + max(segment_start, global_start - frame_step), + global_start + frame_step, + ) + return ( + global_start + duration * 0.20, + global_start + duration * 0.85, + ) + + +def _animation_overlay_filters( + layer: AnimationLayer, + *, + input_index: int, + current_label: str, + sequence: int, + fps: int, +) -> tuple[list[str], str]: + effect = layer.effect + start = effect.start + duration = max(0.05, effect.duration) + strategy = _animation_strategy(effect.name) + source_label = f"animsrc{sequence}" + output_label = f"animbase{sequence}" + progress = f"min(max((t-{start:.3f})/{duration:.3f},0),1)" + alpha_progress = f"min(max((T-{start:.3f})/{duration:.3f},0),1)" + x_expr = f"{layer.x}" + y_expr = f"{layer.y}" + # Layer inputs are static PNGs read at 1 fps to avoid image-demux queues + # growing by tens of gigabytes in a long multi-input filter graph. Restore + # the delivery frame rate lazily inside the filter graph before animation. + source_filter = f"[{input_index}:v]fps={fps},format=rgba" + if strategy == "alpha_fade": + source_filter += f",fade=t=in:st={start:.3f}:d={duration:.3f}:alpha=1" + if strategy == "fly_from_left": + source_filter += f",fade=t=in:st={start:.3f}:d={min(0.18, duration):.3f}:alpha=1" + travel = max(96, min(420, int(round(layer.width * 0.55)))) + x_expr = f"{layer.x}-{travel}*(1-{progress})" + elif strategy == "wipe_from_left": + # `crop` dimensions are configured once, so use a per-frame alpha mask. + source_filter += ( + ",geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':" + f"a='alpha(X,Y)*lte(X,W*{alpha_progress})'" + ) + elif strategy == "zoom_in": + # Scale into a fixed transparent canvas so overlay geometry never jumps. + scale = f"0.72+0.28*{progress}" + pad_margin = 2 + source_filter += ( + f",scale=w='min({layer.width},max(2,floor({layer.width}*({scale}))))':" + f"h='min({layer.height},max(2,floor({layer.height}*({scale}))))':eval=frame," + f"pad=w={layer.width + pad_margin * 2}:h={layer.height + pad_margin * 2}:" + "x='(ow-iw)/2':y='(oh-ih)/2':" + "color=0x00000000:eval=frame," + f"fade=t=in:st={start:.3f}:d={min(0.20, duration):.3f}:alpha=1" + ) + x_expr = f"{layer.x - pad_margin}" + y_expr = f"{layer.y - pad_margin}" + elif strategy == "circle_reveal": + radius = math.hypot(layer.width / 2.0, layer.height / 2.0) + source_filter += ( + ",geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':" + f"a='alpha(X,Y)*lte(hypot(X-W/2,Y-H/2),{radius:.3f}*{alpha_progress})'" + ) + elif strategy == "diamond_reveal": + source_filter += ( + ",geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':" + f"a='alpha(X,Y)*lte(abs(X-W/2)/(W/2)+abs(Y-H/2)/(H/2),{alpha_progress})'" + ) + source_filter += f"[{source_label}]" + filters = [ + source_filter, + f"[{current_label}][{source_label}]overlay=x='{x_expr}':y='{y_expr}':" + f"enable='gte(t,{start:.3f})'" + f"[{output_label}]", + ] + return filters, output_label + + +def write_animation_render_report( + path: Path, + *, + manifest_path: Path, + out_path: Path, + pairs: list[SlidePair], + animation_map: dict[int, AnimationSlide], + animation_assets: dict[int, AnimationSlideAssets], + source_kind: str, + source_path: Path, + source_sha256: str, + start_pad: float, + pad_tail: float, + fps: int, + width: int, + height: int, +) -> dict[str, object]: + """Persist the exact Author Notes animation mapping rendered into MP4 pixels.""" + segment_start = start_pad + slides: list[dict[str, object]] = [] + effect_count = 0 + for pair in pairs: + slide = animation_map[pair.index] + assets = animation_assets[pair.index] + effects: list[dict[str, object]] = [] + for layer in assets.layers: + effect = layer.effect + global_start = segment_start + effect.start + global_end = global_start + effect.duration + strategy = _animation_strategy(effect.name) + sample_early, sample_late = _animation_sample_times( + strategy, + global_start=global_start, + duration=effect.duration, + segment_start=segment_start, + fps=fps, + ) + effects.append( + { + "order": effect.order, + "shape_id": effect.shape_id, + "locator": effect.locator, + "name": effect.name, + "strategy": strategy, + "start": round(effect.start, 3), + "duration": round(effect.duration, 3), + "global_start": round(global_start, 3), + "global_end": round(global_end, 3), + "timing_source": effect.timing_source, + "bbox": [layer.x, layer.y, layer.width, layer.height], + "sample_early": round(sample_early, 3), + "sample_late": round(sample_late, 3), + "rendered": True, + } + ) + effect_count += 1 + segment_end = segment_start + pair.duration + pad_tail + slides.append( + { + "index": pair.index, + "id": slide.slide_id, + "segment_start": round(segment_start, 3), + "segment_end": round(segment_end, 3), + "effect_count": len(effects), + "effects": effects, + } + ) + segment_start = segment_end + report: dict[str, object] = { + "schema_version": ANIMATION_RENDER_REPORT_SCHEMA_VERSION, + "created_at": _utc_now(), + "manifest": str(manifest_path), + "output": str(out_path), + "source_kind": source_kind, + "source_path": str(source_path), + "source_sha256": source_sha256, + "timing_source": "author_notes_or_animation_pane", + "slide_count": len(slides), + "effect_count": effect_count, + "start_pad": start_pad, + "pad_tail": pad_tail, + "fps": fps, + "resolution": {"width": width, "height": height}, + "slides": slides, + } + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return report + def encode_segment(pair: SlidePair, out_seg: Path, *, width: int, height: int, fps: int, pad_tail: float, - ffmpeg: str, visual_cues: list[VisualCue] | None = None) -> None: + ffmpeg: str, visual_cues: list[VisualCue] | None = None, + animation_assets: AnimationSlideAssets | None = None) -> None: """Render one PNG + one MP3 → an MP4 segment of length audio + pad_tail. Image is scaled to fit `width`x`height` while preserving aspect ratio, @@ -1571,27 +2357,38 @@ def encode_segment(pair: SlidePair, out_seg: Path, *, f"apad" ) - if spotlight_cues or cursor_cues or laser_cues: + if animation_assets or spotlight_cues or cursor_cues or laser_cues: with tempfile.TemporaryDirectory(prefix="paper2video_attention_") as td: + base_frame = animation_assets.base_frame if animation_assets else pair.frame input_args = [ - "-loop", "1", "-framerate", str(fps), "-i", str(pair.frame), + "-loop", "1", "-framerate", str(fps), "-i", str(base_frame), "-i", str(pair.audio), ] next_input = 2 + animation_inputs: list[tuple[int, AnimationLayer]] = [] + if animation_assets: + for layer in animation_assets.layers: + input_args.extend( + ["-loop", "1", "-framerate", "1", "-i", str(layer.path)] + ) + animation_inputs.append((next_input, layer)) + next_input += 1 spotlight_inputs: list[tuple[int, VisualCue]] = [] for index, cue in enumerate(spotlight_cues): if cue.box is None: continue thickness = max(3, cue.border or min(width, height) // 180) + spot_box = (_ink_tighten_box(pair.frame, cue.box, width=width, height=height) + if (INK_TIGHTEN and not cue.no_ink_tighten) else cue.box) mask_path = Path(td) / f"spotlight_{index:02d}.png" _write_spotlight_mask_png( mask_path, - box=cue.box, + box=spot_box, width=width, height=height, thickness=thickness, ) - input_args.extend(["-loop", "1", "-framerate", str(fps), "-i", str(mask_path)]) + input_args.extend(["-loop", "1", "-framerate", "1", "-i", str(mask_path)]) spotlight_inputs.append((next_input, cue)) next_input += 1 @@ -1616,7 +2413,7 @@ def encode_segment(pair: SlidePair, out_seg: Path, *, tip_y=tip_y, ) if intervals: - input_args.extend(["-loop", "1", "-framerate", str(fps), "-i", str(cursor_path)]) + input_args.extend(["-loop", "1", "-framerate", "1", "-i", str(cursor_path)]) cursor_input = next_input next_input += 1 @@ -1639,16 +2436,25 @@ def encode_segment(pair: SlidePair, out_seg: Path, *, tip_y=laser_tip_y, ) if laser_intervals: - input_args.extend(["-loop", "1", "-framerate", str(fps), "-i", str(laser_path)]) + input_args.extend(["-loop", "1", "-framerate", "1", "-i", str(laser_path)]) laser_input = next_input next_input += 1 filter_parts = [f"[0:v]{','.join(vf_filters)}[base0]"] current_label = "base0" + for index, (input_index, layer) in enumerate(animation_inputs): + layer_filters, current_label = _animation_overlay_filters( + layer, + input_index=input_index, + current_label=current_label, + sequence=index, + fps=fps, + ) + filter_parts.extend(layer_filters) for index, (input_index, cue) in enumerate(spotlight_inputs): mask_label = f"spotmask{index}" next_label = f"spotbase{index}" - filter_parts.append(f"[{input_index}:v]format=rgba[{mask_label}]") + filter_parts.append(f"[{input_index}:v]fps={fps},format=rgba[{mask_label}]") filter_parts.append( f"[{current_label}][{mask_label}]overlay=x=0:y=0:" f"enable='between(t,{cue.start:.3f},{cue.end:.3f})'[{next_label}]" @@ -1658,7 +2464,7 @@ def encode_segment(pair: SlidePair, out_seg: Path, *, if cursor_input is not None: x_expr = _piecewise_overlay_expr(intervals, axis=0) y_expr = _piecewise_overlay_expr(intervals, axis=1) - filter_parts.append(f"[{cursor_input}:v]format=rgba[cursor]") + filter_parts.append(f"[{cursor_input}:v]fps={fps},format=rgba[cursor]") filter_parts.append( f"[{current_label}][cursor]overlay=x='{x_expr}':y='{y_expr}':" f"enable='between(t,{first_start:.3f},{last_end:.3f})'[withcursor]" @@ -1668,7 +2474,7 @@ def encode_segment(pair: SlidePair, out_seg: Path, *, if laser_input is not None: x_expr = _piecewise_overlay_expr(laser_intervals, axis=0) y_expr = _piecewise_overlay_expr(laser_intervals, axis=1) - filter_parts.append(f"[{laser_input}:v]format=rgba[laser]") + filter_parts.append(f"[{laser_input}:v]fps={fps},format=rgba[laser]") filter_parts.append( f"[{current_label}][laser]overlay=x='{x_expr}':y='{y_expr}':" f"enable='between(t,{laser_first_start:.3f},{laser_last_end:.3f})'[withlaser]" @@ -1908,6 +2714,17 @@ def main() -> int: help="JSON file describing per-slide highlight/cursor cues in normalized coordinates.") ap.add_argument("--allow-missing-visual-cues", action="store_true", help="Degraded/debug only: allow highlight/cursor/both without --visual-cues.") + ap.add_argument("--animation-manifest", default=None, + help="Author Notes animation manifest written by build_animation_manifest.py. " + "Burns the mapped effects into MP4 pixels from SVG or editable PPTX states.") + ap.add_argument("--animation-source", choices=("auto", "svg", "pptx"), default="auto", + help="Animation pixel source. auto follows animation_manifest.source_kind; " + "editable PPTX manifests force the current PPTX to be the frame source.") + ap.add_argument("--animation-report-out", default=None, + help="Persistent animation render report JSON. Defaults to " + "_animation_report.json when animations are rendered.") + ap.add_argument("--require-animations", action="store_true", + help="Fail unless a non-empty, Edge-aligned animation manifest is rendered.") ap.add_argument("--frames-only", action="store_true", help="Stop after PNG export — useful for previewing slide rendering") ap.add_argument("--audio-only-check", action="store_true", @@ -1924,11 +2741,43 @@ def main() -> int: sys.exit("[render_video] --target-minutes must be positive") if args.duration_tolerance_seconds < 0: sys.exit("[render_video] --duration-tolerance-seconds must be non-negative") + if args.require_animations and args.frames_only: + sys.exit("[render_video] --require-animations cannot be combined with --frames-only") audio_dir = Path(args.audio_dir).resolve() if args.audio_dir else project_path / "audio" script_json = Path(args.script_json).resolve() if args.script_json else None out_path = Path(args.out).resolve() if args.out else project_path / "exports" / f"{pptx_path.stem}.mp4" visual_cues_path = Path(args.visual_cues).resolve() if args.visual_cues else None + animation_manifest_path = ( + Path(args.animation_manifest).resolve() if args.animation_manifest else None + ) + animation_metadata = animation_manifest_metadata(animation_manifest_path) + animation_source = ( + animation_metadata["source_kind"] + if args.animation_source == "auto" + else args.animation_source + ) + animation_report_path = ( + Path(args.animation_report_out).resolve() if args.animation_report_out else None + ) + if args.require_animations and animation_manifest_path is None: + sys.exit("[render_video] --require-animations requires --animation-manifest") + if animation_report_path is not None and animation_manifest_path is None: + sys.exit("[render_video] --animation-report-out requires --animation-manifest") + if args.animation_source != "auto" and animation_manifest_path is None: + sys.exit("[render_video] --animation-source requires --animation-manifest") + if animation_manifest_path is not None and args.animation_source != "auto": + declared_source = animation_metadata["source_kind"] + if declared_source != animation_source: + sys.exit( + f"[render_video] --animation-source {animation_source!r} conflicts with " + f"manifest source_kind {declared_source!r}" + ) + if animation_source == "pptx" and args.frame_source == "svg": + sys.exit( + "[render_video] editable PPTX animations cannot use --frame-source svg; " + "the current PPTX must supply both static and animated pixels" + ) explicit_svg_dir = Path(args.svg_dir).resolve() if args.svg_dir else None frames_out = Path(args.frames_out).resolve() if args.frames_out else None browser_executable = Path(args.browser_executable).expanduser() if args.browser_executable else None @@ -1947,10 +2796,14 @@ def main() -> int: work_root.mkdir(parents=True, exist_ok=True) png_dir = work_root / "frames" - svg_dir = discover_svg_dir(project_path, explicit_svg_dir, args.frame_source) - use_svg = args.frame_source in {"auto", "svg"} and svg_dir is not None + effective_frame_source = "pptx" if animation_source == "pptx" else args.frame_source + svg_dir = discover_svg_dir(project_path, explicit_svg_dir, effective_frame_source) + use_svg = effective_frame_source in {"auto", "svg"} and svg_dir is not None frame_source_used = "svg" if use_svg else "pptx" + svgs: list[Path] = [] + libreoffice: str | None = None + pdftoppm: str | None = None if use_svg: svgs = collect_svgs(svg_dir) if svg_dir.name == "svg_output": @@ -1989,6 +2842,28 @@ def main() -> int: pairs = pair_slides(frames, audio_files, ffprobe, ffmpeg) total_audio = sum(p.duration for p in pairs) print(f"[render_video] {len(pairs)} slide(s), audio total {total_audio:.1f}s") + animation_map = load_animation_manifest( + animation_manifest_path, + pairs, + pad_tail=args.pad_tail, + ) + if animation_map and animation_source == "svg" and not use_svg: + sys.exit( + "[render_video] Author Notes animations require SVG frame rendering; " + "pass --frame-source svg and --svg-dir." + ) + if animation_map and animation_source == "pptx": + expected_sha = animation_metadata.get("source_sha256") or "" + actual_sha = file_sha256(pptx_path) + if not expected_sha: + sys.exit( + "[render_video] editable PPTX animation manifest is missing source_sha256" + ) + if expected_sha != actual_sha: + sys.exit( + "[render_video] editable PPTX changed after the animation manifest was built; " + "rerun build_animation_manifest.py --pptx before rendering" + ) if args.audio_only_check: print("[render_video] --audio-only-check passed.") return 0 @@ -2002,16 +2877,54 @@ def main() -> int: allow_missing_visual_cues=args.allow_missing_visual_cues, ) + animation_assets: dict[int, AnimationSlideAssets] = {} + if animation_map: + print( + f"[render_video] Stage B2: rasterize {sum(len(s.effects) for s in animation_map.values())} " + "Author Notes animation layer(s)" + ) + if animation_source == "pptx": + if libreoffice is None: + libreoffice = find_libreoffice() + if pdftoppm is None: + pdftoppm = find_pdftoppm() + animation_assets = render_pptx_animation_assets( + pptx_path, + animation_map, + work_root / "animation_layers", + width=width, + height=height, + libreoffice=libreoffice, + pdftoppm=pdftoppm, + ) + else: + animation_assets = render_svg_animation_assets( + svgs, + animation_map, + work_root / "animation_layers", + project_path=project_path, + width=width, + height=height, + browser_executable=str(browser_executable) if browser_executable else None, + ) + print(f"[render_video] Stage C: encode {len(pairs)} segment(s) and concat") seg_dir = work_root / "segments" seg_dir.mkdir(exist_ok=True) segments: list[Path] = [] for pair in pairs: seg_path = seg_dir / f"seg_{pair.index:04d}.mp4" + animation_count = len(animation_assets.get(pair.index).layers) if pair.index in animation_assets else 0 + print( + f"[render_video] segment {pair.index}/{len(pairs)}: {pair.audio.stem} " + f"({animation_count} animation effect(s))", + flush=True, + ) encode_segment(pair, seg_path, width=width, height=height, fps=args.fps, pad_tail=args.pad_tail, ffmpeg=ffmpeg, - visual_cues=visual_cue_map.get(pair.index)) + visual_cues=visual_cue_map.get(pair.index), + animation_assets=animation_assets.get(pair.index)) segments.append(seg_path) concat_segments(segments, out_path, ffmpeg, @@ -2028,6 +2941,36 @@ def main() -> int: print(f" slides: {len(pairs)} resolution: {width}x{height}@{args.fps}fps") print(f" frames: {frame_source_used} → {png_dir}") + if animation_map: + if animation_report_path is None: + animation_report_path = out_path.with_name( + f"{out_path.stem}_animation_report.json" + ) + animation_report = write_animation_render_report( + animation_report_path, + manifest_path=animation_manifest_path, + out_path=out_path, + pairs=pairs, + animation_map=animation_map, + animation_assets=animation_assets, + source_kind=animation_source, + source_path=pptx_path if animation_source == "pptx" else svg_dir, + source_sha256=( + file_sha256(pptx_path) + if animation_source == "pptx" + else animation_metadata.get("source_sha256", "") + ), + start_pad=args.start_pad, + pad_tail=args.pad_tail, + fps=args.fps, + width=width, + height=height, + ) + print( + f" animations: {animation_report['effect_count']} effect(s) → " + f"{animation_report_path}" + ) + report_path: Path | None = None if args.duration_report_out: report_path = Path(args.duration_report_out).resolve()