reelkit-cli 0.5.0 → 0.8.0
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/README.md +3 -2
- package/package.json +7 -2
- package/skill/SKILL.md +22 -11
- package/skill/THIRD_PARTY.md +102 -0
- package/skill/commands/launch-film.md +7 -0
- package/skill/reference/asset-reuse.md +13 -2
- package/skill/reference/backgrounds.md +63 -0
- package/skill/reference/beat-sync.md +32 -17
- package/skill/reference/captions.md +11 -5
- package/skill/reference/component-authoring.md +12 -1
- package/skill/reference/continuity.md +21 -2
- package/skill/reference/kit.md +140 -11
- package/skill/reference/launch-film.md +190 -0
- package/skill/reference/remotion-composition.md +4 -3
- package/skill/reference/scene-treatments.md +20 -0
- package/skill/reference/scriptwriting.md +4 -1
- package/skill/reference/three-d.md +134 -0
- package/skill/reference/voice-sync.md +108 -0
- package/src/agents.ts +23 -12
- package/src/api/client.ts +4 -1
- package/src/cli.ts +25 -8
- package/src/commands/assets.ts +334 -36
- package/src/commands/build.ts +172 -36
- package/src/commands/components.ts +220 -0
- package/src/commands/init.ts +9 -3
- package/src/commands/install.ts +1 -1
- package/src/commands/plan.ts +8 -5
- package/src/commands/ref.ts +5 -2
- package/src/contract/index.ts +27 -5
- package/src/pipeline/beatsnap.ts +72 -0
- package/src/pipeline/review.ts +67 -7
- package/src/pipeline/schema.ts +51 -4
- package/src/pipeline/timing.ts +27 -1
- package/src/project/background.ts +33 -0
- package/src/project/layers.ts +60 -0
- package/src/project/loudness.ts +68 -0
- package/src/project/manifest.ts +68 -14
- package/src/project/music.ts +19 -5
- package/src/project/project.ts +7 -2
- package/src/project/refmeasure.ts +1 -1
- package/src/project/soundreport.ts +347 -0
- package/src/project/svgcheck.ts +21 -0
- package/src/remotion/kit/Assemble3D.tsx +92 -0
- package/src/remotion/kit/BrowserFrame.tsx +83 -0
- package/src/remotion/kit/Camera.tsx +6 -4
- package/src/remotion/kit/Captions.tsx +33 -17
- package/src/remotion/kit/Card3D.tsx +211 -0
- package/src/remotion/kit/ChapterFrame.tsx +68 -0
- package/src/remotion/kit/CounterRoll.tsx +75 -0
- package/src/remotion/kit/GlassPanel.tsx +43 -0
- package/src/remotion/kit/Grounds.tsx +177 -0
- package/src/remotion/kit/Headline.tsx +97 -0
- package/src/remotion/kit/Hero3D.tsx +197 -0
- package/src/remotion/kit/HudOverlay.tsx +52 -0
- package/src/remotion/kit/ImageLayers.tsx +48 -0
- package/src/remotion/kit/Music.tsx +4 -4
- package/src/remotion/kit/NamedCursor.tsx +54 -0
- package/src/remotion/kit/Orbit3D.tsx +49 -0
- package/src/remotion/kit/Particles3D.tsx +74 -0
- package/src/remotion/kit/Place.tsx +12 -0
- package/src/remotion/kit/PromptBox.tsx +84 -0
- package/src/remotion/kit/Scene3D.tsx +70 -0
- package/src/remotion/kit/SceneFrame.tsx +88 -11
- package/src/remotion/kit/Sfx.tsx +12 -6
- package/src/remotion/kit/SoundCues.tsx +22 -0
- package/src/remotion/kit/TerminalLog.tsx +98 -0
- package/src/remotion/kit/Text3D.tsx +78 -0
- package/src/remotion/kit/TextOnImage.tsx +41 -0
- package/src/remotion/kit/Warp3D.tsx +59 -0
- package/src/remotion/kit/bg-math.ts +179 -0
- package/src/remotion/kit/caption-groups.ts +7 -3
- package/src/remotion/kit/caption-style.ts +45 -0
- package/src/remotion/kit/docs.ts +133 -11
- package/src/remotion/kit/image-layers-math.ts +115 -0
- package/src/remotion/kit/index.ts +43 -1
- package/src/remotion/kit/inter-bold-typeface.ts +3 -0
- package/src/remotion/kit/media.ts +5 -3
- package/src/remotion/kit/motion-math.ts +36 -2
- package/src/remotion/kit/music-math.ts +27 -10
- package/src/remotion/kit/quiet-three.ts +11 -0
- package/src/remotion/kit/sample-text.ts +55 -0
- package/src/remotion/kit/scene3d-context.ts +5 -0
- package/src/remotion/kit/seeded.ts +13 -0
- package/src/remotion/kit/sound-cues.ts +89 -0
- package/src/remotion/kit/sound-kinds.ts +122 -0
- package/src/remotion/kit/theme.ts +2 -0
- package/src/remotion/kit/three-fx-math.ts +192 -0
- package/src/remotion/kit/three-math.ts +145 -0
- package/src/remotion/kit/transition-math.ts +116 -0
- package/src/remotion/kit/ui-math.ts +145 -0
- package/src/remotion/kit/ui-theme.ts +25 -0
- package/src/remotion/kit/word-anchor.ts +107 -0
- package/src/render/contact-sheet.ts +39 -0
- package/src/render/continuity.ts +14 -4
- package/src/render/deps.ts +15 -3
- package/src/render/master.ts +31 -0
- package/src/render/render.ts +15 -8
- package/src/render/sound-notes.ts +106 -0
- package/src/render/static-check.ts +156 -0
- package/src/render/validate.ts +3 -150
- package/src/render/word-check.ts +181 -0
- package/src/testing/conformance.ts +61 -1
- package/src/testing/fake-api.ts +11 -5
- package/src/testing/fixtures.ts +3 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: three-d
|
|
3
|
+
description: Use when a shot needs real depth - extruded type, a screen floating in space, a ring of cards, a camera that orbits or pulls back - and for deciding whether 3D earns its place at all.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 3D shots
|
|
7
|
+
|
|
8
|
+
The kit has a 3D layer on three.js: `Scene3D`, `Text3D`, `Card3D` and `Orbit3D`, and five more elements for the moments in a film that earn them: `Assemble3D`, `Particles3D`, `Hero3D`, `Screen3D` and `Warp3D`. Everything is deterministic and driven by the frame. You import only from `reelkit/kit`; three itself is not importable from the composition.
|
|
9
|
+
|
|
10
|
+
## When 3D earns its place
|
|
11
|
+
|
|
12
|
+
One hero moment in a film: the product's name landing as thick letters, a screen floating in space and turning to face the viewer, a ring of features that the camera pushes through. That is what the extra depth is for.
|
|
13
|
+
|
|
14
|
+
It reads as a template when everything spins, when several objects each get their own 3D entrance, or when body text is extruded. Type that is read in a sentence stays 2D. Keep 3D to one or two shots in a film, and let the rest be flat and fast.
|
|
15
|
+
|
|
16
|
+
## Which element earns its place
|
|
17
|
+
|
|
18
|
+
One 3D moment per film is usually enough. Choose by what the moment has to say:
|
|
19
|
+
|
|
20
|
+
| The moment | Reach for | Why |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| A reveal: the name, a logo, a number of things arriving | `Assemble3D` (`shape="text"` for a word, `"grid"`, `"sphere"`, `"wall"`, `"ring"`) | Hundreds of blocks build into the thing; the piece counter (`assembledCount`) and the lock sound (`assembleEnd`) come with it |
|
|
23
|
+
| An abstract idea: intelligence, flow, a network, "everything in one place" | `Particles3D` (`from` and `to` shapes, or `"text"`) | Light that changes shape reads as thought, and costs no artwork |
|
|
24
|
+
| The product itself | `Hero3D` (`kind="device"` with a screenshot, `"logo"`, `"coin"`, `"box"`) | A studio-lit object with clearcoat, two coloured rim lights and a soft floor |
|
|
25
|
+
| An interface carried across scenes | `Screen3D` with `poses` | One screen, re-posed at each scene, instead of a new mock each time |
|
|
26
|
+
| A fast change of place or a hyperspace beat | `Warp3D` as a ground, or `burst` as a 12 to 20 frame transition | Streaks from the vanishing point, additive |
|
|
27
|
+
|
|
28
|
+
Set the mood once on the `Scene3D` (`mood="studio"`, `"night"`, `"sunset"` or `"neon"`): it chooses the light colours, their intensities and the fog tint together, so nobody tunes lights by hand. Everything fits a 9:16 frame by default (`fit`, the share of the width at the closest the camera comes), and everything is a pure function of the frame and a `seed`.
|
|
29
|
+
|
|
30
|
+
Cost, measured on this machine (software GL, as every render here): one second (30 frames) of 9:16 at 1080 by 1920 took about 1.4 to 1.9 seconds to render for each of them at its default size: `Assemble3D` of 600 blocks 1.9 s, 700 blocks 1.7 s, a 900-block word 1.5 s; `Particles3D` of 12000 points 1.5 to 1.8 s; `Hero3D` and `Screen3D` 1.4 to 1.5 s; `Warp3D` of 500 streaks 1.6 s. That includes the start of the renderer, so a longer shot costs less per second; a 3D shot is not what makes a film slow, but keep `count` at what the picture needs (`Assemble3D` up to 4000 pieces, `Particles3D` up to 60000 points, both of which cost more in proportion).
|
|
31
|
+
|
|
32
|
+
## How to layer it
|
|
33
|
+
|
|
34
|
+
Put a `Scene3D` inside a `SceneFrame`, over the 2D ground. Its background is transparent by default, so the `BgMesh` or flat colour behind shows through. Captions, small labels and logos stay 2D, in front of it. If the 3D shot is the whole scene, `Scene3D` is the only thing in the `SceneFrame` besides those labels.
|
|
35
|
+
|
|
36
|
+
## What the camera sees, in numbers
|
|
37
|
+
|
|
38
|
+
The default camera has a 50 degree field of view. At distance `d` from the object it sees `0.93 * d` units tall, and that times the frame's width over height across:
|
|
39
|
+
|
|
40
|
+
| Frame | Width seen at z 6 | At z 9 | At z 5.5 |
|
|
41
|
+
|---|---|---|---|
|
|
42
|
+
| 9:16 phone (aspect 0.5625) | 3.1 | 4.7 | 2.9 |
|
|
43
|
+
| 1:1 | 5.6 | 8.4 | 5.1 |
|
|
44
|
+
| 16:9 | 9.9 | 14.9 | 9.1 |
|
|
45
|
+
|
|
46
|
+
So on a phone frame the camera sees about 3 units across at z 6, not 5.5; a card 3 wide fills the whole frame, and a word at size 1.1 is cut off. You do not have to do this arithmetic: leave the sizes out and the kit does it. Distances count from the object, so an object at z 2 with the camera at z 8 is 6 away. The kit exports the same numbers as `visibleWidth(distance, aspect, fov?)` and `visibleHeight(distance, fov?)`.
|
|
47
|
+
|
|
48
|
+
## Sizes: leave them out
|
|
49
|
+
|
|
50
|
+
`Text3D` without `size` is sized from the measured width of its own letters so that it fills `fit` of the frame's width (default 0.8) at the closest the camera comes to it. "Closest" is read from the `Scene3D` camera keys: the key where it sees the least. A word is therefore whole at every moment, on any aspect, and exactly as big as it can be. A line break (`\n`) makes a second line; its height is held to `fit` of the frame's height. Giving `size` turns this off and you are back to doing the arithmetic yourself.
|
|
51
|
+
|
|
52
|
+
A push-in makes the word smaller at the start in proportion: from z 9 to z 6 it begins at two thirds of its final width. A start at about 25 percent further away than the end (z 7.5 to 6) reads as a gentle push; a larger one begins as a small word. Camera sideways moves (`x`) are not counted, so keep the word near the middle when you orbit.
|
|
53
|
+
|
|
54
|
+
`Orbit3D` without `radius` sizes the ring from its cards (the radius at which they stand side by side) and then scales the whole ring, radius and cards together, so that it lies inside `fit` of the frame (default 0.85) at the closest key, however it has turned and on any aspect. Set the cards' proportions with `width` and `height` and leave the rest. A `radius` you give wins and nothing is scaled. Pass `position` to move the ring's middle: it is part of the fit.
|
|
55
|
+
|
|
56
|
+
`Card3D` alone has no camera to fit to: size it with `width` from the table above (a card in a phone frame at z 6 is 2.2 wide at most).
|
|
57
|
+
|
|
58
|
+
## What a ring of cards carries
|
|
59
|
+
|
|
60
|
+
A ring of empty coloured cards reads as placeholders. Every card carries something: a `label` (a word, large, in the film's font), an `icon` (an image or svg from the project, drawn above the label), or a picture (`src`). Use `accent` for the rim, and keep the cards' `color` to the film's palette: one hero colour, its darker and lighter neighbours. A card face is drawn with a soft gradient, a highlight and a rim, so a flat `color` is enough.
|
|
61
|
+
|
|
62
|
+
## Camera moves
|
|
63
|
+
|
|
64
|
+
`camera.keys` move the camera with the same timing rule as the 2D `Camera`: a spring that starts 12 frames before the key's frame and lands on it. Three moves cover most needs:
|
|
65
|
+
|
|
66
|
+
- Push in: `z` from 7.5 to 6 over the shot (a word fitted at its closest, so it never leaves the frame). A bigger push from 9 to 5.5 works for a ring or a card, which are fine small at the start.
|
|
67
|
+
- Orbit a quarter turn: move `x` from -4 to 4 with `z` around 6, and keep `lookAt` on the object.
|
|
68
|
+
- Pull back to reveal: begin close on one card (`z` 3) and end wide (`z` 9) as the others arrive.
|
|
69
|
+
|
|
70
|
+
Keep a held word still for at least 1.5 seconds before the camera moves again.
|
|
71
|
+
|
|
72
|
+
## Keeping type readable
|
|
73
|
+
|
|
74
|
+
`Text3D` that must be read faces the camera: rotation near 0 and the camera in front of it. A word that turns in (`enter="turn"`) must finish turning, and then stay, for 1.5 seconds. Latin text only: Hebrew and other scripts stay 2D (see `reference/hebrew-rtl.md`, which is unchanged). Use the film's one accent colour for the extruded word.
|
|
75
|
+
|
|
76
|
+
## Cost
|
|
77
|
+
|
|
78
|
+
A 3D scene renders slower than 2D, and the first 3D render in a project bundles three. Use it for the shots that need it, not for every scene.
|
|
79
|
+
|
|
80
|
+
## Example: a portrait scene, verified by a render test
|
|
81
|
+
|
|
82
|
+
A word and a ring of labelled cards on a phone frame. Nothing is sized by hand, and a test renders this exact block on 9:16 and checks that nothing touches the edges.
|
|
83
|
+
|
|
84
|
+
```tsx
|
|
85
|
+
import React from "react";
|
|
86
|
+
import { AbsoluteFill } from "remotion";
|
|
87
|
+
import { Card3D, Orbit3D, Scene3D, SceneFrame, Text3D, palettes } from "reelkit/kit";
|
|
88
|
+
import type { VideoProps } from "reelkit/kit";
|
|
89
|
+
|
|
90
|
+
export const Video: React.FC<VideoProps> = ({ manifest }) => {
|
|
91
|
+
const palette = palettes.darkTech;
|
|
92
|
+
const s = manifest.scenes[0];
|
|
93
|
+
return (
|
|
94
|
+
<AbsoluteFill style={{ backgroundColor: palette.bg }}>
|
|
95
|
+
<SceneFrame from={s.startFrame} durationInFrames={s.durationFrames} enter="cut" exit="cut">
|
|
96
|
+
<Scene3D camera={{ keys: [{ frame: 0, z: 7.5, y: 0 }, { frame: 40, z: 6 }] }}>
|
|
97
|
+
<Text3D text="Plan it." color={palette.ink} position={[0, 1.2, 0]} enter="rise" />
|
|
98
|
+
<Orbit3D position={[0, -0.6, 0]} speed={1.2} tiltDeg={10}>
|
|
99
|
+
<Card3D color={palette.hero} width={1.2} height={1.6} label="Plan" />
|
|
100
|
+
<Card3D color="#2563EB" width={1.2} height={1.6} label="Focus" />
|
|
101
|
+
<Card3D color="#059669" width={1.2} height={1.6} label="Ship" />
|
|
102
|
+
<Card3D color="#D97706" width={1.2} height={1.6} label="Rest" />
|
|
103
|
+
</Orbit3D>
|
|
104
|
+
</Scene3D>
|
|
105
|
+
</SceneFrame>
|
|
106
|
+
</AbsoluteFill>
|
|
107
|
+
);
|
|
108
|
+
};
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Example: a reveal that builds itself, verified by a render test
|
|
112
|
+
|
|
113
|
+
The name arrives as blocks that fly out of a cloud and settle into the letters, under a neon light, with the camera pushing in. Nothing is sized by hand: the word fills 80 percent of the width at its closest, and a test renders this exact block on 9:16 and checks that it is whole and clear of the edges.
|
|
114
|
+
|
|
115
|
+
```tsx
|
|
116
|
+
import React from "react";
|
|
117
|
+
import { AbsoluteFill } from "remotion";
|
|
118
|
+
import { Assemble3D, Scene3D, SceneFrame, palettes } from "reelkit/kit";
|
|
119
|
+
import type { VideoProps } from "reelkit/kit";
|
|
120
|
+
|
|
121
|
+
export const Video: React.FC<VideoProps> = ({ manifest }) => {
|
|
122
|
+
const palette = palettes.darkTech;
|
|
123
|
+
const s = manifest.scenes[0];
|
|
124
|
+
return (
|
|
125
|
+
<AbsoluteFill style={{ backgroundColor: palette.bg }}>
|
|
126
|
+
<SceneFrame from={s.startFrame} durationInFrames={s.durationFrames} enter="cut" exit="cut">
|
|
127
|
+
<Scene3D mood="neon" camera={{ keys: [{ frame: 0, z: 7.5 }, { frame: 70, z: 6 }] }}>
|
|
128
|
+
<Assemble3D shape="text" text="Relay" count={700} frames={50} order="x" colors={[palette.hero, palette.accent]} />
|
|
129
|
+
</Scene3D>
|
|
130
|
+
</SceneFrame>
|
|
131
|
+
</AbsoluteFill>
|
|
132
|
+
);
|
|
133
|
+
};
|
|
134
|
+
```
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: voice-sync
|
|
3
|
+
description: Use when writing the composition of a narrated video - how to fit the picture to the voice, so that what a word names is on screen on that word.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Fitting the picture to the voice
|
|
7
|
+
|
|
8
|
+
In a narrated film the voice is the clock. The words are the only thing the viewer is sure to follow, so anything that shows or names a word has to be on screen when that word is said: not a beat earlier, not a second later. The music is the clock for scene changes and for decoration (`reference/beat-sync.md`).
|
|
9
|
+
|
|
10
|
+
## Order of work
|
|
11
|
+
|
|
12
|
+
1. Write the narration first (`reference/scriptwriting.md`). Do not design pictures for words that are not written yet.
|
|
13
|
+
2. Record it: `reelkit assets voiceover --all`. It prints the real length and the real silence between the sentences. The word times are in `manifest.json` (`scenes[i].words`, in seconds from the scene's start).
|
|
14
|
+
3. Only now place the visuals, from the real word times, with `onWord`. Never from a guess, and never by counting words by hand: the kit finds the word.
|
|
15
|
+
4. `reelkit check` fails on a word the scene never says. `reelkit preview` then shows one frame 4 frames after each word you timed something to, in a row of its own on the contact sheet: look at it.
|
|
16
|
+
|
|
17
|
+
If you change the narration, record again and place the visuals again: the times moved.
|
|
18
|
+
|
|
19
|
+
## The rules
|
|
20
|
+
|
|
21
|
+
- **One visual event per stressed word.** Pick the words that carry the meaning (a noun, a number, a verb that changes something) and give each one thing to show. Not every word. At most one event every 0.5 s: more is noise, and the viewer cannot follow it.
|
|
22
|
+
- **On the word.** What a word names is on screen within 3 frames of the word starting: `onWord(s, "tasks")` is the frame to START its entrance, 3 frames before the word, so that it is seen landing on it. Never earlier than the word, and never before the previous sentence has ended. Nothing appears for a word that has not been said.
|
|
23
|
+
- **Numbers count up to finish on their word.** Start the count `durationFrames` before the word, so it is whole when the word is said: `<Sequence from={wordFrame(s, "thousand") - 24}><Counter to={10000} durationFrames={24} /></Sequence>`.
|
|
24
|
+
- **Lists tick on each item's word.** One entrance per item, each on its own word (`onWord(s, "tasks")`, then `"deadlines"`, then `"energy"`), not one animation for the whole list on the first word.
|
|
25
|
+
- **A phrase starts on its first word.** `onWord(s, "whole week")` takes the start of "whole". Use `{ edge: "end" }` for something that must land when a word has been said, and `{ nth: 2 }` for the second time a word is said.
|
|
26
|
+
- **Scene changes fall in the gap between sentences, never inside one.** The CLI lays the scenes out that way: a scene lasts until its last word ends plus the plan's `gap`. Do not make a scene break inside a sentence by cutting the voice, and do not hold a picture for a word that belongs to the next scene.
|
|
27
|
+
- **The last word of a sentence gets a hold of the gap's length, not more.** The thing it names stays for the gap (0.2 to 0.5 s) and then the scene changes. Do not add `exitAt` or padding that keeps a picture on after its sentence for a second: that is dead air.
|
|
28
|
+
- **The beat never overrides the word.** `onWordBeat(s, "builds", manifest.music)` lets the entrance land on a beat only when one falls within 3 frames after the word; otherwise the word wins. It is never earlier than `onWord`.
|
|
29
|
+
|
|
30
|
+
## Sound effects under a voice
|
|
31
|
+
|
|
32
|
+
A sound for a word's visual goes at that visual's frame (`wordFrame(s, "tasks")`, or 2 frames before it for a soft click). Under the voice it sits lower than in a film with no voice: `volume` 0.15 to 0.3, and never above 0.3 while the voice speaks (a film with no voice uses 0.35 to 0.6; `reference/launch-film.md`). Sounds are levelled when they are pulled, so the same number is equally loud for every file. One effect per visual event, and none under a word that carries no picture. The music under the voice is `Music`'s default (`duckTo` 0.18) and stays steady through the sentences.
|
|
33
|
+
|
|
34
|
+
After a render, `reelkit sound --detail` says how the voice sits against the music (estimated from the mix) and how many times the music pumped; the voice should be at least 10 dB above the music under it, and a pump count above 2 in 30 s means something is lifting the music between sentences.
|
|
35
|
+
|
|
36
|
+
## An example
|
|
37
|
+
|
|
38
|
+
The plan (three scenes; only what the example needs is shown):
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{
|
|
42
|
+
"gap": "tight",
|
|
43
|
+
"scenes": [
|
|
44
|
+
{ "id": "hook", "narration": "Every single week, ten thousand teams plan with Tempo." },
|
|
45
|
+
{ "id": "list", "narration": "It reads your tasks, your deadlines and your energy." },
|
|
46
|
+
{ "id": "close", "narration": "Tempo. Your week, in rhythm." }
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The composition. The number finishes counting as "thousand" is said, and each row of the list lands on its own word, with a soft click under it:
|
|
52
|
+
|
|
53
|
+
```tsx
|
|
54
|
+
import React from "react";
|
|
55
|
+
import { AbsoluteFill, Sequence, useVideoConfig } from "remotion";
|
|
56
|
+
import { Captions, Counter, Entrance, Music, onWord, SceneFrame, sceneById, Sfx, Voiceover, wordFrame } from "reelkit/kit";
|
|
57
|
+
import type { VideoProps } from "reelkit/kit";
|
|
58
|
+
|
|
59
|
+
const POP = "assets/lib/sfx-ui-bubble-pop/clip.mp3";
|
|
60
|
+
const ink = "#1E2A55", hero = "#E8604C";
|
|
61
|
+
|
|
62
|
+
// One row of the list (a local component): a coloured bar with its label.
|
|
63
|
+
const Card: React.FC<{ label: string; top: number }> = ({ label, top }) => {
|
|
64
|
+
const { width, height } = useVideoConfig();
|
|
65
|
+
return (
|
|
66
|
+
<div style={{ position: "absolute", left: "10%", top: height * top, width: "80%", height: height * 0.1, background: hero, borderRadius: width * 0.03, color: "#ffffff", fontSize: width * 0.06, fontWeight: 800, display: "flex", alignItems: "center", paddingLeft: width * 0.05 }}>{label}</div>
|
|
67
|
+
);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const Video: React.FC<VideoProps> = ({ manifest, urls }) => {
|
|
71
|
+
const hook = sceneById(manifest, "hook");
|
|
72
|
+
const list = sceneById(manifest, "list");
|
|
73
|
+
const close = sceneById(manifest, "close");
|
|
74
|
+
// Captions and the voice, in every scene.
|
|
75
|
+
const voice = (s: typeof hook) => (
|
|
76
|
+
<>
|
|
77
|
+
<Captions words={s.words} group={manifest.captions} highlight={hero} />
|
|
78
|
+
{s.voiceoverKey ? <Voiceover src={urls[s.voiceoverKey]} /> : null}
|
|
79
|
+
</>
|
|
80
|
+
);
|
|
81
|
+
return (
|
|
82
|
+
<AbsoluteFill style={{ background: "#FBF3EA" }}>
|
|
83
|
+
<SceneFrame from={hook.startFrame} durationInFrames={hook.durationFrames}>
|
|
84
|
+
{/* 24 frames of counting, whole on "thousand" */}
|
|
85
|
+
<Sequence from={Math.max(0, wordFrame(hook, "thousand") - 24)}>
|
|
86
|
+
<Counter to={10000} durationFrames={24} color={ink} />
|
|
87
|
+
</Sequence>
|
|
88
|
+
{voice(hook)}
|
|
89
|
+
</SceneFrame>
|
|
90
|
+
<SceneFrame from={list.startFrame} durationInFrames={list.durationFrames}>
|
|
91
|
+
<Entrance delay={onWord(list, "tasks")}><Card label="Tasks" top={0.2} /></Entrance>
|
|
92
|
+
<Entrance delay={onWord(list, "deadlines")}><Card label="Deadlines" top={0.34} /></Entrance>
|
|
93
|
+
<Entrance delay={onWord(list, "energy")}><Card label="Energy" top={0.48} /></Entrance>
|
|
94
|
+
<Sfx src={urls[POP]} at={wordFrame(list, "tasks")} volume={0.25} />
|
|
95
|
+
<Sfx src={urls[POP]} at={wordFrame(list, "deadlines")} volume={0.22} />
|
|
96
|
+
<Sfx src={urls[POP]} at={wordFrame(list, "energy")} volume={0.22} />
|
|
97
|
+
{voice(list)}
|
|
98
|
+
</SceneFrame>
|
|
99
|
+
<SceneFrame from={close.startFrame} durationInFrames={close.durationFrames}>
|
|
100
|
+
{voice(close)}
|
|
101
|
+
</SceneFrame>
|
|
102
|
+
{manifest.music ? <Music src={urls[manifest.music.key]} /> : null}
|
|
103
|
+
</AbsoluteFill>
|
|
104
|
+
);
|
|
105
|
+
};
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`reelkit preview` then shows, for this film, four word frames (`thousand ▸ hook`, `tasks ▸ list`, `deadlines ▸ list`, `energy ▸ list`): the number is whole in the first, and each row is already on screen in the others.
|
package/src/agents.ts
CHANGED
|
@@ -7,9 +7,9 @@ import { PKG_ROOT } from "./render/validate";
|
|
|
7
7
|
type Env = Record<string, string | undefined>;
|
|
8
8
|
|
|
9
9
|
// Every path is relative to the agents home. Skill folders: the vercel-labs/skills README. Command folders: each agent's own docs.
|
|
10
|
-
export const AGENTS: { id: string; name: string; home: string; skillDir: string;
|
|
11
|
-
{ id: "claude", name: "Claude Code", home: ".claude", skillDir: ".claude/skills/reelkit",
|
|
12
|
-
{ id: "codex", name: "Codex", home: ".codex", skillDir: ".codex/skills/reelkit",
|
|
10
|
+
export const AGENTS: { id: string; name: string; home: string; skillDir: string; commandDir?: string }[] = [
|
|
11
|
+
{ id: "claude", name: "Claude Code", home: ".claude", skillDir: ".claude/skills/reelkit", commandDir: ".claude/commands" },
|
|
12
|
+
{ id: "codex", name: "Codex", home: ".codex", skillDir: ".codex/skills/reelkit", commandDir: ".codex/prompts" },
|
|
13
13
|
{ id: "cursor", name: "Cursor", home: ".cursor", skillDir: ".cursor/skills/reelkit" },
|
|
14
14
|
{ id: "gemini", name: "Gemini CLI", home: ".gemini", skillDir: ".gemini/skills/reelkit" },
|
|
15
15
|
{ id: "agents", name: "Shared agents folder", home: ".agents", skillDir: ".agents/skills/reelkit" },
|
|
@@ -19,6 +19,13 @@ export const AGENTS: { id: string; name: string; home: string; skillDir: string;
|
|
|
19
19
|
export const agentsHome = (env: Env): string => env.REELKIT_AGENTS_HOME || homedir();
|
|
20
20
|
|
|
21
21
|
const SKILL_SRC = join(PKG_ROOT, "skill");
|
|
22
|
+
|
|
23
|
+
// The slash commands that ride with the skill. `file` is relative to the skill folder in the package; the command is installed as `<name>.md`
|
|
24
|
+
// in an agent's command folder and kept out of the skill's own folder. The first is the one that was installed before there were two.
|
|
25
|
+
export const COMMANDS: { name: string; file: string }[] = [
|
|
26
|
+
{ name: "reelkit-video", file: "command.md" },
|
|
27
|
+
{ name: "reelkit-launch-film", file: "commands/launch-film.md" },
|
|
28
|
+
];
|
|
22
29
|
const VERSION_FILE = ".reelkit-version";
|
|
23
30
|
const packageVersion = (): string => JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8")).version;
|
|
24
31
|
|
|
@@ -32,13 +39,15 @@ function recoverLeftovers(dest: string): void {
|
|
|
32
39
|
for (const p of left) rmSync(p, { recursive: true, force: true });
|
|
33
40
|
}
|
|
34
41
|
|
|
35
|
-
export type InstallReport = { installed: { agent: string; path: string }[]; skipped: { agent: string; reason: string }[] };
|
|
42
|
+
export type InstallReport = { installed: { agent: string; path: string; commands: string[] }[]; skipped: { agent: string; reason: string }[] };
|
|
36
43
|
|
|
37
44
|
export function installSkill(env: Env, opts: { agents?: string[]; force?: boolean; source?: string; rename?: (from: string, to: string) => void }): InstallReport {
|
|
38
45
|
const source = opts.source ?? SKILL_SRC;
|
|
39
46
|
if (!existsSync(join(source, "SKILL.md")) || !existsSync(join(source, "command.md"))) {
|
|
40
47
|
throw new Error("Reelkit's skill files are missing from this installation. Reinstall reelkit and try again.");
|
|
41
48
|
}
|
|
49
|
+
// A command the package does not have in this source is skipped, so an older source with only command.md still installs.
|
|
50
|
+
const commands = COMMANDS.filter((c) => existsSync(join(source, c.file)));
|
|
42
51
|
const rename = opts.rename ?? renameSync;
|
|
43
52
|
const home = agentsHome(env);
|
|
44
53
|
const version = packageVersion();
|
|
@@ -52,8 +61,9 @@ export function installSkill(env: Env, opts: { agents?: string[]; force?: boolea
|
|
|
52
61
|
const dest = join(home, agent.skillDir);
|
|
53
62
|
recoverLeftovers(dest);
|
|
54
63
|
const current = existsSync(join(dest, VERSION_FILE)) ? readFileSync(join(dest, VERSION_FILE), "utf8").trim() : undefined;
|
|
55
|
-
const
|
|
56
|
-
|
|
64
|
+
const commandPaths = agent.commandDir ? commands.map((c) => join(home, agent.commandDir!, `${c.name}.md`)) : [];
|
|
65
|
+
// Up to date only when the skill is the current version and every command is in place: a missing command is put back.
|
|
66
|
+
if (!opts.force && current === version && commandPaths.every((p) => existsSync(p))) {
|
|
57
67
|
report.skipped.push({ agent: agent.id, reason: "already up to date" });
|
|
58
68
|
continue;
|
|
59
69
|
}
|
|
@@ -62,7 +72,7 @@ export function installSkill(env: Env, opts: { agents?: string[]; force?: boolea
|
|
|
62
72
|
const backup = join(dirname(dest), `.reelkit.old-${process.pid}`);
|
|
63
73
|
try {
|
|
64
74
|
mkdirSync(dirname(dest), { recursive: true });
|
|
65
|
-
cpSync(source, staged, { recursive: true, filter: (src) => src !== join(source, "command.md") });
|
|
75
|
+
cpSync(source, staged, { recursive: true, filter: (src) => src !== join(source, "command.md") && src !== join(source, "commands") });
|
|
66
76
|
writeFileSync(join(staged, VERSION_FILE), `${version}\n`);
|
|
67
77
|
const hadOld = existsSync(dest);
|
|
68
78
|
if (hadOld) rename(dest, backup);
|
|
@@ -76,11 +86,12 @@ export function installSkill(env: Env, opts: { agents?: string[]; force?: boolea
|
|
|
76
86
|
rmSync(staged, { recursive: true, force: true });
|
|
77
87
|
if (existsSync(dest)) rmSync(backup, { recursive: true, force: true }); // never delete the backup while nothing is in place
|
|
78
88
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
89
|
+
commands.forEach((c, i) => {
|
|
90
|
+
if (!commandPaths[i]) return;
|
|
91
|
+
mkdirSync(dirname(commandPaths[i]!), { recursive: true });
|
|
92
|
+
cpSync(join(source, c.file), commandPaths[i]!);
|
|
93
|
+
});
|
|
94
|
+
report.installed.push({ agent: agent.id, path: dest, commands: commands.filter((_, i) => commandPaths[i]).map((c) => c.name) });
|
|
84
95
|
}
|
|
85
96
|
return report;
|
|
86
97
|
}
|
package/src/api/client.ts
CHANGED
|
@@ -12,6 +12,9 @@ const VERSION = (JSON.parse(readFileSync(new URL("../../package.json", import.me
|
|
|
12
12
|
|
|
13
13
|
export type Api = <K extends RouteName>(name: K, input: z.input<Routes[K]["req"]>) => Promise<z.infer<Routes[K]["res"]>>;
|
|
14
14
|
|
|
15
|
+
// A vector graphic takes about 45 seconds to make, and the request stays open until it is done, so the one route that can take that long is allowed 180 seconds.
|
|
16
|
+
const TIMEOUT_MS: Partial<Record<keyof typeof routes, number>> = { images: 180_000 };
|
|
17
|
+
|
|
15
18
|
export function createClient(opts: { baseUrl: string; token?: string }): Api {
|
|
16
19
|
return async (name, input) => {
|
|
17
20
|
const r = routes[name];
|
|
@@ -28,7 +31,7 @@ export function createClient(opts: { baseUrl: string; token?: string }): Api {
|
|
|
28
31
|
}
|
|
29
32
|
let res: Response;
|
|
30
33
|
try {
|
|
31
|
-
res = await fetch(url, { method: r.method, headers, body });
|
|
34
|
+
res = await fetch(url, { method: r.method, headers, body, ...(TIMEOUT_MS[name] ? { signal: AbortSignal.timeout(TIMEOUT_MS[name]!) } : {}) });
|
|
32
35
|
} catch {
|
|
33
36
|
throw new ApiFailure("server_error", `Cannot reach the Reelkit API at ${opts.baseUrl}. Check your connection or REELKIT_API_URL.`);
|
|
34
37
|
}
|
package/src/cli.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { ApiFailure } from "./api/client";
|
|
3
|
-
import { assetsGenClip, assetsGenImage, assetsPull, assetsSearch, assetsUpload, assetsVoiceover, assetsVoices } from "./commands/assets";
|
|
3
|
+
import { assetsGenClip, assetsGenImage, assetsGenSvg, assetsLayers, assetsPull, assetsSearch, assetsUpload, assetsVoiceover, assetsVoices } from "./commands/assets";
|
|
4
4
|
import { authLogin, authLogout, whoami } from "./commands/auth";
|
|
5
|
-
import { check, preview, render } from "./commands/build";
|
|
5
|
+
import { check, preview, render, soundCommand } from "./commands/build";
|
|
6
|
+
import { componentsShare } from "./commands/components";
|
|
6
7
|
import { init } from "./commands/init";
|
|
7
8
|
import { install } from "./commands/install";
|
|
8
9
|
import { openInBrowser } from "./open";
|
|
@@ -65,28 +66,36 @@ auth.command("login").description("Log in to Reelkit: opens the login page in yo
|
|
|
65
66
|
auth.command("logout").description("Log out and remove the stored token").action(run(authLogout));
|
|
66
67
|
program.command("whoami").description("Show your account, quota and contributions").action(run(whoami));
|
|
67
68
|
|
|
68
|
-
program.command("init [name]").description("Set up a video project (in a new folder named after it, or in this folder)").option("--aspect <aspect>", "9:16, 16:9 or 1:1").action(run(init));
|
|
69
|
+
program.command("init [name]").description("Set up a video project (in a new folder named after it, or in this folder)").option("--aspect <aspect>", "9:16, 16:9 or 1:1").option("--private", "keep everything in this project out of the library: no components after a render, and no generated images, clips or graphics (writes shareComponents: false)").action(run(init));
|
|
69
70
|
|
|
70
71
|
const assets = program.command("assets").description("Find, add and generate the files a video needs");
|
|
71
72
|
assets.command("upload <file>").description("Add one of your own files to this project (private unless --share; with --cutout the video is sent to the Reelkit server to be processed)")
|
|
72
73
|
.option("--describe <text>", "what the file shows").option("--footage", "use this video as the footage to overlay").option("--green", "the video is a subject on a green background: also make a copy with the green transparent (free, on this machine)")
|
|
73
74
|
.option("--cutout", "remove any background: the video is sent to the Reelkit server to be processed and a copy with a transparent background comes back (up to 20 seconds, counts against a monthly quota)")
|
|
74
75
|
.option("--resume <id>", "with --cutout: keep waiting for a cutout already started, without uploading or paying again")
|
|
75
|
-
.option("--share", "also send it to the shared library").option("--kind <kind>", "library kind when sharing").option("--tags <tags>", "comma-separated tags when sharing")
|
|
76
|
+
.option("--background", "use this video or image as the ground behind the whole film (it stays on this machine)").option("--share", "also send it to the shared library").option("--kind <kind>", "library kind when sharing").option("--tags <tags>", "comma-separated tags when sharing")
|
|
76
77
|
// Called with the arguments named: commander adds its own command object last, which must not be taken for the function's test hooks.
|
|
77
78
|
.action(run((ctx, file: string, opts: Parameters<typeof assetsUpload>[2]) => assetsUpload(ctx, file, opts)));
|
|
78
79
|
assets.command("search <query>").description("Search the shared library by meaning; each result shows how well it fits").option("--kind <kind>", "image, overlay, sfx, music, component or clip").option("--limit <n>", "how many results")
|
|
79
80
|
.action(run(assetsSearch));
|
|
80
|
-
assets.command("pull <id>").description("Download a library item into this project").option("--scene <sceneId>", "use it as this scene's image or clip").option("--force", "replace a component file that already exists").option("--music", "make this music item the video's track (its tempo is measured and scene changes land on its beat)")
|
|
81
|
+
assets.command("pull <id>").description("Download a library item into this project").option("--scene <sceneId>", "use it as this scene's image or clip").option("--force", "replace a component file that already exists").option("--music", "make this music item the video's track (its tempo is measured and scene changes land on its beat)").option("--background", "make this clip, overlay or image the ground behind the film (with --scene: the ground of that scene, an image only)")
|
|
82
|
+
.option("--layers", "with --scene and an image: right after it is pulled, cut the subject out as a layer (about 1 second of the cutout quota; the picture is sent to the Reelkit server)")
|
|
81
83
|
.action(run((ctx, id, opts) => assetsPull(ctx, id, opts)));
|
|
84
|
+
assets.command("layers [file]").description("Split a picture into layers: the subject cut out as a PNG with alpha beside it, and where words can sit (the picture is sent to the Reelkit server for the cut-out and uses about 1 second of the cutout quota)")
|
|
85
|
+
.option("--scene <sceneId>", "the scene whose image to split").option("--redo", "make the layers again even if they exist").option("--resume <id>", "keep waiting for a cut-out already started, without sending or paying again")
|
|
86
|
+
.action(run((ctx, file: string | undefined, opts: Parameters<typeof assetsLayers>[2]) => assetsLayers(ctx, file, opts)));
|
|
82
87
|
assets.command("voices").description("List the narration voices").action(run(assetsVoices));
|
|
83
88
|
assets.command("voiceover").description("Record the narration from plan.json").option("--scene <sceneId>", "one scene").option("--all", "every scene").option("--redo", "record again even if it exists")
|
|
84
89
|
.action(run(assetsVoiceover));
|
|
85
90
|
const gen = assets.command("gen").description("Generate an asset");
|
|
86
91
|
gen.command("image [prompt]").description("Generate a scene's illustration; the prompt defaults to the plan's")
|
|
87
|
-
.
|
|
92
|
+
.option("--scene <sceneId>", "the scene it is for").option("--background", "make an abstract background picture for the film (or for --scene) instead of an illustration").option("--redo", "generate a new image even if the scene has one").option("--layers", "right after it is made, cut the subject out as a layer (about 1 second of the cutout quota; the picture is sent to the Reelkit server) and measure where words can sit")
|
|
93
|
+
.action(run((ctx, prompt: string | undefined, opts: Parameters<typeof assetsGenImage>[2]) => assetsGenImage(ctx, prompt, opts)));
|
|
94
|
+
gen.command("svg <prompt>").description("Generate a vector graphic (an icon, a mark, a simple illustration or a diagram) that stays sharp at any size; it takes about 45 seconds and counts as one image")
|
|
95
|
+
.option("--scene <sceneId>", "use it as this illustration scene's image").option("--background", "use it as the ground of the film (or of --scene)").option("--redo", "generate a new one even if the scene has an image").option("--private", "never share it with the library, whatever the plan says")
|
|
96
|
+
.action(run((ctx, prompt: string, opts) => assetsGenSvg(ctx, prompt, opts)));
|
|
88
97
|
gen.command("clip [prompt]").description("Generate a scene's video clip (takes minutes); the prompt defaults to the plan's clipPrompt")
|
|
89
|
-
.
|
|
98
|
+
.option("--scene <sceneId>", "the scene it is for").option("--background", "make an abstract, seamless clip to sit behind the whole film (no --scene)").option("--green", "film it on green and key the green out into a transparent .webm")
|
|
90
99
|
.option("--cutout", "make the clip normally, then cut the subject out on the server (the video is sent to the Reelkit server to be processed; counts against a monthly quota)").option("--seconds <n>", "5 or 10 (default 5)")
|
|
91
100
|
.option("--share", "also add it to the shared library (only a generic clip)").option("--redo", "generate a new clip even if the scene has one").option("--resume <id>", "keep waiting for a clip already started, without paying again")
|
|
92
101
|
.action(run((ctx, prompt, opts) => assetsGenClip(ctx, prompt, opts)));
|
|
@@ -107,7 +116,15 @@ program.command("plan").description("Work with plan.json").command("check").desc
|
|
|
107
116
|
program.command("check").description("Check the composition in src/ without rendering").action(run(check));
|
|
108
117
|
program.command("install").description("Install the Reelkit skill into your coding agents").option("--agent <id>", "claude, codex, cursor, gemini, agents or all").option("--force", "reinstall even when up to date").action(run((ctx, opts) => install(ctx, opts)));
|
|
109
118
|
program.command("preview").description("Render preview frames into out/preview/: two per scene (at 30% and 90%) and the frames either side of each scene change, with a report of what carries across").action(run((ctx) => preview(ctx)));
|
|
110
|
-
program.command("render").description("Render the video to out/video.mp4").
|
|
119
|
+
program.command("render").description("Render the video to out/video.mp4; new components written in this project are then shared with the library for review").option("--no-share", "do not share new components with the library after this render (also: REELKIT_NO_SHARE=1)")
|
|
120
|
+
.action(run((ctx, opts: { share?: boolean }) => render(ctx, { noShare: opts.share === false })));
|
|
121
|
+
program.command("sound [file]").description("Measure the sound of a finished film against its picture: hits, swells and how many picture changes have a sound (default out/video.mp4)")
|
|
122
|
+
.option("--detail", "list every picture change with the nearest hit and its distance, every swell (start, peak, length) and the first sound")
|
|
123
|
+
.action(run((ctx, file: string | undefined, o: { detail?: boolean }) => soundCommand(ctx, file, { detail: o.detail })));
|
|
124
|
+
const components = program.command("components").description("Components written in this project");
|
|
125
|
+
components.command("share [names...]").description("Share components written in this project with the library for review (all of them with no name): their source, a description and an example are sent, nothing else")
|
|
126
|
+
.option("--describe <text>", "what it shows and when to use it (one component)").option("--example <jsx>", "an example use with plain values, such as '<StatRing value={42} />' (one component)").option("--tags <tags>", "comma-separated tags, 3 to 6 (one component)")
|
|
127
|
+
.action(run((ctx, names: string[], opts) => componentsShare(ctx, names, opts)));
|
|
111
128
|
|
|
112
129
|
// Parses argv and runs the chosen command. Importing this module parses nothing; bin/reelkit.mjs calls main.
|
|
113
130
|
export async function main(argv: string[]): Promise<void> {
|