feat(video): convert motion compositions from Remotion TSX to HyperFrames HTML

This commit is contained in:
Renn F
2026-07-06 02:03:28 +02:00
parent 4ba5cb0eb5
commit 3e55716eea
17 changed files with 2131 additions and 2043 deletions
+4
View File
@@ -107,6 +107,10 @@ panel/.env.*.local
# motion/ (Remotion compositions) + remotion-renderer (its render sidecar)
motion/node_modules/
remotion-renderer/node_modules/
# hyperframes lint/preview generates index.html in each composition dir —
# regenerated locally as needed, not tracked (the sidecar renders
# <orientation>.html directly, never index.html)
motion/compositions/*/index.html
# Internal-only: strategy/scratch/reference dumps — never publish
docs/internal/
.pnpm-store/
+22 -18
View File
@@ -1,33 +1,37 @@
# motion
In-repo [Remotion](https://www.remotion.dev/) (v4) project. UX/UI devs author bespoke marketing-video compositions here on a normal delivery branch; once a composition merges, the orchestrator's render loop tars this directory and POSTs it to the `remotion-renderer` sidecar, which bundles + renders it to MP4. This package never renders itself — its own gate (`pnpm typecheck` + `pnpm test`) is static: type-correctness and a jsdom component-mount smoke. The render-truth check happens on the sidecar, against the merged code.
In-repo [HyperFrames](https://github.com/heygen-com/hyperframes) composition package. UX/UI devs author bespoke marketing-video compositions here on a normal delivery branch; once a composition merges, the orchestrator's render loop tars this directory and POSTs it to the `remotion-renderer` sidecar (T1 rewrote its render core to `@hyperframes/producer`), which renders the HTML to MP4 with Chromium. This package never renders itself — its own gate (`pnpm test`) is static: a vitest HTML-structure smoke against the authored files. The render-truth check happens on the sidecar, against the merged files.
## Adding a composition
1. Add `src/compositions/YourClip.tsx`, exporting the component, its props type, and a `calculateMetadata` function (see `ReleaseAnnouncement.tsx`).
2. Register it in `src/Root.tsx` with a unique `id` — that `id` is the `composition_id` the authoring dev passes to `propose_video`.
3. Add a same-shape test file (`YourClip.test.tsx`) asserting `calculateMetadata`'s dimensions and a `<Thumbnail>` mount smoke — copy `ReleaseAnnouncement.test.tsx` as a template.
4. `pnpm dev` opens Remotion Studio (`src/index.ts` as the entry) to preview locally. `pnpm typecheck` / `pnpm test` are the same checks CI runs.
Compositions live under `compositions/<composition_id>/`. Each composition is one directory containing the HTML/CSS/JS for that clip; one HTML file per orientation (the sidecar points `inputPath` at a single HTML file, so per-orientation branching belongs in its own file, not inside one file).
## `ReleaseAnnouncement` — inputProps shape
1. Create `compositions/<your_clip>/vertical.html` and `square.html` carrying the HyperFrames render params on `<html>`: `data-width`, `data-height`, `data-duration` (seconds), `data-fps`. Timed visible elements get `class="clip"` plus `data-start`, `data-duration`, and `data-track-index`.
2. Add a shared `theme.css` with `@font-face` declarations (fonts are vendored under `public/fonts/*.woff2` — never load from a CDN, the render is offline) and the color/type tokens for the clip.
3. Ship a `props.js` with default `window.__PROPS__` + `window.__ORIENTATION__` values for local preview. The sidecar OVERWRITES this file at render time with the real per-release values — your HTML loads it via `<script src="props.js"></script>` before any inline script that reads the globals.
4. Add `<your_clip>.test.js` (vitest) asserting the HTML-structure invariants — dimensions, props.js wiring, theme link, at least one `class="clip"` element, no CDN scripts. See `release-announcement.test.js` as a template.
5. `pnpm preview` (alias for `hyperframes preview <file>`) opens the local preview server; `pnpm lint` runs `hyperframes lint` over both orientations; `pnpm test` runs the smoke gate that CI runs.
```ts
type ReleaseAnnouncementProps = {
script: string; // one or two sentences — the voiceover-style hook
version: string; // e.g. "0.19.0" — rendered as "v0.19.0"
highlights: string[]; // shipped-feature bullets; only the first 4 render
orientation: "vertical" | "square";
};
## `release-announcement` — props shape
The composition reads `window.__PROPS__` (written into `props.js` by the sidecar at render time):
```js
{
script: string, // one or two sentences — the voiceover-style hook
version: string, // e.g. "0.19.0" — rendered as "v0.19.0"
highlights: string[],// shipped-feature bullets; only the first 4 render
}
```
`orientation` is what `calculateMetadata` reads to pick the frame: **1080×1920** for `"vertical"` (TikTok + X mobile) or **1080×1080** for `"square"` (X timeline). Width is always 1080 only the height (and so the available vertical canvas) changes. There is no width/height render parameter in Remotion; the orchestrator's `RemotionRenderer` sends the identical `inputProps` object to both `selectComposition` and `renderMedia`, and `calculateMetadata` branches on `inputProps.orientation` to return the right frame for whichever cut is being rendered (the sidecar calls `/render` twice, once per orientation).
`window.__ORIENTATION__` is also set by the sidecar, but each HTML file is for ONE orientation (the sidecar points `inputPath` directly at `compositions/release-announcement/vertical.html` or `square.html`), so the orientation is known at author time and there is no runtime branch. Vertical is **1080×1920** (TikTok + X mobile), square is **1080×1080** (X timeline). Width is always 1080; only the height (and so the available vertical canvas) changes. Square uses a tighter bottom pad (72) and scan-line top (72) than vertical (148 / 104) — those differences are baked into the respective HTML files.
## Design bar for future compositions
This composition is the library's reference point — match its restraint, don't reinvent the palette per clip. Dials (see the org's design-bar doctrine): **variance ~6, motion ~6, density ~4** — an energetic landing/marketing register, not a dense dashboard.
- **Color** — `src/theme.ts` is the single source: a near-black ink field (never pure `#000`), warm off-white text (never pure `#fff`), and **one** accent color used with intent (a label, a rule, a marker — not washed across the frame). Reuse `theme.ts`'s tokens; don't hardcode new hex values per composition.
- **Type** — **Share Tech Mono** (a single static weight, 400 Regular — no bold/italic exists for this family) for the one big headline moment, paired with **Inter** as the clean workhorse body face for everything else. Both are vendored under `public/fonts/*.woff2` and loaded via `@remotion/fonts`' `loadFont()` (see `src/theme.ts`) rather than fetched from Google Fonts at render time — rendering never depends on network access or on whatever fonts happen to be installed on the render host. At least two weights of the body face, so hierarchy comes from more than just size.
- **Motion** — entrances are `spring()`-driven (a little overshoot, quick settle — see `springConfig` in `ReleaseAnnouncement.tsx`), staggered across elements rather than all firing on frame 0. Keep one continuous ambient motion (here: the scanning accent line) so the frame is never fully static once entrances land. Every animation should earn its place — hierarchy, or a beat of pacing, not motion for its own sake.
- **Color** — `compositions/<id>/theme.css` is the single source: a near-black ink field (never pure `#000`), warm off-white text (never pure `#fff`), and **one** accent color used with intent (a label, a rule, a marker — not washed across the frame). Reuse the theme tokens; don't hardcode new hex values per composition.
- **Type** — **Share Tech Mono** (a single static weight, 400 Regular — no bold/italic exists for this family) for the one big headline moment, paired with **Inter** as the clean workhorse body face for everything else. Both are vendored under `public/fonts/*.woff2` and loaded via `@font-face` in `theme.css` rather than fetched from a CDN — rendering never depends on network access or on whatever fonts happen to be installed on the render host. At least two weights of the body face, so hierarchy comes from more than just size.
- **Motion** — entrances are CSS keyframe animations with a restrained easing (`cubic-bezier(0.22, 1, 0.36, 1)` — smooth settle, no overshoot, approximating Remotion's `spring({damping:17, mass:0.7, stiffness:140})` without the bounce), staggered across elements via `animation-delay` rather than all firing on frame 0. Keep one continuous ambient motion (here: the scanning accent line) so the frame is never fully static once entrances land. Every animation should earn its place — hierarchy, or a beat of pacing, not motion for its own sake.
- **Layout** — anchor content asymmetrically (this clip sits in the lower two-thirds, left-aligned); avoid a perfectly centered card, which reads as a generic template rather than a designed frame.
- **AI tells to avoid** — no default AI-purple gradient wash, no centered-everything, no emoji as design elements, no one-font-one-size, no em dash in on-screen copy (voiceover script, highlight bullets, kicker text) or filler verbs ("Elevate", "Seamless", "Unleash", "Next-Gen").
- **AI tells to avoid** — no default AI-purple gradient wash, no centered-everything, no emoji as design elements, no one-font-one-size, no em dash in on-screen copy (voiceover script, highlight bullets, kicker text) or filler verbs ("Elevate", "Seamless", "Unleash", "Next-Gen").
@@ -0,0 +1,13 @@
// Default sample props for local preview (npx hyperframes preview). The
// video-renderer sidecar OVERWRITES this file at render time with the real
// per-release values + window.__ORIENTATION__.
window.__PROPS__ = {
script: "Ship day. Faster renders, sharper docs, one less thing to babysit.",
version: "0.19.0",
highlights: [
"Fable-mode adopted fleet-wide",
"FE/UX-UI design bar shipped",
"Feature spotlight goes live on X",
],
};
window.__ORIENTATION__ = "vertical";
@@ -0,0 +1,55 @@
import { readFileSync, existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
// HTML-structure smoke test for the release-announcement composition. We
// parse the HTML as text and assert structural invariants — no jsdom mount,
// no rendering. The render-truth check lives on the sidecar; this test only
// guards the authoring-side contract: dimensions, props.js wiring, theme
// link, timed clips, and the offline-render constraint (no CDN scripts).
const here = dirname(fileURLToPath(import.meta.url));
const dir = here;
const read = (name) => readFileSync(join(dir, name), "utf8");
const orientations = [
{ file: "vertical.html", height: "1920" },
{ file: "square.html", height: "1080" },
];
describe("release-announcement composition", () => {
it("ships props.js (default preview values, sidecar overwrites at render)", () => {
expect(existsSync(join(dir, "props.js"))).toBe(true);
const src = read("props.js");
expect(src).toContain("window.__PROPS__");
expect(src).toContain("window.__ORIENTATION__");
});
it("ships theme.css", () => {
expect(existsSync(join(dir, "theme.css"))).toBe(true);
});
it.each(orientations)("$file parses with correct dimensions and wiring", ({ file, height }) => {
const html = read(file);
// HyperFrames render params on <html>.
expect(html).toContain('data-width="1080"');
expect(html).toContain(`data-height="${height}"`);
expect(html).toMatch(/data-duration="\d+"/);
expect(html).toMatch(/data-fps="\d+"/);
// props.js is loaded before the inline script reads window.__PROPS__.
expect(html).toContain('<script src="props.js"></script>');
// theme.css is linked.
expect(html).toContain('href="theme.css"');
// At least one timed clip element.
expect(html).toMatch(/class="[^"]*clip[^"]*"/);
// No CDN scripts — the render is offline. Fonts load via @font-face from
// ../../public/fonts/, never from a CDN.
expect(html).not.toMatch(/<script[^>]+src="https?:\/\//);
});
});
@@ -0,0 +1,78 @@
<!doctype html>
<html lang="en" data-composition-id="release-announcement" data-composition-duration="10" data-fps="30">
<head>
<meta charset="utf-8" />
<title>Release Announcement — square (1080×1080)</title>
<link rel="stylesheet" href="theme.css" />
<script src="props.js"></script>
</head>
<body>
<div id="stage" data-composition-id="release-announcement" data-width="1080" data-height="1080" data-start="0" data-duration="10" data-fps="30">
<!-- Scan line: thin hairline + travelling accent marker. Square cut
uses a tighter scan top (72) than the vertical cut (104). -->
<div id="scan" class="scan clip" data-start="0" data-duration="10" data-track-index="0" style="top: 72px;">
<div class="scan__marker"></div>
</div>
<!-- Content anchored in the lower two-thirds, left-aligned. Square cut
uses a tighter bottom pad (72) than the vertical cut (148). -->
<div class="content" style="--content-bottom: 72px;">
<div id="kicker" class="kicker clip" data-start="0" data-duration="10" data-track-index="1">New release</div>
<h1 id="headline" class="headline clip" data-start="0.267" data-duration="9.733" data-track-index="2">v0.19.0</h1>
<p id="script" class="script clip" data-start="0.8" data-duration="9.2" data-track-index="3">Ship day. Faster renders, sharper docs, one less thing to babysit.</p>
<ul id="highlights" class="highlights clip" data-start="1.667" data-duration="8.333" data-track-index="4">
<li class="highlights__item"><span class="highlights__tick"></span><span class="highlights__text">Fable-mode adopted fleet-wide</span></li>
<li class="highlights__item"><span class="highlights__tick"></span><span class="highlights__text">FE/UX-UI design bar shipped</span></li>
<li class="highlights__item"><span class="highlights__tick"></span><span class="highlights__text">Feature spotlight goes live on X</span></li>
<li class="highlights__item"><span class="highlights__tick"></span><span class="highlights__text"></span></li>
</ul>
</div>
<div id="outro" class="outro clip" data-start="7.667" data-duration="2.333" data-track-index="5">roboco.tech</div>
</div>
<script>
// Register the composition timeline metadata. Motion is driven by CSS
// keyframes (no GSAP — the render is offline, no CDN), but HyperFrames
// requires the registry entry to consider the composition valid.
window.__timelines = window.__timelines || {};
window.__timelines["release-announcement"] = {
id: "release-announcement",
duration: 10,
fps: 30,
animations: []
};
// Read sidecar-supplied props (props.js is overwritten at render time)
// and populate the DOM. Defensive: a missing prop leaves the placeholder
// text intact rather than throwing — a missing prop never crashes a render.
(function () {
var props = (window.__PROPS__ && typeof window.__PROPS__ === "object") ? window.__PROPS__ : {};
var version = props.version ? String(props.version) : "";
if (version && !version.startsWith("v")) version = "v" + version;
if (version) {
var headline = document.getElementById("headline");
if (headline) headline.textContent = version;
}
if (typeof props.script === "string" && props.script) {
var scriptEl = document.getElementById("script");
if (scriptEl) scriptEl.textContent = props.script;
}
var highlights = Array.isArray(props.highlights) ? props.highlights.slice(0, 4) : [];
var items = document.querySelectorAll(".highlights__item");
for (var i = 0; i < items.length; i++) {
var textEl = items[i].querySelector(".highlights__text");
if (!textEl) continue;
if (i < highlights.length && highlights[i]) {
textEl.textContent = String(highlights[i]);
} else {
items[i].style.display = "none";
}
}
})();
</script>
</body>
</html>
@@ -0,0 +1,187 @@
/* Shared visual language for the release-announcement composition. New
* compositions should import these tokens instead of inventing their own
* that's what keeps the library coherent as more clips merge in over time.
*
* One accent color, a near-black (never pure #000) ink field, warm off-white
* (never pure #fff) text. A display face for headlines paired with a body
* face for everything else. See motion/README.md for the design-bar rationale.
*/
@font-face {
font-family: "Share Tech Mono";
src: url("../../public/fonts/ShareTechMono-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: block;
}
@font-face {
font-family: "Inter";
src: url("../../public/fonts/Inter-Variable.woff2") format("woff2-variations");
font-weight: 400 600;
font-style: normal;
font-display: block;
}
:root {
--color-ink: #0B0B10;
--color-ink-raised: #15151C;
--color-paper: #F5F2EA;
--color-muted: #9C9891;
--color-accent: #FF5A1F;
--color-hairline: rgba(245, 242, 234, 0.12);
--font-display: "Share Tech Mono";
--font-body: "Inter";
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
width: 100%;
height: 100%;
background: var(--color-ink);
color: var(--color-paper);
font-family: var(--font-body);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
#stage {
position: relative;
width: 1080px;
height: 100%;
overflow: hidden;
background: var(--color-ink);
}
/* Scan line the one continuous ambient motion. A thin hairline near the
* top with a small accent marker that travels leftright across the whole
* clip, so the frame never goes fully static once entrances land. */
.scan {
position: absolute;
left: 84px;
right: 84px;
height: 2px;
background: var(--color-hairline);
}
.scan__marker {
position: absolute;
top: -3px;
left: 0;
width: 64px;
height: 8px;
border-radius: 4px;
background: var(--color-accent);
box-shadow: 0 0 28px var(--color-accent);
transform: translateX(-50%);
animation: scan-travel 10s linear forwards;
}
@keyframes scan-travel {
from { left: -12%; }
to { left: 112%; }
}
/* Content block anchored in the lower two-thirds, left-aligned. */
.content {
position: absolute;
left: 84px;
right: 96px;
bottom: var(--content-bottom, 148px);
}
.kicker {
font-family: var(--font-body);
font-size: 30px;
font-weight: 600;
letter-spacing: 6px;
text-transform: uppercase;
color: var(--color-accent);
margin-bottom: 20px;
opacity: 0;
transform: translateY(16px);
animation: enter-rise 0.9s cubic-bezier(0.22, 1, 0.36, 1) forwards;
animation-delay: 0s;
}
.headline {
font-family: var(--font-display);
font-size: 148px;
font-weight: 400;
line-height: 1;
color: var(--color-paper);
margin-bottom: 28px;
transform-origin: left bottom;
opacity: 0;
transform: translateY(22px) scale(0.94);
animation: enter-rise-scale 0.9s cubic-bezier(0.22, 1, 0.36, 1) forwards;
animation-delay: 0.267s; /* ~8 frames @30fps */
}
.script {
font-family: var(--font-body);
font-size: 44px;
font-weight: 500;
line-height: 1.25;
color: var(--color-paper);
max-width: 820px;
margin-bottom: 40px;
opacity: 0;
transform: translateY(22px);
animation: enter-rise 0.9s cubic-bezier(0.22, 1, 0.36, 1) forwards;
animation-delay: 0.8s; /* ~24 frames @30fps */
}
.highlights {
list-style: none;
padding: 0;
margin: 0;
}
.highlights__item {
display: flex;
align-items: center;
font-family: var(--font-body);
font-size: 34px;
font-weight: 400;
color: var(--color-paper);
margin-bottom: 18px;
opacity: 0;
transform: translateX(-36px);
animation: enter-slide 0.7s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
.highlights__item:nth-child(1) { animation-delay: 1.667s; } /* ~50 frames */
.highlights__item:nth-child(2) { animation-delay: 2.133s; } /* +14 frames */
.highlights__item:nth-child(3) { animation-delay: 2.6s; } /* +14 frames */
.highlights__item:nth-child(4) { animation-delay: 3.067s; } /* +14 frames */
.highlights__tick {
display: inline-block;
width: 22px;
height: 3px;
margin-right: 18px;
flex-shrink: 0;
background: var(--color-accent);
}
.outro {
position: absolute;
left: 84px;
bottom: 56px;
font-family: var(--font-body);
font-size: 26px;
font-weight: 500;
letter-spacing: 2px;
color: var(--color-muted);
opacity: 0;
transform: translateY(14px);
animation: enter-rise 0.8s cubic-bezier(0.22, 1, 0.36, 1) forwards;
animation-delay: 7.667s; /* durationInFrames - 70 = 230 frames @30fps */
}
@keyframes enter-rise {
to { opacity: 1; transform: translateY(0); }
}
@keyframes enter-rise-scale {
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes enter-slide {
to { opacity: 1; transform: translateX(0); }
}
@@ -0,0 +1,77 @@
<!doctype html>
<html lang="en" data-composition-id="release-announcement" data-composition-duration="10" data-fps="30">
<head>
<meta charset="utf-8" />
<title>Release Announcement — vertical (1080×1920)</title>
<link rel="stylesheet" href="theme.css" />
<script src="props.js"></script>
</head>
<body>
<div id="stage" data-composition-id="release-announcement" data-width="1080" data-height="1920" data-start="0" data-duration="10" data-fps="30">
<!-- Scan line: thin hairline + travelling accent marker. The one
continuous ambient motion so the frame never goes fully static. -->
<div id="scan" class="scan clip" data-start="0" data-duration="10" data-track-index="0" style="top: 104px;">
<div class="scan__marker"></div>
</div>
<!-- Content anchored in the lower two-thirds, left-aligned. -->
<div class="content" style="--content-bottom: 148px;">
<div id="kicker" class="kicker clip" data-start="0" data-duration="10" data-track-index="1">New release</div>
<h1 id="headline" class="headline clip" data-start="0.267" data-duration="9.733" data-track-index="2">v0.19.0</h1>
<p id="script" class="script clip" data-start="0.8" data-duration="9.2" data-track-index="3">Ship day. Faster renders, sharper docs, one less thing to babysit.</p>
<ul id="highlights" class="highlights clip" data-start="1.667" data-duration="8.333" data-track-index="4">
<li class="highlights__item"><span class="highlights__tick"></span><span class="highlights__text">Fable-mode adopted fleet-wide</span></li>
<li class="highlights__item"><span class="highlights__tick"></span><span class="highlights__text">FE/UX-UI design bar shipped</span></li>
<li class="highlights__item"><span class="highlights__tick"></span><span class="highlights__text">Feature spotlight goes live on X</span></li>
<li class="highlights__item"><span class="highlights__tick"></span><span class="highlights__text"></span></li>
</ul>
</div>
<div id="outro" class="outro clip" data-start="7.667" data-duration="2.333" data-track-index="5">roboco.tech</div>
</div>
<script>
// Register the composition timeline metadata. Motion is driven by CSS
// keyframes (no GSAP — the render is offline, no CDN), but HyperFrames
// requires the registry entry to consider the composition valid.
window.__timelines = window.__timelines || {};
window.__timelines["release-announcement"] = {
id: "release-announcement",
duration: 10,
fps: 30,
animations: []
};
// Read sidecar-supplied props (props.js is overwritten at render time)
// and populate the DOM. Defensive: a missing prop leaves the placeholder
// text intact rather than throwing — a missing prop never crashes a render.
(function () {
var props = (window.__PROPS__ && typeof window.__PROPS__ === "object") ? window.__PROPS__ : {};
var version = props.version ? String(props.version) : "";
if (version && !version.startsWith("v")) version = "v" + version;
if (version) {
var headline = document.getElementById("headline");
if (headline) headline.textContent = version;
}
if (typeof props.script === "string" && props.script) {
var scriptEl = document.getElementById("script");
if (scriptEl) scriptEl.textContent = props.script;
}
var highlights = Array.isArray(props.highlights) ? props.highlights.slice(0, 4) : [];
var items = document.querySelectorAll(".highlights__item");
for (var i = 0; i < items.length; i++) {
var textEl = items[i].querySelector(".highlights__text");
if (!textEl) continue;
if (i < highlights.length && highlights[i]) {
textEl.textContent = String(highlights[i]);
} else {
items[i].style.display = "none";
}
}
})();
</script>
</body>
</html>
+5 -17
View File
@@ -2,27 +2,15 @@
"name": "motion",
"version": "1.0.0",
"private": true,
"description": "RoboCo motion-video compositions (Remotion) — bespoke marketing clips authored per release, feature-spotlight, or on-demand request, rendered by the remotion-renderer sidecar.",
"description": "RoboCo motion-video compositions (HyperFrames) — bespoke marketing clips authored per release, feature-spotlight, or on-demand request, rendered by the video-renderer sidecar.",
"type": "module",
"scripts": {
"dev": "remotion studio src/index.ts",
"typecheck": "tsc --noEmit",
"preview": "hyperframes preview compositions/release-announcement",
"lint": "hyperframes lint compositions/release-announcement",
"test": "vitest run"
},
"dependencies": {
"@remotion/fonts": "^4.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"remotion": "^4.0.0"
},
"devDependencies": {
"@remotion/cli": "^4.0.0",
"@remotion/player": "^4.0.0",
"@testing-library/react": "^16.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"jsdom": "^29.0.0",
"typescript": "^6.0.0",
"hyperframes": "0.7.36",
"vitest": "^4.0.0"
}
}
}
+1687 -1568
View File
File diff suppressed because it is too large Load Diff
-9
View File
@@ -1,9 +0,0 @@
// Local authoring config (Remotion Studio / `remotion render` from a dev's
// workspace). The remotion-renderer sidecar does NOT read this file — it
// drives `@remotion/bundler` + `@remotion/renderer` programmatically and
// passes its own render options directly (see remotion-renderer/render.js).
import {Config} from "@remotion/cli/config";
Config.setVideoImageFormat("jpeg");
Config.setOverwriteOutput(true);
Config.setChromiumOpenGlRenderer("angle");
-43
View File
@@ -1,43 +0,0 @@
import React from "react";
import { Composition } from "remotion";
import {
calculateReleaseAnnouncementMetadata,
ReleaseAnnouncement,
type ReleaseAnnouncementProps,
} from "./compositions/ReleaseAnnouncement";
const releaseAnnouncementDefaultProps: ReleaseAnnouncementProps = {
script:
"Ship day. Faster renders, sharper docs, one less thing to babysit.",
version: "0.18.0",
highlights: [
"Fable-mode adopted fleet-wide",
"FE/UX-UI design bar shipped",
"Feature spotlight goes live on X",
],
orientation: "vertical",
};
export const RemotionRoot: React.FC = () => {
return (
<>
{/*
width/height/fps/durationInFrames below are the Studio-preview
defaults only. At bundle time the sidecar's selectComposition() +
renderMedia() calls both pass the real inputProps, and
calculateReleaseAnnouncementMetadata overrides width/height from
inputProps.orientation see that function's doc comment.
*/}
<Composition
id="ReleaseAnnouncement"
component={ReleaseAnnouncement}
durationInFrames={300}
fps={30}
width={1080}
height={1920}
defaultProps={releaseAnnouncementDefaultProps}
calculateMetadata={calculateReleaseAnnouncementMetadata}
/>
</>
);
};
@@ -1,68 +0,0 @@
import { Thumbnail } from "@remotion/player";
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
calculateReleaseAnnouncementMetadata,
ReleaseAnnouncement,
type ReleaseAnnouncementProps,
} from "./ReleaseAnnouncement";
const baseProps: Omit<ReleaseAnnouncementProps, "orientation"> = {
script: "Ship day. Faster renders, sharper docs, one less thing to babysit.",
version: "0.18.0",
highlights: [
"Fable-mode adopted fleet-wide",
"FE/UX-UI design bar shipped",
"Feature spotlight goes live on X",
],
};
describe("calculateReleaseAnnouncementMetadata", () => {
it("returns 1080x1920 for the vertical cut", async () => {
// calculateMetadata may be sync or async per Remotion's type — awaiting
// a plain (non-Promise) return value resolves it immediately either way.
const metadata = await calculateReleaseAnnouncementMetadata({
props: { ...baseProps, orientation: "vertical" },
defaultProps: { ...baseProps, orientation: "vertical" },
abortSignal: new AbortController().signal,
isRendering: false,
compositionId: "ReleaseAnnouncement",
});
expect(metadata.width).toBe(1080);
expect(metadata.height).toBe(1920);
});
it("returns 1080x1080 for the square cut", async () => {
const metadata = await calculateReleaseAnnouncementMetadata({
props: { ...baseProps, orientation: "square" },
defaultProps: { ...baseProps, orientation: "square" },
abortSignal: new AbortController().signal,
isRendering: false,
compositionId: "ReleaseAnnouncement",
});
expect(metadata.width).toBe(1080);
expect(metadata.height).toBe(1080);
});
});
describe("ReleaseAnnouncement", () => {
it.each(["vertical", "square"] as const)(
"mounts without throwing (%s)",
(orientation) => {
const height = orientation === "square" ? 1080 : 1920;
const { unmount, container } = render(
<Thumbnail
component={ReleaseAnnouncement}
compositionWidth={1080}
compositionHeight={height}
durationInFrames={300}
fps={30}
frameToDisplay={120}
inputProps={{ ...baseProps, orientation }}
/>,
);
expect(container).toBeTruthy();
unmount();
},
);
});
@@ -1,238 +0,0 @@
import type { CSSProperties } from "react";
import React from "react";
import type { CalculateMetadataFunction } from "remotion";
import {
AbsoluteFill,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
} from "remotion";
import { color, font } from "../theme";
export type Orientation = "vertical" | "square";
// A `type` alias, not an `interface`: Remotion's Composition/
// CalculateMetadataFunction generics constrain Props to
// `Record<string, unknown>`, which only object-literal type aliases satisfy
// structurally — an `interface` (open for declaration merging) does not.
export type ReleaseAnnouncementProps = {
/** One or two spoken/on-screen sentences — the voiceover-style hook. */
script: string;
/** Release version, e.g. "0.18.0" — rendered as "v0.18.0". */
version: string;
/** Shipped-feature bullets. Only the first 4 are shown. */
highlights: string[];
/** Drives calculateMetadata's aspect-ratio switch below. */
orientation: Orientation;
};
/**
* The whole reason this composition can produce two cuts from one timeline:
* width is always 1080, only height (and therefore the available vertical
* canvas) changes. No width/height render param exists in Remotion this
* is the documented mechanism (selectComposition + renderMedia both receive
* the identical inputProps object; this function reads inputProps.orientation
* off it and returns the matching frame).
*/
export const calculateReleaseAnnouncementMetadata: CalculateMetadataFunction<
ReleaseAnnouncementProps
> = ({ props }) => {
const isSquare = props.orientation === "square";
return {
width: 1080,
height: isSquare ? 1080 : 1920,
};
};
// One spring signature for every entrance in this composition — a
// consistent, restrained motion signature (slight overshoot, quick settle)
// rather than ad hoc easing per element.
const springConfig = { damping: 17, mass: 0.7, stiffness: 140 };
const enter = (frame: number, delayFrames: number, fps: number) =>
spring({ frame: frame - delayFrames, fps, config: springConfig });
const rise = (progress: number, distance = 22) =>
interpolate(progress, [0, 1], [distance, 0], {
extrapolateLeft: "clamp",
});
const fadeIn = (progress: number) =>
interpolate(progress, [0, 1], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const HIGHLIGHT_STAGGER = 14;
const HIGHLIGHTS_START = 50;
const MAX_HIGHLIGHTS = 4;
export const ReleaseAnnouncement: React.FC<ReleaseAnnouncementProps> = ({
script,
version,
highlights,
orientation,
}) => {
const frame = useCurrentFrame();
const { fps, durationInFrames } = useVideoConfig();
const isSquare = orientation === "square";
const versionLabel = version.startsWith("v") ? version : `v${version}`;
const visibleHighlights = highlights.slice(0, MAX_HIGHLIGHTS);
const kickerP = enter(frame, 0, fps);
const headlineP = enter(frame, 8, fps);
const scriptP = enter(frame, 24, fps);
const outroP = enter(frame, durationInFrames - 70, fps);
// A slow "signal scan" that runs the whole clip — the one piece of
// continuous ambient motion beyond the entrance beats, so the frame
// never goes fully static once everything has landed.
const scan = interpolate(frame, [0, durationInFrames], [-0.12, 1.12], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const bottomPad = isSquare ? 72 : 148;
const scanTop = isSquare ? 72 : 104;
const kickerStyle: CSSProperties = {
opacity: fadeIn(kickerP),
transform: `translateY(${rise(kickerP, 16)}px)`,
fontFamily: font.body,
fontSize: 30,
fontWeight: 600,
letterSpacing: 6,
textTransform: "uppercase",
color: color.accent,
marginBottom: 20,
};
const headlineStyle: CSSProperties = {
opacity: fadeIn(headlineP),
transform: `translateY(${rise(headlineP)}px) scale(${interpolate(
headlineP,
[0, 1],
[0.94, 1],
{ extrapolateLeft: "clamp" },
)})`,
transformOrigin: "left bottom",
fontFamily: font.display,
fontSize: 148,
// Share Tech Mono ships one static weight only (400) — no real bold to
// request; forcing 700 would just make Chromium synthesize a faux-bold.
fontWeight: 400,
lineHeight: 1,
color: color.paper,
margin: 0,
marginBottom: 28,
};
const scriptStyle: CSSProperties = {
opacity: fadeIn(scriptP),
transform: `translateY(${rise(scriptP)}px)`,
fontFamily: font.body,
fontSize: 44,
fontWeight: 500,
lineHeight: 1.25,
color: color.paper,
maxWidth: 820,
margin: 0,
marginBottom: 40,
};
const outroStyle: CSSProperties = {
position: "absolute",
bottom: 56,
left: 84,
opacity: fadeIn(outroP),
transform: `translateY(${rise(outroP, 14)}px)`,
fontFamily: font.body,
fontSize: 26,
fontWeight: 500,
letterSpacing: 2,
color: color.muted,
};
return (
<AbsoluteFill style={{ backgroundColor: color.ink }}>
<div
style={{
position: "absolute",
top: scanTop,
left: 84,
right: 84,
height: 2,
backgroundColor: color.hairline,
}}
>
<div
style={{
position: "absolute",
top: -3,
left: `${scan * 100}%`,
width: 64,
height: 8,
borderRadius: 4,
transform: "translateX(-50%)",
backgroundColor: color.accent,
boxShadow: `0 0 28px ${color.accent}`,
}}
/>
</div>
<AbsoluteFill
style={{
justifyContent: "flex-end",
paddingLeft: 84,
paddingRight: 96,
paddingBottom: bottomPad,
}}
>
<div style={kickerStyle}>New release</div>
<h1 style={headlineStyle}>{versionLabel}</h1>
<p style={scriptStyle}>{script}</p>
<ul style={{ listStyle: "none", margin: 0, padding: 0 }}>
{visibleHighlights.map((item, index) => {
const delay = HIGHLIGHTS_START + index * HIGHLIGHT_STAGGER;
const progress = enter(frame, delay, fps);
const itemStyle: CSSProperties = {
display: "flex",
alignItems: "center",
opacity: fadeIn(progress),
transform: `translateX(${interpolate(
progress,
[0, 1],
[-36, 0],
{ extrapolateLeft: "clamp" },
)}px)`,
marginBottom: 18,
fontFamily: font.body,
fontSize: 34,
fontWeight: 400,
color: color.paper,
};
return (
<li key={item} style={itemStyle}>
<span
style={{
display: "inline-block",
width: 22,
height: 3,
marginRight: 18,
flexShrink: 0,
backgroundColor: color.accent,
}}
/>
{item}
</li>
);
})}
</ul>
</AbsoluteFill>
<div style={outroStyle}>roboco.tech</div>
</AbsoluteFill>
);
};
-4
View File
@@ -1,4 +0,0 @@
import { registerRoot } from "remotion";
import { RemotionRoot } from "./Root";
registerRoot(RemotionRoot);
-56
View File
@@ -1,56 +0,0 @@
/**
* Shared visual language for motion/ compositions. New compositions should
* import color + font from here instead of inventing their own that's
* what keeps the library coherent as more clips merge in over time.
*
* One accent color, a near-black (never pure #000) ink field, warm
* off-white (never pure #fff) text. A display face for headlines paired
* with a workhorse body face for everything else see motion/README.md
* for the full design-bar rationale.
*/
import { loadFont } from "@remotion/fonts";
import { staticFile } from "remotion";
export const font = {
display: "Share Tech Mono",
body: "Inter",
} as const;
// Vendored under public/fonts/ (see motion/README.md) instead of fetched
// from Google's font CDN at render time via the old Google Fonts
// integration package: that fetch happens inside Chrome, raced against an
// 18s timeout, so a slow or unreachable network is a render failure — and
// one this sidecar has no business depending on at all. Inter is the same
// variable-font instance Google itself serves for these weights; Share
// Tech Mono ships only one static weight (400 Regular — confirmed via its
// own name/OS2 tables: no bold, no italic, no variable axes), so only that
// weight is loaded below.
const displayFontUrl = staticFile("fonts/ShareTechMono-Regular.woff2");
const bodyFontUrl = staticFile("fonts/Inter-Variable.woff2");
// loadFont() wraps a delayRender/continueRender internally — the renderer
// waits for these before it starts capturing frames, so no component
// needs to await anything itself. Unlike the old Google Fonts integration
// package, @remotion/fonts has no built-in guard for a missing `FontFace`
// global; skip explicitly rather than let it hit cancelRender() in the
// jsdom test environment, where there is no real render to cancel.
const loadWeights = (family: string, url: string, weights: string[]) => {
if (typeof FontFace === "undefined") {
return;
}
for (const weight of weights) {
loadFont({ family, url, weight, style: "normal" });
}
};
loadWeights(font.display, displayFontUrl, ["400"]);
loadWeights(font.body, bodyFontUrl, ["400", "500", "600"]);
export const color = {
ink: "#0B0B10",
inkRaised: "#15151C",
paper: "#F5F2EA",
muted: "#9C9891",
accent: "#FF5A1F",
hairline: "rgba(245, 242, 234, 0.12)",
} as const;
-19
View File
@@ -1,19 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"isolatedModules": true,
"noEmit": true
},
"include": ["src/**/*", "vitest.config.ts", "remotion.config.ts"]
}
@@ -2,7 +2,7 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
include: ["src/**/*.test.{ts,tsx}"],
environment: "node",
include: ["compositions/**/*.test.js"],
},
});
});