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
@@ -1,7 +1,11 @@
1
1
  'use client'
2
2
 
3
3
  /**
4
- * AngledScreen — CSS 3D perspective wrapper with fake bokeh depth-of-field.
4
+ * BasicAngledScreen — CSS 3D perspective wrapper with fake bokeh depth-of-field.
5
+ *
6
+ * The shader-based version (true depth-of-field via WebGL) lives in
7
+ * angled-screen-shader.tsx and is exported as AngledScreen. This CSS version
8
+ * is kept as a lightweight fallback that works without HTML-in-canvas support.
5
9
  *
6
10
  * Wraps any children (images, videos, divs) in a 3D-transformed plane using
7
11
  * CSS `perspective` + `transform: rotateX/Y/Z`. Simulates depth-of-field
@@ -23,7 +27,7 @@
23
27
  import { type CSSProperties, type ReactNode } from 'react'
24
28
  import { useTweakpane } from './tweakpane-hook.tsx'
25
29
 
26
- export interface AngledScreenProps {
30
+ export interface BasicAngledScreenProps {
27
31
  children: ReactNode
28
32
 
29
33
  // --- 3D transform ---
@@ -91,7 +95,12 @@ function autoDirection(rotateX: number, rotateY: number): 'top' | 'bottom' | 'le
91
95
  return rotateX > 0 ? 'bottom' : 'top'
92
96
  }
93
97
 
94
- export function AngledScreen(props: AngledScreenProps) {
98
+ /**
99
+ * @deprecated Use `AngledScreen` (angled-screen-shader.tsx) instead — the
100
+ * WebGL version with true depth-of-field. This CSS version only remains as
101
+ * the automatic fallback for browsers without HTML-in-canvas support.
102
+ */
103
+ export function BasicAngledScreen(props: BasicAngledScreenProps) {
95
104
  const {
96
105
  children,
97
106
  transformOrigin = '50% 50%',
@@ -102,7 +111,7 @@ export function AngledScreen(props: AngledScreenProps) {
102
111
  style,
103
112
  } = props
104
113
 
105
- const tp = useTweakpane('AngledScreen', {
114
+ const tp = useTweakpane('BasicAngledScreen', {
106
115
  perspective: { value: props.perspective ?? 1200, min: 100, max: 3000, step: 10 },
107
116
  rotateX: { value: props.rotateX ?? 8, min: -90, max: 90, step: 0.5 },
108
117
  rotateY: { value: props.rotateY ?? -12, min: -90, max: 90, step: 0.5 },
@@ -32,8 +32,8 @@
32
32
  * - Cloudflare frame: https://github.com/raycast/ray-so/blob/main/app/(navigation)/(code)/components/frames/CloudflareFrame.tsx
33
33
  */
34
34
 
35
- import { createContext, useContext, useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react'
36
- import { interpolate, spring, useCurrentFrame, useVideoConfig } from 'remotion'
35
+ import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react'
36
+ import { interpolate, spring, useCurrentFrame, useDelayRender, useVideoConfig } from 'remotion'
37
37
  import { useTweakpane } from './tweakpane-hook.tsx'
38
38
 
39
39
  // ---------------------------------------------------------------------------
@@ -422,12 +422,30 @@ function detectLanguage(title?: string, language?: string): string {
422
422
 
423
423
  function useHighlightedHtml(code: string, lang: string, highlightLines: number[]): string {
424
424
  const [html, setHtml] = useState('')
425
-
426
- useEffect(() => {
425
+ const { delayRender, continueRender } = useDelayRender()
426
+
427
+ // delayRender registered during the first render (useState initializer) and
428
+ // continued only AFTER the highlighted html has been committed to the DOM
429
+ // (layout effect below). Continuing from inside the shiki .then() is too
430
+ // early: React batches the setHtml update, so the renderer could capture the
431
+ // frame between continueRender and the commit, producing an empty block in
432
+ // screenshots/filmstrips/exports.
433
+ const [handle] = useState(() => delayRender('shiki highlight'))
434
+ useLayoutEffect(() => {
435
+ if (html) continueRender(handle)
436
+ }, [html, handle, continueRender])
437
+ // Unmount safety: never leave a dangling handle.
438
+ useLayoutEffect(() => {
439
+ return () => continueRender(handle)
440
+ }, [handle, continueRender])
441
+
442
+ useLayoutEffect(() => {
427
443
  let cancelled = false
428
444
 
429
445
  void getHighlighter().then((h) => {
430
- if (cancelled) return
446
+ if (cancelled) {
447
+ return
448
+ }
431
449
 
432
450
  // Load language on demand if not already loaded
433
451
  const loaded = h.getLoadedLanguages()
@@ -449,9 +467,13 @@ function useHighlightedHtml(code: string, lang: string, highlightLines: number[]
449
467
  }],
450
468
  })
451
469
  if (!cancelled) setHtml(result)
470
+ }).catch(() => {
471
+ continueRender(handle)
452
472
  })
453
473
 
454
- return () => { cancelled = true }
474
+ return () => {
475
+ cancelled = true
476
+ }
455
477
  }, [code, lang, highlightLines.join(',')])
456
478
 
457
479
  return html
@@ -687,7 +687,7 @@ export function LayoutEditor({ playerContainerRef, playerRef, editing, onEditing
687
687
  <div data-layout-editor-toolbar className='flex items-center gap-1.5 text-[13px]'>
688
688
  <ToolbarButton onClick={() => onEditingChange(!editing)} active={editing}
689
689
  title={editing ? 'Exit editing mode' : 'Edit layout — click to select, drag to move, corners to scale, double-click text to edit'}>
690
- {editing ? '✦ Editing' : '✦ Edit Layout'}
690
+ {editing ? 'Editing' : 'Edit'}
691
691
  </ToolbarButton>
692
692
 
693
693
  {editing && changesCount > 0 && (
@@ -14,7 +14,6 @@ import { mdxParse, extractImports, resolveModulePath } from 'safe-mdx/parse'
14
14
  import type { EagerModules } from 'safe-mdx/parse'
15
15
  import { MdastToJsx } from 'safe-mdx'
16
16
  import { splitIntoSections, calculateTotalDuration, resolveAutoDurations, parseFrontmatter, aspectRatioFromDimensions, buildMdxScope } from './mdx-parse.ts'
17
- import { findChangedSectionIndex } from './vite-plugin.ts'
18
17
  import { computeEffectiveDuration } from './media-duration-store.ts'
19
18
  import { findServerNodes, blankServerContents, collectServerImportSources, wrapGenerateNodes, collectServerFileImportNames } from './server-mdx.ts'
20
19
  import { stableJsonKey, hashKey, promptPrefix } from './server-components.tsx'
@@ -28,6 +27,7 @@ describe('MDX_BUILTIN_COMPONENTS', () => {
28
27
  "Audio",
29
28
  "Background",
30
29
  "BandsShader",
30
+ "BasicAngledScreen",
31
31
  "Blur",
32
32
  "BlurReveal",
33
33
  "CodeBlock",
@@ -1978,99 +1978,6 @@ describe('computeEffectiveDuration with startInFrames', () => {
1978
1978
  })
1979
1979
  })
1980
1980
 
1981
- describe('findChangedSectionIndex', () => {
1982
- const base = [
1983
- '---',
1984
- 'fps: 30',
1985
- 'bpm: 120',
1986
- '---',
1987
- '',
1988
- '# Intro duration=3s',
1989
- '',
1990
- 'Hello world',
1991
- '',
1992
- '# Middle duration=2s',
1993
- '',
1994
- 'Some content here',
1995
- '',
1996
- '# Outro duration=1s',
1997
- '',
1998
- 'Goodbye',
1999
- ].join('\n')
2000
-
2001
- test('identical content returns null', () => {
2002
- expect(findChangedSectionIndex(base, base)).toBe(null)
2003
- })
2004
-
2005
- test('editing first section returns 0', () => {
2006
- const edited = base.replace('Hello world', 'Hello EDITED')
2007
- expect(findChangedSectionIndex(base, edited)).toBe(0)
2008
- })
2009
-
2010
- test('editing second section returns 1', () => {
2011
- const edited = base.replace('Some content here', 'New content')
2012
- expect(findChangedSectionIndex(base, edited)).toBe(1)
2013
- })
2014
-
2015
- test('editing last section returns 2', () => {
2016
- const edited = base.replace('Goodbye', 'See ya')
2017
- expect(findChangedSectionIndex(base, edited)).toBe(2)
2018
- })
2019
-
2020
- test('adding a new section returns its index', () => {
2021
- const added = base + '\n\n# Bonus duration=1s\n\nExtra stuff'
2022
- expect(findChangedSectionIndex(base, added)).toBe(3)
2023
- })
2024
-
2025
- test('deleting last section returns null (skip deletions)', () => {
2026
- const deleted = base.replace('\n# Outro duration=1s\n\nGoodbye', '')
2027
- expect(findChangedSectionIndex(base, deleted)).toBe(null)
2028
- })
2029
-
2030
- test('deleting middle section returns null (skip deletions)', () => {
2031
- const deleted = base.replace('\n# Middle duration=2s\n\nSome content here\n', '\n')
2032
- expect(findChangedSectionIndex(base, deleted)).toBe(null)
2033
- })
2034
-
2035
- test('editing heading text returns that section index', () => {
2036
- const edited = base.replace('# Middle duration=2s', '# Middle Renamed duration=2s')
2037
- expect(findChangedSectionIndex(base, edited)).toBe(1)
2038
- })
2039
-
2040
- test('preamble-only change returns null', () => {
2041
- const withPreamble = '<Audio src="/bg.mp3" />\n\n' + base.split('\n').slice(4).join('\n')
2042
- const oldPreamble = '---\nfps: 30\nbpm: 120\n---\n\n' + withPreamble
2043
- const newPreamble = oldPreamble.replace('/bg.mp3', '/new-bg.mp3')
2044
- expect(findChangedSectionIndex(oldPreamble, newPreamble)).toBe(null)
2045
- })
2046
-
2047
- test('frontmatter-only change returns null', () => {
2048
- const edited = base.replace('fps: 30', 'fps: 60')
2049
- expect(findChangedSectionIndex(base, edited)).toBe(null)
2050
- })
2051
-
2052
- test('editing heading duration returns that section index', () => {
2053
- const edited = base.replace('duration=3s', 'duration=4s')
2054
- expect(findChangedSectionIndex(base, edited)).toBe(0)
2055
- })
2056
-
2057
- test('editing heading transition returns that section index', () => {
2058
- const old = '# Intro duration=3s transition=10\n\nHello'
2059
- const edited = '# Intro duration=3s transition=20\n\nHello'
2060
- expect(findChangedSectionIndex(old, edited)).toBe(0)
2061
- })
2062
-
2063
- test('broken MDX returns null (does not throw)', () => {
2064
- const valid = '# Intro duration=3s\n\nHello world'
2065
- const broken = '# Intro duration=3s\n\n<Component'
2066
- expect(findChangedSectionIndex(valid, broken)).toBe(null)
2067
- })
2068
-
2069
- test('both sources broken returns null', () => {
2070
- expect(findChangedSectionIndex('<Foo', '<Bar')).toBe(null)
2071
- })
2072
- })
2073
-
2074
1981
  // ---------------------------------------------------------------------------
2075
1982
  // Parse error recovery
2076
1983
  // ---------------------------------------------------------------------------
@@ -29,7 +29,8 @@ import {
29
29
  StaggeredFadeUp,
30
30
  ShimmerSweep,
31
31
  } from './components.tsx'
32
- import { AngledScreen } from './angled-screen.tsx'
32
+ import { BasicAngledScreen } from './angled-screen.tsx'
33
+ import { AngledScreen } from './angled-screen-shader.tsx'
33
34
  import { CodeBlock, CODE_THEMES } from './code-block.tsx'
34
35
  import { BandsShader } from './bands-shader.tsx'
35
36
  import { WaveGradientShader } from './wave-gradient-shader.tsx'
@@ -924,6 +925,7 @@ export {
924
925
  StaggeredFadeUp,
925
926
  ShimmerSweep,
926
927
  AngledScreen,
928
+ BasicAngledScreen,
927
929
  CodeBlock,
928
930
  CODE_THEMES,
929
931
  BandsShader,
@@ -953,6 +955,7 @@ export const MDX_BUILTIN_COMPONENTS = {
953
955
  Background,
954
956
  LayoutTransition,
955
957
  AngledScreen,
958
+ BasicAngledScreen,
956
959
  BandsShader,
957
960
  WaveGradientShader,
958
961
  LiquidGradientShader,
@@ -27,7 +27,7 @@ import { renderInBrowser } from './render-client.ts'
27
27
  import { createSpiceflowFetch } from 'spiceflow/client'
28
28
  import type { app } from './app.tsx'
29
29
  import { egakiStore } from './store.ts'
30
- import { useGenerationStatus, useGenerationErrors, type GenerationStatus } from './store-hooks.ts'
30
+ import { useGenerationStatus, useGenerationErrors, useSidebarOpen, type GenerationStatus } from './store-hooks.ts'
31
31
  import { egakiSDK } from './sdk.ts'
32
32
  import { LayoutEditor, type SectionMeta } from './layout-editor.tsx'
33
33
  import { TweakpaneRoot, SIDEBAR_WIDTH } from './tweakpane-hook.tsx'
@@ -42,20 +42,6 @@ import {
42
42
  import { SectionIndexContext } from './media-duration-store.ts'
43
43
  import type { VideoFrontmatter } from './mdx-parse.ts'
44
44
 
45
- // ---------------------------------------------------------------------------
46
- // HMR scene-changed: store pending seek at module scope so it survives
47
- // component remounts caused by rsc:update (spiceflow payload swap).
48
- // The vite plugin sends egaki:scene-changed with { sectionIndex } when
49
- // the user edits a specific section of the entry MDX.
50
- // ---------------------------------------------------------------------------
51
- let pendingSceneSeek: { sectionIndex: number; file: string } | null = null
52
- /** Cancels the auto-pause listener from a previous scene-seek, so a new
53
- * HMR during playback doesn't leave stale listeners that pause the wrong scene. */
54
- let cancelPreviousAutoPlay: (() => void) | null = null
55
- /** Monotonic counter to invalidate stale requestAnimationFrame callbacks
56
- * when rapid successive HMR edits arrive before the first rAF fires. */
57
- let sceneSeekVersion = 0
58
-
59
45
  // ---------------------------------------------------------------------------
60
46
  // Generation progress client — fetches /api/generation-progress on page
61
47
  // load and on every rsc:update using the typed Spiceflow fetch client.
@@ -131,9 +117,6 @@ function connectToProgress() {
131
117
  if (typeof window !== 'undefined') connectToProgress()
132
118
 
133
119
  if (import.meta.hot) {
134
- import.meta.hot.on('egaki:scene-changed', (data: { sectionIndex: number; file: string }) => {
135
- pendingSceneSeek = data
136
- })
137
120
  // Reconnect on rsc:update — new generations may have started from
138
121
  // MDX edits that trigger server-side rendering of <Server> blocks.
139
122
  import.meta.hot.on('rsc:update', () => {
@@ -488,81 +471,6 @@ export function PlayerPage({
488
471
  })
489
472
  }, [Component, totalDuration, sections, fps, width, height, scale])
490
473
 
491
- // Consume pending scene seek after the RSC refetch delivers new sections.
492
- // The module-level pendingSceneSeek is set by import.meta.hot.on before
493
- // React re-renders; this effect fires after the commit with fresh data.
494
- // Seeks to the changed section's start frame, plays it, and pauses at
495
- // the section's end so the user sees exactly the content they edited.
496
- useEffect(() => {
497
- if (pendingSceneSeek == null) return
498
- // Ignore scene changes from a different entry MDX file.
499
- if (pendingSceneSeek.file !== entryPath) {
500
- pendingSceneSeek = null
501
- return
502
- }
503
- const idx = pendingSceneSeek.sectionIndex
504
- pendingSceneSeek = null
505
-
506
- // Wait one frame for the Remotion Player to initialize after remount.
507
- // Version token ensures stale rAF callbacks from rapid edits are ignored.
508
- const version = ++sceneSeekVersion
509
- requestAnimationFrame(() => {
510
- if (version !== sceneSeekVersion) return
511
- const player = playerRef.current
512
- if (!player || idx < 0 || idx >= sections.length) return
513
-
514
- // Compute section boundaries from current sections
515
- let startFrame = 0
516
- for (let i = 0; i < idx; i++) {
517
- startFrame += sections[i]!.durationInFrames
518
- }
519
- const endFrame = startFrame + sections[idx]!.durationInFrames - 1
520
-
521
- player.seekTo(startFrame)
522
-
523
- // Cancel any auto-pause from a previous HMR scene-seek so stale
524
- // listeners don't pause playback in the wrong section.
525
- cancelPreviousAutoPlay?.()
526
- cancelPreviousAutoPlay = null
527
-
528
- // Only auto-play if currently paused (after rsc:update remount
529
- // the player always starts paused at frame 0)
530
- if (!player.isPlaying()) {
531
- player.play()
532
-
533
- // Pause at section end so only the edited section plays.
534
- // If the user manually seeks (clicks timeline, arrow keys, etc.)
535
- // cancel the auto-pause so we don't randomly stop playback later.
536
- let cancelled = false
537
- const cleanup = () => {
538
- if (cancelled) return
539
- cancelled = true
540
- cancelPreviousAutoPlay = null
541
- player.removeEventListener('frameupdate', onFrame as any)
542
- player.removeEventListener('seeked', onSeek as any)
543
- }
544
- cancelPreviousAutoPlay = cleanup
545
- const onFrame = () => {
546
- if (cancelled) return
547
- if (player.getCurrentFrame() >= endFrame) {
548
- player.pause()
549
- cleanup()
550
- }
551
- }
552
- const onSeek = (e: any) => {
553
- // User seeked away — cancel auto-pause. Ignore seeks within
554
- // the target section range (could be our own seekTo settling).
555
- const frame = e?.detail?.frame ?? player.getCurrentFrame()
556
- if (frame < startFrame || frame > endFrame) {
557
- cleanup()
558
- }
559
- }
560
- player.addEventListener('frameupdate', onFrame as any)
561
- player.addEventListener('seeked', onSeek as any)
562
- }
563
- })
564
- }, [sections])
565
-
566
474
  // Defer Player mount to client only. Remotion's Player and all composition
567
475
  // components (Series, AbsoluteFill, Audio, Video) use React context provided
568
476
  // by Player at runtime. During SSR there's no Remotion context, so hooks like
@@ -573,6 +481,7 @@ export function PlayerPage({
573
481
 
574
482
  const generationStatus = useGenerationStatus()
575
483
  const generationErrors = useGenerationErrors()
484
+ const sidebarOpen = useSidebarOpen()
576
485
 
577
486
  const [editing, setEditing] = useState(false)
578
487
  const [resetKey, setResetKey] = useState(0)
@@ -961,17 +870,29 @@ export function PlayerPage({
961
870
  </div>
962
871
  </div>
963
872
 
964
- {/* Right sidebar — always visible, hosts tweakpane controls */}
873
+ {/* Right sidebar — collapsible, hosts tweakpane controls */}
965
874
  <TweakpaneRoot playerRef={playerRef} fps={fps} sections={sections} entryPath={entryPath} />
966
875
 
967
876
  {/* Floating toolbar — fixed at bottom, offset left to account for sidebar */}
968
877
  <div
969
878
  className='fixed bottom-6 flex items-center gap-1.5 rounded-full bg-[#1c1c1c] border border-white/10 px-2 py-1.5 shadow-2xl'
970
879
  style={{
971
- left: `calc((100% - ${SIDEBAR_WIDTH}px - 0.75rem) / 2)`,
880
+ left: sidebarOpen ? `calc((100% - ${SIDEBAR_WIDTH}px - 0.75rem) / 2)` : '50%',
972
881
  transform: 'translateX(-50%)',
973
882
  }}
974
883
  >
884
+ {/* Previous scene (Down) — first item in the toolbar */}
885
+ <button
886
+ onClick={goToPrevScene}
887
+ disabled={!hasPrevScene}
888
+ className='flex items-center justify-center rounded-full w-7 h-7 text-zinc-400 hover:text-zinc-200 hover:bg-white/5 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-default disabled:hover:bg-transparent disabled:hover:text-zinc-400'
889
+ title='Previous scene (↓)'
890
+ >
891
+ <ChevronDownIcon />
892
+ </button>
893
+
894
+ <ToolbarSeparator />
895
+
975
896
  {/* Entry selector — only shown when multiple MDX files exist */}
976
897
  {availableEntries.length > 1 && (
977
898
  <>
@@ -1046,28 +967,6 @@ export function PlayerPage({
1046
967
 
1047
968
  <ToolbarSeparator />
1048
969
 
1049
- {/* Scene navigation — prev (Down) / next (Up) */}
1050
- <div className='flex items-center'>
1051
- <button
1052
- onClick={goToPrevScene}
1053
- disabled={!hasPrevScene}
1054
- className='flex items-center justify-center rounded-full w-7 h-7 text-zinc-400 hover:text-zinc-200 hover:bg-white/5 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-default disabled:hover:bg-transparent disabled:hover:text-zinc-400'
1055
- title='Previous scene (↓)'
1056
- >
1057
- <ChevronDownIcon />
1058
- </button>
1059
- <button
1060
- onClick={goToNextScene}
1061
- disabled={!hasNextScene}
1062
- className='flex items-center justify-center rounded-full w-7 h-7 text-zinc-400 hover:text-zinc-200 hover:bg-white/5 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-default disabled:hover:bg-transparent disabled:hover:text-zinc-400'
1063
- title='Next scene (↑)'
1064
- >
1065
- <ChevronUpIcon />
1066
- </button>
1067
- </div>
1068
-
1069
- <ToolbarSeparator />
1070
-
1071
970
  <LayoutEditor
1072
971
  playerContainerRef={playerContainerRef}
1073
972
  playerRef={playerRef}
@@ -1101,6 +1000,18 @@ export function PlayerPage({
1101
1000
  </div>
1102
1001
  </>
1103
1002
  )}
1003
+
1004
+ <ToolbarSeparator />
1005
+
1006
+ {/* Next scene (Up) — last item in the toolbar */}
1007
+ <button
1008
+ onClick={goToNextScene}
1009
+ disabled={!hasNextScene}
1010
+ className='flex items-center justify-center rounded-full w-7 h-7 text-zinc-400 hover:text-zinc-200 hover:bg-white/5 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-default disabled:hover:bg-transparent disabled:hover:text-zinc-400'
1011
+ title='Next scene (↑)'
1012
+ >
1013
+ <ChevronUpIcon />
1014
+ </button>
1104
1015
  </div>
1105
1016
  </div>
1106
1017
  )
@@ -9,32 +9,39 @@ import { ExportContext } from './mdx-video.tsx'
9
9
  import React from 'react'
10
10
 
11
11
  /**
12
- * Cover the web-renderer scaffold during export. The scaffold wrapper has
13
- * position:fixed + visibility:hidden + z-index:-9999, but allowHtmlInCanvas
14
- * sets visibility:visible on the inner layoutsubtree canvas, which can
15
- * make it peek through on the page.
12
+ * Wrap a composition for web-renderer entry points (export, screenshot,
13
+ * filmstrip). The scaffold wrapper is visibility:hidden and Chromium creates
14
+ * no paint records for hidden subtrees, which breaks captureElementImage()
15
+ * inside nested <HtmlInCanvas> (AngledScreen): onInit/onPaint silently never
16
+ * run and exports come out flat. visibility is overridable by descendants,
17
+ * so a visibility:visible div at the composition root restores paint records
18
+ * for the whole tree while deeper visibility:hidden ancestors (e.g.
19
+ * LayoutTransition's inactive instances) keep hiding their subtrees
20
+ * relative to this root.
16
21
  *
17
- * Do NOT modify the scaffold wrapper's own styles (translate, clip-path,
18
- * width/height, opacity). All of those break Chrome's drawElementImage
19
- * paint pipeline and produce black frames in the exported video. The
20
- * layout subtree canvas must remain at full size, in its original position,
21
- * with normal visibility for the capture to work.
22
+ * Tradeoff (accepted): the scaffold sits at z-index:-9999, painting above
23
+ * the page's root background but below all in-flow content — so the
24
+ * rendering composition can peek through wherever the page shows only the
25
+ * body background. Hiding it differently is not possible:
26
+ * - opacity:0 / filter:opacity(0) bake into captureElementImage pixels
27
+ * (verified: capture succeeds but frames come out fully transparent)
28
+ * - translate/clip-path/resize on the scaffold break the drawElementImage
29
+ * paint pipeline (black frames — learned June 2026, commit e501ca5)
30
+ * - visibility:hidden kills paint records entirely (the original bug)
31
+ * An opaque page background or full-viewport player UI fully occludes it.
22
32
  *
23
- * Instead we place an opaque overlay on top (z-index:-9998, one above the
24
- * scaffold's -9999) that visually hides the rendering without touching
25
- * the scaffold's layout or paint properties.
33
+ * TODO: re-evaluate whether this wrapper is still needed once remotion
34
+ * fixes the HtmlInCanvas delayRender scoping / hidden-scaffold capture bug:
35
+ * https://github.com/remotion-dev/remotion/issues/9367
26
36
  */
27
- function injectScaffoldCover(): () => void {
28
- const cover = document.createElement('div')
29
- cover.style.cssText = [
30
- 'position:fixed',
31
- 'inset:0',
32
- 'z-index:-9998',
33
- 'background:#050505',
34
- 'pointer-events:none',
35
- ].join(';')
36
- document.body.appendChild(cover)
37
- return () => cover.remove()
37
+ export function wrapForWebRenderer(component: React.FC): React.FC {
38
+ const Wrapped: React.FC = () =>
39
+ React.createElement(
40
+ 'div',
41
+ { style: { visibility: 'visible', position: 'relative', width: '100%', height: '100%' } },
42
+ React.createElement(component),
43
+ )
44
+ return Wrapped
38
45
  }
39
46
 
40
47
  export async function renderInBrowser(options: {
@@ -48,37 +55,31 @@ export async function renderInBrowser(options: {
48
55
  onProgress?: (progress: number) => void
49
56
  signal?: AbortSignal
50
57
  }) {
51
- const removeCover = injectScaffoldCover()
52
-
53
58
  const ExportWrapped: React.FC = () =>
54
59
  React.createElement(ExportContext.Provider, { value: true },
55
60
  React.createElement(options.component))
56
61
 
57
- try {
58
- const { getBlob } = await renderMediaOnWeb({
59
- composition: {
60
- component: ExportWrapped,
61
- durationInFrames: options.durationInFrames,
62
- fps: options.fps ?? 30,
63
- width: options.width ?? 1920,
64
- height: options.height ?? 1080,
65
- id: 'MdxVideo',
66
- calculateMetadata: null,
67
- },
68
- inputProps: {},
69
- container: 'mp4',
70
- videoCodec: 'h264',
71
- videoBitrate: 'high',
72
- allowHtmlInCanvas: true,
73
- scale: options.scale,
74
- signal: options.signal,
75
- onProgress: ({ progress }) => {
76
- options.onProgress?.(progress)
77
- },
78
- })
62
+ const { getBlob } = await renderMediaOnWeb({
63
+ composition: {
64
+ component: wrapForWebRenderer(ExportWrapped),
65
+ durationInFrames: options.durationInFrames,
66
+ fps: options.fps ?? 30,
67
+ width: options.width ?? 1920,
68
+ height: options.height ?? 1080,
69
+ id: 'MdxVideo',
70
+ calculateMetadata: null,
71
+ },
72
+ inputProps: {},
73
+ container: 'mp4',
74
+ videoCodec: 'h264',
75
+ videoBitrate: 'high',
76
+ allowHtmlInCanvas: true,
77
+ scale: options.scale,
78
+ signal: options.signal,
79
+ onProgress: ({ progress }) => {
80
+ options.onProgress?.(progress)
81
+ },
82
+ })
79
83
 
80
- return getBlob()
81
- } finally {
82
- removeCover()
83
- }
84
+ return getBlob()
84
85
  }
package/src/vite/sdk.ts CHANGED
@@ -13,6 +13,7 @@
13
13
 
14
14
  import React from 'react'
15
15
  import { renderStillOnWeb, renderMediaOnWeb } from '@remotion/web-renderer'
16
+ import { wrapForWebRenderer } from './render-client.ts'
16
17
  import { ExportContext } from './mdx-video.tsx'
17
18
  import type {
18
19
  FrameRange,
@@ -403,7 +404,7 @@ class EgakiSDK {
403
404
 
404
405
  const still = await renderStillOnWeb({
405
406
  composition: {
406
- component: c.component,
407
+ component: wrapForWebRenderer(c.component),
407
408
  durationInFrames: c.totalDuration,
408
409
  fps: c.fps,
409
410
  width: c.width,
@@ -502,7 +503,7 @@ class EgakiSDK {
502
503
  for (let i = 0; i < framesToCapture.length; i++) {
503
504
  const still = await renderStillOnWeb({
504
505
  composition: {
505
- component: c.component,
506
+ component: wrapForWebRenderer(c.component),
506
507
  durationInFrames: c.totalDuration,
507
508
  fps: c.fps,
508
509
  width: c.width,
@@ -542,7 +543,7 @@ class EgakiSDK {
542
543
 
543
544
  const { getBlob } = await renderMediaOnWeb({
544
545
  composition: {
545
- component: this.wrapForExport(c.component),
546
+ component: wrapForWebRenderer(this.wrapForExport(c.component)),
546
547
  durationInFrames: c.totalDuration,
547
548
  fps: c.fps,
548
549
  width: c.width,
@@ -71,7 +71,7 @@ export interface ShaderConfig {
71
71
  // ---------------------------------------------------------------------------
72
72
 
73
73
  /** Parse a hex color string (#RGB, #RRGGBB, #RRGGBBAA) to [r, g, b, a] in 0-1 range. */
74
- function parseHexColor(hex: string): [number, number, number, number] {
74
+ export function parseHexColor(hex: string): [number, number, number, number] {
75
75
  let h = hex.replace('#', '')
76
76
  if (h.length === 3 || h.length === 4) {
77
77
  h = h
@@ -172,7 +172,7 @@ function generateFragmentSource(
172
172
  // WebGL helpers
173
173
  // ---------------------------------------------------------------------------
174
174
 
175
- function compileGlShader(
175
+ export function compileGlShader(
176
176
  gl: WebGL2RenderingContext,
177
177
  type: number,
178
178
  source: string,
@@ -189,7 +189,7 @@ function compileGlShader(
189
189
  return shader
190
190
  }
191
191
 
192
- function linkGlProgram(
192
+ export function linkGlProgram(
193
193
  gl: WebGL2RenderingContext,
194
194
  vs: WebGLShader,
195
195
  fs: WebGLShader,