Sync third-party and MCP marketplace plugins

Constraint: Public skills are published only by explicit administrator action unless they are tracked third-party market sources.
Confidence: high
Scope-risk: narrow
Directive: Keep private/internal skills out of the public marketplace and preserve normal incremental market Git history.
Tested: Marketplace validation passed.
This commit is contained in:
KeyInfo Bot
2026-07-25 00:02:24 +08:00
parent c9d4e644de
commit 4e60e99a0f
18 changed files with 1476 additions and 90 deletions
@@ -3,5 +3,5 @@
"name": "playwright浏览器自动化操作",
"version": "20260605",
"keySource": "none",
"syncedAt": "2026-07-24T06:20:34Z"
"syncedAt": "2026-07-24T16:02:23Z"
}
@@ -2,8 +2,8 @@
"sourceId": "next-skills",
"repo": "https://github.com/vercel/next.js.git",
"ref": "canary",
"commit": "29b3966370420894de50c3c105397985fb366140",
"commit": "6fd889b922fc0aaeaa7a6b63fce5450e20d2df99",
"adapter": "skill-collection",
"sourcePath": "skills",
"syncedAt": "2026-07-24T06:18:20Z"
"syncedAt": "2026-07-24T16:00:00Z"
}
@@ -2,8 +2,8 @@
"sourceId": "ppt-master",
"repo": "https://github.com/hugohe3/ppt-master.git",
"ref": "main",
"commit": "68c690bbe2e170bca657c2dffd47434056cfabcd",
"commit": "1912f0112c711e7b46d893a461648c4cde437314",
"adapter": "claude-skill",
"sourcePath": "skills/ppt-master",
"syncedAt": "2026-07-24T06:18:20Z"
"syncedAt": "2026-07-24T16:00:00Z"
}
@@ -17,12 +17,22 @@ To regenerate a deck with different settings, rerun `svg_to_pptx.py` against the
## 2. Custom Object-Level Animation
Per-element animation is off by default. To enable it deck-wide, pass `-a auto` at export (no config needed). When a deck instead needs specific object timing — for example title first, chart second, annotation last — use the optional `animations.json` sidecar. The SVG remains static visual source; the sidecar only controls PPTX export behavior.
Per-element animation is off by default. To enable it deck-wide, pass `-a auto` at export (no config needed). When a deck instead needs specific object timing — for example title first, chart second, annotation last — use the optional `animations.json` sidecar. The SVG remains the visual source; the custom stage may rewrite its grouping hierarchy, ids, and bounds to create better semantic anchors without changing visible output, while the sidecar controls PPTX animation behavior.
Run the [`customize-animations`](../workflows/stages/customize-animations.md) post-processing stage when the user asks to tune animation order, effects, timing, or object-level reveals.
**Hard rule — semantic anchors before sidecar**: for custom object-level
animation, do not scaffold or choreograph directly from the SVG's pre-existing
`<g>` list. First derive reveal units from page meaning and narration, audit
every page, and rewrite coarse or fragmented ordinary Slide-local groups
without changing visible output. Only the post-regroup top-level ids are valid
custom-animation anchors.
```bash
# Build an editable scaffold from real top-level <g id> anchors
# Inspect the real anchors after the semantic regrouping pass
python3 skills/ppt-master/scripts/animation_config.py list-groups <project>
# Build an editable scaffold from the post-regroup anchors when useful
python3 skills/ppt-master/scripts/animation_config.py scaffold <project>
# Validate references before export
@@ -132,9 +142,9 @@ python3 skills/ppt-master/scripts/svg_to_pptx.py <project> --animation mixed \
python3 skills/ppt-master/scripts/svg_to_pptx.py <project> -a auto --animation-trigger with-previous
```
22 single effects: `appear`, `fade`, `fly`, `cut`, `zoom`, `wipe`, `split`, `blinds`, `checkerboard`, `dissolve`, `random_bars`, `peek`, `wheel`, `box`, `circle`, `diamond`, `plus`, `strips`, `wedge`, `stretch`, `expand`, `swivel`. Plus three auto-vary modes:
29 single effects: `appear`, `fade`, `fly`, `fly_left`, `fly_right`, `fly_top`, `cut`, `zoom`, `wipe`, `wipe_left`, `wipe_right`, `wipe_up`, `wipe_down`, `split`, `blinds`, `checkerboard`, `dissolve`, `random_bars`, `peek`, `wheel`, `box`, `circle`, `diamond`, `plus`, `strips`, `wedge`, `stretch`, `expand`, `swivel`. Plus three auto-vary modes:
These names preserve the established filter / `presetID` / `presetSubtype` tuples documented in [`pptx-animations.md`](../scripts/docs/pptx-animations.md#3-compatibility-contract). `cut` is a legacy public key; compatibility promises its existing tuple, not a semantic interpretation inferred from an external preset-id table.
These names preserve the established filter / `presetID` / `presetSubtype` tuples documented in [`pptx-animations.md`](../scripts/docs/pptx-animations.md#3-compatibility-contract). `fly` remains the bottom-up variant. `wipe` preserves its historical tuple; use `wipe_left`, `wipe_right`, `wipe_up`, or `wipe_down` when motion should follow the layout explicitly. `cut` is a legacy public key; compatibility promises its existing tuple, not a semantic interpretation inferred from an external preset-id table.
- `auto` (recommended when enabling) — map effect from the group's SVG id. Information-dense elements get a single stable effect: `chart` / `table` / `legend` / `timeline` / `track``wipe`; `card-*` / `pillar-*` / `item-*` / `step-*` / `stage-*` / `tier-*` / `principle-*``fly`; `title` / `chapter-*` / `section-*` / `cover-*` / `tagline` / `subtitle``fade`; `takeaway` / `callout` / `quote` / `source` / `conclusion` / `note``fade`. Image-like ids `hero` / `figure-*` / `image` / `img-*` / `kpi` instead cycle a richer visual pool (`zoom` / `dissolve` / `circle` / `box` / `diamond` / `wheel`) so multiple images vary across the deck. Unmatched ids cycle through `fade` / `wipe` / `fly` / `zoom`.
- `mixed` (legacy) — deterministic. The first animated group on each slide uses `fade`; later groups cycle through a 16-effect pool (`blinds` / `checkerboard` / `dissolve` / `fly` / `cut` / `random_bars` / `box` / `split` / `strips` / `wedge` / `wheel` / `wipe` / `expand` / `fade` / `swivel` / `zoom`) across the deck. Kept for backward compatibility.
@@ -159,7 +169,14 @@ Flags:
Per-element animations are anchored on **top-level `<g id="...">` content groups** in the SVG (e.g. `<g id="cover-title">`, `<g id="card-1">`). IDs must be unique within the page. One group produces one animation-pane entrance row; whether that row needs a click depends on the selected Start mode. Nested implementation groups may remain anonymous because the sidecar does not target them.
Use one content group per logical page unit. This is also the granularity PowerPoint uses for group-select / group-move, so semantic grouping improves editing ergonomics regardless of animation; do not split or merge units to hit a target count.
**Hard rule — existing groups are not custom-animation intent**: the
pre-existing SVG hierarchy is implementation evidence, not an authoritative
reveal plan. During the custom-animation stage, derive one group per logical
page unit from claims, comparisons, sequence, causality, and narration beats;
split coarse wrappers and merge fragmented atoms when needed, then use
`list-groups` only after that rewrite. This is also the granularity PowerPoint
uses for group-select / group-move. Do not split or merge units to hit a target
count.
**Chrome groups skip the cascade automatically.** Explicit SVG role and placeholder semantics are authoritative. A group with `data-pptx-layer` or an explicit static role/placeholder marker can never animate. For marker-free legacy SVGs only, top-level groups whose id tokens look like page chrome (background, header/footer, decorations, watermark, page number, nav, logo, dividing rule) are excluded and appear with the slide. An explicit `animations.json` group entry may override this id-name heuristic, but never an explicit structural marker. Examples that auto-skip by legacy id: `<g id="background">`, `<g id="bg-texture">`, `<g id="cover-footer">`, `<g id="p03-header">`, `<g id="bottom-decor">`, `<g id="watermark">`, `<g id="nav">`, `<g id="logo-area">`, `<g id="column-rule">`. Examples that still animate: `<g id="card-1">`, `<g id="cover-title">`, `<g id="step-discover">`, `<g id="timeline-track">`. Do not strip the `<g>` wrapper to avoid animation — keep it for PowerPoint group selection and use `effect: none` when the content should remain static.
@@ -182,7 +199,23 @@ Narration injection merges audio timing into an existing direct `p:sld/p:timing`
---
## 7. Limitations
## 7. Video Adaptation Contract
Custom animation remains the semantic source for video motion. A downstream
video renderer must consume a resolved conversion trace through
`video_motion_plan.py`, not infer motion from delay values or read an unresolved
sidecar directly.
The plan locks object identity, object order, source effect, semantic direction,
and timing anchors. Video-only adaptation may refine easing, travel distance,
opacity, scale, mask feather, blur, motion blur, and overshoot. Unsupported
effect families must fail visibly rather than silently becoming generic fades.
See [`video-motion-plan.md`](../scripts/docs/video-motion-plan.md) for the schema
and renderer contract.
---
## 8. Limitations
- **Native DrawingML output only.** Page transitions and per-element animations are authored on the PPTX produced by the project converter from `svg_output/`. `svg_final/` remains a static SVG visual preview, not an animated or alternate PPTX route.
- **PowerPoint OOXML scope.** Effects preserve their established filter / `presetID` / `presetSubtype` tuples and are validated against the serialized PowerPoint package. Rendering in Keynote, LibreOffice, WPS, or other applications is outside the unconditional compatibility guarantee.
@@ -191,7 +224,7 @@ Narration injection merges audio timing into an existing direct `p:sld/p:timing`
---
## 8. Quick Reference
## 9. Quick Reference
| Goal | Command |
|---|---|
@@ -208,9 +241,11 @@ Narration injection merges audio timing into an existing direct `p:sld/p:timing`
| All groups animate together | `-a auto --animation-trigger with-previous` |
| Slower per-element reveal | `-a auto --animation-duration 0.5` |
| Wider gap in after-previous | `-a auto --animation-stagger 0.7` |
| Derive effect-aware video motion | `video_motion_plan.py <project>/validation/<output_stem>.trace.json --force` |
See also:
- [`scripts/docs/svg-pipeline.md`](../scripts/docs/svg-pipeline.md) for the full `svg_to_pptx.py` reference.
- [`pptx-transitions.md`](../scripts/docs/pptx-transitions.md) for the shared OOXML writer, MCE preservation, and read-back contract.
- [`pptx-animations.md`](../scripts/docs/pptx-animations.md) for the exact effect tuples, timing-tree rules, and animation package validator.
- [`video-motion-plan.md`](../scripts/docs/video-motion-plan.md) for the resolved animation-to-video enhancement contract.
@@ -131,6 +131,7 @@ Before drawing each page, look up its entry in `page_rhythm` (key format `P<NN>`
- **Proximity**: group related elements with tight spacing; separate unrelated groups
- **Element grouping (Mandatory)**: wrap each logical Slide-local body unit in a descriptive, page-unique top-level `<g id>`. Every visible direct root `<g>` declares root-coordinate `data-pptx-bounds="x y width height"`; frame/native coordinates do not replace it, and placeholder bounds also supply the slot frame. Nested groups need no bounds and any such values are ignored. Checker compares root bounds with the `viewBox` and recursively checks only estimable text against its root module: through `1px` is ignored, through `5%` warns, above `5%` fails per side. Images, shapes, paths, `<use>`, effects, and object frames remain geometrically free. Flat pages use ordinary groups; structured slots already qualify, while titles, direct Master/Layout atoms, and canvas-level static framing may remain root primitives. On flat pages, give a root background image or full-canvas scrim/decoration rectangle a stable `id` plus `data-pptx-role="background"` / `"decoration"`; never wrap it only to silence the advisory.
- **Reference — not a constraint**: top-level groups set semantic and automatic-animation granularity, but they may contain descriptive nested `<g>` edit groups when the page has meaningful internal subunits. Nested groups need no bounds and create no automatic animation step; use or omit them from the page's actual editing semantics, with no default pattern, depth, or quota.
- **Default — size `data-pptx-bounds` as the intended module zone, not a glyph box (may skip when no text is estimable)**: make the zone as generous as the canvas and sibling layout allow, without overlapping another module zone. An untransformed line spans `y - 0.85 × font_size` to `y + 0.35 × font_size`; width uses the shared SVG-to-PPTX per-run estimate and safety headroom. If text does not fit, first expand a zone that has unused non-overlapping space; otherwise reflow or adapt. Larger bounds do not repair off-canvas text.
- **Spec adherence**: follow color, layout, canvas format, and typography in the spec
- **Template structure**: inherit the native visual framework only for `template_reuse_scope: mirror|layout`; `style` uses the flat route
@@ -587,6 +587,14 @@ These forms are needed only when the stated PPT behavior matters:
Wrap each logical Slide-local body unit in one descriptive top-level `<g id>`; group count follows the page's semantic units, and each group becomes one animation step when animation is enabled. Nested implementation groups may remain anonymous and need no bounds; any nested bounds are ignored. Flat pages use ordinary groups; structured slots already qualify, while titles, direct atomic Master/Layout elements, and canvas-level static framing—including background images and full-canvas scrim/decoration rectangles—may remain root primitives. On flat pages, give such static framing a stable `id` plus `data-pptx-role="background"` / `"decoration"`; never add a `<g>` solely to silence an ungrouped-element advisory.
**Reference — not a constraint**: A top-level semantic group may contain
descriptive nested `<g>` edit groups when its internal elements form useful
subunits, such as icon + title, value + label, or repeated information rows.
Nested groups carry no `data-pptx-bounds` and create no automatic animation
step; an unnecessary one-child wrapper may flatten. Choose whether and how
deeply to nest from the page's actual editing semantics—there is no default
nesting pattern, level, or quota.
**Structural atoms and slots are excluded automatically.** `data-pptx-layer` and `data-pptx-placeholder` semantics are read first; otherwise explicit `data-pptx-role` values (`background`, `decoration`, `header`, `footer`, `chrome`, `watermark`, `page-number`, `logo`) mark Slide-local static framing (§4.1, [`semantic-svg.md`](semantic-svg.md)). A normal slot group has exactly one direct compatible carrier; several drawing atoms require the explicit composite `object` proxy fallback. Native chart/table carrier groups retain their specialized [`native-data-interface.md`](./native-data-interface.md) contract.
**What to group** (one `<g id>` per unit):
@@ -610,7 +618,7 @@ separate parent content group; never put them inside the preset group itself.
- One giant `<g>` around the whole slide (collapses to a single animation step).
- Many ungrouped Slide-local `<rect>` / `<text>` / `<path>` atoms — they have no stable sidecar target and selection/editing degrades. Primitive fallback applies only when the root contains no top-level `<g>` at all; it is capped at 8 visible primitives.
- One group per icon / text line / mark (too many steps).
- One top-level group per icon / text line / mark (too many animation steps).
- Anonymous top-level groups — every top-level semantic group needs a descriptive `id`.
**Naming — required.** A descriptive, page-unique `id` on every top-level content `<g>` (`card-1`, `step-discover`, `header`, `footer`) is mandatory; it is the stable SVG-side animation and trace anchor. An anonymous top-level group still converts, but `animations.json` cannot reference it; an anonymous one-child implementation wrapper may also flatten. Primitive fallback is unrelated and applies only to roots with no top-level groups.
@@ -620,8 +628,10 @@ separate parent content group; never put them inside the preset group itself.
<!-- Shadow only if the card floats over a colored panel; on flat white, omit it. -->
<rect x="60" y="115" width="565" height="260" rx="20" fill="#FFFFFF" filter="url(#shadow)"/>
<use data-icon="chunk-filled/bolt" x="108" y="163" width="44" height="44" fill="#0071E3"/>
<text x="105" y="270" font-size="56" font-weight="bold" fill="#0071E3">10×</text>
<text x="250" y="270" font-size="30" font-weight="bold" fill="#1D1D1F">Faster</text>
<g id="card-benefits-metric">
<text x="105" y="270" font-size="56" font-weight="bold" fill="#0071E3">10×</text>
<text x="250" y="270" font-size="30" font-weight="bold" fill="#1D1D1F">Faster</text>
</g>
<text x="105" y="310" font-size="18" fill="#6E6E73">Reduce production time from days to hours.</text>
</g>
```
@@ -83,6 +83,25 @@ The registry preserves these established 22 tuples exactly:
`cut` is a legacy public key. Compatibility promises the tuple above; it does
not infer a different semantic name from external preset-id tables.
Seven directional variants extend the registry without changing any established
tuple:
| Key | `p:animEffect@filter` | `presetID` | `presetSubtype` |
|---|---|---:|---:|
| `fly_left` | `slide(fromLeft)` | 2 | 8 |
| `fly_right` | `slide(fromRight)` | 2 | 2 |
| `fly_top` | `slide(fromTop)` | 2 | 1 |
| `wipe_left` | `wipe(left)` | 22 | 8 |
| `wipe_right` | `wipe(right)` | 22 | 2 |
| `wipe_up` | `wipe(up)` | 22 | 1 |
| `wipe_down` | `wipe(down)` | 22 | 4 |
The existing `fly` key remains fly-in from bottom. The existing `wipe` key
keeps its historical `wipe(left)` / subtype `1` tuple; use `wipe_left` for
PowerPoint's native left-direction subtype `8`. Directional keys are explicit
effect names rather than a new configuration field, so version-1 sidecars and
the read-back model remain unchanged.
**Hard rule — no downgrade**:
- Keep the 22 established tuples byte-for-byte equivalent in meaning.
@@ -175,6 +194,13 @@ targets. Template fill and native enhancement fingerprint the source
object-animation tree before and after their allowed edits; any semantic change
fails. These routes have no object-animation write ownership.
The conversion trace is also the authoritative input for downstream video
motion. `video_motion_plan.py` preserves the resolved effect tuple, direction,
row order, duration, absolute offset, object bounds, and narration-derived slide
advance while adding only renderer-specific enhancement parameters. Video
renderers must not bypass this read-back result and infer motion from sidecar
delay values alone.
---
## 7. Compatibility Scope
@@ -192,3 +218,5 @@ Official references:
See [`pptx-transitions.md`](./pptx-transitions.md) for the symmetric page-motion
core, MCE handling, and slide-advance contract.
See [`video-motion-plan.md`](./video-motion-plan.md) for the downstream
animation-to-video contract.
@@ -349,6 +349,7 @@ Behavior:
- Native output uses content-hash media filenames, so identical images are reused and different images cannot overwrite each other by sharing a basename.
- `[Content_Types].xml` is generated from the actual media extensions written into the PPTX. Unknown media extensions fail unless Python's `mimetypes` can identify them.
- Native export writes to a temporary file first and publishes the requested PPTX only after conversion succeeds. A failed conversion does not replace the main output file.
- `--conversion-trace` without a path writes `validation/<output_stem>.trace.json`. `--conversion-trace <path>` respects the explicit destination; relative paths are resolved from the project root, so `exports/<name>.trace.json` remains available when intentionally requested.
- After publication, native export writes `validation/<output_stem>.report.json`. The report distinguishes authored Slides from internal Layout definitions, reruns ZIP integrity and published Slide-count checks, records slide/layout/master/notes part counts, labels relationship/structured/transition/animation validation as enforced at build time, links the final SVG quality report only when its SHA-256 source fingerprint matches the exact export inputs, and surfaces stale/unverified gates, unresolved template tokens, generic-only font stacks, and external image references. A matching final quality report with introduced warnings yields `passed-with-warnings` and a `quality_introduced_warnings=<N>` receipt instead of a clean `passed` claim.
- By default, a successful command also prints a compact receipt instead of requiring a report read: `[POSTFLIGHT] status=<...> quality_gate=<...> slides=<N> warning_categories=<N>`, followed by one compact line per warning category and the `[PPTX]` / `[REPORT]` paths. Resource-warning lines carry counts; a non-passing quality gate carries its status. Routine agents use this receipt and do not load either complete validation JSON into model context. Full reports remain cold audit artifacts; failure investigation and explicit audits extract only the required fields. `--quiet` keeps suppressing successful-run output.
- Before publishing structured template output, export reopens the temporary PPTX and validates the Slide → Layout → Master graph and registrations, Layout identity, placeholder identity, reusable bounds, and prompt/level-one sizes. A mismatch aborts publication. Flat release instead validates its single referenced Master/Layout shell and exact date/footer/slide-number hook roster before packaging.
@@ -0,0 +1,116 @@
# Video Motion Plan
`video_motion_plan.py` converts a resolved SVG-to-PPTX conversion trace into a
renderer-neutral motion plan. It is the handoff between PPT Master's canonical
custom animation and any SVG-native or post-production video renderer.
The planner exists to prevent a video path from reducing animation to delay
values. The conversion trace is authoritative because it already contains the
resolved object target, effect, direction tuple, order, duration, offset, slide
advance, and native object bounds after sidecar inheritance and narration
synchronization.
---
## 1. Generate a Resolved Source Trace
For a narrated deck, generate the trace from the narrated export so its offsets
and slide advances include the final audio timing:
```bash
python3 skills/ppt-master/scripts/svg_to_pptx.py <project_path> \
--recorded-narration audio \
--conversion-trace \
-o <project_path>/validation/video_motion_source.pptx
```
Then build the motion plan:
```bash
python3 skills/ppt-master/scripts/video_motion_plan.py \
<project_path>/validation/video_motion_source.trace.json \
-o <project_path>/validation/video_motion_plan.json \
--style adaptive \
--force
```
`restrained` and `dynamic` are explicit intensity alternatives. `adaptive` is
the default and adjusts enhancement strength from page role, animated-object
count, and object area.
---
## 2. Authority and Locks
The downstream renderer must preserve:
- object identity and SVG group id;
- object order;
- source effect;
- semantic direction;
- resolved start time and slide timing anchor.
The video layer may optimize only the declared `optimizer_scope` parameters:
easing, travel distance, opacity, scale, mask feather, blur, motion blur, and
overshoot. These additions can make motion feel more cinematic without
rewriting the presentation's choreography.
Do not derive a video plan directly from raw `animations.json` or
`narration_animations.json`. Raw sidecars may still contain inheritance,
`auto`, timing modes, or narration-relative values. Always use the resolved
conversion trace.
---
## 3. Effect Mapping
Each object keeps `source_effect` and receives a compatible video family:
| Source effect | Video family |
|---|---|
| `appear` | `hard_reveal` |
| `fade` | `soft_fade` |
| `dissolve` | `grain_dissolve` |
| `fly*`, `cut` | `directional_slide` |
| `wipe*`, `peek` | `soft_mask_reveal` |
| `zoom`, `expand`, `stretch` | `focus_scale` |
| `split` | `split_mask` |
| `box`, `circle`, `diamond`, `plus` | `shape_mask` |
| `blinds`, `checkerboard`, `random_bars`, `strips`, `wedge`, `wheel` | `pattern_reveal` |
| `swivel` | `soft_swivel` |
This mapping is an enhancement contract, not permission to substitute an
unrelated effect. A directional slide remains directional; a wipe remains a
mask reveal.
---
## 4. Output Contract
The output schema is `ppt-master.video-motion-plan.v1`. Each slide records:
- SVG source and canvas size;
- page role;
- slide duration and its source;
- resolved transition;
- ordered animated objects.
Each object records:
- `group_id`, native `shape_id`, and order;
- source effect and trigger;
- absolute `start_ms` and `duration_ms`;
- native `bounds_emu` and normalized area;
- renderer parameters under `video`.
The planner rejects click-triggered animation because a rendered video has no
interactive click event. Re-export with click-free `after-previous` or
`with-previous` timing first.
---
## 5. Current Boundary
This script owns the semantic handoff and deterministic enhancement policy. It
does not encode video by itself. A renderer that consumes the plan must report
unsupported families instead of silently falling back to delay-only fades.
@@ -0,0 +1,407 @@
#!/usr/bin/env python3
"""
PPT Master - PowerPoint Video Export
Export a narrated PPTX through the installed Windows PowerPoint application and
wait until its native video encoder finishes.
See workflows/stages/generate-audio.md for the narration handoff.
Usage:
python3 scripts/powerpoint_video.py <pptx> [-o <video>]
python3 scripts/powerpoint_video.py --check
Examples:
python3 scripts/powerpoint_video.py projects/demo/exports/demo_narrated.pptx
python3 scripts/powerpoint_video.py deck.pptx -o deck.mp4 --resolution 1080
Dependencies:
Windows PowerPoint with the CreateVideo automation API
"""
from __future__ import annotations
import argparse
import base64
import os
import shutil
import subprocess
import sys
from pathlib import Path
from console_encoding import configure_utf8_stdio
configure_utf8_stdio()
_CHECK_SCRIPT = r"""
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
$powerPoint = $null
$ownsApplication = $false
try {
try {
$powerPoint = [Runtime.InteropServices.Marshal]::GetActiveObject(
"PowerPoint.Application"
)
}
catch {
$powerPoint = New-Object -ComObject PowerPoint.Application
$ownsApplication = $true
}
$version = [version]$powerPoint.Version
if ($version.Major -lt 16) {
throw "PowerPoint $version is older than the supported Office 2016 baseline."
}
[Console]::Out.WriteLine(
"PowerPoint video export available (version {0})." -f $version
)
}
catch {
[Console]::Error.WriteLine(
"PowerPoint video export is unavailable: {0}" -f $_.Exception.Message
)
exit 1
}
finally {
if ($null -ne $powerPoint) {
if ($ownsApplication) {
try { $powerPoint.Quit() } catch {}
}
try {
[void][Runtime.InteropServices.Marshal]::FinalReleaseComObject(
$powerPoint
)
}
catch {}
}
[GC]::Collect()
[GC]::WaitForPendingFinalizers()
}
"""
_EXPORT_SCRIPT = r"""
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
$inputPath = [Environment]::GetEnvironmentVariable("PPT_MASTER_VIDEO_INPUT")
$outputPath = [Environment]::GetEnvironmentVariable("PPT_MASTER_VIDEO_OUTPUT")
$resolution = [int][Environment]::GetEnvironmentVariable(
"PPT_MASTER_VIDEO_RESOLUTION"
)
$framesPerSecond = [int][Environment]::GetEnvironmentVariable(
"PPT_MASTER_VIDEO_FPS"
)
$quality = [int][Environment]::GetEnvironmentVariable("PPT_MASTER_VIDEO_QUALITY")
$defaultSlideDuration = [int][Environment]::GetEnvironmentVariable(
"PPT_MASTER_VIDEO_DEFAULT_SLIDE_DURATION"
)
$timeoutSeconds = [int][Environment]::GetEnvironmentVariable(
"PPT_MASTER_VIDEO_TIMEOUT"
)
$powerPoint = $null
$presentation = $null
$ownsApplication = $false
try {
try {
$powerPoint = [Runtime.InteropServices.Marshal]::GetActiveObject(
"PowerPoint.Application"
)
}
catch {
$powerPoint = New-Object -ComObject PowerPoint.Application
$ownsApplication = $true
}
$version = [version]$powerPoint.Version
if ($version.Major -lt 16) {
throw "PowerPoint $version is older than the supported Office 2016 baseline."
}
# ReadOnly=-1, Untitled=0, WithWindow=0.
$presentation = $powerPoint.Presentations.Open($inputPath, -1, 0, 0)
$startMessage = "PowerPoint video export started: {0}p, {1} fps." -f @(
$resolution,
$framesPerSecond
)
[Console]::Error.WriteLine($startMessage)
$presentation.CreateVideo(
$outputPath,
$true,
$defaultSlideDuration,
$resolution,
$framesPerSecond,
$quality
)
$deadline = [DateTime]::UtcNow.AddSeconds($timeoutSeconds)
$nextProgress = [DateTime]::UtcNow.AddSeconds(15)
while ($true) {
$status = [int]$presentation.CreateVideoStatus
if ($status -eq 3) {
break
}
if ($status -eq 4) {
throw "PowerPoint reported that video creation failed."
}
if ([DateTime]::UtcNow -ge $deadline) {
throw "PowerPoint video creation exceeded the ${timeoutSeconds}-second timeout."
}
if ([DateTime]::UtcNow -ge $nextProgress) {
[Console]::Error.WriteLine("PowerPoint video export is still running.")
$nextProgress = [DateTime]::UtcNow.AddSeconds(15)
}
Start-Sleep -Milliseconds 1000
}
if (-not (Test-Path -LiteralPath $outputPath)) {
throw "PowerPoint reported success but did not create the output file."
}
$outputFile = Get-Item -LiteralPath $outputPath
if ($outputFile.Length -le 0) {
throw "PowerPoint created an empty video file."
}
[Console]::Out.WriteLine($outputFile.FullName)
}
catch {
$errorMessage = (
"PowerPoint video export failed: {0} " +
"Close any PowerPoint dialog and retry."
) -f $_.Exception.Message
[Console]::Error.WriteLine($errorMessage)
exit 1
}
finally {
if ($null -ne $presentation) {
try { $presentation.Close() } catch {}
try {
[void][Runtime.InteropServices.Marshal]::FinalReleaseComObject(
$presentation
)
}
catch {}
}
if ($null -ne $powerPoint) {
if ($ownsApplication) {
try { $powerPoint.Quit() } catch {}
}
try {
[void][Runtime.InteropServices.Marshal]::FinalReleaseComObject(
$powerPoint
)
}
catch {}
}
[GC]::Collect()
[GC]::WaitForPendingFinalizers()
}
"""
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
"Export a PPTX with Windows PowerPoint's native video encoder and "
"wait for completion."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("pptx", nargs="?", help="Input PPTX file.")
parser.add_argument(
"-o",
"--output",
help="Output .mp4 or .wmv path. Default: beside the PPTX as .mp4.",
)
parser.add_argument(
"--resolution",
type=int,
default=1080,
help="Vertical video resolution in pixels (default: 1080).",
)
parser.add_argument(
"--fps",
type=int,
default=30,
help="Frames per second, from 1 to 60 (default: 30).",
)
parser.add_argument(
"--quality",
type=int,
default=85,
help="PowerPoint encoder quality, from 1 to 100 (default: 85).",
)
parser.add_argument(
"--default-slide-duration",
type=int,
default=5,
help="Fallback seconds for slides without recorded timings (default: 5).",
)
parser.add_argument(
"--timeout",
type=int,
default=7200,
help="Maximum seconds to wait for PowerPoint (default: 7200).",
)
parser.add_argument(
"--force",
action="store_true",
help="Replace an existing output video.",
)
parser.add_argument(
"--check",
action="store_true",
help="Check whether compatible Windows PowerPoint automation is available.",
)
return parser
def _find_powershell() -> str | None:
"""Return the local Windows PowerShell executable."""
return shutil.which("powershell.exe") or shutil.which("powershell")
def _run_powershell(script: str, *, env: dict[str, str], timeout: int) -> int:
"""Run an encoded PowerShell automation script."""
executable = _find_powershell()
if executable is None:
print(
"PowerPoint video export requires Windows PowerShell. "
"Install or restore powershell.exe, then retry.",
file=sys.stderr,
)
return 1
encoded = base64.b64encode(script.encode("utf-16-le")).decode("ascii")
try:
completed = subprocess.run(
[
executable,
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-STA",
"-OutputFormat",
"Text",
"-EncodedCommand",
encoded,
],
check=False,
env=env,
timeout=timeout,
)
except subprocess.TimeoutExpired:
print(
"PowerPoint automation exceeded the command timeout. "
"Close any PowerPoint dialog and retry.",
file=sys.stderr,
)
return 1
return completed.returncode
def _validate_positive(
parser: argparse.ArgumentParser,
*,
name: str,
value: int,
maximum: int | None = None,
) -> None:
if value <= 0 or (maximum is not None and value > maximum):
suffix = f" and no greater than {maximum}" if maximum is not None else ""
parser.error(f"{name} must be greater than 0{suffix}")
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if sys.platform != "win32":
print(
"PowerPoint video export currently requires Windows PowerPoint. "
"Keep the narrated PPTX and export it manually on another platform.",
file=sys.stderr,
)
return 1
if args.check:
return _run_powershell(
_CHECK_SCRIPT,
env=os.environ.copy(),
timeout=60,
)
if not args.pptx:
parser.error("pptx is required unless --check is used")
_validate_positive(parser, name="resolution", value=args.resolution)
_validate_positive(parser, name="fps", value=args.fps, maximum=60)
_validate_positive(parser, name="quality", value=args.quality, maximum=100)
_validate_positive(
parser,
name="default-slide-duration",
value=args.default_slide_duration,
)
_validate_positive(parser, name="timeout", value=args.timeout)
input_path = Path(args.pptx).expanduser().resolve()
if not input_path.is_file():
print(f"Input PPTX does not exist: {input_path}", file=sys.stderr)
return 1
if input_path.suffix.lower() != ".pptx":
print(f"Input must be a .pptx file: {input_path}", file=sys.stderr)
return 1
output_path = (
Path(args.output).expanduser().resolve()
if args.output
else input_path.with_suffix(".mp4")
)
if output_path.suffix.lower() not in {".mp4", ".wmv"}:
print(
f"Output must use the .mp4 or .wmv extension: {output_path}",
file=sys.stderr,
)
return 1
if output_path == input_path:
print("Input PPTX and output video paths must differ.", file=sys.stderr)
return 1
if output_path.exists() and not args.force:
print(
f"Output already exists: {output_path}. "
"Use --force to replace it.",
file=sys.stderr,
)
return 1
output_path.parent.mkdir(parents=True, exist_ok=True)
if output_path.exists():
output_path.unlink()
env = os.environ.copy()
env.update(
{
"PPT_MASTER_VIDEO_INPUT": str(input_path),
"PPT_MASTER_VIDEO_OUTPUT": str(output_path),
"PPT_MASTER_VIDEO_RESOLUTION": str(args.resolution),
"PPT_MASTER_VIDEO_FPS": str(args.fps),
"PPT_MASTER_VIDEO_QUALITY": str(args.quality),
"PPT_MASTER_VIDEO_DEFAULT_SLIDE_DURATION": str(
args.default_slide_duration
),
"PPT_MASTER_VIDEO_TIMEOUT": str(args.timeout),
}
)
return _run_powershell(
_EXPORT_SCRIPT,
env=env,
timeout=args.timeout + 60,
)
if __name__ == "__main__":
raise SystemExit(main())
@@ -14,9 +14,10 @@ Supported transition effects:
- random: Random
Supported entrance animations (per-element):
appear, fade, fly, cut, zoom, wipe, split, blinds, checkerboard,
dissolve, random_bars, peek, wheel, box, circle, diamond, plus,
strips, wedge, stretch, expand, swivel
appear, fade, fly, fly_left, fly_right, fly_top, cut, zoom, wipe,
wipe_left, wipe_right, wipe_up, wipe_down, split, blinds, checkerboard,
dissolve, random_bars, peek, wheel, box, circle, diamond, plus, strips,
wedge, stretch, expand, swivel
Animation modes used by the builder:
- single effect name (one of the above) apply to every element
@@ -82,9 +83,16 @@ ANIMATIONS: dict[str, dict[str, Any]] = {
'appear': {'name': 'Appear', 'filter': None, 'presetID': 1, 'presetSubtype': 0},
'fade': {'name': 'Fade', 'filter': 'fade', 'presetID': 10, 'presetSubtype': 0},
'fly': {'name': 'Fly In', 'filter': 'slide(fromBottom)', 'presetID': 2, 'presetSubtype': 4},
'fly_left': {'name': 'Fly In from Left', 'filter': 'slide(fromLeft)', 'presetID': 2, 'presetSubtype': 8},
'fly_right': {'name': 'Fly In from Right', 'filter': 'slide(fromRight)', 'presetID': 2, 'presetSubtype': 2},
'fly_top': {'name': 'Fly In from Top', 'filter': 'slide(fromTop)', 'presetID': 2, 'presetSubtype': 1},
'cut': {'name': 'Cut In', 'filter': 'slide(fromLeft)', 'presetID': 42, 'presetSubtype': 8},
'zoom': {'name': 'Zoom', 'filter': 'image', 'presetID': 23, 'presetSubtype': 0},
'wipe': {'name': 'Wipe', 'filter': 'wipe(left)', 'presetID': 22, 'presetSubtype': 1},
'wipe_left': {'name': 'Wipe Left', 'filter': 'wipe(left)', 'presetID': 22, 'presetSubtype': 8},
'wipe_right': {'name': 'Wipe Right', 'filter': 'wipe(right)', 'presetID': 22, 'presetSubtype': 2},
'wipe_up': {'name': 'Wipe Up', 'filter': 'wipe(up)', 'presetID': 22, 'presetSubtype': 1},
'wipe_down': {'name': 'Wipe Down', 'filter': 'wipe(down)', 'presetID': 22, 'presetSubtype': 4},
'split': {'name': 'Split', 'filter': 'barn(inVertical)', 'presetID': 16, 'presetSubtype': 21},
'blinds': {'name': 'Blinds', 'filter': 'blinds(horizontal)', 'presetID': 3, 'presetSubtype': 10},
'checkerboard': {'name': 'Checkerboard', 'filter': 'checkerboard(across)', 'presetID': 5, 'presetSubtype': 6},
@@ -619,7 +619,7 @@
"skills/ppt-master/scripts/docs/pptx-transitions.md",
"skills/ppt-master/scripts/docs/svg-pipeline.md"
],
"max_tokens": 18600
"max_tokens": 21300
},
"stage.generate.animation-options": {
"description": "Conditional deck-wide transition, auto-advance, and entrance-animation options.",
@@ -720,10 +720,16 @@ Recorded narration:
help='Disable paragraph merging. Every dy-stacked line becomes '
'its own text frame for strict SVG line-layout fidelity.')
parser.set_defaults(merge_paragraphs=True)
parser.add_argument('--conversion-trace', action='store_true', default=False,
help='Write a JSON diagnostics report next to the native PPTX '
'(<output>.trace.json). Records per-slide SVG element '
'conversion decisions for debugging.')
parser.add_argument(
'--conversion-trace',
nargs='?',
const='',
default=None,
metavar='PATH',
help='Write per-slide SVG conversion diagnostics. Without PATH, write '
'<project>/validation/<output_stem>.trace.json; relative PATHs '
'are resolved from the project root.',
)
parser.add_argument(
'--native-charts-and-tables',
dest='native_objects',
@@ -1452,10 +1458,19 @@ Recorded narration:
print(f" Output file: {native_path}")
print()
conversion_trace_path = (
native_path.with_name(native_path.name + '.trace.json')
if args.conversion_trace else None
)
conversion_trace_path: Path | None = None
if args.conversion_trace is not None:
if args.conversion_trace:
requested_trace_path = Path(args.conversion_trace).expanduser()
conversion_trace_path = (
requested_trace_path
if requested_trace_path.is_absolute()
else project_path / requested_trace_path
)
else:
conversion_trace_path = (
project_path / 'validation' / f'{native_path.stem}.trace.json'
)
try:
success = create_pptx_with_native_svg(
output_path=native_path,
@@ -0,0 +1,633 @@
#!/usr/bin/env python3
"""
PPT Master - Video Motion Plan
Derive an effect-aware video motion plan from one resolved SVG-to-PPTX
conversion trace. The plan preserves animation order, direction, duration,
and timing anchors while adding deterministic video-only motion parameters.
Usage:
python3 scripts/video_motion_plan.py <conversion_trace.json> [options]
Examples:
python3 scripts/video_motion_plan.py validation/deck.trace.json --force
python3 scripts/video_motion_plan.py validation/deck.trace.json \
--style dynamic -o validation/video_motion_plan.json --force
Dependencies:
None (standard library only)
See scripts/docs/video-motion-plan.md for the downstream renderer contract.
"""
from __future__ import annotations
import argparse
import json
import math
import re
import sys
from pathlib import Path
from typing import Any
from xml.etree import ElementTree as ET
_SCRIPTS_DIR = Path(__file__).resolve().parent
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from console_encoding import configure_utf8_stdio # noqa: E402
configure_utf8_stdio()
VIDEO_MOTION_SCHEMA = "ppt-master.video-motion-plan.v1"
VIDEO_MOTION_STYLES = ("adaptive", "restrained", "dynamic")
_STYLE_MULTIPLIERS = {
"adaptive": 1.0,
"restrained": 0.72,
"dynamic": 1.28,
}
_SVG_NS = "http://www.w3.org/2000/svg"
_EMU_PER_PX = 9525
_NUMBER_RE = re.compile(r"[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?")
def _read_json_object(path: Path) -> dict[str, Any]:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError as exc:
raise ValueError(f"conversion trace not found: {path}") from exc
except json.JSONDecodeError as exc:
raise ValueError(f"invalid conversion trace JSON: {path}: {exc}") from exc
if not isinstance(value, dict):
raise ValueError(f"conversion trace must be a JSON object: {path}")
return value
def _positive_int(value: object, field: str) -> int:
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
raise ValueError(f"{field} must be a positive integer: {value!r}")
return value
def _non_negative_int(value: object, field: str) -> int:
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
raise ValueError(f"{field} must be a non-negative integer: {value!r}")
return value
def _finite_positive_float(value: object, field: str) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(f"{field} must be a finite positive number: {value!r}")
number = float(value)
if not math.isfinite(number) or number <= 0:
raise ValueError(f"{field} must be a finite positive number: {value!r}")
return number
def _resolve_svg_path(raw: object, trace_path: Path) -> Path:
if not isinstance(raw, str) or not raw.strip():
raise ValueError("conversion trace slide is missing its SVG path")
path = Path(raw)
if path.is_absolute() and path.is_file():
return path
candidates = [Path.cwd() / path, trace_path.parent / path]
candidates.extend(parent / path for parent in trace_path.parents)
for candidate in candidates:
if candidate.is_file():
return candidate.resolve()
raise ValueError(
f"SVG referenced by conversion trace was not found: {raw}; "
"run the command from the repository root or regenerate the trace"
)
def _parse_canvas_emu(svg_path: Path) -> tuple[int, int]:
try:
root = ET.parse(svg_path).getroot()
except (OSError, ET.ParseError) as exc:
raise ValueError(f"cannot read SVG canvas: {svg_path}: {exc}") from exc
values = [
float(match)
for match in _NUMBER_RE.findall(root.get("viewBox") or "")
]
if len(values) != 4 or values[2] <= 0 or values[3] <= 0:
raise ValueError(f"SVG must have a positive four-number viewBox: {svg_path}")
return (
max(1, round(values[2] * _EMU_PER_PX)),
max(1, round(values[3] * _EMU_PER_PX)),
)
def _event_score(event: dict[str, Any]) -> int:
score = 0
if event.get("tag") == "g":
score += 4
if isinstance(event.get("id"), str) and event["id"].strip():
score += 2
if event.get("decision") == "native":
score += 1
return score
def _shape_events(slide: dict[str, Any]) -> dict[int, dict[str, Any]]:
events = slide.get("events", [])
if not isinstance(events, list):
raise ValueError("conversion trace slide events must be a list")
selected: dict[int, dict[str, Any]] = {}
for raw_event in events:
if not isinstance(raw_event, dict):
continue
shape_id = raw_event.get("shape_id")
bounds = raw_event.get("bounds_emu")
if (
isinstance(shape_id, int)
and shape_id > 0
and isinstance(bounds, list)
and len(bounds) == 4
and all(isinstance(value, int) for value in bounds)
):
current = selected.get(shape_id)
if current is None or _event_score(raw_event) >= _event_score(current):
selected[shape_id] = raw_event
return selected
def _direction_for_effect(effect: str, filter_name: object) -> str | None:
explicit = {
"fly": "down",
"fly_left": "left",
"fly_right": "right",
"fly_top": "up",
"cut": "left",
"wipe": "left",
"wipe_left": "left",
"wipe_right": "right",
"wipe_up": "up",
"wipe_down": "down",
"peek": "down",
"strips": "down-right",
}
if effect in explicit:
return explicit[effect]
if isinstance(filter_name, str):
match = re.search(
r"\((?:from)?(Top|Bottom|Left|Right|Up|Down)\)",
filter_name,
re.IGNORECASE,
)
if match:
value = match.group(1).lower()
return {"top": "up", "bottom": "down"}.get(value, value)
return None
def _area_ratio(bounds: list[int], canvas_emu: tuple[int, int]) -> float:
width = max(0, bounds[2] - bounds[0])
height = max(0, bounds[3] - bounds[1])
canvas_area = canvas_emu[0] * canvas_emu[1]
return (width * height / canvas_area) if canvas_area else 0.0
def _adaptive_multiplier(
style: str,
page_role: object,
object_count: int,
area_ratio: float,
) -> float:
multiplier = _STYLE_MULTIPLIERS[style]
if style == "adaptive":
if page_role in {"cover", "hero", "closing", "section"}:
multiplier *= 1.08
if object_count >= 5:
multiplier *= 0.82
elif object_count == 1:
multiplier *= 1.06
if area_ratio >= 0.42:
multiplier *= 0.78
elif 0 < area_ratio <= 0.10:
multiplier *= 1.08
return max(0.55, min(1.45, multiplier))
def _travel_vector(direction: str | None, magnitude: float) -> list[float]:
vectors = {
"left": [-magnitude, 0.0],
"right": [magnitude, 0.0],
"up": [0.0, -magnitude],
"down": [0.0, magnitude],
"down-right": [magnitude * 0.72, magnitude * 0.72],
}
return [round(value, 4) for value in vectors.get(direction, [0.0, 0.0])]
def _video_effect(
effect: str,
direction: str | None,
multiplier: float,
) -> dict[str, Any]:
common: dict[str, Any] = {
"easing": "ease_out_cubic",
"opacity_from": 0.0,
"scale_from": 1.0,
"travel_canvas_ratio": [0.0, 0.0],
"blur_px": 0.0,
"overshoot": 0.0,
"mask_feather_px": 0.0,
"motion_blur": 0.0,
}
if effect == "appear":
common.update({
"family": "hard_reveal",
"opacity_from": 1.0,
"easing": "step_end",
})
elif effect == "fade":
common.update({
"family": "soft_fade",
"scale_from": round(1.0 - 0.008 * multiplier, 4),
"blur_px": round(3.0 * multiplier, 2),
})
elif effect == "dissolve":
common.update({
"family": "grain_dissolve",
"scale_from": round(1.0 - 0.006 * multiplier, 4),
"blur_px": round(2.0 * multiplier, 2),
"grain": round(0.26 * multiplier, 3),
})
elif effect in {"fly", "fly_left", "fly_right", "fly_top", "cut"}:
magnitude = 0.045 * multiplier
common.update({
"family": "directional_slide",
"direction": direction,
"travel_canvas_ratio": _travel_vector(direction, magnitude),
"blur_px": round(4.5 * multiplier, 2),
"overshoot": round(0.012 * multiplier, 4),
"motion_blur": round(0.22 * multiplier, 3),
})
elif effect in {
"wipe",
"wipe_left",
"wipe_right",
"wipe_up",
"wipe_down",
"peek",
}:
common.update({
"family": "soft_mask_reveal",
"direction": direction,
"travel_canvas_ratio": _travel_vector(
direction,
0.012 * multiplier,
),
"mask_feather_px": round(18.0 * multiplier, 2),
"blur_px": round(1.5 * multiplier, 2),
})
elif effect in {"zoom", "expand", "stretch"}:
common.update({
"family": "focus_scale",
"scale_from": round(1.0 - 0.055 * multiplier, 4),
"blur_px": round(3.5 * multiplier, 2),
"overshoot": round(0.008 * multiplier, 4),
})
elif effect == "split":
common.update({
"family": "split_mask",
"mask_axis": "vertical",
"mask_feather_px": round(12.0 * multiplier, 2),
})
elif effect in {"box", "circle", "diamond", "plus"}:
common.update({
"family": "shape_mask",
"pattern": effect,
"scale_from": round(1.0 - 0.025 * multiplier, 4),
"mask_feather_px": round(10.0 * multiplier, 2),
})
elif effect in {
"blinds",
"checkerboard",
"random_bars",
"strips",
"wedge",
"wheel",
}:
common.update({
"family": "pattern_reveal",
"pattern": effect,
"mask_feather_px": round(8.0 * multiplier, 2),
})
elif effect == "swivel":
common.update({
"family": "soft_swivel",
"scale_from": round(1.0 - 0.025 * multiplier, 4),
"rotation_from_deg": round(-4.0 * multiplier, 2),
"blur_px": round(3.0 * multiplier, 2),
})
else:
raise ValueError(f"unsupported resolved animation effect for video: {effect}")
return common
def _transition_plan(raw_motion: object) -> dict[str, Any]:
if not isinstance(raw_motion, dict):
return {
"source_effect": None,
"video_effect": "cut",
"duration_ms": 0,
"easing": "linear",
}
effect = raw_motion.get("effect")
duration = raw_motion.get("duration_ms")
if effect is None:
video_effect = "cut"
elif effect == "fade":
video_effect = "crossfade"
elif effect in {"push", "cover"}:
video_effect = "directional_push"
elif effect in {"wipe", "split", "strips"}:
video_effect = f"soft_{effect}"
else:
video_effect = "adaptive_crossfade"
return {
"source_effect": effect,
"video_effect": video_effect,
"duration_ms": duration if isinstance(duration, int) else 0,
"easing": "ease_in_out_cubic",
}
def build_video_motion_plan(
trace_path: str | Path,
*,
style: str = "adaptive",
default_slide_duration: float = 5.0,
) -> dict[str, Any]:
"""Build one renderer-neutral, effect-aware video motion plan."""
path = Path(trace_path).resolve()
if style not in VIDEO_MOTION_STYLES:
raise ValueError(
f"unknown video motion style {style!r}; valid styles: "
f"{', '.join(VIDEO_MOTION_STYLES)}"
)
default_duration_ms = round(
_finite_positive_float(
default_slide_duration,
"default slide duration",
)
* 1000
)
trace = _read_json_object(path)
raw_slides = trace.get("slides")
if not isinstance(raw_slides, list) or not raw_slides:
raise ValueError("conversion trace must contain a non-empty slides list")
slides: list[dict[str, Any]] = []
total_objects = 0
enhanced_objects = 0
for raw_slide in raw_slides:
if not isinstance(raw_slide, dict):
raise ValueError("conversion trace slide entries must be objects")
slide_num = _positive_int(raw_slide.get("slide_num"), "slide_num")
svg_path = _resolve_svg_path(raw_slide.get("svg"), path)
canvas_emu = _parse_canvas_emu(svg_path)
event_index = _shape_events(raw_slide)
animation = raw_slide.get("animation", {})
if not isinstance(animation, dict):
raise ValueError(f"slide {slide_num} animation summary must be an object")
rows = animation.get("rows", [])
if not isinstance(rows, list):
raise ValueError(f"slide {slide_num} animation rows must be a list")
objects: list[dict[str, Any]] = []
for order, raw_row in enumerate(rows, 1):
if not isinstance(raw_row, dict):
raise ValueError(f"slide {slide_num} animation row must be an object")
shape_id = _positive_int(
raw_row.get("shape_id"),
f"slide {slide_num} animation shape_id",
)
trigger = raw_row.get("trigger")
if trigger == "on-click":
raise ValueError(
f"slide {slide_num} uses on-click animation; video motion "
"requires click-free after-previous or with-previous timing"
)
if trigger not in {"after-previous", "with-previous"}:
raise ValueError(
f"slide {slide_num} has unsupported video trigger: {trigger!r}"
)
effect = raw_row.get("effect")
if not isinstance(effect, str) or not effect:
raise ValueError(
f"slide {slide_num} animation row has no resolved effect"
)
start_ms = _non_negative_int(
raw_row.get("offset_ms"),
f"slide {slide_num} animation offset_ms",
)
duration_ms = _positive_int(
raw_row.get("duration_ms"),
f"slide {slide_num} animation duration_ms",
)
event = event_index.get(shape_id)
if event is None:
raise ValueError(
f"slide {slide_num} animation shape {shape_id} has no "
"conversion-trace bounds"
)
bounds = list(event["bounds_emu"])
area_ratio = _area_ratio(bounds, canvas_emu)
multiplier = _adaptive_multiplier(
style,
raw_slide.get("page_role"),
len(rows),
area_ratio,
)
direction = _direction_for_effect(
effect,
raw_row.get("filter_name"),
)
video = _video_effect(effect, direction, multiplier)
video["duration_ms"] = duration_ms
group_id = event.get("id")
if not isinstance(group_id, str) or not group_id.strip():
group_id = f"shape-{shape_id}"
objects.append({
"group_id": group_id,
"shape_id": shape_id,
"order": order,
"source_effect": effect,
"trigger": trigger,
"start_ms": start_ms,
"duration_ms": duration_ms,
"bounds_emu": bounds,
"area_ratio": round(area_ratio, 6),
"video": video,
})
total_objects += 1
if video["family"] != "hard_reveal":
enhanced_objects += 1
motion = raw_slide.get("motion")
advance_after_ms = (
motion.get("advance_after_ms")
if isinstance(motion, dict)
else None
)
content_end_ms = max(
(
item["start_ms"] + item["duration_ms"]
for item in objects
),
default=0,
)
if isinstance(advance_after_ms, int) and advance_after_ms > 0:
slide_duration_ms = max(advance_after_ms, content_end_ms)
duration_source = "recorded-advance"
else:
slide_duration_ms = max(default_duration_ms, content_end_ms + 750)
duration_source = "default-hold"
slides.append({
"slide_num": slide_num,
"svg": str(svg_path),
"page_role": raw_slide.get("page_role"),
"canvas_emu": list(canvas_emu),
"duration_ms": slide_duration_ms,
"duration_source": duration_source,
"transition": _transition_plan(motion),
"objects": objects,
})
return {
"schema": VIDEO_MOTION_SCHEMA,
"source_trace": str(path),
"source_pptx": trace.get("output"),
"style": style,
"locks": {
"object_identity": True,
"object_order": True,
"semantic_direction": True,
"timing_anchor": True,
"source_effect": True,
},
"optimizer_scope": [
"easing",
"travel_distance",
"opacity",
"scale",
"mask_feather",
"blur",
"motion_blur",
"overshoot",
],
"slide_count": len(slides),
"object_count": total_objects,
"enhanced_object_count": enhanced_objects,
"slides": slides,
}
def write_video_motion_plan(
trace_path: str | Path,
output_path: str | Path,
*,
style: str = "adaptive",
default_slide_duration: float = 5.0,
force: bool = False,
) -> Path:
"""Build and write one video motion plan."""
output = Path(output_path)
if output.exists() and not force:
raise FileExistsError(
f"output already exists: {output}; pass --force to overwrite"
)
plan = build_video_motion_plan(
trace_path,
style=style,
default_slide_duration=default_slide_duration,
)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(
json.dumps(plan, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
return output.resolve()
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
"Derive an effect-aware video motion plan from a resolved "
"SVG-to-PPTX conversion trace."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"trace",
help="Path to validation/<output_stem>.trace.json",
)
parser.add_argument(
"-o",
"--output",
default=None,
help="Output JSON path; default: <trace>.video-motion.json",
)
parser.add_argument(
"--style",
choices=VIDEO_MOTION_STYLES,
default="adaptive",
help="Video-only enhancement intensity; default: adaptive",
)
parser.add_argument(
"--default-slide-duration",
type=float,
default=5.0,
help="Fallback seconds for slides without recorded advance timing",
)
parser.add_argument(
"--force",
action="store_true",
help="Overwrite an existing output plan",
)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
trace_path = Path(args.trace)
output_path = (
Path(args.output)
if args.output
else trace_path.with_suffix(".video-motion.json")
)
try:
written = write_video_motion_plan(
trace_path,
output_path,
style=args.style,
default_slide_duration=args.default_slide_duration,
force=args.force,
)
except (FileExistsError, ValueError) as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
plan = _read_json_object(written)
print(written)
print(
f"Slides: {plan['slide_count']}; objects: {plan['object_count']}; "
f"video-enhanced: {plan['enhanced_object_count']}",
file=sys.stderr,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -458,6 +458,7 @@ python3 ${SKILL_DIR}/scripts/svg_to_pptx.py <project_path>
- `exports/<project_name>_<timestamp>.pptx`
- `validation/<project_name>_<timestamp>.report.json` with `passed` or `passed-with-warnings` package/resource postflight status
- `validation/<project_name>_<timestamp>.trace.json` when bare `--conversion-trace` is enabled; an explicit `--conversion-trace <path>` uses that destination instead
The command prints a compact `[POSTFLIGHT]` receipt containing `status`, `quality_gate`, Slide count, warning-category counts, and the PPTX/report paths. Use that receipt as completion evidence and disclose its material warnings to the user. Do not open or `cat` the complete report on routine success; use targeted field extraction only for failure investigation, an explicit audit request, or information absent from the receipt. A failed report or missing PPTX is not success.
@@ -19,48 +19,11 @@ description: Optional post-processing stage for per-slide and per-object animati
| User only wants the default deck (page transitions, no element builds) | Do not run; normal `svg_to_pptx.py` export is enough |
| User only wants deck-wide page transitions, auto-advance, or per-element entrance animation | Do not run; apply [`animations.md`](../../references/animations.md) with exporter flags such as `-a auto` |
| `svg_output/*.svg` is missing | Complete the main Executor phase first |
| This stage is triggered and `animations.json` is present | Validate and edit it; do not overwrite unless the user asks |
| `animations.json` exists | Resolve regeneration versus modification through the §1 intent gate before changing it |
---
## 1. Get Real Group IDs (do NOT dump the full scaffold)
**Mandatory**: use real SVG group ids. Do not invent slide or group keys.
**Default path — `list-groups`** (cheap, ~1KB of output even on a long deck):
```bash
python3 skills/ppt-master/scripts/animation_config.py list-groups <project_path>
```
Output is one line per slide: `<slide_basename>: id1, id2, id3` — default
chrome groups (`bg` / `*-header` / `*-footer` / `*-decor` / `nav` /
`watermark` / `logo` / `pagenumber`) are excluded from the ordinary target
list. Use this as the source of truth when planning §3 and editing §4
— **do not read the full scaffold file unless you need it as an editing
starting point**.
An explicit sidecar entry may override only the marker-free legacy id-name
heuristic. A group carrying `data-pptx-layer` or an explicit static
role/placeholder marker can never animate, even when it is named explicitly.
If `animations.json` does not exist and you want a starting file to edit:
```bash
python3 skills/ppt-master/scripts/animation_config.py scaffold <project_path>
```
Scaffold output also excludes chrome and includes a `defaults` stub.
If it already exists:
```bash
python3 skills/ppt-master/scripts/animation_config.py validate <project_path>
```
---
## 2. Read Semantic Context
## 1. Resolve Intent and Read Semantic Context
**Context read**: before editing `animations.json`, read every semantic planning file below that exists.
@@ -70,9 +33,102 @@ python3 skills/ppt-master/scripts/animation_config.py validate <project_path>
| `<project_path>/spec_lock.md` | Confirm page rhythm, layout role, chart/template constraints, and execution contract |
| `<project_path>/notes/total.md` or `<project_path>/notes/*.md` | Use speaker flow to tune reveal order, delays, and emphasis |
**Hard rule**: semantic files determine animation intent; `svg_output/*.svg` determines valid animation targets. Never reference a slide or group id that is absent from the scaffold / SVG scan.
**Existing sidecar intent gate**:
**Optional-context fallback**: these semantic files inform this supporting stage but are not its gate artifacts. If any are absent, state what is missing and proceed with every remaining file plus real SVG group ids. If all three context inputs are absent, use only explicit user instructions, real SVG group ids, and the resolution rules in [`animations.md`](../../references/animations.md); do not infer detailed object choreography.
| User intent | Action |
|---|---|
| Explicit regeneration / rewrite / replacement | Rebuild the semantic grouping plan and replace `animations.json`; the previous choreography is not a constraint |
| Explicit adjustment / tuning / repair | Validate first, preserve the existing choreography where its semantic units remain valid, and migrate affected group references after any required regrouping |
| Ambiguous generation request | Ask whether to regenerate from scratch or modify the current animation; do not choose on the user's behalf |
When the existing sidecar will be modified:
```bash
python3 skills/ppt-master/scripts/animation_config.py validate <project_path>
```
**Hard rule**: semantic files determine both animation intent and animation
unit boundaries. The current `svg_output/*.svg` supplies visible content and
implementation structure, but its existing `<g>` hierarchy is not accepted as
the animation plan merely because it already exists.
**Optional-context fallback**: these semantic files inform this supporting stage but are not its gate artifacts. If any are absent, state what is missing and proceed with every remaining file plus visible SVG content. If all three context inputs are absent, use only explicit user instructions, visible SVG content, and the resolution rules in [`animations.md`](../../references/animations.md); do not infer detailed choreography beyond what the page itself expresses.
---
## 2. Rebuild Semantic Animation Groups, Then List IDs
**Mandatory — content-first grouping audit**: inspect every slide's visible
content against its communication job and speaker flow before treating any
top-level `<g>` as an animation anchor. Existing groups are implementation
evidence only. Keep a current group unchanged only after confirming that it
already represents exactly one audience-facing reveal unit.
| Content condition | Required grouping action |
|---|---|
| One current group contains several independently narrated rows, cards, steps, claims, or stages | Split it into descriptive direct-root sibling groups, one per reveal unit |
| One reveal unit is scattered across groups or root primitives | Merge or wrap its background, icon, label, value, and supporting text into one direct-root group |
| A connector or arrow explains entry into a node or stage | Reveal it with the relationship or target unit that makes the connection intelligible |
| A hero visual, overview graphic, takeaway, or warning has its own communication role | Give it its own semantic group |
| Several atoms express one inseparable idea | Keep them together; do not animate the atoms separately |
| Page chrome, structural layers, or static framing | Preserve their structure and exclude them from ordinary animation targets |
**Hard rule — visual equivalence**: regrouping changes object boundaries only.
Preserve all visible content, paint order, coordinates, transforms, inherited
paint, opacity, clipping, filters, references, and native metadata. Keep
rendering-bearing implementation wrappers nested inside the new semantic group
when flattening or distributing their attributes could change appearance.
**Hard rule — structural boundary**: never split or merge across
`data-pptx-layer`, `data-pptx-placeholder`, native chart/table carrier, native
preset, or imported logical-object boundaries. Structural/static objects remain
non-animatable. Ordinary Slide-local content groups follow
[`shared-standards-core.md`](../../references/shared-standards-core.md) §4.3:
every visible direct-root group has a descriptive unique `id` and positive
root-coordinate `data-pptx-bounds`; nested implementation groups carry no
bounds.
**Forbidden — group-list-first choreography**:
- Choosing effects or order from the pre-existing `list-groups` output before the content-first audit
- Keeping a coarse wrapper only because it already has an `id`
- Splitting one semantic idea into individual shapes or text lines to increase animation count
- Merging unrelated ideas to reduce animation count
- Adding animation-specific `data-*` attributes to SVG
There is no target group count. Granularity follows the page's actual claims,
comparisons, sequence, causality, and narration beats.
After any regrouping, rerun the final SVG quality gate because `svg_output/`
changed:
```bash
python3 skills/ppt-master/scripts/svg_quality_checker.py <project_path> --stage final --json
```
Then list the **post-regroup** anchors:
```bash
python3 skills/ppt-master/scripts/animation_config.py list-groups <project_path>
```
Output is one line per slide: `<slide_basename>: id1, id2, id3`. Default chrome
groups (`bg` / `*-header` / `*-footer` / `*-decor` / `nav` / `watermark` /
`logo` / `pagenumber`) are excluded. This post-regroup list is the source of
truth when planning §3 and editing §4; never invent a slide or group key.
An explicit sidecar entry may override only the marker-free legacy id-name
heuristic. A group carrying `data-pptx-layer` or an explicit static
role/placeholder marker can never animate, even when it is named explicitly.
If `animations.json` does not exist and a starting file is useful, scaffold
only after semantic regrouping:
```bash
python3 skills/ppt-master/scripts/animation_config.py scaffold <project_path>
```
Do not read the full scaffold unless it is needed as an editing starting point.
---
@@ -86,11 +142,11 @@ python3 skills/ppt-master/scripts/animation_config.py validate <project_path>
| Page animation defaults | `defaults.animation` or `slides.<slide>.animation` | Control the default entrance behavior for animated groups on a slide |
| Object overrides | `slides.<slide>.groups.<group_id>` | Control order, effect, delay, or duration for a real SVG group |
**Per-page motion brief**: for each slide, decide transition effect, transition duration, object reveal sequence, object effects, and timing. Use `design_spec.md` for slide role, `spec_lock.md` for rhythm, speaker notes for narration order, and SVG group ids for target validity.
**Per-page motion brief**: for each slide, first decide what communication job motion should perform—or that it should perform none—then decide transition effect, transition duration, object reveal sequence, object effects, and timing. Use `design_spec.md` for slide role, `spec_lock.md` for rhythm and visual style, speaker notes for narration order, and SVG group ids for target validity.
**Title reveal decision**: when present, treat the page title as a first-class object in the per-page reveal plan, never an afterthought. Consciously choose one of — static (`effect: none`), immediate entrance, delayed entrance, entrance after the page's hero visual, synchronous with related content, or, when narration is part of the workflow, narration-cued — driven by the user's request, slide role, transition, and narration order. This stage uses the effect (§3.2), order, duration, and timing fields already defined below; narration-cued timing is realized later by the audio stage. It does not preset which choice a title uses. A real title must not drop out of the plan merely because its id resembles a legacy chrome name: use the documented sidecar override (§2 / §4) when animation is intended. Explicit structural or static markers remain authoritative; if they incorrectly mark a title that should animate, repair the SVG semantics before continuing.
**Hard rule**: a custom animation pass must not only edit group effects. It must also decide whether each slide should inherit the default transition or need a slide-specific `transition` override.
**Hard rule**: a custom animation pass must not only edit group effects. It must also decide whether each slide should inherit the default transition or need a slide-specific `transition` override. Inheritance is a complete decision; do not create slide-specific transitions to satisfy a variation quota.
**Timing guidance**: prefer content-aware durations when the deck has varied slide rhythm or object importance. Uniform timing is acceptable when it matches the user's requested style or the deck's pacing.
@@ -106,6 +162,25 @@ python3 skills/ppt-master/scripts/animation_config.py validate <project_path>
**Duration guidance**: use shorter timing for repeated scan content, longer timing for conceptual pivots, section transitions, hero diagrams, and final takeaways.
**Reference — not a constraint: motion judgment.** Supported effects are a
vocabulary, not assignments. Decide from content and narration before using
layout geometry:
| Decision question | Evidence to consider |
|---|---|
| What communication job exists? | Reveal, sequence, causality, transition, contrast, emphasis, atmosphere, or none |
| What tone should motion preserve? | Communication objective, consumption mode, visual style, page role, and emotional register |
| What should the audience encounter first? | Audience move, speaker-note order, focal claim, and dependency between objects |
| Does direction carry meaning? | Reading flow and spatial position may refine a direction only after the content relationship justifies motion |
| What should remain coherent across the deck? | Reuse can support recurring semantic roles; variation is optional and follows a real change in content or tone |
If motion adds no clarity or intended feeling, inherit the page default or
choose `none`, `appear`, or `fade`. A left/right layout, vertical stack, hero
image, or quote does not by itself require a directional, zoom, dissolve, or
other special effect. `auto`, `mixed`, `random`, directional, and patterned
effects remain available when the user request or the AI's content judgment
supports them; never use them to satisfy an effect-variety quota.
### 3.1 Supported Page Transitions
| Effect | Behavior |
@@ -135,9 +210,16 @@ python3 skills/ppt-master/scripts/animation_config.py validate <project_path>
| `appear` | Visibility flip without motion |
| `fade` | Neutral entrance |
| `fly` | Fly in from bottom |
| `fly_left` | Fly in from left |
| `fly_right` | Fly in from right |
| `fly_top` | Fly in from top |
| `cut` | Legacy compatibility key; preserve its registered tuple exactly |
| `zoom` | Scale/zoom entrance |
| `wipe` | Wipe entrance |
| `wipe` | Legacy wipe tuple; keep for compatibility |
| `wipe_left` | Left wipe entrance |
| `wipe_right` | Right wipe entrance |
| `wipe_up` | Upward wipe entrance |
| `wipe_down` | Downward wipe entrance |
| `split` | Split/barn entrance |
| `blinds` | Horizontal blinds |
| `checkerboard` | Checkerboard reveal |
@@ -257,7 +339,7 @@ Notes:
---
## 5. Validate and Export
## 5. Validate, Refresh Derived SVGs, and Export
Run sequentially:
@@ -265,13 +347,18 @@ Run sequentially:
python3 skills/ppt-master/scripts/animation_config.py validate <project_path>
```
```bash
python3 skills/ppt-master/scripts/finalize_svg.py <project_path>
```
```bash
python3 skills/ppt-master/scripts/svg_to_pptx.py <project_path>
```
**Validation**: the exported native PPTX must reflect the per-slide and
per-object overrides. `--animation none` still disables all per-element
animation and overrides `animations.json`. Unknown animation
per-object overrides, and `svg_final/` must reflect any semantic regrouping
performed in §2. `--animation none` still disables all per-element animation
and overrides `animations.json`. Unknown animation
effects/modes/triggers; boolean, NaN, or Infinity numeric values; non-positive
durations; negative delay/stagger; invalid order; missing slides/groups; and
structural-layer targets fail validation. Transition validation remains strict
@@ -282,12 +369,36 @@ Generated export performs semantic read-back per slide, comparing row order, tri
Direct-PPTX routes are preserve-only for object animation: they compare the source object-animation fingerprint before and after allowed edits, run structural package validation, and do not write, normalize, or claim ownership of effects. See [`pptx-animations.md`](../../scripts/docs/pptx-animations.md) for the exact compatibility and OOXML contract.
### 5.1 Optional Video Motion Handoff
When a downstream video renderer will enhance the deck, export with
`--conversion-trace` and derive its motion plan from that resolved trace:
```bash
python3 skills/ppt-master/scripts/video_motion_plan.py \
<project_path>/validation/<output_stem>.trace.json \
-o <project_path>/validation/video_motion_plan.json \
--style adaptive \
--force
```
For narrated output, the source trace must come from the final
`--recorded-narration` export. The video plan inherits object identity, source
effect, semantic direction, order, duration, native bounds, and final timing
anchors. Video-only optimization may refine easing, travel distance, opacity,
scale, mask feather, blur, motion blur, and overshoot; it must not replace the
source effect or reduce the choreography to delay values. See
[`video-motion-plan.md`](../../scripts/docs/video-motion-plan.md).
---
## ✅ Customize Animations Complete
- [x] `animations.json` exists only because per-slide or per-object customization was requested
- [x] `design_spec.md`, `spec_lock.md`, and available speaker notes were checked before editing animation overrides
- [x] Every slide's existing `<g>` hierarchy was audited against content and narration before it was accepted or rewritten
- [x] Every animation anchor is one post-regroup semantic reveal unit with a descriptive real SVG id
- [x] Any regrouped SVG passed the final SVG quality gate and `svg_final/` was refreshed
- [x] Every slide in `svg_output/` appears under `slides` with explicit `transition` + `animation` blocks
- [x] Group-level entries were added only for groups that diverge from the slide's `animation` block
- [x] Page transitions and in-slide object animations were planned together
@@ -295,3 +406,4 @@ Direct-PPTX routes are preserve-only for object animation: they compare the sour
- [x] `animation_config.py validate` passed
- [x] PPTX re-export completed with custom animation overrides
- [x] Generated animation semantic read-back and package validation passed
- [x] If video enhancement was requested, its motion plan was derived from the final resolved conversion trace
@@ -1,5 +1,5 @@
---
description: Shared post-processing stage for per-slide narration audio and optional embedding.
description: Shared post-processing stage for narration audio, PPTX embedding, and optional native video export.
---
# Generate Audio Stage
@@ -15,7 +15,13 @@ This stage is **context-independent**: it reads `notes/*.md` and queries the sel
- The stage is page-level only: with edge, one notes file becomes `audio/<stem>.mp3` plus `notes/subtitles/<stem>.srt`; with a cloud provider, it becomes one audio file. Do not use a single long audio track or attempt automatic long-audio splitting.
- PPT narration assets must be PowerPoint-reliable audio: `m4a` (AAC), `mp3`, or `wav`. The built-in TTS path defaults to `mp3`; provider formats such as `pcm`, `opus`, or `flac` must be transcoded before embedding.
- PowerPoint recorded narration export requires `ffprobe` so slide timings can be written from actual audio duration.
- Optional post-export video calibration requires `ffmpeg` plus `numpy`; it runs only when the caller supplies the finished PowerPoint video.
- Optional automatic video export requires Windows PowerPoint 2016+ and runs
through `powerpoint_video.py`; the command waits for PowerPoint's native
encoder to finish before returning.
- macOS PowerPoint may export MP4/MOV manually, but it has no equivalent
`CreateVideo` automation contract and its movie export does not preserve
animation effects. Do not replace the missing API with UI scripting.
- Optional post-export video calibration requires `ffmpeg` plus `numpy`; it runs only after a finished PowerPoint video is supplied or created.
- High-quality cloud mode: provider API key is set before use:
- ElevenLabs: `ELEVENLABS_API_KEY`
- MiniMax: `MINIMAX_API_KEY`
@@ -81,9 +87,9 @@ For each candidate, write a **one-line Chinese description** covering: 性别 ·
## Step 3: One-shot user interaction (mandatory)
Send a single message to the user that asks all three questions at once and provides a recommended value for each. Do NOT split into multiple rounds.
Send a single message to the user that resolves all five configuration decisions at once and provides a recommended value for each. Before offering automatic video export, run `python3 skills/ppt-master/scripts/powerpoint_video.py --check`; do not present an unavailable local capability as executable. Do NOT split into multiple rounds.
**Cloned-voice fast path**: if the user mentioned a cloned voice / 克隆音色 / 复刻音色 / "my own voice" along with a `voice_id`, skip the voice-recommendation list — set the provider to whichever the user named (`elevenlabs` / `minimax` / `qwen` / `cosyvoice`), pin the `voice_id` they gave you, and only confirm rate + embed-or-not.
**Cloned-voice fast path**: if the user mentioned a cloned voice / 克隆音色 / 复刻音色 / "my own voice" along with a `voice_id`, skip the voice-recommendation list — set the provider to whichever the user named (`elevenlabs` / `minimax` / `qwen` / `cosyvoice`), pin the `voice_id` they gave you, and only confirm rate + embed + video.
**Message template** (Chinese; translate to user's chat language if different). “Embed” means caller-specific integration: SVG re-export for Generate PPTX, or native OOXML application for Enhance Native PPTX.
@@ -103,6 +109,8 @@ Send a single message to the user that asks all three questions at once and prov
>
> **生成完是否重新导出嵌入音频的 PPTX**:⭐ 推荐 **是**(一次到位,自动按音频时长设页面停留)。
>
> **带音频 PPTX 完成后是否继续导出视频**:⭐ 推荐 **是**(仅在本机 Windows PowerPoint 2016+ 可用时;将等待原生视频导出完成)。
>
> 直接回"好"用全部推荐值,或告诉我想改的部分(如"音色 2,语速 -5%"或"用 MiniMax 的 voice_id xxx")。
**Recommended-value rules**:
@@ -110,6 +118,7 @@ Send a single message to the user that asks all three questions at once and prov
- 音色:从 Step 2 候选里挑最贴合 deck 调性的那一个。
- 语速:edge 默认 `+0%`notes 字数密集(页均 >4 句长句)建议 `-5%`notes 简短紧凑建议 `+5%`;超出此范围需说明理由。Cloud providers 默认用 provider defaults,除非用户明确要调速或改风格。
- 嵌入:默认推荐"是";除非用户已有定制 PPTX 不希望覆盖。
- 视频:`powerpoint_video.py --check` 成功时默认推荐"是";不可用时说明只能交付带音频 PPTX,不自动改用第三方渲染器。
---
@@ -168,10 +177,13 @@ python3 skills/ppt-master/scripts/svg_to_pptx.py <project_path> \
python3 skills/ppt-master/scripts/narration_sync.py subtitles <project_path> \
--pptx <final_narrated_pptx> --force
# 2D. After PowerPoint exports the video, align the frozen narration text
# against the finished video's actual audio track
# 2D. Optional: export through installed Windows PowerPoint and wait for completion
python3 skills/ppt-master/scripts/powerpoint_video.py \
<final_narrated_pptx> -o <final_video.mp4>
# 2E. Align the frozen narration text against the finished video's audio track
python3 skills/ppt-master/scripts/video_subtitles.py <project_path> \
--video <powerpoint_exported_video> --language <language> --force
--video <final_video.mp4> --language <language> --force
```
**Default — bounded Edge concurrency (may override)**: Generate up to three
@@ -219,16 +231,22 @@ python3 skills/ppt-master/scripts/narration_sync.py fingerprint <project_path>
`narration_sync.py subtitles` may still write `<project_path>/notes/subtitles/total.srt` as a PPTX-timeline diagnostic. It is not the delivery subtitle for a finished video.
After PowerPoint exports the video, `video_subtitles.py` takes the exact narration text frozen in the page SRT set and force-aligns it against the finished video's actual audio track with `stable-ts`. Long delivery cues may be split for display at this final stage. This writes a same-stem external SRT without changing the MP4, notes, page SRT, or animation files.
When video export was selected, `powerpoint_video.py` opens the final narrated PPTX through local Windows PowerPoint, requests its native video encoder with recorded timings and narrations enabled, and polls `CreateVideoStatus` until the MP4 succeeds, fails, or times out. The interface is synchronous to its caller even though PowerPoint performs encoding asynchronously. It preserves PowerPoint's own animation and media behavior rather than re-rendering the deck.
This stage keeps subtitles as external SRT files. It does not embed subtitles into PPTX or export MP4 directly.
If native video export fails, keep the narrated PPTX as a successful upstream
artifact and report the video failure separately. Do not regenerate audio or
the PPTX unless their own validation failed.
After the MP4 exists, `video_subtitles.py` takes the exact narration text frozen in the page SRT set and force-aligns it against the finished video's actual audio track with `stable-ts`. Long delivery cues may be split for display at this final stage. This writes a same-stem external SRT without changing the MP4, notes, page SRT, or animation files.
This stage keeps subtitles as external SRT files. It does not burn subtitles into the video. Automatic MP4 export is an optional Windows PowerPoint integration, not an independent renderer; when PowerPoint automation is unavailable, stop after the narrated PPTX instead of claiming a downgraded video.
**Caller integration**:
| Caller | After audio generation |
|---|---|
| Generate PPTX | With Edge SRT and an existing `animations.json`, derive `narration_animations.json`, export with `--recorded-narration audio` (derived animation by default; canonical or no-animation modes remain explicit), and generate the delivery SRT only after PowerPoint exports the video. |
| Enhance Native PPTX | Return to [`native-enhance-pptx`](../native-enhance-pptx.md) Step 9; its `apply` command owns audio relationships, timings, transitions, and the enhanced export |
| Generate PPTX | With Edge SRT and an existing `animations.json`, derive `narration_animations.json`, export with `--recorded-narration audio` (derived animation by default; canonical or no-animation modes remain explicit), optionally continue through `powerpoint_video.py`, then generate the delivery SRT from the finished video. |
| Enhance Native PPTX | Return to [`native-enhance-pptx`](../native-enhance-pptx.md) Step 9; its `apply` command owns audio relationships, timings, transitions, and the enhanced export. If video was selected, pass that final PPTX to `powerpoint_video.py`. |
For Generate PPTX, `--recorded-narration audio` prepares PowerPoint's recorded timings and narrations: every slide must have a matching supported audio file, every duration must be readable by `ffprobe`, and object animations must not use `--animation-trigger on-click`. Use `after-previous` or `with-previous` for narrated/video export. Narration changes the slide-advance layer only: the resolved page-transition effect remains unchanged, `-t none` remains visually transition-free, and narration advance disables click while using audio duration plus padding. The re-export is saved as `exports/<project_name>_<timestamp>_narrated.pptx`, telling it apart from silent exports.
@@ -244,7 +262,8 @@ Output one summary block listing:
- For edge, number of matching page-local SRT files and their location (`<project_path>/notes/subtitles/*`).
- For narrated object animation, whether current SVG semantics were reused or which missing/stale pages were reread, plus semantic mapping coverage and fallback count.
- For Generate PPTX with Edge SRT, derived narration animation group count and `narration_animations.json` path.
- When a finished video was supplied, the final aligned sidecar SRT path.
- When video export was selected, the final MP4 path and native PowerPoint export status.
- When a finished video exists, the final aligned sidecar SRT path.
- The provider, voice, and rate/settings actually used.
- The caller-owned integration result: narrated SVG export path, enhanced native PPTX path, or “audio only”.
- For Generate PPTX when embedding was skipped, one-line hint: `python3 skills/ppt-master/scripts/svg_to_pptx.py <project_path> --recorded-narration audio`.