visualfries 0.1.10120 → 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.
- package/README.md +97 -0
- package/bin/visualfries.js +1570 -0
- package/dist/DIContainer.d.ts +2 -1
- package/dist/DIContainer.js +16 -5
- package/dist/SceneBuilder.svelte.d.ts +70 -1
- package/dist/SceneBuilder.svelte.js +96 -29
- package/dist/agent/audioMixer.d.ts +34 -0
- package/dist/agent/audioMixer.js +308 -0
- package/dist/agent/broll.d.ts +35 -0
- package/dist/agent/broll.js +226 -0
- package/dist/agent/capabilities.d.ts +34 -0
- package/dist/agent/capabilities.js +246 -0
- package/dist/agent/captionScene.d.ts +23 -0
- package/dist/agent/captionScene.js +204 -0
- package/dist/agent/catalog.d.ts +24 -0
- package/dist/agent/catalog.js +67 -0
- package/dist/agent/cueFiles.d.ts +54 -0
- package/dist/agent/cueFiles.js +219 -0
- package/dist/agent/cuePresets.d.ts +7 -0
- package/dist/agent/cuePresets.js +50 -0
- package/dist/agent/deterministicMedia.d.ts +61 -0
- package/dist/agent/deterministicMedia.js +303 -0
- package/dist/agent/index.d.ts +18 -0
- package/dist/agent/index.js +18 -0
- package/dist/agent/inspectScene.d.ts +36 -0
- package/dist/agent/inspectScene.js +139 -0
- package/dist/agent/localRender.d.ts +82 -0
- package/dist/agent/localRender.js +702 -0
- package/dist/agent/overlays.d.ts +40 -0
- package/dist/agent/overlays.js +325 -0
- package/dist/agent/productionPlan.d.ts +131 -0
- package/dist/agent/productionPlan.js +322 -0
- package/dist/agent/renderPlan.d.ts +27 -0
- package/dist/agent/renderPlan.js +68 -0
- package/dist/agent/renderRanges.d.ts +24 -0
- package/dist/agent/renderRanges.js +73 -0
- package/dist/agent/runtimeSupport.d.ts +19 -0
- package/dist/agent/runtimeSupport.js +192 -0
- package/dist/agent/streamingEncoder.d.ts +31 -0
- package/dist/agent/streamingEncoder.js +208 -0
- package/dist/agent/transcripts.d.ts +29 -0
- package/dist/agent/transcripts.js +146 -0
- package/dist/agent/transitions.d.ts +20 -0
- package/dist/agent/transitions.js +169 -0
- package/dist/animations/PixiAnimationTarget.d.ts +29 -0
- package/dist/animations/PixiAnimationTarget.js +63 -0
- package/dist/animations/builders/LineHighlighterAnimationBuilder.js +20 -3
- package/dist/animations/builders/WordHighlighterAnimationBuilder.js +15 -2
- package/dist/browser/browserExport.d.ts +73 -0
- package/dist/browser/browserExport.js +180 -0
- package/dist/browser/glTransitions.d.ts +37 -0
- package/dist/browser/glTransitions.js +261 -0
- package/dist/browser/index.d.ts +3 -0
- package/dist/browser/index.js +3 -0
- package/dist/browser/mediaDecode.d.ts +31 -0
- package/dist/browser/mediaDecode.js +152 -0
- package/dist/builders/PixiComponentBuilder.d.ts +6 -0
- package/dist/builders/PixiComponentBuilder.js +12 -0
- package/dist/builders/_ComponentState.svelte.d.ts +7 -5
- package/dist/builders/_ComponentState.svelte.js +29 -15
- package/dist/builders/html/StyleBuilder.js +9 -8
- package/dist/builders/html/TextComponentHtmlBuilder.js +4 -2
- package/dist/builders/html/processors/AppearanceStyleProcessor.js +1 -1
- package/dist/builders/html/processors/TextAppearanceStyleProcessor.js +7 -0
- package/dist/builders/html/processors/TextEffectsStyleProcessor.js +32 -13
- package/dist/commands/ReplaceSourceOnTimeCommand.js +9 -1
- package/dist/commands/SeekCommand.js +19 -6
- package/dist/components/AnimatedGIF.d.ts +3 -0
- package/dist/components/AnimatedGIF.js +28 -1
- package/dist/components/Component.svelte.d.ts +5 -5
- package/dist/components/Component.svelte.js +29 -13
- package/dist/components/ComponentContext.svelte.js +68 -23
- package/dist/components/hooks/AnimationHook.js +1 -1
- package/dist/components/hooks/CanvasFillHook.d.ts +12 -0
- package/dist/components/hooks/CanvasFillHook.js +75 -0
- package/dist/components/hooks/DeterministicMediaFrameHook.js +3 -0
- package/dist/components/hooks/HtmlTextHook.js +11 -3
- package/dist/components/hooks/HtmlToCanvasHook.js +8 -25
- package/dist/components/hooks/MediaHook.js +172 -47
- package/dist/components/hooks/MediaSeekingHook.js +138 -88
- package/dist/components/hooks/PixiGifHook.js +4 -4
- package/dist/components/hooks/PixiSplitScreenDisplayObjectHook.js +73 -6
- package/dist/components/hooks/PixiVideoTextureHook.js +10 -4
- package/dist/components/hooks/PixiVisualTransformHook.d.ts +16 -0
- package/dist/components/hooks/PixiVisualTransformHook.js +102 -0
- package/dist/components/hooks/SubtitlesHook.js +48 -24
- package/dist/components/hooks/VerifyMediaHook.js +2 -2
- package/dist/directors/ComponentDirector.d.ts +1 -0
- package/dist/directors/ComponentDirector.js +41 -8
- package/dist/fonts/fontDiscovery.js +12 -1
- package/dist/layers/Layer.svelte.d.ts +54 -0
- package/dist/layers/Layer.svelte.js +2 -25
- package/dist/managers/AppManager.svelte.js +8 -5
- package/dist/managers/ComponentsManager.svelte.d.ts +7 -7
- package/dist/managers/ComponentsManager.svelte.js +29 -15
- package/dist/managers/DeterministicMediaManager.js +38 -3
- package/dist/managers/EventManager.js +3 -1
- package/dist/managers/LayersManager.svelte.d.ts +54 -0
- package/dist/managers/RenderManager.d.ts +5 -0
- package/dist/managers/RenderManager.js +67 -21
- package/dist/managers/StateManager.svelte.d.ts +56 -0
- package/dist/managers/StateManager.svelte.js +60 -58
- package/dist/managers/SubtitlesManager.svelte.js +1 -3
- package/dist/managers/TimeManager.svelte.d.ts +4 -0
- package/dist/managers/TimeManager.svelte.js +23 -7
- package/dist/schemas/runtime/deterministic.js +5 -1
- package/dist/schemas/runtime/types.d.ts +36 -15
- package/dist/schemas/scene/components.d.ts +126 -0
- package/dist/schemas/scene/components.js +11 -0
- package/dist/schemas/scene/core.d.ts +109 -10
- package/dist/schemas/scene/core.js +1 -1
- package/dist/utils/mediaWindow.d.ts +13 -0
- package/dist/utils/mediaWindow.js +25 -0
- package/dist/utils/svgGenerator.js +1 -1
- package/dist/utils/timelineWindow.d.ts +2 -0
- package/dist/utils/timelineWindow.js +7 -0
- package/dist/utils/utils.js +2 -4
- package/docs/AGENT_PATTERNS.md +243 -0
- package/docs/AGENT_WORKFLOW.md +364 -0
- package/docs/AUTHORING_BEST_PRACTICES.md +188 -0
- package/docs/AUTHORING_SYSTEM_AUDIT.md +98 -0
- package/docs/BROWSER_EXPORT_SPIKE.md +55 -0
- package/docs/DETERMINISTIC_RENDERER.md +83 -0
- package/docs/NEXT_RELEASE.md +28 -0
- package/docs/PROPOSED_VISUALFRIES_SKILL.md +68 -0
- package/docs/examples/agent-cues.json +49 -0
- package/docs/examples/agent-transcript.json +23 -0
- package/docs/examples/agent-transcript.srt +7 -0
- package/docs/examples/agent-transcript.vtt +7 -0
- package/docs/examples/image-animation.json +87 -0
- package/docs/examples/native-text-metric-badge.json +109 -0
- package/package.json +133 -100
- 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
|
|
44
|
-
const
|
|
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
|
|
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 {};
|