visualfries 0.1.10110 → 0.1.10120
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 +25 -0
- package/dist/SceneBuilder.svelte.js +3 -1
- package/dist/commands/SeekCommand.js +61 -25
- package/dist/components/hooks/PixiSplitScreenDisplayObjectHook.d.ts +6 -0
- package/dist/components/hooks/PixiSplitScreenDisplayObjectHook.js +81 -14
- package/dist/factories/SceneBuilderFactory.d.ts +3 -0
- package/dist/factories/SceneBuilderFactory.js +6 -0
- package/dist/layers/Layer.svelte.js +40 -5
- package/dist/managers/AppManager.svelte.d.ts +12 -0
- package/dist/managers/AppManager.svelte.js +175 -6
- package/dist/managers/ComponentsManager.svelte.js +31 -4
- package/dist/managers/DeterministicMediaManager.d.ts +9 -0
- package/dist/managers/DeterministicMediaManager.js +65 -0
- package/dist/schemas/runtime/deterministic.d.ts +19 -0
- package/dist/schemas/runtime/deterministic.js +4 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -198,6 +198,31 @@ const builder = await createSceneBuilder(sceneData, container, {
|
|
|
198
198
|
});
|
|
199
199
|
```
|
|
200
200
|
|
|
201
|
+
## Server Renderer Mode (Canvas vs WebGL)
|
|
202
|
+
|
|
203
|
+
Server mode remains canvas-first by default for backward compatibility:
|
|
204
|
+
|
|
205
|
+
```typescript
|
|
206
|
+
await createSceneBuilder(sceneData, container, {
|
|
207
|
+
environment: 'server'
|
|
208
|
+
// serverRendererMode defaults to "canvas"
|
|
209
|
+
});
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
To opt into GPU rendering on server/headless runtimes:
|
|
213
|
+
|
|
214
|
+
```typescript
|
|
215
|
+
await createSceneBuilder(sceneData, container, {
|
|
216
|
+
environment: 'server',
|
|
217
|
+
serverRendererMode: 'webgl',
|
|
218
|
+
preferWebGL2: true,
|
|
219
|
+
powerPreference: 'high-performance'
|
|
220
|
+
});
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
If WebGL is unavailable or initialization fails, Visualfries automatically falls back to canvas so deterministic render jobs keep running. When deterministic diagnostics are enabled, renderer selection and fallback reason are included in `getDiagnosticsReport()`.
|
|
224
|
+
In `serverRendererMode: 'webgl'`, `fillBackgroundBlur` uses native `PIXI.BlurFilter` (same rendering path as client mode).
|
|
225
|
+
|
|
201
226
|
## Contributing
|
|
202
227
|
|
|
203
228
|
As an early-stage, solo-developed project, contributions are highly encouraged! The best way to contribute right now is by:
|
|
@@ -403,7 +403,9 @@ export class SceneBuilder {
|
|
|
403
403
|
framesSkipped += 1;
|
|
404
404
|
}
|
|
405
405
|
else {
|
|
406
|
-
|
|
406
|
+
// isSceneDirty() already sought and prepared server deterministic state.
|
|
407
|
+
// Render directly to avoid a second seek in the dirty path.
|
|
408
|
+
frame = await this.renderFrame(undefined, format, quality, imageOptions);
|
|
407
409
|
previousFrame = frame;
|
|
408
410
|
}
|
|
409
411
|
}
|
|
@@ -13,6 +13,7 @@ export class SeekCommand {
|
|
|
13
13
|
renderManager;
|
|
14
14
|
componentsManager;
|
|
15
15
|
deterministicMediaManager;
|
|
16
|
+
#didAwaitFontsReady = false;
|
|
16
17
|
constructor(cradle) {
|
|
17
18
|
this.timeline = cradle.timelineManager;
|
|
18
19
|
this.state = cradle.stateManager;
|
|
@@ -61,17 +62,62 @@ export class SeekCommand {
|
|
|
61
62
|
}
|
|
62
63
|
return pending;
|
|
63
64
|
}
|
|
65
|
+
#getCurrentSceneFrameIndex() {
|
|
66
|
+
const fps = this.state.data?.settings?.fps || 30;
|
|
67
|
+
const currentTime = this.state.currentTime ?? 0;
|
|
68
|
+
return Math.max(0, Math.round(currentTime * fps));
|
|
69
|
+
}
|
|
70
|
+
async #delay(ms) {
|
|
71
|
+
if (ms <= 0) {
|
|
72
|
+
const immediate = globalThis.setImmediate;
|
|
73
|
+
if (typeof immediate === 'function') {
|
|
74
|
+
await new Promise((resolve) => {
|
|
75
|
+
immediate(resolve);
|
|
76
|
+
});
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
await Promise.resolve();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
83
|
+
}
|
|
84
|
+
async #awaitFontsReadyOnce() {
|
|
85
|
+
if (this.#didAwaitFontsReady) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
this.#didAwaitFontsReady = true;
|
|
89
|
+
if (typeof document === 'undefined' || !document.fonts?.ready) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
await Promise.race([
|
|
94
|
+
document.fonts.ready,
|
|
95
|
+
new Promise((resolve) => setTimeout(resolve, 2000))
|
|
96
|
+
]);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// Font readiness is best-effort in seek path.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
64
102
|
async #renderUntilDeterministicReady() {
|
|
65
|
-
const
|
|
103
|
+
const pendingBeforeRetry = this.#getPendingDeterministicComponents();
|
|
104
|
+
if (pendingBeforeRetry.length === 0) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const maxAttempts = this.deterministicMediaManager.config.seekMaxAttempts ?? 4;
|
|
108
|
+
const readyYieldMs = this.deterministicMediaManager.config.readyYieldMs ?? 0;
|
|
109
|
+
const sceneFrameIndex = this.#getCurrentSceneFrameIndex();
|
|
110
|
+
let pending = pendingBeforeRetry;
|
|
66
111
|
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
112
|
+
this.deterministicMediaManager.recordReadyAttempt(sceneFrameIndex);
|
|
113
|
+
this.deterministicMediaManager.recordExtraRenderPass(sceneFrameIndex);
|
|
114
|
+
await this.#delay(readyYieldMs);
|
|
67
115
|
await this.renderManager.render();
|
|
68
|
-
|
|
116
|
+
pending = this.#getPendingDeterministicComponents();
|
|
69
117
|
if (pending.length === 0) {
|
|
70
118
|
return;
|
|
71
119
|
}
|
|
72
|
-
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
73
120
|
}
|
|
74
|
-
const pending = this.#getPendingDeterministicComponents();
|
|
75
121
|
if (pending.length > 0) {
|
|
76
122
|
throw new Error(`Deterministic media was not ready after seek for active components: ${pending.join(', ')}`);
|
|
77
123
|
}
|
|
@@ -85,28 +131,18 @@ export class SeekCommand {
|
|
|
85
131
|
this.timeline.seek(time);
|
|
86
132
|
// Ensure a deterministic render on server after seek to advance media frames
|
|
87
133
|
if (this.state.environment === 'server') {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
// animations may fail silently or not appear on first subtitles
|
|
92
|
-
if (typeof document !== 'undefined' && document.fonts?.ready) {
|
|
93
|
-
try {
|
|
94
|
-
await Promise.race([
|
|
95
|
-
document.fonts.ready,
|
|
96
|
-
new Promise((resolve) => setTimeout(resolve, 2000)) // 2s timeout
|
|
97
|
-
]);
|
|
98
|
-
}
|
|
99
|
-
catch {
|
|
100
|
-
// Ignore font loading errors, continue with rendering
|
|
101
|
-
}
|
|
134
|
+
const deterministicEnabled = this.deterministicMediaManager.isEnabled();
|
|
135
|
+
if (deterministicEnabled) {
|
|
136
|
+
await this.#awaitFontsReadyOnce();
|
|
102
137
|
}
|
|
103
|
-
|
|
104
|
-
const
|
|
105
|
-
|
|
138
|
+
const readyYieldMs = this.deterministicMediaManager.config.readyYieldMs ?? 0;
|
|
139
|
+
const loadingMaxAttempts = this.deterministicMediaManager.config.loadingMaxAttempts ?? 2;
|
|
140
|
+
const sceneFrameIndex = this.#getCurrentSceneFrameIndex();
|
|
141
|
+
await this.renderManager.render();
|
|
142
|
+
for (let i = 0; i < loadingMaxAttempts && this.state.state === 'loading'; i += 1) {
|
|
143
|
+
this.deterministicMediaManager.recordExtraRenderPass(sceneFrameIndex);
|
|
144
|
+
await this.#delay(readyYieldMs);
|
|
106
145
|
await this.renderManager.render();
|
|
107
|
-
if (this.state.state !== 'loading')
|
|
108
|
-
break;
|
|
109
|
-
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
110
146
|
}
|
|
111
147
|
if (this.state.state === 'loading') {
|
|
112
148
|
console.warn('SeekCommand: Max render attempts exhausted while still loading');
|
|
@@ -116,7 +152,7 @@ export class SeekCommand {
|
|
|
116
152
|
// This fixes the race condition where subtitle animations are added
|
|
117
153
|
// AFTER the initial seek, causing them to miss their initial state.
|
|
118
154
|
this.timeline.seek(time);
|
|
119
|
-
if (
|
|
155
|
+
if (deterministicEnabled) {
|
|
120
156
|
await this.#renderUntilDeterministicReady();
|
|
121
157
|
}
|
|
122
158
|
else {
|
|
@@ -2,14 +2,20 @@ import type { IComponentContext, IComponentHook, HookType } from '../..';
|
|
|
2
2
|
import { ImageComponentShape, VideoComponentShape } from '../..';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import type { StateManager } from '../../managers/StateManager.svelte.ts';
|
|
5
|
+
import type { DeterministicMediaManager } from '../../managers/DeterministicMediaManager.ts';
|
|
6
|
+
import type { AppManager } from '../../managers/AppManager.svelte.ts';
|
|
5
7
|
export declare class PixiSplitScreenDisplayObjectHook implements IComponentHook {
|
|
6
8
|
#private;
|
|
7
9
|
types: HookType[];
|
|
8
10
|
priority: number;
|
|
9
11
|
componentElement: z.infer<typeof VideoComponentShape> | z.infer<typeof ImageComponentShape>;
|
|
10
12
|
private sceneState;
|
|
13
|
+
private deterministicMediaManager?;
|
|
14
|
+
private appManager?;
|
|
11
15
|
constructor(cradle: {
|
|
12
16
|
stateManager: StateManager;
|
|
17
|
+
deterministicMediaManager?: DeterministicMediaManager;
|
|
18
|
+
appManager?: AppManager;
|
|
13
19
|
});
|
|
14
20
|
get sceneWidth(): number;
|
|
15
21
|
get sceneHeight(): number;
|
|
@@ -16,10 +16,20 @@ export class PixiSplitScreenDisplayObjectHook {
|
|
|
16
16
|
#pixiTexture;
|
|
17
17
|
#displayObject;
|
|
18
18
|
#bgCanvas = undefined;
|
|
19
|
+
#bgSprite = undefined;
|
|
20
|
+
#blurStrength = 50;
|
|
21
|
+
#blurDownscale = 0.33;
|
|
22
|
+
#lastBlurFrameKey = '';
|
|
23
|
+
#resourceIds = new WeakMap();
|
|
24
|
+
#nextResourceId = 1;
|
|
19
25
|
componentElement;
|
|
20
26
|
sceneState;
|
|
27
|
+
deterministicMediaManager;
|
|
28
|
+
appManager;
|
|
21
29
|
constructor(cradle) {
|
|
22
30
|
this.sceneState = cradle.stateManager;
|
|
31
|
+
this.deterministicMediaManager = cradle.deterministicMediaManager;
|
|
32
|
+
this.appManager = cradle.appManager;
|
|
23
33
|
}
|
|
24
34
|
get sceneWidth() {
|
|
25
35
|
return this.#context.sceneState.width;
|
|
@@ -29,10 +39,21 @@ export class PixiSplitScreenDisplayObjectHook {
|
|
|
29
39
|
}
|
|
30
40
|
initBlurBackground(strength = 50) {
|
|
31
41
|
const sanitizedStrength = this.#sanitizeBlurStrength(strength);
|
|
42
|
+
this.#blurDownscale = this.#getBlurDownscale();
|
|
43
|
+
this.#blurStrength = sanitizedStrength;
|
|
44
|
+
this.#lastBlurFrameKey = '';
|
|
32
45
|
const backgroundSprite = new PIXI.Sprite(this.#pixiTexture);
|
|
46
|
+
this.#bgSprite = backgroundSprite;
|
|
33
47
|
this.setupBackground(backgroundSprite, sanitizedStrength);
|
|
34
48
|
this.#displayObject.addChild(backgroundSprite);
|
|
35
49
|
}
|
|
50
|
+
#getBlurDownscale() {
|
|
51
|
+
const configured = this.deterministicMediaManager?.config.blurDownscale ?? 0.33;
|
|
52
|
+
if (!Number.isFinite(configured)) {
|
|
53
|
+
return 0.33;
|
|
54
|
+
}
|
|
55
|
+
return Math.max(0.05, Math.min(1, configured));
|
|
56
|
+
}
|
|
36
57
|
setupBackground(backgroundSprite, strength = 50) {
|
|
37
58
|
const sanitizedStrength = this.#sanitizeBlurStrength(strength);
|
|
38
59
|
const sceneRatio = this.sceneWidth / this.sceneHeight;
|
|
@@ -51,39 +72,83 @@ export class PixiSplitScreenDisplayObjectHook {
|
|
|
51
72
|
// Center the background sprite
|
|
52
73
|
backgroundSprite.x = (this.sceneWidth - backgroundSprite.width) / 2;
|
|
53
74
|
backgroundSprite.y = (this.sceneHeight - backgroundSprite.height) / 2;
|
|
54
|
-
if (this.sceneState.environment === 'server') {
|
|
75
|
+
if (this.sceneState.environment === 'server' && !this.#isWebGLRendererActive()) {
|
|
55
76
|
// Create a temporary canvas for blur effect
|
|
56
77
|
const bgCanvas = document.createElement('canvas');
|
|
57
|
-
bgCanvas.width = backgroundSprite.width;
|
|
58
|
-
bgCanvas.height = backgroundSprite.height;
|
|
78
|
+
bgCanvas.width = Math.max(1, Math.round(backgroundSprite.width * this.#blurDownscale));
|
|
79
|
+
bgCanvas.height = Math.max(1, Math.round(backgroundSprite.height * this.#blurDownscale));
|
|
59
80
|
this.#bgCanvas = bgCanvas;
|
|
60
|
-
this.#drawBlurredBackground(sanitizedStrength);
|
|
81
|
+
this.#drawBlurredBackground(sanitizedStrength, true);
|
|
61
82
|
const blurredTexture = PIXI.Texture.from(bgCanvas);
|
|
62
83
|
backgroundSprite.texture = blurredTexture;
|
|
63
84
|
}
|
|
64
85
|
else {
|
|
65
|
-
//
|
|
86
|
+
// Client mode and server webgl mode both use PIXI blur filter.
|
|
87
|
+
this.#bgCanvas = undefined;
|
|
66
88
|
const blurFilter = new PIXI.BlurFilter(sanitizedStrength, 50, 1, 7);
|
|
67
89
|
backgroundSprite.filters = [blurFilter];
|
|
68
90
|
}
|
|
69
91
|
}
|
|
70
|
-
#
|
|
92
|
+
#isWebGLRendererActive() {
|
|
93
|
+
const renderer = this.appManager?.app?.renderer;
|
|
94
|
+
if (renderer?.gl || renderer?.context?.gl || renderer?.context?.webGLVersion) {
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
return this.deterministicMediaManager?.getSelectedRendererType?.() === 'webgl';
|
|
98
|
+
}
|
|
99
|
+
#drawBlurredBackground(strength = 50, force = false) {
|
|
71
100
|
if (!this.#bgCanvas) {
|
|
72
|
-
return;
|
|
101
|
+
return false;
|
|
73
102
|
}
|
|
74
103
|
// Validate and sanitize strength parameter
|
|
75
104
|
const sanitizedStrength = this.#sanitizeBlurStrength(strength);
|
|
76
105
|
const ctx = this.#bgCanvas.getContext('2d');
|
|
77
|
-
// Use sanitized value to prevent XSS
|
|
78
|
-
ctx.filter = `blur(${sanitizedStrength}px)`;
|
|
79
106
|
// Get the source element (video/image)
|
|
80
107
|
const sourceElement = this.#context.getResource('videoElement') || this.#context.getResource('imageElement');
|
|
81
108
|
if (!sourceElement) {
|
|
82
109
|
// Video or Image element not ready yet - will be called again on next update
|
|
83
|
-
return;
|
|
110
|
+
return false;
|
|
84
111
|
}
|
|
112
|
+
const fps = this.sceneState.data.settings.fps || 30;
|
|
113
|
+
const componentFrameIndex = Math.max(0, Math.round(this.#context.currentComponentTime * fps));
|
|
114
|
+
const textureToken = this.#context.getResource('pixiTexture') ?? sourceElement;
|
|
115
|
+
const frameKey = `${componentFrameIndex}:${this.#resourceToken(textureToken)}`;
|
|
116
|
+
if (!force && frameKey === this.#lastBlurFrameKey) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
this.#lastBlurFrameKey = frameKey;
|
|
120
|
+
// Use sanitized value to prevent XSS
|
|
121
|
+
const effectiveBlurStrength = sanitizedStrength * this.#blurDownscale;
|
|
122
|
+
ctx.filter = `blur(${effectiveBlurStrength}px)`;
|
|
123
|
+
ctx.clearRect(0, 0, this.#bgCanvas.width, this.#bgCanvas.height);
|
|
85
124
|
// Draw the original texture with blur
|
|
86
|
-
|
|
125
|
+
try {
|
|
126
|
+
ctx.drawImage(sourceElement, 0, 0, this.#bgCanvas.width, this.#bgCanvas.height);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
const texture = this.#bgSprite?.texture;
|
|
132
|
+
texture.baseTexture?.update?.();
|
|
133
|
+
texture.update?.();
|
|
134
|
+
this.deterministicMediaManager?.recordBlurRedraw(this.#currentSceneFrameIndex());
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
#resourceToken(value) {
|
|
138
|
+
if (typeof value === 'object' && value !== null) {
|
|
139
|
+
let id = this.#resourceIds.get(value);
|
|
140
|
+
if (!id) {
|
|
141
|
+
id = this.#nextResourceId;
|
|
142
|
+
this.#nextResourceId += 1;
|
|
143
|
+
this.#resourceIds.set(value, id);
|
|
144
|
+
}
|
|
145
|
+
return `obj-${id}`;
|
|
146
|
+
}
|
|
147
|
+
return String(value);
|
|
148
|
+
}
|
|
149
|
+
#currentSceneFrameIndex() {
|
|
150
|
+
const fps = this.sceneState.data.settings.fps || 30;
|
|
151
|
+
return Math.max(0, Math.round(this.sceneState.currentTime * fps));
|
|
87
152
|
}
|
|
88
153
|
/**
|
|
89
154
|
* Sanitizes blur strength parameter to prevent XSS and ensure valid numeric values
|
|
@@ -187,9 +252,6 @@ export class PixiSplitScreenDisplayObjectHook {
|
|
|
187
252
|
}
|
|
188
253
|
async #handleUpdate() {
|
|
189
254
|
const isActive = this.#context.isActive;
|
|
190
|
-
if (isActive) {
|
|
191
|
-
this.#drawBlurredBackground();
|
|
192
|
-
}
|
|
193
255
|
if (this.#displayObject) {
|
|
194
256
|
// Texture swaps are frequent in deterministic mode; update sprite textures
|
|
195
257
|
// in-place instead of rebuilding split/blur geometry each frame.
|
|
@@ -197,6 +259,9 @@ export class PixiSplitScreenDisplayObjectHook {
|
|
|
197
259
|
if (currentTexture && currentTexture !== this.#pixiTexture) {
|
|
198
260
|
this.#swapDisplayTexture(currentTexture);
|
|
199
261
|
}
|
|
262
|
+
if (isActive && this.#bgCanvas) {
|
|
263
|
+
this.#drawBlurredBackground(this.#blurStrength);
|
|
264
|
+
}
|
|
200
265
|
// Always re-assert the resource in case the context was cleared or updated
|
|
201
266
|
this.#context.setResource('pixiRenderObject', this.#displayObject);
|
|
202
267
|
if (this.#displayObject.visible != isActive) {
|
|
@@ -244,6 +309,8 @@ export class PixiSplitScreenDisplayObjectHook {
|
|
|
244
309
|
async #handleDestroy() {
|
|
245
310
|
// remove event listeners from video
|
|
246
311
|
this.#bgCanvas = undefined;
|
|
312
|
+
this.#bgSprite = undefined;
|
|
313
|
+
this.#lastBlurFrameKey = '';
|
|
247
314
|
}
|
|
248
315
|
async handle(type, context) {
|
|
249
316
|
this.#context = context;
|
|
@@ -7,6 +7,9 @@ type Config = {
|
|
|
7
7
|
subtitles: Record<string, Subtitle[]> | Record<string, SubtitleCollection>;
|
|
8
8
|
fonts: FontType[];
|
|
9
9
|
forceCanvas: boolean;
|
|
10
|
+
serverRendererMode: 'canvas' | 'webgl';
|
|
11
|
+
preferWebGL2: boolean;
|
|
12
|
+
powerPreference: 'default' | 'high-performance' | 'low-power';
|
|
10
13
|
scale: number;
|
|
11
14
|
autoPlay?: boolean;
|
|
12
15
|
loop?: boolean;
|
|
@@ -18,6 +18,9 @@ const defaultConfig = {
|
|
|
18
18
|
subtitles: {},
|
|
19
19
|
fonts: [],
|
|
20
20
|
forceCanvas: false,
|
|
21
|
+
serverRendererMode: 'canvas',
|
|
22
|
+
preferWebGL2: true,
|
|
23
|
+
powerPreference: 'high-performance',
|
|
21
24
|
scale: 1,
|
|
22
25
|
autoPlay: false,
|
|
23
26
|
loop: false,
|
|
@@ -35,6 +38,9 @@ export const createSceneBuilder = async function (sceneData, container, config)
|
|
|
35
38
|
['subtitles', subs],
|
|
36
39
|
['fonts', sceneConfig.fonts],
|
|
37
40
|
['forceCanvas', sceneConfig.forceCanvas],
|
|
41
|
+
['serverRendererMode', sceneConfig.serverRendererMode],
|
|
42
|
+
['preferWebGL2', sceneConfig.preferWebGL2],
|
|
43
|
+
['powerPreference', sceneConfig.powerPreference],
|
|
38
44
|
['scale', sceneConfig.scale],
|
|
39
45
|
['autoPlay', sceneConfig.autoPlay],
|
|
40
46
|
['loop', sceneConfig.loop],
|
|
@@ -57,8 +57,8 @@ export class Layer {
|
|
|
57
57
|
if (!this.#displayObject) {
|
|
58
58
|
return false;
|
|
59
59
|
}
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
let componentDisplayIndex = 0;
|
|
61
|
+
for (const component of this.components) {
|
|
62
62
|
const displayObject = component.displayObject;
|
|
63
63
|
if (!displayObject) {
|
|
64
64
|
continue;
|
|
@@ -68,16 +68,51 @@ export class Layer {
|
|
|
68
68
|
if (parent && typeof parent.removeChild === 'function') {
|
|
69
69
|
parent.removeChild(displayObject);
|
|
70
70
|
}
|
|
71
|
-
this.#displayObject.
|
|
71
|
+
const childCount = this.#displayObject.children?.length ?? 0;
|
|
72
|
+
const insertIndex = Math.max(0, Math.min(componentDisplayIndex, childCount));
|
|
73
|
+
if (typeof this.#displayObject.addChildAt === 'function') {
|
|
74
|
+
this.#displayObject.addChildAt(displayObject, insertIndex);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
this.#displayObject.addChild(displayObject);
|
|
78
|
+
}
|
|
72
79
|
changed = true;
|
|
73
80
|
}
|
|
74
81
|
if (typeof this.#displayObject.getChildIndex === 'function' &&
|
|
75
82
|
typeof this.#displayObject.setChildIndex === 'function') {
|
|
83
|
+
const childCount = this.#displayObject.children?.length ?? 0;
|
|
84
|
+
if (childCount > 0) {
|
|
85
|
+
const currentIndex = this.#displayObject.getChildIndex(displayObject);
|
|
86
|
+
const targetIndex = Math.max(0, Math.min(componentDisplayIndex, childCount - 1));
|
|
87
|
+
if (currentIndex >= 0 && currentIndex !== targetIndex) {
|
|
88
|
+
this.#displayObject.setChildIndex(displayObject, targetIndex);
|
|
89
|
+
changed = true;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
componentDisplayIndex += 1;
|
|
94
|
+
}
|
|
95
|
+
// Ensure deterministic ordering among only component display objects without
|
|
96
|
+
// assuming a 1:1 mapping with all container children.
|
|
97
|
+
if (typeof this.#displayObject.getChildIndex === 'function' &&
|
|
98
|
+
typeof this.#displayObject.setChildIndex === 'function') {
|
|
99
|
+
let nextIndex = 0;
|
|
100
|
+
for (const component of this.components) {
|
|
101
|
+
const displayObject = component.displayObject;
|
|
102
|
+
if (!displayObject || displayObject.parent !== this.#displayObject) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const childCount = this.#displayObject.children?.length ?? 0;
|
|
106
|
+
if (childCount === 0) {
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
76
109
|
const currentIndex = this.#displayObject.getChildIndex(displayObject);
|
|
77
|
-
|
|
78
|
-
|
|
110
|
+
const targetIndex = Math.max(0, Math.min(nextIndex, childCount - 1));
|
|
111
|
+
if (currentIndex >= 0 && currentIndex !== targetIndex) {
|
|
112
|
+
this.#displayObject.setChildIndex(displayObject, targetIndex);
|
|
79
113
|
changed = true;
|
|
80
114
|
}
|
|
115
|
+
nextIndex += 1;
|
|
81
116
|
}
|
|
82
117
|
}
|
|
83
118
|
return changed;
|
|
@@ -1,15 +1,26 @@
|
|
|
1
1
|
import * as PIXI from 'pixi.js-legacy';
|
|
2
2
|
import { DomManager } from './DomManager.js';
|
|
3
3
|
import { StateManager } from './StateManager.svelte.js';
|
|
4
|
+
import type { DeterministicMediaManager } from './DeterministicMediaManager.js';
|
|
5
|
+
type ServerRendererMode = 'canvas' | 'webgl';
|
|
6
|
+
type PowerPreference = 'default' | 'high-performance' | 'low-power';
|
|
4
7
|
export declare class AppManager {
|
|
5
8
|
#private;
|
|
6
9
|
private state;
|
|
7
10
|
private dom;
|
|
8
11
|
private forceCanvas;
|
|
12
|
+
private serverRendererMode;
|
|
13
|
+
private preferWebGL2;
|
|
14
|
+
private powerPreference;
|
|
15
|
+
private deterministicMediaManager?;
|
|
9
16
|
constructor(cradle: {
|
|
10
17
|
stateManager: StateManager;
|
|
11
18
|
domManager: DomManager;
|
|
12
19
|
forceCanvas: boolean;
|
|
20
|
+
serverRendererMode?: ServerRendererMode;
|
|
21
|
+
preferWebGL2?: boolean;
|
|
22
|
+
powerPreference?: PowerPreference;
|
|
23
|
+
deterministicMediaManager?: DeterministicMediaManager;
|
|
13
24
|
});
|
|
14
25
|
get app(): PIXI.Application;
|
|
15
26
|
get stage(): PIXI.Container;
|
|
@@ -21,3 +32,4 @@ export declare class AppManager {
|
|
|
21
32
|
scale(scale: number): void;
|
|
22
33
|
destroy(): void;
|
|
23
34
|
}
|
|
35
|
+
export {};
|
|
@@ -9,10 +9,18 @@ export class AppManager {
|
|
|
9
9
|
state;
|
|
10
10
|
dom;
|
|
11
11
|
forceCanvas; // ForceCanvas
|
|
12
|
+
serverRendererMode;
|
|
13
|
+
preferWebGL2;
|
|
14
|
+
powerPreference;
|
|
15
|
+
deterministicMediaManager;
|
|
12
16
|
constructor(cradle) {
|
|
13
17
|
this.state = cradle.stateManager;
|
|
14
18
|
this.dom = cradle.domManager;
|
|
15
19
|
this.forceCanvas = cradle.forceCanvas;
|
|
20
|
+
this.serverRendererMode = cradle.serverRendererMode ?? 'canvas';
|
|
21
|
+
this.preferWebGL2 = cradle.preferWebGL2 ?? true;
|
|
22
|
+
this.powerPreference = cradle.powerPreference ?? 'high-performance';
|
|
23
|
+
this.deterministicMediaManager = cradle.deterministicMediaManager;
|
|
16
24
|
}
|
|
17
25
|
get app() {
|
|
18
26
|
return this.#app;
|
|
@@ -30,29 +38,190 @@ export class AppManager {
|
|
|
30
38
|
const { width, height, environment, scale } = this.state;
|
|
31
39
|
this.#destroyed = false;
|
|
32
40
|
const canvas = this.dom.canvas;
|
|
33
|
-
|
|
34
|
-
|
|
41
|
+
let rendererSelection = this.#resolveRendererSelection(environment);
|
|
42
|
+
if (environment === 'server' && rendererSelection.forceCanvas) {
|
|
43
|
+
canvas.getContext('2d', { willReadFrequently: true });
|
|
35
44
|
}
|
|
36
45
|
const { PixiPlugin } = await registerGsapPlugins();
|
|
37
46
|
// give the plugin a reference to the PIXI object
|
|
38
|
-
PixiPlugin.registerPIXI(
|
|
39
|
-
const
|
|
47
|
+
PixiPlugin.registerPIXI(PIXI);
|
|
48
|
+
const baseOptions = {
|
|
40
49
|
...PIXI_DEFAULTS,
|
|
41
50
|
preserveDrawingBuffer: environment === 'server',
|
|
42
51
|
// clearBeforeRender: environment === 'server' ? false : true,
|
|
43
52
|
width,
|
|
44
53
|
height,
|
|
45
54
|
view: canvas,
|
|
46
|
-
forceCanvas: environment === 'server' || this.forceCanvas,
|
|
47
55
|
backgroundColor: environment === 'server' ? 'transparent' : '#ffffff',
|
|
48
56
|
backgroundAlpha: environment === 'server' ? 0 : 1
|
|
49
57
|
};
|
|
50
|
-
this.#
|
|
58
|
+
let options = this.#buildRendererOptions(baseOptions, environment, rendererSelection);
|
|
59
|
+
try {
|
|
60
|
+
this.#app = new PIXI.Application(options);
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
if (!rendererSelection.forceCanvas) {
|
|
64
|
+
const fallbackReason = this.#buildInitFallbackReason(error);
|
|
65
|
+
this.#warnRendererFallback(fallbackReason);
|
|
66
|
+
rendererSelection = {
|
|
67
|
+
requestedMode: rendererSelection.requestedMode,
|
|
68
|
+
selectedMode: 'canvas',
|
|
69
|
+
forceCanvas: true,
|
|
70
|
+
fallbackOccurred: true,
|
|
71
|
+
fallbackReason
|
|
72
|
+
};
|
|
73
|
+
canvas.getContext('2d', { willReadFrequently: true });
|
|
74
|
+
options = this.#buildRendererOptions(baseOptions, environment, rendererSelection);
|
|
75
|
+
this.#app = new PIXI.Application(options);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (!rendererSelection.forceCanvas) {
|
|
82
|
+
const actualRenderer = this.#detectRendererType();
|
|
83
|
+
if (actualRenderer !== 'webgl') {
|
|
84
|
+
const fallbackReason = 'Renderer initialized as canvas despite serverRendererMode="webgl"';
|
|
85
|
+
this.#warnRendererFallback(fallbackReason);
|
|
86
|
+
rendererSelection = {
|
|
87
|
+
requestedMode: rendererSelection.requestedMode,
|
|
88
|
+
selectedMode: 'canvas',
|
|
89
|
+
forceCanvas: true,
|
|
90
|
+
fallbackOccurred: true,
|
|
91
|
+
fallbackReason
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
rendererSelection = { ...rendererSelection, selectedMode: actualRenderer };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
51
98
|
if (scale !== 1) {
|
|
52
99
|
this.scale(scale);
|
|
53
100
|
}
|
|
54
101
|
// Stop the default ticker as we'll use GSAP's ticker
|
|
55
102
|
this.#app.ticker.stop();
|
|
103
|
+
this.deterministicMediaManager?.recordRendererSelection({
|
|
104
|
+
rendererType: rendererSelection.selectedMode,
|
|
105
|
+
fallbackOccurred: rendererSelection.fallbackOccurred,
|
|
106
|
+
fallbackReason: rendererSelection.fallbackReason
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
#buildRendererOptions(baseOptions, environment, selection) {
|
|
110
|
+
const options = {
|
|
111
|
+
...baseOptions,
|
|
112
|
+
forceCanvas: selection.forceCanvas
|
|
113
|
+
};
|
|
114
|
+
if (environment === 'server' && selection.selectedMode === 'webgl' && !selection.forceCanvas) {
|
|
115
|
+
this.#configureWebGLEnvironmentPreference();
|
|
116
|
+
options.preference = 'webgl';
|
|
117
|
+
options.powerPreference = this.powerPreference;
|
|
118
|
+
}
|
|
119
|
+
return options;
|
|
120
|
+
}
|
|
121
|
+
#resolveRendererSelection(environment) {
|
|
122
|
+
if (environment !== 'server') {
|
|
123
|
+
return {
|
|
124
|
+
requestedMode: 'canvas',
|
|
125
|
+
selectedMode: this.forceCanvas ? 'canvas' : 'webgl',
|
|
126
|
+
forceCanvas: this.forceCanvas,
|
|
127
|
+
fallbackOccurred: false
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
const requestedMode = this.serverRendererMode;
|
|
131
|
+
if (this.forceCanvas) {
|
|
132
|
+
if (requestedMode === 'webgl') {
|
|
133
|
+
const fallbackReason = 'serverRendererMode="webgl" requested but forceCanvas=true override is enabled';
|
|
134
|
+
this.#warnRendererFallback(fallbackReason);
|
|
135
|
+
return {
|
|
136
|
+
requestedMode,
|
|
137
|
+
selectedMode: 'canvas',
|
|
138
|
+
forceCanvas: true,
|
|
139
|
+
fallbackOccurred: true,
|
|
140
|
+
fallbackReason
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
requestedMode,
|
|
145
|
+
selectedMode: 'canvas',
|
|
146
|
+
forceCanvas: true,
|
|
147
|
+
fallbackOccurred: false
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
if (requestedMode === 'canvas') {
|
|
151
|
+
return {
|
|
152
|
+
requestedMode,
|
|
153
|
+
selectedMode: 'canvas',
|
|
154
|
+
forceCanvas: true,
|
|
155
|
+
fallbackOccurred: false
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const support = this.#checkWebGLSupport();
|
|
159
|
+
if (!support.supported) {
|
|
160
|
+
this.#warnRendererFallback(support.reason);
|
|
161
|
+
return {
|
|
162
|
+
requestedMode,
|
|
163
|
+
selectedMode: 'canvas',
|
|
164
|
+
forceCanvas: true,
|
|
165
|
+
fallbackOccurred: true,
|
|
166
|
+
fallbackReason: support.reason
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
requestedMode,
|
|
171
|
+
selectedMode: 'webgl',
|
|
172
|
+
forceCanvas: false,
|
|
173
|
+
fallbackOccurred: false
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
#checkWebGLSupport() {
|
|
177
|
+
try {
|
|
178
|
+
if (typeof document === 'undefined') {
|
|
179
|
+
return { supported: false, reason: 'WebGL unavailable: no document in server environment' };
|
|
180
|
+
}
|
|
181
|
+
const probe = document.createElement('canvas');
|
|
182
|
+
if (this.preferWebGL2 && probe.getContext('webgl2')) {
|
|
183
|
+
return { supported: true, reason: '' };
|
|
184
|
+
}
|
|
185
|
+
if (probe.getContext('webgl') || probe.getContext('experimental-webgl')) {
|
|
186
|
+
return { supported: true, reason: '' };
|
|
187
|
+
}
|
|
188
|
+
return { supported: false, reason: 'WebGL unavailable: context creation failed' };
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return { supported: false, reason: 'WebGL unavailable: context probe failed' };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
#configureWebGLEnvironmentPreference() {
|
|
195
|
+
const pixiAny = PIXI;
|
|
196
|
+
if (!pixiAny.settings || !pixiAny.ENV) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (this.preferWebGL2 && pixiAny.ENV.WEBGL2 !== undefined) {
|
|
200
|
+
pixiAny.settings.PREFER_ENV = pixiAny.ENV.WEBGL2;
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (pixiAny.ENV.WEBGL !== undefined) {
|
|
204
|
+
pixiAny.settings.PREFER_ENV = pixiAny.ENV.WEBGL;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
#detectRendererType() {
|
|
208
|
+
if (!this.#app) {
|
|
209
|
+
return 'canvas';
|
|
210
|
+
}
|
|
211
|
+
const renderer = this.#app.renderer;
|
|
212
|
+
if (renderer?.gl || renderer?.context?.gl || renderer?.context?.webGLVersion) {
|
|
213
|
+
return 'webgl';
|
|
214
|
+
}
|
|
215
|
+
return 'canvas';
|
|
216
|
+
}
|
|
217
|
+
#buildInitFallbackReason(error) {
|
|
218
|
+
if (error instanceof Error && error.message) {
|
|
219
|
+
return `WebGL initialization failed: ${error.message}`;
|
|
220
|
+
}
|
|
221
|
+
return 'WebGL initialization failed with unknown error';
|
|
222
|
+
}
|
|
223
|
+
#warnRendererFallback(reason) {
|
|
224
|
+
console.warn(`[AppManager] Falling back to canvas renderer. ${reason}`);
|
|
56
225
|
}
|
|
57
226
|
async extractBase64(target, format = 'png', quality = 1) {
|
|
58
227
|
if (!this.#app)
|
|
@@ -199,11 +199,38 @@ export class ComponentsManager {
|
|
|
199
199
|
const displayObject = layer.displayObject;
|
|
200
200
|
if (!displayObject)
|
|
201
201
|
return;
|
|
202
|
-
// Sync displayObject children order with components array
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
202
|
+
// Sync displayObject children order with components array while clamping indexes.
|
|
203
|
+
let nextIndex = 0;
|
|
204
|
+
layer.components.forEach((component) => {
|
|
205
|
+
const componentDisplayObject = component.displayObject;
|
|
206
|
+
if (!componentDisplayObject) {
|
|
207
|
+
return;
|
|
206
208
|
}
|
|
209
|
+
const parent = componentDisplayObject.parent;
|
|
210
|
+
if (parent !== displayObject) {
|
|
211
|
+
if (parent && typeof parent.removeChild === 'function') {
|
|
212
|
+
parent.removeChild(componentDisplayObject);
|
|
213
|
+
}
|
|
214
|
+
const insertMax = displayObject.children?.length ?? 0;
|
|
215
|
+
const insertIndex = Math.max(0, Math.min(nextIndex, insertMax));
|
|
216
|
+
if (typeof displayObject.addChildAt === 'function') {
|
|
217
|
+
displayObject.addChildAt(componentDisplayObject, insertIndex);
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
displayObject.addChild(componentDisplayObject);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const childCount = displayObject.children?.length ?? 0;
|
|
224
|
+
if (childCount > 0 &&
|
|
225
|
+
typeof displayObject.getChildIndex === 'function' &&
|
|
226
|
+
typeof displayObject.setChildIndex === 'function') {
|
|
227
|
+
const currentIndex = displayObject.getChildIndex(componentDisplayObject);
|
|
228
|
+
const targetIndex = Math.max(0, Math.min(nextIndex, childCount - 1));
|
|
229
|
+
if (currentIndex >= 0 && currentIndex !== targetIndex) {
|
|
230
|
+
displayObject.setChildIndex(componentDisplayObject, targetIndex);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
nextIndex += 1;
|
|
207
234
|
});
|
|
208
235
|
}
|
|
209
236
|
bulkUpdate(updates) {
|
|
@@ -16,6 +16,15 @@ export declare class DeterministicMediaManager {
|
|
|
16
16
|
getFingerprint(): string;
|
|
17
17
|
setOneTimeOverride(componentId: string, frameIndex: number, payload: DeterministicFramePayload): void;
|
|
18
18
|
getDiagnosticsReport(): DeterministicDiagnosticsReport | null;
|
|
19
|
+
getSelectedRendererType(): 'canvas' | 'webgl';
|
|
20
|
+
recordRendererSelection(args: {
|
|
21
|
+
rendererType: 'canvas' | 'webgl';
|
|
22
|
+
fallbackOccurred?: boolean;
|
|
23
|
+
fallbackReason?: string;
|
|
24
|
+
}): void;
|
|
25
|
+
recordReadyAttempt(sceneFrameIndex: number, count?: number): void;
|
|
26
|
+
recordExtraRenderPass(sceneFrameIndex: number, count?: number): void;
|
|
27
|
+
recordBlurRedraw(sceneFrameIndex: number, count?: number): void;
|
|
19
28
|
destroy(): Promise<void>;
|
|
20
29
|
releaseComponent(componentId: string): Promise<void>;
|
|
21
30
|
}
|
|
@@ -4,6 +4,13 @@ const createDefaultDiagnosticsState = () => ({
|
|
|
4
4
|
providerHits: 0,
|
|
5
5
|
providerMisses: 0,
|
|
6
6
|
cacheHits: 0,
|
|
7
|
+
selectedRendererType: 'canvas',
|
|
8
|
+
rendererFallbackOccurred: false,
|
|
9
|
+
rendererFallbackReason: undefined,
|
|
10
|
+
readyAttempts: 0,
|
|
11
|
+
extraRenderPasses: 0,
|
|
12
|
+
blurRedraws: 0,
|
|
13
|
+
perFrame: new Map(),
|
|
7
14
|
latencyCount: 0,
|
|
8
15
|
latencyTotalMs: 0,
|
|
9
16
|
latencyMinMs: Number.POSITIVE_INFINITY,
|
|
@@ -127,11 +134,26 @@ export class DeterministicMediaManager {
|
|
|
127
134
|
? this.#diagnostics.latencyTotalMs / this.#diagnostics.latencyCount
|
|
128
135
|
: 0;
|
|
129
136
|
const minLatency = this.#diagnostics.latencyCount > 0 ? this.#diagnostics.latencyMinMs : 0;
|
|
137
|
+
const perFrame = {};
|
|
138
|
+
for (const [frameIndex, counters] of [...this.#diagnostics.perFrame.entries()].sort(([a], [b]) => a - b)) {
|
|
139
|
+
perFrame[String(frameIndex)] = {
|
|
140
|
+
readyAttempts: counters.readyAttempts,
|
|
141
|
+
extraRenderPasses: counters.extraRenderPasses,
|
|
142
|
+
blurRedraws: counters.blurRedraws
|
|
143
|
+
};
|
|
144
|
+
}
|
|
130
145
|
return {
|
|
131
146
|
providerHits: this.#diagnostics.providerHits,
|
|
132
147
|
providerMisses: this.#diagnostics.providerMisses,
|
|
133
148
|
cacheHits: this.#diagnostics.cacheHits,
|
|
134
149
|
cacheHitRatio,
|
|
150
|
+
selectedRendererType: this.#diagnostics.selectedRendererType,
|
|
151
|
+
rendererFallbackOccurred: this.#diagnostics.rendererFallbackOccurred,
|
|
152
|
+
rendererFallbackReason: this.#diagnostics.rendererFallbackReason,
|
|
153
|
+
readyAttempts: this.#diagnostics.readyAttempts,
|
|
154
|
+
extraRenderPasses: this.#diagnostics.extraRenderPasses,
|
|
155
|
+
blurRedraws: this.#diagnostics.blurRedraws,
|
|
156
|
+
perFrame,
|
|
135
157
|
latency: {
|
|
136
158
|
minMs: minLatency,
|
|
137
159
|
maxMs: this.#diagnostics.latencyMaxMs,
|
|
@@ -139,6 +161,28 @@ export class DeterministicMediaManager {
|
|
|
139
161
|
}
|
|
140
162
|
};
|
|
141
163
|
}
|
|
164
|
+
getSelectedRendererType() {
|
|
165
|
+
return this.#diagnostics.selectedRendererType;
|
|
166
|
+
}
|
|
167
|
+
recordRendererSelection(args) {
|
|
168
|
+
try {
|
|
169
|
+
this.#diagnostics.selectedRendererType = args.rendererType;
|
|
170
|
+
this.#diagnostics.rendererFallbackOccurred = Boolean(args.fallbackOccurred);
|
|
171
|
+
this.#diagnostics.rendererFallbackReason = args.fallbackReason;
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
// Diagnostics are best-effort and never allowed to fail the render path.
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
recordReadyAttempt(sceneFrameIndex, count = 1) {
|
|
178
|
+
this.#recordRuntimeCounter(sceneFrameIndex, 'readyAttempts', count);
|
|
179
|
+
}
|
|
180
|
+
recordExtraRenderPass(sceneFrameIndex, count = 1) {
|
|
181
|
+
this.#recordRuntimeCounter(sceneFrameIndex, 'extraRenderPasses', count);
|
|
182
|
+
}
|
|
183
|
+
recordBlurRedraw(sceneFrameIndex, count = 1) {
|
|
184
|
+
this.#recordRuntimeCounter(sceneFrameIndex, 'blurRedraws', count);
|
|
185
|
+
}
|
|
142
186
|
async destroy() {
|
|
143
187
|
for (const [cacheKey, cached] of this.#cacheByCacheKey.entries()) {
|
|
144
188
|
this.#disposeCachedOverride(cacheKey, cached);
|
|
@@ -314,6 +358,27 @@ export class DeterministicMediaManager {
|
|
|
314
358
|
// Diagnostics are best-effort and never allowed to fail the render path.
|
|
315
359
|
}
|
|
316
360
|
}
|
|
361
|
+
#recordRuntimeCounter(sceneFrameIndex, counter, count) {
|
|
362
|
+
if (!this.config.diagnostics) {
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if (!Number.isFinite(count) || count <= 0) {
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
try {
|
|
369
|
+
this.#diagnostics[counter] += count;
|
|
370
|
+
const existing = this.#diagnostics.perFrame.get(sceneFrameIndex) ?? {
|
|
371
|
+
readyAttempts: 0,
|
|
372
|
+
extraRenderPasses: 0,
|
|
373
|
+
blurRedraws: 0
|
|
374
|
+
};
|
|
375
|
+
existing[counter] += count;
|
|
376
|
+
this.#diagnostics.perFrame.set(sceneFrameIndex, existing);
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
// Diagnostics are best-effort and never allowed to fail the render path.
|
|
380
|
+
}
|
|
381
|
+
}
|
|
317
382
|
#countSceneMediaComponents(sceneData) {
|
|
318
383
|
return sceneData.layers.reduce((count, layer) => {
|
|
319
384
|
const mediaCount = layer.components.filter((component) => component.type === 'VIDEO' || component.type === 'GIF').length;
|
|
@@ -35,6 +35,10 @@ export type DeterministicMediaConfig = {
|
|
|
35
35
|
strict: boolean;
|
|
36
36
|
diagnostics: boolean;
|
|
37
37
|
maxCachedTextures?: number;
|
|
38
|
+
seekMaxAttempts?: number;
|
|
39
|
+
loadingMaxAttempts?: number;
|
|
40
|
+
readyYieldMs?: number;
|
|
41
|
+
blurDownscale?: number;
|
|
38
42
|
provider?: DeterministicFrameProvider;
|
|
39
43
|
};
|
|
40
44
|
export declare const DeterministicMediaConfigShape: z.ZodPrefault<z.ZodObject<{
|
|
@@ -42,6 +46,10 @@ export declare const DeterministicMediaConfigShape: z.ZodPrefault<z.ZodObject<{
|
|
|
42
46
|
strict: z.ZodPrefault<z.ZodBoolean>;
|
|
43
47
|
diagnostics: z.ZodPrefault<z.ZodBoolean>;
|
|
44
48
|
maxCachedTextures: z.ZodOptional<z.ZodNumber>;
|
|
49
|
+
seekMaxAttempts: z.ZodPrefault<z.ZodNumber>;
|
|
50
|
+
loadingMaxAttempts: z.ZodPrefault<z.ZodNumber>;
|
|
51
|
+
readyYieldMs: z.ZodPrefault<z.ZodNumber>;
|
|
52
|
+
blurDownscale: z.ZodPrefault<z.ZodNumber>;
|
|
45
53
|
provider: z.ZodOptional<z.ZodCustom<DeterministicFrameProvider, DeterministicFrameProvider>>;
|
|
46
54
|
}, z.core.$strip>>;
|
|
47
55
|
export declare const defaultDeterministicMediaConfig: DeterministicMediaConfig;
|
|
@@ -55,6 +63,17 @@ export type DeterministicDiagnosticsReport = {
|
|
|
55
63
|
providerMisses: number;
|
|
56
64
|
cacheHits: number;
|
|
57
65
|
cacheHitRatio: number;
|
|
66
|
+
selectedRendererType: 'canvas' | 'webgl';
|
|
67
|
+
rendererFallbackOccurred: boolean;
|
|
68
|
+
rendererFallbackReason?: string;
|
|
69
|
+
readyAttempts: number;
|
|
70
|
+
extraRenderPasses: number;
|
|
71
|
+
blurRedraws: number;
|
|
72
|
+
perFrame: Record<string, {
|
|
73
|
+
readyAttempts: number;
|
|
74
|
+
extraRenderPasses: number;
|
|
75
|
+
blurRedraws: number;
|
|
76
|
+
}>;
|
|
58
77
|
latency: {
|
|
59
78
|
minMs: number;
|
|
60
79
|
maxMs: number;
|
|
@@ -4,6 +4,10 @@ export const DeterministicMediaConfigShape = z.object({
|
|
|
4
4
|
strict: z.boolean().prefault(false),
|
|
5
5
|
diagnostics: z.boolean().prefault(false),
|
|
6
6
|
maxCachedTextures: z.number().int().positive().optional(),
|
|
7
|
+
seekMaxAttempts: z.number().int().positive().prefault(4),
|
|
8
|
+
loadingMaxAttempts: z.number().int().positive().prefault(2),
|
|
9
|
+
readyYieldMs: z.number().int().nonnegative().prefault(0),
|
|
10
|
+
blurDownscale: z.number().positive().max(1).prefault(0.33),
|
|
7
11
|
provider: z.custom().optional()
|
|
8
12
|
}).prefault({});
|
|
9
13
|
export const defaultDeterministicMediaConfig = DeterministicMediaConfigShape.parse(undefined);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "visualfries",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10120",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "ContentFries",
|
|
6
6
|
"repository": {
|
|
@@ -90,6 +90,7 @@
|
|
|
90
90
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
|
91
91
|
"test": "pnpm run test:unit -- --run",
|
|
92
92
|
"test:unit": "vitest",
|
|
93
|
+
"bench:deterministic": "pnpm vitest tests/perf/deterministic-server-render.perf.test.ts --run",
|
|
93
94
|
"lint": "eslint . && prettier --check .",
|
|
94
95
|
"format": "prettier --write .",
|
|
95
96
|
"knip": "knip",
|