reelkit-cli 0.8.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.
Files changed (122) hide show
  1. package/README.md +13 -1
  2. package/package.json +49 -10
  3. package/skill/.claude-plugin/plugin.json +7 -0
  4. package/skill/SKILL.md +44 -19
  5. package/skill/THIRD_PARTY.md +37 -5
  6. package/skill/commands/launch-film.md +1 -1
  7. package/skill/hooks/hooks.json +1 -0
  8. package/skill/hooks/player.ts +278 -0
  9. package/skill/hooks/project.ts +227 -0
  10. package/skill/hooks/register.tsx +533 -0
  11. package/skill/hooks/timeline.ts +218 -0
  12. package/skill/reference/art-styles.md +70 -0
  13. package/skill/reference/backgrounds.md +1 -1
  14. package/skill/reference/brand-motion.md +62 -0
  15. package/skill/reference/clips.md +3 -3
  16. package/skill/reference/component-authoring.md +3 -1
  17. package/skill/reference/continuity.md +5 -5
  18. package/skill/reference/delivery-review.md +40 -0
  19. package/skill/reference/hebrew-rtl.md +3 -4
  20. package/skill/reference/higgsfield-production.md +70 -0
  21. package/skill/reference/{remotion-composition.md → hyperframes-composition.md} +14 -8
  22. package/skill/reference/kit.md +7 -3
  23. package/skill/reference/launch-film.md +13 -9
  24. package/skill/reference/motion-design.md +26 -22
  25. package/skill/reference/sound-design.md +34 -12
  26. package/skill/reference/studio-editing.md +55 -0
  27. package/skill/reference/studio-pane.md +31 -0
  28. package/skill/reference/style-components.md +40 -0
  29. package/skill/reference/style-recipes.md +86 -0
  30. package/skill/reference/styles.md +17 -10
  31. package/skill/reference/three-d.md +8 -7
  32. package/skill/reference/voice-sync.md +1 -1
  33. package/skill/tests/studio.test.ts +111 -0
  34. package/skill/types/index.d.ts +65 -0
  35. package/src/commands/assets.ts +5 -1
  36. package/src/contract/index.ts +1 -1
  37. package/src/hyperframes/Root.tsx +1 -0
  38. package/src/hyperframes/fonts.ts +54 -0
  39. package/src/hyperframes/frame.tsx +46 -0
  40. package/src/hyperframes/host.tsx +38 -0
  41. package/src/{remotion → hyperframes}/kit/Assemble3D.tsx +1 -1
  42. package/src/hyperframes/kit/BrandTransform3D.tsx +12 -0
  43. package/src/{remotion → hyperframes}/kit/BrowserFrame.tsx +1 -1
  44. package/src/{remotion → hyperframes}/kit/Camera.tsx +1 -1
  45. package/src/{remotion → hyperframes}/kit/Captions.tsx +1 -1
  46. package/src/{remotion → hyperframes}/kit/Card3D.tsx +2 -2
  47. package/src/{remotion → hyperframes}/kit/Carry.tsx +1 -1
  48. package/src/{remotion → hyperframes}/kit/ChapterFrame.tsx +1 -1
  49. package/src/{remotion → hyperframes}/kit/ClipLayer.tsx +1 -1
  50. package/src/{remotion → hyperframes}/kit/Counter.tsx +1 -1
  51. package/src/{remotion → hyperframes}/kit/CounterRoll.tsx +1 -1
  52. package/src/{remotion → hyperframes}/kit/Entrance.tsx +1 -1
  53. package/src/{remotion → hyperframes}/kit/FootageLayer.tsx +1 -1
  54. package/src/{remotion → hyperframes}/kit/GlassPanel.tsx +1 -1
  55. package/src/{remotion → hyperframes}/kit/Grounds.tsx +1 -1
  56. package/src/{remotion → hyperframes}/kit/Headline.tsx +1 -1
  57. package/src/{remotion → hyperframes}/kit/Hero3D.tsx +1 -1
  58. package/src/{remotion → hyperframes}/kit/HudOverlay.tsx +1 -1
  59. package/src/{remotion → hyperframes}/kit/ImageLayers.tsx +1 -1
  60. package/src/{remotion → hyperframes}/kit/KenBurnsImage.tsx +1 -1
  61. package/src/{remotion → hyperframes}/kit/KeyedClip.tsx +1 -1
  62. package/src/{remotion → hyperframes}/kit/Layers.tsx +1 -1
  63. package/src/{remotion → hyperframes}/kit/LowerThird.tsx +1 -1
  64. package/src/{remotion → hyperframes}/kit/Music.tsx +1 -1
  65. package/src/{remotion → hyperframes}/kit/NamedCursor.tsx +1 -1
  66. package/src/{remotion → hyperframes}/kit/Orbit3D.tsx +1 -1
  67. package/src/{remotion → hyperframes}/kit/Particles3D.tsx +1 -1
  68. package/src/{remotion → hyperframes}/kit/Place.tsx +1 -1
  69. package/src/{remotion → hyperframes}/kit/PromptBox.tsx +1 -1
  70. package/src/{remotion → hyperframes}/kit/Scene3D.tsx +2 -2
  71. package/src/{remotion → hyperframes}/kit/SceneFrame.tsx +1 -1
  72. package/src/{remotion → hyperframes}/kit/ScreenOverlay.tsx +1 -1
  73. package/src/{remotion → hyperframes}/kit/Sfx.tsx +1 -1
  74. package/src/{remotion → hyperframes}/kit/SoundCues.tsx +1 -1
  75. package/src/{remotion → hyperframes}/kit/TerminalLog.tsx +1 -1
  76. package/src/{remotion → hyperframes}/kit/Text3D.tsx +1 -1
  77. package/src/{remotion → hyperframes}/kit/TextOnImage.tsx +1 -1
  78. package/src/{remotion → hyperframes}/kit/TitleCard.tsx +1 -1
  79. package/src/{remotion → hyperframes}/kit/Voiceover.tsx +1 -1
  80. package/src/{remotion → hyperframes}/kit/Warp3D.tsx +1 -1
  81. package/src/hyperframes/kit/brand-transform.ts +25 -0
  82. package/src/{remotion → hyperframes}/kit/docs.ts +6 -2
  83. package/src/{remotion → hyperframes}/kit/index.ts +3 -0
  84. package/src/{remotion → hyperframes}/kit/sound-cues.ts +1 -1
  85. package/src/{remotion → hyperframes}/kit/sound-kinds.ts +14 -1
  86. package/src/{remotion → hyperframes}/kit/theme.ts +42 -40
  87. package/src/{remotion → hyperframes}/kit/ui-math.ts +1 -1
  88. package/src/hyperframes/math.ts +62 -0
  89. package/src/hyperframes/three.tsx +10 -0
  90. package/src/project/chromakey.ts +1 -1
  91. package/src/project/layers.ts +1 -1
  92. package/src/project/serve.ts +2 -2
  93. package/src/render/component-preview.ts +11 -55
  94. package/src/render/render.ts +62 -64
  95. package/src/render/serve.ts +31 -0
  96. package/src/render/static-check.ts +15 -4
  97. package/src/render/validate.ts +4 -4
  98. package/src/render/word-check.ts +1 -1
  99. package/src/render/worker.ts +71 -0
  100. package/src/testing/fixtures.ts +1 -1
  101. package/src/remotion/Root.tsx +0 -31
  102. /package/src/{remotion → hyperframes}/kit/Icon.tsx +0 -0
  103. /package/src/{remotion → hyperframes}/kit/beat.ts +0 -0
  104. /package/src/{remotion → hyperframes}/kit/bg-math.ts +0 -0
  105. /package/src/{remotion → hyperframes}/kit/brand-icons.ts +0 -0
  106. /package/src/{remotion → hyperframes}/kit/caption-groups.ts +0 -0
  107. /package/src/{remotion → hyperframes}/kit/caption-style.ts +0 -0
  108. /package/src/{remotion → hyperframes}/kit/image-layers-math.ts +0 -0
  109. /package/src/{remotion → hyperframes}/kit/inter-bold-typeface.ts +0 -0
  110. /package/src/{remotion → hyperframes}/kit/media.ts +0 -0
  111. /package/src/{remotion → hyperframes}/kit/motion-math.ts +0 -0
  112. /package/src/{remotion → hyperframes}/kit/music-math.ts +0 -0
  113. /package/src/{remotion → hyperframes}/kit/quiet-three.ts +0 -0
  114. /package/src/{remotion → hyperframes}/kit/sample-text.ts +0 -0
  115. /package/src/{remotion → hyperframes}/kit/scene3d-context.ts +0 -0
  116. /package/src/{remotion → hyperframes}/kit/seeded.ts +0 -0
  117. /package/src/{remotion → hyperframes}/kit/three-fx-math.ts +0 -0
  118. /package/src/{remotion → hyperframes}/kit/three-math.ts +0 -0
  119. /package/src/{remotion → hyperframes}/kit/transition-math.ts +0 -0
  120. /package/src/{remotion → hyperframes}/kit/ui-theme.ts +0 -0
  121. /package/src/{remotion → hyperframes}/kit/word-anchor.ts +0 -0
  122. /package/src/{remotion → hyperframes}/types.ts +0 -0
@@ -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
+ }