Skip to content

Commit ebfd024

Browse files
committed
feat: hold the nearest baked html5 frame while playing
1 parent 774f1bd commit ebfd024

2 files changed

Lines changed: 66 additions & 3 deletions

File tree

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

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ 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.
29+
const HELD_FRAME_LIMIT = 7;
2830

2931
type HarnessWindow = Window & {
3032
["__shotstackSeek"]?: (ms: number) => void;
@@ -99,6 +101,12 @@ export function captureFramesInFlight(logicalCores: number | undefined, frameByt
99101
return Math.max(1, Math.min(byCores, Math.floor(CAPTURE_MEMORY_BUDGET_BYTES / frameBytes)));
100102
}
101103

104+
/** The latest baked frame at most `maxBehind` frames before `idx`, or null. */
105+
export function heldFrame(frames: ArrayLike<Blob | undefined>, idx: number, maxBehind: number): number | null {
106+
for (let i = idx; i >= Math.max(0, idx - maxBehind); i -= 1) if (frames[i]) return i;
107+
return null;
108+
}
109+
102110
export function bakeScaleFor(onScreenScale: number): number {
103111
return BAKE_SCALES.find(scale => scale >= onScreenScale) ?? 1;
104112
}
@@ -160,6 +168,8 @@ export class Html5Player extends Player {
160168
private stillTexture: pixi.Texture | null = null;
161169
private liveStill: pixi.Sprite | null = null;
162170
private liveStillFrame: number = -1;
171+
// False while the live still is a held frame from before the playhead, so pausing on it renders the exact one.
172+
private liveStillExact: boolean = true;
163173
private liveStillInFlight: boolean = false;
164174
private stillRequest: number | null = null;
165175
// The frame the running bake draws next. Null between bakes.
@@ -1092,8 +1102,9 @@ export class Html5Player extends Player {
10921102
// One still renders at a time. Positions asked for meanwhile replace each other, so only the newest renders next.
10931103
private showStillAt(seconds: number): void {
10941104
const frame = Math.floor(seconds * this.captureFps);
1095-
if (frame === this.liveStillFrame) return;
1105+
if (frame === this.liveStillFrame && (this.liveStillExact || this.edit.isPlaying)) return;
10961106
this.liveStillFrame = frame;
1107+
this.liveStillExact = true;
10971108
this.stillRequest = seconds;
10981109
if (!this.liveStillInFlight) this.renderLiveStills().catch(err => console.warn("[Html5Player] still failed:", err));
10991110
}
@@ -1111,7 +1122,10 @@ export class Html5Player extends Player {
11111122
if (!this.disposed) console.warn("[Html5Player] still failed:", err);
11121123
return null;
11131124
});
1114-
const baked = this.bakingFrames?.[frame];
1125+
// Playing, a clip holds a recent baked frame rather than spend bake time on a still.
1126+
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;
11151129
let texture = baked ? await this.bakingTexture(baked) : await attempt();
11161130
// A page can load too slowly once, so a failed still gets one more try while nothing newer is waiting.
11171131
if (!texture && !baked && this.liveStillFrame === frame && this.stillRequest === null) texture = await attempt();
@@ -1143,6 +1157,7 @@ export class Html5Player extends Player {
11431157
private removeLiveStill(): void {
11441158
this.stillRequest = null;
11451159
this.liveStillFrame = -1;
1160+
this.liveStillExact = true;
11461161
if (!this.liveStill) return;
11471162
this.contentContainer.removeChild(this.liveStill);
11481163
this.liveStill.texture.destroy(true);

‎tests/html5-player.test.ts‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/** @jest-environment jsdom */
22

33
import { BakeQueue } from "@canvas/players/bake-queue";
4-
import { Html5Player, bakeScaleFor, captureFramesInFlight } from "@canvas/players/html5-player";
4+
import { Html5Player, bakeScaleFor, captureFramesInFlight, heldFrame } from "@canvas/players/html5-player";
55
import type { Edit } from "@core/edit-session";
66
import { EditEvent, InternalEvent } from "@core/events/edit-events";
77
import { sec } from "@core/timing/types";
@@ -1600,6 +1600,54 @@ it("shows a first bake's frames as they land", async () => {
16001600
await capture;
16011601
});
16021602

1603+
it.each([
1604+
[[0, 3], 2, 7, 0],
1605+
[[0, 3], 3, 7, 3],
1606+
[[0], 8, 7, null],
1607+
[[0, 3], 2, 0, null],
1608+
[[19], 20, 7, 19]
1609+
])("finds the frame to hold among %p at %p within %p frames: %p", (present, idx, maxBehind, expected) => {
1610+
const frames = Array.from({ length: 20 }, (_, i) => (present.includes(i) ? new Blob([`${i}`]) : undefined));
1611+
1612+
expect(heldFrame(frames, idx, maxBehind)).toBe(expected);
1613+
});
1614+
1615+
const playingAhead = async (...baked: number[]) => {
1616+
const created = createStillPlayer();
1617+
const { player, harness, edit } = created;
1618+
Object.assign(edit, { isPlaying: true });
1619+
Object.assign(player, { getPlaybackTime: () => 0.4 }); // frame 12
1620+
baking(harness);
1621+
harness.beginCapture();
1622+
Object.assign(harness, { bakingFrames: bakedAt(...baked), bakingScale: 1 });
1623+
player.update(0, 0);
1624+
await until(stillShown(harness));
1625+
return created;
1626+
};
1627+
1628+
it("holds the nearest baked frame while playing ahead of the bake", async () => {
1629+
const { harness, seeks } = await playingAhead(8, 16);
1630+
1631+
expect(await frameMarkup(layer(harness)[0].texture.source.blob)).toBe("baked 8");
1632+
expect(seeks.some(seek => seek.startsWith("still"))).toBe(false);
1633+
});
1634+
1635+
it("renders a still while playing when the nearest baked frame is more than 7 frames back", async () => {
1636+
const { harness } = await playingAhead(0);
1637+
1638+
expect(await frameMarkup(layer(harness)[0].texture.source.blob)).toContain(">t=0.4<");
1639+
});
1640+
1641+
it("renders the exact still once playback pauses on a held frame", async () => {
1642+
const { player, harness, edit } = await playingAhead(8, 16);
1643+
const held = layer(harness)[0].texture;
1644+
Object.assign(edit, { isPlaying: false });
1645+
player.update(0, 0);
1646+
await until(() => layer(harness)[0].texture !== held);
1647+
1648+
expect(await frameMarkup(layer(harness)[0].texture.source.blob)).toContain(">t=0.4<");
1649+
});
1650+
16031651
it("swaps the still for baked frames once the bake finishes", async () => {
16041652
const { player, harness } = createStillPlayer();
16051653
let finish: (frames: Blob[]) => void = () => {};

0 commit comments

Comments
 (0)