reelkit-cli 0.1.1 → 0.1.3

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 CHANGED
@@ -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` | One test frame per scene |
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,6 +1,6 @@
1
1
  {
2
2
  "name": "reelkit-cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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",
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. Plan
29
- Read `reference/scriptwriting.md` and `reference/scene-treatments.md`. Run `reelkit assets voices` and choose a voice that fits the idea, audience and language.
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`. Fix everything under "Fix these". Act on "Worth improving" unless you have a good reason not to.
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
- ### 3. Voice
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
- ### 4. Images
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
- ### 5. Composition
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 each frame in `out/preview/`. 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. Fix real problems and preview again.
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
- ### 6. Render
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 render before the user has approved the preview.
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 40 to 60 px from the sides; platform buttons and descriptions cover the rest. The component's `bottom` prop (a fraction of the height, default 0.16) controls this.
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
- - If any on-screen text is Hebrew, 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.
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 neon look uses glow on its hero element and 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
- Run `reelkit preview`, then look at the frames in `out/preview/` 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.
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,65 @@
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 slight turn, 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
+ Each look is built from what the kit and the shared library already hold, so most of it is found with one search rather than written from nothing. The search words are suggestions for `reelkit assets search "<words>" --kind component`.
28
+
29
+ ### 1. Product walkthrough (restrained)
30
+ **When:** an app, a site or a feature, with the user's real screenshots. **How:** the screenshot sits in a device or browser frame (search "phone mockup", "browser mockup") on a calm mesh background; interface moments arrive as real-looking parts (search "notification", "chat messages", "alert dialog", "status toast"); a pointer label names one thing at a time (search "callout"). One frame stays on screen and the content inside it changes. **Forbidden:** an invented screen, more than one callout at once, glow on interface parts.
31
+
32
+ ### 2. Numbers that land (restrained)
33
+ **When:** results, growth, a comparison, anything with figures from the narration. **How:** one figure or one chart per scene (search "statistic counter", "line chart", "bar chart", "pie chart", "radial gauge"); the number counts up and finishes before the scene's midpoint; a before and after sits side by side (search "comparison"). The payoff number gets the biggest move of the video. **Forbidden:** any figure that is not in the plan, two charts in one frame, decorative data.
34
+
35
+ ### 3. Headline hits (showreel)
36
+ **When:** an opener, a launch, a message of a few words that must stop the scroll. **How:** one or two words fill the frame (search "kinetic title"), hard cuts to a new full colour on the beat, each word entering a different way, and the whole message together at the end, held. The camera keeps a slow push the whole time. **Forbidden:** soft gradients, outlined words, a still frame before the final hold.
37
+
38
+ ### 4. Cinematic story (restrained)
39
+ **When:** a mood, a place, a story told by a voice. **How:** generated or reused pictures with a slow move across them, a film grade and grain over everything, a poster-style title to open (search "cinematic title"), a film overlay if it suits (`--kind overlay`, search "film"). Few words on screen; the captions carry the text. **Forbidden:** interface parts, bright accent colours, fast cuts.
40
+
41
+ ### 5. Neon and night (showreel)
42
+ **When:** technology, AI, nightlife, anything that should feel electric. **How:** near-black background, one glowing colour, a sign-like title (search "neon title"), thin lines that draw themselves. Glow is the look here, so it is allowed, on the hero element and the lines only. **Forbidden:** glow on body text, a second glowing colour, flashes above the limit.
43
+
44
+ ### 6. Notebook (restrained)
45
+ **When:** a personal, warm or teaching tone. **How:** a paper background with faint static grain, a handwriting or rounded face, hand-drawn arrows, circles and underlines that draw themselves onto the point being made (search "hand drawn arrow circle underline"). Slight wobble comes from switching between two or three drawn versions by frame number, never at random. **Forbidden:** crisp interface chrome, more than two ink colours.
46
+
47
+ ### 7. Step by step (restrained)
48
+ **When:** a process, a how-to, a recipe, three to five steps. **How:** a numbered list that builds one step at a time (search "steps process"), or one drawing the camera moves through, stopping at each step with a short label beside the action. Each label stays at least 1.3 seconds; end on a short title over the result. **Forbidden:** all steps appearing at once, labels covering the thing they describe.
49
+
50
+ ### 8. People and proof (restrained)
51
+ **When:** a testimonial, an introduction, a creator talking, social proof. **How:** the person's name and role in a lower third (search "name lower third"), a quote card with its stars (search "quote testimonial"), a follow line to close (search "social handle", "cta button"). Quotes and names come from the user, word for word. **Forbidden:** an invented quote, rating or follower count.
52
+
53
+ ### 9. Over footage (either mode)
54
+ **When:** the user supplied their own video. **How:** the footage is the picture; graphics are guests. One overlay at a time, clear of the speaker's face; a small punch-in on each sentence's key word; lower thirds and callouts from the library; captions always. **Forbidden:** covering the face, a graphic left on screen after its sentence, zooms on footage that already has its own.
55
+
56
+ ### 10. Logo sting (either mode)
57
+ **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.
58
+
59
+ ## Your own
60
+ 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.
61
+
62
+ ## What every look shares
63
+ - 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.
64
+ - 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.
65
+ - 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
- export const program = new Command().name("reelkit").description("Make short-form video with a shared asset library.").option("--json", "print the result as JSON");
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 one test frame per scene into out/preview/").action(run((ctx) => preview(ctx)));
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> {
@@ -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
- export async function preview(ctx: Ctx, deps: { toJpeg?: (png: string, jpg: string) => Promise<void> } = {}): Promise<Result> {
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 mids = manifest.scenes.map((s) => ({ sceneId: s.id, frame: s.startFrame + Math.floor(s.durationFrames / 2) }));
97
+ const points = previewPoints(manifest.scenes);
73
98
  try {
74
99
  try {
75
- await withBundle(project, (serveUrl, urls) => renderStills(serveUrl, { manifest, urls }, mids.map((m) => m.frame), dir));
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 mids) {
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.sceneId}.jpg`;
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: `One frame per scene, taken at its midpoint:\n${frames.map((f) => `${f.sceneId}: ${f.path}`).join("\n")}` };
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)}`]);
@@ -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
  }
@@ -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
 
@@ -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 total = plan.scenes.reduce((n, s) => n + words(s.narration), 0);
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.`);
@@ -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
- expect((await mine("me", {})).contributions).toBe(1);
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 counts once", async () => {
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(1);
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 () => {
@@ -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
- contributions: m.uploads,
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.