visualfries 0.1.10115 → 0.3.2

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 (139) hide show
  1. package/README.md +122 -0
  2. package/bin/visualfries.js +1570 -0
  3. package/dist/DIContainer.d.ts +2 -1
  4. package/dist/DIContainer.js +16 -5
  5. package/dist/SceneBuilder.svelte.d.ts +70 -1
  6. package/dist/SceneBuilder.svelte.js +96 -29
  7. package/dist/agent/audioMixer.d.ts +34 -0
  8. package/dist/agent/audioMixer.js +308 -0
  9. package/dist/agent/broll.d.ts +35 -0
  10. package/dist/agent/broll.js +226 -0
  11. package/dist/agent/capabilities.d.ts +34 -0
  12. package/dist/agent/capabilities.js +246 -0
  13. package/dist/agent/captionScene.d.ts +23 -0
  14. package/dist/agent/captionScene.js +204 -0
  15. package/dist/agent/catalog.d.ts +24 -0
  16. package/dist/agent/catalog.js +67 -0
  17. package/dist/agent/cueFiles.d.ts +54 -0
  18. package/dist/agent/cueFiles.js +219 -0
  19. package/dist/agent/cuePresets.d.ts +7 -0
  20. package/dist/agent/cuePresets.js +50 -0
  21. package/dist/agent/deterministicMedia.d.ts +61 -0
  22. package/dist/agent/deterministicMedia.js +303 -0
  23. package/dist/agent/index.d.ts +18 -0
  24. package/dist/agent/index.js +18 -0
  25. package/dist/agent/inspectScene.d.ts +36 -0
  26. package/dist/agent/inspectScene.js +139 -0
  27. package/dist/agent/localRender.d.ts +82 -0
  28. package/dist/agent/localRender.js +702 -0
  29. package/dist/agent/overlays.d.ts +40 -0
  30. package/dist/agent/overlays.js +325 -0
  31. package/dist/agent/productionPlan.d.ts +131 -0
  32. package/dist/agent/productionPlan.js +322 -0
  33. package/dist/agent/renderPlan.d.ts +27 -0
  34. package/dist/agent/renderPlan.js +68 -0
  35. package/dist/agent/renderRanges.d.ts +24 -0
  36. package/dist/agent/renderRanges.js +73 -0
  37. package/dist/agent/runtimeSupport.d.ts +19 -0
  38. package/dist/agent/runtimeSupport.js +192 -0
  39. package/dist/agent/streamingEncoder.d.ts +31 -0
  40. package/dist/agent/streamingEncoder.js +208 -0
  41. package/dist/agent/transcripts.d.ts +29 -0
  42. package/dist/agent/transcripts.js +146 -0
  43. package/dist/agent/transitions.d.ts +20 -0
  44. package/dist/agent/transitions.js +169 -0
  45. package/dist/animations/PixiAnimationTarget.d.ts +29 -0
  46. package/dist/animations/PixiAnimationTarget.js +63 -0
  47. package/dist/animations/builders/LineHighlighterAnimationBuilder.js +20 -3
  48. package/dist/animations/builders/WordHighlighterAnimationBuilder.js +15 -2
  49. package/dist/browser/browserExport.d.ts +73 -0
  50. package/dist/browser/browserExport.js +180 -0
  51. package/dist/browser/glTransitions.d.ts +37 -0
  52. package/dist/browser/glTransitions.js +261 -0
  53. package/dist/browser/index.d.ts +3 -0
  54. package/dist/browser/index.js +3 -0
  55. package/dist/browser/mediaDecode.d.ts +31 -0
  56. package/dist/browser/mediaDecode.js +152 -0
  57. package/dist/builders/PixiComponentBuilder.d.ts +6 -0
  58. package/dist/builders/PixiComponentBuilder.js +12 -0
  59. package/dist/builders/_ComponentState.svelte.d.ts +7 -5
  60. package/dist/builders/_ComponentState.svelte.js +29 -15
  61. package/dist/builders/html/StyleBuilder.js +9 -8
  62. package/dist/builders/html/TextComponentHtmlBuilder.js +4 -2
  63. package/dist/builders/html/processors/AppearanceStyleProcessor.js +1 -1
  64. package/dist/builders/html/processors/TextAppearanceStyleProcessor.js +7 -0
  65. package/dist/builders/html/processors/TextEffectsStyleProcessor.js +32 -13
  66. package/dist/commands/ReplaceSourceOnTimeCommand.js +9 -1
  67. package/dist/commands/SeekCommand.js +19 -6
  68. package/dist/components/AnimatedGIF.d.ts +3 -0
  69. package/dist/components/AnimatedGIF.js +28 -1
  70. package/dist/components/Component.svelte.d.ts +5 -5
  71. package/dist/components/Component.svelte.js +29 -13
  72. package/dist/components/ComponentContext.svelte.js +68 -23
  73. package/dist/components/hooks/AnimationHook.js +1 -1
  74. package/dist/components/hooks/CanvasFillHook.d.ts +12 -0
  75. package/dist/components/hooks/CanvasFillHook.js +75 -0
  76. package/dist/components/hooks/DeterministicMediaFrameHook.js +3 -0
  77. package/dist/components/hooks/HtmlTextHook.js +11 -3
  78. package/dist/components/hooks/HtmlToCanvasHook.js +8 -25
  79. package/dist/components/hooks/MediaHook.js +172 -47
  80. package/dist/components/hooks/MediaSeekingHook.js +138 -88
  81. package/dist/components/hooks/PixiGifHook.js +4 -4
  82. package/dist/components/hooks/PixiSplitScreenDisplayObjectHook.d.ts +3 -0
  83. package/dist/components/hooks/PixiSplitScreenDisplayObjectHook.js +85 -8
  84. package/dist/components/hooks/PixiVideoTextureHook.js +10 -4
  85. package/dist/components/hooks/PixiVisualTransformHook.d.ts +16 -0
  86. package/dist/components/hooks/PixiVisualTransformHook.js +102 -0
  87. package/dist/components/hooks/SubtitlesHook.js +48 -24
  88. package/dist/components/hooks/VerifyMediaHook.js +2 -2
  89. package/dist/directors/ComponentDirector.d.ts +1 -0
  90. package/dist/directors/ComponentDirector.js +41 -8
  91. package/dist/factories/SceneBuilderFactory.d.ts +3 -0
  92. package/dist/factories/SceneBuilderFactory.js +6 -0
  93. package/dist/fonts/fontDiscovery.js +12 -1
  94. package/dist/layers/Layer.svelte.d.ts +54 -0
  95. package/dist/layers/Layer.svelte.js +2 -25
  96. package/dist/managers/AppManager.svelte.d.ts +12 -0
  97. package/dist/managers/AppManager.svelte.js +180 -8
  98. package/dist/managers/ComponentsManager.svelte.d.ts +7 -7
  99. package/dist/managers/ComponentsManager.svelte.js +29 -15
  100. package/dist/managers/DeterministicMediaManager.d.ts +6 -0
  101. package/dist/managers/DeterministicMediaManager.js +57 -3
  102. package/dist/managers/EventManager.js +3 -1
  103. package/dist/managers/LayersManager.svelte.d.ts +54 -0
  104. package/dist/managers/RenderManager.d.ts +5 -0
  105. package/dist/managers/RenderManager.js +67 -21
  106. package/dist/managers/StateManager.svelte.d.ts +56 -0
  107. package/dist/managers/StateManager.svelte.js +60 -58
  108. package/dist/managers/SubtitlesManager.svelte.js +1 -3
  109. package/dist/managers/TimeManager.svelte.d.ts +4 -0
  110. package/dist/managers/TimeManager.svelte.js +23 -7
  111. package/dist/schemas/runtime/deterministic.d.ts +3 -0
  112. package/dist/schemas/runtime/deterministic.js +5 -1
  113. package/dist/schemas/runtime/types.d.ts +36 -15
  114. package/dist/schemas/scene/components.d.ts +126 -0
  115. package/dist/schemas/scene/components.js +11 -0
  116. package/dist/schemas/scene/core.d.ts +109 -10
  117. package/dist/schemas/scene/core.js +1 -1
  118. package/dist/utils/mediaWindow.d.ts +13 -0
  119. package/dist/utils/mediaWindow.js +25 -0
  120. package/dist/utils/svgGenerator.js +1 -1
  121. package/dist/utils/timelineWindow.d.ts +2 -0
  122. package/dist/utils/timelineWindow.js +7 -0
  123. package/dist/utils/utils.js +2 -4
  124. package/docs/AGENT_PATTERNS.md +243 -0
  125. package/docs/AGENT_WORKFLOW.md +364 -0
  126. package/docs/AUTHORING_BEST_PRACTICES.md +188 -0
  127. package/docs/AUTHORING_SYSTEM_AUDIT.md +98 -0
  128. package/docs/BROWSER_EXPORT_SPIKE.md +55 -0
  129. package/docs/DETERMINISTIC_RENDERER.md +83 -0
  130. package/docs/NEXT_RELEASE.md +28 -0
  131. package/docs/PROPOSED_VISUALFRIES_SKILL.md +68 -0
  132. package/docs/examples/agent-cues.json +49 -0
  133. package/docs/examples/agent-transcript.json +23 -0
  134. package/docs/examples/agent-transcript.srt +7 -0
  135. package/docs/examples/agent-transcript.vtt +7 -0
  136. package/docs/examples/image-animation.json +87 -0
  137. package/docs/examples/native-text-metric-badge.json +109 -0
  138. package/package.json +133 -100
  139. package/skills/visualfries/SKILL.md +328 -0
@@ -0,0 +1,169 @@
1
+ import { ComponentShape, SceneShape } from '../schemas/scene/index.js';
2
+ function transitionAnimation(id, cue, scene) {
3
+ const style = cue.style ?? 'dip-to-black';
4
+ const duration = cue.duration ?? 0.26;
5
+ const half = Math.max(0.04, duration / 2);
6
+ if (style === 'focus-pull') {
7
+ return {
8
+ id: `${id}-focus-pull`,
9
+ name: 'Agent transition focus pull',
10
+ animation: {
11
+ id: `${id}-focus-pull-preset`,
12
+ timeline: [
13
+ {
14
+ tweens: [
15
+ {
16
+ method: 'fromTo',
17
+ vars: {
18
+ from: { opacity: 0, scale: 0.82 },
19
+ duration: half,
20
+ opacity: 0.42,
21
+ scale: 1.08,
22
+ ease: 'power2.in'
23
+ }
24
+ }
25
+ ]
26
+ },
27
+ {
28
+ tweens: [
29
+ {
30
+ method: 'to',
31
+ vars: { duration: half, opacity: 0, scale: 1.22, ease: 'power2.out' }
32
+ }
33
+ ]
34
+ }
35
+ ]
36
+ }
37
+ };
38
+ }
39
+ if (style === 'swipe-left') {
40
+ return {
41
+ id: `${id}-swipe-left`,
42
+ name: 'Agent transition swipe left',
43
+ animation: {
44
+ id: `${id}-swipe-left-preset`,
45
+ timeline: [
46
+ {
47
+ tweens: [
48
+ {
49
+ method: 'fromTo',
50
+ vars: {
51
+ from: { x: scene.settings.width },
52
+ duration,
53
+ x: -scene.settings.width,
54
+ ease: 'power2.inOut'
55
+ }
56
+ }
57
+ ]
58
+ }
59
+ ]
60
+ }
61
+ };
62
+ }
63
+ if (style === 'swipe-up') {
64
+ return {
65
+ id: `${id}-swipe-up`,
66
+ name: 'Agent transition swipe up',
67
+ animation: {
68
+ id: `${id}-swipe-up-preset`,
69
+ timeline: [
70
+ {
71
+ tweens: [
72
+ {
73
+ method: 'fromTo',
74
+ vars: {
75
+ from: { y: scene.settings.height },
76
+ duration,
77
+ y: -scene.settings.height,
78
+ ease: 'power2.inOut'
79
+ }
80
+ }
81
+ ]
82
+ }
83
+ ]
84
+ }
85
+ };
86
+ }
87
+ return {
88
+ id: `${id}-fade`,
89
+ name: style === 'flash' ? 'Agent transition flash' : 'Agent transition dip',
90
+ animation: {
91
+ id: `${id}-fade-preset`,
92
+ timeline: [
93
+ {
94
+ tweens: [
95
+ {
96
+ method: 'fromTo',
97
+ vars: {
98
+ from: { opacity: 0 },
99
+ duration: half,
100
+ opacity: style === 'flash' ? 0.85 : 1,
101
+ ease: 'power1.out'
102
+ }
103
+ }
104
+ ]
105
+ },
106
+ {
107
+ tweens: [
108
+ {
109
+ method: 'to',
110
+ vars: {
111
+ duration: half,
112
+ opacity: 0,
113
+ ease: 'power1.in'
114
+ }
115
+ }
116
+ ]
117
+ }
118
+ ]
119
+ }
120
+ };
121
+ }
122
+ export function createAgentTransitionComponent(cue, scene, index = 0) {
123
+ const parsedScene = SceneShape.parse(scene);
124
+ const id = cue.id ?? `agent-transition-${index + 1}`;
125
+ const style = cue.style ?? 'dip-to-black';
126
+ const duration = cue.duration ?? 0.26;
127
+ const component = {
128
+ id,
129
+ name: `Agent Transition: ${style}`,
130
+ type: 'SHAPE',
131
+ shape: { type: 'rectangle', cornerRadius: 0 },
132
+ timeline: {
133
+ startAt: Math.max(0, cue.time - duration / 2),
134
+ endAt: cue.time + duration / 2
135
+ },
136
+ order: index,
137
+ appearance: {
138
+ x: 0,
139
+ y: 0,
140
+ width: parsedScene.settings.width,
141
+ height: parsedScene.settings.height,
142
+ opacity: 1,
143
+ rotation: 0,
144
+ scaleX: 1,
145
+ scaleY: 1,
146
+ color: cue.animated === false
147
+ ? 'transparent'
148
+ : (cue.color ?? (style === 'flash' || style === 'focus-pull' ? '#FFFFFF' : '#000000'))
149
+ },
150
+ animations: {
151
+ enabled: cue.animated !== false,
152
+ list: cue.animated === false ? [] : [transitionAnimation(id, cue, parsedScene)]
153
+ }
154
+ };
155
+ return ComponentShape.parse(component);
156
+ }
157
+ export function addAgentTransitions(input) {
158
+ const scene = SceneShape.parse(input.scene);
159
+ const layer = {
160
+ id: input.layerId ?? 'layer-agent-transitions',
161
+ name: input.layerName ?? 'Agent Transitions',
162
+ order: input.layerOrder ?? 95,
163
+ components: input.transitions.map((cue, index) => createAgentTransitionComponent(cue, scene, index))
164
+ };
165
+ return SceneShape.parse({
166
+ ...scene,
167
+ layers: [...scene.layers, layer]
168
+ });
169
+ }
@@ -0,0 +1,29 @@
1
+ export type PixiTransformContainer = {
2
+ x: number;
3
+ y: number;
4
+ alpha: number;
5
+ angle: number;
6
+ scale: {
7
+ x: number;
8
+ y: number;
9
+ set?: (value: number) => void;
10
+ };
11
+ };
12
+ export type PixiAnimationTarget = {
13
+ x: number;
14
+ y: number;
15
+ opacity: number;
16
+ scale: number;
17
+ scaleX: number;
18
+ scaleY: number;
19
+ rotation: number;
20
+ };
21
+ /**
22
+ * Presents Pixi's transform model as the numeric properties used by native
23
+ * VisualFries animation data. Rotation is expressed in degrees, matching the
24
+ * scene schema; uniform scale maps to both Pixi scale axes.
25
+ */
26
+ export declare function createPixiAnimationTarget(container: PixiTransformContainer, onChange?: () => void, origin?: {
27
+ x: number;
28
+ y: number;
29
+ }): PixiAnimationTarget;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Presents Pixi's transform model as the numeric properties used by native
3
+ * VisualFries animation data. Rotation is expressed in degrees, matching the
4
+ * scene schema; uniform scale maps to both Pixi scale axes.
5
+ */
6
+ export function createPixiAnimationTarget(container, onChange = () => undefined, origin) {
7
+ return {
8
+ get x() {
9
+ return origin ? container.x - origin.x : container.x;
10
+ },
11
+ set x(value) {
12
+ container.x = (origin?.x ?? 0) + value;
13
+ onChange();
14
+ },
15
+ get y() {
16
+ return origin ? container.y - origin.y : container.y;
17
+ },
18
+ set y(value) {
19
+ container.y = (origin?.y ?? 0) + value;
20
+ onChange();
21
+ },
22
+ get opacity() {
23
+ return container.alpha;
24
+ },
25
+ set opacity(value) {
26
+ container.alpha = value;
27
+ onChange();
28
+ },
29
+ get scale() {
30
+ return container.scale.x;
31
+ },
32
+ set scale(value) {
33
+ if (container.scale.set)
34
+ container.scale.set(value);
35
+ else {
36
+ container.scale.x = value;
37
+ container.scale.y = value;
38
+ }
39
+ onChange();
40
+ },
41
+ get scaleX() {
42
+ return container.scale.x;
43
+ },
44
+ set scaleX(value) {
45
+ container.scale.x = value;
46
+ onChange();
47
+ },
48
+ get scaleY() {
49
+ return container.scale.y;
50
+ },
51
+ set scaleY(value) {
52
+ container.scale.y = value;
53
+ onChange();
54
+ },
55
+ get rotation() {
56
+ return container.angle;
57
+ },
58
+ set rotation(value) {
59
+ container.angle = value;
60
+ onChange();
61
+ }
62
+ };
63
+ }
@@ -11,6 +11,10 @@ export class LineHighlighterAnimationBuilder {
11
11
  if (!activeLine || !activeLine.enabled) {
12
12
  return [];
13
13
  }
14
+ // Both modes target the same split glyphs. Active-word highlighting is the
15
+ // deterministic precedence for old scenes that enable both.
16
+ if (get(data, 'appearance.text.activeWord.enabled', false))
17
+ return [];
14
18
  const config = {
15
19
  activeLine,
16
20
  data,
@@ -39,14 +43,27 @@ export class LineHighlighterAnimationBuilder {
39
43
  return animations;
40
44
  }
41
45
  static prepareHighlightStyles(config) {
42
- const { activeLine, data } = config;
43
- const highlightStyles = ColorTransformer.transform(activeLine.color, 'background');
44
- const originalStyles = ColorTransformer.transform(data.appearance.text?.color, 'background');
46
+ const { activeLine, data, animationData } = config;
47
+ const solidPalette = (data.appearance.text?.highlightColors ?? []).filter((color) => typeof color === 'string');
48
+ const highlightStyles = solidPalette.length > 0
49
+ ? {
50
+ color: { fromData: 'highlightColors', mode: 'cycle', fallbackValue: solidPalette[0] }
51
+ }
52
+ : ColorTransformer.transform(activeLine.color, 'text');
53
+ if (solidPalette.length > 0)
54
+ animationData.highlightColors = solidPalette;
55
+ const originalStyles = ColorTransformer.transform(data.appearance.text?.color, 'text');
45
56
  // Handle font weight if specified
46
57
  if (get(activeLine, 'fontWeight', undefined)) {
47
58
  highlightStyles.fontWeight = activeLine.fontWeight;
48
59
  originalStyles.fontWeight = data.appearance.text?.fontWeight ?? 'normal';
49
60
  }
61
+ if (get(activeLine, 'scale', undefined)) {
62
+ highlightStyles.scale = activeLine.scale;
63
+ highlightStyles.transformOrigin = '50% 50%';
64
+ originalStyles.scale = 1;
65
+ originalStyles.transformOrigin = '50% 50%';
66
+ }
50
67
  return {
51
68
  highlight: highlightStyles,
52
69
  original: originalStyles
@@ -39,14 +39,27 @@ export class WordHighlighterAnimationBuilder {
39
39
  return animations;
40
40
  }
41
41
  static prepareHighlightStyles(config) {
42
- const { activeWord, data } = config;
43
- const highlightStyles = ColorTransformer.transform(activeWord.color, 'text');
42
+ const { activeWord, data, animationData } = config;
43
+ const solidPalette = (data.appearance.text?.highlightColors ?? []).filter((color) => typeof color === 'string');
44
+ const highlightStyles = solidPalette.length > 0
45
+ ? {
46
+ color: { fromData: 'highlightColors', mode: 'cycle', fallbackValue: solidPalette[0] }
47
+ }
48
+ : ColorTransformer.transform(activeWord.color, 'text');
49
+ if (solidPalette.length > 0)
50
+ animationData.highlightColors = solidPalette;
44
51
  const originalStyles = ColorTransformer.transform(data.appearance.text?.color, 'text');
45
52
  // Handle font weight if specified
46
53
  if (get(activeWord, 'fontWeight', undefined)) {
47
54
  highlightStyles.fontWeight = activeWord.fontWeight;
48
55
  originalStyles.fontWeight = data.appearance.text?.fontWeight ?? 'normal';
49
56
  }
57
+ if (get(activeWord, 'scale', undefined)) {
58
+ highlightStyles.scale = activeWord.scale;
59
+ highlightStyles.transformOrigin = '50% 50%';
60
+ originalStyles.scale = 1;
61
+ originalStyles.transformOrigin = '50% 50%';
62
+ }
50
63
  return {
51
64
  highlight: highlightStyles,
52
65
  original: originalStyles
@@ -0,0 +1,73 @@
1
+ import { getFirstEncodableAudioCodec, getFirstEncodableVideoCodec, type StreamTargetChunk } from 'mediabunny';
2
+ export declare const BROWSER_EXPORT_FALLBACK: "deterministic-local-ffmpeg";
3
+ export type BrowserExportCapabilityRequest = {
4
+ width: number;
5
+ height: number;
6
+ fps: number;
7
+ videoBitrate?: number;
8
+ audio?: boolean;
9
+ audioBitrate?: number;
10
+ sampleRate?: number;
11
+ numberOfChannels?: number;
12
+ };
13
+ export type BrowserExportCapabilityReport = {
14
+ supported: boolean;
15
+ container: 'mp4';
16
+ videoCodec: 'avc' | null;
17
+ audioCodec: 'aac' | null;
18
+ reasons: string[];
19
+ fallback: typeof BROWSER_EXPORT_FALLBACK;
20
+ };
21
+ type CapabilityDependencies = {
22
+ getVideoCodec: typeof getFirstEncodableVideoCodec;
23
+ getAudioCodec: typeof getFirstEncodableAudioCodec;
24
+ };
25
+ /** Probe exact MVP codecs. A different codec is not silently substituted into an MP4. */
26
+ export declare function probeBrowserExportCapabilities(request: BrowserExportCapabilityRequest, dependencies?: CapabilityDependencies): Promise<BrowserExportCapabilityReport>;
27
+ export type BrowserExportFrame = {
28
+ frameIndex: number;
29
+ timestamp: number;
30
+ duration: number;
31
+ progress: number;
32
+ };
33
+ export type BrowserExportOptions = Omit<BrowserExportCapabilityRequest, 'audio'> & {
34
+ canvas: HTMLCanvasElement | OffscreenCanvas;
35
+ duration: number;
36
+ renderFrame: (frame: BrowserExportFrame) => void | Promise<void>;
37
+ audioBuffer?: AudioBuffer;
38
+ /** Positional stream: its sink must honor each StreamTargetChunk.position (not append blindly). */
39
+ writable?: WritableStream<StreamTargetChunk>;
40
+ onProgress?: (progress: number, frameIndex: number) => void;
41
+ };
42
+ export type BrowserExportResult = {
43
+ buffer: ArrayBuffer | null;
44
+ mimeType: 'video/mp4';
45
+ frameCount: number;
46
+ duration: number;
47
+ capabilities: BrowserExportCapabilityReport;
48
+ };
49
+ export declare class BrowserExportUnsupportedError extends Error {
50
+ readonly capabilities: BrowserExportCapabilityReport;
51
+ constructor(capabilities: BrowserExportCapabilityReport);
52
+ }
53
+ /**
54
+ * Deterministic canvas-to-MP4 path. Every await on source.add() propagates encoder/writer
55
+ * backpressure; no image serialization or frame queue exists between compositor and encoder.
56
+ */
57
+ export declare function exportCanvasToMp4(options: BrowserExportOptions): Promise<BrowserExportResult>;
58
+ export type BrowserAudioTone = {
59
+ start: number;
60
+ end: number;
61
+ frequency: number;
62
+ gain?: number;
63
+ pan?: number;
64
+ };
65
+ /** Minimal deterministic in-browser MVP mix. First sample begins at t=0. */
66
+ export declare function mixBrowserAudio(options: {
67
+ duration: number;
68
+ tone?: BrowserAudioTone[];
69
+ source?: AudioBuffer;
70
+ sourceGain?: number;
71
+ sampleRate?: number;
72
+ }): Promise<AudioBuffer>;
73
+ export {};
@@ -0,0 +1,180 @@
1
+ import { AudioBufferSource, BufferTarget, CanvasSource, Mp4OutputFormat, Output, QUALITY_HIGH, StreamTarget, getFirstEncodableAudioCodec, getFirstEncodableVideoCodec } from 'mediabunny';
2
+ export const BROWSER_EXPORT_FALLBACK = 'deterministic-local-ffmpeg';
3
+ const defaultCapabilityDependencies = {
4
+ getVideoCodec: getFirstEncodableVideoCodec,
5
+ getAudioCodec: getFirstEncodableAudioCodec
6
+ };
7
+ /** Probe exact MVP codecs. A different codec is not silently substituted into an MP4. */
8
+ export async function probeBrowserExportCapabilities(request, dependencies = defaultCapabilityDependencies) {
9
+ const reasons = [];
10
+ const videoCodec = await dependencies.getVideoCodec(['avc'], {
11
+ width: request.width,
12
+ height: request.height,
13
+ bitrate: request.videoBitrate ?? QUALITY_HIGH
14
+ });
15
+ if (videoCodec !== 'avc') {
16
+ reasons.push(`AVC/H.264 encoding unavailable at ${request.width}x${request.height}`);
17
+ }
18
+ let audioCodec = null;
19
+ if (request.audio === true) {
20
+ const result = await dependencies.getAudioCodec(['aac'], {
21
+ numberOfChannels: request.numberOfChannels ?? 2,
22
+ sampleRate: request.sampleRate ?? 48_000,
23
+ bitrate: request.audioBitrate ?? 192_000
24
+ });
25
+ audioCodec = result === 'aac' ? 'aac' : null;
26
+ if (!audioCodec) {
27
+ reasons.push('AAC encoding unavailable');
28
+ }
29
+ }
30
+ return {
31
+ supported: reasons.length === 0,
32
+ container: 'mp4',
33
+ videoCodec: videoCodec === 'avc' ? 'avc' : null,
34
+ audioCodec,
35
+ reasons,
36
+ fallback: BROWSER_EXPORT_FALLBACK
37
+ };
38
+ }
39
+ export class BrowserExportUnsupportedError extends Error {
40
+ capabilities;
41
+ constructor(capabilities) {
42
+ super(`Browser export unavailable: ${capabilities.reasons.join('; ')}. ` +
43
+ `Use ${capabilities.fallback}.`);
44
+ this.name = 'BrowserExportUnsupportedError';
45
+ this.capabilities = capabilities;
46
+ }
47
+ }
48
+ /**
49
+ * Deterministic canvas-to-MP4 path. Every await on source.add() propagates encoder/writer
50
+ * backpressure; no image serialization or frame queue exists between compositor and encoder.
51
+ */
52
+ export async function exportCanvasToMp4(options) {
53
+ if (!Number.isFinite(options.duration) || options.duration <= 0) {
54
+ throw new Error('Browser export duration must be a positive finite number');
55
+ }
56
+ if (!Number.isFinite(options.fps) || options.fps <= 0) {
57
+ throw new Error('Browser export FPS must be a positive finite number');
58
+ }
59
+ if (options.audioBuffer &&
60
+ options.audioBuffer.duration > options.duration + 1 / options.audioBuffer.sampleRate) {
61
+ throw new Error(`Audio duration ${options.audioBuffer.duration.toFixed(6)}s exceeds video duration ` +
62
+ `${options.duration.toFixed(6)}s`);
63
+ }
64
+ const capabilities = await probeBrowserExportCapabilities({
65
+ width: options.width,
66
+ height: options.height,
67
+ fps: options.fps,
68
+ videoBitrate: options.videoBitrate,
69
+ audio: Boolean(options.audioBuffer),
70
+ audioBitrate: options.audioBitrate,
71
+ sampleRate: options.audioBuffer?.sampleRate ?? options.sampleRate,
72
+ numberOfChannels: options.audioBuffer?.numberOfChannels ?? options.numberOfChannels
73
+ });
74
+ if (!capabilities.supported) {
75
+ throw new BrowserExportUnsupportedError(capabilities);
76
+ }
77
+ if (options.canvas.width !== options.width || options.canvas.height !== options.height) {
78
+ throw new Error(`Canvas size ${options.canvas.width}x${options.canvas.height} does not match export ` +
79
+ `${options.width}x${options.height}`);
80
+ }
81
+ const frameCount = Math.round(options.duration * options.fps);
82
+ const frameDuration = 1 / options.fps;
83
+ const target = options.writable
84
+ ? new StreamTarget(options.writable, { chunked: true, chunkSize: 4 * 1024 * 1024 })
85
+ : new BufferTarget();
86
+ const output = new Output({
87
+ // BufferTarget already owns the finished file. Avoid a second full media copy for fast-start.
88
+ format: new Mp4OutputFormat({ fastStart: options.writable ? 'reserve' : false }),
89
+ target
90
+ });
91
+ const videoSource = new CanvasSource(options.canvas, {
92
+ codec: 'avc',
93
+ bitrate: options.videoBitrate ?? QUALITY_HIGH,
94
+ keyFrameInterval: 2
95
+ });
96
+ output.addVideoTrack(videoSource, {
97
+ frameRate: options.fps,
98
+ maximumPacketCount: Math.ceil(frameCount * 1.05)
99
+ });
100
+ let audioSource;
101
+ if (options.audioBuffer) {
102
+ audioSource = new AudioBufferSource({
103
+ codec: 'aac',
104
+ bitrate: options.audioBitrate ?? 192_000
105
+ });
106
+ output.addAudioTrack(audioSource, {
107
+ maximumPacketCount: Math.ceil(options.duration * 100 * 1.33)
108
+ });
109
+ }
110
+ await output.start();
111
+ try {
112
+ // AudioBufferSource places its first sample at timestamp zero. It can encode in
113
+ // parallel with video while both sources apply backpressure to the same output.
114
+ // Audio is small relative to video and first AudioBuffer is guaranteed to start at zero.
115
+ // Adding it first surfaces encoder failure before expensive video composition begins.
116
+ if (audioSource)
117
+ await audioSource.add(options.audioBuffer);
118
+ for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) {
119
+ const timestamp = frameIndex / options.fps;
120
+ await options.renderFrame({
121
+ frameIndex,
122
+ timestamp,
123
+ duration: frameDuration,
124
+ progress: frameCount <= 1 ? 1 : frameIndex / (frameCount - 1)
125
+ });
126
+ await videoSource.add(timestamp, frameDuration);
127
+ options.onProgress?.((frameIndex + 1) / frameCount, frameIndex);
128
+ }
129
+ await output.finalize();
130
+ }
131
+ catch (error) {
132
+ if (output.state !== 'finalized' && output.state !== 'canceled') {
133
+ await output.cancel();
134
+ }
135
+ throw error;
136
+ }
137
+ return {
138
+ buffer: target instanceof BufferTarget ? target.buffer : null,
139
+ mimeType: 'video/mp4',
140
+ frameCount,
141
+ duration: frameCount / options.fps,
142
+ capabilities
143
+ };
144
+ }
145
+ /** Minimal deterministic in-browser MVP mix. First sample begins at t=0. */
146
+ export async function mixBrowserAudio(options) {
147
+ if (!Number.isFinite(options.duration) || options.duration <= 0) {
148
+ throw new Error('Browser audio duration must be a positive finite number');
149
+ }
150
+ const sampleRate = options.sampleRate ?? 48_000;
151
+ const context = new OfflineAudioContext(2, Math.ceil(options.duration * sampleRate), sampleRate);
152
+ const master = context.createGain();
153
+ master.gain.value = 0.92;
154
+ master.connect(context.destination);
155
+ if (options.source) {
156
+ const source = context.createBufferSource();
157
+ const gain = context.createGain();
158
+ source.buffer = options.source;
159
+ gain.gain.value = options.sourceGain ?? 1;
160
+ source.connect(gain).connect(master);
161
+ source.start(0);
162
+ }
163
+ for (const tone of options.tone ?? []) {
164
+ const oscillator = context.createOscillator();
165
+ const gain = context.createGain();
166
+ const panner = context.createStereoPanner();
167
+ oscillator.frequency.value = tone.frequency;
168
+ oscillator.type = 'sine';
169
+ panner.pan.value = tone.pan ?? 0;
170
+ const level = tone.gain ?? 0.08;
171
+ gain.gain.setValueAtTime(0, tone.start);
172
+ gain.gain.linearRampToValueAtTime(level, tone.start + 0.01);
173
+ gain.gain.setValueAtTime(level, Math.max(tone.start + 0.01, tone.end - 0.03));
174
+ gain.gain.linearRampToValueAtTime(0, tone.end);
175
+ oscillator.connect(gain).connect(panner).connect(master);
176
+ oscillator.start(tone.start);
177
+ oscillator.stop(tone.end);
178
+ }
179
+ return context.startRendering();
180
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Curated, gl-transitions-compatible WebGL transitions for deterministic browser export.
3
+ *
4
+ * Shader source is intentionally private. Callers select a known transition and may only
5
+ * provide the parameters declared by that transition; arbitrary GLSL is never accepted.
6
+ */
7
+ export declare const GL_TRANSITION_NAMES: readonly ["fade", "radial-wipe"];
8
+ export type GlTransitionName = (typeof GL_TRANSITION_NAMES)[number];
9
+ export type GlTransitionParameters = Readonly<Record<string, number>>;
10
+ export type GlTransitionRenderInput = {
11
+ /** Image source uploaded to the reusable `from` texture and shown at progress=0. */
12
+ from: TexImageSource;
13
+ /** Image source uploaded to the reusable `to` texture and shown at progress=1. */
14
+ to: TexImageSource;
15
+ /** Normalized transition position. Values outside 0..1 are clamped. */
16
+ progress: number;
17
+ /** Output width / output height. */
18
+ ratio: number;
19
+ transition: GlTransitionName;
20
+ parameters?: GlTransitionParameters;
21
+ };
22
+ export type GlTransitionRenderResult = {
23
+ requested: GlTransitionName;
24
+ used: GlTransitionName;
25
+ fallbackReason?: string;
26
+ };
27
+ type GlContext = WebGLRenderingContext | WebGL2RenderingContext;
28
+ export declare function listGlTransitions(): readonly GlTransitionName[];
29
+ export declare function createGlTransitionRenderer(gl: GlContext): GlTransitionRenderer;
30
+ export declare class GlTransitionRenderer {
31
+ #private;
32
+ readonly canvas: HTMLCanvasElement | OffscreenCanvas;
33
+ constructor(gl: GlContext);
34
+ render(input: GlTransitionRenderInput): GlTransitionRenderResult;
35
+ destroy(): void;
36
+ }
37
+ export {};