@vosjs/cli 0.52.0 → 0.52.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vosjs/cli",
|
|
3
|
-
"version": "0.52.
|
|
3
|
+
"version": "0.52.1",
|
|
4
4
|
"description": "The vos CLI: record the real product from a scripted browser flow, auto-zoom from the cursor track, cut as data in doc.json, render deterministic video and stills, deliver a release's media per destination spec, and sync with vos.so. One binary, every verb, MIT.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -49,14 +49,14 @@
|
|
|
49
49
|
"dependencies": {
|
|
50
50
|
"mediabunny": "^1.55.7",
|
|
51
51
|
"playwright": "^1.49.0",
|
|
52
|
-
"@vosjs/
|
|
53
|
-
"@vosjs/
|
|
52
|
+
"@vosjs/elements": "^0.8.2",
|
|
53
|
+
"@vosjs/shared": "^0.4.1",
|
|
54
54
|
"@vosjs/core": "^0.25.0",
|
|
55
55
|
"@vosjs/render-core": "^0.3.6",
|
|
56
|
-
"@vosjs/
|
|
56
|
+
"@vosjs/editor": "^1.3.1",
|
|
57
57
|
"@vosjs/timeline": "^0.4.1",
|
|
58
|
-
"@vosjs/
|
|
59
|
-
"@vosjs/
|
|
58
|
+
"@vosjs/studio-core": "^0.32.0",
|
|
59
|
+
"@vosjs/tween": "^0.8.2"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"@types/node": "^22",
|
package/skills/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.7.1
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: vos-port
|
|
3
|
+
description: Bring a video someone made in Remotion or HyperFrames (or a hand-rolled HTML/canvas animation) into vos as a program whose words, colours and timings are knobs, so it keeps changing without code and lives on vos.so with versions, a studio and free export. Use when asked to "port", "convert", "move" or "bring" a Remotion or HyperFrames project to vos or vosso, to make such a video editable, or to put one on vos.so as more than a file.
|
|
4
|
+
license: MIT
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Port a Remotion or HyperFrames video into vos
|
|
8
|
+
|
|
9
|
+
A port is a REWRITE into a vos program whose content is data. The source
|
|
10
|
+
keeps its look; what changes is that every word, colour and timing a person
|
|
11
|
+
would change becomes a value in `data` with a knob over it, so the next
|
|
12
|
+
change is a data edit (in the studio, or `vos push` of a patched value),
|
|
13
|
+
never another rewrite.
|
|
14
|
+
|
|
15
|
+
Two ways to get this wrong, both seen in real ports:
|
|
16
|
+
|
|
17
|
+
- **One canvas black box.** Painting the whole picture in one Canvas2D
|
|
18
|
+
function on a fullscreen quad matches the frames and leaves the studio
|
|
19
|
+
nothing to edit: every literal in the source becomes a literal in code.
|
|
20
|
+
- **A tabulated page.** Sampling the rendered DOM into CSS keyframes is
|
|
21
|
+
faithful and dead: thousands of sampled values, not one knob.
|
|
22
|
+
|
|
23
|
+
Read `references/intro-port.mjs` first: the Remotion showreel's intro scene,
|
|
24
|
+
ported by these rules and checked against Remotion's own render. It is the
|
|
25
|
+
shape every port takes: real functions, stringified into a `config.json`
|
|
26
|
+
(`node intro-port.mjs` writes one beside it).
|
|
27
|
+
|
|
28
|
+
## The rules (the port grammar)
|
|
29
|
+
|
|
30
|
+
1. **Content is data.** Every string, colour, number and asset a person
|
|
31
|
+
might change lives in `config.data`. Function strings hold none of it.
|
|
32
|
+
Timings live in data too (`data.t`), in SECONDS, converted from frames
|
|
33
|
+
once (`frames / fps`).
|
|
34
|
+
2. **What people change is a knob.** `params` over those keys: `text` for
|
|
35
|
+
words, `color` for the palette (the source's own palette first), `font`
|
|
36
|
+
for faces. Prove each one moves the picture:
|
|
37
|
+
`vos still config.json k.png --time <t> --set data.<key>=<value>`.
|
|
38
|
+
3. **Things are elements.** Text is a `text` element whose `content`,
|
|
39
|
+
`font.family` and `font.color` take `{ "$data": "<key>" }` (they
|
|
40
|
+
re-raster live on a data edit). Logos and shapes are `svg` or `image`
|
|
41
|
+
elements, footage a `video` element. Elements are what the studio selects
|
|
42
|
+
and moves.
|
|
43
|
+
4. **Motion is the timeline.** `createTimeline` holds the tweens, on
|
|
44
|
+
`ctx.elements.get(id).props` (whole) or `.segments` (`split: { type:
|
|
45
|
+
'chars' | 'words' | 'lines' }` units), in the GSAP dialect. Keep curves
|
|
46
|
+
exact: Remotion's `Easing.bezier(a, b, c, d)` is `ease: 'css-bezier(a, b,
|
|
47
|
+
c, d)'`; GSAP eases keep their names.
|
|
48
|
+
5. **Scenes are labels.** One `tl.addLabel('<scene>', t)` per scene or
|
|
49
|
+
`<Sequence>`: the studio draws them on the program's timeline.
|
|
50
|
+
6. **A painter only for procedural pixels.** Noise blobs, grain, a scramble,
|
|
51
|
+
a counter: `onFrame(ctx)` reads `ctx.time` and `ctx.data` and draws or
|
|
52
|
+
writes exactly those. Never the whole picture.
|
|
53
|
+
7. **Sound is a track.** The score goes in `doc.json` beside the config
|
|
54
|
+
(`audio: [{ id, key: "score.ogg", name, start, in, out, duration, gain,
|
|
55
|
+
fadeIn, fadeOut }]`); `vos push` uploads it and `vos render` mixes it
|
|
56
|
+
(`@vosjs/cli` 0.52+). Never a `data:` URI in an audio element.
|
|
57
|
+
8. **Faces from the catalog.** `fonts: [{ family, weight, url }]` with
|
|
58
|
+
`https://assets.vos.so/fonts/<slug>/<weight>.woff2` (`GET
|
|
59
|
+
https://vos.so/api/fonts`). A face the catalog lacks is SUBSTITUTED and
|
|
60
|
+
the substitution is said in the push note.
|
|
61
|
+
|
|
62
|
+
## The coordinates, measured (these cost the reference port three rounds)
|
|
63
|
+
|
|
64
|
+
- **`props.x` / `props.y` are RENDER pixels, centre-relative, and `y` grows
|
|
65
|
+
DOWN**, like CSS `translateY`. A source offset in design pixels (a
|
|
66
|
+
1080-high frame) is scaled: `const k = ctx.resolution.height / 1080`,
|
|
67
|
+
then `{ y: y0 + 190 * k }`. An unscaled offset looks right at 1080p and
|
|
68
|
+
doubles at 540p. Render at two sizes to catch one.
|
|
69
|
+
- **`rotation` is degrees, counter-clockwise**: CSS `rotate(12deg)` is
|
|
70
|
+
`rotation: -12`.
|
|
71
|
+
- **A static offset goes in the config**, `transform: { translateX,
|
|
72
|
+
translateY }` in design pixels, never a `tl.set` on `props.x/y`, for any
|
|
73
|
+
text whose content or colour changes live: every re-raster lays the
|
|
74
|
+
element out again from its config and drops a tweened position.
|
|
75
|
+
- **Bind a live text to its full words.** Text that `onFrame` writes each
|
|
76
|
+
frame (a scramble, a counter) starts as `{ "$data": "subtitle" }`, never
|
|
77
|
+
`' '`: a still capture (and so the vos.so thumbnail) misses what `onFrame`
|
|
78
|
+
writes, and then shows the bound words instead of nothing.
|
|
79
|
+
- **Transform origin is the centre.** Remotion's `transformOrigin: 'right
|
|
80
|
+
center'` with `scaleX` becomes a centre scale plus an `x` tween that keeps
|
|
81
|
+
the right edge still: `{ scaleX: 0, x: x0 + (width / 2) * k }`.
|
|
82
|
+
|
|
83
|
+
## The mapping
|
|
84
|
+
|
|
85
|
+
- Remotion: `references/remotion.md`.
|
|
86
|
+
- HyperFrames: `references/hyperframes.md`.
|
|
87
|
+
- A hand-rolled page (a single HTML file, its own canvas engine): read it as
|
|
88
|
+
source with the same tables. What the page draws with DOM becomes
|
|
89
|
+
elements; what it paints in a canvas is a painter, kept to the procedural
|
|
90
|
+
parts.
|
|
91
|
+
|
|
92
|
+
## The procedure
|
|
93
|
+
|
|
94
|
+
1. **Inventory before writing.** From the source: size, fps, duration;
|
|
95
|
+
scenes and their frame windows; every visible string; every colour (CSS
|
|
96
|
+
custom properties, theme constants); fonts; media and audio files; the
|
|
97
|
+
variables or `defaultProps` (they become params). Write them into `data`
|
|
98
|
+
first. This IS the port's contract.
|
|
99
|
+
2. **Get the source's own render.** Remotion: `npx remotion render <id>
|
|
100
|
+
out/source.mp4`. HyperFrames: `npx hyperframes render`. It is the
|
|
101
|
+
reference every check compares against.
|
|
102
|
+
3. **Scaffold** a `build.mjs` from `references/intro-port.mjs`: real
|
|
103
|
+
functions stringified, a template-literal guard, `data`, `params`,
|
|
104
|
+
`fonts`, one element per string and shape, a label per scene.
|
|
105
|
+
4. **Translate scene by scene** with the tables. One scene, then check it,
|
|
106
|
+
then the next.
|
|
107
|
+
5. **Check** after every scene:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
node build.mjs && vos check config.json
|
|
111
|
+
vos still config.json v.png --times 0.5,1,2 --width 1920 --height 1080
|
|
112
|
+
ffmpeg -ss 1 -i out/source.mp4 -frames:v 1 r-1.png
|
|
113
|
+
ffmpeg -i r-1.png -i v-1.00s.png -filter_complex "[0:v][1:v]ssim" -f null - # SSIM
|
|
114
|
+
ffmpeg -i r-1.png -i v-1.00s.png -filter_complex hstack side.png # LOOK at it
|
|
115
|
+
vos render config.json small.mp4 --width 960 --height 540 # the scale check
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
A number does not catch a missing element: look at every side-by-side.
|
|
119
|
+
Stills are for layout; text that `onFrame` writes shows in `vos render`
|
|
120
|
+
output, not in `vos still` (a known capture gap), so check it in a
|
|
121
|
+
rendered frame.
|
|
122
|
+
6. **Knob honesty**: one `--set data.<key>=<value>` still per param.
|
|
123
|
+
7. **Push** with the score: `vos push config.json --folder <slug> --label
|
|
124
|
+
"port of <source>" --note "<what was substituted, what is a painter>"
|
|
125
|
+
--wait`, and hand over the watch and studio links.
|
|
126
|
+
|
|
127
|
+
## The honest gaps (say them in the push note)
|
|
128
|
+
|
|
129
|
+
These have no element equivalent today; each becomes a painter item or a
|
|
130
|
+
stated approximation:
|
|
131
|
+
|
|
132
|
+
| Source | In vos today |
|
|
133
|
+
| --- | --- |
|
|
134
|
+
| `overflow: hidden` masks, `clip-path` reveals | no element masks: approximate with opacity, or paint the item |
|
|
135
|
+
| a colour per split unit (`charStyle` making one letter red) | segments carry x/y/opacity/scale/rotation only: a separate element, or paint it |
|
|
136
|
+
| `mixBlendMode` | no blend modes on elements: paint it |
|
|
137
|
+
| a shape's colour as a knob | an svg's colours are compiled in (static `colors`): paint the shape if its colour must change live |
|
|
138
|
+
| `spring()` | no spring ease in the dialect: a `back.out(n)` of the same shape, checked by eye |
|
|
139
|
+
| `@remotion/noise`, SVG `feTurbulence` | the painter |
|
|
140
|
+
| a group transform (scale the whole scene) | no element groups: tween each element the same way |
|
|
141
|
+
|
|
142
|
+
Colours render as authored from `@vosjs/elements` 0.8.2 (older versions
|
|
143
|
+
drew text, svg and image elements lighter): match the source's hex values
|
|
144
|
+
exactly and never adjust a colour by eye.
|
|
145
|
+
|
|
146
|
+
## When not to port
|
|
147
|
+
|
|
148
|
+
A piece that is mostly procedural canvas (a hand-rolled engine painting
|
|
149
|
+
every frame) has few elements to give: port it as a painter with its values
|
|
150
|
+
in `data`, and say that its knobs are the values, not the layout. A piece
|
|
151
|
+
whose owner only wants it hosted can be ingested as it is: `vos ingest
|
|
152
|
+
render.mp4` opens a finished render bare (0.52+).
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# HyperFrames → vos
|
|
2
|
+
|
|
3
|
+
A HyperFrames composition is HTML and CSS moved by ONE paused GSAP timeline.
|
|
4
|
+
vos speaks the GSAP dialect, so most calls carry over nearly verbatim; what
|
|
5
|
+
changes is the target (an element's `props` instead of a DOM node) and the
|
|
6
|
+
units (render pixels, y down, degrees counter-clockwise).
|
|
7
|
+
|
|
8
|
+
| HyperFrames | vos |
|
|
9
|
+
| --- | --- |
|
|
10
|
+
| root `data-width`, `data-height`, `data-duration` | render size; `config.duration` |
|
|
11
|
+
| `data-composition-variables`, CSS custom properties on the root (`--bg`, `--accent`) | `data` keys and `params` (a colour variable is a `color` knob) |
|
|
12
|
+
| a text node or heading | a `text` element, `content: { "$data": key }` |
|
|
13
|
+
| inline `<svg>` / `<img>` | an `svg` element (`src` is the markup) / an `image` element, the file uploaded |
|
|
14
|
+
| `<video data-start data-media-start>` | a `video` element, windowed by opacity; no source in-point yet |
|
|
15
|
+
| `<audio data-start data-duration data-volume>` | `doc.json` `audio: [{ key, start, in: 0, out: duration, duration, gain: volume, fadeIn, fadeOut }]` |
|
|
16
|
+
| `gsap.timeline({ paused: true })` at `window.__timelines[id]` | `createTimeline` returns `ctx.gsap.timeline({ paused: true })`; vos drives it |
|
|
17
|
+
| `tl.to / from / fromTo / set` on a DOM node | the same call on `ctx.elements.get(id).props` |
|
|
18
|
+
| `x`, `y` in CSS pixels | `* k` (`k = ctx.resolution.height / 1080`); y grows down |
|
|
19
|
+
| `rotation` / `rotate` in degrees | negate it (vos is counter-clockwise) |
|
|
20
|
+
| SplitText-style per-character motion | `split: { type: 'chars' }` + tweens over `segments` |
|
|
21
|
+
| a sub-composition or scene `div` with a time window | `tl.addLabel(name, t)` and its elements tweened in and out inside the window |
|
|
22
|
+
| an `onUpdate` proxy clock that draws (`renderAll(t)`) | `onFrame(ctx)`, reading `ctx.time`, for exactly those procedural parts |
|
|
23
|
+
| GSAP ease names (`power3.out`, `back.out(1.7)`, `expo.inOut`) | the same names |
|
|
24
|
+
| CSS `--chrome` set by the timeline | a `data` value read in `onFrame`, or a tween on the element that shows it |
|
|
25
|
+
| faces resolved by the runtime from CSS families | `fonts: [{ family, weight, url }]` from the catalog |
|
|
26
|
+
| `mix-blend-mode`, `clip-path`, `overflow: hidden` reveals | GAPS: the painter, or an opacity approximation, said |
|
|
27
|
+
|
|
28
|
+
Two traps specific to HyperFrames sources:
|
|
29
|
+
|
|
30
|
+
- The runtime rewrites a sub-composition's CSS (it scopes selectors), which
|
|
31
|
+
can flip the cascade: the file you read is not always the document that
|
|
32
|
+
renders. When a colour or size disagrees with the render, believe the
|
|
33
|
+
render (`npx hyperframes render`), not the stylesheet.
|
|
34
|
+
- `window.__player.seek(t)` is the clock the renderer uses; seeking
|
|
35
|
+
`__timelines.main` does not drive a mounted sub-composition. Read times
|
|
36
|
+
from the render, not by scrubbing the main timeline.
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// The Remotion showreel's INTRO scene (62 frames at 30 fps), ported to a
|
|
2
|
+
// data-first vos program. Authored as real functions, stringified here.
|
|
3
|
+
import { writeFileSync } from 'node:fs'
|
|
4
|
+
|
|
5
|
+
const FPS = 30
|
|
6
|
+
const f = (frames) => +(frames / FPS).toFixed(4) // Remotion frames → seconds
|
|
7
|
+
|
|
8
|
+
// Remotion Easing.bezier curves, spelled as the dialect's css-bezier.
|
|
9
|
+
const EXPO_OUT = 'css-bezier(0.16, 1, 0.3, 1)'
|
|
10
|
+
const EXPO_IN = 'css-bezier(0.7, 0, 0.84, 0)'
|
|
11
|
+
const IN_OUT = 'css-bezier(0.83, 0, 0.17, 1)'
|
|
12
|
+
|
|
13
|
+
function createContent(ctx) {
|
|
14
|
+
// The ground is a knob: read in onFrame, never baked.
|
|
15
|
+
ctx.scene.background = new ctx.THREE.Color(ctx.data.ink)
|
|
16
|
+
return { objects: [], refs: {}, dispose: () => {} }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function createTimeline(ctx, content, duration) {
|
|
20
|
+
const { gsap, elements, data } = ctx
|
|
21
|
+
const T = data.t
|
|
22
|
+
// Element props x/y are RENDER pixels; the source's offsets are design
|
|
23
|
+
// pixels on a 1080-high frame. Scale every offset by k.
|
|
24
|
+
const k = ctx.resolution.height / 1080
|
|
25
|
+
const tl = gsap.timeline({ paused: true })
|
|
26
|
+
tl.addLabel('intro', 0)
|
|
27
|
+
|
|
28
|
+
// The pill: a 40px dot that pops, stretches to a line, drops, and leaves
|
|
29
|
+
// toward its right end (Remotion's transformOrigin: right center).
|
|
30
|
+
const line = elements.get('line').props
|
|
31
|
+
const y0 = line.y
|
|
32
|
+
const x0 = line.x
|
|
33
|
+
tl.fromTo(line, { scale: 0 }, { scale: 1, duration: T.pop, ease: 'back.out(2.2)' }, 0)
|
|
34
|
+
tl.fromTo(line, { scaleX: 40 / 1180 }, { scaleX: 1, duration: T.stretch, ease: T.expoOut }, T.stretchAt)
|
|
35
|
+
tl.fromTo(line, { scaleY: 1 }, { scaleY: 0.2, duration: T.thin, ease: T.expoOut }, T.stretchAt)
|
|
36
|
+
tl.fromTo(line, { y: y0 }, { y: y0 + 190 * k, duration: T.drop, ease: T.expoOut }, T.dropAt)
|
|
37
|
+
tl.to(line, { scaleX: 0, x: x0 + 590 * k, duration: T.leave, ease: T.expoIn }, T.leaveAt)
|
|
38
|
+
|
|
39
|
+
// The title rises letter by letter, then leaves upward.
|
|
40
|
+
tl.addLabel('title', T.titleAt)
|
|
41
|
+
const segs = (elements.get('title') || {}).segments || []
|
|
42
|
+
segs.forEach((seg, i) => {
|
|
43
|
+
const at = T.titleAt + i * T.stagger
|
|
44
|
+
tl.fromTo(
|
|
45
|
+
seg,
|
|
46
|
+
{ y: seg.y + 380 * k, rotation: -12, opacity: 0 },
|
|
47
|
+
{ y: seg.y, rotation: 0, opacity: 1, duration: T.rise, ease: T.expoOut },
|
|
48
|
+
at,
|
|
49
|
+
)
|
|
50
|
+
tl.to(seg, { y: seg.y - 380 * k, opacity: 0, duration: T.exit, ease: T.expoIn }, T.leaveAt + i * T.exitStagger)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// The subtitle's words are onFrame's; its place is its config transform,
|
|
54
|
+
// because every content write re-lays it out from the config.
|
|
55
|
+
const sub = elements.get('subtitle').props
|
|
56
|
+
tl.to(sub, { opacity: 0, duration: T.leave, ease: T.expoIn }, T.leaveAt)
|
|
57
|
+
|
|
58
|
+
// The red wipe grows from the centre and covers the frame.
|
|
59
|
+
tl.addLabel('wipe', T.wipeAt)
|
|
60
|
+
const wipe = elements.get('wipe').props
|
|
61
|
+
tl.fromTo(wipe, { scale: 0 }, { scale: 1, duration: T.wipe, ease: T.inOut }, T.wipeAt)
|
|
62
|
+
|
|
63
|
+
tl.to({}, { duration: 0.01 }, duration)
|
|
64
|
+
return tl
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function onFrame(ctx) {
|
|
68
|
+
const d = ctx.data
|
|
69
|
+
ctx.scene.background.set(d.ink)
|
|
70
|
+
// Scramble: a typewriter whose leading edge is glyph noise, a pure
|
|
71
|
+
// function of time and the words in data.
|
|
72
|
+
const el = ctx.elements.get('subtitle')
|
|
73
|
+
if (!el) return
|
|
74
|
+
const frame = ctx.time * 30
|
|
75
|
+
const n = Math.max(0, (frame - d.t.scrambleAt * 30) * 1.4)
|
|
76
|
+
const text = String(d.subtitle)
|
|
77
|
+
const glyphs = '!<>-_\\/[]{}=+*^?#01ABCDEFXYZ'
|
|
78
|
+
let out = ''
|
|
79
|
+
for (let i = 0; i < text.length; i++) {
|
|
80
|
+
if (i < Math.floor(n) - 3) out += text[i]
|
|
81
|
+
else if (i < n) out += text[i] === ' ' ? ' ' : glyphs[(i * 7 + Math.floor(frame)) % glyphs.length]
|
|
82
|
+
else out += ' '
|
|
83
|
+
}
|
|
84
|
+
if (el.props.content !== out) el.props.content = out
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const config = {
|
|
88
|
+
version: 2,
|
|
89
|
+
duration: f(62),
|
|
90
|
+
camera: { preset: 'fullscreen' },
|
|
91
|
+
fonts: [
|
|
92
|
+
{ family: 'Anton', weight: 400, url: 'https://assets.vos.so/fonts/anton/400.woff2' },
|
|
93
|
+
{ family: 'JetBrains Mono', weight: 500, url: 'https://assets.vos.so/fonts/jetbrains-mono/500.woff2' },
|
|
94
|
+
],
|
|
95
|
+
elements: [
|
|
96
|
+
{
|
|
97
|
+
id: 'line',
|
|
98
|
+
type: 'svg',
|
|
99
|
+
src: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1180 40"><rect width="1180" height="40" rx="20" fill="#F2EFE8"/></svg>',
|
|
100
|
+
position: 'center',
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: 'title',
|
|
104
|
+
type: 'text',
|
|
105
|
+
content: { $data: 'title' },
|
|
106
|
+
font: { family: 'Anton', size: 330, weight: 400, color: { $data: 'paper' }, letterSpacing: -4 },
|
|
107
|
+
split: { type: 'chars' },
|
|
108
|
+
position: 'center',
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
id: 'subtitle',
|
|
112
|
+
type: 'text',
|
|
113
|
+
// Bound to the full words: onFrame writes each frame's string, and a
|
|
114
|
+
// still capture, which misses onFrame's writes, shows these words.
|
|
115
|
+
content: { $data: 'subtitle' },
|
|
116
|
+
font: { family: 'JetBrains Mono', size: 26, weight: 500, color: { $data: 'paper' }, letterSpacing: 6 },
|
|
117
|
+
position: 'center',
|
|
118
|
+
transform: { translateY: 238 },
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: 'wipe',
|
|
122
|
+
type: 'svg',
|
|
123
|
+
src: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2500 2500"><circle cx="1250" cy="1250" r="1250" fill="#FF4D2E"/></svg>',
|
|
124
|
+
position: 'center',
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
data: {
|
|
128
|
+
ink: '#0B0B0F',
|
|
129
|
+
paper: '#F2EFE8',
|
|
130
|
+
title: 'MOTION',
|
|
131
|
+
subtitle: 'A SHOWREEL WRITTEN ENTIRELY IN CODE',
|
|
132
|
+
// Every timing the scene has, in seconds, from Remotion's frames.
|
|
133
|
+
t: {
|
|
134
|
+
pop: f(8),
|
|
135
|
+
stretchAt: f(8),
|
|
136
|
+
stretch: f(16),
|
|
137
|
+
thin: f(8),
|
|
138
|
+
dropAt: f(14),
|
|
139
|
+
drop: f(14),
|
|
140
|
+
titleAt: f(14),
|
|
141
|
+
stagger: f(2.2),
|
|
142
|
+
rise: f(16),
|
|
143
|
+
scrambleAt: f(24),
|
|
144
|
+
leaveAt: f(42),
|
|
145
|
+
leave: f(12),
|
|
146
|
+
exit: f(10),
|
|
147
|
+
exitStagger: f(1.2),
|
|
148
|
+
wipeAt: f(46),
|
|
149
|
+
wipe: f(16),
|
|
150
|
+
expoOut: EXPO_OUT,
|
|
151
|
+
expoIn: EXPO_IN,
|
|
152
|
+
inOut: IN_OUT,
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
params: [
|
|
156
|
+
{ key: 'title', kind: 'text', default: 'MOTION', hint: 'The word that rises' },
|
|
157
|
+
{ key: 'subtitle', kind: 'text', default: 'A SHOWREEL WRITTEN ENTIRELY IN CODE', hint: 'The line typed under it' },
|
|
158
|
+
{ key: 'paper', kind: 'color', default: '#F2EFE8', hint: 'Type colour' },
|
|
159
|
+
{ key: 'ink', kind: 'color', default: '#0B0B0F', hint: 'Ground' },
|
|
160
|
+
],
|
|
161
|
+
createContent: createContent.toString(),
|
|
162
|
+
createTimeline: createTimeline.toString(),
|
|
163
|
+
onFrame: onFrame.toString(),
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
for (const k of ['createContent', 'createTimeline', 'onFrame']) {
|
|
167
|
+
if (config[k].includes('${')) throw new Error(`${k} holds a template literal`)
|
|
168
|
+
}
|
|
169
|
+
writeFileSync(new URL('./config.json', import.meta.url), JSON.stringify(config, null, 2))
|
|
170
|
+
console.log('wrote config.json')
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Remotion → vos
|
|
2
|
+
|
|
3
|
+
Remotion's model is a pure function of the frame; vos's is a timeline the
|
|
4
|
+
engine seeks. Most of a port is turning `interpolate(frame, …)` calls into
|
|
5
|
+
tweens that start and end at the same seconds with the same curve.
|
|
6
|
+
Remotion's compositing (a persistent ground under transparent scenes that
|
|
7
|
+
cross-fade) is vos's, so cross-fades port with no compensation.
|
|
8
|
+
|
|
9
|
+
The interpolate, bezier, translate, rotate, origin, per-letter, scramble and
|
|
10
|
+
ground rows were checked against Remotion's render in `intro-port.mjs`; GAP
|
|
11
|
+
rows name what has no element equivalent yet.
|
|
12
|
+
|
|
13
|
+
| Remotion | vos |
|
|
14
|
+
| --- | --- |
|
|
15
|
+
| `<Composition width height fps durationInFrames defaultProps schema>` | `config.duration = durationInFrames / fps`; render at `--width/--height`; `defaultProps` → `data`; the zod `schema` → `params` kinds (`z.string()` text, a colour string `color`) |
|
|
16
|
+
| `useCurrentFrame()` | nothing in a tween (the timeline is the clock); `ctx.time * fps` inside `onFrame` |
|
|
17
|
+
| `interpolate(f, [a, b], [x, y], { easing, extrapolate: 'clamp' })` | `tl.fromTo(props, { p: x }, { p: y, duration: (b - a) / fps, ease }, a / fps)` |
|
|
18
|
+
| `Easing.bezier(x1, y1, x2, y2)` | `ease: 'css-bezier(x1, y1, x2, y2)'` (exact) |
|
|
19
|
+
| `spring({ frame, fps, config })` | GAP: no spring ease; `back.out(n)` of a similar overshoot, checked by eye |
|
|
20
|
+
| `<Sequence from durationInFrames>` | `tl.addLabel(name, from / fps)` and the scene's elements tweened in and out inside that window |
|
|
21
|
+
| `<TransitionSeries>` + `fade()` | opacity tweens across the overlap window |
|
|
22
|
+
| `<AbsoluteFill>` + flex centring | `position: 'center'`; offsets in `transform: { translateX, translateY }` (design px) |
|
|
23
|
+
| `translateY(Npx)` animated | `props.y` += `N * k` (`k = ctx.resolution.height / 1080`; y grows down, like CSS) |
|
|
24
|
+
| `rotate(Ndeg)` | `rotation: -N` (degrees, counter-clockwise) |
|
|
25
|
+
| `transformOrigin` other than centre | a centre transform plus a compensating `x`/`y` tween |
|
|
26
|
+
| a React text component per letter (`RiseText`) | `split: { type: 'chars' }` + one tween per `segments[i]` at `start + i * stagger` |
|
|
27
|
+
| `charStyle` giving one letter a colour | GAP: segments carry no colour; a separate element or a painter |
|
|
28
|
+
| `overflow: hidden` letter masks | GAP: no element masks; opacity approximates |
|
|
29
|
+
| a string rebuilt per frame (`Scramble`, a counter) | `onFrame` writes `ctx.elements.get(id).props.content`; the element sized by the full words first |
|
|
30
|
+
| `<Img src={staticFile('x.png')}>` | an `image` element; the file uploaded (`vos asset push x.png`) and its url in `data` |
|
|
31
|
+
| `<Audio src={staticFile('s.mp3')} />` | `doc.json` `audio: [{ key: 's.mp3', start, in, out, duration, gain, fadeIn, fadeOut }]` |
|
|
32
|
+
| `<Video>` / `<OffthreadVideo>` | a `video` element, windowed by opacity; a source in-point is not supported yet |
|
|
33
|
+
| `@remotion/google-fonts` `loadFont()` | `fonts: [{ family, weight, url }]` from `https://vos.so/api/fonts`; a face the catalog lacks is substituted and said |
|
|
34
|
+
| `@remotion/noise` `noise3D`, SVG `feTurbulence` grain | the painter, reading its seed and amounts from `data` |
|
|
35
|
+
| `mixBlendMode` | GAP: the painter |
|
|
36
|
+
| an inline `<svg>` shape | an `svg` element (`src` is the markup); its colours are static, so a shape colour that must be a knob is painted |
|
|
37
|
+
| a solid background | `ctx.scene.background = new ctx.THREE.Color(ctx.data.ink)` in `createContent`, `.set(ctx.data.ink)` in `onFrame` |
|
|
38
|
+
|
|
39
|
+
Two traps specific to Remotion sources:
|
|
40
|
+
|
|
41
|
+
- A side-effect-only font import can be tree-shaken by the project's
|
|
42
|
+
`sideEffects` field, so the SOURCE render may already be in a fallback
|
|
43
|
+
face. Compare the source render's headline width against the declared
|
|
44
|
+
face before blaming the port.
|
|
45
|
+
- `durationInFrames` counts frames, and `interpolate` ranges are frames:
|
|
46
|
+
convert every number once, in `data.t`, and never mix frames and seconds
|
|
47
|
+
inside a function.
|