2
0

fix: decode portrait frames before first interaction

This commit is contained in:
Leon
2026-08-18 19:09:38 +08:00
parent 4060b9fb84
commit dda7963d8d
3 changed files with 40 additions and 4 deletions

View File

@@ -838,6 +838,7 @@ var SkinPresenter = class {
poster; poster;
portrait; portrait;
preloads; preloads;
portraitReady = false;
enabled = true; enabled = true;
// Default to the max frame so the first paint after load is the dark shell; // Default to the max frame so the first paint after load is the dark shell;
// starting at 0 flashed the light palette before the directory resolved. // starting at 0 flashed the light palette before the directory resolved.
@@ -852,7 +853,7 @@ var SkinPresenter = class {
this.root = document.createElement("div"); this.root = document.createElement("div");
this.root.className = "liang-skin-backdrop"; this.root.className = "liang-skin-backdrop";
this.root.dataset.plugin = PACKAGE_ID; this.root.dataset.plugin = PACKAGE_ID;
this.root.dataset.media = "sequence"; this.root.dataset.media = "poster";
this.root.setAttribute("aria-hidden", "true"); this.root.setAttribute("aria-hidden", "true");
this.poster = document.createElement("img"); this.poster = document.createElement("img");
this.poster.className = "liang-skin-poster"; this.poster.className = "liang-skin-poster";
@@ -866,10 +867,22 @@ var SkinPresenter = class {
this.portrait.addEventListener("error", this.handleSequenceError); this.portrait.addEventListener("error", this.handleSequenceError);
this.preloads = PORTRAIT_ANCHORS.map(({ file }) => { this.preloads = PORTRAIT_ANCHORS.map(({ file }) => {
const image = new Image(); const image = new Image();
image.loading = "eager";
image.decoding = "async"; image.decoding = "async";
image.src = `${ASSET_PREFIX}/portrait-source-v2/${file}`; image.src = `${ASSET_PREFIX}/portrait-source-v2/${file}`;
return image; return image;
}); });
void Promise.all(this.preloads.map((image) => image.decode())).then(
() => {
if (this.disposed) return;
this.portraitReady = true;
this.root.dataset.media = "sequence";
this.updatePortrait(paletteForFrame(this.frame).level);
},
() => {
if (!this.disposed) this.root.dataset.media = "poster";
}
);
this.root.append(this.portrait, this.poster); this.root.append(this.portrait, this.poster);
document.body.prepend(this.root); document.body.prepend(this.root);
this.poster.addEventListener("error", this.handlePosterError); this.poster.addEventListener("error", this.handlePosterError);
@@ -942,6 +955,7 @@ var SkinPresenter = class {
this.theme.setTheme(theme); this.theme.setTheme(theme);
} }
updatePortrait(level) { updatePortrait(level) {
if (!this.portraitReady) return;
const { lowerIndex, upperIndex, mix: mix2 } = portraitBlendForLevel( const { lowerIndex, upperIndex, mix: mix2 } = portraitBlendForLevel(
level, level,
ANCHOR_LEVELS ANCHOR_LEVELS

File diff suppressed because one or more lines are too long

View File

@@ -143,6 +143,7 @@ class SkinPresenter {
private readonly poster: HTMLImageElement; private readonly poster: HTMLImageElement;
private readonly portrait: HTMLImageElement; private readonly portrait: HTMLImageElement;
private readonly preloads: HTMLImageElement[]; private readonly preloads: HTMLImageElement[];
private portraitReady = false;
private enabled = true; private enabled = true;
// Default to the max frame so the first paint after load is the dark shell; // Default to the max frame so the first paint after load is the dark shell;
// starting at 0 flashed the light palette before the directory resolved. // starting at 0 flashed the light palette before the directory resolved.
@@ -158,7 +159,10 @@ class SkinPresenter {
this.root = document.createElement("div"); this.root = document.createElement("div");
this.root.className = "liang-skin-backdrop"; this.root.className = "liang-skin-backdrop";
this.root.dataset.plugin = PACKAGE_ID; this.root.dataset.plugin = PACKAGE_ID;
this.root.dataset.media = "sequence"; // Keep the stable poster visible while every portrait frame is fetched
// and decoded. A request being complete does not mean the bitmap is ready
// for a tear-free first swap.
this.root.dataset.media = "poster";
this.root.setAttribute("aria-hidden", "true"); this.root.setAttribute("aria-hidden", "true");
this.poster = document.createElement("img"); this.poster = document.createElement("img");
@@ -175,11 +179,28 @@ class SkinPresenter {
this.preloads = PORTRAIT_ANCHORS.map(({ file }) => { this.preloads = PORTRAIT_ANCHORS.map(({ file }) => {
const image = new Image(); const image = new Image();
image.loading = "eager";
image.decoding = "async"; image.decoding = "async";
image.src = `${ASSET_PREFIX}/portrait-source-v2/${file}`; image.src = `${ASSET_PREFIX}/portrait-source-v2/${file}`;
return image; return image;
}); });
// `new Image()` starts the requests, but the browser may still defer
// decoding until the image is attached to the document. Wait for all
// frames up front so the first slider interaction never reveals a blank
// or half-painted frame.
void Promise.all(this.preloads.map((image) => image.decode())).then(
() => {
if (this.disposed) return;
this.portraitReady = true;
this.root.dataset.media = "sequence";
this.updatePortrait(paletteForFrame(this.frame).level);
},
() => {
if (!this.disposed) this.root.dataset.media = "poster";
},
);
this.root.append(this.portrait, this.poster); this.root.append(this.portrait, this.poster);
document.body.prepend(this.root); document.body.prepend(this.root);
@@ -263,6 +284,7 @@ class SkinPresenter {
} }
private updatePortrait(level: number) { private updatePortrait(level: number) {
if (!this.portraitReady) return;
const { lowerIndex, upperIndex, mix } = portraitBlendForLevel( const { lowerIndex, upperIndex, mix } = portraitBlendForLevel(
level, level,
ANCHOR_LEVELS, ANCHOR_LEVELS,