egaki 0.8.0 → 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.
@@ -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 },
@@ -27,6 +27,7 @@ describe('MDX_BUILTIN_COMPONENTS', () => {
27
27
  "Audio",
28
28
  "Background",
29
29
  "BandsShader",
30
+ "BasicAngledScreen",
30
31
  "Blur",
31
32
  "BlurReveal",
32
33
  "CodeBlock",
@@ -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,
@@ -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,
@@ -18,6 +18,14 @@
18
18
  * Tweakpane is pure DOM (no React dependency), so there are no dual-React
19
19
  * or SSR issues. During SSR (typeof window === 'undefined') the hook
20
20
  * returns the raw default values and skips all DOM work.
21
+ *
22
+ * Live props vs user overrides: each param follows the LIVE schema value
23
+ * passed on every render (so animated props like translateX={interpolate(...)}
24
+ * work) until the user touches that control in the pane. Once touched, the
25
+ * user's value wins for that key until the component remounts. Untouched
26
+ * controls are refreshed in the pane every render so animated values are
27
+ * visible while playing. Programmatic refreshes are guarded so tweakpane's
28
+ * change events (which fire on refresh() too) don't mark keys as touched.
21
29
  */
22
30
 
23
31
  import { useCallback, useEffect, useRef, useState, useSyncExternalStore, createContext, useContext } from 'react'
@@ -129,6 +137,10 @@ interface FolderEntry {
129
137
  params: Record<string, unknown>
130
138
  /** The original default values for diffing. */
131
139
  defaults: Record<string, unknown>
140
+ /** Keys the user changed via the pane UI. Only these count as "changed"
141
+ * in the copy prompt — untouched keys track live (possibly animated)
142
+ * props, so diffing them against mount-time defaults would be noise. */
143
+ touched: Set<string>
132
144
  }
133
145
  const activeFolders = new Map<string, FolderEntry>()
134
146
 
@@ -146,6 +158,22 @@ function cloneValue<T>(value: T): T {
146
158
  return value
147
159
  }
148
160
 
161
+ /** Deep-equal for param values: primitives, small arrays (bezier tuples),
162
+ * and flat objects (range {min, max}). */
163
+ function valuesEqual(a: unknown, b: unknown): boolean {
164
+ if (a === b) return true
165
+ if (Array.isArray(a) && Array.isArray(b)) {
166
+ return a.length === b.length && a.every((x, i) => x === b[i])
167
+ }
168
+ if (typeof a === 'object' && a !== null && typeof b === 'object' && b !== null) {
169
+ const ra = a as Record<string, unknown>
170
+ const rb = b as Record<string, unknown>
171
+ const ka = Object.keys(ra)
172
+ return ka.length === Object.keys(rb).length && ka.every((k) => ra[k] === rb[k])
173
+ }
174
+ return false
175
+ }
176
+
149
177
  function resolveDefault(v: ParamValue): number | boolean | string | [number, number, number, number] | { min: number; max: number } {
150
178
  if (isCubicBezierParam(v)) return [...v.value]
151
179
  if (isRangeParam(v)) return { ...v.value }
@@ -279,7 +307,10 @@ function serializeTweakpanePrompt(
279
307
  const changed: string[] = []
280
308
  for (const [key, current] of Object.entries(entry.params)) {
281
309
  const def = entry.defaults[key]
282
- if (current !== def) {
310
+ // Only user-touched keys count as changed. Untouched keys mirror live
311
+ // (possibly animated) props, so comparing them to mount-time defaults
312
+ // would produce false positives on every playing frame.
313
+ if (entry.touched.has(key) && !valuesEqual(current, def)) {
283
314
  changed.push(` ${key}: ${formatValue(current)}`)
284
315
  }
285
316
  }
@@ -380,6 +411,12 @@ function CopyChangesButton() {
380
411
  * Returns a reactive object with current values. Updating a control in the
381
412
  * pane immediately triggers a React re-render with fresh values.
382
413
  *
414
+ * Values follow live props: each key returns the schema value from the
415
+ * CURRENT render until the user touches that control in the pane, so
416
+ * animated props (e.g. `translateX={interpolate(frame, ...)}`) flow through
417
+ * every frame. Once touched, the user's pane value wins for that key until
418
+ * the component remounts.
419
+ *
383
420
  * On unmount the folder is disposed and removed from the pane.
384
421
  */
385
422
  export function useTweakpane<T extends ParamSchema>(
@@ -408,6 +445,22 @@ export function useTweakpane<T extends ParamSchema>(
408
445
  const schemaRef = useRef(schema)
409
446
  schemaRef.current = schema
410
447
 
448
+ // Keys the user changed via the pane UI. Untouched keys follow the live
449
+ // schema value on every render (so animated props work); touched keys keep
450
+ // the user's pane value until remount.
451
+ const touchedRef = useRef<Set<string>>(new Set())
452
+ // Latest user override values, mirrored from state so the mount effect can
453
+ // seed rebuilt folders without re-running on every pane change.
454
+ const valuesRef = useRef(values)
455
+ valuesRef.current = values
456
+ // Live handles to the mounted folder + bound params, used by the per-render
457
+ // sync effect below to push animated prop values into the pane.
458
+ const liveRef = useRef<{ folder: import('tweakpane').FolderApi; params: Record<string, unknown> } | null>(null)
459
+ // True while we call folder.refresh() programmatically. tweakpane emits
460
+ // change events on refresh() too, so handlers must ignore those or every
461
+ // animated frame would mark keys as user-touched.
462
+ const refreshingRef = useRef(false)
463
+
411
464
  // Stable ID for this hook instance
412
465
  const idRef = useRef(`${label}-${Math.random().toString(36).slice(2, 8)}`)
413
466
 
@@ -425,7 +478,10 @@ export function useTweakpane<T extends ParamSchema>(
425
478
  const def = resolveDefault(param)
426
479
  // Clone object/array values so tweakpane's in-place mutations on params
427
480
  // don't corrupt the defaults (used by "Copy changes" diffing).
428
- params[key] = cloneValue(def)
481
+ // Touched keys keep the user's override when the folder is rebuilt
482
+ // (e.g. premount → active transition re-runs this effect).
483
+ const touched = touchedRef.current.has(key)
484
+ params[key] = cloneValue(touched ? valuesRef.current[key] : def)
429
485
  defaults[key] = cloneValue(def)
430
486
  }
431
487
 
@@ -449,6 +505,7 @@ export function useTweakpane<T extends ParamSchema>(
449
505
  picker: 'inline',
450
506
  } as any)
451
507
  ;(blade as any).on('change', (ev: any) => {
508
+ if (refreshingRef.current) return
452
509
  const val = ev.value
453
510
  const tuple: [number, number, number, number] = [
454
511
  Math.round(val.x1 * 1000) / 1000,
@@ -457,6 +514,7 @@ export function useTweakpane<T extends ParamSchema>(
457
514
  Math.round(val.y2 * 1000) / 1000,
458
515
  ]
459
516
  params[key] = tuple
517
+ touchedRef.current.add(key)
460
518
  setValues((prev) => ({ ...prev, [key]: tuple }))
461
519
  })
462
520
  continue
@@ -468,7 +526,9 @@ export function useTweakpane<T extends ParamSchema>(
468
526
  max: param.max,
469
527
  step: param.step,
470
528
  }).on('change', (ev) => {
529
+ if (refreshingRef.current) return
471
530
  const val = ev.value as { min: number; max: number }
531
+ touchedRef.current.add(key)
472
532
  setValues((prev) => ({ ...prev, [key]: { min: val.min, max: val.max } }))
473
533
  })
474
534
  continue
@@ -497,17 +557,22 @@ export function useTweakpane<T extends ParamSchema>(
497
557
  }
498
558
 
499
559
  folder.addBinding(params, key, opts).on('change', (ev) => {
560
+ if (refreshingRef.current) return
561
+ touchedRef.current.add(key)
500
562
  setValues((prev) => ({ ...prev, [key]: ev.value }))
501
563
  })
502
564
  }
503
565
 
566
+ liveRef.current = { folder, params }
567
+
504
568
  // Register for copy prompt
505
- activeFolders.set(id, { label, params, defaults })
569
+ activeFolders.set(id, { label, params, defaults, touched: touchedRef.current })
506
570
  notifyFolderListeners()
507
571
  })()
508
572
 
509
573
  return () => {
510
574
  disposed = true
575
+ liveRef.current = null
511
576
  if (folder) {
512
577
  folder.dispose()
513
578
  folder = null
@@ -522,7 +587,40 @@ export function useTweakpane<T extends ParamSchema>(
522
587
  }
523
588
  }, [label, skip])
524
589
 
525
- return values as ResolvedValues<T>
590
+ // Push live (possibly animated) prop values into the pane for untouched
591
+ // keys. Runs after every render — during playback the component re-renders
592
+ // each frame, so animated values are visible moving in the pane UI.
593
+ // folder.refresh() emits change events; refreshingRef makes handlers
594
+ // ignore them so refreshes never mark keys as touched.
595
+ useEffect(() => {
596
+ const live = liveRef.current
597
+ if (!live) return
598
+ let dirty = false
599
+ for (const [key, param] of Object.entries(schemaRef.current)) {
600
+ if (touchedRef.current.has(key)) continue
601
+ // Bezier blades are not bindings — refresh() won't read params for
602
+ // them. The returned value still follows live props below.
603
+ if (isCubicBezierParam(param)) continue
604
+ const liveVal = resolveDefault(param)
605
+ if (!valuesEqual(live.params[key], liveVal)) {
606
+ live.params[key] = cloneValue(liveVal)
607
+ dirty = true
608
+ }
609
+ }
610
+ if (dirty) {
611
+ refreshingRef.current = true
612
+ live.folder.refresh()
613
+ refreshingRef.current = false
614
+ }
615
+ })
616
+
617
+ // Merge: user-touched keys return the pane value, untouched keys follow
618
+ // the live schema value so animated props work without any extra wiring.
619
+ const merged: Record<string, unknown> = {}
620
+ for (const [key, param] of Object.entries(schema)) {
621
+ merged[key] = touchedRef.current.has(key) ? values[key] : resolveDefault(param)
622
+ }
623
+ return merged as ResolvedValues<T>
526
624
  }
527
625
 
528
626
  // ---------------------------------------------------------------------------