mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(skills): remotion-to-hyperframes corpus T3 (4/7)
Adds the data-driven tier — a purpose-built fixture (option 2 from the stack discussion, not a port of PR #214's examples/remotion-full/) that exercises the realistic shape of a production Remotion composition without using the runtime adapter. Stargazed.tsx (10s @ 30fps, 1280x720): Sequence 0-3s TitleScene (title + subtitle) Sequence 3-7s StatsScene (3 reused StatCards staggered 12 frames apart) Sequence 7-10s OutroScene (UnderlinedText with scaleX-from-left underline) Composition shape exercises: - <Composition schema={z.object({...})} defaultProps={...} /> - nested array prop (stats[]) materialized as repeated HTML - custom React subcomponents (StatCard, AnimatedNumber, UnderlinedText) reused with different props - per-instance delay via prop (delayInFrames -> GSAP timeline offset) - frame-driven count-up (AnimatedNumber, manual cubic ease-out) - two different spring configs in the same composition (damping:12 -> back.out(1.4), damping:14 -> back.out(1.2)) - useCurrentFrame, useVideoConfig Translation choices documented in README.md and expected.json: - Zod props -> data-* on root #stage div - Custom subcomponents inline as repeated HTML using prop interface as the template - AnimatedNumber's frame-driven count-up -> GSAP onUpdate tween on a { v: 0 } counter object, ease power3.out - Two different spring configs -> two different back.out overshoots (1.4 vs 1.2 approximates the damping difference) - delayInFrames={i * 12} -> GSAP offset (i * 0.4)s Validated end-to-end: rendered Remotion baseline + HF translation, ran scripts/render_diff.sh. measured mean SSIM 0.953 measured min SSIM 0.927 measured p05 SSIM 0.938 threshold 0.90 (~0.04 below p05) The wider gap vs T1/T2 reflects T3's bigger approximation budget (2 spring instances + count-up timing + font fallback on multiple text sizes). Mean SSIM below 0.90 = structural mismatch (wrong durations, wrong stagger, missing prop wiring), not approximation drift. Same Remotion config as PR 3: setVideoImageFormat("png") + setColorSpace("bt709") to match HF's yuv420p output. Lint: 9 files scanned, 0 blockers / 0 warnings / 0 infos. oxlint, oxfmt, typecheck all pass. The fixture is not yet wired into CI; render + diff is documented in README.md and runs by hand via the harness from PR 2. PR 7's orchestrator will wire all four tiers into a CI eval run.
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
# Render output
|
||||
remotion-src/out/
|
||||
hf-src/out/
|
||||
hf.mp4
|
||||
diff/
|
||||
strip/
|
||||
|
||||
# Remotion / HF dependencies
|
||||
node_modules/
|
||||
package-lock.json
|
||||
@@ -0,0 +1,85 @@
|
||||
# Tier 3 — stargazed-data-driven
|
||||
|
||||
## What it tests
|
||||
|
||||
A purpose-built data-driven fixture that exercises the realistic shape of a
|
||||
production Remotion composition without using the runtime adapter from
|
||||
PR #214. If a translation passes T3, the skill correctly handles:
|
||||
|
||||
- A `<Composition>` with a `z.object` schema and typed `defaultProps`
|
||||
- Custom React subcomponents reused with different props across scenes
|
||||
- A nested data structure (`stats[]`) materialized as repeated HTML with
|
||||
per-instance attributes
|
||||
- A frame-driven count-up animation (`AnimatedNumber` → GSAP `onUpdate`)
|
||||
- Two different `spring` configs translated to two different `back.out` overshoots
|
||||
- Per-instance delays via component props (`delayInFrames` → GSAP timeline offsets)
|
||||
|
||||
## Composition shape
|
||||
|
||||
```
|
||||
Stargazed (10 s @ 30 fps, 1280×720)
|
||||
├── Sequence 0–3 s TitleScene
|
||||
│ ├── title ← spring scale
|
||||
│ └── subtitle ← linear fade
|
||||
├── Sequence 3–7 s StatsScene
|
||||
│ ├── StatCard "Stars" 1247 #fbbf24 (delay 0 frames)
|
||||
│ ├── StatCard "Forks" 312 #60a5fa (delay 12 frames)
|
||||
│ └── StatCard "Issues" 48 #f87171 (delay 24 frames)
|
||||
└── Sequence 7–10 s OutroScene
|
||||
└── UnderlinedText "thanks for watching" ← scale-in underline
|
||||
```
|
||||
|
||||
Each `StatCard` is a custom subcomponent that internally uses `AnimatedNumber`
|
||||
to count from 0 to the target. `AnimatedNumber` itself derives the displayed
|
||||
value from `useCurrentFrame()` + a manual `1 - (1 - t)^3` ease.
|
||||
|
||||
## The lossy parts (and why threshold = 0.90)
|
||||
|
||||
1. **`spring → back.out(N)`**: two different spring configs in this composition.
|
||||
- `{ damping: 12, stiffness: 100, mass: 1 }` (title) → `back.out(1.4)`
|
||||
- `{ damping: 14, stiffness: 90, mass: 1 }` (stat card) → `back.out(1.2)`
|
||||
|
||||
Overshoot ratio (1.4 vs 1.2) approximates the damping difference. The
|
||||
late-tail curve of GSAP's back ease and Remotion's spring don't match
|
||||
exactly — costs ~0.03 mean SSIM per spring instance.
|
||||
|
||||
2. **Count-up easing**: `AnimatedNumber` uses `1 - (1 - t)^3` (cubic ease-out)
|
||||
manually computed in the component. GSAP's `power3.out` is the same curve
|
||||
shape — should match closely. The displayed integer is rounded each frame
|
||||
in both renderers; minor mismatches occur when the rounded value flips
|
||||
between two numbers on a sub-frame timing difference.
|
||||
|
||||
3. **Font rendering**: same caveat as T1/T2. System Helvetica/Arial fallback
|
||||
produces minor anti-aliasing differences between renderers. Affects the
|
||||
stat card numbers (large weight 800) most.
|
||||
|
||||
A mean SSIM below 0.90 in T3 indicates a _structural_ mismatch (wrong scene
|
||||
durations, wrong stagger timing, missing prop wiring), not approximation
|
||||
drift. That's the failure signal we care about. The calibrated mean against
|
||||
Remotion @ 4.0 with PNG/BT.709 output is 0.953.
|
||||
|
||||
## Translation walk-through (skill cheat sheet)
|
||||
|
||||
| Remotion | HyperFrames |
|
||||
| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
|
||||
| `<Composition schema={z.object({...})} defaultProps={...} />` | data-\* attributes on root `#stage` div |
|
||||
| nested array prop (`stats[]`) | repeated HTML markup with per-instance `data-*` attrs |
|
||||
| custom React subcomponent | inline repeated HTML using the component's prop interface as the template |
|
||||
| `<AnimatedNumber from={0} to={value} dur={45} />` (cubic ease-out count-up) | tween on `{ v: 0 }` object with `onUpdate` rewriting `textContent`, ease `power3.out` |
|
||||
| `spring({damping:12, stiffness:100})` | `back.out(1.4)` over ~0.7 s |
|
||||
| `spring({damping:14, stiffness:90})` | `back.out(1.2)` over ~0.7 s |
|
||||
| `delayInFrames={i * 12}` (per-instance) | GSAP timeline offset `(i * 0.4)` s |
|
||||
| `useVideoConfig()` to get `fps` | dropped — composition fps is in `data-fps` on `#stage` |
|
||||
|
||||
## How to render and evaluate
|
||||
|
||||
```bash
|
||||
# Render Remotion baseline (no setup.sh — no binary assets in this fixture)
|
||||
cd remotion-src && npm install && npm run render
|
||||
|
||||
# Render HyperFrames translation
|
||||
cd ../hf-src && npx hyperframes render --output ../hf.mp4
|
||||
|
||||
# Compare
|
||||
../../../scripts/render_diff.sh ./remotion-src/out/baseline.mp4 ./hf.mp4 ./diff
|
||||
```
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"tier": 3,
|
||||
"name": "stargazed-data-driven",
|
||||
"composition_id": "Stargazed",
|
||||
"description": "Data-driven 10-second composition with three scenes, custom React subcomponents reused across scenes, a Zod schema with defaultProps, and a count-up number animation. Translates the realistic shape of a production Remotion composition into HF — without using the runtime adapter from PR #214.",
|
||||
"duration_seconds": 10,
|
||||
"fps": 30,
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"ssim_threshold": 0.9,
|
||||
"validation": {
|
||||
"measured_mean_ssim": 0.953,
|
||||
"measured_min_ssim": 0.927,
|
||||
"measured_p05_ssim": 0.938,
|
||||
"measured_p95_ssim": 0.977,
|
||||
"measured_at": "2026-04-27",
|
||||
"measured_against": "remotion@4.0 (PNG output, BT.709) vs hyperframes@0.4.15-alpha.1",
|
||||
"notes": "Count-up timing in StatsScene shows a few-frame offset between Remotion's manual 1-(1-t)^3 and GSAP's power3.out — both formulas are identical, so the offset comes from sub-frame timing of when the seek + onUpdate fire. Final values converge correctly. Visible only as transient digit mismatches mid-animation; no SSIM impact above the noise floor."
|
||||
},
|
||||
"remotion_apis_exercised": [
|
||||
"Composition with z.object schema and typed defaultProps",
|
||||
"Sequence (3 nested with computed offsets)",
|
||||
"AbsoluteFill",
|
||||
"useCurrentFrame, useVideoConfig",
|
||||
"interpolate (single-segment, multi-segment, with extrapolation)",
|
||||
"spring (two configs: damping:12 and damping:14)",
|
||||
"Custom React subcomponents reused with different props (StatCard ×3)",
|
||||
"Custom React utility component (AnimatedNumber for count-up)",
|
||||
"Custom React utility component (UnderlinedText)",
|
||||
"Per-instance delay via prop (delayInFrames)"
|
||||
],
|
||||
"translation_notes": [
|
||||
"Zod schema + defaultProps → data-* attributes on the root #stage div. The skill emits one data attribute per scalar prop; nested arrays (stats[]) get materialized as repeated HTML markup with per-instance data attributes (data-stat-index, data-stat-value, --card-color).",
|
||||
"Custom React subcomponents inline as repeated HTML divs. The component prop interface becomes the repeated markup template. This is lossy for components with internal state — fine here because StatCard, AnimatedNumber, UnderlinedText all derive from props alone.",
|
||||
"AnimatedNumber's frame-driven count-up → a GSAP tween on a { v: 0 } counter object with onUpdate rewriting textContent. GSAP's power3.out is cubic easeOut, matching the Remotion 1-(1-t)^3 manual ease.",
|
||||
"Two different springs in this composition: damping:12 → back.out(1.4) (snappy), damping:14 → back.out(1.2) (calmer). The 1.4 vs 1.2 overshoot ratio approximates the damping difference.",
|
||||
"Per-instance stagger via delayInFrames prop translates to a GSAP timeline offset of (i * 0.4)s.",
|
||||
"Threshold 0.90 reflects: spring → back.out approximation (×2 different configs), the count-up easing curve match (very close but not identical due to sub-frame seek timing), font/AA differences on body text. SSIM well below 0.90 indicates a structural mismatch, not approximation drift."
|
||||
],
|
||||
"rationale": "Threshold 0.90 sits ~0.04 below measured p05 (0.938). The wider gap vs T1/T2 reflects T3's bigger approximation budget (2 spring instances + count-up timing + font fallback on multiple text sizes). Mean SSIM below 0.90 = structural mismatch (wrong durations, wrong stagger, missing prop wiring), not approximation drift."
|
||||
}
|
||||
+269
@@ -0,0 +1,269 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>tier-3-data-driven</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 1280px;
|
||||
height: 720px;
|
||||
overflow: hidden;
|
||||
background: #0a0a0a;
|
||||
font-family: Helvetica, Arial, sans-serif;
|
||||
color: #ffffff;
|
||||
}
|
||||
.scene {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Scene 1 — Title */
|
||||
.scene-1 {
|
||||
flex-direction: column;
|
||||
}
|
||||
.scene-1 .title {
|
||||
font-size: 160px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.05em;
|
||||
transform: scale(0);
|
||||
}
|
||||
.scene-1 .subtitle {
|
||||
font-size: 36px;
|
||||
font-weight: 400;
|
||||
color: #9ca3af;
|
||||
margin-top: 24px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Scene 2 — Stats */
|
||||
.scene-2 {
|
||||
gap: 48px;
|
||||
}
|
||||
.stat-card {
|
||||
width: 280px;
|
||||
height: 220px;
|
||||
background: #1a1a1a;
|
||||
border-radius: 16px;
|
||||
border: 2px solid var(--card-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
opacity: 0;
|
||||
transform: scale(0);
|
||||
}
|
||||
.stat-card .number {
|
||||
font-size: 72px;
|
||||
font-weight: 800;
|
||||
color: var(--card-color);
|
||||
line-height: 1;
|
||||
}
|
||||
.stat-card .label {
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
color: #9ca3af;
|
||||
margin-top: 16px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
/* Scene 3 — Outro */
|
||||
.scene-3 .outro-wrap {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
opacity: 0;
|
||||
}
|
||||
.scene-3 .outro-text {
|
||||
font-size: 80px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.scene-3 .outro-underline {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: -8px;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: #fbbf24;
|
||||
border-radius: 3px;
|
||||
transform: scaleX(0);
|
||||
transform-origin: left center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="stage"
|
||||
data-composition-id="tier-3-data-driven"
|
||||
data-start="0"
|
||||
data-width="1280"
|
||||
data-height="720"
|
||||
data-duration="10"
|
||||
data-fps="30"
|
||||
data-title="STARGAZED"
|
||||
data-subtitle="by HeyGen"
|
||||
data-outro="thanks for watching"
|
||||
>
|
||||
<!-- Scene 1: Title -->
|
||||
<div
|
||||
id="scene-1"
|
||||
class="scene scene-1 clip"
|
||||
data-start="0"
|
||||
data-duration="3"
|
||||
data-track-index="0"
|
||||
>
|
||||
<div class="title">STARGAZED</div>
|
||||
<div class="subtitle">by HeyGen</div>
|
||||
</div>
|
||||
|
||||
<!-- Scene 2: Stats — three StatCards as repeated markup with per-instance data attrs -->
|
||||
<div
|
||||
id="scene-2"
|
||||
class="scene scene-2 clip"
|
||||
data-start="3"
|
||||
data-duration="4"
|
||||
data-track-index="0"
|
||||
>
|
||||
<div
|
||||
class="stat-card"
|
||||
data-stat-index="0"
|
||||
data-stat-value="1247"
|
||||
style="--card-color: #fbbf24"
|
||||
>
|
||||
<div class="number">0</div>
|
||||
<div class="label">Stars</div>
|
||||
</div>
|
||||
<div
|
||||
class="stat-card"
|
||||
data-stat-index="1"
|
||||
data-stat-value="312"
|
||||
style="--card-color: #60a5fa"
|
||||
>
|
||||
<div class="number">0</div>
|
||||
<div class="label">Forks</div>
|
||||
</div>
|
||||
<div
|
||||
class="stat-card"
|
||||
data-stat-index="2"
|
||||
data-stat-value="48"
|
||||
style="--card-color: #f87171"
|
||||
>
|
||||
<div class="number">0</div>
|
||||
<div class="label">Issues</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scene 3: Outro -->
|
||||
<div
|
||||
id="scene-3"
|
||||
class="scene scene-3 clip"
|
||||
data-start="7"
|
||||
data-duration="3"
|
||||
data-track-index="0"
|
||||
>
|
||||
<div class="outro-wrap">
|
||||
<div class="outro-text">thanks for watching</div>
|
||||
<div class="outro-underline"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Translation of Stargazed.tsx
|
||||
//
|
||||
// Remotion structure:
|
||||
// <Composition schema={zod} defaultProps={...} fps=30 dur=300>
|
||||
// <Sequence 0..90> <TitleScene title subtitle />
|
||||
// <Sequence 90..210> <StatsScene stats />
|
||||
// <Sequence 210..300><OutroScene text />
|
||||
//
|
||||
// HF structure:
|
||||
// - root #stage carries data-* with the same props (title, subtitle, outro)
|
||||
// - 3 scene divs with data-start/data-duration in seconds (frames/fps)
|
||||
// - Custom React subcomponents inline as repeated HTML; their per-instance
|
||||
// props become data-* attributes on the markup
|
||||
// - Animation lives in a single paused GSAP timeline keyed by composition seconds
|
||||
//
|
||||
// Frame → time:
|
||||
// Sequence(0,90) → 0s..3s (title)
|
||||
// Sequence(90,210) → 3s..7s (stats; 4s window)
|
||||
// Sequence(210,300) → 7s..10s (outro)
|
||||
// Stagger(i*12) → i*0.4s (12 frames at 30fps)
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// ─── Scene 1: Title ─────────────────────────────────────────────────
|
||||
const scene1 = document.querySelector("#scene-1");
|
||||
const title = scene1.querySelector(".title");
|
||||
const subtitle = scene1.querySelector(".subtitle");
|
||||
|
||||
tl.set(scene1, { opacity: 1 }, 0);
|
||||
// spring({damping:12, stiffness:100, mass:1}) → back.out(1.4) ~0.7s
|
||||
tl.to(title, { scale: 1, duration: 0.7, ease: "back.out(1.4)" }, 0);
|
||||
// interpolate(frame, [20,40], [0,1]) at fps=30 → 0.667s..1.333s linear
|
||||
tl.fromTo(subtitle, { opacity: 0 }, { opacity: 1, duration: 0.667, ease: "none" }, 0.667);
|
||||
tl.set(scene1, { opacity: 0 }, 3);
|
||||
|
||||
// ─── Scene 2: Stats ─────────────────────────────────────────────────
|
||||
const scene2 = document.querySelector("#scene-2");
|
||||
const cards = scene2.querySelectorAll(".stat-card");
|
||||
|
||||
tl.set(scene2, { opacity: 1 }, 3);
|
||||
|
||||
cards.forEach((card, i) => {
|
||||
const stagger = i * 0.4; // i * 12 frames at 30 fps
|
||||
const start = 3 + stagger;
|
||||
const value = Number(card.dataset.statValue);
|
||||
const numberEl = card.querySelector(".number");
|
||||
|
||||
// StatCard entrance:
|
||||
// spring({damping:14, stiffness:90, mass:1}) → back.out(1.2) ~0.7s
|
||||
// interpolate(local, [0,12], [0,1]) → 0s..0.4s linear opacity
|
||||
tl.to(card, { scale: 1, duration: 0.7, ease: "back.out(1.2)" }, start);
|
||||
tl.to(card, { opacity: 1, duration: 0.4, ease: "none" }, start);
|
||||
|
||||
// AnimatedNumber: count from 0 → value with easeOutCubic over 45 frames (1.5s).
|
||||
// GSAP equivalent: a tween on a counter object with onUpdate rewriting textContent.
|
||||
// power3.out is GSAP's name for cubic easeOut.
|
||||
const counter = { v: 0 };
|
||||
tl.to(
|
||||
counter,
|
||||
{
|
||||
v: value,
|
||||
duration: 1.5,
|
||||
ease: "power3.out",
|
||||
onUpdate: () => {
|
||||
numberEl.textContent = Math.round(counter.v).toLocaleString();
|
||||
},
|
||||
},
|
||||
start,
|
||||
);
|
||||
});
|
||||
|
||||
tl.set(scene2, { opacity: 0 }, 7);
|
||||
|
||||
// ─── Scene 3: Outro ─────────────────────────────────────────────────
|
||||
const scene3 = document.querySelector("#scene-3");
|
||||
const outroWrap = scene3.querySelector(".outro-wrap");
|
||||
const underline = scene3.querySelector(".outro-underline");
|
||||
|
||||
tl.set(scene3, { opacity: 1 }, 7);
|
||||
// interpolate(frame, [0,12], [0,1]) → 0s..0.4s linear opacity
|
||||
tl.to(outroWrap, { opacity: 1, duration: 0.4, ease: "none" }, 7);
|
||||
// interpolate(frame, [10,40], [0,1]) for underline → starts at 7+0.333s, dur 1s
|
||||
tl.to(underline, { scaleX: 1, duration: 1.0, ease: "none" }, 7.333);
|
||||
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["tier-3-data-driven"] = tl;
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "tier-3-data-driven-remotion",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"render": "remotion render Stargazed out/baseline.mp4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@remotion/cli": "^4.0.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"remotion": "^4.0.0",
|
||||
"zod": "^3.22.0"
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { Config } from "@remotion/cli/config";
|
||||
|
||||
// Match HyperFrames' default render so SSIM diffs measure translation
|
||||
// fidelity, not encoder differences.
|
||||
//
|
||||
// setVideoImageFormat("png") avoids the JPEG limited-range/full-range
|
||||
// colorspace flag (yuvj420p vs yuv420p) that otherwise costs ~0.05 SSIM.
|
||||
//
|
||||
// setColorSpace("bt709") matches HF's BT.709 SDR output.
|
||||
Config.setVideoImageFormat("png");
|
||||
Config.setColorSpace("bt709");
|
||||
Config.setOverwriteOutput(true);
|
||||
Config.setConcurrency(1);
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { Composition } from "remotion";
|
||||
import { z } from "zod";
|
||||
import { Stargazed, stargazedSchema } from "./Stargazed";
|
||||
|
||||
const defaultProps: z.infer<typeof stargazedSchema> = {
|
||||
title: "STARGAZED",
|
||||
subtitle: "by HeyGen",
|
||||
stats: [
|
||||
{ label: "Stars", value: 1247, color: "#fbbf24" },
|
||||
{ label: "Forks", value: 312, color: "#60a5fa" },
|
||||
{ label: "Issues", value: 48, color: "#f87171" },
|
||||
],
|
||||
outro: "thanks for watching",
|
||||
};
|
||||
|
||||
export const RemotionRoot = () => (
|
||||
<Composition
|
||||
id="Stargazed"
|
||||
component={Stargazed}
|
||||
schema={stargazedSchema}
|
||||
durationInFrames={300}
|
||||
fps={30}
|
||||
width={1280}
|
||||
height={720}
|
||||
defaultProps={defaultProps}
|
||||
/>
|
||||
);
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import { AbsoluteFill, Sequence } from "remotion";
|
||||
import { z } from "zod";
|
||||
import { TitleScene } from "./scenes/TitleScene";
|
||||
import { StatsScene } from "./scenes/StatsScene";
|
||||
import { OutroScene } from "./scenes/OutroScene";
|
||||
|
||||
export const stargazedSchema = z.object({
|
||||
title: z.string(),
|
||||
subtitle: z.string(),
|
||||
stats: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.number(),
|
||||
color: z.string(),
|
||||
}),
|
||||
),
|
||||
outro: z.string(),
|
||||
});
|
||||
|
||||
export const Stargazed: React.FC<z.infer<typeof stargazedSchema>> = ({
|
||||
title,
|
||||
subtitle,
|
||||
stats,
|
||||
outro,
|
||||
}) => (
|
||||
<AbsoluteFill style={{ backgroundColor: "#0a0a0a" }}>
|
||||
<Sequence from={0} durationInFrames={90}>
|
||||
<TitleScene title={title} subtitle={subtitle} />
|
||||
</Sequence>
|
||||
<Sequence from={90} durationInFrames={120}>
|
||||
<StatsScene stats={stats} />
|
||||
</Sequence>
|
||||
<Sequence from={210} durationInFrames={90}>
|
||||
<OutroScene text={outro} />
|
||||
</Sequence>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { interpolate, useCurrentFrame } from "remotion";
|
||||
|
||||
interface Props {
|
||||
from: number;
|
||||
to: number;
|
||||
durationInFrames: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts from `from` to `to` over `durationInFrames` with easeOut.
|
||||
* Driven entirely by useCurrentFrame — deterministic.
|
||||
*/
|
||||
export const AnimatedNumber: React.FC<Props> = ({ from, to, durationInFrames }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const t = interpolate(frame, [0, durationInFrames], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
// Ease-out cubic — fast start, slow finish, matches the ramp on data dashboards.
|
||||
const eased = 1 - (1 - t) ** 3;
|
||||
const value = Math.round(from + (to - from) * eased);
|
||||
return <>{value.toLocaleString()}</>;
|
||||
};
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import { interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { AnimatedNumber } from "./AnimatedNumber";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
delayInFrames: number;
|
||||
}
|
||||
|
||||
export const StatCard: React.FC<Props> = ({ label, value, color, delayInFrames }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const local = frame - delayInFrames;
|
||||
|
||||
const scale = spring({
|
||||
frame: local,
|
||||
fps,
|
||||
config: { damping: 14, stiffness: 90, mass: 1 },
|
||||
});
|
||||
const opacity = interpolate(local, [0, 12], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: 280,
|
||||
height: 220,
|
||||
background: "#1a1a1a",
|
||||
borderRadius: 16,
|
||||
border: `2px solid ${color}`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
opacity,
|
||||
transform: `scale(${scale})`,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 72, fontWeight: 800, color, lineHeight: 1 }}>
|
||||
{local >= 0 ? <AnimatedNumber from={0} to={value} durationInFrames={45} /> : 0}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 24,
|
||||
fontWeight: 500,
|
||||
color: "#9ca3af",
|
||||
marginTop: 16,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.1em",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { interpolate, useCurrentFrame } from "remotion";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export const UnderlinedText: React.FC<Props> = ({ text, color }) => {
|
||||
const frame = useCurrentFrame();
|
||||
// Underline scales from left over 0-30 frames.
|
||||
const underlineScaleX = interpolate(frame, [10, 40], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
const opacity = interpolate(frame, [0, 12], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", display: "inline-block", opacity }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 80,
|
||||
fontWeight: 600,
|
||||
color: "#ffffff",
|
||||
fontFamily: "Helvetica, Arial, sans-serif",
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
bottom: -8,
|
||||
width: "100%",
|
||||
height: 6,
|
||||
background: color,
|
||||
transform: `scaleX(${underlineScaleX})`,
|
||||
transformOrigin: "left center",
|
||||
borderRadius: 3,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import { registerRoot } from "remotion";
|
||||
import { RemotionRoot } from "./Root";
|
||||
|
||||
registerRoot(RemotionRoot);
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { AbsoluteFill } from "remotion";
|
||||
import { UnderlinedText } from "../components/UnderlinedText";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export const OutroScene: React.FC<Props> = ({ text }) => (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
fontFamily: "Helvetica, Arial, sans-serif",
|
||||
}}
|
||||
>
|
||||
<UnderlinedText text={text} color="#fbbf24" />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { AbsoluteFill } from "remotion";
|
||||
import { StatCard } from "../components/StatCard";
|
||||
|
||||
interface Stat {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
stats: Stat[];
|
||||
}
|
||||
|
||||
export const StatsScene: React.FC<Props> = ({ stats }) => (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: 48,
|
||||
flexDirection: "row",
|
||||
fontFamily: "Helvetica, Arial, sans-serif",
|
||||
}}
|
||||
>
|
||||
{stats.map((stat, i) => (
|
||||
<StatCard
|
||||
key={stat.label}
|
||||
label={stat.label}
|
||||
value={stat.value}
|
||||
color={stat.color}
|
||||
delayInFrames={i * 12}
|
||||
/>
|
||||
))}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
export const TitleScene: React.FC<Props> = ({ title, subtitle }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const titleScale = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: { damping: 12, stiffness: 100, mass: 1 },
|
||||
});
|
||||
const subtitleOpacity = interpolate(frame, [20, 40], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
flexDirection: "column",
|
||||
fontFamily: "Helvetica, Arial, sans-serif",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 160,
|
||||
fontWeight: 900,
|
||||
color: "#ffffff",
|
||||
letterSpacing: "0.05em",
|
||||
transform: `scale(${titleScale})`,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 36,
|
||||
fontWeight: 400,
|
||||
color: "#9ca3af",
|
||||
marginTop: 24,
|
||||
opacity: subtitleOpacity,
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2018",
|
||||
"module": "ESNext",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "node",
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user