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.
- package/CHANGELOG.md +52 -0
- package/dist/cli/bpm-detect.d.ts +7 -0
- package/dist/cli/bpm-detect.d.ts.map +1 -0
- package/dist/cli/bpm-detect.js +123 -0
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +115 -0
- package/dist/cli/loudness-detect.d.ts +13 -0
- package/dist/cli/loudness-detect.d.ts.map +1 -0
- package/dist/cli/loudness-detect.js +96 -0
- package/dist/vite/angled-screen-shader.d.ts +45 -0
- package/dist/vite/angled-screen-shader.d.ts.map +1 -0
- package/dist/vite/angled-screen-shader.js +573 -0
- package/dist/vite/angled-screen.d.ts +12 -3
- package/dist/vite/angled-screen.d.ts.map +1 -1
- package/dist/vite/angled-screen.js +12 -3
- package/dist/vite/code-block.d.ts.map +1 -1
- package/dist/vite/code-block.js +26 -5
- package/dist/vite/layout-editor.js +1 -1
- package/dist/vite/mdx-video.d.ts +4 -2
- package/dist/vite/mdx-video.d.ts.map +1 -1
- package/dist/vite/mdx-video.js +4 -2
- package/dist/vite/mdx-video.test.js +1 -79
- package/dist/vite/player-page.d.ts.map +1 -1
- package/dist/vite/player-page.js +6 -94
- package/dist/vite/render-client.d.ts +27 -0
- package/dist/vite/render-client.d.ts.map +1 -1
- package/dist/vite/render-client.js +47 -51
- package/dist/vite/sdk.d.ts.map +1 -1
- package/dist/vite/sdk.js +4 -3
- package/dist/vite/shader-renderer.d.ts +4 -0
- package/dist/vite/shader-renderer.d.ts.map +1 -1
- package/dist/vite/shader-renderer.js +3 -3
- package/dist/vite/store-hooks.d.ts +2 -0
- package/dist/vite/store-hooks.d.ts.map +1 -1
- package/dist/vite/store-hooks.js +8 -0
- package/dist/vite/store.d.ts +5 -0
- package/dist/vite/store.d.ts.map +1 -1
- package/dist/vite/store.js +5 -0
- package/dist/vite/tweakpane-hook.d.ts +12 -3
- package/dist/vite/tweakpane-hook.d.ts.map +1 -1
- package/dist/vite/tweakpane-hook.js +128 -20
- package/dist/vite/vite-plugin.d.ts +0 -6
- package/dist/vite/vite-plugin.d.ts.map +1 -1
- package/dist/vite/vite-plugin.js +1 -92
- package/package.json +9 -6
- package/skills/egaki/SKILL.md +26 -224
- package/src/cli/bpm-detect.ts +165 -0
- package/src/cli/cli.ts +149 -0
- package/src/cli/loudness-detect.ts +129 -0
- package/src/vite/angled-screen-shader.tsx +706 -0
- package/src/vite/angled-screen.tsx +13 -4
- package/src/vite/code-block.tsx +28 -6
- package/src/vite/layout-editor.tsx +1 -1
- package/src/vite/mdx-video.test.tsx +1 -94
- package/src/vite/mdx-video.tsx +4 -1
- package/src/vite/player-page.tsx +28 -117
- package/src/vite/render-client.ts +52 -51
- package/src/vite/sdk.ts +4 -3
- package/src/vite/shader-renderer.tsx +3 -3
- package/src/vite/store-hooks.ts +11 -0
- package/src/vite/store.ts +10 -0
- package/src/vite/tweakpane-hook.tsx +173 -28
- package/src/vite/vite-plugin.ts +1 -91
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
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
|
|
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
|
-
|
|
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('
|
|
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 },
|
package/src/vite/code-block.tsx
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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 () => {
|
|
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 ? '
|
|
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
|
// ---------------------------------------------------------------------------
|
package/src/vite/mdx-video.tsx
CHANGED
|
@@ -29,7 +29,8 @@ import {
|
|
|
29
29
|
StaggeredFadeUp,
|
|
30
30
|
ShimmerSweep,
|
|
31
31
|
} from './components.tsx'
|
|
32
|
-
import {
|
|
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,
|
package/src/vite/player-page.tsx
CHANGED
|
@@ -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 —
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
onProgress
|
|
76
|
-
|
|
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
|
-
|
|
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,
|