fix(motion): preserve reference assets and exported animation frames

This commit is contained in:
William
2026-10-10 11:49:12 +08:00
parent 4a97f0d68b
commit 0a06e31369
14 changed files with 256 additions and 14 deletions
+56
View File
@@ -0,0 +1,56 @@
# Motion template sources and maintenance
These five examples extend the existing official HTML template mechanism:
`SKILL.md`, `example.html`, `open-design.json`, curated Home chips, and Community
cards. They use the existing preview baker, example binding, frozen reference
package, Remix, and HyperFrames MP4 exporter.
## Content provenance
The original October 8–9, 2026 work used the Opus 5.5 video community to choose
topics, including the collection at
[zhuyansen/awesome-opus-5.5-video](https://github.com/zhuyansen/awesome-opus-5.5-video)
and discovery through
[yihui-dev/awesome-opus5-5-videos](https://github.com/yihui-dev/awesome-opus5-5-videos)
and [Skillry's collection](https://skillry.dev/ai-videos/opus-5-5).
View-count ranking was an informal topic-selection aid, not evidence that these
are recreations of the five most popular individual videos.
Prompts were written for OD. HTML previews were generated in OD, then packaged
with local script dependencies, poster-ready markers, and export-safe drawing.
The source-project mapping comes from the original generator's `sources.json`;
all five original `index.html` files were still present during the handoff audit.
| Template | Original OD project |
| --- | --- |
| Procedural 3D island | `5873852b-3e24-4734-a909-903ad038a8e3` |
| Explainer | `baab39ba-f4ee-4261-a302-a98cd2c58dac` |
| Product launch | `d38769ce-f3d6-4451-9977-72887e551fce` |
| Showreel | `aebf983b-6223-46eb-bf63-4f7d03087695` |
| Data story | `515296bd-a73b-475d-b343-ee1451b2b695` |
Driftnote and Studio Kinetik are fictional examples. Chart figures are sample
content, not a verified business dataset. Preserve that distinction when
adapting a template.
## Local dependencies and export
The bundled script versions are GSAP 3.12.5 and Three.js 0.160.0, originally
retrieved from their version-pinned jsDelivr package URLs. No runtime script CDN
is required. Each SKILL explicitly names the dependencies that the existing
frozen-package scanner should carry.
Three.js exceeds the existing 256 KiB side-file limit. The gallery and Remix
use the original `assets/three.min.js`; the frozen reference carries an identical
gzip copy, with decompression instructions in its SKILL. When changing the
vendored Three.js file, regenerate its compressed copy deterministically:
```sh
gzip -n -9 -c plugins/_official/examples/motion-3d-scene/assets/three.min.js > plugins/_official/examples/motion-3d-scene/assets/three.min.js.gz
```
The package regression checks that all referenced local scripts can be restored
byte-for-byte. The browser regression checks callback-suppressed export seeking,
including backward scrubbing of the Showreel's slider, graph, and signature.
Keep procedural drawing in the shared per-frame renderer: nested tween callbacks
alone do not execute during MP4 export.
@@ -0,0 +1,44 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { gunzipSync } from 'node:zlib';
import { expect, test } from 'vitest';
import {
captureFrozenSkillPackageFromSources,
materializeFrozenSkillPackage,
} from '../../../apps/daemon/src/strategies/od-next/frozen-skill-package.js';
const examples = fileURLToPath(new URL('../../../plugins/_official/examples/', import.meta.url));
test.each(['3d-scene', 'explainer', 'product-launch', 'showreel', 'data-story'])(
'[P1] motion-%s carries its local script dependencies through the frozen reference package',
async (name) => {
const source = path.join(examples, `motion-${name}`);
const frozen = await captureFrozenSkillPackageFromSources({ sources: [{ dir: source, name: `Motion ${name}` }] });
const project = await mkdtemp(path.join(tmpdir(), 'motion-reference-'));
try {
const [relativeRoot] = await materializeFrozenSkillPackage({ frozen, cwd: project });
const reference = path.join(project, relativeRoot!);
const html = await readFile(path.join(reference, 'example.html'), 'utf8');
const skill = await readFile(path.join(reference, 'SKILL.md'), 'utf8');
// Follow the template's documented offline preparation step for Three.js.
if (name === '3d-scene') {
expect(skill).toContain('gzip -dc');
const bytes = gunzipSync(await readFile(path.join(reference, 'assets/three.min.js.gz')));
await writeFile(path.join(reference, 'assets/three.min.js'), bytes);
}
const scripts = [...html.matchAll(/<script\b[^>]*\bsrc=["']([^"']+)["']/g)];
expect(scripts.length).toBeGreaterThan(0);
for (const [, src] of scripts) {
expect(src).not.toMatch(/^https?:/);
const relative = src!.replace(/^\.\//, '');
expect(await readFile(path.join(reference, relative))).toEqual(await readFile(path.join(source, relative)));
}
} finally {
await rm(project, { recursive: true, force: true });
}
},
);
+49
View File
@@ -0,0 +1,49 @@
import { expect, test } from '@/playwright/suite';
for (const name of ['3d-scene', 'explainer', 'product-launch', 'showreel', 'data-story']) {
test(`[P1] motion-${name} exports the full composition without preview controls`, async ({ page }) => {
await page.setViewportSize({ width: 1920, height: 1080 });
// This is the marker installed by the daemon's existing frame bridge.
// Actual MP4 encoding is verified separately through that renderer.
await page.addInitScript(() => {
Object.assign(window, { __odFrameRenderer: {} });
});
await page.goto(`/api/plugins/example-motion-${name}/preview`);
await expect(page.locator('html')).toHaveClass(/od-export/);
await expect(page.locator('#controls')).toBeHidden();
const bounds = await page.locator('[data-composition-id]').boundingBox();
expect(bounds).toEqual({ x: 0, y: 0, width: 1920, height: 1080 });
});
}
test('[P1] showreel procedural layers survive export seeks and backward scrubbing', async ({ page }) => {
await page.goto('/api/plugins/example-motion-showreel/preview');
await page.waitForFunction(() => Boolean((window as unknown as { __timelines?: unknown }).__timelines));
const samples = await page.evaluate(async () => {
const tl = (window as unknown as {
__timelines: { main: { pause(): void; totalTime(t: number, suppressEvents: boolean): void } };
}).__timelines.main;
tl.pause();
const result = [];
for (const time of [8.7, 11.4, 14, 8.7, 11.4, 0, 14]) {
tl.totalTime(time, true);
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
result.push({
time,
slider: document.querySelector('#sRead')?.textContent,
number: document.querySelector('#bigNum')?.textContent,
line: (document.querySelector('#linePath') as SVGElement).style.strokeDashoffset,
dot: (document.querySelector('#morph') as HTMLElement).style.transform,
});
}
return result;
});
expect(samples[0]?.slider).toBe('63%');
expect(samples[1]?.number).toBe('+211%');
expect(Number(samples[1]?.line)).toBeGreaterThan(0);
expect(samples[3]).toEqual(samples[0]);
expect(samples[4]).toEqual(samples[1]);
expect(samples[5]?.slider).toBe('20%');
expect(samples[5]?.number).toBe('+0%');
expect(samples[6]).toEqual(samples[2]);
});
@@ -15,6 +15,18 @@ A stylized 3D motion piece rendered live in the browser with Three.js: a small
procedural island at golden hour, one continuous camera move, a calm title
close. `example.html` is the finished reference — start from it.
## Reference files
- Copy `example.html` and `assets/gsap.min.js` into the new composition,
preserving the relative `assets/` path. These files are carried in the frozen
reference package; use these local bytes instead of a CDN or a new version.
- The gallery uses `assets/three.min.js`. The frozen reference package carries
the same Three.js bytes compressed as `assets/three.min.js.gz` to fit the
existing per-file budget. Before rendering, create the composition's assets
directory and decompress this file into `assets/three.min.js`:
`gzip -dc "<reference-root>/assets/three.min.js.gz" > "<composition-root>/assets/three.min.js"`.
Both placeholders are the actual reference and composition directories in
this task. Keep the HTML script URL pointing to the decompressed `.js` file.
## Art direction
- Toon-shaded low-poly world: faceted island (sand, grass mound, rocks), palm
trees with drooping fronds, soft puffy clouds. 3-step toon light ramp, warm
@@ -47,3 +59,6 @@ close. `example.html` is the finished reference — start from it.
must not depend on `onUpdate`/`onComplete`; procedural layers redraw from the
timeline time on every animation frame, as the per-frame loop in
`example.html` does.
- Keep playback controls outside the composition. The example detects the
existing OD frame-export bridge and fills its capture viewport with only the
composition; preserve this export layout when adapting the preview shell.
@@ -36,6 +36,12 @@
#controls button:focus-visible, #scrub:focus-visible { outline: 3px solid #fff; outline-offset: 2px; }
#scrub { flex: 1; accent-color: var(--coral); min-height: 44px; }
#time { font-variant-numeric: tabular-nums; min-width: 96px; text-align: right; font-size: 16px; }
/* The existing OD export bridge marks frame capture; keep preview controls out. */
html.od-export body { margin:0!important; padding:0!important; gap:0!important; }
html.od-export #controls { display:none!important; }
html.od-export #viewport { position:fixed!important; inset:0!important; width:100vw!important; height:100vh!important; display:block!important; }
html.od-export #scaler, html.od-export #main { position:absolute!important; left:0!important; top:0!important; margin:0!important; transform:none!important; }
</style>
</head>
<body>
@@ -391,6 +397,9 @@ syncUI();
var drawnAt = null;
tl.eventCallback('onUpdate', function () { drawnAt = tl.time(); return draw.apply(this, arguments); });
(function loop() {
if (window.__odFrameRenderer && !document.documentElement.classList.contains('od-export')) {
document.documentElement.classList.add('od-export');
}
var t = tl.time();
if (t !== drawnAt) { drawnAt = t; draw(); }
requestAnimationFrame(loop);
@@ -15,6 +15,11 @@ An animated data story where the shrinking gaps between milestones are the
story: how long it took humanity to add each billion people (UN milestones
1804 → 2022). `example.html` is the finished reference — start from it.
## Reference files
- Copy `example.html` and `assets/gsap.min.js` into the new composition,
preserving the relative `assets/` path. These files are carried in the frozen
reference package; use these local bytes instead of a CDN or a new version.
## Art direction
- Editorial print look: paper `#FAFAF7`, ink `#111111`, a single red highlight
`#D7261E`. Serif headline, small sans/mono labels, thin black rules.
@@ -42,3 +47,6 @@ story: how long it took humanity to add each billion people (UN milestones
must not depend on `onUpdate`/`onComplete`; procedural layers redraw from the
timeline time on every animation frame, as the per-frame loop in
`example.html` does.
- Keep playback controls outside the composition. The example detects the
existing OD frame-export bridge and fills its capture viewport with only the
composition; preserve this export layout when adapting the preview shell.
@@ -40,6 +40,12 @@ body{display:flex;flex-direction:column;align-items:center;justify-content:cente
#controls button{font:600 16px var(--sans);padding:8px 16px;border:2px solid var(--ink);background:var(--paper);color:var(--ink);cursor:pointer}
#controls button:focus-visible,#scrub:focus-visible{outline:3px solid var(--red);outline-offset:2px}
#scrub{width:520px}
/* The existing OD export bridge marks frame capture; keep preview controls out. */
html.od-export body { margin:0!important; padding:0!important; gap:0!important; }
html.od-export #controls { display:none!important; }
html.od-export #viewport { position:fixed!important; inset:0!important; width:100vw!important; height:100vh!important; display:block!important; }
html.od-export #scaler, html.od-export #main { position:absolute!important; left:0!important; top:0!important; margin:0!important; transform:none!important; }
</style>
</head>
<body>
@@ -211,6 +217,9 @@ else { tl.play(); }
var drawnAt = null;
tl.eventCallback('onUpdate', function () { drawnAt = tl.time(); return draw.apply(this, arguments); });
(function loop() {
if (window.__odFrameRenderer && !document.documentElement.classList.contains('od-export')) {
document.documentElement.classList.add('od-export');
}
var t = tl.time();
if (t !== drawnAt) { drawnAt = t; draw(); }
requestAnimationFrame(loop);
@@ -15,6 +15,11 @@ A technical explainer that follows one recurring object — a waveform — throu
cause and effect: how noise-cancelling headphones work. `example.html` is the
finished reference — start from it.
## Reference files
- Copy `example.html` and `assets/gsap.min.js` into the new composition,
preserving the relative `assets/` path. These files are carried in the frozen
reference package; use these local bytes instead of a CDN or a new version.
## Art direction
- Clean technical diagram on dark graphite `#14171a`; cyan `#22d3ee` for the
active signal, white `#f5f7f8` lines, dim `#8a9aa3` for secondary labels.
@@ -45,3 +50,6 @@ finished reference — start from it.
must not depend on `onUpdate`/`onComplete`; procedural layers redraw from the
timeline time on every animation frame, as the per-frame loop in
`example.html` does.
- Keep playback controls outside the composition. The example detects the
existing OD frame-export bridge and fills its capture viewport with only the
composition; preserve this export layout when adapting the preview shell.
@@ -47,6 +47,12 @@
#controls button:focus-visible, #scrub:focus-visible { outline: 2px solid var(--cyan); outline-offset: 2px; }
#scrub { flex: 1; accent-color: var(--cyan); }
#time { font-size: 14px; color: var(--dim); min-width: 90px; text-align: right; }
/* The existing OD export bridge marks frame capture; keep preview controls out. */
html.od-export body { margin:0!important; padding:0!important; gap:0!important; }
html.od-export #controls { display:none!important; }
html.od-export #viewport { position:fixed!important; inset:0!important; width:100vw!important; height:100vh!important; display:block!important; }
html.od-export #scaler, html.od-export #main { position:absolute!important; left:0!important; top:0!important; margin:0!important; transform:none!important; }
</style>
</head>
<body>
@@ -263,6 +269,9 @@ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
var drawnAt = null;
tl.eventCallback('onUpdate', function () { drawnAt = tl.time(); return draw.apply(this, arguments); });
(function loop() {
if (window.__odFrameRenderer && !document.documentElement.classList.contains('od-export')) {
document.documentElement.classList.add('od-export');
}
var t = tl.time();
if (t !== drawnAt) { drawnAt = t; draw(); }
requestAnimationFrame(loop);
@@ -16,6 +16,11 @@ becomes a clean result, then the brand resolves. Shown with the fictional app
Driftnote (meeting audio → one-page summary). `example.html` is the finished
reference — start from it.
## Reference files
- Copy `example.html` and `assets/gsap.min.js` into the new composition,
preserving the relative `assets/` path. These files are carried in the frozen
reference package; use these local bytes instead of a CDN or a new version.
## Art direction
- Deep navy stage `#0B1530`, warm paper text `#F4EFE6`, one coral accent
`#FF6B57`. Editorial serif for display, clean sans for UI, mono for transcript
@@ -46,3 +51,6 @@ reference — start from it.
must not depend on `onUpdate`/`onComplete`; procedural layers redraw from the
timeline time on every animation frame, as the per-frame loop in
`example.html` does.
- Keep playback controls outside the composition. The example detects the
existing OD frame-export bridge and fills its capture viewport with only the
composition; preserve this export layout when adapting the preview shell.
@@ -59,6 +59,12 @@
#scrub { flex: 1; accent-color: var(--coral); min-height: 40px; }
#time { min-width: 110px; text-align: right; }
#rm-note { display: none; color: var(--coral); }
/* The existing OD export bridge marks frame capture; keep preview controls out. */
html.od-export body { margin:0!important; padding:0!important; gap:0!important; }
html.od-export #controls { display:none!important; }
html.od-export #viewport { position:fixed!important; inset:0!important; width:100vw!important; height:100vh!important; display:block!important; }
html.od-export #scaler, html.od-export #main { position:absolute!important; left:0!important; top:0!important; margin:0!important; transform:none!important; }
</style>
</head>
<body>
@@ -270,6 +276,9 @@ const start = () => {
var drawnAt = null;
tl.eventCallback('onUpdate', function () { drawnAt = tl.time(); return draw.apply(this, arguments); });
(function loop() {
if (window.__odFrameRenderer && !document.documentElement.classList.contains('od-export')) {
document.documentElement.classList.add('od-export');
}
var t = tl.time();
if (t !== drawnAt) { drawnAt = t; draw(); }
requestAnimationFrame(loop);
@@ -15,6 +15,11 @@ A 15-second motion designer showreel: several distinct techniques inside one
visual identity, linked by a recurring accent dot. Signed by the fictional
"Studio Kinetik". `example.html` is the finished reference — start from it.
## Reference files
- Copy `example.html` and `assets/gsap.min.js` into the new composition,
preserving the relative `assets/` path. These files are carried in the frozen
reference package; use these local bytes instead of a CDN or a new version.
## Art direction
- Off-white `#F4F4F1`, ink-black `#0E0E10`, a single vivid accent `#FF3D00`.
- Heavy display grotesque (900 weight, very large) for kinetic type; uppercase
@@ -42,3 +47,6 @@ visual identity, linked by a recurring accent dot. Signed by the fictional
must not depend on `onUpdate`/`onComplete`; procedural layers redraw from the
timeline time on every animation frame, as the per-frame loop in
`example.html` does.
- Keep playback controls outside the composition. The example detects the
existing OD frame-export bridge and fills its capture viewport with only the
composition; preserve this export layout when adapting the preview shell.
@@ -48,6 +48,7 @@
/* UI card */
#card{left:360px;top:200px;width:1200px;height:720px;background:#FFFFFF;border:3px solid var(--ink);border-radius:24px;opacity:0;z-index:5}
#card .abs, .uiItem{opacity:0}
.uiItem{z-index:6}
.uiLabel{font-family:var(--mono);font-weight:700;font-size:28px;letter-spacing:.06em;color:var(--ink);text-transform:uppercase}
#tTrack{left:1240px;top:300px;width:160px;height:80px;border-radius:40px;border:3px solid var(--ink);background:var(--bg);z-index:6}
#tKnob{left:1252px;top:312px;width:56px;height:56px;border-radius:28px;background:var(--ink);z-index:7}
@@ -82,6 +83,12 @@
#controls button:focus-visible,#controls input:focus-visible{outline:3px solid var(--accent);outline-offset:3px}
#scrub{flex:1;accent-color:#FF3D00;height:24px}
#tc{min-width:120px;text-align:right}
/* The existing OD export bridge marks frame capture; keep preview controls out. */
html.od-export body { margin:0!important; padding:0!important; gap:0!important; }
html.od-export #controls { display:none!important; }
html.od-export #viewport { position:fixed!important; inset:0!important; width:100vw!important; height:100vh!important; display:block!important; }
html.od-export #scaler, html.od-export #main { position:absolute!important; left:0!important; top:0!important; margin:0!important; transform:none!important; }
</style>
</head>
<body>
@@ -276,12 +283,7 @@ const sx = p => SL.left + p*SL.w;
tl.to('#cursor',{x:sx(0.2),y:SL.y,duration:0.35,ease:'power2.inOut'},7.8);
tl.to('#cursor',{scale:0.82,duration:0.08},8.15);
tl.to('#cursor',{x:sx(0.72),duration:0.7,ease:'power2.inOut'},8.25);
tl.to(ui,{p:0.72,duration:0.7,ease:'power2.inOut',
onUpdate(){
gsap.set('#sKnob',{x:sx(ui.p)});
gsap.set('#sFill',{scaleX:ui.p});
$('#sRead').textContent = Math.round(ui.p*100)+'%';
}},8.25);
tl.to(ui,{p:0.72,duration:0.7,ease:'power2.inOut'},8.25);
tl.to('#cursor',{scale:1,duration:0.12},8.95);
tl.to('#cursor',{x:CFG.cardBtn.x+40,y:CFG.cardBtn.y,duration:0.4,ease:'power2.inOut'},9.0);
tl.to('#cursor',{scale:0.82,duration:0.08},9.4);
@@ -308,13 +310,7 @@ CFG.bars.forEach((_,i)=>{
tl.to(['#bigNum','#bigCap'],{opacity:1,duration:0.3,ease:'power2.out'},10.4);
tl.to('#bigNote',{opacity:1,duration:0.3},11.2);
const dp = { p:0 };
tl.to(dp,{p:1,duration:0.9,ease:'power2.inOut',
onUpdate(){
path.style.strokeDashoffset = pathLen*(1-dp.p);
const pt = path.getPointAtLength(pathLen*dp.p);
gsap.set('#morph',{x:pt.x,y:pt.y});
$('#bigNum').textContent = '+'+Math.round(dp.p*248)+'%';
}},10.8);
tl.to(dp,{p:1,duration:0.9,ease:'power2.inOut'},10.8);
tl.fromTo('#rip1',{x:lastX.x,y:lastX.y,scale:0.4,opacity:1},{scale:3,opacity:0,duration:0.5,ease:'power2.out'},11.7);
tl.to('#morph',{scale:1.5,duration:0.15,ease:'power2.out',yoyo:true,repeat:1},11.7);
@@ -326,7 +322,7 @@ CFG.bars.forEach((_,i)=>{
});
/* Beat 5 — signature (12.4 – 13.0, then hold to 15.0) */
tl.to('#morph',{x:()=>offsetCenter($('#p2')).x,y:()=>offsetCenter($('#p2')).y,width:36,height:36,borderRadius:18,scale:1,duration:0.55,ease:'power3.inOut'},12.4);
tl.fromTo('#morph',{x:lastX.x,y:lastX.y},{x:()=>offsetCenter($('#p2')).x,y:()=>offsetCenter($('#p2')).y,width:36,height:36,borderRadius:18,scale:1,duration:0.55,ease:'power3.inOut',immediateRender:false},12.4);
tl.set('#sigWrap',{opacity:1},12.4);
tl.to('#sigText',{yPercent:0,duration:0.5,ease:'power4.out'},12.45);
tl.set('#frame',{opacity:1},12.4);
@@ -340,6 +336,17 @@ const fmt = t => {
};
tl.eventCallback('onUpdate',()=>{
const t = clock.t;
// Rebuild every procedural layer here so suppressed export seeks can use
// the same renderer via the per-frame loop below, including backward seeks.
gsap.set('#sKnob',{x:sx(ui.p)});
gsap.set('#sFill',{scaleX:ui.p});
$('#sRead').textContent = Math.round(ui.p*100)+'%';
path.style.strokeDashoffset = pathLen*(1-dp.p);
$('#bigNum').textContent = '+'+Math.round(dp.p*248)+'%';
if(t>=10.8 && t<12.4){
const pt = path.getPointAtLength(pathLen*dp.p);
gsap.set('#morph',{x:pt.x,y:pt.y});
}
$('#tcStage').textContent = fmt(t);
gsap.set('#progressFill',{scaleX:t/CFG.duration});
});
@@ -391,6 +398,9 @@ sync();
var drawnAt = null;
tl.eventCallback('onUpdate', function () { drawnAt = tl.time(); return draw.apply(this, arguments); });
(function loop() {
if (window.__odFrameRenderer && !document.documentElement.classList.contains('od-export')) {
document.documentElement.classList.add('od-export');
}
var t = tl.time();
if (t !== drawnAt) { drawnAt = t; draw(); }
requestAnimationFrame(loop);