reelkit-cli 0.1.0 → 0.1.2
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 +2 -2
- package/package.json +4 -11
- package/skill/SKILL.md +17 -11
- package/skill/reference/captions.md +7 -2
- package/skill/reference/hebrew-rtl.md +51 -0
- package/skill/reference/motion-design.md +41 -5
- package/skill/reference/remotion-composition.md +32 -0
- package/skill/reference/sound-design.md +4 -1
- package/skill/reference/styles.md +64 -0
- package/src/cli.ts +6 -3
- package/src/commands/build.ts +37 -10
- package/src/commands/init.ts +1 -1
- package/src/commands/plan.ts +5 -2
- package/src/contract/index.ts +1 -0
- package/src/pipeline/review.ts +7 -2
- package/src/render/render.ts +8 -1
- package/src/testing/conformance.ts +4 -4
- package/src/testing/fake-api.ts +2 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ A CLI and a Claude skill for making short-form video. Claude plans the video and
|
|
|
4
4
|
|
|
5
5
|
## Requirements
|
|
6
6
|
|
|
7
|
-
- Node
|
|
7
|
+
- Node 22.12 or newer
|
|
8
8
|
- ffmpeg
|
|
9
9
|
- A Reelkit account (accounts open when the service launches)
|
|
10
10
|
|
|
@@ -61,7 +61,7 @@ reelkit render
|
|
|
61
61
|
| `reelkit assets gen image` | Generate a scene's illustration |
|
|
62
62
|
| `reelkit plan check` | Validate `plan.json` |
|
|
63
63
|
| `reelkit check` | Check the composition without rendering |
|
|
64
|
-
| `reelkit preview` |
|
|
64
|
+
| `reelkit preview` | Two test frames per scene |
|
|
65
65
|
| `reelkit render` | Render `out/video.mp4` |
|
|
66
66
|
|
|
67
67
|
## What is shared
|
package/package.json
CHANGED
|
@@ -1,17 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "reelkit-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "CLI and Claude skill for making short-form video with a shared asset library.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Daniel Livshin",
|
|
7
|
-
"repository": {
|
|
8
|
-
"type": "git",
|
|
9
|
-
"url": "git+https://github.com/Livshind15/reelkit.git"
|
|
10
|
-
},
|
|
11
|
-
"homepage": "https://github.com/Livshind15/reelkit#readme",
|
|
12
|
-
"bugs": {
|
|
13
|
-
"url": "https://github.com/Livshind15/reelkit/issues"
|
|
14
|
-
},
|
|
15
7
|
"keywords": [
|
|
16
8
|
"video",
|
|
17
9
|
"short-form",
|
|
@@ -47,7 +39,7 @@
|
|
|
47
39
|
"skill"
|
|
48
40
|
],
|
|
49
41
|
"engines": {
|
|
50
|
-
"node": ">=
|
|
42
|
+
"node": ">=22.12"
|
|
51
43
|
},
|
|
52
44
|
"publishConfig": {
|
|
53
45
|
"access": "public"
|
|
@@ -85,5 +77,6 @@
|
|
|
85
77
|
"vitest": {
|
|
86
78
|
"optional": true
|
|
87
79
|
}
|
|
88
|
-
}
|
|
80
|
+
},
|
|
81
|
+
"homepage": "https://reelkit-kohl.vercel.app"
|
|
89
82
|
}
|
package/skill/SKILL.md
CHANGED
|
@@ -25,8 +25,11 @@ For each file the user gives, look at it, then register it with a description of
|
|
|
25
25
|
`reelkit assets upload ./logo.png --describe "Acme logo, white wordmark on blue"`
|
|
26
26
|
Add `--footage` for a video the motion design should be laid over. User files stay on this machine.
|
|
27
27
|
|
|
28
|
-
### 2.
|
|
29
|
-
Read `reference/
|
|
28
|
+
### 2. Look
|
|
29
|
+
Read `reference/styles.md` and agree the video's look with the user: one of its looks, or their own. If they already described what they want, match it and confirm in one sentence. Ask anything still open in one message, each question with a default. If any on-screen text will be Hebrew, read `reference/hebrew-rtl.md` now: it changes how words may enter and how lines are written.
|
|
30
|
+
|
|
31
|
+
### 3. Plan
|
|
32
|
+
Read `reference/scriptwriting.md` and `reference/scene-treatments.md`. Write the chosen look into the first scene's `notes`. Run `reelkit assets voices` and choose a voice that fits the idea, audience and language.
|
|
30
33
|
|
|
31
34
|
Write `plan.json`:
|
|
32
35
|
|
|
@@ -59,19 +62,19 @@ Write `plan.json`:
|
|
|
59
62
|
- `userAssetIds` lists the ids of the user's files shown in that scene.
|
|
60
63
|
- `pace` is `slow`, `normal` or `fast`.
|
|
61
64
|
|
|
62
|
-
Run `reelkit plan check
|
|
65
|
+
Run `reelkit plan check`; it prints the estimated length to tell the user. Fix everything under "Fix these". Act on "Worth improving" unless you have a good reason not to.
|
|
63
66
|
|
|
64
|
-
**Checkpoint.** Show the user the title, the estimated length, and each scene's narration, on-screen text and one line on the visual. Wait for a clear yes. A question or a comment is not approval: answer it and ask again.
|
|
67
|
+
**Checkpoint.** Show the user the look, the title, the estimated length, and each scene's narration, the exact on-screen text and one line on the visual, in plain words about what they will see. Wait for a clear yes. A question or a comment is not approval: answer it and ask again.
|
|
65
68
|
|
|
66
|
-
###
|
|
69
|
+
### 4. Voice
|
|
67
70
|
`reelkit assets voiceover --all`. It records each scene and prints the real length of the video. If the user wants a different voice, change `voiceId` in `plan.json` and run it again with `--redo`.
|
|
68
71
|
|
|
69
|
-
###
|
|
72
|
+
### 5. Images
|
|
70
73
|
Read `reference/asset-reuse.md`. For each illustration scene, search first:
|
|
71
74
|
`reelkit assets search "<what the scene needs>" --kind image`
|
|
72
75
|
Each result starts with a match percentage: how likely it is good enough to reuse. Pull the best result (`reelkit assets pull <id> --scene <sceneId>`) when its match is 60% or more and, reading its description, it fits the scene. Otherwise generate: `reelkit assets gen image --scene <sceneId>`. Never give two scenes the same image.
|
|
73
76
|
|
|
74
|
-
###
|
|
77
|
+
### 6. Composition
|
|
75
78
|
Read `reference/kit.md`, `reference/remotion-composition.md`, `reference/motion-design.md` and `reference/captions.md`.
|
|
76
79
|
|
|
77
80
|
- Search the library before writing a component: `reelkit assets search "<what it shows>" --kind component`. To use one, `reelkit assets pull <id>`: it lands in `src/` and the command prints the import line and an example.
|
|
@@ -80,11 +83,13 @@ Read `reference/kit.md`, `reference/remotion-composition.md`, `reference/motion-
|
|
|
80
83
|
- Write `src/Video.tsx` and any component files beside it. `Video.tsx` may import only `react`, `remotion`, `reelkit/kit` and sibling components (`./Name`).
|
|
81
84
|
- `manifest.json` is the exact object passed to `Video` as the `manifest` prop. Media is referenced as `urls[path]`, where `path` is the file's path in the project, such as `urls[scene.voiceoverKey]` or `urls["assets/lib/<id>/clip.mp3"]`.
|
|
82
85
|
|
|
83
|
-
Run `reelkit check` and fix every error until it passes. Then `reelkit preview` and look at
|
|
86
|
+
Run `reelkit check` and fix every error until it passes. Then `reelkit preview` (the first preview on a machine downloads a browser once and can take a minute) and look at every frame in `out/preview/`: each scene has two, `early` (30% into the scene) and `late` (90%), so look at both frames of each scene. Look for: text cut off or overflowing, text overlapping other text or the captions, text too small or too low-contrast for a phone, an empty or broken frame, content hidden behind another layer, and any number, price or quote on screen that is not in the plan. A frame is one moment: an element mid-animation is not a problem, but anything that should be fully on screen by the late frame and is not is. Fix real problems and preview again. Go round at least twice, and finish with the studio test in `reference/motion-design.md`.
|
|
87
|
+
|
|
88
|
+
You only ever see frames, never the moving video, so the frames are your eyes: look at every one. And whoever built a video is the worst judge of it. If you can start a separate agent, give it only the user's original request and the preview frames (not your explanations) and ask for a score out of 100 per scene, a list of flaws, and a concrete fix for each, in numbers ("raise the title 60 px", "hold the label 0.4 s longer"). Fix, preview, and have the same reviewer look again until every scene passes 90. If two rounds leave a scene under 70, stop and show the user the gap instead of spending more rounds.
|
|
84
89
|
|
|
85
|
-
**Checkpoint.** Show the user the preview frames. Wait for a clear yes. For changes, edit the code, `reelkit check`, `reelkit preview`, and show them again.
|
|
90
|
+
**Checkpoint.** Show the user the preview frames (both of each scene). Wait for a clear yes. For changes, edit the code, `reelkit check`, `reelkit preview`, and show them again.
|
|
86
91
|
|
|
87
|
-
###
|
|
92
|
+
### 7. Render
|
|
88
93
|
`reelkit render`. Give the user the path it prints.
|
|
89
94
|
|
|
90
95
|
If the render fails, read the error, fix the composition, and run `reelkit check` before rendering again.
|
|
@@ -96,6 +101,7 @@ If the render fails, read the error, fix the composition, and run `reelkit check
|
|
|
96
101
|
- The user's own files are private. Do not pass `--share` unless they ask you to contribute a file.
|
|
97
102
|
- Pass the user's facts through unchanged. Never invent a number, statistic, price or quote.
|
|
98
103
|
- Keep components driven by props, not hardcoded, so they can be reused.
|
|
99
|
-
- One stage at a time. Do not
|
|
104
|
+
- One stage at a time. Do not write composition code before the user has approved the plan, and do not render before they have approved the preview.
|
|
105
|
+
- Never say a video is done without having looked at its frames and checked that the file exists.
|
|
100
106
|
- If a command reports that a quota is used up, tell the user what ran out and when it resets. Do not work around it. A message that says to wait a minute or an hour ("Too many searches", "Too many uploads started") is a throttle, not a used-up quota: wait and retry once instead of stopping.
|
|
101
107
|
- Reply in the user's language.
|
|
@@ -23,7 +23,7 @@ Do not mix modes between scenes. Set `highlight` to the palette's hero colour (c
|
|
|
23
23
|
|
|
24
24
|
## Words per screen
|
|
25
25
|
- `pop`: 1 to 3 words (`perLine` 1 to 3). Short words can share a screen.
|
|
26
|
-
- `highlight` and `karaoke`: 3 to 5 words for vertical video, up to 6 for landscape.
|
|
26
|
+
- `highlight` and `karaoke`: 3 to 5 words for vertical video, up to 6 for landscape. In Hebrew, at most 4.
|
|
27
27
|
- Never show a full sentence at once. Word-level timing reads better than sentence captions.
|
|
28
28
|
|
|
29
29
|
## Timing
|
|
@@ -33,11 +33,16 @@ Do not mix modes between scenes. Set `highlight` to the palette's hero colour (c
|
|
|
33
33
|
- No caption group should flash by in under about 0.25 seconds.
|
|
34
34
|
|
|
35
35
|
## Position and safe zone
|
|
36
|
-
- Vertical video (1080 by 1920): keep captions at least 200 to 300 px above the bottom edge and
|
|
36
|
+
- Vertical video (1080 by 1920): keep captions at least 200 to 300 px above the bottom edge and inside pixels 140 to 940 across (tall phones crop about 100 px from each side); platform buttons and descriptions cover the rest. The component's `bottom` prop (a fraction of the height, default 0.16) controls this.
|
|
37
37
|
- Keep the top 150 to 200 px clear as well.
|
|
38
38
|
- Captions must never overlap other on-screen text. If a scene has a low headline or a lower-third, raise `bottom` or move the graphic.
|
|
39
39
|
- In footage mode, do not cover the subject's face.
|
|
40
40
|
|
|
41
|
+
## Read them before you show them
|
|
42
|
+
- Read every caption as part of its whole sentence, not word by word. A word that is spelled right but is the wrong word ("on" for "an", one Hebrew homophone for another) only shows up in context.
|
|
43
|
+
- The captions must say what the narration says, letter for letter. Never tidy a user's wording in the caption alone.
|
|
44
|
+
- A number, name or unusual word is where mistakes hide: check each one against the plan.
|
|
45
|
+
|
|
41
46
|
## Legibility
|
|
42
47
|
- Heavy sans-serif weight, large: about 5.5 percent of the width for line captions, 7.5 percent for pop. The component sets these.
|
|
43
48
|
- Contrast of at least 4.5 to 1 against whatever is behind. The component adds a dark outline and shadow; on very bright footage also dim the footage.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: hebrew-rtl
|
|
3
|
+
description: Use whenever any text on screen is Hebrew - which faces to use, direction, how words may enter, punctuation and hyphens, line breaks, and what to check in the preview frames.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Hebrew on screen
|
|
7
|
+
|
|
8
|
+
Most broken Hebrew videos break in the same few ways. These rules are for text drawn by the composition; the narration and captions follow them too.
|
|
9
|
+
|
|
10
|
+
## Faces
|
|
11
|
+
- Set Hebrew in a Hebrew face from the kit (see `reference/kit.md`): `heebo`, `rubik`, `notoSansHebrew`, `assistant` and the display faces listed there. A Latin-only face silently falls back and the weights stop matching.
|
|
12
|
+
- One Hebrew family for the whole video, two weights. Heavy weights (800, 900) for headlines; 500 to 700 for labels.
|
|
13
|
+
- No faces that look antique, and no hollow outlined words: Hebrew letterforms lose their shape as outlines.
|
|
14
|
+
- Small technical labels that are only Latin letters and digits ("W 612", "v2") may use a mono Latin face. Never set Hebrew in it.
|
|
15
|
+
|
|
16
|
+
## Direction
|
|
17
|
+
- Give every Hebrew text container `direction: "rtl"`. Without it the full stop, comma and exclamation mark jump to the wrong end of the line.
|
|
18
|
+
- Digits and Latin words inside a Hebrew sentence order themselves when the base direction is RTL. A label that is entirely Latin or digits gets `direction: "ltr"`.
|
|
19
|
+
- Everything laid out in reading order runs right to left: the first word on the right, the first list item on the right or top, tabs, chat bubbles, steps, and charts over time (the first day on the right).
|
|
20
|
+
- **The exception:** progress bars, sliders, loading bars and media timelines still fill left to right, as every player does.
|
|
21
|
+
- A typing effect adds characters in reading order; the caret sits at the left end of the text, where it grows.
|
|
22
|
+
- Captions: pass `rtl` and a Hebrew `face` to the kit's captions.
|
|
23
|
+
|
|
24
|
+
## How words enter
|
|
25
|
+
- **No masked reveals.** A Hebrew word uncovered by a moving edge (a clip, a wipe, an overflow-hidden box it rises out of) reads for a moment as a row of dashes or as a different word, because so much of the letter's identity is in its top. The frame edge counts as a mask too: a word sliding in from off screen shows a lone letter first.
|
|
26
|
+
- Enter whole words instead: a short rise (1 to 2 percent of the frame height) with opacity reaching 1 within two frames, or a scale down from slightly larger (at most 1.25 times) onto its place.
|
|
27
|
+
- At most one word in three gets the big scale entrance. The rest rise, or widen with letter spacing, or slide a short distance from inside the frame.
|
|
28
|
+
- Letter by letter: each letter appears whole, starting from the rightmost (the first one read).
|
|
29
|
+
- This overrides the masked rise that `reference/motion-design.md` recommends for Latin headlines.
|
|
30
|
+
|
|
31
|
+
## Hyphens and punctuation
|
|
32
|
+
- **Never join two Hebrew words with a hyphen on screen**, in headlines, labels or interface text. Write the two words with a space.
|
|
33
|
+
- No em dash and no en dash on screen. Use a comma, a full stop or a new line.
|
|
34
|
+
- A prefix letter before a number or a Latin word ("ב-2026") is correct Hebrew, but on screen prefer wording that avoids it.
|
|
35
|
+
- Quote the user's text exactly, letter for letter. Never "fix" spelling, add vowel points or swap a final letter.
|
|
36
|
+
|
|
37
|
+
## Lines
|
|
38
|
+
- Never let the renderer wrap a Hebrew line. Break lines yourself, by meaning, and give each line its own element with `whiteSpace: "nowrap"`.
|
|
39
|
+
- In a stacked headline keep every line the same size; shrink the whole stack rather than one line.
|
|
40
|
+
- Leave room: a word that fills the width should use about 90 percent of the safe width, so a camera push or a shake cannot push a letter out.
|
|
41
|
+
|
|
42
|
+
## Text on a path or in a drawing
|
|
43
|
+
- Text inside a scaled drawing blurs and distorts when the camera zooms. Keep labels in a layer above the drawing and compute their position from the camera.
|
|
44
|
+
- Text on a curve or ring: render a preview frame and read it. Path text in RTL starts from the other end of the path than Latin text does, and a wrong offset makes it vanish rather than look wrong.
|
|
45
|
+
|
|
46
|
+
## Check before you show
|
|
47
|
+
1. Every word in the right order, right to left, spelled exactly as given.
|
|
48
|
+
2. No hyphen between words, no long dash.
|
|
49
|
+
3. No letter cut by the frame edge or by a mask, in the early frame or the late one.
|
|
50
|
+
4. Progress bars fill left to right; everything else runs right to left.
|
|
51
|
+
5. The full stop and exclamation mark sit at the left end of the last word.
|
|
@@ -6,7 +6,7 @@ description: Use when deciding how a scene looks and moves - layout, typography,
|
|
|
6
6
|
# Motion design guidelines
|
|
7
7
|
|
|
8
8
|
## The user's uploads
|
|
9
|
-
`assets/index.json` lists the user's own files with a description of each. Show each one only in the scenes whose userAssetKeys include it, sized and framed for what it is: a logo small and clean on a plain area; a screenshot framed in a device or browser mockup (search the library with `reelkit assets search "phone mockup" --kind component`, then `reelkit assets pull <id>`, which puts it in `src/`; if none fits, build a simple rounded framed card); a product photo large, with KenBurnsImage or a rounded card. Never stretch a logo, never crop a screenshot's important part, and never invent a screen when a real one was supplied.
|
|
9
|
+
`assets/index.json` lists the user's own files with a description of each. Ask for a logo as an SVG where the user has one: its edges stay sharp at any size, while a PNG softens when scaled up. Show each one only in the scenes whose userAssetKeys include it, sized and framed for what it is: a logo small and clean on a plain area; a screenshot framed in a device or browser mockup (search the library with `reelkit assets search "phone mockup" --kind component`, then `reelkit assets pull <id>`, which puts it in `src/`; if none fits, build a simple rounded framed card); a product photo large, with KenBurnsImage or a rounded card. Never stretch a logo, never crop a screenshot's important part, and never invent a screen when a real one was supplied.
|
|
10
10
|
|
|
11
11
|
## One system, start to end
|
|
12
12
|
- One base colour, one text colour and **one hero colour**, kept for the whole video. Roughly 60 percent base, 30 percent secondary surfaces, 10 percent hero.
|
|
@@ -19,29 +19,42 @@ description: Use when deciding how a scene looks and moves - layout, typography,
|
|
|
19
19
|
## Layout
|
|
20
20
|
- One focal point per scene. Everything else supports it.
|
|
21
21
|
- Keep important content inside the middle 80 percent of the frame horizontally. On vertical video keep critical text in the middle 75 percent vertically: the platform's own buttons cover the top and bottom. Captions sit just above the bottom of that zone.
|
|
22
|
+
- On a 1080 by 1920 reel that means text stays clear of the top 250 pixels, the bottom 350, and 80 on each side (140 for anything that must be read, since tall phones crop about 100 from each side), plus 140 on the right in the lower half, where the app's own buttons sit. Backgrounds and graphics may run to the edge. On square and wide video keep text 6 percent in from every side.
|
|
23
|
+
- Zoom so that each state fills the frame. A small thing centred in an empty frame looks like a mistake.
|
|
22
24
|
- Reuse the same margins in every scene.
|
|
23
25
|
- Vary composition between scenes (centred, top-aligned, split) so the video is not one slide repeated.
|
|
24
26
|
|
|
25
27
|
## Typography
|
|
26
28
|
- Headlines 7 to 10 percent of the frame width; supporting text 3.5 to 5 percent. Never smaller than 3 percent: it must read on a phone.
|
|
27
|
-
-
|
|
29
|
+
- A message the viewer must read is at least 90 pixels tall on a 1080 wide video. Hero words run from 220 to 520. Interface text inside a recreated interface and tiny decorative technical labels are exempt.
|
|
30
|
+
- If any on-screen text is Hebrew, read `reference/hebrew-rtl.md` first. Set it in one of the kit's Hebrew faces and give its container `direction: "rtl"`. The Latin faces have no Hebrew letters. Good pairings: `heebo` or `assistant` for everything; `secularOne` or `karantina` headlines over `assistant` body; `suezOne` or `frankRuhlLibre` for an editorial feel; `varelaRound` or `fredoka` for a friendly one.
|
|
28
31
|
- Short lines, broken by meaning, not by width. A title should fit on one line where it can.
|
|
29
|
-
- Reveal text with a masked rise (translate up from behind an overflow-hidden box), word by word, about 2 frames apart. Avoid plain opacity fades for headlines.
|
|
32
|
+
- Reveal Latin text with a masked rise (translate up from behind an overflow-hidden box), word by word, about 2 frames apart. Avoid plain opacity fades for headlines. Hebrew never uses a mask: its words enter whole (see `reference/hebrew-rtl.md`).
|
|
30
33
|
|
|
31
34
|
## Motion
|
|
32
35
|
- **Springs, never linear moves and never cartoon bounce.** Use `spring()` with a damping high enough for a tiny overshoot at most. Presets (stiffness / damping): snappy UI 320 / 30, default containers 170 / 26, heavy type and logos 120 / 24.
|
|
36
|
+
- A big move gets a small anticipation first: a few frames of pull the other way.
|
|
37
|
+
- Exits are faster than entrances. An entrance takes about 0.1 to 0.25 seconds.
|
|
38
|
+
- Linear motion is only for movement with no start or end inside the shot (a slow camera turn, a continuous scroll). Every entrance, exit and change of state is a spring.
|
|
39
|
+
- **The camera is a group.** Put the whole scene inside one wrapper and move that: a slow push of 2 to 4 percent across a shot, on a softer spring than the content. Never scale text up by enlarging a container that holds it as an image; set the size so it stays sharp.
|
|
40
|
+
- **Work on a grid.** Place every event on a fixed grid (half a second is a good default) or on the spoken words. Something happens on every grid step.
|
|
33
41
|
- **One thing moves at a time.** Stagger related items by 4 to 8 frames rather than moving everything at once.
|
|
34
42
|
- **Pace to speech.** One visible change per spoken beat, roughly every 0.4 to 1.2 seconds. Use the word timings in `s.words` to bring an element in on the frame its word starts, never before.
|
|
35
43
|
- Something new should happen every 2 to 4 seconds, and something must move within the first half second of the video. No dead stretches.
|
|
36
44
|
- **Rhythm is hit, hold, build.** A fast move, then a hold of about half a second where nothing new enters, then the next move. Constant motion reads amateur; contrast reads expensive. During a hold, settled elements keep their slow breathe.
|
|
37
45
|
- **Nothing sits perfectly frozen for more than about a second.** After an element settles, keep a very slow drift or scale (1 to 2 percent over the scene) alive so the frame breathes.
|
|
38
|
-
- Let an element finish animating and stay readable for at least 1.5 seconds before it leaves.
|
|
46
|
+
- Let an element finish animating and stay readable for at least 1.5 seconds before it leaves. A label or phrase of more than one word stays at least 1.3 seconds; the closing title or message is held at least 1.5 seconds before the last frame. If the plan's length cannot fit that, lengthen the scene and tell the user.
|
|
39
47
|
- Counters, progress bars and drawn lines finish before the scene's midpoint.
|
|
40
48
|
- Text that swaps inside a container that is also changing size needs its own exit and enter timing, or old and new text overlap.
|
|
49
|
+
- **Whatever a scene adds, the scene removes.** Every overlay, label and effect leaves when its moment ends. A leftover from an earlier scene is the most common thing a review finds.
|
|
50
|
+
- **Show exactly what is being said.** Each visual belongs to its own sentence. A picture that fits only roughly is worse than plain type, and the same picture or clip never appears twice.
|
|
41
51
|
|
|
42
52
|
## Legibility over images and footage
|
|
43
53
|
- Text over an image or footage needs help: a dim layer of 0.2 to 0.45 on the media, or a solid plate behind the text, plus a soft shadow.
|
|
44
54
|
- In footage mode keep overlays to one graphic at a time, clear of the subject.
|
|
55
|
+
- Talking to a still camera gets dull fast. Give each sentence one small punch-in on its most important word: 10 to 15 percent, on a spring, centred on the speaker's face, landing on the frame the word starts and resetting at the next sentence. Give the last sentence a slow 3 percent push instead. Do not add this over footage that already has zooms of its own (screen recordings from tools that zoom automatically): two zooms on top of each other make people dizzy.
|
|
56
|
+
- Never let more than about 3 seconds of speech pass in footage with nothing new on screen.
|
|
57
|
+
- A punch-in softens 1080 footage. If the user can still choose, suggest filming in 4K when zooms are planned.
|
|
45
58
|
|
|
46
59
|
## Truth on screen
|
|
47
60
|
- Never show a number, quote, price, statistic or result that is not in the narration or on-screen text of the plan.
|
|
@@ -55,8 +68,31 @@ description: Use when deciding how a scene looks and moves - layout, typography,
|
|
|
55
68
|
## Shape of a 30 second video
|
|
56
69
|
Hook (first 1.5 s: the boldest visual and claim) → context (one line, one visual) → body (three or four beats) → payoff (the result or number, the biggest animation of the video) → close (one action, calm).
|
|
57
70
|
|
|
71
|
+
## Motion blur and speed
|
|
72
|
+
- Do not fake blur on fast motion with a blur filter on everything. Keep a short blur (6 to 10 pixels for a few frames) for content swapping inside a container and for depth.
|
|
73
|
+
- A whole word sliding hundreds of pixels in a frame leaves separate ghost copies. Give it a directional blur that follows its speed, or shorten the travel.
|
|
74
|
+
|
|
75
|
+
## Weight on the page
|
|
76
|
+
- Keep a scene under roughly 1,500 elements; beyond that draw on one canvas and redraw it each frame.
|
|
77
|
+
- Compute heavy geometry once, outside the frame function, and only position it per frame.
|
|
78
|
+
- Grain or paper texture is one static layer above everything, outside the camera group. Inside a zooming group it is slow and the grain swells into blotches.
|
|
79
|
+
|
|
58
80
|
## Banned, because they read as AI-made or cheap
|
|
81
|
+
These hold unless the look chosen in `reference/styles.md` names an exception (a showreel look uses flat colour fields; the glowing-branches look uses glow on its lines).
|
|
82
|
+
|
|
59
83
|
Flat solid backgrounds, opacity-only fades, everything entering at once, rainbow or multi-stop gradients on text and UI, particles and sparkles, glows on interface chrome, emoji as graphics, bouncy easing, gratuitous 3D flips, a centred title on a plain gradient, mixed icon styles, lorem ipsum, and anything that looks like a stock template.
|
|
60
84
|
|
|
85
|
+
## The studio test
|
|
86
|
+
Before showing anything, every answer is yes:
|
|
87
|
+
1. There is one clear idea at each moment, and the eye knows where to look.
|
|
88
|
+
2. Every move starts and ends on a spring, with a small anticipation before a big one.
|
|
89
|
+
3. Nothing is frozen longer than the look allows, and nothing shakes without a reason.
|
|
90
|
+
4. Every word reads at phone size, in the right order, uncut and not touching its neighbour.
|
|
91
|
+
5. Every colour comes from the palette that was agreed; no shade crept in on the way.
|
|
92
|
+
6. Nothing from the chosen look's forbidden list is on screen.
|
|
93
|
+
7. It looks like a studio made it, not like a template or a slide deck.
|
|
94
|
+
|
|
61
95
|
## Check your own frames
|
|
62
|
-
|
|
96
|
+
Go round at least twice: preview, fix, preview again. The first pass finds what is broken; the second finds what is merely weak. If the review shows that something in the approved plan cannot work (an ending too small in the frame), make the smallest change that fixes it and tell the user what changed and why; a different scene or different words goes back for approval.
|
|
97
|
+
|
|
98
|
+
Run `reelkit preview`, then look at both frames of every scene in `out/preview/` (early at 30%, late at 90%) as a harsh director would. Hunt for: text overlapping during a swap, text cut by the frame edge, type too small for a phone, a frame with nothing in it, and anything that looks like a template. Fix what is real, then check again.
|
|
@@ -10,6 +10,38 @@ description: Use when writing Video.tsx - the file rules, timing model and media
|
|
|
10
10
|
- Extra component files are flat siblings, PascalCase, one component per file: `NumberBadge.tsx` exports `NumberBadge`. Import them in Video.tsx as `./NumberBadge`.
|
|
11
11
|
- Video.tsx may import only `react`, `remotion`, `reelkit/kit` and sibling components. No other packages, no network, no file access, no `process`.
|
|
12
12
|
|
|
13
|
+
## What the composition receives
|
|
14
|
+
`Video` gets two props, `{ manifest, urls }` (type `VideoProps`, exported by `reelkit/kit`). `manifest` is the content of `manifest.json` in the project, rewritten by `reelkit assets voiceover`, `check`, `preview` and `render` from `plan.json` and the recorded voiceovers. It is not hand-edited. `urls` is separate: it is built at render time and is not in `manifest.json`.
|
|
15
|
+
|
|
16
|
+
`manifest`:
|
|
17
|
+
- `fps`: frames per second (30).
|
|
18
|
+
- `width`, `height`: pixels. 1080x1920 for 9:16, 1920x1080 for 16:9, 1080x1080 for 1:1; in footage mode the footage's own size, scaled down to at most 1920 on its long side.
|
|
19
|
+
- `totalFrames`: the length of the video in frames. Seconds are `totalFrames / fps`.
|
|
20
|
+
- `footageKey`: only in footage mode, the project path of the footage; look it up as `urls[manifest.footageKey]`.
|
|
21
|
+
- `scenes`: one entry per plan scene, in plan order, laid end to end.
|
|
22
|
+
|
|
23
|
+
Each `scenes` entry:
|
|
24
|
+
- `id`: the scene id from `plan.json`.
|
|
25
|
+
- `startFrame`, `durationFrames`: frames on the whole video's timeline. A scene lasts its narration plus 0.4 s of breathing room, rounded up to a whole frame.
|
|
26
|
+
- `voiceoverKey`: the project path of the narration audio.
|
|
27
|
+
- `words`: one entry per spoken word, `{ word, startSec, endSec }`, in seconds from the start of that scene (not of the video, and not frames). Convert with `Math.round(sec * fps)` for a frame inside the scene; `<Captions words={s.words} />` takes them as they are.
|
|
28
|
+
- `imageKey`: only on an illustration scene that has its image; the project path of the image.
|
|
29
|
+
- `userAssetKeys`: project paths of the user's own files that the plan assigned to this scene (an empty array when none).
|
|
30
|
+
|
|
31
|
+
`urls` maps a project path (every file under `assets/`, except `.json` files) to the address Remotion can load it from. Pass paths from the manifest, or one you pulled, such as `urls["assets/lib/<id>/clip.mp3"]`; never build an address yourself.
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{
|
|
35
|
+
"fps": 30, "width": 1080, "height": 1920, "totalFrames": 150,
|
|
36
|
+
"scenes": [
|
|
37
|
+
{ "id": "a", "startFrame": 0, "durationFrames": 75, "voiceoverKey": "assets/vo-a.mp3",
|
|
38
|
+
"words": [{ "word": "Hello", "startSec": 0, "endSec": 0.4 }, { "word": "there", "startSec": 0.4, "endSec": 0.9 }],
|
|
39
|
+
"userAssetKeys": [] },
|
|
40
|
+
{ "id": "b", "startFrame": 75, "durationFrames": 75, "voiceoverKey": "assets/vo-b.mp3", "words": [], "userAssetKeys": [] }
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
13
45
|
## Timing
|
|
14
46
|
- All timing comes from the manifest. Each scene has `startFrame` and `durationFrames`; wrap each scene's content in `<SceneFrame from={s.startFrame} durationInFrames={s.durationFrames}>`.
|
|
15
47
|
- Inside a SceneFrame, `useCurrentFrame()` starts at 0 for that scene.
|
|
@@ -12,7 +12,9 @@ A few well-placed sounds make motion feel real. Too many make a video tiring. Th
|
|
|
12
12
|
- **Scene changes:** a short transition sound across the cut.
|
|
13
13
|
- **Interface moments:** a notification arriving, a button tap, a tick appearing. A click, pop or chime.
|
|
14
14
|
- **Build-ups:** a riser into the payoff or the final call to action.
|
|
15
|
-
- Not every element. Two to four sounds per scene at most, and usually one.
|
|
15
|
+
- Not every element. Two to four sounds per scene at most, and usually one. A sound on every movement sounds like a toy.
|
|
16
|
+
- At most one whoosh or swish per scene, and quieter than the hits on purpose.
|
|
17
|
+
- One deep sub or boom per scene at most, and one or two big impacts in the whole video, on its peak moments.
|
|
16
18
|
|
|
17
19
|
## Finding sounds
|
|
18
20
|
1. `reelkit assets search "<description>" --kind sfx` with what you want to hear: "whoosh", "soft click", "notification pop", "riser", "camera shutter", "bass impact". Add "one-shot" or "short" to the query for one-shots.
|
|
@@ -30,6 +32,7 @@ A few well-placed sounds make motion feel real. Too many make a video tiring. Th
|
|
|
30
32
|
- The voiceover plays at 1. Sound effects sit between 0.2 and 0.45. Impacts on the hook can go to 0.6.
|
|
31
33
|
- Ambience and drone beds: 0.06 to 0.12.
|
|
32
34
|
- Never let two loud sounds overlap. Stagger them.
|
|
35
|
+
- When the same kind of sound repeats, grade it: the first at full level, the rest quieter.
|
|
33
36
|
|
|
34
37
|
## Fit
|
|
35
38
|
- Match the sound to the look: soft pops and gentle whooshes for calm, friendly videos; glitches and bass hits for tech and high energy; camera and tape sounds for retro.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: styles
|
|
3
|
+
description: Use before planning, to choose the video's look with the user - the two modes (restrained and showreel) and a catalogue of looks, each with when to use it, how it is built, and what it forbids.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Choosing the look
|
|
7
|
+
|
|
8
|
+
Pick one look for the whole video before writing the plan, and say it in the first scene's `notes`. A video that mixes looks reads as a template.
|
|
9
|
+
|
|
10
|
+
If the user already described what they want, match it to a look and confirm in one sentence. Otherwise offer the catalogue below as a short numbered list with one line each, and include "your own: describe it, or send a picture or a link". Ask every open question in one message, each with a default, so the user can answer "defaults".
|
|
11
|
+
|
|
12
|
+
## Two modes
|
|
13
|
+
Every look belongs to one mode. The mode decides which rules in `reference/motion-design.md` bend.
|
|
14
|
+
|
|
15
|
+
**Restrained** (product demos, explainers, anything that must feel expensive and calm)
|
|
16
|
+
- One background, one hero colour or black and white, one face.
|
|
17
|
+
- One object that changes shape instead of cuts. Content swaps inside it with a short blur, and each text has its own exit and entrance.
|
|
18
|
+
- Forbidden: glows, gradients on interface parts, particles, mixed icon weights.
|
|
19
|
+
|
|
20
|
+
**Showreel** (openers, announcements, anything that must stop the scroll)
|
|
21
|
+
- Hard cuts on the beat between full-colour backgrounds; type that fills the frame. Flat solid colour fields are right here.
|
|
22
|
+
- The camera never stops: a slow push, a degree or two of rotation, a short shake on each hit.
|
|
23
|
+
- No frame sits still longer than a third of a second, except the final hold.
|
|
24
|
+
- At most three full-screen flashes in any second, and never a red strobe. This is a safety rule, not a taste rule.
|
|
25
|
+
|
|
26
|
+
## The catalogue
|
|
27
|
+
|
|
28
|
+
### 1. One shape, many interfaces (restrained)
|
|
29
|
+
**When:** a product or app should look polished. **How:** a single rounded shape morphs through button, loader, player, toggle, tabs and chart; its width, height and radius move together on one spring, and the leading edge of a slider or toggle moves on a faster spring than the trailing edge so it stretches. A pointer, if shown, drives values directly while it drags and springs back on release. **Forbidden:** a cut between states, more than one accent colour.
|
|
30
|
+
|
|
31
|
+
### 2. Giant type on colour fields (showreel)
|
|
32
|
+
**When:** an opener or a strong message of 3 to 6 words. **How:** one or two words per frame, a hard cut to a new full colour on each beat, each word entering a different way (a scale hit, widening from tight to wide, a short slide, a design-tool selection box that snaps on). End with the whole message together, held at least 1.5 seconds. **Forbidden:** soft gradients, outlined words, a still frame.
|
|
33
|
+
|
|
34
|
+
### 3. A word made of particles (showreel)
|
|
35
|
+
**When:** revealing a name or brand, or a closing wow. **How:** sample the word's pixels once, then move each particle between targets (word, sphere, swirl, word) as a function of the frame; draw on a canvas, not thousands of elements. The word must be readable again at the end and held half a second. **Forbidden:** per-frame randomness.
|
|
36
|
+
|
|
37
|
+
### 4. A field of blocks (showreel)
|
|
38
|
+
**When:** an energetic background behind a headline, or anything about data. **How:** a grid of blocks rising and falling as a travelling wave, one accent object moving across it, the headline on a dark soft patch above. All blocks jump on the last word. **Forbidden:** letting the background out-shout the text.
|
|
39
|
+
|
|
40
|
+
### 5. Rings of words (showreel)
|
|
41
|
+
**When:** a list of values, services or topics. **How:** each line of words bends into a ring; rings turn at different slow speeds inside one another with the title in the centre. Read every ring in a preview frame. **Forbidden:** more than about ten words, rings too small to read.
|
|
42
|
+
|
|
43
|
+
### 6. Hand-drawn character (restrained)
|
|
44
|
+
**When:** a personal, warm or funny message. **How:** a simple pencil character on paper; lines boil slightly by switching between two or three hand-offset versions every few frames (chosen by frame number, never at random), a speech bubble in a handwriting face. **Forbidden:** clean vector precision, more than two colours beside the pencil.
|
|
45
|
+
|
|
46
|
+
### 7. Storybook explainer (restrained)
|
|
47
|
+
**When:** teaching how something works, in 3 to 5 steps. **How:** flat illustration with rounded shapes, one dark outline colour, a paper background and faint static grain. The camera moves into the object and a round cutaway opens to show the inside, instead of cutting to a new scene. Each step gets a short label beside the action with a thin leader line, on screen at least 1.3 seconds; end on a 2 to 4 word title over the result. **Forbidden:** realistic shadows, strong gradients, labels covering the drawing.
|
|
48
|
+
|
|
49
|
+
### 8. Glowing branches (showreel)
|
|
50
|
+
**When:** AI, science, the moment an idea is born. **How:** lines of light that grow and fork like nerve cells, a spark running along them, on near-black. Glow is the look here, so it is allowed, on the lines only. **Forbidden:** glow on text, flashes above the limit.
|
|
51
|
+
|
|
52
|
+
### 9. Sketch to object (restrained)
|
|
53
|
+
**When:** showing how a physical thing is built. **How:** a pencil technical drawing draws itself line by line, gains colour and shade, then does what it was built to do. **Forbidden:** skipping the drawing stage; it is the point.
|
|
54
|
+
|
|
55
|
+
### 10. Logo reveal (either mode)
|
|
56
|
+
**When:** an opener or end card, from the user's own logo file. **How:** build toward the logo from its own shapes and colours and land on it exactly as supplied, held at least 1.5 seconds. **Forbidden:** redrawing, recolouring or stretching the logo; anyone else's logo or trademark.
|
|
57
|
+
|
|
58
|
+
## Your own
|
|
59
|
+
Ask what the message is, what happens on screen, the colours, the length and shape, and for an example if they have one. Choose the nearest look above as the technical base and write a four-line brief in the same shape (when, how, forbidden, structure). Show the brief with the plan at the checkpoint.
|
|
60
|
+
|
|
61
|
+
## What every look shares
|
|
62
|
+
- The plan checkpoint lists the exact words that will appear on screen and when. Fixing a list takes a minute; fixing a finished animation takes an hour.
|
|
63
|
+
- Describe the look to the user in plain words about what they will see ("the title sits on a soft dark patch"), not in studio terms.
|
|
64
|
+
- Only the user's own logos, pictures and faces. Never another company's logo or mark.
|
package/src/cli.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { install } from "./commands/install";
|
|
|
8
8
|
import { openInBrowser } from "./open";
|
|
9
9
|
import { planCheck } from "./commands/plan";
|
|
10
10
|
import type { Ctx, Result } from "./context";
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
11
12
|
|
|
12
13
|
const ctx: Ctx = {
|
|
13
14
|
cwd: process.cwd(),
|
|
@@ -52,7 +53,9 @@ export function run<A extends unknown[]>(fn: (ctx: Ctx, ...args: A) => Promise<R
|
|
|
52
53
|
};
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
|
|
56
|
+
// Read from the package itself, so the number can never drift from what was installed.
|
|
57
|
+
const VERSION = (JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string }).version;
|
|
58
|
+
export const program = new Command().name("reelkit").description("Make short-form video with a shared asset library.").version(VERSION, "-v, --version", "print the version").option("--json", "print the result as JSON");
|
|
56
59
|
|
|
57
60
|
const auth = program.command("auth").description("Log in and out");
|
|
58
61
|
auth.command("login").description("Log in to Reelkit: opens the login page in your browser").option("--start", "begin a login and return the link at once, without opening a browser (for an agent)").option("--finish", "finish a login begun with --start")
|
|
@@ -82,8 +85,8 @@ program.command("plan").description("Work with plan.json").command("check").desc
|
|
|
82
85
|
|
|
83
86
|
program.command("check").description("Check the composition in src/ without rendering").action(run(check));
|
|
84
87
|
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)));
|
|
85
|
-
program.command("preview").description("Render
|
|
86
|
-
program.command("render").description("Render the video to out/video.mp4").action(run(render));
|
|
88
|
+
program.command("preview").description("Render two test frames per scene (at 30% and 90%) into out/preview/").action(run((ctx) => preview(ctx)));
|
|
89
|
+
program.command("render").description("Render the video to out/video.mp4").action(run((ctx) => render(ctx)));
|
|
87
90
|
|
|
88
91
|
// Parses argv and runs the chosen command. Importing this module parses nothing; bin/reelkit.mjs calls main.
|
|
89
92
|
export async function main(argv: string[]): Promise<void> {
|
package/src/commands/build.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type { AssetManifest } from "../pipeline/schema";
|
|
|
7
7
|
import { buildManifest, missingAssets } from "../project/manifest";
|
|
8
8
|
import { openProject, type Project } from "../project/project";
|
|
9
9
|
import { mediaUrls, serveDir } from "../project/serve";
|
|
10
|
-
import { bundleProject, disposeBundle, renderStills, renderVideo, writeEntry } from "../render/render";
|
|
10
|
+
import { bundleProject, disposeBundle, ensureRenderBrowser, renderStills, renderVideo, writeEntry, type EnsureBrowser } from "../render/render";
|
|
11
11
|
import { FILE_NAME, MAIN_FILE, staticCheck, typecheck } from "../render/validate";
|
|
12
12
|
import { loadPlan } from "./plan";
|
|
13
13
|
|
|
@@ -59,9 +59,34 @@ async function withBundle<T>(project: Project, fn: (serveUrl: string, urls: Reco
|
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
// The first render on a machine downloads about 90 MB with no other sign of life, so it is announced. Nothing is said
|
|
63
|
+
// when the browser is already there. The lines go through ctx.log, which never writes to stdout.
|
|
64
|
+
export async function ensureBrowserWithNotice(ctx: Ctx, ensure: EnsureBrowser = ensureRenderBrowser): Promise<void> {
|
|
65
|
+
let downloaded = false;
|
|
66
|
+
await ensure(() => {
|
|
67
|
+
downloaded = true;
|
|
68
|
+
ctx.log("Downloading the browser used for rendering (about 90 MB, one time)...");
|
|
69
|
+
});
|
|
70
|
+
if (downloaded) ctx.log("Downloaded the browser.");
|
|
71
|
+
}
|
|
72
|
+
|
|
62
73
|
const ffmpegToJpeg = (png: string, jpg: string) => exec("ffmpeg", ["-y", "-loglevel", "error", "-i", png, "-vf", "scale=540:-2", "-q:v", "5", jpg]).then(() => undefined);
|
|
63
74
|
|
|
64
|
-
|
|
75
|
+
type PreviewPoint = { sceneId: string; point: "early" | "late" | "middle"; frame: number; name: string };
|
|
76
|
+
|
|
77
|
+
// Two frames per scene, at 30% and 90% of its length, so what enters late in a scene is seen as well as what opens it.
|
|
78
|
+
// A scene under 4 frames has no room for two distinct frames and gets its middle one. The scene's number leads each name
|
|
79
|
+
// so the files sort by scene order and then by time, whatever the scene ids are.
|
|
80
|
+
export function previewPoints(scenes: { id: string; startFrame: number; durationFrames: number }[]): PreviewPoint[] {
|
|
81
|
+
return scenes.flatMap((s, i) => {
|
|
82
|
+
const at = (point: PreviewPoint["point"], offset: number): PreviewPoint => ({ sceneId: s.id, point, frame: s.startFrame + offset, name: `${String(i + 1).padStart(2, "0")}-${s.id}-${point}` });
|
|
83
|
+
if (s.durationFrames < 4) return [at("middle", Math.floor(s.durationFrames / 2))];
|
|
84
|
+
const inside = (share: number) => Math.min(s.durationFrames - 1, Math.round(s.durationFrames * share));
|
|
85
|
+
return [at("early", inside(0.3)), at("late", inside(0.9))];
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function preview(ctx: Ctx, deps: { toJpeg?: (png: string, jpg: string) => Promise<void>; ensureBrowser?: EnsureBrowser } = {}): Promise<Result> {
|
|
65
90
|
const toJpeg = deps.toJpeg ?? ffmpegToJpeg;
|
|
66
91
|
const project = openProject(ctx.cwd);
|
|
67
92
|
const { errors, manifest } = inspect(project);
|
|
@@ -69,37 +94,39 @@ export async function preview(ctx: Ctx, deps: { toJpeg?: (png: string, jpg: stri
|
|
|
69
94
|
const dir = project.path("out/preview");
|
|
70
95
|
rmSync(dir, { recursive: true, force: true });
|
|
71
96
|
mkdirSync(dir, { recursive: true });
|
|
72
|
-
const
|
|
97
|
+
const points = previewPoints(manifest.scenes);
|
|
73
98
|
try {
|
|
74
99
|
try {
|
|
75
|
-
await
|
|
100
|
+
await ensureBrowserWithNotice(ctx, deps.ensureBrowser);
|
|
101
|
+
await withBundle(project, (serveUrl, urls) => renderStills(serveUrl, { manifest, urls }, points.map((p) => p.frame), dir));
|
|
76
102
|
} catch (e) {
|
|
77
103
|
return failed([`Render failed: ${e instanceof Error ? e.message : String(e)}`]);
|
|
78
104
|
}
|
|
79
|
-
const frames: { sceneId: string; path: string }[] = [];
|
|
105
|
+
const frames: { sceneId: string; point: PreviewPoint["point"]; path: string }[] = [];
|
|
80
106
|
try {
|
|
81
|
-
for (const m of
|
|
107
|
+
for (const m of points) {
|
|
82
108
|
// Shrunk to phone width: small enough to read cheaply, and what is unreadable here is unreadable on a phone.
|
|
83
|
-
const path = `out/preview/${m.
|
|
109
|
+
const path = `out/preview/${m.name}.jpg`;
|
|
84
110
|
await toJpeg(join(dir, `still-${m.frame}.png`), project.path(path));
|
|
85
|
-
frames.push({ sceneId: m.sceneId, path });
|
|
111
|
+
frames.push({ sceneId: m.sceneId, point: m.point, path });
|
|
86
112
|
}
|
|
87
113
|
} catch {
|
|
88
114
|
return failed(["Could not write the preview frames: ffmpeg failed. Check that ffmpeg is installed (macOS: `brew install ffmpeg`) and run `reelkit preview` again."]);
|
|
89
115
|
}
|
|
90
|
-
return { ok: true, data: { frames }, summary: `
|
|
116
|
+
return { ok: true, data: { frames }, summary: `Two frames per scene, at 30% and at 90% of its length (a scene under 4 frames gets its middle one). Look at both frames of every scene:\n${frames.map((f) => `${f.sceneId} (${f.point}): ${f.path}`).join("\n")}` };
|
|
91
117
|
} finally {
|
|
92
118
|
// The full-size stills are only inputs; none stays behind however this ends.
|
|
93
119
|
for (const f of readdirSync(dir)) if (/^still-\d+\.png$/.test(f)) rmSync(join(dir, f), { force: true });
|
|
94
120
|
}
|
|
95
121
|
}
|
|
96
122
|
|
|
97
|
-
export async function render(ctx: Ctx): Promise<Result> {
|
|
123
|
+
export async function render(ctx: Ctx, deps: { ensureBrowser?: EnsureBrowser } = {}): Promise<Result> {
|
|
98
124
|
const project = openProject(ctx.cwd);
|
|
99
125
|
const { errors, manifest } = inspect(project);
|
|
100
126
|
if (!manifest) return failed(errors);
|
|
101
127
|
const path = "out/video.mp4";
|
|
102
128
|
try {
|
|
129
|
+
await ensureBrowserWithNotice(ctx, deps.ensureBrowser);
|
|
103
130
|
await withBundle(project, (serveUrl, urls) => renderVideo(serveUrl, { manifest, urls }, project.path(path)));
|
|
104
131
|
} catch (e) {
|
|
105
132
|
return failed([`Render failed: ${e instanceof Error ? e.message : String(e)}`]);
|
package/src/commands/init.ts
CHANGED
|
@@ -15,7 +15,7 @@ export async function init(ctx: Ctx, name: string | undefined, opts: { aspect?:
|
|
|
15
15
|
if (slug === "") return { ok: false, summary: "Give the project a name with letters or digits, for example: reelkit init launch-video" };
|
|
16
16
|
const aspect = AspectSchema.safeParse(opts.aspect ?? "9:16");
|
|
17
17
|
if (!aspect.success) return { ok: false, summary: `Unknown aspect "${opts.aspect}". Use 9:16, 16:9 or 1:1.` };
|
|
18
|
-
if (Number(process.versions.node.split(".")[0]) <
|
|
18
|
+
if (Number(process.versions.node.split(".")[0]) < 22) return { ok: false, summary: `Node 22 or newer is needed; this is ${process.versions.node}. Install a newer Node.` };
|
|
19
19
|
try { await exec("ffmpeg", ["-version"]); } catch { return { ok: false, summary: "ffmpeg was not found. Install it (macOS: `brew install ffmpeg`) and run `reelkit init` again." }; }
|
|
20
20
|
|
|
21
21
|
const dir = slug ? join(ctx.cwd, slug) : ctx.cwd;
|
package/src/commands/plan.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { client, type Ctx, type Result } from "../context";
|
|
2
2
|
import { loadCredentials } from "../credentials";
|
|
3
|
-
import { reviewPlan } from "../pipeline/review";
|
|
3
|
+
import { estimateLength, reviewPlan } from "../pipeline/review";
|
|
4
4
|
import { ScenePlanSchema, validatePlan, type ScenePlan } from "../pipeline/schema";
|
|
5
5
|
import { FILES, issueLines, openProject, type Project } from "../project/project";
|
|
6
6
|
|
|
@@ -36,10 +36,13 @@ export async function planCheck(ctx: Ctx): Promise<Result> {
|
|
|
36
36
|
const voiceIds = loadCredentials(ctx.env) ? (await client(ctx)("voices", {})).voices.map((v) => v.id) : undefined;
|
|
37
37
|
const mustFix = validatePlan(parsed.data, { aspect: project.config().aspect, footage, assets, voiceIds });
|
|
38
38
|
const shouldImprove = mustFix.length ? [] : reviewPlan(parsed.data, { footage });
|
|
39
|
+
const length = estimateLength(parsed.data);
|
|
40
|
+
const estimatedSeconds = Math.round(length.seconds);
|
|
39
41
|
const lines = [
|
|
40
42
|
mustFix.length ? `Fix these:\n- ${mustFix.join("\n- ")}` : "The plan is valid.",
|
|
43
|
+
`Estimated length: about ${estimatedSeconds}s (${length.words} words).`,
|
|
41
44
|
...(shouldImprove.length ? [`Worth improving:\n- ${shouldImprove.join("\n- ")}`] : []),
|
|
42
45
|
...(voiceIds ? [] : ["The voice was not checked because you are not logged in. Run `reelkit auth login`."]),
|
|
43
46
|
];
|
|
44
|
-
return { ok: mustFix.length === 0, data: { mustFix, shouldImprove }, summary: lines.join("\n") };
|
|
47
|
+
return { ok: mustFix.length === 0, data: { mustFix, shouldImprove, estimatedSeconds, words: length.words }, summary: lines.join("\n") };
|
|
45
48
|
}
|
package/src/contract/index.ts
CHANGED
|
@@ -106,6 +106,7 @@ const Meter = z.object({ used: z.number(), limit: z.number() });
|
|
|
106
106
|
export const MeSchema = z.object({
|
|
107
107
|
userId: z.string(), handle: z.string(),
|
|
108
108
|
quota: z.object({ voiceoverChars: Meter, images: Meter, resetsAt: z.string() }),
|
|
109
|
+
// How many of the user's own items are published in the shared library. An item still in review, or sent back, is not counted.
|
|
109
110
|
contributions: z.number(),
|
|
110
111
|
});
|
|
111
112
|
|
package/src/pipeline/review.ts
CHANGED
|
@@ -2,12 +2,17 @@ import { WORDS_PER_SEC, type AssetRecord, type ScenePlan } from "./schema";
|
|
|
2
2
|
|
|
3
3
|
const words = (s: string) => s.trim().split(/\s+/).filter(Boolean).length;
|
|
4
4
|
|
|
5
|
+
// The same estimate the length advice below is based on: narration words at the voice's usual pace.
|
|
6
|
+
export function estimateLength(plan: ScenePlan): { words: number; seconds: number } {
|
|
7
|
+
const total = plan.scenes.reduce((n, s) => n + words(s.narration), 0);
|
|
8
|
+
return { words: total, seconds: total / WORDS_PER_SEC };
|
|
9
|
+
}
|
|
10
|
+
|
|
5
11
|
// Soft quality checks on a plan that already passes the hard rules in validatePlan.
|
|
6
12
|
// These are things worth improving in the script; they never fail a video on their own.
|
|
7
13
|
export function reviewPlan(plan: ScenePlan, ctx: { footage?: AssetRecord }): string[] {
|
|
8
14
|
const issues: string[] = [];
|
|
9
|
-
const
|
|
10
|
-
const seconds = total / WORDS_PER_SEC;
|
|
15
|
+
const { words: total, seconds } = estimateLength(plan);
|
|
11
16
|
|
|
12
17
|
if (!ctx.footage) {
|
|
13
18
|
if (seconds < 20) issues.push(`The script runs about ${seconds.toFixed(0)}s (${total} words). Aim for 30 to 60 seconds.`);
|
package/src/render/render.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { bundle } from "@remotion/bundler";
|
|
4
|
-
import { renderMedia, renderStill, selectComposition } from "@remotion/renderer";
|
|
4
|
+
import { ensureBrowser, renderMedia, renderStill, selectComposition } from "@remotion/renderer";
|
|
5
5
|
import type { VideoProps } from "../remotion/types";
|
|
6
6
|
import { packageDir } from "./deps";
|
|
7
7
|
import { KIT_INDEX, ROOT_FILE } from "./validate";
|
|
@@ -50,6 +50,13 @@ export async function disposeBundle(serveUrl: string): Promise<void> {
|
|
|
50
50
|
if (/remotion-webpack-bundle-/.test(serveUrl)) await rm(serveUrl, { recursive: true, force: true });
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
// Makes sure Remotion's headless browser is installed. onDownload is called once, only when a download is about to begin.
|
|
54
|
+
// Remotion's own progress bar is replaced because it writes to stdout, which --json keeps for the result alone.
|
|
55
|
+
export type EnsureBrowser = (onDownload: () => void) => Promise<void>;
|
|
56
|
+
export const ensureRenderBrowser: EnsureBrowser = async (onDownload) => {
|
|
57
|
+
await ensureBrowser({ onBrowserDownload: () => { onDownload(); return { version: null, onProgress: () => {} }; } });
|
|
58
|
+
};
|
|
59
|
+
|
|
53
60
|
const asInput = (props: VideoProps) => props as unknown as Record<string, unknown>;
|
|
54
61
|
|
|
55
62
|
export async function renderStills(serveUrl: string, props: VideoProps, frames: number[], outDir: string) {
|
|
@@ -90,7 +90,8 @@ export function runConformance(name: string, start: (opts: StartOptions) => Prom
|
|
|
90
90
|
expect((await put(up.uploadUrl, PNG, "image/png")).ok).toBe(true);
|
|
91
91
|
const { item } = await mine("libraryCommit", { id: up.id });
|
|
92
92
|
expect(item).toMatchObject({ id: up.id, kind: "image", visibility: "review" });
|
|
93
|
-
|
|
93
|
+
// Contributions are the user's published items; one still waiting for review is not one yet.
|
|
94
|
+
expect((await mine("me", {})).contributions).toBe(0);
|
|
94
95
|
// An item that is not published never appears, whoever searches.
|
|
95
96
|
expect((await mine("librarySearch", { q: "zebra" })).items.map((i) => i.id)).not.toContain(up.id);
|
|
96
97
|
expect((await other("librarySearch", { q: "zebra" })).items.map((i) => i.id)).not.toContain(up.id);
|
|
@@ -106,14 +107,13 @@ export function runConformance(name: string, start: (opts: StartOptions) => Prom
|
|
|
106
107
|
const up = await mine("libraryUpload", upload({ shareable: false, title: "Hidden gecko", description: "hidden gecko" }));
|
|
107
108
|
await put(up.uploadUrl, PNG, "image/png");
|
|
108
109
|
expect((await mine("libraryCommit", { id: up.id })).item.visibility).toBe("private");
|
|
109
|
-
// Contributions are shared uploads only.
|
|
110
110
|
expect((await mine("me", {})).contributions).toBe(0);
|
|
111
111
|
expect((await mine("librarySearch", { q: "gecko" })).items.map((i) => i.id)).not.toContain(up.id);
|
|
112
112
|
expect((await failure(other("libraryPull", { id: up.id }))).code).toBe("not_found");
|
|
113
113
|
expect((await mine("libraryPull", { id: up.id })).item.id).toBe(up.id);
|
|
114
114
|
});
|
|
115
115
|
|
|
116
|
-
it("an upload is not an item before commit, and committing twice
|
|
116
|
+
it("an upload is not an item before commit, and committing twice returns the same item", async () => {
|
|
117
117
|
const mine = await as("user-up00005");
|
|
118
118
|
const up = await mine("libraryUpload", upload());
|
|
119
119
|
expect((await failure(mine("libraryCommit", { id: up.id }))).code).toBe("invalid_request");
|
|
@@ -121,7 +121,7 @@ export function runConformance(name: string, start: (opts: StartOptions) => Prom
|
|
|
121
121
|
expect((await failure(mine("libraryPull", { id: up.id }))).code).toBe("not_found");
|
|
122
122
|
const first = await mine("libraryCommit", { id: up.id });
|
|
123
123
|
expect(await mine("libraryCommit", { id: up.id })).toEqual(first);
|
|
124
|
-
expect((await mine("me", {})).contributions).toBe(
|
|
124
|
+
expect((await mine("me", {})).contributions).toBe(0);
|
|
125
125
|
});
|
|
126
126
|
|
|
127
127
|
it("refuses a PUT of the wrong type or length, and commit then has nothing to commit", async () => {
|
package/src/testing/fake-api.ts
CHANGED
|
@@ -89,7 +89,8 @@ export async function startFakeApi(opts: { voiceoverCharLimit?: number; imageLim
|
|
|
89
89
|
return {
|
|
90
90
|
userId: userId ?? "u-test", handle: handleOf(userId ?? "u-test"),
|
|
91
91
|
quota: { voiceoverChars: { used: m.chars, limit: limits.chars }, images: { used: m.images, limit: limits.images }, resetsAt: resetDate() },
|
|
92
|
-
|
|
92
|
+
// What the user gave the shared library that was accepted: their own items that are published. One waiting for review does not count.
|
|
93
|
+
contributions: [...store.values()].filter((x) => x.owner === userId && x.committed && x.item.visibility === "published").length,
|
|
93
94
|
};
|
|
94
95
|
},
|
|
95
96
|
// Search and the public library list published items only; nothing private or in review is ever found.
|