egaki 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/cli/bpm-detect.d.ts +7 -0
  3. package/dist/cli/bpm-detect.d.ts.map +1 -0
  4. package/dist/cli/bpm-detect.js +123 -0
  5. package/dist/cli/cli.d.ts.map +1 -1
  6. package/dist/cli/cli.js +115 -0
  7. package/dist/cli/loudness-detect.d.ts +13 -0
  8. package/dist/cli/loudness-detect.d.ts.map +1 -0
  9. package/dist/cli/loudness-detect.js +96 -0
  10. package/dist/vite/code-block.d.ts.map +1 -1
  11. package/dist/vite/code-block.js +26 -5
  12. package/dist/vite/layout-editor.js +1 -1
  13. package/dist/vite/mdx-video.test.js +0 -79
  14. package/dist/vite/player-page.d.ts.map +1 -1
  15. package/dist/vite/player-page.js +6 -94
  16. package/dist/vite/store-hooks.d.ts +2 -0
  17. package/dist/vite/store-hooks.d.ts.map +1 -1
  18. package/dist/vite/store-hooks.js +8 -0
  19. package/dist/vite/store.d.ts +5 -0
  20. package/dist/vite/store.d.ts.map +1 -1
  21. package/dist/vite/store.js +5 -0
  22. package/dist/vite/tweakpane-hook.d.ts +6 -3
  23. package/dist/vite/tweakpane-hook.d.ts.map +1 -1
  24. package/dist/vite/tweakpane-hook.js +27 -16
  25. package/dist/vite/vite-plugin.d.ts +0 -6
  26. package/dist/vite/vite-plugin.d.ts.map +1 -1
  27. package/dist/vite/vite-plugin.js +1 -92
  28. package/package.json +9 -9
  29. package/skills/egaki/SKILL.md +26 -224
  30. package/src/cli/bpm-detect.ts +165 -0
  31. package/src/cli/cli.ts +149 -0
  32. package/src/cli/loudness-detect.ts +129 -0
  33. package/src/vite/code-block.tsx +28 -6
  34. package/src/vite/layout-editor.tsx +1 -1
  35. package/src/vite/mdx-video.test.tsx +0 -94
  36. package/src/vite/player-page.tsx +28 -117
  37. package/src/vite/store-hooks.ts +11 -0
  38. package/src/vite/store.ts +10 -0
  39. package/src/vite/tweakpane-hook.tsx +71 -24
  40. package/src/vite/vite-plugin.ts +1 -91
package/CHANGELOG.md CHANGED
@@ -2,6 +2,38 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 0.8.0
6
+
7
+ 1. **`egaki bpm` command** — detect BPM of audio files locally using peak detection and interval analysis. No API call needed, runs entirely on device via `node-web-audio-api`:
8
+
9
+ ```bash
10
+ egaki bpm song.mp3
11
+ egaki bpm track.wav --json
12
+ ffmpeg -i video.mp4 -f mp3 - | egaki bpm --stdin
13
+ ```
14
+
15
+ Returns BPM and beat interval in seconds. Useful for syncing music to video sections with `bpm:` frontmatter.
16
+
17
+ 2. **`egaki loudness` command** — measure perceived loudness (LUFS) of audio files locally. Uses K-weighted filtering and two-pass gating inspired by EBU R128:
18
+
19
+ ```bash
20
+ egaki loudness song.mp3
21
+ egaki loudness track.wav --json
22
+ ffmpeg -i video.mp4 -f mp3 - | egaki loudness --stdin
23
+ ```
24
+
25
+ Returns integrated, max, min loudness and loudness range. Helpful for normalizing audio levels across video scenes.
26
+
27
+ 3. **Collapsible tweakpane sidebar** — the right-side tweakpane panel is now collapsed by default so the video preview takes the full width. A panel icon button in the top-right corner toggles it open.
28
+
29
+ 4. **Fixed CodeBlock empty frames during export** — shiki highlight was racing with React's batched state updates, causing empty code blocks in screenshots, filmstrips, and video exports. Now uses `delayRender`/`continueRender` correctly via `useLayoutEffect`.
30
+
31
+ 5. **Removed HMR auto-seek on MDX edits** — editing the entry MDX no longer auto-seeks to the changed scene and auto-plays. The behavior was fragile and surprising. MDX edits still hot-reload via RSC update.
32
+
33
+ ## 0.7.2
34
+
35
+ 1. **Fixed missing `spiceflow` and `@vitejs/plugin-react` dependencies** — both were listed as optional peer dependencies but imported unconditionally by the Vite plugin. Now included as regular dependencies so `egaki/vite` works out of the box without manually installing them.
36
+
5
37
  ## 0.7.1
6
38
 
7
39
  1. **Fixed `egaki/vite` import failing from npm** — the Vite plugin export was pointing to raw `.ts` source files, which Node cannot load from `node_modules`. Now correctly points to compiled JS. The `./cached-generate` export had the same issue.
@@ -0,0 +1,7 @@
1
+ interface BpmResult {
2
+ bpm: number;
3
+ intervalInSeconds: number;
4
+ }
5
+ export declare function detectBpm(buffer: ArrayBuffer): Promise<Error | BpmResult>;
6
+ export {};
7
+ //# sourceMappingURL=bpm-detect.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bpm-detect.d.ts","sourceRoot":"","sources":["../../src/cli/bpm-detect.ts"],"names":[],"mappings":"AAKA,UAAU,SAAS;IACjB,GAAG,EAAE,MAAM,CAAA;IACX,iBAAiB,EAAE,MAAM,CAAA;CAC1B;AAMD,wBAAsB,SAAS,CAAC,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,KAAK,GAAG,SAAS,CAAC,CAsB/E"}
@@ -0,0 +1,123 @@
1
+ // BPM detection for audio files using Web Audio API.
2
+ // Ported from https://github.com/tornqvist/bpm-detective
3
+ // Uses node-web-audio-api (native NAPI Rust binding) as an optional dependency.
4
+ // If missing, returns an Error with install instructions.
5
+ // Cached module reference set by detectBpm() before calling detect().
6
+ // This avoids require() in ESM and avoids passing the module through every function.
7
+ let webAudioMod = null;
8
+ export async function detectBpm(buffer) {
9
+ try {
10
+ webAudioMod = await import('node-web-audio-api');
11
+ }
12
+ catch {
13
+ return new Error('node-web-audio-api is required for BPM detection. Install it:\n\n' +
14
+ ' pnpm add node-web-audio-api\n');
15
+ }
16
+ const context = new webAudioMod.AudioContext();
17
+ try {
18
+ const data = await context.decodeAudioData(buffer);
19
+ const bpm = detect(data);
20
+ const intervalInSeconds = 60 / bpm;
21
+ return { bpm, intervalInSeconds };
22
+ }
23
+ catch (err) {
24
+ if (err instanceof Error)
25
+ return err;
26
+ return new Error(String(err));
27
+ }
28
+ finally {
29
+ await context.close().catch(() => { });
30
+ }
31
+ }
32
+ function detect(buffer) {
33
+ const source = getLowPassSource(buffer);
34
+ source.start(0);
35
+ const channelData = source.buffer.getChannelData(0);
36
+ const peaks = findPeaks(channelData);
37
+ const intervals = identifyIntervals(peaks);
38
+ const tempoCounts = groupByTempo(buffer.sampleRate, intervals);
39
+ return getTopCandidate(tempoCounts);
40
+ }
41
+ function getTopCandidate(candidates) {
42
+ return candidates.sort((a, b) => b.count - a.count).splice(0, 5)[0].tempo;
43
+ }
44
+ function getLowPassSource(buffer) {
45
+ const { length, numberOfChannels, sampleRate } = buffer;
46
+ const context = new webAudioMod.OfflineAudioContext(numberOfChannels, length, sampleRate);
47
+ const source = context.createBufferSource();
48
+ source.buffer = buffer;
49
+ const filter = context.createBiquadFilter();
50
+ filter.type = 'lowpass';
51
+ source.connect(filter);
52
+ filter.connect(context.destination);
53
+ return source;
54
+ }
55
+ function findPeaks(data) {
56
+ let peaks = [];
57
+ let threshold = 0.9;
58
+ const minThreshold = 0.3;
59
+ const minPeaks = 15;
60
+ while (peaks.length < minPeaks && threshold >= minThreshold) {
61
+ peaks = findPeaksAtThreshold(data, threshold);
62
+ threshold -= 0.05;
63
+ }
64
+ if (peaks.length < minPeaks) {
65
+ throw new Error('Could not find enough samples for a reliable BPM detection.');
66
+ }
67
+ return peaks;
68
+ }
69
+ function findPeaksAtThreshold(data, threshold) {
70
+ const peaks = [];
71
+ for (let i = 0, l = data.length; i < l; i += 1) {
72
+ if (data[i] > threshold) {
73
+ peaks.push(i);
74
+ // Skip forward ~1/4s to get past this peak
75
+ i += 10000;
76
+ }
77
+ }
78
+ return peaks;
79
+ }
80
+ function identifyIntervals(peaks) {
81
+ const intervals = [];
82
+ peaks.forEach((peak, index) => {
83
+ for (let i = 0; i < 10; i += 1) {
84
+ const interval = peaks[index + i] - peak;
85
+ const foundInterval = intervals.some((ic) => {
86
+ if (ic.interval === interval) {
87
+ ic.count += 1;
88
+ return true;
89
+ }
90
+ return false;
91
+ });
92
+ if (!foundInterval) {
93
+ intervals.push({ interval, count: 1 });
94
+ }
95
+ }
96
+ });
97
+ return intervals;
98
+ }
99
+ function groupByTempo(sampleRate, intervalCounts) {
100
+ const tempoCounts = [];
101
+ intervalCounts.forEach((intervalCount) => {
102
+ if (intervalCount.interval !== 0) {
103
+ let theoreticalTempo = 60 / (intervalCount.interval / sampleRate);
104
+ // Adjust to fit within the 90-180 BPM range
105
+ while (theoreticalTempo < 90)
106
+ theoreticalTempo *= 2;
107
+ while (theoreticalTempo > 180)
108
+ theoreticalTempo /= 2;
109
+ theoreticalTempo = Math.round(theoreticalTempo);
110
+ const foundTempo = tempoCounts.some((tc) => {
111
+ if (tc.tempo === theoreticalTempo) {
112
+ tc.count += intervalCount.count;
113
+ return true;
114
+ }
115
+ return false;
116
+ });
117
+ if (!foundTempo) {
118
+ tempoCounts.push({ tempo: theoreticalTempo, count: intervalCount.count });
119
+ }
120
+ }
121
+ });
122
+ return tempoCounts;
123
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/cli/cli.ts"],"names":[],"mappings":";AA0FA,QAAA,MAAM,GAAG,yBAAgB,CAAA;AA4pCzB,OAAO,EAAE,GAAG,EAAE,CAAA"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/cli/cli.ts"],"names":[],"mappings":";AAgGA,QAAA,MAAM,GAAG,yBAAgB,CAAA;AA2yCzB,OAAO,EAAE,GAAG,EAAE,CAAA"}
package/dist/cli/cli.js CHANGED
@@ -30,6 +30,8 @@ import { DEFAULT_SPEECH_MODEL, } from './speech-models.js';
30
30
  import { DEFAULT_TRANSCRIPTION_MODEL, } from './transcription-models.js';
31
31
  import { separateAudioUncached as separateAudio, downloadStem, } from './separation-generate.js';
32
32
  import { cloneVoiceUncached as cloneVoice, } from './clone-generate.js';
33
+ import { detectBpm, } from './bpm-detect.js';
34
+ import { detectLoudness, } from './loudness-detect.js';
33
35
  const cli = goke('egaki');
34
36
  process.title = 'egaki';
35
37
  process.on('uncaughtException', (err) => {
@@ -654,6 +656,119 @@ cli
654
656
  }, null, 2));
655
657
  }
656
658
  });
659
+ // ─── bpm command ─────────────────────────────────────────────────────────────
660
+ cli
661
+ .command('bpm [audio]', dedent `
662
+ Detect the BPM (beats per minute) of an audio file.
663
+ Runs locally using peak detection, no API call needed.
664
+ Requires node-web-audio-api as an optional dependency.
665
+ `)
666
+ .option('--stdin', 'Read audio from stdin instead of a file path')
667
+ .option('--json', 'Output result as JSON to stdout')
668
+ .example('# Detect BPM of a song')
669
+ .example('egaki bpm song.mp3')
670
+ .example('# Pipe audio from another command')
671
+ .example('ffmpeg -i video.mp4 -f mp3 - | egaki bpm --stdin')
672
+ .example('# Get JSON output')
673
+ .example('egaki bpm track.wav --json')
674
+ .action(async (audioPath = '', options) => {
675
+ if (!options.stdin && !audioPath) {
676
+ console.error(pc.red('No audio provided. Pass an audio file path or use --stdin.'));
677
+ process.exit(1);
678
+ }
679
+ let audioData;
680
+ if (options.stdin) {
681
+ const chunks = [];
682
+ for await (const chunk of process.stdin) {
683
+ chunks.push(chunk);
684
+ }
685
+ const buf = Buffer.concat(chunks);
686
+ audioData = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
687
+ }
688
+ else {
689
+ const resolved = path.resolve(audioPath);
690
+ if (!fs.existsSync(resolved)) {
691
+ console.error(pc.red(`File not found: ${resolved}`));
692
+ process.exit(1);
693
+ }
694
+ const buf = fs.readFileSync(resolved);
695
+ audioData = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
696
+ }
697
+ if (!options.json) {
698
+ console.error(pc.dim(`Audio: ${options.stdin ? 'stdin' : audioPath} (${(audioData.byteLength / 1024).toFixed(1)} KB)`));
699
+ console.error(pc.cyan('Detecting BPM...'));
700
+ }
701
+ const result = await detectBpm(audioData);
702
+ if (result instanceof Error) {
703
+ console.error(pc.red(result.message));
704
+ process.exit(1);
705
+ }
706
+ if (options.json) {
707
+ console.log(JSON.stringify(result, null, 2));
708
+ }
709
+ else {
710
+ console.error(pc.green(`BPM: ${result.bpm}`));
711
+ console.error(pc.dim(`Interval: ${result.intervalInSeconds.toFixed(3)}s`));
712
+ }
713
+ });
714
+ // ─── loudness command ────────────────────────────────────────────────────────
715
+ cli
716
+ .command('loudness [audio]', dedent `
717
+ Measure the perceived loudness (LUFS) of an audio file.
718
+ Uses K-weighting and two-pass gating inspired by EBU R128.
719
+ Runs locally, no API call needed.
720
+ Requires node-web-audio-api as an optional dependency.
721
+ `)
722
+ .option('--stdin', 'Read audio from stdin instead of a file path')
723
+ .option('--json', 'Output result as JSON to stdout')
724
+ .example('# Measure loudness of a song')
725
+ .example('egaki loudness song.mp3')
726
+ .example('# Pipe audio from another command')
727
+ .example('ffmpeg -i video.mp4 -f mp3 - | egaki loudness --stdin')
728
+ .example('# Get JSON output')
729
+ .example('egaki loudness track.wav --json')
730
+ .action(async (audioPath = '', options) => {
731
+ if (!options.stdin && !audioPath) {
732
+ console.error(pc.red('No audio provided. Pass an audio file path or use --stdin.'));
733
+ process.exit(1);
734
+ }
735
+ let audioData;
736
+ if (options.stdin) {
737
+ const chunks = [];
738
+ for await (const chunk of process.stdin) {
739
+ chunks.push(chunk);
740
+ }
741
+ const buf = Buffer.concat(chunks);
742
+ audioData = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
743
+ }
744
+ else {
745
+ const resolved = path.resolve(audioPath);
746
+ if (!fs.existsSync(resolved)) {
747
+ console.error(pc.red(`File not found: ${resolved}`));
748
+ process.exit(1);
749
+ }
750
+ const buf = fs.readFileSync(resolved);
751
+ audioData = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
752
+ }
753
+ if (!options.json) {
754
+ console.error(pc.dim(`Audio: ${options.stdin ? 'stdin' : audioPath} (${(audioData.byteLength / 1024).toFixed(1)} KB)`));
755
+ console.error(pc.cyan('Measuring loudness...'));
756
+ }
757
+ const result = await detectLoudness(audioData);
758
+ if (result instanceof Error) {
759
+ console.error(pc.red(result.message));
760
+ process.exit(1);
761
+ }
762
+ if (options.json) {
763
+ console.log(JSON.stringify(result, null, 2));
764
+ }
765
+ else {
766
+ console.error(pc.green(`Integrated: ${result.integrated.toFixed(1)} LUFS`));
767
+ console.error(pc.dim(`Max: ${result.max.toFixed(1)} LUFS`));
768
+ console.error(pc.dim(`Min: ${result.min.toFixed(1)} LUFS`));
769
+ console.error(pc.dim(`Range: ${result.range.toFixed(1)} LU`));
770
+ }
771
+ });
657
772
  // ─── voice clone command ─────────────────────────────────────────────────────
658
773
  cli
659
774
  .command('voice clone [audio]', dedent `
@@ -0,0 +1,13 @@
1
+ interface LoudnessResult {
2
+ /** Integrated loudness in LUFS (two-pass gated average). */
3
+ integrated: number;
4
+ /** Maximum momentary loudness (single 400ms block). */
5
+ max: number;
6
+ /** Minimum momentary loudness above the absolute gate. */
7
+ min: number;
8
+ /** Loudness range (max - min) in LU. */
9
+ range: number;
10
+ }
11
+ export declare function detectLoudness(buffer: ArrayBuffer): Promise<Error | LoudnessResult>;
12
+ export {};
13
+ //# sourceMappingURL=loudness-detect.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"loudness-detect.d.ts","sourceRoot":"","sources":["../../src/cli/loudness-detect.ts"],"names":[],"mappings":"AAcA,UAAU,cAAc;IACtB,4DAA4D;IAC5D,UAAU,EAAE,MAAM,CAAA;IAClB,uDAAuD;IACvD,GAAG,EAAE,MAAM,CAAA;IACX,0DAA0D;IAC1D,GAAG,EAAE,MAAM,CAAA;IACX,wCAAwC;IACxC,KAAK,EAAE,MAAM,CAAA;CACd;AAMD,wBAAsB,cAAc,CAAC,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,KAAK,GAAG,cAAc,CAAC,CAmGzF"}
@@ -0,0 +1,96 @@
1
+ // LUFS (Loudness Units Full Scale) measurement for audio files.
2
+ // Ported from meltica. Uses node-web-audio-api (native NAPI Rust binding)
3
+ // as an optional dependency. If missing, returns an Error with install instructions.
4
+ //
5
+ // Implements a simplified LUFS measurement inspired by EBU R128 / ITU-R BS.1770:
6
+ // K-weighting via biquad filters (approximate, not exact BS.1770 coefficients),
7
+ // 400ms blocks with 100ms hop, two-pass gating (absolute -70 LUFS gate, then
8
+ // relative -10 LU gate). Energy averaging is done in the linear domain (mean
9
+ // square) before converting to LUFS, which is the correct approach.
10
+ //
11
+ // The K-weighting filters are simplified (highpass 60Hz + highshelf 1500Hz)
12
+ // rather than the exact BS.1770 pre-filter + RLB coefficients, so results
13
+ // may differ slightly from reference implementations.
14
+ function lufsFromMeanSquare(ms) {
15
+ return -0.691 + 10 * Math.log10(ms);
16
+ }
17
+ export async function detectLoudness(buffer) {
18
+ let webAudioMod;
19
+ try {
20
+ webAudioMod = await import('node-web-audio-api');
21
+ }
22
+ catch {
23
+ return new Error('node-web-audio-api is required for loudness measurement. Install it:\n\n' +
24
+ ' pnpm add node-web-audio-api\n');
25
+ }
26
+ const context = new webAudioMod.AudioContext();
27
+ try {
28
+ const audioBuffer = await context.decodeAudioData(buffer);
29
+ const offlineContext = new webAudioMod.OfflineAudioContext(audioBuffer.numberOfChannels, audioBuffer.length, audioBuffer.sampleRate);
30
+ const source = offlineContext.createBufferSource();
31
+ source.buffer = audioBuffer;
32
+ // Simplified K-weighting: high-pass at 60Hz + high-shelf boost at 1500Hz
33
+ const highPass = offlineContext.createBiquadFilter();
34
+ highPass.type = 'highpass';
35
+ highPass.frequency.value = 60;
36
+ highPass.Q.value = 0.7;
37
+ const highShelf = offlineContext.createBiquadFilter();
38
+ highShelf.type = 'highshelf';
39
+ highShelf.frequency.value = 1500;
40
+ highShelf.gain.value = 4;
41
+ source.connect(highPass);
42
+ highPass.connect(highShelf);
43
+ highShelf.connect(offlineContext.destination);
44
+ source.start(0);
45
+ const rendered = await offlineContext.startRendering();
46
+ // 400ms blocks, 100ms hop
47
+ const blockSize = Math.floor(0.4 * rendered.sampleRate);
48
+ const hopSize = Math.floor(0.1 * rendered.sampleRate);
49
+ if (rendered.length < blockSize) {
50
+ return new Error('Audio must be at least 400ms for loudness measurement.');
51
+ }
52
+ // Collect mean square energy per block
53
+ const blocks = [];
54
+ for (let i = 0; i <= rendered.length - blockSize; i += hopSize) {
55
+ let sumSquared = 0;
56
+ for (let ch = 0; ch < rendered.numberOfChannels; ch++) {
57
+ const data = rendered.getChannelData(ch);
58
+ for (let j = 0; j < blockSize; j++) {
59
+ sumSquared += data[i + j] * data[i + j];
60
+ }
61
+ }
62
+ blocks.push(sumSquared / (blockSize * rendered.numberOfChannels));
63
+ }
64
+ // Two-pass gating per EBU R128:
65
+ // Pass 1: absolute gate at -70 LUFS
66
+ const absoluteGate = -70;
67
+ const absoluteGated = blocks.filter((ms) => lufsFromMeanSquare(ms) > absoluteGate);
68
+ if (absoluteGated.length === 0) {
69
+ return new Error('Audio is silent or below the -70 LUFS gate threshold.');
70
+ }
71
+ // Pass 2: relative gate at -10 LU below the absolute-gated integrated loudness
72
+ const preliminaryMs = absoluteGated.reduce((s, v) => s + v, 0) / absoluteGated.length;
73
+ const relativeGate = lufsFromMeanSquare(preliminaryMs) - 10;
74
+ const relativeGated = absoluteGated.filter((ms) => lufsFromMeanSquare(ms) > relativeGate);
75
+ if (relativeGated.length === 0) {
76
+ return new Error('Audio is too quiet for reliable loudness measurement.');
77
+ }
78
+ // Integrated loudness: average energy in linear domain, then convert to LUFS
79
+ const integratedMs = relativeGated.reduce((s, v) => s + v, 0) / relativeGated.length;
80
+ const integrated = lufsFromMeanSquare(integratedMs);
81
+ // Momentary loudness values for range calculation (from absolute-gated blocks)
82
+ const momentaryLufs = absoluteGated.map(lufsFromMeanSquare);
83
+ const max = Math.max(...momentaryLufs);
84
+ const min = Math.min(...momentaryLufs);
85
+ const range = max - min;
86
+ return { integrated, max, min, range };
87
+ }
88
+ catch (err) {
89
+ if (err instanceof Error)
90
+ return err;
91
+ return new Error(String(err));
92
+ }
93
+ finally {
94
+ await context.close().catch(() => { });
95
+ }
96
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"code-block.d.ts","sourceRoot":"","sources":["../../src/vite/code-block.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"code-block.d.ts","sourceRoot":"","sources":["../../src/vite/code-block.tsx"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,EAAgG,KAAK,SAAS,EAAE,MAAM,OAAO,CAAA;AAQpI,eAAO,MAAM,gBAAgB,kCAAuB,CAAA;AAgFpD,KAAK,YAAY,GAAG;IAClB,UAAU,EAAE,MAAM,CAAA;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB,CAAA;AA6BD,KAAK,SAAS,GACV,SAAS,GACT,QAAQ,GACR,QAAQ,GACR,QAAQ,GACR,UAAU,GACV,YAAY,CAAA;AAehB,MAAM,MAAM,cAAc,GAAG;IAC3B,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,UAAU,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAA;KAAE,CAAA;IACxC,KAAK,EAAE,SAAS,CAAA;IAChB;0EACsE;IACtE,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,iCAAiC;IACjC,WAAW,CAAC,EAAE;QACZ,QAAQ,CAAC,EAAE,MAAM,CAAA;QACjB,aAAa,CAAC,EAAE,MAAM,CAAA;QACtB,MAAM,CAAC,EAAE,MAAM,CAAA;QACf,WAAW,CAAC,EAAE,MAAM,CAAA;QACpB,QAAQ,CAAC,EAAE,MAAM,CAAA;QACjB,aAAa,CAAC,EAAE,MAAM,CAAA;QACtB,QAAQ,CAAC,EAAE,MAAM,CAAA;QACjB,aAAa,CAAC,EAAE,MAAM,CAAA;QACtB,UAAU,CAAC,EAAE,MAAM,CAAA;QACnB,eAAe,CAAC,EAAE,MAAM,CAAA;QACxB,OAAO,CAAC,EAAE,MAAM,CAAA;QAChB,YAAY,CAAC,EAAE,MAAM,CAAA;KACtB,CAAA;IACD,MAAM,EAAE;QACN,KAAK,CAAC,EAAE,YAAY,CAAA;QACpB,IAAI,CAAC,EAAE,YAAY,CAAA;KACpB,CAAA;CACF,CAAA;AAMD,eAAO,MAAM,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,cAAc,CAwKtD,CAAA;AA6SD,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,EAAE,SAAS,CAAA;IACpB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,eAAe,CAAC,EAAE,OAAO,CAAA;IACzB,cAAc,CAAC,EAAE,MAAM,EAAE,CAAA;IACzB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;IACvB,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;IACxB,cAAc,CAAC,EAAE,OAAO,CAAA;IACxB,aAAa,CAAC,EAAE,MAAM,CAAA;CACvB;AAED,wBAAgB,SAAS,CAAC,EACxB,QAAQ,EACR,KAAK,EAAE,OAAkB,EACzB,QAAe,EACf,KAAK,EACL,QAAQ,EACR,eAAuB,EACvB,cAAmB,EACnB,OAAY,EACZ,QAAa,EACb,KAAW,EACX,MAAM,EACN,cAAqB,EACrB,aAAiB,GAClB,EAAE,cAAc,+BA2LhB;AAqBD,wBAAgB,mBAAmB,CAAC,EAClC,QAAQ,EACR,GAAG,IAAI,EACR,EAAE;IAAE,QAAQ,CAAC,EAAE,SAAS,CAAA;CAAE,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,+BA0BhD"}
@@ -31,8 +31,8 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
31
31
  * - Supabase frame: https://github.com/raycast/ray-so/blob/main/app/(navigation)/(code)/components/frames/SupabaseFrame.tsx
32
32
  * - Cloudflare frame: https://github.com/raycast/ray-so/blob/main/app/(navigation)/(code)/components/frames/CloudflareFrame.tsx
33
33
  */
34
- import { createContext, useContext, useEffect, useMemo, useState } from 'react';
35
- import { interpolate, spring, useCurrentFrame, useVideoConfig } from 'remotion';
34
+ import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useState } from 'react';
35
+ import { interpolate, spring, useCurrentFrame, useDelayRender, useVideoConfig } from 'remotion';
36
36
  import { useTweakpane } from "./tweakpane-hook.js";
37
37
  // ---------------------------------------------------------------------------
38
38
  // Nesting guard
@@ -328,11 +328,28 @@ function detectLanguage(title, language) {
328
328
  // ---------------------------------------------------------------------------
329
329
  function useHighlightedHtml(code, lang, highlightLines) {
330
330
  const [html, setHtml] = useState('');
331
- useEffect(() => {
331
+ const { delayRender, continueRender } = useDelayRender();
332
+ // delayRender registered during the first render (useState initializer) and
333
+ // continued only AFTER the highlighted html has been committed to the DOM
334
+ // (layout effect below). Continuing from inside the shiki .then() is too
335
+ // early: React batches the setHtml update, so the renderer could capture the
336
+ // frame between continueRender and the commit, producing an empty block in
337
+ // screenshots/filmstrips/exports.
338
+ const [handle] = useState(() => delayRender('shiki highlight'));
339
+ useLayoutEffect(() => {
340
+ if (html)
341
+ continueRender(handle);
342
+ }, [html, handle, continueRender]);
343
+ // Unmount safety: never leave a dangling handle.
344
+ useLayoutEffect(() => {
345
+ return () => continueRender(handle);
346
+ }, [handle, continueRender]);
347
+ useLayoutEffect(() => {
332
348
  let cancelled = false;
333
349
  void getHighlighter().then((h) => {
334
- if (cancelled)
350
+ if (cancelled) {
335
351
  return;
352
+ }
336
353
  // Load language on demand if not already loaded
337
354
  const loaded = h.getLoadedLanguages();
338
355
  const langToUse = loaded.includes(lang) ? lang : 'tsx';
@@ -353,8 +370,12 @@ function useHighlightedHtml(code, lang, highlightLines) {
353
370
  });
354
371
  if (!cancelled)
355
372
  setHtml(result);
373
+ }).catch(() => {
374
+ continueRender(handle);
356
375
  });
357
- return () => { cancelled = true; };
376
+ return () => {
377
+ cancelled = true;
378
+ };
358
379
  }, [code, lang, highlightLines.join(',')]);
359
380
  return html;
360
381
  }
@@ -583,5 +583,5 @@ export function LayoutEditor({ playerContainerRef, playerRef, editing, onEditing
583
583
  setSelectedEl(null);
584
584
  onReset();
585
585
  }, [onReset]);
586
- return (_jsxs("div", { "data-layout-editor-toolbar": true, className: 'flex items-center gap-1.5 text-[13px]', children: [_jsx(ToolbarButton, { onClick: () => onEditingChange(!editing), active: editing, title: editing ? 'Exit editing mode' : 'Edit layout — click to select, drag to move, corners to scale, double-click text to edit', children: editing ? '✦ Editing' : '✦ Edit Layout' }), editing && changesCount > 0 && (_jsxs(_Fragment, { children: [_jsx(ToolbarButton, { onClick: handleCopy, title: 'Copy changes for an AI agent', children: copied ? 'Copied!' : `Copy ${changesCount} change${changesCount > 1 ? 's' : ''}` }), _jsx(ToolbarButton, { onClick: handleReset, title: 'Revert all changes', children: "Reset" })] })), editing && !selectedEl && (_jsx("span", { className: 'text-zinc-600 italic', children: "Click an element" }))] }));
586
+ return (_jsxs("div", { "data-layout-editor-toolbar": true, className: 'flex items-center gap-1.5 text-[13px]', children: [_jsx(ToolbarButton, { onClick: () => onEditingChange(!editing), active: editing, title: editing ? 'Exit editing mode' : 'Edit layout — click to select, drag to move, corners to scale, double-click text to edit', children: editing ? 'Editing' : 'Edit' }), editing && changesCount > 0 && (_jsxs(_Fragment, { children: [_jsx(ToolbarButton, { onClick: handleCopy, title: 'Copy changes for an AI agent', children: copied ? 'Copied!' : `Copy ${changesCount} change${changesCount > 1 ? 's' : ''}` }), _jsx(ToolbarButton, { onClick: handleReset, title: 'Revert all changes', children: "Reset" })] })), editing && !selectedEl && (_jsx("span", { className: 'text-zinc-600 italic', children: "Click an element" }))] }));
587
587
  }
@@ -13,7 +13,6 @@ import { SafeMdxRenderer } from 'safe-mdx';
13
13
  import { mdxParse, extractImports, resolveModulePath } from 'safe-mdx/parse';
14
14
  import { MdastToJsx } from 'safe-mdx';
15
15
  import { splitIntoSections, calculateTotalDuration, resolveAutoDurations, parseFrontmatter, aspectRatioFromDimensions, buildMdxScope } from "./mdx-parse.js";
16
- import { findChangedSectionIndex } from "./vite-plugin.js";
17
16
  import { computeEffectiveDuration } from "./media-duration-store.js";
18
17
  import { findServerNodes, blankServerContents, collectServerImportSources, wrapGenerateNodes, collectServerFileImportNames } from "./server-mdx.js";
19
18
  import { stableJsonKey, hashKey, promptPrefix } from "./server-components.js";
@@ -1734,84 +1733,6 @@ describe('computeEffectiveDuration with startInFrames', () => {
1734
1733
  expect(computeEffectiveDuration({ fps: 30, startInFrames: 30 })).toBeNull();
1735
1734
  });
1736
1735
  });
1737
- describe('findChangedSectionIndex', () => {
1738
- const base = [
1739
- '---',
1740
- 'fps: 30',
1741
- 'bpm: 120',
1742
- '---',
1743
- '',
1744
- '# Intro duration=3s',
1745
- '',
1746
- 'Hello world',
1747
- '',
1748
- '# Middle duration=2s',
1749
- '',
1750
- 'Some content here',
1751
- '',
1752
- '# Outro duration=1s',
1753
- '',
1754
- 'Goodbye',
1755
- ].join('\n');
1756
- test('identical content returns null', () => {
1757
- expect(findChangedSectionIndex(base, base)).toBe(null);
1758
- });
1759
- test('editing first section returns 0', () => {
1760
- const edited = base.replace('Hello world', 'Hello EDITED');
1761
- expect(findChangedSectionIndex(base, edited)).toBe(0);
1762
- });
1763
- test('editing second section returns 1', () => {
1764
- const edited = base.replace('Some content here', 'New content');
1765
- expect(findChangedSectionIndex(base, edited)).toBe(1);
1766
- });
1767
- test('editing last section returns 2', () => {
1768
- const edited = base.replace('Goodbye', 'See ya');
1769
- expect(findChangedSectionIndex(base, edited)).toBe(2);
1770
- });
1771
- test('adding a new section returns its index', () => {
1772
- const added = base + '\n\n# Bonus duration=1s\n\nExtra stuff';
1773
- expect(findChangedSectionIndex(base, added)).toBe(3);
1774
- });
1775
- test('deleting last section returns null (skip deletions)', () => {
1776
- const deleted = base.replace('\n# Outro duration=1s\n\nGoodbye', '');
1777
- expect(findChangedSectionIndex(base, deleted)).toBe(null);
1778
- });
1779
- test('deleting middle section returns null (skip deletions)', () => {
1780
- const deleted = base.replace('\n# Middle duration=2s\n\nSome content here\n', '\n');
1781
- expect(findChangedSectionIndex(base, deleted)).toBe(null);
1782
- });
1783
- test('editing heading text returns that section index', () => {
1784
- const edited = base.replace('# Middle duration=2s', '# Middle Renamed duration=2s');
1785
- expect(findChangedSectionIndex(base, edited)).toBe(1);
1786
- });
1787
- test('preamble-only change returns null', () => {
1788
- const withPreamble = '<Audio src="/bg.mp3" />\n\n' + base.split('\n').slice(4).join('\n');
1789
- const oldPreamble = '---\nfps: 30\nbpm: 120\n---\n\n' + withPreamble;
1790
- const newPreamble = oldPreamble.replace('/bg.mp3', '/new-bg.mp3');
1791
- expect(findChangedSectionIndex(oldPreamble, newPreamble)).toBe(null);
1792
- });
1793
- test('frontmatter-only change returns null', () => {
1794
- const edited = base.replace('fps: 30', 'fps: 60');
1795
- expect(findChangedSectionIndex(base, edited)).toBe(null);
1796
- });
1797
- test('editing heading duration returns that section index', () => {
1798
- const edited = base.replace('duration=3s', 'duration=4s');
1799
- expect(findChangedSectionIndex(base, edited)).toBe(0);
1800
- });
1801
- test('editing heading transition returns that section index', () => {
1802
- const old = '# Intro duration=3s transition=10\n\nHello';
1803
- const edited = '# Intro duration=3s transition=20\n\nHello';
1804
- expect(findChangedSectionIndex(old, edited)).toBe(0);
1805
- });
1806
- test('broken MDX returns null (does not throw)', () => {
1807
- const valid = '# Intro duration=3s\n\nHello world';
1808
- const broken = '# Intro duration=3s\n\n<Component';
1809
- expect(findChangedSectionIndex(valid, broken)).toBe(null);
1810
- });
1811
- test('both sources broken returns null', () => {
1812
- expect(findChangedSectionIndex('<Foo', '<Bar')).toBe(null);
1813
- });
1814
- });
1815
1736
  // ---------------------------------------------------------------------------
1816
1737
  // Parse error recovery
1817
1738
  // ---------------------------------------------------------------------------
@@ -1 +1 @@
1
- {"version":3,"file":"player-page.d.ts","sourceRoot":"","sources":["../../src/vite/player-page.tsx"],"names":[],"mappings":"AAEA;;;;;;;;;GASG;AAEH,OAAO,cAAc,CAAA;AAErB,OAAO,KAAK,EAAE,EAAyG,KAAK,SAAS,EAAE,MAAM,OAAO,CAAA;AA2BpJ,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AAmOtD,UAAU,YAAY;IACpB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,gBAAgB,EAAE,MAAM,CAAA;IACxB,GAAG,EAAE,SAAS,CAAA;CACf;AAyJD,wBAAgB,UAAU,CAAC,EACzB,QAAQ,EACR,aAAa,EACb,QAAQ,EACR,SAAS,EACT,sBAA8B,EAC9B,WAAW,EACX,gBAAqB,EACrB,YAAiB,GAClB,EAAE;IACD,QAAQ,EAAE,YAAY,EAAE,CAAA;IACxB,aAAa,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,EAAE,SAAS,CAAA;IACpB,qEAAqE;IACrE,SAAS,EAAE,MAAM,CAAA;IACjB;6DACyD;IACzD,sBAAsB,CAAC,EAAE,OAAO,CAAA;IAChC,WAAW,EAAE,gBAAgB,CAAA;IAC7B,0DAA0D;IAC1D,gBAAgB,CAAC,EAAE,MAAM,EAAE,CAAA;IAC3B,iDAAiD;IACjD,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB,qBAipBA"}
1
+ {"version":3,"file":"player-page.d.ts","sourceRoot":"","sources":["../../src/vite/player-page.tsx"],"names":[],"mappings":"AAEA;;;;;;;;;GASG;AAEH,OAAO,cAAc,CAAA;AAErB,OAAO,KAAK,EAAE,EAAyG,KAAK,SAAS,EAAE,MAAM,OAAO,CAAA;AA2BpJ,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AAkNtD,UAAU,YAAY;IACpB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,gBAAgB,EAAE,MAAM,CAAA;IACxB,GAAG,EAAE,SAAS,CAAA;CACf;AAyJD,wBAAgB,UAAU,CAAC,EACzB,QAAQ,EACR,aAAa,EACb,QAAQ,EACR,SAAS,EACT,sBAA8B,EAC9B,WAAW,EACX,gBAAqB,EACrB,YAAiB,GAClB,EAAE;IACD,QAAQ,EAAE,YAAY,EAAE,CAAA;IACxB,aAAa,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,EAAE,SAAS,CAAA;IACpB,qEAAqE;IACrE,SAAS,EAAE,MAAM,CAAA;IACjB;6DACyD;IACzD,sBAAsB,CAAC,EAAE,OAAO,CAAA;IAChC,WAAW,EAAE,gBAAgB,CAAA;IAC7B,0DAA0D;IAC1D,gBAAgB,CAAC,EAAE,MAAM,EAAE,CAAA;IAC3B,iDAAiD;IACjD,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB,qBAykBA"}