egaki 0.7.2 → 0.9.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 (63) hide show
  1. package/CHANGELOG.md +52 -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/angled-screen-shader.d.ts +45 -0
  11. package/dist/vite/angled-screen-shader.d.ts.map +1 -0
  12. package/dist/vite/angled-screen-shader.js +573 -0
  13. package/dist/vite/angled-screen.d.ts +12 -3
  14. package/dist/vite/angled-screen.d.ts.map +1 -1
  15. package/dist/vite/angled-screen.js +12 -3
  16. package/dist/vite/code-block.d.ts.map +1 -1
  17. package/dist/vite/code-block.js +26 -5
  18. package/dist/vite/layout-editor.js +1 -1
  19. package/dist/vite/mdx-video.d.ts +4 -2
  20. package/dist/vite/mdx-video.d.ts.map +1 -1
  21. package/dist/vite/mdx-video.js +4 -2
  22. package/dist/vite/mdx-video.test.js +1 -79
  23. package/dist/vite/player-page.d.ts.map +1 -1
  24. package/dist/vite/player-page.js +6 -94
  25. package/dist/vite/render-client.d.ts +27 -0
  26. package/dist/vite/render-client.d.ts.map +1 -1
  27. package/dist/vite/render-client.js +47 -51
  28. package/dist/vite/sdk.d.ts.map +1 -1
  29. package/dist/vite/sdk.js +4 -3
  30. package/dist/vite/shader-renderer.d.ts +4 -0
  31. package/dist/vite/shader-renderer.d.ts.map +1 -1
  32. package/dist/vite/shader-renderer.js +3 -3
  33. package/dist/vite/store-hooks.d.ts +2 -0
  34. package/dist/vite/store-hooks.d.ts.map +1 -1
  35. package/dist/vite/store-hooks.js +8 -0
  36. package/dist/vite/store.d.ts +5 -0
  37. package/dist/vite/store.d.ts.map +1 -1
  38. package/dist/vite/store.js +5 -0
  39. package/dist/vite/tweakpane-hook.d.ts +12 -3
  40. package/dist/vite/tweakpane-hook.d.ts.map +1 -1
  41. package/dist/vite/tweakpane-hook.js +128 -20
  42. package/dist/vite/vite-plugin.d.ts +0 -6
  43. package/dist/vite/vite-plugin.d.ts.map +1 -1
  44. package/dist/vite/vite-plugin.js +1 -92
  45. package/package.json +9 -6
  46. package/skills/egaki/SKILL.md +26 -224
  47. package/src/cli/bpm-detect.ts +165 -0
  48. package/src/cli/cli.ts +149 -0
  49. package/src/cli/loudness-detect.ts +129 -0
  50. package/src/vite/angled-screen-shader.tsx +706 -0
  51. package/src/vite/angled-screen.tsx +13 -4
  52. package/src/vite/code-block.tsx +28 -6
  53. package/src/vite/layout-editor.tsx +1 -1
  54. package/src/vite/mdx-video.test.tsx +1 -94
  55. package/src/vite/mdx-video.tsx +4 -1
  56. package/src/vite/player-page.tsx +28 -117
  57. package/src/vite/render-client.ts +52 -51
  58. package/src/vite/sdk.ts +4 -3
  59. package/src/vite/shader-renderer.tsx +3 -3
  60. package/src/vite/store-hooks.ts +11 -0
  61. package/src/vite/store.ts +10 -0
  62. package/src/vite/tweakpane-hook.tsx +173 -28
  63. package/src/vite/vite-plugin.ts +1 -91
package/CHANGELOG.md CHANGED
@@ -2,6 +2,58 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 0.9.0
6
+
7
+ 1. **`<AngledScreen>` with true WebGL depth-of-field** — the 3D tilted screen component is now shader-based. Children DOM is captured per frame with Chromium's HTML-in-canvas API and rendered as a perspective-tilted plane in a WebGL2 fragment shader, giving true per-pixel depth. Depth drives bokeh blur (the exact Three.js BokehShader 41-tap ring kernel), fog toward `backgroundColor`, and subtle film grain. Available in MDX without imports:
8
+
9
+ ```mdx
10
+ # Hero duration=5s
11
+
12
+ <AngledScreen rotateX={9} rotateY={-17} translateZ={120} backgroundColor="#0a0608">
13
+ <Img src="/screenshot.png" style={{ width: '100%', borderRadius: 14 }} />
14
+ </AngledScreen>
15
+ ```
16
+
17
+ New depth-of-field props: `bokeh`, `aperture` (default 0.5), `maxBlur` (default 0.12), `focus` (default 0 = auto-track the near side so blur ramps toward the far edge), plus `fog` and `grainIntensity`. Transform props (`perspective`, `rotateX/Y/Z`, `translateX`, `translateZ`) keep the same semantics as before.
18
+
19
+ Requires Chrome 149+ with `chrome://flags/#canvas-draw-element`. When HTML-in-canvas is unsupported, the previous CSS version renders automatically as a fallback (also exported directly as `BasicAngledScreen`).
20
+
21
+ 2. **Tweakpane controls now follow animated props** — `useTweakpane`-backed components (like `AngledScreen`) previously froze all values at mount, so animated props like `translateX={interpolate(frame, ...)}` stayed stuck at frame 0. Untouched keys now return the live value every frame and their sliders visibly animate during playback. Once you touch a control, that key freezes to your value until remount, so manual overrides survive seeks. "Copy changes" now only reports keys you actually edited.
22
+
23
+ 3. **Fixed flat exports and screenshots of shader scenes** — web-renderer exports, SDK `screenshot()`, `filmstrip()`, and `export()` of scenes with HTML-in-canvas shaders came out without the shader output (the export scaffold is hidden, so Chromium produced no paint records for the capture). The composition root is now wrapped in a visible container for all web-renderer entry points, restoring shader output in every export path.
24
+
25
+ 4. **Fixed export hang with hidden `<AngledScreen>` instances** — instances inside hidden ancestors (LayoutTransition ghosts, inactive timed instances) never fired their paint callback, leaving an unreleased delayRender handle that stalled exports ~28s per frame. Handle registration is now gated on actual visibility.
26
+
27
+ 5. **Remotion bumped to 4.0.494** — required for WebGL contexts inside HTML-in-canvas components (earlier versions grabbed a 2D context on the offscreen canvas first, blocking WebGL).
28
+
29
+ ## 0.8.0
30
+
31
+ 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`:
32
+
33
+ ```bash
34
+ egaki bpm song.mp3
35
+ egaki bpm track.wav --json
36
+ ffmpeg -i video.mp4 -f mp3 - | egaki bpm --stdin
37
+ ```
38
+
39
+ Returns BPM and beat interval in seconds. Useful for syncing music to video sections with `bpm:` frontmatter.
40
+
41
+ 2. **`egaki loudness` command** — measure perceived loudness (LUFS) of audio files locally. Uses K-weighted filtering and two-pass gating inspired by EBU R128:
42
+
43
+ ```bash
44
+ egaki loudness song.mp3
45
+ egaki loudness track.wav --json
46
+ ffmpeg -i video.mp4 -f mp3 - | egaki loudness --stdin
47
+ ```
48
+
49
+ Returns integrated, max, min loudness and loudness range. Helpful for normalizing audio levels across video scenes.
50
+
51
+ 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.
52
+
53
+ 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`.
54
+
55
+ 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.
56
+
5
57
  ## 0.7.2
6
58
 
7
59
  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.
@@ -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
+ }
@@ -0,0 +1,45 @@
1
+ import { type CSSProperties, type ReactNode } from 'react';
2
+ export interface AngledScreenProps {
3
+ children: ReactNode;
4
+ /** CSS perspective distance in px. Smaller = more dramatic. Default 1200. */
5
+ perspective?: number;
6
+ /** X-axis rotation in degrees (tilts top/bottom). Default 8. */
7
+ rotateX?: number;
8
+ /** Y-axis rotation in degrees (angles left/right). Default -12. */
9
+ rotateY?: number;
10
+ /** Z-axis rotation in degrees. Default 0. */
11
+ rotateZ?: number;
12
+ /** Shift the plane horizontally in px. Positive = right. Default 0. */
13
+ translateX?: number;
14
+ /** Push the plane forward/back in px. Positive = closer/larger. Default 0. */
15
+ translateZ?: number;
16
+ /** Enable depth-based bokeh blur. Default true. */
17
+ bokeh?: boolean;
18
+ /** Aperture — bigger values for shallower depth of field. Blur grows
19
+ * linearly with distance from the focus plane, scaled by this.
20
+ * Same semantics as Three's BokehPass. Default 0.5 (plugin value). */
21
+ aperture?: number;
22
+ /** Max blur in screen-UV units (fraction of frame size), the saturation
23
+ * cap of the blur. Same as Three's `maxblur`. Default 0.12 (plugin value). */
24
+ maxBlur?: number;
25
+ /** Focus distance as a fraction of `perspective`. Default 0 = auto: focus
26
+ * tracks the nearest visible point of the plane, so the near side stays
27
+ * sharp and blur ramps progressively toward the far edge only. */
28
+ focus?: number;
29
+ /** Fog toward backgroundColor at the far depth (0-1). Default 0.35. */
30
+ fog?: number;
31
+ /** Film grain intensity. Default 0.02. */
32
+ grainIntensity?: number;
33
+ /** Background color behind the tilted screen. Default '#000000'. */
34
+ backgroundColor?: string;
35
+ /** Width of the inner content wrapper. Default '80%'. */
36
+ width?: string | number;
37
+ /** Height of the inner content wrapper. Default 'auto'. */
38
+ height?: string | number;
39
+ /** Render normalized depth as grayscale for debugging the DOF ramp. */
40
+ debug?: boolean;
41
+ /** Optional inline style on the outer container. */
42
+ style?: CSSProperties;
43
+ }
44
+ export declare function AngledScreen(props: AngledScreenProps): import("react").JSX.Element;
45
+ //# sourceMappingURL=angled-screen-shader.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"angled-screen-shader.d.ts","sourceRoot":"","sources":["../../src/vite/angled-screen-shader.tsx"],"names":[],"mappings":"AAyEA,OAAO,EAAwC,KAAK,aAAa,EAAE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAA;AAKhG,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,SAAS,CAAA;IAGnB,6EAA6E;IAC7E,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,gEAAgE;IAChE,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,mEAAmE;IACnE,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,6CAA6C;IAC7C,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,uEAAuE;IACvE,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,8EAA8E;IAC9E,UAAU,CAAC,EAAE,MAAM,CAAA;IAGnB,mDAAmD;IACnD,KAAK,CAAC,EAAE,OAAO,CAAA;IACf;;2EAEuE;IACvE,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;mFAC+E;IAC/E,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB;;uEAEmE;IACnE,KAAK,CAAC,EAAE,MAAM,CAAA;IAGd,uEAAuE;IACvE,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,0CAA0C;IAC1C,cAAc,CAAC,EAAE,MAAM,CAAA;IAEvB,oEAAoE;IACpE,eAAe,CAAC,EAAE,MAAM,CAAA;IAGxB,yDAAyD;IACzD,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;IACvB,2DAA2D;IAC3D,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;IAExB,uEAAuE;IACvE,KAAK,CAAC,EAAE,OAAO,CAAA;IAEf,oDAAoD;IACpD,KAAK,CAAC,EAAE,aAAa,CAAA;CACtB;AAwND,wBAAgB,YAAY,CAAC,KAAK,EAAE,iBAAiB,+BAuWpD"}