diff --git a/src/components/canvas/buffering-notice.ts b/src/components/canvas/buffering-notice.ts new file mode 100644 index 00000000..5ffa71d2 --- /dev/null +++ b/src/components/canvas/buffering-notice.ts @@ -0,0 +1,80 @@ +export interface ContentBounds { + left: number; + right: number; + top: number; + bottom: number; +} + +/** The part of the video inside a preview of this size, or null when none of it is. */ +export function visibleBounds(content: ContentBounds, width: number, height: number): ContentBounds | null { + const left = Math.max(content.left, 0); + const right = Math.min(content.right, width); + const top = Math.max(content.top, 0); + const bottom = Math.min(content.bottom, height); + return right > left && bottom > top ? { left, right, top, bottom } : null; +} + +// Long enough that one late frame doesn't flash it, short enough that a real stutter gets explained. +const SHOW_AFTER_MS = 500; +const HIDE_AFTER_MS = 1500; +const INSET_PX = 12; +// A longer gap between ticks means ticking paused, as in an export or a hidden tab, and says nothing about stutter. +const MAX_TICK_GAP_MS = 100; + +/** + * Labels stuttering html5 playback "Buffering" in the video's bottom-left corner. It holds only its own display + * state, and the canvas feeds it on every tick. + * @internal + */ +export class BufferingNotice { + public readonly element: HTMLDivElement; + private readonly now: () => number; + private bufferingSince: number | null = null; + private smoothSince: number | null = null; + private lastTickAt: number | null = null; + + constructor(now: () => number = () => performance.now()) { + this.now = now; + this.element = document.createElement("div"); + this.element.className = "ss-buffering-notice"; + this.element.hidden = true; + // The toolbar's status line already tells assistive tech that playback is preparing. + this.element.setAttribute("aria-hidden", "true"); + this.element.innerHTML = ''; + this.element.append("Buffering"); + } + + public mount(root: HTMLElement): void { + root.appendChild(this.element); + } + + /** `idle` is paused or fully loaded, and `bounds` is null when none of the video is in view. Either hides it at once. */ + public update(buffering: boolean, idle: boolean, bounds: ContentBounds | null): void { + const now = this.now(); + // A change happened somewhere after the last tick, so its timer starts there. + const previous = this.lastTickAt !== null && now - this.lastTickAt <= MAX_TICK_GAP_MS ? this.lastTickAt : now; + this.lastTickAt = now; + if (idle || !bounds) { + this.bufferingSince = null; + this.smoothSince = null; + this.element.hidden = true; + return; + } + if (buffering) { + this.smoothSince = null; + this.bufferingSince ??= previous; + if (now - this.bufferingSince >= SHOW_AFTER_MS) this.element.hidden = false; + } else { + this.bufferingSince = null; + this.smoothSince ??= previous; + if (now - this.smoothSince >= HIDE_AFTER_MS) this.element.hidden = true; + } + if (this.element.hidden) return; + this.element.style.left = `${bounds.left + INSET_PX}px`; + this.element.style.top = `${bounds.bottom - INSET_PX}px`; + } + + public dispose(): void { + this.element.remove(); + } +} diff --git a/src/components/canvas/players/html5-player.ts b/src/components/canvas/players/html5-player.ts index ba0a6216..eee960b3 100644 --- a/src/components/canvas/players/html5-player.ts +++ b/src/components/canvas/players/html5-player.ts @@ -972,6 +972,12 @@ export class Html5Player extends Player { return this.edit.getHtml5Prerender() === "on-load" || this.hasTriggeredCapture ? "pending" : null; } + // A baked frame reaches the screen a tick or two after the playhead does, so the bake decides, not the screen. + public override isPlaybackBuffering(): boolean { + if (!this.edit.isPlaying || !this.isActive() || this.getPlaybackReadiness() !== "pending") return false; + return !this.bakingFrames?.[Math.floor(this.getPlaybackTime() * this.captureFps)]; + } + public override getCaptureProgress(): number { if (this.getPlaybackReadiness() !== "pending") return 1; return this.captureFramesTotal > 0 ? this.captureFramesDone / this.captureFramesTotal : 0; diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index d82724bd..160782ad 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -585,6 +585,10 @@ export abstract class Player extends Entity { return 1; } + public isPlaybackBuffering(): boolean { + return false; + } + public isActive(): boolean { // playbackTime is in seconds, matching clip start/end return this.edit.playbackTime >= this.getStart() && this.edit.playbackTime < this.getEnd(); diff --git a/src/components/canvas/shotstack-canvas.ts b/src/components/canvas/shotstack-canvas.ts index e0ac9251..1fe03d64 100644 --- a/src/components/canvas/shotstack-canvas.ts +++ b/src/components/canvas/shotstack-canvas.ts @@ -1,8 +1,9 @@ +import { BufferingNotice, visibleBounds } from "@canvas/buffering-notice"; import type { Player } from "@canvas/players/player"; import { AlignmentGuides } from "@canvas/system/alignment-guides"; import { createWebGLErrorOverlay } from "@canvas/webgl-error-overlay"; import { Edit } from "@core/edit-session"; -import { InternalEvent } from "@core/events/edit-events"; +import { EditEvent, InternalEvent } from "@core/events/edit-events"; import { sanitizeColor } from "@core/shared/color-utils"; import { ms } from "@core/timing/types"; import type { UIController } from "@core/ui/ui-controller"; @@ -61,6 +62,7 @@ export class Canvas { private onBackgroundClickBound: (event: pixi.FederatedPointerEvent) => void; private onWheelBound: (e: WheelEvent) => void; private canvasRoot: HTMLDivElement | null = null; + private readonly bufferingNotice = new BufferingNotice(); constructor(edit: Edit) { this.application = new pixi.Application(); @@ -141,6 +143,7 @@ export class Canvas { root.style.position = "relative"; root.appendChild(this.application.canvas); + this.bufferingNotice.mount(root); // Auto-mount UIController to canvas root (toolbars sit inside canvas) // mount() handles deferred positioning via double rAF @@ -568,6 +571,7 @@ export class Canvas { internalEvents.on(InternalEvent.PlayerRemovedFromTrack, this.onPlayerRemovedFromTrack); internalEvents.on(InternalEvent.TrackContainerRemoved, this.onTrackContainerRemoved); internalEvents.on(InternalEvent.ViewportSizeChanged, this.onViewportSizeChanged); + this.edit.events.on(EditEvent.PlaybackPause, this.onPlaybackPause); internalEvents.on(InternalEvent.ViewportNeedsZoomToFit, this.onViewportNeedsZoomToFit); } @@ -603,6 +607,11 @@ export class Canvas { this.removeTrackContainer(trackIndex); }; + // An export stops the ticker before it pauses, so no tick would be left to hide the notice. + private onPlaybackPause = (): void => { + this.bufferingNotice.update(false, true, null); + }; + private onViewportSizeChanged = ({ width, height, backgroundColor }: { width: number; height: number; backgroundColor: string }): void => { this.updateViewportForSize(width, height, backgroundColor); }; @@ -648,6 +657,9 @@ export class Canvas { private onTick(ticker: pixi.Ticker): void { this.edit.update(ticker.deltaTime, ms(ticker.deltaMS)); + const idle = !this.edit.isPlaying || this.edit.getPendingCaptures().pending === 0; + const shown = visibleBounds(this.getContentBounds(), this.viewportSize.width, this.viewportSize.height); + this.bufferingNotice.update(this.edit.isPlaybackBuffering(), idle, shown); // Update canvas overlays (selection handles, guides, etc.) this.uiController?.updateOverlays(ticker.deltaTime, ticker.deltaMS); @@ -693,6 +705,7 @@ export class Canvas { } public dispose(): void { + this.bufferingNotice.dispose(); // Unsubscribe from Edit internal events const internalEvents = this.edit.getInternalEvents(); internalEvents.off(InternalEvent.PlayerAddedToTrack, this.onPlayerAddedToTrack); @@ -700,6 +713,7 @@ export class Canvas { internalEvents.off(InternalEvent.PlayerRemovedFromTrack, this.onPlayerRemovedFromTrack); internalEvents.off(InternalEvent.TrackContainerRemoved, this.onTrackContainerRemoved); internalEvents.off(InternalEvent.ViewportSizeChanged, this.onViewportSizeChanged); + this.edit.events.off(EditEvent.PlaybackPause, this.onPlaybackPause); internalEvents.off(InternalEvent.ViewportNeedsZoomToFit, this.onViewportNeedsZoomToFit); const root = document.querySelector(Canvas.CanvasSelector); diff --git a/src/core/edit-session.ts b/src/core/edit-session.ts index 70e13fa4..b4e92b7d 100644 --- a/src/core/edit-session.ts +++ b/src/core/edit-session.ts @@ -584,6 +584,14 @@ export class Edit { return captureProgressOf(this.getPlayerByClipId(clipId)); } + /** + * Whether a playing html5 clip is showing a held frame, a late still or nothing. + * @internal + */ + public isPlaybackBuffering(): boolean { + return this.clips.some(player => player.isPlaybackBuffering()); + } + /** * Transient generation state for a clip, or undefined when idle. Not part of the * edit: never saved, never undone, cleared on reload. diff --git a/src/styles/index.css b/src/styles/index.css index 8bb59087..e3b6ecf8 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -22,6 +22,7 @@ @import "./ui/background-color-picker.css"; @import "./ui/font-picker.css"; @import "./ui/webgl-error.css"; +@import "./ui/buffering-notice.css"; /* Canvas Styles */ @import "./canvas/canvas-pan.css"; diff --git a/src/styles/ui/buffering-notice.css b/src/styles/ui/buffering-notice.css new file mode 100644 index 00000000..ddbdff4c --- /dev/null +++ b/src/styles/ui/buffering-notice.css @@ -0,0 +1,48 @@ +/* Sits in the video's corner while html5 playback stutters. It's editor chrome, so it never reaches an export. */ +.ss-buffering-notice { + position: absolute; + z-index: 8; + transform: translateY(-100%); + display: flex; + align-items: center; + gap: 7px; + padding: 6px 11px; + border-radius: 999px; + background: rgb(17 24 39 / 72%); + -webkit-backdrop-filter: blur(4px); + backdrop-filter: blur(4px); + color: #fff; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + font-size: 12px; + font-weight: 600; + line-height: 1.3; + white-space: nowrap; + /* A label, not a control: clicks go through to the canvas. */ + pointer-events: none; +} + +.ss-buffering-notice[hidden] { + display: none; +} + +.ss-buffering-notice-spinner { + flex: none; + width: 12px; + height: 12px; + border: 2px solid rgb(255 255 255 / 35%); + border-top-color: #fff; + border-radius: 50%; + animation: ss-buffering-spin 0.9s linear infinite; +} + +@keyframes ss-buffering-spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .ss-buffering-notice-spinner { + animation-duration: 3s; + } +} diff --git a/tests/buffering-notice.test.ts b/tests/buffering-notice.test.ts new file mode 100644 index 00000000..36961bf6 --- /dev/null +++ b/tests/buffering-notice.test.ts @@ -0,0 +1,113 @@ +/** @jest-environment jsdom */ +import { BufferingNotice, type ContentBounds, visibleBounds } from "@canvas/buffering-notice"; + +const wide: ContentBounds = { left: 100, right: 740, top: 50, bottom: 410 }; + +function mountNotice() { + let now = 0; + const root = document.createElement("div"); + document.body.appendChild(root); + const notice = new BufferingNotice(() => now); + notice.mount(root); + notice.update(false, false, wide); + const { element } = notice; + // Ticks at 50 ms, the way the canvas would call it every frame. + const play = (ms: number, buffering: boolean, idle = false, bounds: ContentBounds | null = wide) => { + for (let t = 0; t < ms; t += 50) { + now += 50; + notice.update(buffering, idle, bounds); + } + }; + const pauseTicking = (ms: number) => { + now += ms; + }; + return { notice, element, root, play, pauseTicking }; +} + +afterEach(() => { + document.body.replaceChildren(); +}); + +it("appears after half a second of buffering, not for a single late frame", () => { + const { element, play } = mountNotice(); + + play(100, true); + play(100, false); + play(450, true); + expect(element.hidden).toBe(true); + + play(50, true); + expect(element.hidden).toBe(false); +}); + +it("goes after 1.5 s of smooth playback, and at once when paused or fully loaded", () => { + const { element, play } = mountNotice(); + play(500, true); + + play(1450, false); + expect(element.hidden).toBe(false); + play(50, false); + expect(element.hidden).toBe(true); + + play(500, true); + expect(element.hidden).toBe(false); + play(50, true, true); + expect(element.hidden).toBe(true); +}); + +it("says only Buffering, and leaves it to the toolbar's status to tell assistive tech", () => { + const { element, play } = mountNotice(); + + play(500, true); + + expect(element.textContent).toBe("Buffering"); + expect(element.getAttribute("aria-hidden")).toBe("true"); +}); + +it("sits inside the video's bottom-left corner", () => { + const { element, play } = mountNotice(); + + play(500, true); + + expect(element.style.left).toBe("112px"); + expect(element.style.top).toBe("398px"); +}); + +it("leaves the canvas when disposed", () => { + const { notice, root } = mountNotice(); + + notice.dispose(); + + expect(root.querySelector(".ss-buffering-notice")).toBeNull(); +}); + +it("keeps to the part of the video inside the preview", () => { + const { element, play } = mountNotice(); + const shown = visibleBounds({ left: -50, right: 600, top: -20, bottom: 900 }, 800, 500); + expect(shown).toEqual({ left: 0, right: 600, top: 0, bottom: 500 }); + + play(500, true, false, shown); + + expect(element.style.left).toBe("12px"); + expect(element.style.top).toBe("488px"); +}); + +it("hides while none of the video is in view", () => { + const { element, play } = mountNotice(); + play(500, true); + const offscreen = visibleBounds({ left: 900, right: 1500, top: 0, bottom: 300 }, 800, 500); + expect(offscreen).toBeNull(); + + play(50, true, false, offscreen); + + expect(element.hidden).toBe(true); +}); + +it("dates no timer from before a pause in ticking", () => { + const { element, play, pauseTicking } = mountNotice(); + pauseTicking(5000); + + play(50, true); + + expect(element.hidden).toBe(true); +}); diff --git a/tests/html5-player.test.ts b/tests/html5-player.test.ts index 4abb328c..90399796 100644 --- a/tests/html5-player.test.ts +++ b/tests/html5-player.test.ts @@ -1626,6 +1626,47 @@ const playingAhead = async (...baked: number[]) => { return created; }; +it("reports buffering while the playhead's frame isn't baked, whatever is on screen", async () => { + const held = await playingAhead(8); + expect(held.player.isPlaybackBuffering()).toBe(true); + + const still = await playingAhead(0); + expect(still.player.isPlaybackBuffering()).toBe(true); + + const empty = createStillPlayer(); + Object.assign(empty.edit, { isPlaying: true }); + baking(empty.harness); + empty.harness.beginCapture(); + expect(empty.player.isPlaybackBuffering()).toBe(true); +}); + +it("reports no buffering once the playhead's frame is baked, before it reaches the screen", async () => { + const { player, harness } = await playingAhead(12); + expect(player.isPlaybackBuffering()).toBe(false); + + // The playhead moves before the frame on screen catches up. + Object.assign(player, { getPlaybackTime: () => 13 / 30 }); + harness.bakingFrames![13] = new Blob(["baked 13"]); + expect(player.isPlaybackBuffering()).toBe(false); + + Object.assign(player, { getPlaybackTime: () => 14 / 30 }); + expect(player.isPlaybackBuffering()).toBe(true); +}); + +it("reports no buffering while paused, once the bake is complete, or for a clip that failed", async () => { + const { player, harness, edit } = await playingAhead(8); + Object.assign(edit, { isPlaying: false }); + expect(player.isPlaybackBuffering()).toBe(false); + + Object.assign(edit, { isPlaying: true }); + Object.assign(harness, { mode: "playback" }); + expect(player.isPlaybackBuffering()).toBe(false); + + const failed = await playingAhead(8); + Object.assign(failed.harness, { fallbackGraphic: { destroy: jest.fn() } }); + expect(failed.player.isPlaybackBuffering()).toBe(false); +}); + it("holds the nearest baked frame while playing ahead of the bake", async () => { const { harness, seeks } = await playingAhead(8, 16);