visualfries 0.1.10101 → 0.1.10110

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.
@@ -8,7 +8,7 @@ import { DomManager } from './managers/DomManager.js';
8
8
  import { AppManager } from './managers/AppManager.svelte.js';
9
9
  import { ComponentsManager } from './managers/ComponentsManager.svelte.js';
10
10
  import type { EventMap, EventType, EventPayload, BuilderState, ISceneBuilder } from './';
11
- import type { DeterministicFrameProvider, DeterministicMediaConfig, DeterministicDiagnosticsReport, RenderFrameRangeOptions, RenderFrameRangeSummary } from './';
11
+ import type { DeterministicFrameProvider, DeterministicMediaConfig, DeterministicDiagnosticsReport, FrameImageEncodingOptions, RenderFrameRangeOptions, RenderFrameRangeSummary } from './';
12
12
  import { MediaManager } from './managers/MediaManager.js';
13
13
  import { DeterministicMediaManager } from './managers/DeterministicMediaManager.js';
14
14
  import { LayersManager } from './managers/LayersManager.svelte.js';
@@ -5377,7 +5377,7 @@ export declare class SceneBuilder implements ISceneBuilder {
5377
5377
  getDeterministicFrameProvider(): DeterministicFrameProvider | null;
5378
5378
  getDeterministicMediaConfig(): DeterministicMediaConfig;
5379
5379
  getDiagnosticsReport(): DeterministicDiagnosticsReport | null;
5380
- seekAndRenderFrame(time: number, target?: PIXI.DisplayObject | PIXI.RenderTexture, format?: string, quality?: number): Promise<string | ArrayBuffer | Blob>;
5380
+ seekAndRenderFrame(time: number, target?: PIXI.DisplayObject | PIXI.RenderTexture, format?: string, quality?: number, imageOptions?: FrameImageEncodingOptions): Promise<string | ArrayBuffer | Blob>;
5381
5381
  /**
5382
5382
  * Check if seeking to a specific time would result in visual changes
5383
5383
  * without actually extracting frame data.
@@ -5402,7 +5402,7 @@ export declare class SceneBuilder implements ISceneBuilder {
5402
5402
  * }
5403
5403
  */
5404
5404
  isSceneDirty(time: number): Promise<boolean>;
5405
- renderFrame(target?: PIXI.DisplayObject | PIXI.RenderTexture, format?: string, quality?: number): Promise<string | ArrayBuffer | Blob>;
5405
+ renderFrame(target?: PIXI.DisplayObject | PIXI.RenderTexture, format?: string, quality?: number, imageOptions?: FrameImageEncodingOptions): Promise<string | ArrayBuffer | Blob>;
5406
5406
  renderFrameRange(options: RenderFrameRangeOptions): Promise<RenderFrameRangeSummary>;
5407
5407
  log(message: string): void;
5408
5408
  play(changeState?: boolean): void;
@@ -3,6 +3,7 @@ import { gsap } from 'gsap';
3
3
  import { ComponentShape } from './';
4
4
  import { buildCharactersListFromComponentsAndSubtitles, changeIdDeep } from './utils/utils.js';
5
5
  import { loadFonts } from './utils/document.js';
6
+ import { discoverRequiredFontVariants } from './fonts/fontDiscovery.js';
6
7
  import { CommandType } from './commands/CommandTypes.js';
7
8
  import { CommandRunner } from './commands/CommandRunner.js';
8
9
  import { StateManager } from './managers/StateManager.svelte.js';
@@ -172,7 +173,8 @@ export class SceneBuilder {
172
173
  this.domManager.scale(clampedScale);
173
174
  }
174
175
  async loadFonts(fonts) {
175
- return await loadFonts(fonts);
176
+ const variants = discoverRequiredFontVariants(this.sceneData, fonts);
177
+ return await loadFonts(fonts, variants);
176
178
  }
177
179
  async initialize() {
178
180
  if (this.initialized) {
@@ -183,9 +185,7 @@ export class SceneBuilder {
183
185
  this.renderTicker = () => {
184
186
  this.render();
185
187
  };
186
- if (this.fonts.length > 0) {
187
- await this.loadFonts(this.fonts);
188
- }
188
+ await this.loadFonts(this.fonts);
189
189
  this.layersManager.setAppManager(this.appManager);
190
190
  await this.appManager.initialize();
191
191
  if (this.stateManager.scale !== 1) {
@@ -310,14 +310,14 @@ export class SceneBuilder {
310
310
  getDiagnosticsReport() {
311
311
  return this.deterministicMediaManager.getDiagnosticsReport();
312
312
  }
313
- async seekAndRenderFrame(time, target, format = 'png', quality = 1) {
313
+ async seekAndRenderFrame(time, target, format = 'png', quality = 1, imageOptions) {
314
314
  await this.seek(time);
315
315
  // In server mode SeekCommand performs awaited render preparation.
316
316
  // Keep client render behavior unchanged.
317
317
  if (this.environment !== 'server') {
318
318
  this.render();
319
319
  }
320
- const frame = await this.renderFrame(target, format, quality);
320
+ const frame = await this.renderFrame(target, format, quality, imageOptions);
321
321
  return frame;
322
322
  }
323
323
  /**
@@ -354,8 +354,14 @@ export class SceneBuilder {
354
354
  this.render();
355
355
  return wasDirty || this.stateManager.isDirty;
356
356
  }
357
- async renderFrame(target, format = 'png', quality = 1) {
358
- const frame = (await this.run(CommandType.RENDER_FRAME, { target, format, quality }));
357
+ async renderFrame(target, format = 'png', quality = 1, imageOptions) {
358
+ const frame = (await this.run(CommandType.RENDER_FRAME, {
359
+ target,
360
+ format,
361
+ quality,
362
+ imageFormat: imageOptions?.imageFormat,
363
+ imageQuality: imageOptions?.imageQuality
364
+ }));
359
365
  if (!frame) {
360
366
  throw new Error('Rendering frame failed');
361
367
  }
@@ -367,6 +373,10 @@ export class SceneBuilder {
367
373
  }
368
374
  const format = options.format ?? 'blob';
369
375
  const quality = options.quality ?? 1;
376
+ const imageOptions = {
377
+ imageFormat: options.imageFormat,
378
+ imageQuality: options.imageQuality
379
+ };
370
380
  const skipDuplicates = options.skipDuplicates ?? false;
371
381
  const fromFrame = Math.max(0, Math.floor(options.fromFrame));
372
382
  const toFrame = Math.max(fromFrame, Math.floor(options.toFrame));
@@ -374,6 +384,7 @@ export class SceneBuilder {
374
384
  let framesSkipped = 0;
375
385
  let aborted = false;
376
386
  let previousFrame = null;
387
+ let previousMimeType;
377
388
  for (let frameIndex = fromFrame; frameIndex < toFrame; frameIndex += 1) {
378
389
  if (options.signal?.aborted) {
379
390
  aborted = true;
@@ -381,23 +392,35 @@ export class SceneBuilder {
381
392
  }
382
393
  let frame;
383
394
  let isDuplicate = false;
395
+ let mimeType;
384
396
  const frameTime = frameIndex / this.fps;
385
397
  if (skipDuplicates) {
386
398
  const isDirty = await this.isSceneDirty(frameTime);
387
399
  if (!isDirty && previousFrame) {
388
400
  frame = previousFrame;
401
+ mimeType = previousMimeType;
389
402
  isDuplicate = true;
390
403
  framesSkipped += 1;
391
404
  }
392
405
  else {
393
- frame = await this.seekAndRenderFrame(frameTime, undefined, format, quality);
406
+ frame = await this.seekAndRenderFrame(frameTime, undefined, format, quality, imageOptions);
394
407
  previousFrame = frame;
395
408
  }
396
409
  }
397
410
  else {
398
- frame = await this.seekAndRenderFrame(frameTime, undefined, format, quality);
411
+ frame = await this.seekAndRenderFrame(frameTime, undefined, format, quality, imageOptions);
399
412
  previousFrame = frame;
400
413
  }
414
+ if (!mimeType && frame instanceof Blob) {
415
+ mimeType = frame.type || undefined;
416
+ }
417
+ if (!mimeType && format === 'blob') {
418
+ const imageFormat = imageOptions.imageFormat ?? 'png';
419
+ mimeType = imageFormat === 'jpg' || imageFormat === 'jpeg' ? 'image/jpeg' : 'image/png';
420
+ }
421
+ if (!isDuplicate) {
422
+ previousMimeType = mimeType;
423
+ }
401
424
  let released = false;
402
425
  const release = () => {
403
426
  if (released) {
@@ -409,6 +432,7 @@ export class SceneBuilder {
409
432
  frameIndex,
410
433
  frame,
411
434
  isDuplicate,
435
+ mimeType,
412
436
  release
413
437
  });
414
438
  release();
@@ -17,5 +17,6 @@ export declare class RenderFrameCommand implements Command<string | ArrayBuffer
17
17
  appManager: AppManager;
18
18
  deterministicMediaManager?: DeterministicMediaManager;
19
19
  });
20
+ private resolveBlobMimeType;
20
21
  execute(args: unknown): Promise<string | ArrayBuffer | Blob | null>;
21
22
  }
@@ -3,10 +3,13 @@ import { StateManager } from '../managers/StateManager.svelte.js';
3
3
  import { DomManager } from '../managers/DomManager.js';
4
4
  import { AppManager } from '../managers/AppManager.svelte.js';
5
5
  import { DeterministicMediaManager } from '../managers/DeterministicMediaManager.js';
6
- const replaceSourceOnTimeSchema = z.object({
7
- format: z.enum(['arraybuffer', 'blob', 'png', 'jpg', 'jpeg']),
8
- quality: z.number().min(0).max(1),
9
- target: z.any()
6
+ import { RenderFrameEncodingError } from '../schemas/runtime/deterministic.js';
7
+ const renderFrameSchema = z.object({
8
+ format: z.enum(['arraybuffer', 'blob', 'png', 'jpg', 'jpeg']).prefault('png'),
9
+ quality: z.number().min(0).max(1).prefault(1),
10
+ target: z.any().optional(),
11
+ imageFormat: z.enum(['png', 'jpg', 'jpeg']).optional(),
12
+ imageQuality: z.number().min(0).max(1).optional()
10
13
  });
11
14
  export class RenderFrameCommand {
12
15
  sceneState;
@@ -22,12 +25,21 @@ export class RenderFrameCommand {
22
25
  this.appManager = cradle.appManager;
23
26
  this.deterministicMediaManager = cradle.deterministicMediaManager;
24
27
  }
28
+ resolveBlobMimeType(imageFormat) {
29
+ if (!imageFormat) {
30
+ return undefined;
31
+ }
32
+ if (imageFormat === 'jpg' || imageFormat === 'jpeg') {
33
+ return 'image/jpeg';
34
+ }
35
+ return 'image/png';
36
+ }
25
37
  async execute(args) {
26
- const check = replaceSourceOnTimeSchema.safeParse(args);
38
+ const check = renderFrameSchema.safeParse(args);
27
39
  if (!check.success) {
28
40
  return null;
29
41
  }
30
- const { format, quality, target } = check.data;
42
+ const { format, quality, target, imageFormat, imageQuality } = check.data;
31
43
  const currentDeterministicFingerprint = this.deterministicMediaManager?.isEnabled() ? this.deterministicMediaManager.getFingerprint() : '';
32
44
  // Server optimization: Return cached frame if nothing changed visually and render args match
33
45
  if (this.sceneState.environment === 'server' && !this.sceneState.isDirty) {
@@ -35,7 +47,9 @@ export class RenderFrameCommand {
35
47
  // Check if render args match current args
36
48
  const argsMatch = this.lastRenderArgs.format === format &&
37
49
  this.lastRenderArgs.quality === quality &&
38
- this.lastRenderArgs.target === target;
50
+ this.lastRenderArgs.target === target &&
51
+ this.lastRenderArgs.imageFormat === imageFormat &&
52
+ this.lastRenderArgs.imageQuality === imageQuality;
39
53
  if (argsMatch && this.lastDeterministicFingerprint === currentDeterministicFingerprint) {
40
54
  return this.lastRenderedFrame;
41
55
  }
@@ -59,11 +73,17 @@ export class RenderFrameCommand {
59
73
  }));
60
74
  }
61
75
  if (format === 'blob') {
62
- frame = (await new Promise((resolve) => {
76
+ const mimeType = this.resolveBlobMimeType(imageFormat);
77
+ const blobQuality = imageQuality ?? quality;
78
+ frame = (await new Promise((resolve, reject) => {
63
79
  requestAnimationFrame(() => {
64
80
  this.domManager.canvas.toBlob((blob) => {
81
+ if (!blob) {
82
+ reject(new RenderFrameEncodingError(`RenderFrameCommand: canvas.toBlob returned null for format="blob" (imageFormat="${imageFormat ?? 'default'}").`));
83
+ return;
84
+ }
65
85
  resolve(blob);
66
- });
86
+ }, mimeType, blobQuality);
67
87
  });
68
88
  }));
69
89
  }
@@ -73,7 +93,7 @@ export class RenderFrameCommand {
73
93
  ? (await new Promise((resolve) => {
74
94
  requestAnimationFrame(() => {
75
95
  const frame = format === 'jpg' || format === 'jpeg'
76
- ? this.domManager.canvas.toDataURL('image/jpeg', quality)
96
+ ? this.domManager.canvas.toDataURL('image/jpeg', imageQuality ?? quality)
77
97
  : this.domManager.canvas.toDataURL();
78
98
  resolve(frame);
79
99
  });
@@ -90,7 +110,7 @@ export class RenderFrameCommand {
90
110
  // Cache frame and render args, then clear dirty flag after successful render
91
111
  if (this.sceneState.environment === 'server') {
92
112
  this.lastRenderedFrame = frame;
93
- this.lastRenderArgs = { format, quality, target };
113
+ this.lastRenderArgs = { format, quality, target, imageFormat, imageQuality };
94
114
  this.lastDeterministicFingerprint = currentDeterministicFingerprint;
95
115
  this.sceneState.clearDirty();
96
116
  }
@@ -0,0 +1,9 @@
1
+ import type { FontType, Scene } from '..';
2
+ export type FontVariantDescriptor = {
3
+ family: string;
4
+ weight: number;
5
+ source: 'google' | 'custom';
6
+ fileUrl?: string;
7
+ };
8
+ export declare const extractConfiguredFontVariants: (configuredFonts?: FontType[]) => FontVariantDescriptor[];
9
+ export declare const discoverRequiredFontVariants: (sceneData: Scene, configuredFonts?: FontType[]) => FontVariantDescriptor[];
@@ -0,0 +1,202 @@
1
+ const NAMED_WEIGHTS = {
2
+ normal: 400,
3
+ regular: 400,
4
+ italic: 400,
5
+ bold: 700,
6
+ bolder: 700,
7
+ lighter: 300
8
+ };
9
+ const DEFAULT_WEIGHT = 400;
10
+ const normalizeFamily = (value) => {
11
+ if (typeof value !== 'string') {
12
+ return null;
13
+ }
14
+ const trimmed = value.trim().replace(/^['"]|['"]$/g, '');
15
+ return trimmed.length > 0 ? trimmed : null;
16
+ };
17
+ const normalizeWeight = (value) => {
18
+ if (typeof value === 'number' && value >= 100 && value <= 900) {
19
+ return Math.round(value / 100) * 100;
20
+ }
21
+ if (typeof value !== 'string') {
22
+ return null;
23
+ }
24
+ const normalized = value.trim().toLowerCase();
25
+ if (!normalized) {
26
+ return null;
27
+ }
28
+ if (NAMED_WEIGHTS[normalized] !== undefined) {
29
+ return NAMED_WEIGHTS[normalized];
30
+ }
31
+ const directNumeric = Number.parseInt(normalized, 10);
32
+ if (!Number.isNaN(directNumeric) && directNumeric >= 100 && directNumeric <= 900) {
33
+ return Math.round(directNumeric / 100) * 100;
34
+ }
35
+ const embeddedWeightMatch = normalized.match(/([1-9]00)/);
36
+ if (embeddedWeightMatch?.[1]) {
37
+ return Number.parseInt(embeddedWeightMatch[1], 10);
38
+ }
39
+ return null;
40
+ };
41
+ const parseWeightsFromVariants = (variants) => {
42
+ if (!Array.isArray(variants)) {
43
+ return [];
44
+ }
45
+ const weights = new Set();
46
+ for (const variant of variants) {
47
+ const normalizedWeight = normalizeWeight(variant);
48
+ if (normalizedWeight !== null) {
49
+ weights.add(normalizedWeight);
50
+ }
51
+ }
52
+ return [...weights];
53
+ };
54
+ const parseConfiguredFamilyDescriptor = (value, fallbackFamily) => {
55
+ const [familyPart, variantPart] = value.split(':', 2);
56
+ const family = normalizeFamily(familyPart) ?? normalizeFamily(fallbackFamily) ?? fallbackFamily;
57
+ const weights = new Set();
58
+ if (variantPart) {
59
+ const numericMatches = variantPart.matchAll(/([1-9]00)/g);
60
+ for (const match of numericMatches) {
61
+ const parsed = Number.parseInt(match[1], 10);
62
+ if (!Number.isNaN(parsed)) {
63
+ weights.add(parsed);
64
+ }
65
+ }
66
+ if (weights.size === 0) {
67
+ for (const token of variantPart.split(/[;,]/)) {
68
+ const parsed = normalizeWeight(token);
69
+ if (parsed !== null) {
70
+ weights.add(parsed);
71
+ }
72
+ }
73
+ }
74
+ }
75
+ if (weights.size === 0) {
76
+ weights.add(DEFAULT_WEIGHT);
77
+ }
78
+ return {
79
+ family,
80
+ weights: [...weights]
81
+ };
82
+ };
83
+ const parseConfiguredFonts = (configuredFonts) => {
84
+ const parsedFonts = [];
85
+ for (const configuredFont of configuredFonts) {
86
+ const alias = normalizeFamily(configuredFont.alias);
87
+ const fallbackFamily = alias ?? 'Unnamed Font';
88
+ const configuredFamilyDescriptor = configuredFont.data?.family ?? fallbackFamily;
89
+ const parsedFamilyDescriptor = parseConfiguredFamilyDescriptor(configuredFamilyDescriptor, fallbackFamily);
90
+ const fileUrl = normalizeFamily(configuredFont.src ?? configuredFont.url);
91
+ parsedFonts.push({
92
+ family: parsedFamilyDescriptor.family,
93
+ source: configuredFont.source,
94
+ fileUrl: fileUrl ?? undefined,
95
+ weights: parsedFamilyDescriptor.weights,
96
+ aliases: alias ? [alias] : []
97
+ });
98
+ }
99
+ return parsedFonts;
100
+ };
101
+ const createConfiguredLookup = (configuredFonts) => {
102
+ const lookup = new Map();
103
+ for (const configuredFont of configuredFonts) {
104
+ const keys = [configuredFont.family, ...configuredFont.aliases];
105
+ for (const key of keys) {
106
+ lookup.set(key.toLowerCase(), configuredFont);
107
+ }
108
+ }
109
+ return lookup;
110
+ };
111
+ const upsertVariant = (variants, variant) => {
112
+ const key = `${variant.family.toLowerCase()}::${variant.weight}`;
113
+ const existing = variants.get(key);
114
+ if (!existing) {
115
+ variants.set(key, variant);
116
+ return;
117
+ }
118
+ // Prefer custom source when available and preserve fileUrl if provided by any input.
119
+ if (existing.source !== 'custom' && variant.source === 'custom') {
120
+ existing.source = 'custom';
121
+ }
122
+ if (!existing.fileUrl && variant.fileUrl) {
123
+ existing.fileUrl = variant.fileUrl;
124
+ }
125
+ };
126
+ const collectComponentTextVariants = (component, configuredLookup, output) => {
127
+ const textAppearance = component?.appearance?.text;
128
+ if (!textAppearance) {
129
+ return;
130
+ }
131
+ const family = normalizeFamily(textAppearance.fontFamily) ?? normalizeFamily(textAppearance.fontSource?.family);
132
+ if (!family) {
133
+ return;
134
+ }
135
+ const configMatch = configuredLookup.get(family.toLowerCase());
136
+ const source = (textAppearance.fontSource?.source ?? configMatch?.source ?? 'google');
137
+ const fileUrl = normalizeFamily(textAppearance.fontSource?.fileUrl) ?? configMatch?.fileUrl ?? undefined;
138
+ const weights = new Set();
139
+ const styleWeights = [
140
+ textAppearance.fontWeight,
141
+ textAppearance.activeWord?.fontWeight,
142
+ textAppearance.activeLine?.fontWeight
143
+ ];
144
+ for (const styleWeight of styleWeights) {
145
+ const parsed = normalizeWeight(styleWeight);
146
+ if (parsed !== null) {
147
+ weights.add(parsed);
148
+ }
149
+ }
150
+ for (const variantWeight of parseWeightsFromVariants(textAppearance.fontSource?.variants)) {
151
+ weights.add(variantWeight);
152
+ }
153
+ if (weights.size === 0 && configMatch?.weights?.length) {
154
+ for (const configuredWeight of configMatch.weights) {
155
+ weights.add(configuredWeight);
156
+ }
157
+ }
158
+ if (weights.size === 0) {
159
+ weights.add(DEFAULT_WEIGHT);
160
+ }
161
+ for (const weight of weights) {
162
+ upsertVariant(output, {
163
+ family,
164
+ weight,
165
+ source,
166
+ fileUrl
167
+ });
168
+ }
169
+ };
170
+ export const extractConfiguredFontVariants = (configuredFonts = []) => {
171
+ const variants = new Map();
172
+ const parsedFonts = parseConfiguredFonts(configuredFonts);
173
+ for (const parsedFont of parsedFonts) {
174
+ for (const weight of parsedFont.weights) {
175
+ upsertVariant(variants, {
176
+ family: parsedFont.family,
177
+ weight,
178
+ source: parsedFont.source,
179
+ fileUrl: parsedFont.fileUrl
180
+ });
181
+ }
182
+ }
183
+ return [...variants.values()];
184
+ };
185
+ export const discoverRequiredFontVariants = (sceneData, configuredFonts = []) => {
186
+ const variants = new Map();
187
+ const parsedConfiguredFonts = parseConfiguredFonts(configuredFonts);
188
+ const configuredLookup = createConfiguredLookup(parsedConfiguredFonts);
189
+ for (const layer of sceneData.layers ?? []) {
190
+ for (const component of layer.components ?? []) {
191
+ if (component.type !== 'TEXT' && component.type !== 'SUBTITLES') {
192
+ continue;
193
+ }
194
+ collectComponentTextVariants(component, configuredLookup, variants);
195
+ }
196
+ }
197
+ // Preserve backwards compatibility with caller-supplied fonts by including explicit config entries.
198
+ for (const configuredVariant of extractConfiguredFontVariants(configuredFonts)) {
199
+ upsertVariant(variants, configuredVariant);
200
+ }
201
+ return [...variants.values()];
202
+ };
@@ -18,6 +18,7 @@ export declare class Layer implements ILayer {
18
18
  });
19
19
  build(): Promise<void>;
20
20
  addComponent(component: IComponent): void;
21
+ syncDisplayObjects(): boolean;
21
22
  removeComponent(component: IComponent): void;
22
23
  update(props: Partial<SceneLayerInput>): void;
23
24
  setOrder(order: number): void;
@@ -52,6 +52,36 @@ export class Layer {
52
52
  this.components.sort((a, b) => a.props.timeline.startAt - b.props.timeline.startAt);
53
53
  this.#emit('layerschange');
54
54
  }
55
+ syncDisplayObjects() {
56
+ let changed = false;
57
+ if (!this.#displayObject) {
58
+ return false;
59
+ }
60
+ for (let index = 0; index < this.components.length; index += 1) {
61
+ const component = this.components[index];
62
+ const displayObject = component.displayObject;
63
+ if (!displayObject) {
64
+ continue;
65
+ }
66
+ const parent = displayObject.parent;
67
+ if (parent !== this.#displayObject) {
68
+ if (parent && typeof parent.removeChild === 'function') {
69
+ parent.removeChild(displayObject);
70
+ }
71
+ this.#displayObject.addChild(displayObject);
72
+ changed = true;
73
+ }
74
+ if (typeof this.#displayObject.getChildIndex === 'function' &&
75
+ typeof this.#displayObject.setChildIndex === 'function') {
76
+ const currentIndex = this.#displayObject.getChildIndex(displayObject);
77
+ if (currentIndex !== index) {
78
+ this.#displayObject.setChildIndex(displayObject, index);
79
+ changed = true;
80
+ }
81
+ }
82
+ }
83
+ return changed;
84
+ }
55
85
  removeComponent(component) {
56
86
  const hasComponent = this.components.find((c) => c.id === component.id);
57
87
  if (hasComponent) {
@@ -3,11 +3,14 @@ import { EventManager } from './EventManager.js';
3
3
  import type { ResourceManager, IComponent, ComponentData } from '..';
4
4
  import type { AppearanceInput } from '..';
5
5
  import { AppManager } from './AppManager.svelte.js';
6
+ import { LayersManager } from './LayersManager.svelte.js';
6
7
  export declare class RenderManager {
8
+ #private;
7
9
  private state;
8
10
  private componentsManager;
9
11
  private eventManager;
10
12
  private appManager;
13
+ private layersManager;
11
14
  private lastActiveById;
12
15
  private lastRenderTime;
13
16
  constructor(cradle: {
@@ -15,6 +18,7 @@ export declare class RenderManager {
15
18
  componentsManager: ResourceManager<IComponent, ComponentData, AppearanceInput>;
16
19
  eventManager: EventManager;
17
20
  appManager: AppManager;
21
+ layersManager: LayersManager;
18
22
  });
19
23
  private initializeEventListeners;
20
24
  private handleBeforeRender;
@@ -1,11 +1,13 @@
1
1
  import { StateManager } from './StateManager.svelte.js';
2
2
  import { EventManager } from './EventManager.js';
3
3
  import { AppManager } from './AppManager.svelte.js';
4
+ import { LayersManager } from './LayersManager.svelte.js';
4
5
  export class RenderManager {
5
6
  state;
6
7
  componentsManager;
7
8
  eventManager;
8
9
  appManager;
10
+ layersManager;
9
11
  // Track last visibility to ensure we run one more update to hide components that just became invisible
10
12
  lastActiveById = new Map();
11
13
  lastRenderTime = -1;
@@ -14,6 +16,7 @@ export class RenderManager {
14
16
  this.componentsManager = cradle.componentsManager;
15
17
  this.eventManager = cradle.eventManager;
16
18
  this.appManager = cradle.appManager;
19
+ this.layersManager = cradle.layersManager;
17
20
  this.initializeEventListeners();
18
21
  }
19
22
  initializeEventListeners() {
@@ -27,6 +30,18 @@ export class RenderManager {
27
30
  });
28
31
  }
29
32
  async handleBeforeRender() { }
33
+ #syncLayerDisplayObjects() {
34
+ const layers = this.layersManager.getAll();
35
+ let changed = false;
36
+ for (const layer of layers) {
37
+ if (layer.syncDisplayObjects()) {
38
+ changed = true;
39
+ }
40
+ }
41
+ if (changed) {
42
+ this.state.markDirty();
43
+ }
44
+ }
30
45
  async render() {
31
46
  const components = this.componentsManager.getAll();
32
47
  const currentTime = this.state.currentTime;
@@ -47,6 +62,7 @@ export class RenderManager {
47
62
  for (const e of entries) {
48
63
  this.lastActiveById.set(e.component.id, e.shouldBeVisible);
49
64
  }
65
+ this.#syncLayerDisplayObjects();
50
66
  this.appManager.render();
51
67
  this.lastRenderTime = currentTime;
52
68
  }
@@ -65,13 +65,21 @@ export type RenderFrameRangeItem = {
65
65
  frameIndex: number;
66
66
  frame: string | ArrayBuffer | Blob;
67
67
  isDuplicate: boolean;
68
+ mimeType?: string;
68
69
  release: () => void;
69
70
  };
71
+ export type FrameImageFormat = 'jpg' | 'jpeg' | 'png';
72
+ export type FrameImageEncodingOptions = {
73
+ imageFormat?: FrameImageFormat;
74
+ imageQuality?: number;
75
+ };
70
76
  export type RenderFrameRangeOptions = {
71
77
  fromFrame: number;
72
78
  toFrame: number;
73
79
  format?: 'arraybuffer' | 'blob' | 'png' | 'jpg' | 'jpeg';
74
80
  quality?: number;
81
+ imageFormat?: FrameImageFormat;
82
+ imageQuality?: number;
75
83
  skipDuplicates?: boolean;
76
84
  signal?: AbortSignal;
77
85
  onFrame: (item: RenderFrameRangeItem) => Promise<void> | void;
@@ -92,3 +100,6 @@ export declare class DeterministicRenderError extends Error {
92
100
  sceneTime: number;
93
101
  });
94
102
  }
103
+ export declare class RenderFrameEncodingError extends Error {
104
+ constructor(message: string);
105
+ }
@@ -19,3 +19,9 @@ export class DeterministicRenderError extends Error {
19
19
  this.sceneTime = props.sceneTime;
20
20
  }
21
21
  }
22
+ export class RenderFrameEncodingError extends Error {
23
+ constructor(message) {
24
+ super(message);
25
+ this.name = 'RenderFrameEncodingError';
26
+ }
27
+ }
@@ -1,5 +1,5 @@
1
1
  export type { BuilderState, StateEvents, TimelineEvents, RenderEvents, PlaybackEvents, LayerEvents, ComponentEvents, SubtitlesEvents, EventMap, EventType, EventPayload, ComponentRefreshType, HookType, SceneLayerComponentType, SplitScreenChunk, SplitScreen, } from './types.js';
2
2
  export type { MediaComponent, ResourceManager, ComponentData, ComponentProps, PixiComponent, ResourceTypes, HookHandler, HookHandlers, } from './types.js';
3
- export { DeterministicMediaConfigShape, defaultDeterministicMediaConfig, DeterministicRenderError } from './deterministic.js';
4
- export type { DeterministicMediaConfig, DeterministicFrameRequest, DeterministicFramePayload, DeterministicFrameProvider, DeterministicFrameOverride, DeterministicDiagnosticsReport, RenderFrameRangeOptions, RenderFrameRangeItem, RenderFrameRangeSummary } from './deterministic.js';
3
+ export { DeterministicMediaConfigShape, defaultDeterministicMediaConfig, DeterministicRenderError, RenderFrameEncodingError } from './deterministic.js';
4
+ export type { DeterministicMediaConfig, DeterministicFrameRequest, DeterministicFramePayload, DeterministicFrameProvider, DeterministicFrameOverride, DeterministicDiagnosticsReport, FrameImageFormat, FrameImageEncodingOptions, RenderFrameRangeOptions, RenderFrameRangeItem, RenderFrameRangeSummary } from './deterministic.js';
5
5
  export type { Component as IComponent, Layer as ILayer, SceneBuilder as ISceneBuilder, ComponentContext as IComponentContext, ComponentBuilder as IComponentBuilder, StateManager as IStateManager, ComponentHook as IComponentHook, ComponentBuildStrategy as IComponentBuildStrategy, } from './types.js';
@@ -1,4 +1,4 @@
1
1
  // Runtime types for the SceneBuilder application
2
2
  // These represent runtime interfaces for class instances
3
3
  // They are prefixed with "I" to distinguish from data schemas
4
- export { DeterministicMediaConfigShape, defaultDeterministicMediaConfig, DeterministicRenderError } from './deterministic.js';
4
+ export { DeterministicMediaConfigShape, defaultDeterministicMediaConfig, DeterministicRenderError, RenderFrameEncodingError } from './deterministic.js';
@@ -5,7 +5,7 @@ import type { LayersManager } from '../../managers/LayersManager.svelte.js';
5
5
  import type { SubtitlesManager } from '../../managers/SubtitlesManager.svelte.js';
6
6
  import type { EventManager } from '../../managers/EventManager.js';
7
7
  import type { Component as SceneLayerComponent, ComponentBase, AppearanceInput, Scene, RenderEnvironment, ComponentInput, SceneLayerInput, SceneLayer, VideoComponentShape, ImageComponentShape, GifComponentShape, Subtitle } from '../..';
8
- import type { DeterministicMediaConfig, DeterministicFrameProvider, DeterministicDiagnosticsReport, RenderFrameRangeOptions, RenderFrameRangeSummary } from './deterministic.js';
8
+ import type { DeterministicMediaConfig, DeterministicFrameProvider, DeterministicDiagnosticsReport, FrameImageEncodingOptions, RenderFrameRangeOptions, RenderFrameRangeSummary } from './deterministic.js';
9
9
  declare const SCENE_LAYER_COMPONENT_TYPE: readonly ["IMAGE", "GIF", "VIDEO", "TEXT", "SHAPE", "AUDIO", "COLOR", "GRADIENT", "SUBTITLES"];
10
10
  export type SceneLayerComponentType = (typeof SCENE_LAYER_COMPONENT_TYPE)[number];
11
11
  type MediaShape = z.infer<typeof VideoComponentShape>;
@@ -177,6 +177,7 @@ export interface Layer {
177
177
  update(layerData: Partial<SceneLayerInput>): void;
178
178
  addComponent(component: Component): void;
179
179
  removeComponent(component: Component): void;
180
+ syncDisplayObjects(): boolean;
180
181
  build(): Promise<void>;
181
182
  setOrder(order: number): void;
182
183
  getData(): SceneLayer;
@@ -263,10 +264,10 @@ export interface SceneBuilder {
263
264
  getDeterministicFrameProvider(): DeterministicFrameProvider | null;
264
265
  getDeterministicMediaConfig(): DeterministicMediaConfig;
265
266
  getDiagnosticsReport(): DeterministicDiagnosticsReport | null;
266
- seekAndRenderFrame(time: number, target?: DisplayObject | RenderTexture, format?: string, quality?: number): Promise<string | ArrayBuffer | Blob>;
267
+ seekAndRenderFrame(time: number, target?: DisplayObject | RenderTexture, format?: string, quality?: number, imageOptions?: FrameImageEncodingOptions): Promise<string | ArrayBuffer | Blob>;
267
268
  renderFrameRange(options: RenderFrameRangeOptions): Promise<RenderFrameRangeSummary>;
268
269
  isSceneDirty(time: number): Promise<boolean>;
269
- renderFrame(target?: DisplayObject | RenderTexture, format?: string, quality?: number): Promise<string | ArrayBuffer | Blob>;
270
+ renderFrame(target?: DisplayObject | RenderTexture, format?: string, quality?: number, imageOptions?: FrameImageEncodingOptions): Promise<string | ArrayBuffer | Blob>;
270
271
  log(message: string): void;
271
272
  play(changeState?: boolean): void;
272
273
  pause(changeState?: boolean): void;
@@ -1,2 +1,3 @@
1
1
  import type { FontType } from '..';
2
- export declare const loadFonts: (fonts: FontType[]) => Promise<void>;
2
+ import type { FontVariantDescriptor } from '../fonts/fontDiscovery.js';
3
+ export declare const loadFonts: (fonts: FontType[], variants?: FontVariantDescriptor[]) => Promise<void>;
@@ -1,36 +1,125 @@
1
+ import { extractConfiguredFontVariants } from '../fonts/fontDiscovery.js';
2
+ const FONT_PROBE_TEXT = 'BESbswy';
3
+ const FONT_PROBE_SIZE = '60px';
4
+ const customFaceRegistry = new Set();
5
+ const isFontApiAvailable = () => typeof document !== 'undefined' &&
6
+ typeof document.fonts !== 'undefined' &&
7
+ typeof document.createElement === 'function';
8
+ const loadStylesheet = async (href) => {
9
+ await new Promise((resolve) => {
10
+ const link = document.createElement('link');
11
+ link.href = href;
12
+ link.rel = 'stylesheet';
13
+ link.onload = () => resolve();
14
+ link.onerror = () => {
15
+ console.warn(`Failed to load font stylesheet: ${href}`);
16
+ resolve();
17
+ };
18
+ document.head.appendChild(link);
19
+ });
20
+ };
21
+ const groupByFamily = (variants) => {
22
+ const grouped = new Map();
23
+ for (const variant of variants) {
24
+ if (!grouped.has(variant.family)) {
25
+ grouped.set(variant.family, new Set());
26
+ }
27
+ grouped.get(variant.family).add(variant.weight);
28
+ }
29
+ return grouped;
30
+ };
31
+ const buildGoogleCss2Url = (variants) => {
32
+ if (!variants.length) {
33
+ return null;
34
+ }
35
+ const groupedFamilies = groupByFamily(variants);
36
+ const familyQueryParts = [];
37
+ for (const [family, weights] of groupedFamilies.entries()) {
38
+ const encodedFamily = encodeURIComponent(family).replace(/%20/g, '+');
39
+ const sortedWeights = [...weights].sort((a, b) => a - b);
40
+ const familyPart = sortedWeights.length
41
+ ? `family=${encodedFamily}:wght@${sortedWeights.join(';')}`
42
+ : `family=${encodedFamily}`;
43
+ familyQueryParts.push(familyPart);
44
+ }
45
+ if (!familyQueryParts.length) {
46
+ return null;
47
+ }
48
+ return `https://fonts.googleapis.com/css2?${familyQueryParts.join('&')}&display=swap`;
49
+ };
50
+ const registerCustomFontFaces = async (variants) => {
51
+ const customVariants = variants.filter((variant) => variant.source === 'custom');
52
+ if (!customVariants.length) {
53
+ return;
54
+ }
55
+ const hasFontFaceApi = typeof FontFace !== 'undefined';
56
+ for (const variant of customVariants) {
57
+ if (!variant.fileUrl) {
58
+ console.warn(`Custom font "${variant.family}" (weight ${variant.weight}) is missing fileUrl/src and cannot be preloaded.`);
59
+ continue;
60
+ }
61
+ if (!hasFontFaceApi) {
62
+ console.warn(`FontFace API is unavailable. Skipping custom font preload for "${variant.family}" (${variant.weight}).`);
63
+ continue;
64
+ }
65
+ const registryKey = `${variant.family}::${variant.weight}::${variant.fileUrl}`;
66
+ if (customFaceRegistry.has(registryKey)) {
67
+ continue;
68
+ }
69
+ try {
70
+ const face = new FontFace(variant.family, `url("${variant.fileUrl}")`, {
71
+ weight: String(variant.weight),
72
+ style: 'normal'
73
+ });
74
+ await face.load();
75
+ document.fonts.add(face);
76
+ customFaceRegistry.add(registryKey);
77
+ }
78
+ catch (error) {
79
+ console.warn(`Failed to preload custom font "${variant.family}" (${variant.weight}) from ${variant.fileUrl}.`, error);
80
+ }
81
+ }
82
+ };
83
+ const loadDescriptors = async (variants) => {
84
+ const descriptorPromises = variants.map(async (variant) => {
85
+ const descriptor = `${variant.weight} ${FONT_PROBE_SIZE} "${variant.family}"`;
86
+ try {
87
+ const loadedFaces = await document.fonts.load(descriptor, FONT_PROBE_TEXT);
88
+ if (!loadedFaces.length) {
89
+ console.warn(`Font descriptor did not resolve any faces: ${descriptor}`);
90
+ }
91
+ }
92
+ catch (error) {
93
+ console.warn(`Failed to load font descriptor: ${descriptor}`, error);
94
+ }
95
+ });
96
+ await Promise.all(descriptorPromises);
97
+ };
1
98
  async function waitForAllFonts() {
2
- // Guard clause for server-side rendering or non-browser environments
3
- if (typeof document === 'undefined' || !document.fonts) {
99
+ if (!isFontApiAvailable()) {
4
100
  return;
5
101
  }
6
102
  try {
7
103
  await document.fonts.ready;
8
104
  }
9
105
  catch (error) {
10
- console.warn('One or more fonts failed to load.', error);
106
+ console.warn('One or more fonts failed to reach ready state.', error);
11
107
  }
12
108
  }
13
- export const loadFonts = async function (fonts) {
14
- // const results = await PIXI.Assets.load(fonts);
15
- const googleFonts = fonts.reduce((acc, font) => {
16
- if (font.source == 'google' && font.data) {
17
- acc.push(font.data.family);
18
- }
19
- return acc;
20
- }, []);
21
- if (document && googleFonts.length > 0 && typeof document !== 'undefined') {
22
- const fontPromises = googleFonts.map((font) => new Promise((resolve) => {
23
- const link = document.createElement('link');
24
- link.href = `https://fonts.googleapis.com/css?family=${encodeURIComponent(font)}`;
25
- link.rel = 'stylesheet';
26
- link.onload = () => resolve(true);
27
- link.onerror = () => {
28
- console.warn(`Failed to load font: ${font}`);
29
- resolve(false);
30
- };
31
- document.head.appendChild(link);
32
- }));
33
- await Promise.all(fontPromises);
109
+ export const loadFonts = async function (fonts, variants) {
110
+ if (!isFontApiAvailable()) {
111
+ return;
112
+ }
113
+ const resolvedVariants = variants && variants.length ? variants : extractConfiguredFontVariants(fonts);
114
+ if (!resolvedVariants.length) {
115
+ return;
116
+ }
117
+ const googleVariants = resolvedVariants.filter((variant) => variant.source === 'google');
118
+ const googleCss2Url = buildGoogleCss2Url(googleVariants);
119
+ if (googleCss2Url) {
120
+ await loadStylesheet(googleCss2Url);
34
121
  }
122
+ await registerCustomFontFaces(resolvedVariants);
123
+ await loadDescriptors(resolvedVariants);
35
124
  await waitForAllFonts();
36
125
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "visualfries",
3
- "version": "0.1.10101",
3
+ "version": "0.1.10110",
4
4
  "license": "MIT",
5
5
  "author": "ContentFries",
6
6
  "repository": {