Skip to content

Commit e56d4c2

Browse files
committed
fix: keep the frame on screen when a held html5 frame would be older
1 parent ebfd024 commit e56d4c2

2 files changed

Lines changed: 70 additions & 26 deletions

File tree

‎src/components/canvas/players/html5-player.ts‎

Lines changed: 40 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { createCaptureLoadingGraphic, createPlaceholderGraphic } from "./placeho
2525
const IFRAME_OFFSCREEN_X = -10000;
2626
const IFRAME_LOAD_TIMEOUT_MS = 10_000;
2727
const DECODED_FRAME_LIMIT = 30;
28-
// One first-pass stride: a held frame is never more than 233 ms behind at 30 fps.
28+
// How stale a held frame may be when it's chosen: 233 ms at 30 fps.
2929
const HELD_FRAME_LIMIT = 7;
3030

3131
type HarnessWindow = Window & {
@@ -170,6 +170,8 @@ export class Html5Player extends Player {
170170
private liveStillFrame: number = -1;
171171
// False while the live still is a held frame from before the playhead, so pausing on it renders the exact one.
172172
private liveStillExact: boolean = true;
173+
// The frame the live still shows, which a held frame trails.
174+
private liveStillShown: number = -1;
173175
private liveStillInFlight: boolean = false;
174176
private stillRequest: number | null = null;
175177
// The frame the running bake draws next. Null between bakes.
@@ -1117,38 +1119,51 @@ export class Html5Player extends Player {
11171119
const frame = Math.floor(seconds * this.captureFps);
11181120
const hash = this.contentHash;
11191121
this.stillRequest = null;
1120-
const attempt = () =>
1121-
this.renderStill(seconds).catch(err => {
1122-
if (!this.disposed) console.warn("[Html5Player] still failed:", err);
1123-
return null;
1124-
});
1125-
// Playing, a clip holds a recent baked frame rather than spend bake time on a still.
1122+
// A still takes main-thread time from the bake, so a playing clip holds a recent baked frame.
11261123
const held = this.bakingFrames ? heldFrame(this.bakingFrames, frame, this.edit.isPlaying ? HELD_FRAME_LIMIT : 0) : null;
1127-
const baked = held === null ? undefined : this.bakingFrames?.[held];
1128-
if (baked && held !== frame) this.liveStillExact = false;
1129-
let texture = baked ? await this.bakingTexture(baked) : await attempt();
1130-
// A page can load too slowly once, so a failed still gets one more try while nothing newer is waiting.
1131-
if (!texture && !baked && this.liveStillFrame === frame && this.stillRequest === null) texture = await attempt();
1132-
const unbaked = this.mode === "editing" || this.mode === "stale" || this.mode === "capturing";
1133-
if (!texture || this.disposed || !unbaked || this.contentHash !== hash) {
1134-
texture?.destroy(true);
1135-
} else if (this.liveStill) {
1136-
this.liveStill.texture.destroy(true);
1137-
this.liveStill.texture = texture;
1124+
const shown = this.liveStillShown;
1125+
// Playback never steps back to an older frame, or decodes the frame it already shows.
1126+
if (this.edit.isPlaying && held !== null && this.liveStill && held <= shown && shown <= frame) {
1127+
this.liveStillExact = shown === frame;
11381128
} else {
1139-
this.removeLoadingGraphic();
1140-
this.liveStill = new pixi.Sprite(texture);
1141-
// A capture in progress shows its own copy of the clip.
1142-
this.liveStill.visible = !this.staticSprite;
1143-
this.contentContainer.addChild(this.liveStill);
1144-
if (this.clipConfiguration.width && this.clipConfiguration.height) this.applyFixedDimensions();
1129+
await this.renderLiveStill(seconds, frame, held, hash);
11451130
}
11461131
}
11471132
} finally {
11481133
this.liveStillInFlight = false;
11491134
}
11501135
}
11511136

1137+
private async renderLiveStill(seconds: number, frame: number, held: number | null, hash: string | null): Promise<void> {
1138+
const attempt = () =>
1139+
this.renderStill(seconds).catch(err => {
1140+
if (!this.disposed) console.warn("[Html5Player] still failed:", err);
1141+
return null;
1142+
});
1143+
const baked = held === null ? undefined : this.bakingFrames?.[held];
1144+
if (baked && held !== frame) this.liveStillExact = false;
1145+
let texture = baked ? await this.bakingTexture(baked) : await attempt();
1146+
// A page can load too slowly once, so a failed still gets one more try while nothing newer is waiting.
1147+
if (!texture && !baked && this.liveStillFrame === frame && this.stillRequest === null) texture = await attempt();
1148+
const unbaked = this.mode === "editing" || this.mode === "stale" || this.mode === "capturing";
1149+
if (!texture || this.disposed || !unbaked || this.contentHash !== hash) {
1150+
texture?.destroy(true);
1151+
return;
1152+
}
1153+
this.liveStillShown = baked && held !== null ? held : frame;
1154+
if (this.liveStill) {
1155+
this.liveStill.texture.destroy(true);
1156+
this.liveStill.texture = texture;
1157+
return;
1158+
}
1159+
this.removeLoadingGraphic();
1160+
this.liveStill = new pixi.Sprite(texture);
1161+
// A capture in progress shows its own copy of the clip.
1162+
this.liveStill.visible = !this.staticSprite;
1163+
this.contentContainer.addChild(this.liveStill);
1164+
if (this.clipConfiguration.width && this.clipConfiguration.height) this.applyFixedDimensions();
1165+
}
1166+
11521167
// The resolution makes a scaled frame's texture report the clip's full size.
11531168
private async bakingTexture(blob: Blob): Promise<pixi.Texture> {
11541169
return pixi.Texture.from({ resource: await createImageBitmap(blob), resolution: this.bakingScale });
@@ -1157,7 +1172,7 @@ export class Html5Player extends Player {
11571172
private removeLiveStill(): void {
11581173
this.stillRequest = null;
11591174
this.liveStillFrame = -1;
1160-
this.liveStillExact = true;
1175+
this.liveStillShown = -1;
11611176
if (!this.liveStill) return;
11621177
this.contentContainer.removeChild(this.liveStill);
11631178
this.liveStill.texture.destroy(true);

‎tests/html5-player.test.ts‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1604,6 +1604,7 @@ it.each([
16041604
[[0, 3], 2, 7, 0],
16051605
[[0, 3], 3, 7, 3],
16061606
[[0], 8, 7, null],
1607+
[[1], 8, 7, 1],
16071608
[[0, 3], 2, 0, null],
16081609
[[19], 20, 7, 19]
16091610
])("finds the frame to hold among %p at %p within %p frames: %p", (present, idx, maxBehind, expected) => {
@@ -1632,8 +1633,36 @@ it("holds the nearest baked frame while playing ahead of the bake", async () =>
16321633
expect(seeks.some(seek => seek.startsWith("still"))).toBe(false);
16331634
});
16341635

1636+
it("holds a baked frame exactly 7 frames back while playing", async () => {
1637+
const { harness } = await playingAhead(5);
1638+
1639+
expect(await frameMarkup(layer(harness)[0].texture.source.blob)).toBe("baked 5");
1640+
});
1641+
16351642
it("renders a still while playing when the nearest baked frame is more than 7 frames back", async () => {
1636-
const { harness } = await playingAhead(0);
1643+
const { harness } = await playingAhead(4);
1644+
1645+
expect(await frameMarkup(layer(harness)[0].texture.source.blob)).toContain(">t=0.4<");
1646+
});
1647+
1648+
it("decodes a held frame once while playing on through it", async () => {
1649+
const { player } = await playingAhead(8);
1650+
const decode = jest.spyOn(globalThis, "createImageBitmap");
1651+
[0.4, 0.4, 13 / 30, 13 / 30, 14 / 30].forEach(t => {
1652+
Object.assign(player, { getPlaybackTime: () => t });
1653+
player.update(0, 0);
1654+
});
1655+
await flush(50);
1656+
1657+
expect(decode).not.toHaveBeenCalled();
1658+
});
1659+
1660+
it("never steps back to an older baked frame than the still on screen while playing", async () => {
1661+
const { player, harness } = await playingAhead(0); // a still of frame 12
1662+
harness.bakingFrames![11] = new Blob(["baked 11"]);
1663+
Object.assign(player, { getPlaybackTime: () => 13 / 30 });
1664+
player.update(0, 0);
1665+
await flush(50);
16371666

16381667
expect(await frameMarkup(layer(harness)[0].texture.source.blob)).toContain(">t=0.4<");
16391668
});

0 commit comments

Comments
 (0)