Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FEATURE] Assets loader automatic retry #423

Merged
merged 5 commits into from
Oct 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 119 additions & 35 deletions pandora-client-web/src/assets/graphicsLoader.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,105 @@
import { GetLogger, Logger } from 'pandora-common';
import { Texture } from 'pixi.js';
import { Assert, GetLogger, Logger } from 'pandora-common';
import { BaseTexture, IImageResourceOptions, Resource, Texture, autoDetectResource } from 'pixi.js';
import { PersistentToast } from '../persistentToast';
import { IGraphicsLoader } from './graphicsManager';

/**
* Interval after which texture load is retried, if the texture is still being requested.
* Last interval is repeated indefinitely until the load either succeeds or the texture is no longer needed.
*/
const RETRY_INTERVALS = [100, 500, 500, 1000, 1000, 5000];

export const ERROR_TEXTURE = Texture.EMPTY;

type TextureUpdateListener = (texture: Texture<Resource>) => void;

class TextureData {
public readonly path: string;
public readonly loader: IGraphicsLoader;
private readonly logger: Logger;

private readonly _listeners = new Set<TextureUpdateListener>;

private _loadedResource: Resource | null = null;
private _loadedTexture: Texture | null = null;
public get loadedTexture(): Texture | null {
return this._loadedTexture;
}

private _pendingLoad: boolean = false;
private _failedCounter: number = 0;

constructor(path: string, loader: IGraphicsLoader, logger: Logger) {
this.path = path;
this.loader = loader;
this.logger = logger;
}

public registerListener(listener: TextureUpdateListener): () => void {
this._listeners.add(listener);
this.load();
return () => {
this._listeners.delete(listener);
};
}

public load(): void {
if (this._loadedResource != null || this._pendingLoad)
return;
this._pendingLoad = true;

this.loader.loadResource(this.path)
.then((resource) => {
Assert(this._pendingLoad);
Assert(this._loadedResource == null);
Assert(this._loadedTexture == null);

if (this._failedCounter > 0) {
this.logger.info(`Image '${this.path}' loaded successfully after ${this._failedCounter + 1} tries`);
}

// Finish load
this._loadedResource = resource;
const texture = new Texture(new BaseTexture(resource, {
resolution: 1,
}));
this._loadedTexture = texture;
this._failedCounter = 0;
this._pendingLoad = false;

// Notify all listeners about load finishing
this._listeners.forEach((listener) => listener(texture));
})
.catch((err) => {
Assert(this._pendingLoad);
Assert(this._loadedResource == null);
Assert(this._loadedTexture == null);

this._failedCounter++;
const shouldRetry = this._listeners.size > 0;

if (shouldRetry) {
const retryTimer = RETRY_INTERVALS[Math.min(this._failedCounter, RETRY_INTERVALS.length) - 1];
this.logger.warning(`Failed to load image '${this.path}', will retry after ${retryTimer}ms\n`, err);

setTimeout(() => {
this._pendingLoad = false;
this.load();
}, retryTimer);
} else {
this.logger.error(`Failed to load image '${this.path}', will not retry\n`, err);
this._pendingLoad = false;
}

// Send an error texture to all listeners
this._listeners.forEach((listener) => listener(ERROR_TEXTURE));
});
}
}

export abstract class GraphicsLoaderBase implements IGraphicsLoader {
private readonly cache = new Map<string, Texture>();
private readonly pending = new Map<string, Promise<Texture>>();
private readonly store = new Map<string, TextureData>();

private readonly textureLoadingProgress = new PersistentToast();
protected readonly logger: Logger;

Expand All @@ -17,37 +111,24 @@ export abstract class GraphicsLoaderBase implements IGraphicsLoader {
if (!path)
return Texture.EMPTY;

return this.cache.get(path) ?? null;
return this.store.get(path)?.loadedTexture ?? null;
}

public async getTexture(path: string): Promise<Texture> {
if (!path)
return Texture.EMPTY;

let texture = this.cache.get(path);
if (texture !== undefined)
return texture;

let promise = this.pending.get(path);
if (promise !== undefined)
return promise;
public useTexture(path: string, listener: TextureUpdateListener): () => void {
return this._initTexture(path).registerListener(listener);
}

promise = this.monitorProgress(this.loadTexture(path));
this.pending.set(path, promise);
private _initTexture(path: string): TextureData {
let data: TextureData | undefined = this.store.get(path);
if (data != null)
return data;

const errorWithStack = new Error('Error loading image');
data = new TextureData(path, this, this.logger);
this.store.set(path, data);

try {
texture = await promise;
} catch (err) {
this.logger.error('Failed to load image', path, '\n', err);
throw errorWithStack;
} finally {
this.pending.delete(path);
}
data.load();

this.cache.set(path, texture);
return texture;
return data;
}

private readonly _pendingPromises = new Set<Promise<unknown>>();
Expand All @@ -72,7 +153,7 @@ export abstract class GraphicsLoaderBase implements IGraphicsLoader {
}
}

protected abstract loadTexture(path: string): Promise<Texture>;
public abstract loadResource(path: string): Promise<Resource>;

public abstract loadTextFile(path: string): Promise<string>;

Expand All @@ -89,19 +170,22 @@ export class URLGraphicsLoader extends GraphicsLoaderBase {
this.prefix = prefix;
}

protected override loadTexture(path: string): Promise<Texture> {
return Texture.fromURL(this.prefix + path);
public override loadResource(path: string): Promise<Resource> {
return autoDetectResource<Resource, IImageResourceOptions>(this.prefix + path, {
autoLoad: false,
crossorigin: 'anonymous',
}).load();
}

public loadTextFile(path: string): Promise<string> {
public override loadTextFile(path: string): Promise<string> {
return this.monitorProgress(fetch(this.prefix + path).then((r) => r.text()));
}

public loadFileArrayBuffer(path: string): Promise<ArrayBuffer> {
public override loadFileArrayBuffer(path: string): Promise<ArrayBuffer> {
return this.monitorProgress(fetch(this.prefix + path).then((r) => r.arrayBuffer()));
}

public pathToUrl(path: string): Promise<string> {
public override pathToUrl(path: string): Promise<string> {
return Promise.resolve(this.prefix + path);
}
}
11 changes: 9 additions & 2 deletions pandora-client-web/src/assets/graphicsManager.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import { AssetGraphicsDefinition, AssetId, AssetsGraphicsDefinitionFile, PointTemplate } from 'pandora-common';
import { Texture } from 'pixi.js';
import { Resource, Texture } from 'pixi.js';
import { useState, useEffect } from 'react';
import { Observable, useObservable } from '../observable';
import { AssetGraphics } from './assetGraphics';

export interface IGraphicsLoader {
getCachedTexture(path: string): Texture | null;
getTexture(path: string): Promise<Texture>;
/**
* Requests a texture to be loaded and marks the texture as in-use
* @param path - The requested texture
* @param listener - Listener for when the texture is successfully loaded
* @returns A callback to release the texture
*/
useTexture(path: string, listener: (texture: Texture) => void): () => void;
loadResource(path: string): Promise<Resource>;
loadTextFile(path: string): Promise<string>;
loadFileArrayBuffer(path: string, type?: string): Promise<ArrayBuffer>;
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,7 @@ const RoomDeviceGraphics = React.forwardRef(RoomDeviceGraphicsImpl);
function RoomDeviceGraphicsLayerSprite({ item, layer, getTexture }: {
item: ItemRoomDevice;
layer: IRoomDeviceGraphicsLayerSprite;
getTexture?: (path: string) => Promise<PIXI.Texture>;
getTexture?: (path: string) => PIXI.Texture;
}): ReactElement | null {

const image = useMemo<string>(() => {
Expand Down
8 changes: 4 additions & 4 deletions pandora-client-web/src/editor/assetLoader.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { AssetsDefinitionFile, AssetsGraphicsDefinitionFile, GetLogger } from 'pandora-common';
import { Texture } from 'pixi.js';
import { Resource } from 'pixi.js';
import { GraphicsManager, GraphicsManagerInstance, IGraphicsLoader } from '../assets/graphicsManager';
import { GraphicsLoaderBase, URLGraphicsLoader } from '../assets/graphicsLoader';
import { AssetManagerEditor, EditorAssetManager } from './assets/assetManager';
import { LoadArrayBufferTexture } from '../graphics/utility';
import { LoadArrayBufferImageResource } from '../graphics/utility';
import { EDITOR_ASSETS_ADDRESS } from '../config/Environment';

export async function LoadAssetsFromFileSystem(): Promise<[AssetManagerEditor, GraphicsManager]> {
Expand Down Expand Up @@ -58,8 +58,8 @@ class FileSystemGraphicsLoader extends GraphicsLoaderBase {
this._handle = handle;
}

protected override async loadTexture(path: string): Promise<Texture> {
return LoadArrayBufferTexture(await ReadFile(this._handle, path, false));
public override async loadResource(path: string): Promise<Resource> {
return LoadArrayBufferImageResource(await ReadFile(this._handle, path, false));
}

public loadTextFile(path: string): Promise<string> {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { CharacterAppearance, Assert, AssetGraphicsDefinition, AssetId, CharacterSize, LayerDefinition, LayerImageSetting, LayerMirror, Asset, ActionAddItem, ItemId, ActionProcessingContext, ActionRemoveItem, ActionMoveItem, ActionRoomContext, CharacterRestrictionsManager, ICharacterMinimalData, CloneDeepMutable, GetLogger, CharacterId, AssetFrameworkCharacterState, AssetFrameworkGlobalStateContainer, AssertNotNullable, CharacterView, ICharacterRoomData, CHARACTER_DEFAULT_PUBLIC_SETTINGS, TypedEventEmitter } from 'pandora-common';
import { Texture } from 'pixi.js';
import { BaseTexture, Texture } from 'pixi.js';
import { toast } from 'react-toastify';
import { AssetGraphics, AssetGraphicsLayer, LayerToImmediateName } from '../../../assets/assetGraphics';
import { GraphicsManagerInstance, IGraphicsLoader } from '../../../assets/graphicsManager';
import { LoadArrayBufferTexture, StripAssetHash } from '../../../graphics/utility';
import { LoadArrayBufferImageResource, StripAssetHash } from '../../../graphics/utility';
import { TOAST_OPTIONS_ERROR } from '../../../persistentToast';
import { Editor } from '../../editor';
import { cloneDeep } from 'lodash';
Expand Down Expand Up @@ -374,13 +374,13 @@ export class EditorAssetGraphics extends AssetGraphics {
return this._loadedTextures;
}

public getTexture(image: string): Promise<Texture> {
public getTexture(image: string): Texture {
const texture = this.textures.get(image);
return texture ? Promise.resolve(texture) : Promise.reject();
return texture ?? Texture.EMPTY;
}

public async addTextureFromArrayBuffer(name: string, buffer: ArrayBuffer): Promise<void> {
const texture = await LoadArrayBufferTexture(buffer);
const texture = new Texture(new BaseTexture(await LoadArrayBufferImageResource(buffer)));
this.fileContents.set(name, buffer);
this.textures.set(name, texture);
if (!this._loadedTextures.includes(name)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export function EditorLayer({
const asset = layer.asset;

// TODO: Make editor asset's images observable
const editorGetTexture = useMemo<((image: string) => Promise<Texture>) | undefined>(() => {
const editorGetTexture = useMemo<((image: string) => Texture) | undefined>(() => {
if (getTexture)
return getTexture;
if (asset instanceof EditorAssetGraphics)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ export function SetupLayerSelected({
const asset = layer.asset;

// TODO: Make editor asset's images observable
const editorGetTexture = useMemo<((image: string) => Promise<Texture>) | undefined>(() => {
const editorGetTexture = useMemo<((image: string) => Texture) | undefined>(() => {
if (asset instanceof EditorAssetGraphics)
return (i) => asset.getTexture(i);
return undefined;
Expand Down
2 changes: 2 additions & 0 deletions pandora-client-web/src/editor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { GraphicsManager } from '../assets/graphicsManager';
import { EulaGate } from '../components/Eula';
import { EditorWardrobeContextProvider } from './components/wardrobe/wardrobe';
import { AssetManagerEditor } from './assets/assetManager';
import { ConfigurePixiSettings } from '../graphics/pixiSettings';

const logger = GetLogger('init');

Expand All @@ -27,6 +28,7 @@ Start().catch((error) => {
*/
async function Start(): Promise<void> {
SetupLogging();
ConfigurePixiSettings();
logger.info('Starting...');
createRoot(document.querySelector('#editor-root') as HTMLElement).render(
<React.StrictMode>
Expand Down
Loading