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 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
- frame = await this.seekAndRenderFrame(frameTime, undefined, format, quality, imageOptions);
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 maxAttempts = 12;
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
- const pending = this.#getPendingDeterministicComponents();
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
- // Wait for fonts to be ready before rendering
89
- // This is critical for subtitle animations that use SplitText -
90
- // if fonts aren't loaded, text measurements will be wrong and
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
- // Try multiple render passes until loading state clears or attempts exhausted
104
- const maxAttempts = 10;
105
- for (let i = 0; i < maxAttempts; i += 1) {
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 (this.deterministicMediaManager.isEnabled()) {
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
- // Create new PIXI texture from blurred canvas
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
- #drawBlurredBackground(strength = 50) {
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
- ctx.drawImage(sourceElement, 0, 0, this.#bgCanvas.width, this.#bgCanvas.height);
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
- for (let index = 0; index < this.components.length; index += 1) {
61
- const component = this.components[index];
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.addChild(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
- if (currentIndex !== index) {
78
- this.#displayObject.setChildIndex(displayObject, index);
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
- if (environment === 'server') {
34
- const ctx = canvas.getContext('2d', { willReadFrequently: true });
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(this.app);
39
- const options = {
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.#app = new PIXI.Application(options);
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
- layer.components.forEach((component, index) => {
204
- if (component.displayObject) {
205
- displayObject.setChildIndex(component.displayObject, index);
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.10110",
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",