Editing with Remotion
Thirteen original silent studies and a 50-second sequence. These are runnable
adaptations of inspected editing patterns, not recovered OpenAI projects or
product demonstrations. The source films, product data and brand assets are
deliberately absent from this download.
Run from a fresh directory
Use Node.js 22.18 or newer and npm. Install the exact dependency graph in the
included lockfile; do not substitute npm install remotion@latest.
npm ci
npm run typecheck
npm test
npx remotion browser ensure
npm run preview
The last command opens Remotion Studio. Choose a composition in the left-hand
list and play it. The initial install and browser download require internet;
the compositions themselves make no external asset requests. Linux needs the
Remotion system requirements.
If a supported Chrome installation is already present, use the CLI's
--browser-executable=/path/to/chrome option. The batch script instead accepts
REMOTION_BROWSER_EXECUTABLE; that variable is optional and not a required
private path.
From a second terminal in the project directory:
npm run render -- TitleCut out/title.mp4 --codec=h264 --pixel-format=yuv420p --muted
npm run render -- StudySequence out/sequence.mp4 --codec=h264 --pixel-format=yuv420p --muted
npm run render:all -- out
The batch command bundles once, renders all thirteen studies and the sequence,
and writes out/render-manifest.json. Every composition is 1280 × 720,
16:9, 30 fps, silent H.264 MP4. CLI configuration and batch options explicitly
use PNG frame capture and BT.709 conversion, with yuv420p output. These are research settings, not approval of
final presentation delivery settings. Remotion's own
license applies to the dependency; evaluate
its terms before commercial production. This bundle does not grant an upstream
software license or permission to reuse source films.
Pick an edit
| Composition |
Use it to… |
Protect across the edit |
TitleCut |
Build a premise, hold it, cut to a formed surface |
Full title reading interval; no simultaneous UI action |
ChapterReturn |
Interrupt a task with a new chapter, then return |
Exact original task state; no hidden result change |
PunchCut |
Make a small region immediately readable |
Same artifact across the crop discontinuity |
CameraPush |
Move attention through an established workspace |
Common transform for every workspace layer; settle before action |
FocusType |
Aim at a field before the request appears |
Stable baseline and complete request before leaving |
SelectionContext |
Identify scope, reveal its action, restore context |
Selected object, attached action, fixed outer frame |
ScrollMask |
Bring a destination into a fixed viewport |
Header stays still; reading starts after travel |
SplitResult |
Make room for a result while retaining the request |
Container arrives before content; completed content before the hold |
ObjectContext |
Move the same request into another container |
Semantic identity; a cut is not a claimed morph |
CaptionCut |
Retain an identifier across a shot change |
Caption stays in screen space, outside the camera transform |
MotifMontage |
Compare variants through a recurring silhouette |
Consistent motif; holds long enough for the intended comparison |
DensityCollapse |
Reduce a busy wall to a sparse takeaway |
Shared group transform, explicit clearing before the title |
QuietEnding |
Give a finished proof a quiet exit |
Proof first, brief original-mark construction, stable final hold |
Read the source interval and discrepancy notes on the
published chapter
before treating one of these as appropriate for a real presentation.
Inputs and units
src/model.ts is the input contract. src/Edits.tsx implements every edit.
src/visuals.tsx supplies the complete original window, artwork, typography,
pointer and camera. src/Root.tsx registers the compositions and assembles the
sequence. The project has no omitted components or external design package.
| Input |
Type / limits |
Meaning |
title |
1–42 characters |
Premise or takeaway; type-on may reflow as it grows |
prompt |
1–58 characters |
Same original request throughout a sequence |
result |
1–52 characters |
Short visible result label, not an invented product claim |
label |
1–28 characters |
Artifact identifier / window label |
accent |
Six-digit hex color |
Original artwork and emphasis; check contrast after changing |
action |
Integer local frame |
First action or departure from the established state |
change |
Integer local frame |
Decisive cut, completed travel, or start of content reveal |
settle |
Integer local frame |
First frame of the final quiet state |
hold |
Integer, at least 30 |
Frames after settle; changes total duration without speeding motion |
focusX, focusY |
0.2–0.8 |
Target in the full 1280 × 720 design frame, normalized |
zoom |
1–2 |
Final workspace scale for the camera examples |
Require action < change <= settle. Duration is settle + hold, computed by
calculateMetadata; there is no second manually maintained duration field.
Frame ranges are half-open. For example, a cut at frame 54 leaves shot A visible
through frame 53 and starts shot B at frame 54. At 30 fps, that is 1.8 seconds.
Source films have different frame rates; convert their source PTS to seconds
before mapping into this project. Do not equate source frame 54 with output
frame 54.
Phase meanings by composition
| Composition |
action → change |
change → settle |
Final hold |
| TitleCut |
Reveal title characters |
Complete title remains |
Hard cut at settle, formed workspace |
| ChapterReturn |
Cut to title; reveal characters |
Complete title remains |
Cut back at settle, unchanged task |
| PunchCut |
Wide view remains |
Hard crop at change |
Same crop; default change = settle |
| CameraPush |
Chosen eased camera travel |
Camera holds |
Action label appears at settle; camera remains fixed |
| FocusType |
Pointer approaches the input |
Request types in |
Complete request, pointer removed; no submit implied |
| SelectionContext |
Selection appears; camera moves closer |
Action appears; camera restores context |
Same selection and action |
| ScrollMask |
Content moves 360 design pixels behind mask |
Destination remains |
Destination remains; no new camera move |
| SplitResult |
Request pane narrows; empty preview appears |
Preview content fades and settles |
Complete result beside request |
| ObjectContext |
Isolated input holds |
Cut to phone; four blank frames, then request/art return |
Same request and result in phone |
| CaptionCut |
Identifier appears on wide view |
Crop cut at change |
Identifier is unchanged in frame space |
| MotifMontage |
First variation appears at action |
Further cuts spaced by change - action |
Last variant; keep settle after the last cut |
| DensityCollapse |
Output wall moves upward slightly |
Whole wall scales toward center |
Wall removed, title present |
| QuietEnding |
Cut from proof to sparse ending |
Three bars build with four-frame stagger |
Completely stable original mark |
The character caps prevent common overflow cases; they are not a proof that any
string in any language will fit. Test the longest title, widest glyphs and actual
line breaks. For more content, redesign the layout or split the beat. Do not
silently shrink essential text until it is unreadable.
Adapt a study
Create custom-edit.json:
{
"title": "Make the result readable.",
"result": "One clear result, ready for review.",
"accent": "#365c78",
"hold": 150
}
npm run render -- TitleCut out/custom-title.mp4 --props=custom-edit.json --codec=h264 --pixel-format=yuv420p --muted
Only supplied fields override composition defaults. Here the title cut remains
at frame 84 and the final hold becomes 150 frames: 234 frames / 7.8 seconds
total. To move the cut itself, change settle, not hold. If moving any phase
boundary, preserve phase ordering and inspect the boundary frames.
The camera uses full-frame coordinates. At zoom z, the target lands at the
center using translation (640 - focusX × 1280 × z,
360 - focusY × 720 × z). Captions sit outside that transform. A scroll instead
translates only the document inside an overflow: hidden viewport. These are
different operations, with different layer ownership.
Adapt the 50-second sequence
The eight beats in sequenceBeats in src/model.ts total 1,500 frames:
| Interval |
Edit |
Role |
| 0–5 s |
TitleCut |
Premise and formed surface |
| 5–10 s |
CameraPush |
Identify the selected study |
| 10–17 s |
FocusType |
Establish the request |
| 17–24 s |
SplitResult |
Make room for its original result |
| 24–30 s |
ChapterReturn |
Pause for comparison, retain task state |
| 30–36 s |
MotifMontage |
Compare the same silhouette in three treatments |
| 36–42 s |
ObjectContext |
Carry request and artwork into another context |
| 42–50 s |
QuietEnding |
Return to proof, then leave a quiet tail |
Create custom-sequence.json:
{
"title": "A small exhibition, clearly told.",
"prompt": "Compare three exhibition studies.",
"result": "Three studies prepared for review.",
"label": "EXHIBITION / REVIEW",
"accent": "#365c78",
"extraHold": 60
}
npm run render -- StudySequence out/custom-sequence.mp4 --props=custom-sequence.json --codec=h264 --pixel-format=yuv420p --muted
This is 1,560 frames / 52 seconds. extraHold accepts 0–300 frames and extends
only the final quiet tail. To change individual beats, edit sequenceBeats and
its matching phase overrides; its runtime assertion rejects a duration mismatch.
Keep a task's content shared across shots rather than creating unrelated fixtures.
The sequence is a composed study, not a claim that one source film uses this edit list.
Replace neutral graphics with real footage
The original geometry is suitable for research comparisons, not for claiming
product behavior. Obtain permitted captures from the actual application. Keep
the capture, pointer and UI overlays under one camera; keep editorial captions
outside it. For a raster capture that fills the frame, start with at least
output width × maximum zoom pixels of capture width. Settle before reading,
and check the final MP4 at 640 × 360 meeting-view size. Never draw an invented
success over a failed interaction. Declared edits can omit waiting, but their
duration is not measured application latency.
Verify the file, not just Studio
Use FFmpeg and ffprobe on PATH:
ffprobe -v error -show_entries stream=codec_type,codec_name,width,height,r_frame_rate,nb_frames -show_entries format=duration -of json out/custom-sequence.mp4
ffmpeg -v error -i out/custom-sequence.mp4 -f null -
Confirm 1280 × 720, 30/1 fps, 52 seconds for the adaptation above, and no audio
stream. Play the whole file. Inspect both frames around each hard cut, the
camera's middle frames, text at its longest state, masks at their edges, and
the last few seconds for accidental movement. A passing decoder does not prove
readability, semantic continuity or a good edit. The chapter's verification
report states which checks were actually run.
The production approval boundary
These examples do not authorize final all-hands rendering. For a presentation,
derive timing from the supplied talking-head recording and its real pauses.
Keep the talking head in OBS and the separate, silent presentation video in
Google Slides. Do not composite them or duplicate narration. Use a recognizable
opening transition as a manual cue, then measure the actual reaction/startup
offset in rehearsal; do not assume zero delay.
If the talking head starts at presentation time S, recording time h maps to
S + h. For duration H, actual tail onset T, presentation end P, and
required remaining buffer B, check S + T <= P <= S + H - B, after all needed
speech and proof finish. The opening lead-in and ending buffer are separate.
If the wave/pause is absent, raise it in review; never manufacture it. Obtain
one consolidated storyboard/demo approval, including real captures, chosen
techniques, exact offsets, buffer and export settings, before production.
Asset provenance
All diagrams, text, pointers and geometric artwork in src are original to
this study. The two bundled DejaVu font files carry their own permission notice
in public/fonts/LICENSE.txt. The downloadable project contains no OpenAI
font, logo, footage, source demo data, private product material or recording.
Research excerpts remain separately credited on the site. Preserve the font
notice when redistributing the project.
Implementation references: Sequence timing,
metadata from props,
render CLI,
renderMedia.
The numerical easing, geometry, colors and output durations are chosen defaults,
not recovered source-film specifications.
For a checked schedule against an actual recording, read
RECORDING_ALIGNMENT.md. The included
alignToRecording helper maps proof anchors to local recipe frames, rejects
overlapping shots and inadequate reading holds, and checks both sides of the
real ending-buffer window. npm test includes its explicitly synthetic clock
tests; those tests do not claim that any supplied recording has been aligned.
API_VERIFICATION.md records the official API contracts,
including the important difference between CLI config and programmatic render
settings.
RECIPE_GEOMETRY.md lists each study's pixel coordinates,
layer ownership, original asset requirements, and constraints when substituting
different captures or artwork.
Complete source files
These are the exact files in the ZIP, not shortened snippets. Shared primitives and registration are included.
package.json
Download this file
{
"name": "fieldnotes-editing-with-remotion",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Original, silent editing studies. Source research footage is not bundled.",
"engines": {"node": ">=22.18.0"},
"scripts": {
"preview": "remotion studio src/index.ts",
"typecheck": "tsc --noEmit",
"test": "node scripts/test-model.mjs && node scripts/test-recording-timing.mjs",
"render": "remotion render src/index.ts",
"render:all": "node scripts/render-all.mjs"
},
"dependencies": {
"@remotion/cli": "4.0.520",
"@remotion/renderer": "4.0.520",
"@remotion/bundler": "4.0.520",
"remotion": "4.0.520",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@types/react": "19.2.18",
"@types/react-dom": "19.2.7",
"typescript": "5.9.3"
}
}
package-lock.json
Download the complete pinned dependency graph. This file remains unchanged in the ZIP; it is not expanded into this reading view.
tsconfig.json
Download this file
{
"compilerOptions": {
"target": "ES2022", "lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext", "moduleResolution": "bundler",
"jsx": "react-jsx", "strict": true, "noEmit": true,
"esModuleInterop": true, "skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src", "remotion.config.ts"]
}
remotion.config.ts
Download this file
import {Config} from '@remotion/cli/config';
Config.setVideoImageFormat('png');
Config.setColorSpace('bt709');
Config.setPixelFormat('yuv420p');
Config.setCodec('h264');
Config.setMuted(true);
README.md
Download this file
# Editing with Remotion
Thirteen original silent studies and a 50-second sequence. These are runnable
adaptations of inspected editing patterns, not recovered OpenAI projects or
product demonstrations. The source films, product data and brand assets are
deliberately absent from this download.
## Run from a fresh directory
Use Node.js 22.18 or newer and npm. Install the exact dependency graph in the
included lockfile; do not substitute `npm install remotion@latest`.
```bash
npm ci
npm run typecheck
npm test
npx remotion browser ensure
npm run preview
```
The last command opens Remotion Studio. Choose a composition in the left-hand
list and play it. The initial install and browser download require internet;
the compositions themselves make no external asset requests. Linux needs the
[Remotion system requirements](https://www.remotion.dev/docs#system-requirements).
If a supported Chrome installation is already present, use the CLI's
`--browser-executable=/path/to/chrome` option. The batch script instead accepts
`REMOTION_BROWSER_EXECUTABLE`; that variable is optional and not a required
private path.
From a second terminal in the project directory:
```bash
npm run render -- TitleCut out/title.mp4 --codec=h264 --pixel-format=yuv420p --muted
npm run render -- StudySequence out/sequence.mp4 --codec=h264 --pixel-format=yuv420p --muted
npm run render:all -- out
```
The batch command bundles once, renders all thirteen studies and the sequence,
and writes `out/render-manifest.json`. Every composition is **1280 × 720,
16:9, 30 fps, silent H.264 MP4**. CLI configuration and batch options explicitly
use PNG frame capture and BT.709 conversion, with yuv420p output. These are research settings, not approval of
final presentation delivery settings. Remotion's own
[license](https://www.remotion.dev/license) applies to the dependency; evaluate
its terms before commercial production. This bundle does not grant an upstream
software license or permission to reuse source films.
## Pick an edit
| Composition | Use it to… | Protect across the edit |
| --- | --- | --- |
| `TitleCut` | Build a premise, hold it, cut to a formed surface | Full title reading interval; no simultaneous UI action |
| `ChapterReturn` | Interrupt a task with a new chapter, then return | Exact original task state; no hidden result change |
| `PunchCut` | Make a small region immediately readable | Same artifact across the crop discontinuity |
| `CameraPush` | Move attention through an established workspace | Common transform for every workspace layer; settle before action |
| `FocusType` | Aim at a field before the request appears | Stable baseline and complete request before leaving |
| `SelectionContext` | Identify scope, reveal its action, restore context | Selected object, attached action, fixed outer frame |
| `ScrollMask` | Bring a destination into a fixed viewport | Header stays still; reading starts after travel |
| `SplitResult` | Make room for a result while retaining the request | Container arrives before content; completed content before the hold |
| `ObjectContext` | Move the same request into another container | Semantic identity; a cut is not a claimed morph |
| `CaptionCut` | Retain an identifier across a shot change | Caption stays in screen space, outside the camera transform |
| `MotifMontage` | Compare variants through a recurring silhouette | Consistent motif; holds long enough for the intended comparison |
| `DensityCollapse` | Reduce a busy wall to a sparse takeaway | Shared group transform, explicit clearing before the title |
| `QuietEnding` | Give a finished proof a quiet exit | Proof first, brief original-mark construction, stable final hold |
Read the source interval and discrepancy notes on the
[published chapter](https://openai-film-fieldnotes-100.pages.dev/videos/editing/)
before treating one of these as appropriate for a real presentation.
## Inputs and units
`src/model.ts` is the input contract. `src/Edits.tsx` implements every edit.
`src/visuals.tsx` supplies the complete original window, artwork, typography,
pointer and camera. `src/Root.tsx` registers the compositions and assembles the
sequence. The project has no omitted components or external design package.
| Input | Type / limits | Meaning |
| --- | --- | --- |
| `title` | 1–42 characters | Premise or takeaway; type-on may reflow as it grows |
| `prompt` | 1–58 characters | Same original request throughout a sequence |
| `result` | 1–52 characters | Short visible result label, not an invented product claim |
| `label` | 1–28 characters | Artifact identifier / window label |
| `accent` | Six-digit hex color | Original artwork and emphasis; check contrast after changing |
| `action` | Integer local frame | First action or departure from the established state |
| `change` | Integer local frame | Decisive cut, completed travel, or start of content reveal |
| `settle` | Integer local frame | First frame of the final quiet state |
| `hold` | Integer, at least 30 | Frames after `settle`; changes total duration without speeding motion |
| `focusX`, `focusY` | 0.2–0.8 | Target in the full 1280 × 720 design frame, normalized |
| `zoom` | 1–2 | Final workspace scale for the camera examples |
Require `action < change <= settle`. Duration is `settle + hold`, computed by
`calculateMetadata`; there is no second manually maintained duration field.
Frame ranges are half-open. For example, a cut at frame 54 leaves shot A visible
through frame 53 and starts shot B at frame 54. At 30 fps, that is 1.8 seconds.
Source films have different frame rates; convert their source PTS to seconds
before mapping into this project. Do not equate source frame 54 with output
frame 54.
### Phase meanings by composition
| Composition | `action` → `change` | `change` → `settle` | Final hold |
| --- | --- | --- | --- |
| TitleCut | Reveal title characters | Complete title remains | Hard cut at `settle`, formed workspace |
| ChapterReturn | Cut to title; reveal characters | Complete title remains | Cut back at `settle`, unchanged task |
| PunchCut | Wide view remains | Hard crop at `change` | Same crop; default `change = settle` |
| CameraPush | Chosen eased camera travel | Camera holds | Action label appears at `settle`; camera remains fixed |
| FocusType | Pointer approaches the input | Request types in | Complete request, pointer removed; no submit implied |
| SelectionContext | Selection appears; camera moves closer | Action appears; camera restores context | Same selection and action |
| ScrollMask | Content moves 360 design pixels behind mask | Destination remains | Destination remains; no new camera move |
| SplitResult | Request pane narrows; empty preview appears | Preview content fades and settles | Complete result beside request |
| ObjectContext | Isolated input holds | Cut to phone; four blank frames, then request/art return | Same request and result in phone |
| CaptionCut | Identifier appears on wide view | Crop cut at `change` | Identifier is unchanged in frame space |
| MotifMontage | First variation appears at `action` | Further cuts spaced by `change - action` | Last variant; keep `settle` after the last cut |
| DensityCollapse | Output wall moves upward slightly | Whole wall scales toward center | Wall removed, title present |
| QuietEnding | Cut from proof to sparse ending | Three bars build with four-frame stagger | Completely stable original mark |
The character caps prevent common overflow cases; they are not a proof that any
string in any language will fit. Test the longest title, widest glyphs and actual
line breaks. For more content, redesign the layout or split the beat. Do not
silently shrink essential text until it is unreadable.
## Adapt a study
Create `custom-edit.json`:
```json
{
"title": "Make the result readable.",
"result": "One clear result, ready for review.",
"accent": "#365c78",
"hold": 150
}
```
```bash
npm run render -- TitleCut out/custom-title.mp4 --props=custom-edit.json --codec=h264 --pixel-format=yuv420p --muted
```
Only supplied fields override composition defaults. Here the title cut remains
at frame 84 and the final hold becomes 150 frames: 234 frames / 7.8 seconds
total. To move the cut itself, change `settle`, not `hold`. If moving any phase
boundary, preserve phase ordering and inspect the boundary frames.
The camera uses full-frame coordinates. At zoom `z`, the target lands at the
center using translation `(640 - focusX × 1280 × z,
360 - focusY × 720 × z)`. Captions sit outside that transform. A scroll instead
translates only the document inside an `overflow: hidden` viewport. These are
different operations, with different layer ownership.
## Adapt the 50-second sequence
The eight beats in `sequenceBeats` in `src/model.ts` total 1,500 frames:
| Interval | Edit | Role |
| --- | --- | --- |
| 0–5 s | TitleCut | Premise and formed surface |
| 5–10 s | CameraPush | Identify the selected study |
| 10–17 s | FocusType | Establish the request |
| 17–24 s | SplitResult | Make room for its original result |
| 24–30 s | ChapterReturn | Pause for comparison, retain task state |
| 30–36 s | MotifMontage | Compare the same silhouette in three treatments |
| 36–42 s | ObjectContext | Carry request and artwork into another context |
| 42–50 s | QuietEnding | Return to proof, then leave a quiet tail |
Create `custom-sequence.json`:
```json
{
"title": "A small exhibition, clearly told.",
"prompt": "Compare three exhibition studies.",
"result": "Three studies prepared for review.",
"label": "EXHIBITION / REVIEW",
"accent": "#365c78",
"extraHold": 60
}
```
```bash
npm run render -- StudySequence out/custom-sequence.mp4 --props=custom-sequence.json --codec=h264 --pixel-format=yuv420p --muted
```
This is 1,560 frames / 52 seconds. `extraHold` accepts 0–300 frames and extends
only the final quiet tail. To change individual beats, edit `sequenceBeats` and
its matching phase overrides; its runtime assertion rejects a duration mismatch.
Keep a task's content shared across shots rather than creating unrelated fixtures.
The sequence is a composed study, not a claim that one source film uses this edit list.
## Replace neutral graphics with real footage
The original geometry is suitable for research comparisons, not for claiming
product behavior. Obtain permitted captures from the actual application. Keep
the capture, pointer and UI overlays under one camera; keep editorial captions
outside it. For a raster capture that fills the frame, start with at least
`output width × maximum zoom` pixels of capture width. Settle before reading,
and check the final MP4 at 640 × 360 meeting-view size. Never draw an invented
success over a failed interaction. Declared edits can omit waiting, but their
duration is not measured application latency.
## Verify the file, not just Studio
Use FFmpeg and ffprobe on PATH:
```bash
ffprobe -v error -show_entries stream=codec_type,codec_name,width,height,r_frame_rate,nb_frames -show_entries format=duration -of json out/custom-sequence.mp4
ffmpeg -v error -i out/custom-sequence.mp4 -f null -
```
Confirm 1280 × 720, 30/1 fps, 52 seconds for the adaptation above, and no audio
stream. Play the whole file. Inspect both frames around each hard cut, the
camera's middle frames, text at its longest state, masks at their edges, and
the last few seconds for accidental movement. A passing decoder does not prove
readability, semantic continuity or a good edit. The chapter's verification
report states which checks were actually run.
## The production approval boundary
These examples do not authorize final all-hands rendering. For a presentation,
derive timing from the supplied talking-head recording and its real pauses.
Keep the talking head in OBS and the separate, silent presentation video in
Google Slides. Do not composite them or duplicate narration. Use a recognizable
opening transition as a manual cue, then measure the actual reaction/startup
offset in rehearsal; do not assume zero delay.
If the talking head starts at presentation time `S`, recording time `h` maps to
`S + h`. For duration `H`, actual tail onset `T`, presentation end `P`, and
required remaining buffer `B`, check `S + T <= P <= S + H - B`, after all needed
speech and proof finish. The opening lead-in and ending buffer are separate.
If the wave/pause is absent, raise it in review; never manufacture it. Obtain
one consolidated storyboard/demo approval, including real captures, chosen
techniques, exact offsets, buffer and export settings, before production.
## Asset provenance
All diagrams, text, pointers and geometric artwork in `src` are original to
this study. The two bundled DejaVu font files carry their own permission notice
in `public/fonts/LICENSE.txt`. The downloadable project contains no OpenAI
font, logo, footage, source demo data, private product material or recording.
Research excerpts remain separately credited on the site. Preserve the font
notice when redistributing the project.
Implementation references: [Sequence timing](https://www.remotion.dev/docs/sequence),
[metadata from props](https://www.remotion.dev/docs/calculate-metadata),
[render CLI](https://www.remotion.dev/docs/cli/render),
[renderMedia](https://www.remotion.dev/docs/renderer/render-media).
The numerical easing, geometry, colors and output durations are chosen defaults,
not recovered source-film specifications.
For a checked schedule against an actual recording, read
[`RECORDING_ALIGNMENT.md`](RECORDING_ALIGNMENT.md). The included
`alignToRecording` helper maps proof anchors to local recipe frames, rejects
overlapping shots and inadequate reading holds, and checks both sides of the
real ending-buffer window. `npm test` includes its explicitly synthetic clock
tests; those tests do not claim that any supplied recording has been aligned.
[`API_VERIFICATION.md`](API_VERIFICATION.md) records the official API contracts,
including the important difference between CLI config and programmatic render
settings.
[`RECIPE_GEOMETRY.md`](RECIPE_GEOMETRY.md) lists each study's pixel coordinates,
layer ownership, original asset requirements, and constraints when substituting
different captures or artwork.
RECORDING_ALIGNMENT.md
Download this file
# Schedule against the recording
`src/recording-timing.ts` turns the separate-video timing contract into a checked
frame schedule. It does not infer a transcript, manufacture a pause, approve a
storyboard, or play the talking head. Its inputs must come from the actual
recording and a rehearsal of the intended playback setup.
## Gather evidence first
1. Preserve the supplied recording. Derive a timed transcript from its audio,
including pauses. Watch it to mark the genuine ending wave/pause onset.
2. For each spoken beat, identify the exact recording time when its completed
visual proof should be available. Record that as `recordingProofSeconds`.
3. Pick a recipe whose source-backed mechanic fits that beat. Specify the local
output frame where its proof is complete, total duration, and a protected
reading hold. Do not assume `settle` always means completed text: in
`TitleCut`, for example, `change` completes the title and `settle` cuts away.
4. Place the recognizable content transition that cues the OBS scene switch.
Measure reaction/startup delay in rehearsal; do not assume it is zero.
5. Choose the presentation end inside the actual talking-head tail, leaving
enough real footage for the presenter to mute and stop video manually.
## Use the checked schedule
Import `alignToRecording` and the `RecordingClock` / `RecordingBeat` types from
`./recording-timing`. Supply a clock with these fields:
| Field | Evidence / units |
| --- | --- |
| `fps` | Chosen output frames per second, established at storyboard review |
| `cueSeconds` | Visible presentation cue time |
| `measuredReactionSeconds` | Rehearsed manual reaction plus OBS startup delay |
| `recordingDurationSeconds` | Actual supplied recording duration |
| `actualTailStartSeconds` | Watched onset of the genuine ending wave/pause |
| `lastRequiredSpeechSeconds` | End of the last required spoken word, from the actual timed transcript |
| `requiredRemainingTailSeconds` | Positive remaining buffer chosen against footage and rehearsal |
| `presentationEndFrame` | Exclusive end of the presentation file, not its last frame index |
Each beat supplies `id`, `recordingProofSeconds`, `proofLocalFrame`,
`durationInFrames`, and `minimumReadingFrames`. All frame fields are integers.
Use the returned `from` and `durationInFrames` on a Remotion `Sequence`; its
child's `useCurrentFrame()` then reads the recipe's local time. Keep existing
recipe phase durations. The scheduler works backward from proof, not forward
from a guessed speech rate.
The function checks both sides of the final interval, not just the files'
durations. With `S = cueSeconds + measuredReactionSeconds`, recording time `h`
maps to presentation time `S + h`. The permitted end window is
`S + actualTailStartSeconds <= presentation end <= S + recordingDurationSeconds - requiredRemainingTailSeconds`.
End boundaries round inward to whole output frames. Proof anchors round to
the nearest frame (at most half an output frame of quantization).
The earliest end also cannot precede `S + lastRequiredSpeechSeconds`. If the
presenter starts waving while still speaking, the wave does not authorize
ending the presentation before the required words finish.
Overlapping shots, missing reading time, a beat that cannot fit after the cue,
or an inadequate ending pause are errors. Shorten surplus holds, simplify the
visual, choose a direct crop instead of travel, or raise a recording conflict
in storyboard review. Do not automatically accelerate the whole edit.
Unfilled gaps need an explicit context/proof hold or chapter card; a schedule
with gaps is not itself a finished composition. A successful numerical check
does not prove that the chosen phrase matches the visible content.
Run the executable synthetic example and rejection checks:
```bash
node scripts/test-recording-timing.mjs
```
The numbers in that test are deliberately invented test fixtures, not timings
of any supplied footage. The test shows how the input contract works without
publishing a private recording or suggesting that its synchronization is done.
## Approval and paired playback
Consolidate the transcript-derived beat map, selected source examples, demo
list and current product verification, capture prerequisites, cue, measured
offset, ending buffer, and delivery settings into the single storyboard
review. Get approval before rendering the final presentation. Material
departures require review; routine approved scenes do not need new approvals.
Keep two assets: supplied talking head in OBS and the separate presentation in
Google Slides. Do not composite them, synthesize a voice, duplicate narration,
or add music. Verify the exported pair in the real setup. A stable Remotion
render cannot establish the real OBS startup delay or Slides playback behavior.
API_VERIFICATION.md
Download this file
# Remotion API checks
Checked against official Remotion documentation on September 5, 2026. The
project pins Remotion packages to **4.0.520** and checks the actual installed
types. Documentation is a reference for API behavior, not evidence of how an
OpenAI film was authored. No claim is made that OpenAI used Remotion.
| API / official reference | Contract used in this project |
| --- | --- |
| [registerRoot](https://www.remotion.dev/docs/register-root) | `src/index.ts` registers one root containing the compositions. |
| [Composition](https://www.remotion.dev/docs/composition) | Each study has a stable id, component, width, height, fps, duration and default props. |
| [calculateMetadata](https://www.remotion.dev/docs/calculate-metadata) | Validated input props determine the actual duration; changing a hold cannot leave stale composition metadata. |
| [Sequence](https://www.remotion.dev/docs/sequence) | `from` places a shot on the outer timeline; the child frame clock is local to that shot. `durationInFrames` bounds its visibility. |
| [interpolate](https://www.remotion.dev/docs/interpolate) | Explicit left/right clamping prevents motion extrapolating outside a phase. |
| [Easing](https://www.remotion.dev/docs/easing) | The chosen cubic Bézier transforms normalized progress; its control points are not recovered film settings. |
| [staticFile](https://www.remotion.dev/docs/staticfile) | Bundled font paths resolve inside the public asset directory; they do not fetch fonts from a remote CDN. |
| [delayRender / continueRender](https://www.remotion.dev/docs/delay-render) | Rendering waits for bundled font loading, then releases the handle. Failure cancels rather than exporting fallback typography. |
| [getCompositions](https://www.remotion.dev/docs/renderer/get-compositions) | The batch script retrieves the registered, resolved video configs from its bundle. |
| [renderMedia](https://www.remotion.dev/docs/renderer/render-media) | The batch script sets codec, pixel format, image format, color space, mute, output and concurrency explicitly. |
| [Configuration](https://www.remotion.dev/docs/config) | CLI defaults live in `remotion.config.ts`. The programmatic renderer does **not** inherit that file, so batch settings are repeated explicitly. |
| [Render CLI](https://www.remotion.dev/docs/cli/render) | Commands specify entry point through npm, composition id, output file and optional JSON input props. |
| [Browser ensure](https://www.remotion.dev/docs/cli/browser) | The documented browser-download command is `npx remotion browser ensure`; an existing compatible executable is optional. |
| [System requirements](https://www.remotion.dev/docs#system-requirements) | A clean Linux environment may need browser system packages before rendering. They are not npm dependencies. |
`npm ci` installs the included lockfile without resolving a new dependency
graph. `npm run typecheck` checks TypeScript; `npm test` checks phase ordering,
sequence isolation and recording alignment. Rendering and decoded-video checks
are separate acceptance steps. A type check alone does not establish that a
frame fits, an asset loads, a hold reads well, or a source mechanic is faithful.
## Keep render settings in agreement
CLI renders use `remotion.config.ts`; `scripts/render-all.mjs` uses the Node
renderer API. Both choose PNG intermediate frames, BT.709, H.264, yuv420p and
mute. The PNG intermediate avoids an implicit intermediate JPEG YUV conversion.
These are reproducible research settings, not recovered source production
settings or approved Google Slides delivery settings.
When adapting the batch renderer to custom input props, pass the same input
props into metadata selection and rendering. Otherwise the video duration and
the rendered content can disagree. Keep every Remotion package on the same
pinned version when upgrading, then rerun all checks and compare rendered
boundary frames. Consult Remotion's own license before downstream use; this
guide does not grant rights to dependencies or source films.
RECIPE_GEOMETRY.md
Download this file
# Geometry and asset contracts
These are **chosen implementation values** for the original 1280 × 720 studies,
not measurements of OpenAI's production geometry. Coordinates are design
pixels before any camera transform; the origin is the upper-left corner. The
source articles separately identify observed framing and limitations. There is
no claim to recover source lens settings, fonts, crop curves or exact easing.
## Shared stage
- The canvas is 1280 × 720, 16:9, 30 fps. `Stage` clips everything at its edges.
- The original window starts at `(80, 68)` and measures `1120 × 584`; its header
is 52 pixels tall and its navigation rail is 188 pixels wide.
- `Artwork` is an original SVG with a `500 × 320` view box. Its circles, arch,
baseline, palette and label are generated in `src/visuals.tsx`; no image
download is required. Inline SVG scales without a raster-resolution limit.
- Bundled DejaVu Sans regular/bold supply typography. The font notice is in
`public/fonts/LICENSE.txt`. All other asset content is original code/text.
- `Camera` transforms its entire child workspace, including any pointer placed
inside it. At the final scale `zoom`, it translates by
`(640 - focusX × 1280 × zoom, 360 - focusY × 720 × zoom)`. Its origin is `(0,0)`.
A frame-space caption must be a sibling of the camera, not its child.
## Per-recipe construction
| Composition | Original geometry and owned layers | Required asset / adaptation constraint |
| --- | --- | --- |
| `TitleCut` | Centered flex title; 150-pixel canvas padding; 64-pixel bold type; maximum text width 970. At `settle`, replace the whole title with `Proof`. | Short title plus completed proof. No source slogan or logo. A title-to-window cut does not interpolate these geometries. |
| `ChapterReturn` | The same title geometry interrupts the same `Proof` component. Before/after task props are identical. | Keep one snapshot or shared data object; do not quietly swap the result during the title. |
| `PunchCut` | Switch camera amount from 0 to 1 in one frame. Default focus `(0.66, 0.64)`, zoom `1.55`. | Same capture/artifact on both sides. For a full-frame raster capture, start at least 1984 pixels wide for this zoom, then inspect sharpness. |
| `CameraPush` | Same camera as `PunchCut`, but progress is eased. Action label box `(850,470,260,60)` appears only at `settle`, inside the camera. | Place the target and its action in the transformed workspace. The full-window heading leaves the final crop so it does not become a partially clipped reading target. |
| `FocusType` | Input box `(334,365,750,150)`, 30-pixel padding, 26-pixel text. Pointer tip travels `(1070,310)` → `(365,402)`. | One original request; keep a stable text baseline. The example removes the pointer at completion and does not simulate submission. |
| `SelectionContext` | Three row boxes at `x=334`, `y=258 + row×65`, size `680 × 52`. Attached request `(500,474,514,56)`. Camera focus is fixed here at `(0.58,0.49)`. | Selection and attached action refer to the same rows. Its focus override is recipe-specific; generic `focusX/Y` props do not override it. |
| `ScrollMask` | Fixed viewport `(312,230,815,360)` with `overflow:hidden`. Five 180-pixel rows translate upward by 360 pixels. Header is outside the mask. | Supply enough content below the fold. The destination is row 3; if changing row height/count, recalculate travel and hold at that landmark. |
| `SplitResult` | Request starts `(312,165,800,418)` and narrows to 300 pixels. Preview box `(640,165,486,418)` arrives empty; result content then translates upward 14 pixels while fading in. | Same request throughout; result must exist before the reading hold. This synthetic staging does not measure or promise product latency. |
| `ObjectContext` | Initial artwork `(410,90,460,296)` and request `(340,442,600,115)`. Cut to device `(430,40,420,640)`, radius 36; chosen entrance starts 24 pixels lower at 0.96 scale. | Keep the same request/art identity. Blank device lasts four output frames; content returns after it. This is not a pixel-matched morph. |
| `CaptionCut` | Caption `(72,590,590,84)` stays in frame space, dark backed with 6-pixel accent border. Workspace cuts underneath it. | Short artifact label, not a new speaker inset. Keep the label clear of essential proof; maintain contrast after changing accent. |
| `MotifMontage` | Artwork footprint `(230,173,820,525)` stays fixed; three palette/variant states use the same silhouette. Title box `(140,68,1000,80)`. | Supply distinguishable variants retaining one visual motif. The slower 0.8-second default cadence is not the source's roughly 0.1-second rapid run. |
| `DensityCollapse` | Six `460 × 330` cards start at `(40 + column×416, -30 + row×385)`, rotated −4°/+5°. A common group scale falls to 0.03 before title replacement. | Parent transforms own the wall. Do not independently animate a label that is meant to collapse with its card. Source internal card motion and blur are omitted. |
| `QuietEnding` | Centered three-bar mark: widths 27, gap 14, heights 54/76/98. Four-frame stagger; fixed final state at `settle`. | Original identity only. This implementation does not reconstruct source wordmark deletion or branded knot geometry. |
The complete source in `src/Edits.tsx` and `src/visuals.tsx` is authoritative if
you change a layout. `src/model.ts` gives each recipe's phase-frame defaults;
the README explains phase meanings. Do not reuse one recipe's `change` meaning
for another without checking that table.
## When using a different original asset
Keep asset identity and crop placement in one shared component. Put a licensed
image in `public/`, resolve it with `staticFile`, and use Remotion's `Img` so
loading finishes before export. Use explicit `width`, `height` and `objectFit`;
choose `contain` when the whole object must survive a cut and `cover` only when
the crop is intentional. Do not substitute an ordinary HTML image with
uncontrolled loading. The official [Img documentation](https://www.remotion.dev/docs/img)
describes its render-loading behavior.
If the capture is not 16:9, establish the letterbox/crop transform first, then
calculate focus coordinates in the complete design frame. Check every boundary
frame and the longest text at 640 × 360 playback size. Character-count caps are
not fit guarantees. Redesign or simplify before reducing essential text below
readable size.
src/index.ts
Download this file
import {registerRoot} from 'remotion';
import {Root} from './Root';
registerRoot(Root);
src/Root.tsx
Download this file
import {useEffect, useState} from 'react';
import {cancelRender, Composition, continueRender, delayRender, Sequence, staticFile} from 'remotion';
import {Edit} from './Edits';
import {defaults, duration, EditProps, Kind, kinds, timing, validateEdit, sequenceDefaults, sequenceLayout, SequenceProps} from './model';
const useFonts = () => {
const [handle]=useState(()=>delayRender('Loading bundled study fonts'));
useEffect(()=>{
Promise.all([['Regular','400'],['Bold','700']].map(async([file,weight])=>{
const font=new FontFace('Study Sans',`url(${staticFile(`fonts/DejaVuSans${file==='Bold'?'-Bold':''}.ttf`)})`,{weight});
await font.load();document.fonts.add(font);
})).then(()=>continueRender(handle)).catch(error=>cancelRender(error));
},[handle]);
};
const OneEdit = ({kind,...p}: EditProps & {kind:Kind}) => {useFonts();return <Edit kind={kind} p={validateEdit(kind,p)}/>;};
const StudySequence=(props:SequenceProps)=>{
useFonts();return <>{sequenceLayout(props).map(beat=><Sequence key={beat.kind} from={beat.from}
durationInFrames={beat.frames} name={beat.kind}><Edit kind={beat.kind} p={beat.p}/></Sequence>)}</>;
};
export const Root=()=> <>
{kinds.map(kind=><Composition key={kind} id={kind} component={OneEdit} width={1280} height={720} fps={30}
durationInFrames={duration({...defaults,...timing[kind]})}
defaultProps={{...defaults,...timing[kind],kind}}
calculateMetadata={({props})=>({durationInFrames:duration(validateEdit(kind,props))})}/>)}
<Composition id="StudySequence" component={StudySequence} width={1280} height={720} fps={30} durationInFrames={1500}
defaultProps={sequenceDefaults} calculateMetadata={({props})=>({durationInFrames:sequenceLayout(props).reduce((sum,b)=>sum+b.frames,0)})}/>
</>;
src/model.ts
Download this file
export const kinds = ['TitleCut', 'ChapterReturn', 'PunchCut', 'CameraPush', 'FocusType',
'SelectionContext', 'ScrollMask', 'SplitResult', 'ObjectContext', 'CaptionCut',
'MotifMontage', 'DensityCollapse', 'QuietEnding'] as const;
export type Kind = typeof kinds[number];
export type EditProps = {
title: string; prompt: string; result: string; label: string; accent: string;
action: number; change: number; settle: number; hold: number;
focusX: number; focusY: number; zoom: number;
};
export const defaults: EditProps = {
title: 'Make room for the idea.', prompt: 'Arrange a small exhibition.',
result: 'Three studies, ready to compare.', label: 'EXHIBITION / 03', accent: '#40685b',
action: 30, change: 60, settle: 90, hold: 90,
focusX: .66, focusY: .64, zoom: 1.55,
};
export const timing: Record<Kind, Pick<EditProps, 'action'|'change'|'settle'|'hold'>> = {
TitleCut: {action: 0, change: 42, settle: 84, hold: 66},
ChapterReturn: {action: 30, change: 66, settle: 126, hold: 60},
PunchCut: {action: 30, change: 54, settle: 54, hold: 90},
CameraPush: {action: 30, change: 45, settle: 66, hold: 90},
FocusType: {action: 24, change: 54, settle: 126, hold: 60},
SelectionContext: {action: 30, change: 60, settle: 120, hold: 60},
ScrollMask: {action: 30, change: 84, settle: 96, hold: 90},
SplitResult: {action: 30, change: 66, settle: 114, hold: 90},
ObjectContext: {action: 30, change: 54, settle: 84, hold: 90},
CaptionCut: {action: 18, change: 72, settle: 72, hold: 72},
MotifMontage: {action: 24, change: 48, settle: 120, hold: 60},
DensityCollapse: {action: 24, change: 84, settle: 108, hold: 72},
QuietEnding: {action: 30, change: 54, settle: 90, hold: 90},
};
export function validate(p: EditProps): EditProps {
for (const k of ['action', 'change', 'settle', 'hold'] as const)
if (!Number.isInteger(p[k]) || p[k] < 0) throw new Error(`${k} must be a nonnegative integer frame count`);
if (!(p.action < p.change && p.change <= p.settle && p.hold >= 30))
throw new Error('Require action < change <= settle and hold >= 30; lengthen the hold, not the action.');
for (const [key, max] of [['title', 42], ['prompt', 58], ['result', 52], ['label', 28]] as const)
if (typeof p[key] !== 'string' || !p[key].trim() || Array.from(p[key]).length > max)
throw new Error(`${key} must contain 1–${max} characters. Shorten or redesign before rendering.`);
if (!/^#[\da-f]{6}$/i.test(p.accent)) throw new Error('accent must be a six-digit hex color');
if (![p.focusX, p.focusY].every(v=>Number.isFinite(v)&&v>=.2&&v<=.8) || !Number.isFinite(p.zoom) || p.zoom<1 || p.zoom>2)
throw new Error('focusX/focusY must be .2–.8 and zoom 1–2. Inspect the resulting crop.');
return p;
}
export const duration = (p: EditProps) => validate(p).settle + p.hold;
export function validateEdit(kind:Kind,p:EditProps){
validate(p);
if(!['PunchCut','CaptionCut'].includes(kind)&&p.settle<=p.change)
throw new Error(`${kind} needs settle > change`);
if(kind==='QuietEnding'&&p.settle<=p.change+8)throw new Error('QuietEnding needs settle > change + 8 for its stagger.');
if(kind==='ObjectContext'&&p.settle<=p.change+4)throw new Error('ObjectContext needs settle > change + 4 for the blank device.');
return p;
}
export type SequenceProps={title:string;prompt:string;result:string;label:string;accent:string;extraHold:number};
export const sequenceDefaults:SequenceProps={title:defaults.title,prompt:defaults.prompt,result:defaults.result,
label:defaults.label,accent:defaults.accent,extraHold:0};
export const sequenceBeats:{kind:Kind;frames:number;p?:Partial<EditProps>}[]=[
{kind:'TitleCut',frames:150},
{kind:'CameraPush',frames:150,p:{settle:66,hold:84}},
{kind:'FocusType',frames:210,p:{hold:84}},
{kind:'SplitResult',frames:210,p:{hold:96}},
{kind:'ChapterReturn',frames:180,p:{settle:120,hold:60,title:'Compare the possibilities.'}},
{kind:'MotifMontage',frames:180,p:{title:'Three ways to see one idea.'}},
{kind:'ObjectContext',frames:180,p:{hold:96}},
{kind:'QuietEnding',frames:240,p:{hold:150}},
];
export function sequenceLayout(props:SequenceProps){
const {title,prompt,result,label,accent,extraHold}=props;
const content={title,prompt,result,label,accent};
validate({...defaults,...content});
if(!Number.isInteger(extraHold)||extraHold<0||extraHold>300)
throw new Error('extraHold must be 0–300 integer frames (50–60 seconds total).');
let offset=0;
return sequenceBeats.map((beat,i)=>{
const extension=i===sequenceBeats.length-1?extraHold:0;
const p=validateEdit(beat.kind,{...defaults,...timing[beat.kind],...content,...beat.p,
hold:(beat.p?.hold??timing[beat.kind].hold)+extension});
const frames=duration(p);
if(frames!==beat.frames+extension)throw new Error(`Sequence duration mismatch: ${beat.kind}`);
const from=offset;offset+=frames;
return {kind:beat.kind,p,from,frames};
});
}
src/Edits.tsx
Download this file
import {AbsoluteFill, useCurrentFrame} from 'remotion';
import type {EditProps, Kind} from './model';
import {Artwork, Box, Camera, ease, ink, lerp, paper, Pointer, progress, Proof, Stage, Title, Window} from './visuals';
/** Every animation is a pure function of the local frame. No CSS timers or network assets. */
export const Edit = ({kind,p}:{kind:Kind;p:EditProps}) => {
const f=useCurrentFrame(), a=progress(f,p.action,p.change), b=progress(f,p.change,Math.max(p.change+1,p.settle));
let body;
switch(kind) {
case 'TitleCut':
body=f<p.settle?<Title text={p.title} p={p} reveal={a}/>:<Proof p={p}/>;
break;
case 'ChapterReturn':
body=f>=p.action&&f<p.settle?<Title text={p.title} p={p} reveal={a}/>:<Proof p={p}/>;
break;
case 'PunchCut':
body=<Camera p={p} amount={f>=p.change?1:0}><Proof p={p}/></Camera>;
break;
case 'CameraPush':
body=<Camera p={p} amount={ease(a)}><Proof p={p}/>{f>=p.settle&&<Box x={850} y={470} w={260} h={60} style={{background:p.accent,color:'white',padding:20,borderRadius:8,fontSize:16}}>Open selected study ↗</Box>}</Camera>;
break;
case 'FocusType': {
const text=Array.from(p.prompt).slice(0,Math.round(Array.from(p.prompt).length*b)).join('');
body=<Window p={p}>
<Box x={334} y={220} w={750} h={80} style={{fontSize:42,fontWeight:700}}>Start with a clear request.</Box>
<Box x={334} y={365} w={750} h={150} style={{border:`2px solid ${f>=p.change?p.accent:'#d8ddd6'}`,borderRadius:16,padding:30,fontSize:26,background:'#fff'}}>
<span style={{color:text?ink:'#7a827b'}}>{text||(f<p.change?'Describe the next study':'')}</span>{f>=p.change&&f<p.settle&&<span style={{borderLeft:`2px solid ${p.accent}`,marginLeft:3}}/>}
<span style={{position:'absolute',right:22,bottom:16,fontSize:15,color:p.accent}}>↑</span>
</Box>
{f<p.settle&&<Pointer x={lerp(1070,365,ease(a))} y={lerp(310,402,ease(a))}/>}
</Window>;break;
}
case 'SelectionContext':
body=<Camera p={{...p,focusX:.58,focusY:.49}} amount={f<p.change?ease(a):1-ease(b)}><Window p={p}>
<Box x={334} y={168} w={720} h={64} style={{fontSize:32,fontWeight:700}}>Exhibition plan</Box>
{['Study one — form','Study two — texture','Study three — color'].map((text,i)=><Box key={text} x={334} y={258+i*65} w={680} h={52} style={{fontSize:23,padding:12,background:f>=p.action?'#d5e4da':'#f1f3ee',borderBottom:'1px solid #c7d1c8'}}>{text}</Box>)}
{f>=p.change&&<Box x={500} y={474} w={514} h={56} style={{background:ink,color:'white',borderRadius:10,padding:16,fontSize:17}}>{p.prompt}</Box>}
</Window></Camera>;break;
case 'ScrollMask':
body=<Window p={p} header="CATALOG / FIXED VIEWPORT">
<Box x={312} y={148} w={815} h={64} style={{fontSize:32,fontWeight:700}}>Find the third study.</Box>
<Box x={312} y={230} w={815} h={360} style={{overflow:'hidden',border:'1px solid #dde2d9',borderRadius:10}}>
<div style={{transform:`translateY(${-360*ease(a)}px)`}}>{[0,1,2,3,4].map(i=><div key={i} style={{height:180,padding:22,borderBottom:'1px solid #dbe2d6',display:'flex',alignItems:'center',gap:30,background:i===2?'#e7eee7':'#fff'}}><div style={{width:195,height:125}}><Artwork accent={p.accent} variant={i}/></div><div><div style={{fontSize:12,letterSpacing:2}}>CATALOG / 0{i+1}</div><div style={{fontSize:26,marginTop:10}}>{i===2?p.result:`Form exploration ${i+1}`}</div></div></div>)}</div>
</Box>
</Window>;break;
case 'SplitResult': {
const width=lerp(800,300,ease(a));
body=<Window p={p}>
<Box x={312} y={165} w={width} h={418} style={{padding:24,background:'#f1f4ed',borderRadius:12,overflow:'hidden'}}><div style={{fontSize:12,letterSpacing:2,marginBottom:22}}>REQUEST</div><div style={{fontSize:25,lineHeight:1.35,maxWidth:252}}>{p.prompt}</div><div style={{marginTop:40,fontSize:14,color:p.accent}}>{f<p.change?'Preparing a study…':'Study available'}</div></Box>
{f>=p.action&&<Box x={640} y={165} w={486} h={418} style={{opacity:Math.min(1,a*2),background:'#f7f8f4',border:'1px solid #dbe2d7',borderRadius:12,overflow:'hidden'}}>
{f<p.change?<div style={{padding:30,color:'#66736a'}}>Opening preview…</div>:<div style={{opacity:b,transform:`translateY(${14*(1-ease(b))}px)`,padding:20}}><div style={{fontSize:20,fontWeight:700,marginBottom:20}}>{p.result}</div><Artwork accent={p.accent}/></div>}
</Box>}
</Window>;break;
}
case 'ObjectContext':
body=f<p.change?<>
<Box x={410} y={90} w={460} h={296}><Artwork accent={p.accent}/></Box>
<Box x={340} y={442} w={600} h={115} style={{background:'white',border:'1px solid #cbd6ca',borderRadius:18,padding:30,fontSize:27}}>{p.prompt}</Box>
</>:<>
<Box x={430} y={40} w={420} h={640} style={{background:'white',border:`2px solid ${ink}`,borderRadius:36,overflow:'hidden',transform:`translateY(${24*(1-ease(b))}px) scale(${lerp(.96,1,ease(b))})`}}>
<div style={{height:46,borderBottom:'1px solid #d6dfd5',padding:15,textAlign:'center',fontSize:12,letterSpacing:2}}>FIELD DESK / MOBILE</div>
{f>=p.change+4&&<div style={{padding:24,opacity:progress(f,p.change+4,p.settle)}}><div style={{width:228,height:146}}><Artwork accent={p.accent}/></div><p style={{fontSize:19,lineHeight:1.4}}>{p.prompt}</p><div style={{borderTop:'1px solid #d6dfd5',paddingTop:24,opacity:b,fontSize:24,lineHeight:1.4}}>{p.result}</div></div>}
</Box>
</>;break;
case 'CaptionCut':
body=<><Camera p={p} amount={f>=p.change?1:0}><Proof p={p}/></Camera>
{f>=p.action&&<Box x={72} y={590} w={590} h={84} style={{background:ink,color:'white',padding:'15px 24px',borderLeft:`6px solid ${p.accent}`,zIndex:10}}><div style={{fontSize:23,fontWeight:700}}>{p.label}</div><div style={{fontSize:14,marginTop:6}}>Original artifact / fixed screen-space identifier</div></Box>}
</>;break;
case 'MotifMontage': {
const interval=p.change-p.action;
const variant=f<p.action?0:Math.min(2,1+Math.floor((f-p.action)/interval));
body=<><Box x={140} y={68} w={1000} h={80} style={{fontSize:36,fontWeight:700}}>{p.title}</Box><Box x={230} y={173} w={820} h={525}><Artwork accent={p.accent} variant={variant}/></Box></>;break;
}
case 'DensityCollapse': {
const collapse=ease(b),scale=lerp(1,.03,collapse);
body=f>=p.settle?<Title text={p.title} p={p}/>:<AbsoluteFill style={{transform:`translateY(${-a*30}px) scale(${scale})`,opacity:1-collapse*.7}}>{[0,1,2,3,4,5].map(i=><Box key={i} x={40+(i%3)*416} y={-30+Math.floor(i/3)*385} w={460} h={330} style={{background:'white',border:'12px solid white',boxShadow:'0 12px 30px #18272228',transform:`rotate(${i%2?5:-4}deg)`}}><Artwork accent={p.accent} variant={i}/></Box>)}</AbsoluteFill>;break;
}
case 'QuietEnding':
body=f<p.action?<Proof p={p}/>:<AbsoluteFill style={{background:paper,alignItems:'center',justifyContent:'center'}}>
<div style={{display:'flex',gap:14,height:100,alignItems:'flex-end'}}>{[0,1,2].map(i=><div key={i} style={{width:27,height:lerp(0,54+i*22,ease(progress(f,p.change+i*4,p.settle))),background:p.accent}}/>)}</div>
<div style={{marginTop:30,fontSize:17,letterSpacing:4}}>FIELDNOTES</div>
</AbsoluteFill>;break;
}
return <Stage>{body}</Stage>;
};
src/visuals.tsx
Download this file
import React from 'react';
import {AbsoluteFill, Easing, interpolate} from 'remotion';
import type {EditProps} from './model';
export const W = 1280, H = 720;
export const ink = '#182722', paper = '#f2f1e9';
export const progress = (f: number, a: number, b: number) =>
interpolate(f, [a,b], [0,1], {extrapolateLeft:'clamp', extrapolateRight:'clamp'});
export const ease = (v:number) => Easing.bezier(.16,1,.3,1)(v);
export const lerp = (a:number,b:number,t:number) => a+(b-a)*t;
export const Box: React.FC<React.PropsWithChildren<{x:number;y:number;w:number;h:number;style?:React.CSSProperties}>> =
({x,y,w,h,style,children}) => <div style={{position:'absolute',left:x,top:y,width:w,height:h,...style}}>{children}</div>;
export const Stage: React.FC<React.PropsWithChildren> = ({children}) =>
<AbsoluteFill style={{background:paper,color:ink,fontFamily:'Study Sans',overflow:'hidden'}}>{children}</AbsoluteFill>;
export const Camera: React.FC<React.PropsWithChildren<{amount:number;p:EditProps}>> = ({amount,p,children}) => {
const scale=lerp(1,p.zoom,amount);
const x=lerp(0,W/2-p.focusX*W*p.zoom,amount), y=lerp(0,H/2-p.focusY*H*p.zoom,amount);
return <AbsoluteFill style={{transform:`translate(${x}px,${y}px) scale(${scale})`,transformOrigin:'0 0'}}>{children}</AbsoluteFill>;
};
export const Pointer = ({x,y}:{x:number;y:number}) => <svg width="28" height="36" viewBox="0 0 28 36" style={{position:'absolute',left:x,top:y,zIndex:6}}><path d="M3 2 L24 22 L15 23 L20 32 L14 35 L9 25 L3 31Z" fill={ink} stroke="white" strokeWidth="2"/></svg>;
export const Artwork = ({accent,variant=0}:{accent:string;variant?:number}) => {
const colors=[accent,'#ba633c','#606dad','#b99036'];
return <svg viewBox="0 0 500 320" width="100%" height="100%" aria-label="Original geometric exhibition study">
<rect width="500" height="320" fill={variant%2?'#e6e5d7':'#e3ece6'}/>
<circle cx="250" cy="142" r="96" fill={colors[variant%4]}/>
<path d="M154 142H346V242H154Z" fill={ink}/>
<circle cx="250" cy="142" r="40" fill="#f5f3e9"/>
<path d="M55 280H445" stroke={ink} strokeWidth="2"/>
<text x="55" y="306" fill={ink} fontFamily="Study Sans" fontSize="15" letterSpacing="3">FORM STUDY / 0{variant+1}</text>
</svg>;
};
export const Window: React.FC<React.PropsWithChildren<{p:EditProps;header?:string}>> = ({p,header,children}) => <>
<Box x={80} y={68} w={1120} h={584} style={{background:'#fff',border:'1px solid #c9cfc7',borderRadius:16,overflow:'hidden',boxShadow:'0 20px 65px #26392a15'}}>
<div style={{height:52,background:'#fbfcf8',borderBottom:'1px solid #e1e5dd',display:'flex',alignItems:'center',padding:'0 25px',gap:8}}>
{[0,1,2].map(i=><i key={i} style={{width:8,height:8,borderRadius:'50%',background:'#c0c8bf'}}/>)}
<span style={{fontSize:14,marginLeft:24,letterSpacing:1}}>FIELD DESK</span><span style={{marginLeft:'auto',fontSize:12,color:'#627066'}}>{header??p.label}</span>
</div>
<div style={{position:'absolute',top:52,left:0,width:188,bottom:0,borderRight:'1px solid #e1e5dd',padding:24,fontSize:14,color:'#677268'}}>
<div style={{fontSize:11,letterSpacing:2,marginBottom:26}}>WORKSPACE</div>
{['Overview','Exhibition','Library'].map((s,i)=><div key={s} style={{padding:'13px 0',color:i===1?p.accent:undefined,fontWeight:i===1?700:400}}>{s}</div>)}
<div style={{position:'absolute',bottom:24,fontSize:11}}>Original research fixture</div>
</div>
</Box>{children}
</>;
export const Proof = ({p,variant=0}:{p:EditProps;variant?:number}) => <Window p={p}>
<Box x={314} y={156} w={812} h={70}><div style={{fontSize:12,letterSpacing:2,color:p.accent,marginBottom:10}}>EXHIBITION NOTES</div><div style={{fontSize:30,fontWeight:700}}>{p.result}</div></Box>
<Box x={314} y={252} w={510} h={326}><Artwork accent={p.accent} variant={variant}/></Box>
<Box x={852} y={262} w={268} h={288} style={{fontSize:18,lineHeight:1.7}}><b>Selected study</b><p style={{color:'#647166'}}>One motif.<br/>A clear silhouette.<br/>Space to read.</p><div style={{borderTop:'1px solid #d9dfd7',paddingTop:14,fontSize:12,letterSpacing:2}}>READY TO REVIEW</div></Box>
</Window>;
export const Title = ({text,p,reveal=1}:{text:string;p:EditProps;reveal?:number}) => <AbsoluteFill style={{background:paper,justifyContent:'center',alignItems:'center',padding:150}}>
<div style={{fontSize:12,letterSpacing:3,color:p.accent,marginBottom:28}}>FIELDNOTES / AN ORIGINAL STUDY</div>
<div style={{fontSize:64,fontWeight:700,letterSpacing:-2,lineHeight:1.15,textAlign:'center',maxWidth:970}}>{Array.from(text).slice(0,Math.round(Array.from(text).length*reveal)).join('')}</div>
</AbsoluteFill>;
src/recording-timing.ts
Download this file
export type RecordingClock = {
fps: number;
cueSeconds: number;
measuredReactionSeconds: number;
recordingDurationSeconds: number;
actualTailStartSeconds: number;
lastRequiredSpeechSeconds: number;
requiredRemainingTailSeconds: number;
presentationEndFrame: number;
};
export type RecordingBeat = {
id: string;
recordingProofSeconds: number;
proofLocalFrame: number;
durationInFrames: number;
minimumReadingFrames: number;
};
export function alignToRecording(clock: RecordingClock, beats: RecordingBeat[]) {
for (const name of ['fps', 'cueSeconds', 'measuredReactionSeconds',
'recordingDurationSeconds', 'actualTailStartSeconds', 'lastRequiredSpeechSeconds',
'requiredRemainingTailSeconds', 'presentationEndFrame'] as const) {
const value = clock[name];
if (!Number.isFinite(value) || value < 0) {
throw new Error(`${name} must be finite and nonnegative`);
}
}
if (clock.fps <= 0 || !Number.isInteger(clock.presentationEndFrame)) {
throw new Error('fps must be positive; presentationEndFrame must be an integer');
}
if (clock.actualTailStartSeconds >= clock.recordingDurationSeconds) {
throw new Error('An actual ending tail must exist in the supplied recording');
}
if (clock.lastRequiredSpeechSeconds > clock.recordingDurationSeconds) {
throw new Error('Required speech must lie inside the supplied recording');
}
if (clock.requiredRemainingTailSeconds <= 0) {
throw new Error('Choose a positive remaining tail against actual footage and rehearsal');
}
const talkingHeadStartSeconds = clock.cueSeconds + clock.measuredReactionSeconds;
const earliestEndFrame = Math.ceil(
(talkingHeadStartSeconds + Math.max(clock.actualTailStartSeconds, clock.lastRequiredSpeechSeconds)) * clock.fps,
);
const latestEndFrame = Math.floor(
(talkingHeadStartSeconds + clock.recordingDurationSeconds
- clock.requiredRemainingTailSeconds) * clock.fps,
);
if (earliestEndFrame > latestEndFrame) {
throw new Error('The real ending tail is too short; raise this during storyboard review');
}
if (clock.presentationEndFrame < earliestEndFrame
|| clock.presentationEndFrame > latestEndFrame) {
throw new Error(`Presentation end must lie in frames ${earliestEndFrame}–${latestEndFrame}`);
}
const identifiers = new Set<string>();
const scheduled = beats.map((beat) => {
if (!beat.id.trim() || identifiers.has(beat.id)) {
throw new Error('Every recording beat needs a unique, nonempty id');
}
identifiers.add(beat.id);
if (!Number.isFinite(beat.recordingProofSeconds)
|| beat.recordingProofSeconds < 0
|| beat.recordingProofSeconds >= clock.actualTailStartSeconds) {
throw new Error(`${beat.id}: proof must map to a measured time before the real tail`);
}
for (const name of ['proofLocalFrame', 'durationInFrames', 'minimumReadingFrames'] as const) {
if (!Number.isInteger(beat[name]) || beat[name] < 0) {
throw new Error(`${beat.id}: ${name} must be a nonnegative integer`);
}
}
if (beat.minimumReadingFrames < 1
|| beat.proofLocalFrame + beat.minimumReadingFrames > beat.durationInFrames) {
throw new Error(`${beat.id}: completed proof needs its protected reading hold`);
}
const proofFrame = Math.round(
(talkingHeadStartSeconds + beat.recordingProofSeconds) * clock.fps,
);
const from = proofFrame - beat.proofLocalFrame;
const endExclusive = from + beat.durationInFrames;
if (from < Math.ceil(clock.cueSeconds * clock.fps)) {
throw new Error(`${beat.id}: insufficient room after the visible cue; revise the storyboard`);
}
if (endExclusive > clock.presentationEndFrame) {
throw new Error(`${beat.id}: required proof extends beyond the presentation end`);
}
return {...beat, from, proofFrame, endExclusive};
}).sort((left, right) => left.from - right.from);
for (let index = 1; index < scheduled.length; index++) {
if (scheduled[index].from < scheduled[index - 1].endExclusive) {
throw new Error(`${scheduled[index - 1].id} overlaps ${scheduled[index].id}; do not silently accelerate either beat`);
}
}
return {
talkingHeadStartSeconds,
earliestEndFrame,
latestEndFrame,
presentationEndFrame: clock.presentationEndFrame,
beats: scheduled,
};
}
scripts/test-recording-timing.mjs
Download this file
import assert from 'node:assert/strict';
import {alignToRecording} from '../src/recording-timing.ts';
const clock = {
fps: 30, cueSeconds: 3, measuredReactionSeconds: 0.7,
recordingDurationSeconds: 60, actualTailStartSeconds: 57,
lastRequiredSpeechSeconds: 56.5,
requiredRemainingTailSeconds: 2, presentationEndFrame: 1830,
};
const beats = [{
id: 'synthetic-proof', recordingProofSeconds: 10, proofLocalFrame: 60,
durationInFrames: 150, minimumReadingFrames: 90,
}];
const plan = alignToRecording(clock, beats);
assert.equal(plan.talkingHeadStartSeconds, 3.7);
assert.equal(plan.beats[0].from, 351);
assert.equal(plan.beats[0].proofFrame, 411);
assert.equal(plan.beats[0].endExclusive, 501);
assert.equal(plan.earliestEndFrame, 1821);
assert.equal(plan.latestEndFrame, 1851);
assert.throws(() => alignToRecording({...clock, presentationEndFrame: 1800}, beats), /Presentation end/);
assert.throws(() => alignToRecording({...clock, actualTailStartSeconds: 59}, beats), /too short/);
assert.throws(() => alignToRecording({...clock, actualTailStartSeconds: 60}, beats), /actual ending tail/);
assert.throws(() => alignToRecording({...clock, lastRequiredSpeechSeconds: 59.5}, beats), /too short/);
assert.throws(() => alignToRecording({...clock, lastRequiredSpeechSeconds: 61}, beats), /inside the supplied recording/);
assert.throws(() => alignToRecording({...clock, measuredReactionSeconds: Number.NaN}, beats), /finite/);
assert.throws(() => alignToRecording(clock, [...beats, {...beats[0], id: 'overlap'}]), /overlaps/);
assert.throws(() => alignToRecording(clock, [{...beats[0], minimumReadingFrames: 91}]), /reading hold/);
assert.throws(() => alignToRecording(clock, [{...beats[0], recordingProofSeconds: 0}]), /visible cue/);
assert.throws(() => alignToRecording(clock, [{...beats[0], recordingProofSeconds: 57}]), /before the real tail/);
assert.throws(() => alignToRecording(clock, [{...beats[0], durationInFrames: 2000}]), /presentation end/);
assert.throws(() => alignToRecording(clock, [...beats, {...beats[0]}]), /unique/);
const incompleteClock = {...clock};
delete incompleteClock.lastRequiredSpeechSeconds;
assert.throws(() => alignToRecording(incompleteClock, beats), /lastRequiredSpeechSeconds/);
console.log('Recording alignment: synthetic clock mapping and thirteen rejection checks passed. No actual recording has been measured.');
scripts/render-all.mjs
Download this file
import {bundle} from '@remotion/bundler';
import {getCompositions,renderMedia} from '@remotion/renderer';
import {mkdir,writeFile} from 'node:fs/promises';
import path from 'node:path';
const output=path.resolve(process.argv[2]??'out');
await mkdir(output,{recursive:true});
const browserExecutable=process.env.REMOTION_BROWSER_EXECUTABLE||undefined;
const serveUrl=await bundle({entryPoint:path.resolve('src/index.ts')});
const compositions=await getCompositions(serveUrl,{browserExecutable});
const records=[];
for(const composition of compositions){
const outputLocation=path.join(output,`${composition.id}.mp4`);
await renderMedia({composition,serveUrl,codec:'h264',pixelFormat:'yuv420p',crf:18,imageFormat:'png',colorSpace:'bt709',
muted:true,outputLocation,browserExecutable,concurrency:2});
records.push({id:composition.id,width:composition.width,height:composition.height,
fps:composition.fps,frames:composition.durationInFrames,file:`${composition.id}.mp4`,props:composition.defaultProps});
console.log(`${composition.id}: ${composition.durationInFrames} frames`);
}
await writeFile(path.join(output,'render-manifest.json'),JSON.stringify(records,null,2)+'\n');
scripts/test-model.mjs
Download this file
import assert from 'node:assert/strict';
import {defaults,duration,kinds,timing,validateEdit,sequenceDefaults,sequenceLayout} from '../src/model.ts';
for(const kind of kinds){
const p={...defaults,...timing[kind]};validateEdit(kind,p);
assert.equal(duration({...p,hold:p.hold+45}),duration(p)+45);
assert.throws(()=>validateEdit(kind,{...p,hold:0}));
assert.throws(()=>validateEdit(kind,{...p,action:p.change+1}));
assert.throws(()=>validateEdit(kind,{...p,title:'x'.repeat(43)}));
}
const original=sequenceLayout(sequenceDefaults);
const extended=sequenceLayout({...sequenceDefaults,extraHold:60});
assert.equal(original.reduce((s,b)=>s+b.frames,0),1500);
assert.equal(extended.reduce((s,b)=>s+b.frames,0),1560);
assert.deepEqual(extended.slice(0,-1),original.slice(0,-1));
assert.throws(()=>sequenceLayout({...sequenceDefaults,extraHold:301}));
// Extra input fields cannot leak global phase defaults into individual beats.
assert.deepEqual(sequenceLayout({...sequenceDefaults,action:999,settle:999}),original);
assert.throws(()=>validateEdit('QuietEnding',{...defaults,change:86,settle:90}));
assert.throws(()=>validateEdit('ObjectContext',{...defaults,change:88,settle:90}));
console.log('Phase validation, hold-only extension and sequence isolation passed.');
public/fonts/LICENSE.txt
Download the complete original font permission notice. This file remains unchanged in the ZIP; it is not expanded into this reading view.