reelkit-cli 0.10.0 → 0.10.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 CHANGED
@@ -25,6 +25,16 @@ reelkit auth login
25
25
 
26
26
  It installs for every agent it finds on your machine. Name one with `--agent claude`, or use `--agent all`. `reelkit init` and `reelkit auth login` keep the installed skill up to date on their own; set `REELKIT_NO_AUTO_INSTALL=1` to turn that off.
27
27
 
28
+ ## Studio pane in Claude Code
29
+
30
+ In Claude Code 2.1.287 or newer the installed skill is also a mod: a Reelkit pane opens beside the conversation when a project is in reach (`/reelkit-studio` opens it by hand, `/reelkit-studio my-video` on a named project). It has five tabs:
31
+
32
+ - **Timeline**: scene clips with a filmstrip, on-screen text, the voice with a mark per word and the music with its beats. **Play with timeline** opens `out/studio.html` in your browser, the render playing over that timeline with a draggable playhead, zoom and frame-by-frame keys.
33
+ - **Plan**, **Music**, **Components**: what the project holds, read from its files.
34
+ - **Browser**: search the shared library; **Use** hands the pick to Claude.
35
+
36
+ The pane only reads the project. Its buttons write a request into the prompt for you to send. The mod lives in `skill/hooks`; check it with `claude plugin validate skill` and `claude plugin test skill`.
37
+
28
38
  ## Use
29
39
 
30
40
  Ask Claude to make a video. The skill takes it from there. To drive it by hand:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reelkit-cli",
3
- "version": "0.10.0",
3
+ "version": "0.10.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",
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "reelkit",
3
+ "version": "0.1.0",
4
+ "description": "Reelkit studio: a pane with the film's timeline, plan, music, components and the library, and a browser player for the render.",
5
+ "author": { "name": "Daniel Livshin" },
6
+ "types": "./types/index.d.ts"
7
+ }
package/skill/SKILL.md CHANGED
@@ -11,7 +11,11 @@ Read the material before choosing a treatment. Own routine editing decisions and
11
11
 
12
12
  For footage, talking heads, screen recordings, mixed media, or an open creative brief, read `reference/studio-editing.md`. It explains how to choose the approach and what this CLI can actually edit. For final verification and platform variants, read `reference/delivery-review.md`.
13
13
 
14
- This skill targets `reelkit-cli` 0.10.0 and its HyperFrames kit. Check the installed version with `reelkit --version`; in a source checkout, invoke its `bin/reelkit.mjs` with Node. Use the matching CLI before authoring with `reelkit/frame`.
14
+ For image or video cutouts, editable Blender product shots, custom 3D materials, or native Higgsfield motion workflows beyond the kit, read `reference/higgsfield-production.md`. It maps the external tools, their connection requirements and the inspected media handoff back to Reelkit.
15
+
16
+ To implement a chosen style, read its row in `reference/style-recipes.md` for action, timing, sound and proof. `reference/style-components.md` maps the shared components and built-in kit to shot roles; inspect the selected source before using its props.
17
+
18
+ This skill targets `reelkit-cli` 0.10.2 and its HyperFrames kit. Check the installed version with `reelkit --version`; in a source checkout, invoke its `bin/reelkit.mjs` with Node. Use the matching CLI before authoring with `reelkit/frame`.
15
19
 
16
20
  Every command takes `--json` for machine-readable output and exits non-zero with a one-line reason when something is wrong. Read the reason and act on it.
17
21
 
@@ -108,6 +112,7 @@ Read `reference/hyperframes-composition.md` for the seekable HyperFrames timing
108
112
  - **Fit the picture to the voice** (narrated films; the whole method is in `reference/voice-sync.md`). The voice is the clock: write the narration first, record it (step 4), and only then place visuals from the real word times with `onWord(s, "word")` (the frame to start an entrance so it lands on its word; `wordFrame` is the word's own frame, `onWordBeat` lets it land on a beat only within 3 frames after the word). Never count words by hand. One visual event per stressed word and at most one every 0.5 s; the thing a word names is on screen within 3 frames of the word starting, never before the previous sentence ends; numbers count up to finish on their word; a list ticks on each item's word; scene changes fall in the gaps between sentences, never inside one; the last word of a sentence is held for the gap, not longer; sound effects for a word's visual sit under the voice at 0.3 or less. `reelkit check` fails on a word the scene never says, and `reelkit preview` renders a frame 4 frames after each word you timed: each of those should already show the thing that word names.
109
113
  - Choose the music before you write the composition, and read `reference/beat-sync.md`: `reelkit assets search "<mood and tempo>" --kind music`, then `reelkit assets pull <id> --music`. Scene changes then land on its beat by themselves; bring each element in on a beat inside a scene as that file shows.
110
114
  - 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.
115
+ - When the brief is open ("make it impressive", a launch film, a showreel) and you want to see how others asked for such a video, search the prompt gallery: `reelkit assets search "<kind of video>" --kind prompt`, then `reelkit assets pull <id>` to read the whole prompt in `prompts/`. These are other people's prompts with their author and source: borrow the structure (what they specified: length, beats, style, constraints), never the wording, and never show one in the film. Prompts appear only with `--kind prompt`.
111
116
  - Read `reference/sound-design.md`, then find the few sounds the video needs: `reelkit assets search "whoosh" --kind sfx`, `reelkit assets pull <id>`. The pull prints how to reference the file.
112
117
  - For a brand reveal, a transformation, or a shot with real depth, read `reference/brand-motion.md` and `reference/three-d.md`. Choose the move by what it explains, preserve the supplied identity, and share its event frames with the sound cues. Keep reading-heavy shots in 2D; a requested brand film or showreel may use several purposeful 3D moments.
113
118
  - For what sits behind the whole film (a video, a picture that changes, or an animated ground), read `reference/backgrounds.md`; pick one ground and keep it.
@@ -128,6 +133,16 @@ Verify the finished export using `reference/delivery-review.md`; previews alone
128
133
 
129
134
  If the render fails, read the error, fix the composition, and run `reelkit check` before rendering again.
130
135
 
136
+ ## The studio pane
137
+
138
+ In Claude Code 2.1.287 or newer this skill also loads a mod (`hooks/register.tsx`, read `reference/studio-pane.md` before changing it). It opens a Reelkit pane beside the conversation whenever a project is in reach, and `/reelkit-studio [project folder]` opens it by hand. The pane reads the project's files and follows your `reelkit` commands and edits, so it is current after each step:
139
+
140
+ - **Timeline**: the film as an editor's timeline (scene clips with a filmstrip from the preview frames, on-screen text, the voice with a mark per word, the music with its beats) and the selected scene's narration. **Play with timeline** writes `out/studio.html` and opens it in the browser: the render playing over the same timeline, with a playhead to drag, zoom, and frame and scene keys. Before a render it steps through the preview frames.
141
+ - **Plan**, **Music**, **Components**: the plan as written, the track, voice and sound effects, and each component in `src/` with its description and source.
142
+ - **Browser**: searches the shared library by kind.
143
+
144
+ The pane never changes the project by itself. Its buttons (Edit this scene, Change the plan, Use, Render) write a request into the prompt for the user to finish or send; treat such a request as any other from the user. After a preview or a render, tell the user in one line that the pane shows it and that **Play with timeline** plays it.
145
+
131
146
  ## Rules
132
147
 
133
148
  - Search before generating. Reuse beats regenerate.
@@ -1,6 +1,6 @@
1
1
  # Third-party sources
2
2
 
3
- Some guidance in these skills, and some starter-kit components, are adapted from three open-source motion-design skills. The first two target a different renderer (HTML pages captured with Playwright), so their engines, scripts and workflows are not used here; only renderer-independent design principles were taken and rewritten for Remotion.
3
+ Some guidance in these skills, and some starter-kit components, are adapted from three open-source motion-design skills. The first two target a different renderer (HTML pages captured with Playwright), so their engines, scripts and workflows are not used here; only renderer-independent design principles were taken and rewritten for Reelkit’s HyperFrames adapter.
4
4
 
5
5
  - **Barty-Bart/motion-graphics** (`skills/motion-broll`), MIT License, Copyright (c) 2026 Bart.
6
6
  https://github.com/Barty-Bart/motion-graphics
@@ -21,7 +21,7 @@ Used in: `motion-design`, `hyperframes-composition`, `scriptwriting`, `scene-tre
21
21
 
22
22
  ## The 3D typeface
23
23
 
24
- - **Inter** (Bold weight), by the Inter Project Authors (https://github.com/rsms/inter), SIL Open Font License 1.1. It is the one typeface bundled for the kit's `Text3D`, converted to three.js's typeface JSON (the Latin letters, digits, punctuation and a few symbols) and kept as data in `src/remotion/kit/inter-bold-typeface.ts`. The three npm package ships no typeface files, so the glyph outlines were taken from the Inter font files in the `@fontsource/inter` package; the outlines are unchanged apart from scaling to 1000 units to the em. The licence text, as shipped with that package:
24
+ - **Inter** (Bold weight), by the Inter Project Authors (https://github.com/rsms/inter), SIL Open Font License 1.1. It is the one typeface bundled for the kit's `Text3D`, converted to three.js's typeface JSON (the Latin letters, digits, punctuation and a few symbols) and kept as data in `src/hyperframes/kit/inter-bold-typeface.ts`. The three npm package ships no typeface files, so the glyph outlines were taken from the Inter font files in the `@fontsource/inter` package; the outlines are unchanged apart from scaling to 1000 units to the em. The licence text, as shipped with that package:
25
25
 
26
26
  ```
27
27
  Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter)
@@ -120,3 +120,35 @@ OTHER DEALINGS IN THE FONT SOFTWARE.
120
120
  ```
121
121
 
122
122
  The 3D layer depends on `three` (MIT) and `@react-three/fiber` (MIT). Rendering uses `@hyperframes/engine` (Apache-2.0). Fonts are bundled locally from Fontsource; each family retains its original font licence in its installed package.
123
+
124
+ ## Additional production references
125
+
126
+ The style recipes and external-production guide summarize the project owner’s supplied CapCut studio-editing reference and reviewed official Higgsfield and HyperFrames documentation. They adapt relevant craft and identify runtime boundaries; external editor code and services are not bundled. Sources: https://higgsfield.ai/skills, https://higgsfield.ai/plugins/blender, https://github.com/heygen-com/hyperframes/tree/main/skills.
127
+
128
+ ## Art-style catalog
129
+
130
+ `reference/art-styles.md` adapts the fifteen style films and prompts in **Vincentwei1021/mg-styles-15**, https://github.com/VincentWei1021/mg-styles-15. The motion recipes are translated for Reelkit rather than bundling the original renderer. Its MIT notice follows:
131
+
132
+ ```
133
+ MIT License
134
+
135
+ Copyright (c) 2026 Vincentwei1021
136
+
137
+ Permission is hereby granted, free of charge, to any person obtaining a copy
138
+ of this software and associated documentation files (the "Software"), to deal
139
+ in the Software without restriction, including without limitation the rights
140
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
141
+ copies of the Software, and to permit persons to whom the Software is
142
+ furnished to do so, subject to the following conditions:
143
+
144
+ The above copyright notice and this permission notice shall be included in all
145
+ copies or substantial portions of the Software.
146
+
147
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
148
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
149
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
150
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
151
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
152
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
153
+ SOFTWARE.
154
+ ```
@@ -0,0 +1 @@
1
+ { "modules": ["./register.tsx"] }
@@ -0,0 +1,278 @@
1
+ import type { Film } from '../types'
2
+
3
+ /**
4
+ * The page the pane opens in a browser, written to out/studio.html beside the render: the film playing over an editor's timeline with a
5
+ * ruler, clips, text, voice and music lanes, a playhead to drag, zoom, and keys. Before a render exists it steps through the preview frames.
6
+ */
7
+ export function playerHtml(film: Film): string {
8
+ const data = {
9
+ title: film.title,
10
+ aspect: film.aspect,
11
+ fps: film.fps,
12
+ seconds: film.totalFrames / film.fps,
13
+ isTimed: film.isTimed,
14
+ hasVideo: film.render !== null,
15
+ videoStamp: film.render?.at ?? 0,
16
+ music: film.music,
17
+ scenes: film.scenes.map((scene, index) => ({
18
+ id: scene.id,
19
+ start: scene.start / film.fps,
20
+ length: scene.frames / film.fps,
21
+ narration: scene.narration,
22
+ text: scene.text,
23
+ notes: scene.notes,
24
+ treatment: scene.treatment,
25
+ words: scene.words,
26
+ stem: `preview/${String(index + 1).padStart(2, '0')}-${scene.id}-`,
27
+ })),
28
+ }
29
+ const json = JSON.stringify(data).replace(/</g, '\\u003c')
30
+ return `<!doctype html>
31
+ <html lang="en">
32
+ <head>
33
+ <meta charset="utf-8">
34
+ <meta name="viewport" content="width=device-width,initial-scale=1">
35
+ <title>Reelkit studio</title>
36
+ <style>
37
+ :root { --bg:#0f1013; --panel:#17181d; --lane:#1e2026; --line:#2a2c35; --ink:#eceef4; --dim:#8b8d98; --accent:#c8f55a; }
38
+ * { box-sizing:border-box; }
39
+ html,body { height:100%; margin:0; background:var(--bg); color:var(--ink); font:13px/1.45 -apple-system,"Segoe UI",Helvetica,Arial,sans-serif; }
40
+ body { display:grid; grid-template-rows:minmax(0,1fr) auto auto; }
41
+ .top { display:grid; grid-template-columns:minmax(0,1fr) 320px; min-height:0; }
42
+ .stage { display:flex; align-items:center; justify-content:center; padding:16px; min-height:0; }
43
+ .stage video, .stage img { max-width:100%; max-height:100%; border-radius:10px; background:#000; box-shadow:0 10px 40px rgba(0,0,0,.5); }
44
+ .stage .none { color:var(--dim); text-align:center; max-width:340px; }
45
+ .side { background:var(--panel); border-left:1px solid var(--line); padding:16px; overflow:auto; }
46
+ .side h1 { font-size:15px; margin:0 0 2px; }
47
+ .side .meta { color:var(--dim); margin-bottom:14px; }
48
+ .side h2 { font-size:11px; letter-spacing:.08em; text-transform:uppercase; color:var(--dim); margin:16px 0 4px; }
49
+ .side p { margin:0; white-space:pre-wrap; unicode-bidi:plaintext; }
50
+ .chips span { display:inline-block; background:#3b2a66; color:#d6c9ff; border-radius:5px; padding:1px 7px; margin:0 4px 4px 0; unicode-bidi:plaintext; }
51
+ .bar { display:flex; align-items:center; gap:12px; padding:8px 14px; background:var(--panel); border-top:1px solid var(--line); }
52
+ .bar button { background:#262830; color:var(--ink); border:1px solid var(--line); border-radius:7px; padding:5px 11px; font:inherit; cursor:pointer; }
53
+ .bar button:hover { background:#30333d; }
54
+ .bar button.play { background:var(--accent); color:#111; border-color:transparent; font-weight:600; min-width:64px; }
55
+ .bar .time { font-variant-numeric:tabular-nums; min-width:120px; }
56
+ .bar .grow { flex:1; }
57
+ .bar label { color:var(--dim); display:flex; align-items:center; gap:6px; }
58
+ .bar .keys { color:var(--dim); font-size:12px; }
59
+ .tl { background:var(--panel); border-top:1px solid var(--line); display:grid; grid-template-columns:64px minmax(0,1fr); height:228px; }
60
+ .names { padding-top:26px; color:var(--dim); font-size:12px; }
61
+ .names div { padding-left:12px; display:flex; align-items:center; }
62
+ .scroll { overflow-x:auto; overflow-y:hidden; position:relative; }
63
+ .inner { position:relative; height:100%; min-width:100%; }
64
+ .ruler { height:26px; position:relative; border-bottom:1px solid var(--line); cursor:pointer; }
65
+ .ruler i { position:absolute; top:0; bottom:0; border-left:1px solid var(--line); padding:5px 0 0 4px; color:var(--dim); font-style:normal; font-size:11px; font-variant-numeric:tabular-nums; }
66
+ .lane { position:relative; margin-top:6px; background:var(--lane); border-radius:5px; }
67
+ .h-video { height:70px; } .h-text { height:24px; } .h-voice { height:30px; } .h-music { height:24px; }
68
+ .clip { position:absolute; top:0; bottom:0; border-radius:6px; overflow:hidden; cursor:pointer; background-size:auto 100%; background-repeat:repeat-x; outline:1px solid rgba(0,0,0,.4); }
69
+ .clip b { position:absolute; left:0; right:0; bottom:0; padding:1px 6px; background:rgba(0,0,0,.55); font-weight:600; font-size:11px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
70
+ .clip.on { outline:2px solid #fff; z-index:1; }
71
+ .chip { position:absolute; top:0; bottom:0; border-radius:5px; padding:3px 6px; font-size:11px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; background:#3b2a66; color:#d6c9ff; unicode-bidi:plaintext; }
72
+ .vo { position:absolute; top:0; bottom:0; border-radius:5px; background:#0f3d3a; }
73
+ .vo u { position:absolute; width:2px; border-radius:1px; background:#2dd4bf; top:50%; transform:translateY(-50%); }
74
+ .mu { position:absolute; top:0; bottom:0; left:0; border-radius:5px; background:#1e2a5a; color:#aeb8ff; font-size:11px; padding:4px 7px; white-space:nowrap; overflow:hidden; }
75
+ .mu u { position:absolute; bottom:0; width:1px; height:6px; background:#7c8cff; }
76
+ .head { position:absolute; top:0; bottom:0; width:0; border-left:2px solid #fff; pointer-events:none; z-index:3; }
77
+ .head:before { content:""; position:absolute; top:14px; left:-7px; border:6px solid transparent; border-top:9px solid #fff; }
78
+ @media (max-width:760px) { .top { grid-template-columns:1fr; } .side { display:none; } .bar .keys { display:none; } }
79
+ </style>
80
+ </head>
81
+ <body>
82
+ <div class="top">
83
+ <div class="stage" id="stage"></div>
84
+ <aside class="side">
85
+ <h1 id="title"></h1>
86
+ <div class="meta" id="meta"></div>
87
+ <h2>Scene</h2><p id="scene"></p>
88
+ <h2>Narration</h2><p id="narration"></p>
89
+ <h2>On screen</h2><div class="chips" id="chips"></div>
90
+ <h2>Direction</h2><p id="notes"></p>
91
+ </aside>
92
+ </div>
93
+ <div class="bar">
94
+ <button class="play" id="play">Play</button>
95
+ <button id="prev" title="Previous scene">|◀</button>
96
+ <button id="next" title="Next scene">▶|</button>
97
+ <span class="time" id="time"></span>
98
+ <span class="grow"></span>
99
+ <span class="keys">Space play · ←/→ one frame · Shift+←/→ one second · J/K scene</span>
100
+ <label>Zoom <input type="range" id="zoom" min="1" max="12" step="0.25" value="1"></label>
101
+ </div>
102
+ <div class="tl">
103
+ <div class="names">
104
+ <div style="height:76px">Video</div><div style="height:30px">Text</div><div style="height:36px">Voice</div><div style="height:30px">Music</div>
105
+ </div>
106
+ <div class="scroll" id="scroll">
107
+ <div class="inner" id="inner">
108
+ <div class="ruler" id="ruler"></div>
109
+ <div class="lane h-video" id="lv"></div>
110
+ <div class="lane h-text" id="lt"></div>
111
+ <div class="lane h-voice" id="lo"></div>
112
+ <div class="lane h-music" id="lm"></div>
113
+ <div class="head" id="head"></div>
114
+ </div>
115
+ </div>
116
+ </div>
117
+ <script id="film" type="application/json">${json}</script>
118
+ <script>
119
+ (function () {
120
+ var F = JSON.parse(document.getElementById('film').textContent);
121
+ var $ = function (id) { return document.getElementById(id); };
122
+ var COLORS = ['#2563eb', '#7c3aed', '#db2777', '#ea580c', '#059669', '#0891b2'];
123
+ var total = Math.max(0.1, F.seconds);
124
+ var zoom = 1, now = 0, playing = false, last = 0, picked = -1;
125
+ var video = null, still = null;
126
+
127
+ var fmt = function (s) { s = Math.max(0, s); return Math.floor(s / 60) + ':' + (s % 60).toFixed(2).padStart(5, '0'); };
128
+ var el = function (tag, cls, text) { var n = document.createElement(tag); if (cls) n.className = cls; if (text !== undefined) n.textContent = text; return n; };
129
+ var sceneAt = function (t) {
130
+ for (var i = F.scenes.length - 1; i >= 0; i--) if (t >= F.scenes[i].start - 0.0005) return i;
131
+ return 0;
132
+ };
133
+
134
+ $('title').textContent = F.title;
135
+ $('meta').textContent = F.aspect + ' · ' + fmt(total) + ' · ' + F.scenes.length + ' scenes' + (F.isTimed ? '' : ' · estimated timings');
136
+
137
+ if (F.hasVideo) {
138
+ video = el('video');
139
+ video.src = 'video.mp4?v=' + F.videoStamp;
140
+ video.playsInline = true;
141
+ video.addEventListener('play', function () { playing = true; paint(); });
142
+ video.addEventListener('pause', function () { playing = false; paint(); });
143
+ video.addEventListener('click', toggle);
144
+ video.addEventListener('timeupdate', function () { now = video.currentTime; paint(); });
145
+ $('stage').appendChild(video);
146
+ } else {
147
+ still = el('img');
148
+ still.alt = 'Preview frame';
149
+ still.addEventListener('error', function () {
150
+ if (still.dataset.tried !== '1') { still.dataset.tried = '1'; still.src = still.src.replace('-late.jpg', '-early.jpg'); return; }
151
+ still.replaceWith(el('div', 'none', 'Nothing is rendered yet. Run reelkit preview for frames or reelkit render for the film, then open this page again.'));
152
+ });
153
+ $('stage').appendChild(still);
154
+ }
155
+
156
+ function width() { return Math.max(1, $('scroll').clientWidth * zoom); }
157
+ function px(t) { return (t / total) * width(); }
158
+
159
+ function build() {
160
+ var w = width();
161
+ $('inner').style.width = w + 'px';
162
+ var ruler = $('ruler'); ruler.textContent = '';
163
+ var steps = [0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60];
164
+ var step = 120;
165
+ for (var k = 0; k < steps.length; k++) if (px(steps[k]) >= 64) { step = steps[k]; break; }
166
+ for (var t = 0; t <= total + 0.0001; t += step) {
167
+ var mark = el('i', '', px(t) > w - 44 ? '' : fmt(t)); mark.style.left = px(t) + 'px'; ruler.appendChild(mark);
168
+ }
169
+ var lv = $('lv'), lt = $('lt'), lo = $('lo'), lm = $('lm');
170
+ lv.textContent = lt.textContent = lo.textContent = lm.textContent = '';
171
+ F.scenes.forEach(function (s, i) {
172
+ var left = px(s.start) + 1, wide = Math.max(3, px(s.length) - 2);
173
+ var clip = el('div', 'clip' + (i === picked ? ' on' : ''));
174
+ clip.style.left = left + 'px'; clip.style.width = wide + 'px';
175
+ clip.style.backgroundColor = COLORS[i % COLORS.length];
176
+ clip.style.backgroundImage = 'url("' + s.stem + 'late.jpg"), url("' + s.stem + 'early.jpg")';
177
+ clip.appendChild(el('b', '', (i + 1) + ' ' + s.id));
178
+ clip.addEventListener('click', function (e) { e.stopPropagation(); seek(s.start + 0.001); });
179
+ lv.appendChild(clip);
180
+ if (s.text.length) {
181
+ var chip = el('div', 'chip', s.text.join(' · ')); chip.style.left = left + 'px'; chip.style.width = wide + 'px'; lt.appendChild(chip);
182
+ }
183
+ if (s.narration) {
184
+ var vo = el('div', 'vo'); vo.style.left = left + 'px'; vo.style.width = wide + 'px'; vo.title = s.narration;
185
+ s.words.forEach(function (sec, n) {
186
+ var bar = el('u'); bar.style.left = (px(sec) - 1) + 'px'; bar.style.height = (8 + ((n * 7 + i * 3) % 5) * 4) + 'px'; vo.appendChild(bar);
187
+ });
188
+ lo.appendChild(vo);
189
+ }
190
+ });
191
+ if (F.music) {
192
+ var length = F.music.seconds > 0 ? Math.min(F.music.seconds, total) : total;
193
+ var mu = el('div', 'mu', '♪ ' + F.music.title + (F.music.bpm ? ' · ' + Math.round(F.music.bpm) + ' BPM' : ''));
194
+ mu.style.width = px(length) + 'px';
195
+ F.music.beats.forEach(function (sec) { if (sec > length) return; var tick = el('u'); tick.style.left = px(sec) + 'px'; mu.appendChild(tick); });
196
+ lm.appendChild(mu);
197
+ }
198
+ paint();
199
+ }
200
+
201
+ function paint() {
202
+ $('head').style.left = px(now) + 'px';
203
+ $('time').textContent = fmt(now) + ' / ' + fmt(total);
204
+ $('play').textContent = playing ? 'Pause' : 'Play';
205
+ var i = sceneAt(now);
206
+ if (i === picked) return;
207
+ picked = i;
208
+ var s = F.scenes[i];
209
+ var clips = $('lv').children;
210
+ for (var k = 0; k < clips.length; k++) clips[k].classList.toggle('on', k === i);
211
+ if (!s) return;
212
+ $('scene').textContent = (i + 1) + '. ' + s.id + ' · ' + fmt(s.start) + ' to ' + fmt(s.start + s.length) + (s.treatment ? ' · ' + s.treatment : '');
213
+ $('narration').textContent = s.narration || 'No narration.';
214
+ var chips = $('chips'); chips.textContent = '';
215
+ s.text.forEach(function (line) { chips.appendChild(el('span', '', line)); });
216
+ if (!s.text.length) chips.textContent = 'None.';
217
+ $('notes').textContent = s.notes || 'None.';
218
+ if (still) { still.dataset.tried = ''; still.src = s.stem + 'late.jpg'; }
219
+ }
220
+
221
+ function follow() {
222
+ var box = $('scroll'), at = px(now);
223
+ if (at < box.scrollLeft + 40 || at > box.scrollLeft + box.clientWidth - 40) box.scrollLeft = Math.max(0, at - box.clientWidth / 3);
224
+ }
225
+
226
+ function seek(t) {
227
+ now = Math.min(total, Math.max(0, t));
228
+ if (video) video.currentTime = now;
229
+ paint(); follow();
230
+ }
231
+
232
+ function toggle() {
233
+ if (video) { if (video.paused) video.play(); else video.pause(); return; }
234
+ playing = !playing; last = performance.now();
235
+ if (playing && now >= total - 0.01) now = 0;
236
+ paint();
237
+ }
238
+
239
+ function frame(stamp) {
240
+ if (video) { if (!video.paused) { now = video.currentTime; paint(); follow(); } }
241
+ else if (playing) {
242
+ now += (stamp - last) / 1000; last = stamp;
243
+ if (now >= total) { now = total; playing = false; }
244
+ paint(); follow();
245
+ }
246
+ requestAnimationFrame(frame);
247
+ }
248
+
249
+ function pointerTime(e) {
250
+ var box = $('inner').getBoundingClientRect();
251
+ return ((e.clientX - box.left) / box.width) * total;
252
+ }
253
+ var dragging = false;
254
+ $('inner').addEventListener('pointerdown', function (e) { dragging = true; $('inner').setPointerCapture(e.pointerId); seek(pointerTime(e)); });
255
+ $('inner').addEventListener('pointermove', function (e) { if (dragging) seek(pointerTime(e)); });
256
+ $('inner').addEventListener('pointerup', function () { dragging = false; });
257
+ $('play').addEventListener('click', toggle);
258
+ $('prev').addEventListener('click', function () { var i = sceneAt(now); var s = F.scenes[now - F.scenes[i].start > 0.3 ? i : Math.max(0, i - 1)]; seek(s.start + 0.001); });
259
+ $('next').addEventListener('click', function () { var s = F.scenes[Math.min(F.scenes.length - 1, sceneAt(now) + 1)]; seek(s.start + 0.001); });
260
+ $('zoom').addEventListener('input', function (e) { zoom = Number(e.target.value); build(); follow(); });
261
+ window.addEventListener('resize', build);
262
+ window.addEventListener('keydown', function (e) {
263
+ if (e.target && e.target.tagName === 'INPUT') return;
264
+ if (e.code === 'Space') { e.preventDefault(); toggle(); }
265
+ else if (e.key === 'ArrowLeft') seek(now - (e.shiftKey ? 1 : 1 / F.fps));
266
+ else if (e.key === 'ArrowRight') seek(now + (e.shiftKey ? 1 : 1 / F.fps));
267
+ else if (e.key === 'j' || e.key === 'J') $('prev').click();
268
+ else if (e.key === 'k' || e.key === 'K') $('next').click();
269
+ });
270
+
271
+ build();
272
+ requestAnimationFrame(frame);
273
+ })();
274
+ </script>
275
+ </body>
276
+ </html>
277
+ `
278
+ }
@@ -0,0 +1,227 @@
1
+
2
+ import type { Film, FilmComponent, FilmScene, FilmSound } from '../types'
3
+
4
+ // What the loader needs of the engine, handed over as plain functions: a hooks module keeps `$` itself in its own file.
5
+ type Entry = { name: string; kind: 'file' | 'dir' | 'other'; size: number; mtimeMs: number }
6
+
7
+ export type Host = {
8
+ read: (path: string) => Promise<string>
9
+ list: (path: string) => Promise<Entry[]>
10
+ exists: (path: string) => Promise<boolean>
11
+ stat: (path: string) => Promise<Omit<Entry, 'name'>>
12
+ run: (argv: string[], timeoutMs?: number) => Promise<{ exitCode: number }>
13
+ cwd: () => Promise<string>
14
+ now: () => Promise<number>
15
+ }
16
+
17
+ type Loose = Record<string, any>
18
+
19
+ const MARK = 'reelkit.json'
20
+ const THUMBS = 'out/.studio'
21
+
22
+ export const join = (...parts: string[]): string => parts.join('/').replace(/\/{2,}/g, '/')
23
+ export const baseName = (path: string): string => path.replace(/\/+$/, '').split('/').pop() ?? path
24
+ const parent = (path: string): string => path.replace(/\/+$/, '').split('/').slice(0, -1).join('/') || '/'
25
+ const round = (n: number, digits = 2): number => Number(n.toFixed(digits))
26
+
27
+ async function readJson(host: Host, path: string): Promise<Loose | undefined> {
28
+ try {
29
+ return JSON.parse(await host.read(path)) as Loose
30
+ } catch {
31
+ return undefined
32
+ }
33
+ }
34
+
35
+ const isProject = (host: Host, dir: string): Promise<boolean> => host.exists(join(dir, MARK)).catch(() => false)
36
+
37
+ // The project a path belongs to: the nearest folder at or above it that holds reelkit.json.
38
+ export async function projectOf(host: Host, path: string): Promise<string | undefined> {
39
+ let dir = path
40
+ for (let depth = 0; depth < 6 && dir !== '/' && dir !== ''; depth += 1) {
41
+ if (await isProject(host, dir)) return dir
42
+ dir = parent(dir)
43
+ }
44
+ return undefined
45
+ }
46
+
47
+ // The projects in reach of the session: the one the working directory is in, then the ones directly under it, newest first.
48
+ export async function findProjects(host: Host): Promise<string[]> {
49
+ const cwd = await host.cwd()
50
+ const own = await projectOf(host, cwd)
51
+ const entries = await host.list(cwd).catch(() => [])
52
+ const dirs = entries.filter(one => one.kind === 'dir' && !one.name.startsWith('.') && one.name !== 'node_modules').slice(0, 80)
53
+ const found = await Promise.all(
54
+ dirs.map(async one => {
55
+ const dir = join(cwd, one.name)
56
+ if (!(await isProject(host, dir))) return undefined
57
+ const stat = await host.stat(join(dir, 'plan.json')).catch(() => undefined)
58
+ return { dir, at: stat?.mtimeMs ?? 0 }
59
+ }),
60
+ )
61
+ const below = found
62
+ .filter((one): one is { dir: string; at: number } => one !== undefined)
63
+ .sort((a, b) => b.at - a.at)
64
+ .map(one => one.dir)
65
+ return [...new Set([...(own ? [own] : []), ...below])].slice(0, 12)
66
+ }
67
+
68
+ // The length a scene will have before the voice is recorded: its `seconds`, or its narration read at a normal pace.
69
+ const estimateSeconds = (scene: Loose): number => {
70
+ if (typeof scene.seconds === 'number') return scene.seconds
71
+ const words = String(scene.narration ?? '').split(/\s+/).filter(Boolean).length
72
+ return Math.max(1.5, words / 2.6 + 0.3)
73
+ }
74
+
75
+ // What a component shows: the comment written just above its export, else the nearest comment above the first export, else the file's first.
76
+ const aboutOf = (source: string, name: string): string => {
77
+ const clean = (lines: string): string => lines.replace(/^\s*(\/\/|\/\*+|\*\/|\*)\s?/gm, '').replace(/\s+/g, ' ').trim().slice(0, 240)
78
+ const above = (anchor: RegExp): string => {
79
+ const at = source.search(anchor)
80
+ if (at < 0) return ''
81
+ const before = source.slice(0, at).split('\n').slice(0, -1)
82
+ const block: string[] = []
83
+ while (before.length && /^\s*(\/\/|\/\*|\*)/.test(before[before.length - 1] ?? '')) block.unshift(before.pop() ?? '')
84
+ return clean(block.join('\n'))
85
+ }
86
+ const own = above(new RegExp(`^export (?:default )?(?:const|function) ${name}\\b`, 'm'))
87
+ if (own) return own
88
+ const first = above(/^export (?:default )?(?:const|function) /m)
89
+ if (first) return first
90
+ return clean(/^(?:\/\/[^\n]*\n)+/m.exec(source)?.[0] ?? '')
91
+ }
92
+
93
+ async function loadComponents(host: Host, dir: string): Promise<FilmComponent[]> {
94
+ const entries = await host.list(join(dir, 'src')).catch(() => [])
95
+ const files = entries.filter(one => one.kind === 'file' && /\.tsx$/.test(one.name) && one.name !== 'Video.tsx').slice(0, 24)
96
+ return Promise.all(
97
+ files.map(async one => {
98
+ const path = join(dir, 'src', one.name)
99
+ const source = await host.read(path).catch(() => '')
100
+ const name = one.name.replace(/\.tsx$/, '')
101
+ return { name, path, about: aboutOf(source, name) }
102
+ }),
103
+ )
104
+ }
105
+
106
+ // One small picture per scene, cut from the preview frames, so the timeline can show a filmstrip. Skipped where no tool can scale a picture.
107
+ async function makeThumbs(host: Host, dir: string, ids: string[]): Promise<Record<string, string>> {
108
+ const frames = await host.list(join(dir, 'out/preview')).catch(() => [])
109
+ if (frames.length === 0) return {}
110
+ await host.run(['mkdir', '-p', join(dir, THUMBS)]).catch(() => undefined)
111
+ const made = await host.list(join(dir, THUMBS)).catch(() => [])
112
+ const pairs = await Promise.all(
113
+ ids.map(async (id, index) => {
114
+ const stem = `${String(index + 1).padStart(2, '0')}-${id}-`
115
+ const frame = frames.find(one => one.name === `${stem}late.jpg`) ?? frames.find(one => one.name === `${stem}early.jpg`)
116
+ if (!frame) return undefined
117
+ const target = join(dir, THUMBS, `${id}.jpg`)
118
+ const old = made.find(one => one.name === `${id}.jpg`)
119
+ if (old && old.mtimeMs >= frame.mtimeMs) return [id, target] as const
120
+ const source = join(dir, 'out/preview', frame.name)
121
+ const scaled = await host.run(['ffmpeg', '-y', '-loglevel', 'error', '-i', source, '-vf', 'scale=-2:120', '-q:v', '8', target], 20000)
122
+ .catch(() => undefined)
123
+ if (scaled?.exitCode === 0) return [id, target] as const
124
+ const sipped = await host.run(['sips', '-Z', '120', source, '--out', target], 20000).catch(() => undefined)
125
+ return sipped?.exitCode === 0 ? ([id, target] as const) : undefined
126
+ }),
127
+ )
128
+ return Object.fromEntries(pairs.filter((one): one is readonly [string, string] => one !== undefined))
129
+ }
130
+
131
+ // Everything the pane shows about one project, read from its files: the plan, the measured timings, the sound, the components, the render.
132
+ export async function loadFilm(host: Host, dir: string): Promise<Film | null> {
133
+ const [mark, plan, manifest, music, library, index] = await Promise.all([
134
+ readJson(host, join(dir, MARK)),
135
+ readJson(host, join(dir, 'plan.json')),
136
+ readJson(host, join(dir, 'manifest.json')),
137
+ readJson(host, join(dir, 'assets/music.json')),
138
+ readJson(host, join(dir, 'assets/library.json')),
139
+ readJson(host, join(dir, 'assets/index.json')),
140
+ ])
141
+ if (!mark) return null
142
+ const fps = typeof manifest?.fps === 'number' ? manifest.fps : 30
143
+ const planned: Loose[] = Array.isArray(plan?.scenes) ? plan.scenes : []
144
+ const timed: Loose[] = Array.isArray(manifest?.scenes) ? manifest.scenes : []
145
+ const ids: string[] = (planned.length ? planned : timed).map(one => String(one.id))
146
+ const thumbs = await makeThumbs(host, dir, ids).catch(() => ({}) as Record<string, string>)
147
+
148
+ let cursor = 0
149
+ const scenes: FilmScene[] = ids.slice(0, 24).map(id => {
150
+ const p = planned.find(one => one.id === id) ?? {}
151
+ const m = timed.find(one => one.id === id)
152
+ const start = typeof m?.startFrame === 'number' ? m.startFrame : cursor
153
+ const frames = typeof m?.durationFrames === 'number' ? m.durationFrames : Math.round(estimateSeconds(p) * fps)
154
+ cursor = start + frames
155
+ const words: Loose[] = Array.isArray(m?.words) ? m.words : []
156
+ return {
157
+ id,
158
+ start,
159
+ frames,
160
+ narration: String(p.narration ?? '').slice(0, 600),
161
+ text: (Array.isArray(p.onScreenText) ? p.onScreenText : []).map(String).slice(0, 4),
162
+ treatment: String(p.treatment ?? ''),
163
+ notes: String(p.notes ?? '').slice(0, 400),
164
+ words: words.slice(0, 60).map(one => round(Number(one.startSec) || 0)),
165
+ thumb: thumbs[id] ?? '',
166
+ }
167
+ })
168
+ const totalFrames = typeof manifest?.totalFrames === 'number' ? manifest.totalFrames : cursor
169
+
170
+ const sounds: FilmSound[] = Object.entries((library ?? {}) as Record<string, Loose>)
171
+ .filter(([, one]) => one && typeof one === 'object' && typeof one.path === 'string')
172
+ .map(([id, one]) => ({
173
+ id,
174
+ kind: String(one.kind ?? ''),
175
+ title: String(one.title ?? id),
176
+ path: String(one.path),
177
+ seconds: round(Number(one.meta?.durationSec) || 0),
178
+ }))
179
+ .slice(0, 60)
180
+
181
+ const musicKey = String(music?.key ?? manifest?.music?.key ?? '')
182
+ const beatFrames: number[] = Array.isArray(manifest?.music?.beatFrames) ? manifest.music.beatFrames : []
183
+ const beats = beatFrames.length
184
+ ? beatFrames.map(frame => round(frame / fps))
185
+ : (Array.isArray(music?.beats) ? (music.beats as number[]) : []).map(one => round(one))
186
+
187
+ const [components, render, previews] = await Promise.all([
188
+ loadComponents(host, dir),
189
+ host.stat(join(dir, 'out/video.mp4')).catch(() => undefined),
190
+ host.list(join(dir, 'out/preview')).catch(() => []),
191
+ ])
192
+ const users: Loose[] = Array.isArray(index) ? index : []
193
+
194
+ return {
195
+ dir,
196
+ name: String(mark.name ?? baseName(dir)),
197
+ title: String(plan?.title ?? mark.name ?? baseName(dir)),
198
+ aspect: String(plan?.aspect ?? mark.aspect ?? '9:16'),
199
+ fps,
200
+ totalFrames,
201
+ isTimed: timed.length > 0,
202
+ voice: String(plan?.voice ?? plan?.voiceId ?? ''),
203
+ pace: String(plan?.pace ?? ''),
204
+ captions: String(plan?.captions ?? manifest?.captions ?? ''),
205
+ scenes,
206
+ music: musicKey
207
+ ? {
208
+ title: String(music?.title ?? sounds.find(one => one.path === musicKey)?.title ?? baseName(parent(musicKey))),
209
+ path: musicKey,
210
+ bpm: round(Number(manifest?.music?.bpm ?? music?.bpm) || 0, 1),
211
+ seconds: round(Number(music?.durationSec) || 0),
212
+ beats: beats.slice(0, 400),
213
+ }
214
+ : null,
215
+ sounds,
216
+ components,
217
+ userAssets: users.slice(0, 30).map(one => ({
218
+ id: String(one.id ?? ''),
219
+ kind: String(one.kind ?? ''),
220
+ name: String(one.filename ?? ''),
221
+ about: String(one.description ?? '').slice(0, 200),
222
+ })),
223
+ render: render?.kind === 'file' ? { path: join(dir, 'out/video.mp4'), bytes: render.size, at: render.mtimeMs } : null,
224
+ previews: previews.filter(one => /\.jpg$/.test(one.name)).length,
225
+ loadedAt: await host.now(),
226
+ }
227
+ }