diff --git a/package-lock.json b/package-lock.json index f827800a..c7e6a3a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "prism", - "version": "0.117.6", + "version": "0.117.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "prism", - "version": "0.117.6", + "version": "0.117.7", "license": "MIT", "dependencies": { "@codemirror/commands": "^6.11.0", diff --git a/package.json b/package.json index 6bcd66f7..29532b3f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prism", - "version": "0.117.6", + "version": "0.117.7", "description": "A fast, universal media viewer for Windows.", "author": "Max", "license": "MIT", diff --git a/src/main/convertWaiters.test.ts b/src/main/convertWaiters.test.ts new file mode 100644 index 00000000..5b3f9da2 --- /dev/null +++ b/src/main/convertWaiters.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { createConvertWaiters } from './convertWaiters' + +describe('the conversion waiters', () => { + it('keeps a cancel that arrives while main is still probing (review, 2026-10-16)', () => { + const stopped: string[] = [] + const w = createConvertWaiters((out) => stopped.push(out)) + const ask = w.join('C:\\film.wmv') + // The viewer arrows away before convertVideo has even named the copy. + w.cancel('C:\\film.wmv') + expect(ask.cancelled).toBe(true) + expect(w.started(ask, 'C:\\conv\\abc.mp4')).toBe(false) + expect(stopped).toEqual(['C:\\conv\\abc.mp4']) + }) + + it('stops a running copy when its only viewer walks away', () => { + const stopped: string[] = [] + const w = createConvertWaiters((out) => stopped.push(out)) + const ask = w.join('a.wmv') + expect(w.started(ask, 'a.mp4')).toBe(true) + w.cancel('a.wmv') + expect(stopped).toEqual(['a.mp4']) + }) + + it('leaves a shared copy running while another tab still waits', () => { + const stopped: string[] = [] + const w = createConvertWaiters((out) => stopped.push(out)) + const a = w.join('a.wmv') + const b = w.join('a.wmv') + w.started(a, 'a.mp4') + w.started(b, 'a.mp4') + w.cancel('a.wmv') + expect(stopped).toEqual([]) + w.cancel('a.wmv') + expect(stopped).toEqual(['a.mp4']) + }) + + it('does not stop a copy a NEWER ask wants, after an older ask was cancelled', () => { + const stopped: string[] = [] + const w = createConvertWaiters((out) => stopped.push(out)) + const old = w.join('a.wmv') + w.cancel('a.wmv') + const fresh = w.join('a.wmv') // the user came straight back + expect(w.started(old, 'a.mp4')).toBe(false) + expect(stopped).toEqual([]) + expect(w.started(fresh, 'a.mp4')).toBe(true) + }) + + it('treats a cancel after the copy finished as nothing', () => { + const stopped: string[] = [] + const w = createConvertWaiters((out) => stopped.push(out)) + const ask = w.join('a.wmv') + w.started(ask, 'a.mp4') + w.done(ask) + w.cancel('a.wmv') + expect(stopped).toEqual([]) + }) +}) diff --git a/src/main/convertWaiters.ts b/src/main/convertWaiters.ts new file mode 100644 index 00000000..75f275c0 --- /dev/null +++ b/src/main/convertWaiters.ts @@ -0,0 +1,83 @@ +/** + * Who is waiting for a video conversion, by SOURCE file (the renderer knows + * the file it asked about, never the copy's name). + * + * Two tabs can hold the same film and share ONE conversion: the first to walk + * away must not cancel it out from under the other (2026-08-28). + * + * A viewer is counted from the moment it ASKS (review, 2026-10-16). It used to + * be counted only once the probe and convertVideo's mkdir and stats had + * returned, and a cancel that arrived in that gap found nobody to cancel and + * was dropped: arrowing past a few WMVs left one full re-encode running per + * file, for nobody. Now a cancel in the gap marks the ask, and the copy is + * stopped as soon as its name is known, unless another viewer still wants it. + */ +export interface ConvertAsk { + readonly source: string + /** Set by a cancel that arrived before the copy's name was known. */ + cancelled: boolean +} + +interface Entry { + out: string | null + viewers: number + asks: Set +} + +export interface ConvertWaiters { + /** A viewer asks for `source`: counted at once, before any await. */ + join(source: string): ConvertAsk + /** The copy's name is known. False (and the copy stopped, if nobody else + * wants it) when this ask was cancelled meanwhile. */ + started(ask: ConvertAsk, out: string): boolean + /** The ask is over, finished or failed. */ + done(ask: ConvertAsk): void + /** A viewer walked away from `source`. */ + cancel(source: string): void +} + +export function createConvertWaiters(stop: (out: string) => void): ConvertWaiters { + const entries = new Map() + const live = (ask: ConvertAsk): Entry | null => { + const e = entries.get(ask.source) + return e && e.asks.has(ask) ? e : null + } + return { + join(source) { + const ask: ConvertAsk = { source, cancelled: false } + const e = entries.get(source) ?? { out: null, viewers: 0, asks: new Set() } + e.viewers += 1 + e.asks.add(ask) + entries.set(source, e) + return ask + }, + started(ask, out) { + if (ask.cancelled) { + // Somebody newer may have asked for the same film since: the job is + // shared (videoConvert's own dedup), so it stays for them. + const other = entries.get(ask.source) + if (!other || other.viewers <= 0) stop(out) + return false + } + const e = live(ask) + if (e) e.out = out + return true + }, + done(ask) { + const e = live(ask) + if (!e) return + e.asks.delete(ask) + if (e.asks.size === 0) entries.delete(ask.source) + }, + cancel(source) { + const e = entries.get(source) + if (!e) return + e.viewers -= 1 + if (e.viewers > 0) return // somebody else is still waiting for it + entries.delete(source) + if (e.out) stop(e.out) + // Not started yet: each ask stops its copy as soon as it is named. + for (const ask of e.asks) ask.cancelled = true + } + } +} diff --git a/src/main/imageDecode.ts b/src/main/imageDecode.ts index 735a93d2..62509dab 100644 --- a/src/main/imageDecode.ts +++ b/src/main/imageDecode.ts @@ -61,7 +61,10 @@ export function imageArgs(file: string): string[] { ] } -const cache = createLru(6) +/** Six by count, and by SIZE too (review, 2026-10-16): an RGBA PNG of a big + * TIFF scan or PSD runs to tens of MB, and six of them sat pinned in main. */ +const DECODED_CACHE_BYTES = 96 << 20 +const cache = createLru(6, { of: (b) => b.length, max: DECODED_CACHE_BYTES }) /** Decode one still to PNG bytes. Keyed by path + mtime, so an edit re-decodes. */ export async function decodeImage(ffmpeg: string, file: string, mtimeMs: number): Promise { diff --git a/src/main/index.ts b/src/main/index.ts index a202afca..1eecafea 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -77,6 +77,7 @@ import { initSweepOverlay, loadSweepAddon, sweepDirs, type SweepOverlay } from ' import { documentImages } from './docImages' import { AUDIO_SCHEME, killSidecars, serveSidecarAudio } from './audioSidecar' import { probeMedia, type MediaInfo } from './ffmpeg' +import { createProbeCache } from './probeCache' import { cancelAllConversions } from './videoConvert' @@ -1336,24 +1337,16 @@ if (!app.requestSingleInstanceLock()) { // audio player, the video player, the no-picture note and the phone's // /api/play all ask. Null is "ffprobe could not read it" AND "there is // no ffprobe": the callers that tell those apart ask findFfmpeg first. - const probeCache = new Map() - const probeCached = async (p: string): Promise => { - const tools = ffmpegTools() - if (!tools?.ffprobe) return null - let key: string - try { - key = `${p}|${(await stat(p)).mtimeMs}` - } catch { - return null - } - let info = probeCache.get(key) - if (info === undefined) { - info = await probeMedia(tools.ffprobe, p) - if (probeCache.size > 40) probeCache.clear() - probeCache.set(key, info) - } - return info - } + // The in-flight probe is shared too (review, 2026-10-16): see probeCache.ts. + const probeShared = createProbeCache( + (p) => { + const ffprobe = ffmpegTools()?.ffprobe + return ffprobe ? probeMedia(ffprobe, p) : Promise.resolve(null) + }, + async (p) => (await stat(p)).mtimeMs + ) + const probeCached = async (p: string): Promise => + ffmpegTools()?.ffprobe ? probeShared(p) : null // The phone's HLS jobs (#105): one ffmpeg per phone and file, under // userData. Made once, and the directory is wiped at startup: segments diff --git a/src/main/ipc/media.ts b/src/main/ipc/media.ts index f7db0c6d..a3a462e3 100644 --- a/src/main/ipc/media.ts +++ b/src/main/ipc/media.ts @@ -5,9 +5,10 @@ import { basename, dirname, extname } from 'path' import { extendDesktopDirectories, insideDesktop } from '../desktopAccess' import { userPaths } from '../paths' import { AUDIO_SCHEME } from '../audioSidecar' -import { FIRST_AUDIO, needsSidecar, probeMedia } from '../ffmpeg' +import { FIRST_AUDIO, needsSidecar } from '../ffmpeg' import { cancelConversion, convertVideo, planConversion } from '../videoConvert' -import { cachedPeaks, loadPeaks } from '../peaks' +import { createConvertWaiters } from '../convertWaiters' +import { cancelPeaks, loadPeaks } from '../peaks' import { isMidi, renderMidi } from '../midi' import { readAsVtt, sidecarsFor, type SubTrack } from '../subtitles' import type { MediaProbe } from '@shared/types' @@ -76,15 +77,21 @@ export function registerMediaIpc(ctx: MainCtx, ready: ReadyCtx): void { */ ipcMain.handle(CALL.mediaPeaks, async (_e, p: string): Promise => { if (!readable(p)) return null - // Known already: no ffprobe, no ffmpeg, no wait. - const known = await cachedPeaks(p) - if (known) return known const tools = ffmpegTools() - if (!tools) return null - if (!tools.ffprobe) return null - const info = await probeMedia(tools.ffprobe, p) - if (!info?.audio || !info.duration) return null - return loadPeaks(tools.ffmpeg, p, info.duration) + if (!tools?.ffprobe) return null + // A known answer comes back without probing; otherwise the probe runs + // INSIDE the queued job, through the shared cache, so a cancel during it + // is not lost and the player's own probe is reused (review, 2026-10-16). + return loadPeaks(tools.ffmpeg, p, async () => { + const info = await probeCached(p) + return info?.audio && info.duration ? info.duration : 0 + }) + }) + // The waveform's viewer moved on: the decode of a whole film behind it is + // for nobody (review, 2026-10-16). Counted per ask, so another tab on the + // same file keeps its job. + ipcMain.on(CALL.mediaPeaksCancel, (_e, p: string) => { + if (typeof p === 'string') cancelPeaks(p) }) ipcMain.handle(CALL.mediaProbe, async (_e, p: string): Promise => { const none: MediaProbe = { ffmpeg: false, needed: false } @@ -168,21 +175,27 @@ export function registerMediaIpc(ctx: MainCtx, ready: ReadyCtx): void { // renderer waits on this and shows progress; a file already converted // comes back immediately. const convertDir = userPaths.converted - /** Source file -> the copy being written for it and how many viewers are - * waiting. Two tabs can hold the same film (each tab keeps its own live - * player), and they share ONE conversion: the first to walk away must not - * cancel it out from under the other (2026-08-28). */ - const converting = new Map() + /** Source file -> the viewers waiting for its copy. Two tabs can hold the + * same film and share ONE conversion (2026-08-28); a viewer now counts + * from the moment it asks, so a cancel during the probe is no longer lost + * (review, 2026-10-16, see convertWaiters.ts). */ + const converting = createConvertWaiters(cancelConversion) ipcMain.handle( CALL.videoConvert, async (e, p: string): Promise => { if (!readable(p)) return { error: 'outside the folder' } const tools = ffmpegTools() if (!tools?.ffprobe) return { error: 'no decoder available' } - const info = await probeMedia(tools.ffprobe, p) - const plan = planConversion(info, extname(p)) - if (!plan.needed) return { error: 'nothing to convert' } + // Joined BEFORE the first await: the renderer cancels in its effect + // cleanup, and arrowing away while main was still probing used to drop + // that cancel and leave a whole re-encode running for nobody (review, + // 2026-10-16). The probe is the shared one the player already asked. + const ask = converting.join(p) try { + const info = await probeCached(p) + const plan = planConversion(info, extname(p)) + if (!plan.needed) return { error: 'nothing to convert' } + if (ask.cancelled) return { error: 'cancelled' } const handle = await convertVideo( tools.ffmpeg, p, @@ -191,17 +204,15 @@ export function registerMediaIpc(ctx: MainCtx, ready: ReadyCtx): void { info?.duration ?? 0, (pct) => e.sender.send(PUSH.videoProgress, { path: p, pct }) ) - // The renderer knows the FILE it asked about, never the copy's name, - // so cancelling has to be answerable from the source (2026-08-28). - const seen = converting.get(p) - converting.set(p, { out: handle.out, viewers: (seen?.viewers ?? 0) + 1 }) + if (!converting.started(ask, handle.out)) return { error: 'cancelled' } const out = await handle.done - converting.delete(p) wall.grantExtracted(out) // the copy is ours to serve, wherever it sits wall.grantServable(out) return { url: `${MEDIA_SCHEME}://local/${encodeURIComponent(out)}` } } catch (err) { return { error: err instanceof Error ? err.message : 'conversion failed' } + } finally { + converting.done(ask) } } ) @@ -209,12 +220,7 @@ export function registerMediaIpc(ctx: MainCtx, ready: ReadyCtx): void { // file while a whole film was being re-encoded behind it (2026-08-28). ipcMain.on(CALL.videoCancel, (_e, source: string) => { if (typeof source !== 'string') return - const job = converting.get(source) - if (!job) return - job.viewers -= 1 - if (job.viewers > 0) return // somebody else is still waiting for it - converting.delete(source) - cancelConversion(job.out) + converting.cancel(source) }) // Office and ebook documents: converted to HTML in main, sanitised there diff --git a/src/main/lru.test.ts b/src/main/lru.test.ts index 32b22db2..d8456cc5 100644 --- a/src/main/lru.test.ts +++ b/src/main/lru.test.ts @@ -51,3 +51,31 @@ describe('createLru', () => { expect(c.size).toBe(0) }) }) + +describe('createLru by weight (review, 2026-10-16)', () => { + it('drops the oldest once the total passes the cap, whatever the count', () => { + const c = createLru(6, { of: (b) => b.length, max: 100 }) + c.set('a', Buffer.alloc(60)) + c.set('b', Buffer.alloc(30)) + c.set('c', Buffer.alloc(30)) // 120: a goes + expect(c.peek('a')).toBeUndefined() + expect(c.size).toBe(2) + }) + + it('keeps nothing heavier than the whole cap', () => { + const c = createLru(6, { of: (b) => b.length, max: 100 }) + c.set('small', Buffer.alloc(10)) + c.set('huge', Buffer.alloc(500)) + expect(c.peek('huge')).toBeUndefined() + expect(c.size).toBe(0) + }) + + it('counts a replaced or deleted entry out of the total', () => { + const c = createLru(6, { of: (b) => b.length, max: 100 }) + c.set('a', Buffer.alloc(90)) + c.set('a', Buffer.alloc(10)) + c.delete('a') + c.set('b', Buffer.alloc(95)) + expect(c.peek('b')).toBeDefined() + }) +}) diff --git a/src/main/lru.ts b/src/main/lru.ts index 09e32400..d14c3ea7 100644 --- a/src/main/lru.ts +++ b/src/main/lru.ts @@ -6,6 +6,10 @@ * * `get` counts as a use and moves the entry to the newest end; `peek` reads * without that, for the caches that drop strictly oldest-written first. + * + * `weight` caps by SIZE as well as by count (review, 2026-10-16): six decoded + * TIFF scans as RGBA PNGs were hundreds of MB pinned in main. An entry heavier + * than the whole cap is not kept at all. */ export interface Lru { get(key: K): V | undefined @@ -17,8 +21,18 @@ export interface Lru { readonly size: number } -export function createLru(max: number): Lru { +export function createLru( + max: number, + weight?: { of: (value: V) => number; max: number } +): Lru { const map = new Map() + let total = 0 + const drop = (key: K): boolean => { + if (!map.has(key)) return false + if (weight) total -= weight.of(map.get(key) as V) + return map.delete(key) + } + const over = (): boolean => map.size > max || (!!weight && total > weight.max) return { get(key) { if (!map.has(key)) return undefined @@ -29,12 +43,16 @@ export function createLru(max: number): Lru { }, peek: (key) => map.get(key), set(key, value) { - map.delete(key) + drop(key) map.set(key, value) - while (map.size > max) map.delete(map.keys().next().value as K) + if (weight) total += weight.of(value) + while (map.size > 0 && over()) drop(map.keys().next().value as K) + }, + delete: drop, + clear: () => { + map.clear() + total = 0 }, - delete: (key) => map.delete(key), - clear: () => map.clear(), get size() { return map.size } diff --git a/src/main/mediaProtocol.test.ts b/src/main/mediaProtocol.test.ts index f36eb16d..4837032b 100644 --- a/src/main/mediaProtocol.test.ts +++ b/src/main/mediaProtocol.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, writeFileSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' import { describe, expect, it } from 'vitest' -import { createMediaServer } from './mediaProtocol' +import { WHOLE_BUFFER_MAX, createMediaServer, madeImageBody } from './mediaProtocol' // fsmedia:// (stage 9b): the wall answers first, and a Range request is a // 206 with exactly the bytes asked for, so media can seek. @@ -56,3 +56,48 @@ describe('fsmedia://', () => { expect(big.headers.get('content-range')).toBe('bytes 8-9/10') }) }) + +// A picture main made (decoded TIFF/PSD, raw preview, HEIC) follows the same +// 8 MB rule as a file on disk (review, 2026-10-16): one huge buffer is one +// huge IPC message and a renderer stall. +describe('a picture main made', () => { + it('goes back whole when small', () => { + const small = Buffer.alloc(1024) + expect(madeImageBody(small)).toBe(small) + }) + + it('streams in chunks above 8 MB, every byte intact', async () => { + const big = Buffer.alloc(WHOLE_BUFFER_MAX + 3 * 1024 * 1024 + 7) + big[big.length - 1] = 0x5a + const body = madeImageBody(big) + expect(body).toBeInstanceOf(ReadableStream) + const reader = (body as ReadableStream).getReader() + let chunks = 0 + let total = 0 + let last = 0 + for (;;) { + const { done, value } = await reader.read() + if (done) break + chunks += 1 + total += value.length + expect(value.length).toBeLessThanOrEqual(1024 * 1024) + last = value[value.length - 1] + } + expect(chunks).toBeGreaterThan(8) + expect(total).toBe(big.length) + expect(last).toBe(0x5a) + }) + + it('is what the HEIC route answers with', async () => { + const heic = join(dir, 'photo.heic') + writeFileSync(heic, Buffer.from('x')) + const jpeg = Buffer.alloc(WHOLE_BUFFER_MAX + 1) + const res = await createMediaServer({ + allowed: () => true, + ffmpeg: () => null, + heic: () => Promise.resolve(jpeg) + })(new Request(url(heic))) + expect(res.headers.get('content-length')).toBe(String(jpeg.length)) + expect((await res.arrayBuffer()).byteLength).toBe(jpeg.length) + }) +}) diff --git a/src/main/mediaProtocol.ts b/src/main/mediaProtocol.ts index 9ae6a93c..320d7f31 100644 --- a/src/main/mediaProtocol.ts +++ b/src/main/mediaProtocol.ts @@ -23,6 +23,33 @@ export interface MediaServerDeps { heic: (p: string, mtimeMs: number) => Promise } +/** Past this a whole buffer is one IPC message, and tracing a 40 MB image + * showed a 1.2s "Receive mojo message" stall in the renderer (see below). */ +export const WHOLE_BUFFER_MAX = 8 * 1024 * 1024 +const CHUNK = 1024 * 1024 + +/** + * A picture main MADE (a decoded TIFF or PSD, a raw's preview, a HEIC), as a + * response body: whole when small, in 1 MB chunks above the 8 MB rule + * (review, 2026-10-16). Those used to go back as one buffer of any size, so a + * big scan froze the renderer for the second the file's own rule was there to + * prevent. + */ +export function madeImageBody(buf: Buffer): Buffer | ReadableStream { + if (buf.length <= WHOLE_BUFFER_MAX) return buf + let at = 0 + return new ReadableStream({ + pull(controller) { + if (at >= buf.length) { + controller.close() + return + } + controller.enqueue(buf.subarray(at, Math.min(buf.length, at + CHUNK))) + at += CHUNK + } + }) +} + /** * fsmedia:// (stage 9b of the structure cleanup, 2026-10-12, out of index.ts): * serve a local file honouring Range requests (206), so media can seek. Camera @@ -54,7 +81,7 @@ export function createMediaServer(deps: MediaServerDeps): (request: Request) => if (isRaw(filePath)) { try { const jpeg = await rawPreview(filePath, st.mtimeMs) - return new Response(jpeg, { + return new Response(madeImageBody(jpeg), { status: 200, headers: { 'Content-Type': 'image/jpeg', @@ -78,7 +105,7 @@ export function createMediaServer(deps: MediaServerDeps): (request: Request) => // scans and faxes arrive this way constantly, and silence there is // how someone misses eleven pages of a document. const pages = /\.tiff?$/i.test(filePath) ? await tiffPages(filePath) : 1 - return new Response(png, { + return new Response(madeImageBody(png), { status: 200, headers: { 'Content-Type': 'image/png', @@ -103,7 +130,7 @@ export function createMediaServer(deps: MediaServerDeps): (request: Request) => if (isHeic(filePath)) { try { const jpeg = await deps.heic(filePath, st.mtimeMs) - return new Response(jpeg, { + return new Response(madeImageBody(jpeg), { status: 200, headers: { 'Content-Type': 'image/jpeg', @@ -125,7 +152,7 @@ export function createMediaServer(deps: MediaServerDeps): (request: Request) => // large buffer crosses the process boundary as one IPC message, and tracing a // 40 MB image showed a 1.2s "Receive mojo message" stall in the renderer. Bigger // files stream, arriving in chunks that never block. - if (type.startsWith('image/') && size <= 8 * 1024 * 1024) { + if (type.startsWith('image/') && size <= WHOLE_BUFFER_MAX) { try { const buf = await readFile(filePath) return new Response(buf, { diff --git a/src/main/peaks.ts b/src/main/peaks.ts index 0424d07e..c3e1a085 100644 --- a/src/main/peaks.ts +++ b/src/main/peaks.ts @@ -98,12 +98,108 @@ async function key(file: string): Promise { * `duration` decides the bucket width; without it the stream would have to be * held to know how long it was, which is the whole thing this avoids. */ -export async function loadPeaks(ffmpeg: string, file: string, duration: number): Promise { - const k = await key(file) - const hit = cache.peek(k) - if (hit) return hit - if (!Number.isFinite(duration) || duration <= 0) return null +/** + * One job per file at a time, ONE decode at a time, and a way to stop one + * (review, 2026-10-16). + * + * Every miss used to spawn its own ffmpeg reading the whole file, and nothing + * killed it: the renderer's cleanup only set a local flag. Arrowing through + * big films with the Wave transport stacked whole-file decodes that fought + * playback for the disk. Now a second ask for the same file joins the first, + * the decodes queue behind one another, and `cancelPeaks` stops a job once + * the last viewer that asked for it has gone. + */ +interface PeakRun { + file: string + waiters: number + cancelled: boolean + kill: (() => void) | null + promise: Promise +} + +const inflight = new Map() +let busy = false +const queue: Array<() => void> = [] + +function acquire(): Promise { + if (!busy) { + busy = true + return Promise.resolve() + } + return new Promise((resolve) => queue.push(resolve)) +} + +function release(): void { + const next = queue.shift() + if (next) next() + else busy = false +} + +/** A viewer of `file` walked away: its job stops once nobody else waits. */ +export function cancelPeaks(file: string): void { + const run = inflight.get(file) + if (!run) return + run.waiters -= 1 + if (run.waiters > 0) return + run.cancelled = true + run.kill?.() + inflight.delete(file) +} + +/** + * `duration` may be a function, asked only once this job's turn has come and + * it has not been cancelled: the caller's probe then waits in the same queue + * and a cancel during it is not lost. + * + * The ask is registered SYNCHRONOUSLY, keyed by path, before the first await + * (the stat for the cache key): a cancel that arrives while that stat runs + * then finds the job and stops it, where keying by the stat's answer let it + * slip past and left a whole-file decode at the head of the one-at-a-time + * queue, for nobody. + */ +export function loadPeaks( + ffmpeg: string, + file: string, + duration: number | (() => Promise) +): Promise { + const running = inflight.get(file) + if (running) { + running.waiters += 1 + return running.promise + } + const run: PeakRun = { file, waiters: 1, cancelled: false, kill: null, promise: Promise.resolve(null) } + run.promise = (async (): Promise => { + try { + const k = await key(file) + // A known answer never waits behind another file's decode. + const hit = cache.peek(k) + if (hit) return hit + if (run.cancelled) return null + if (typeof duration === 'number' && (!Number.isFinite(duration) || duration <= 0)) return null + await acquire() + try { + if (run.cancelled) return null + const secs = typeof duration === 'number' ? duration : await duration() + if (run.cancelled || !Number.isFinite(secs) || secs <= 0) return null + return await decode(ffmpeg, file, secs, k, run) + } finally { + release() + } + } finally { + if (inflight.get(file) === run) inflight.delete(file) + } + })() + inflight.set(file, run) + return run.promise +} +function decode( + ffmpeg: string, + file: string, + duration: number, + k: string, + run: PeakRun +): Promise { return new Promise((resolve) => { const acc = { peaks: new Array(BUCKETS).fill(0), @@ -112,6 +208,7 @@ export async function loadPeaks(ffmpeg: string, file: string, duration: number): odd: null as number | null } const proc = spawn(ffmpeg, peaksArgs(file), { windowsHide: true }) + run.kill = () => proc.kill() holders.add(proc, file) // a move of the file kills this first (#127) // A file with no audio, or one ffmpeg cannot open, simply produces nothing. let any = false @@ -121,7 +218,8 @@ export async function loadPeaks(ffmpeg: string, file: string, duration: number): }) proc.stderr.resume() const done = (): void => { - if (!any) return resolve(null) + // A killed job's half-envelope is not the file's: never cached. + if (!any || run.cancelled) return resolve(null) const out = normalise(acc.peaks) cache.set(k, out) resolve(out) diff --git a/src/main/peaksJobs.test.ts b/src/main/peaksJobs.test.ts new file mode 100644 index 00000000..2dd7460b --- /dev/null +++ b/src/main/peaksJobs.test.ts @@ -0,0 +1,96 @@ +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { spawn } from 'child_process' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { cancelPeaks, loadPeaks } from './peaks' + +// The waveform's jobs (review, 2026-10-16): a fake ffmpeg per spawn, so the +// test can see how many decodes run at once and which ones were killed. +vi.mock('child_process', () => ({ spawn: vi.fn() })) +vi.mock('./holders', () => ({ holders: { add: vi.fn() } })) + +interface Fake extends EventEmitter { + stdout: PassThrough + stderr: PassThrough + killed: boolean + kill: () => void +} + +let procs: Fake[] = [] + +beforeEach(() => { + procs = [] + vi.mocked(spawn).mockImplementation((() => { + const p = new EventEmitter() as Fake + p.stdout = new PassThrough() + p.stderr = new PassThrough() + p.killed = false + p.kill = () => { + p.killed = true + setTimeout(() => p.emit('close', null), 0) + } + procs.push(p) + return p + }) as unknown as typeof spawn) +}) + +const tick = (): Promise => new Promise((r) => setTimeout(r, 5)) +const finish = (p: Fake): void => { + p.stdout.write(Buffer.from([0, 0x40, 0, 0x20])) + setTimeout(() => p.emit('close', 0), 1) +} + +describe('the waveform jobs', () => { + it('runs one decode at a time, and a cancel stops the running one', async () => { + const a = loadPeaks('ffmpeg', 'prism-no-such-dir/a.mkv', 60) + const b = loadPeaks('ffmpeg', 'prism-no-such-dir/b.mkv', 60) + await tick() + expect(procs).toHaveLength(1) // b waits its turn + cancelPeaks('prism-no-such-dir/a.mkv') + expect(procs[0].killed).toBe(true) + expect(await a).toBeNull() // a killed job's half-envelope is never kept + await tick() + expect(procs).toHaveLength(2) + finish(procs[1]) + expect((await b)?.length).toBeGreaterThan(0) + }) + + it('shares one job between two asks, and keeps it while one still waits', async () => { + const a1 = loadPeaks('ffmpeg', 'prism-no-such-dir/c.mkv', 60) + const a2 = loadPeaks('ffmpeg', 'prism-no-such-dir/c.mkv', 60) + await tick() + expect(procs).toHaveLength(1) + cancelPeaks('prism-no-such-dir/c.mkv') + expect(procs[0].killed).toBe(false) // the other tab still wants it + finish(procs[0]) + expect(await a2).toBe(await a1) + }) + + it('keeps a cancel that arrives while the cache key is still being read', async () => { + const probe = vi.fn(async () => 60) + const asked = loadPeaks('ffmpeg', 'prism-no-such-dir/f.mkv', probe) + cancelPeaks('prism-no-such-dir/f.mkv') // before the stat has answered + expect(await asked).toBeNull() + expect(probe).not.toHaveBeenCalled() + expect(procs).toHaveLength(0) + // ...and nothing was left at the head of the queue: the next file decodes. + const next = loadPeaks('ffmpeg', 'prism-no-such-dir/g.mkv', 60) + await tick() + expect(procs).toHaveLength(1) + finish(procs[0]) + expect((await next)?.length).toBeGreaterThan(0) + }) + + it('never probes or spawns for a job cancelled while it queued', async () => { + const first = loadPeaks('ffmpeg', 'prism-no-such-dir/d.mkv', 60) + const probe = vi.fn(async () => 60) + const queued = loadPeaks('ffmpeg', 'prism-no-such-dir/e.mkv', probe) + await tick() + cancelPeaks('prism-no-such-dir/e.mkv') + finish(procs[0]) + await first + expect(await queued).toBeNull() + expect(probe).not.toHaveBeenCalled() + expect(procs).toHaveLength(1) + }) +}) diff --git a/src/main/probeCache.test.ts b/src/main/probeCache.test.ts new file mode 100644 index 00000000..e84bc8f3 --- /dev/null +++ b/src/main/probeCache.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import type { MediaInfo } from './ffmpeg' +import { createProbeCache } from './probeCache' + +const info = { duration: 60 } as unknown as MediaInfo + +describe('the probe cache', () => { + it('shares a probe still running: two asks at one mount spawn ONE ffprobe (2026-10-16)', async () => { + let calls = 0 + let finish: (v: MediaInfo | null) => void = () => {} + const probed = createProbeCache( + () => { + calls += 1 + return new Promise((r) => (finish = r)) + }, + async () => 1 + ) + const a = probed('C:\\film.wmv') + const b = probed('C:\\film.wmv') + // Both asks have passed the mtime await before the probe answers. + await new Promise((r) => setTimeout(r, 0)) + finish(info) + expect(await a).toBe(info) + expect(await b).toBe(info) + expect(calls).toBe(1) + }) + + it('probes again after an edit (a new mtime)', async () => { + let calls = 0 + let mtime = 1 + const probed = createProbeCache( + async () => { + calls += 1 + return info + }, + async () => mtime + ) + await probed('a.mkv') + await probed('a.mkv') + mtime = 2 + await probed('a.mkv') + expect(calls).toBe(2) + }) + + it('answers null for a file it cannot stat, without probing', async () => { + let calls = 0 + const probed = createProbeCache( + async () => { + calls += 1 + return info + }, + () => Promise.reject(new Error('ENOENT')) + ) + expect(await probed('gone.mkv')).toBeNull() + expect(calls).toBe(0) + }) + + it('does not keep a probe that threw', async () => { + let calls = 0 + const probed = createProbeCache( + async () => { + calls += 1 + if (calls === 1) throw new Error('spawn failed') + return info + }, + async () => 1 + ) + await expect(probed('a.mkv')).rejects.toThrow() + expect(await probed('a.mkv')).toBe(info) + }) +}) diff --git a/src/main/probeCache.ts b/src/main/probeCache.ts new file mode 100644 index 00000000..5037d9a7 --- /dev/null +++ b/src/main/probeCache.ts @@ -0,0 +1,40 @@ +import type { MediaInfo } from './ffmpeg' + +/** + * One probe per file, kept for as long as the file has not changed: the + * audio player, the video player, the no-picture note, the conversion, the + * waveform and the phone's /api/play all ask. + * + * It holds the PROMISE, not the answer (review, 2026-10-16): it used to store + * the answer only once ffprobe had finished, so the video player and the + * sidecar audio asking at the same mount each spawned their own ffprobe, and + * the conversion and the peaks probed a third time outside the cache. A + * second caller now joins the probe already running. + */ +export function createProbeCache( + probe: (p: string) => Promise, + mtimeOf: (p: string) => Promise, + max = 40 +): (p: string) => Promise { + const cache = new Map>() + return async (p) => { + let key: string + try { + key = `${p}|${await mtimeOf(p)}` + } catch { + return null + } + let info = cache.get(key) + if (!info) { + info = probe(p) + if (cache.size > max) cache.clear() + cache.set(key, info) + // A probe that threw is not an answer to keep: the next ask tries again. + const mine = info + info.catch(() => { + if (cache.get(key) === mine) cache.delete(key) + }) + } + return info + } +} diff --git a/src/main/rawPreview.test.ts b/src/main/rawPreview.test.ts index 8cebd611..a5122869 100644 --- a/src/main/rawPreview.test.ts +++ b/src/main/rawPreview.test.ts @@ -43,6 +43,26 @@ describe('camera raw', () => { expect(findJpeg(file)?.length).toBe(60000) }) + it('hands back a COPY, so the cached preview does not pin the scan buffer (2026-10-16)', () => { + const file = Buffer.concat([Buffer.alloc(2048, 0x11), jpeg(9000), Buffer.alloc(50000)]) + const found = findJpeg(file) + expect(found?.buffer).not.toBe(file.buffer) + expect(found?.buffer.byteLength).toBe(9000) + file.fill(0) + expect(found?.[0]).toBe(0xff) // untouched by the scan buffer's reuse + }) + + it('finds the largest run among several, skipping a start with no end', () => { + const file = Buffer.concat([ + jpeg(6000, 0x22), + Buffer.alloc(10), + jpeg(20000, 0x33), + Buffer.from([0xff, 0xd8, 0xff]), + Buffer.alloc(9000, 0x5) + ]) + expect(findJpeg(file)?.length).toBe(20000) + }) + it('ignores a fragment too small to be a picture', () => { expect(findJpeg(jpeg(200))).toBeNull() }) diff --git a/src/main/rawPreview.ts b/src/main/rawPreview.ts index a20a1010..29f9732c 100644 --- a/src/main/rawPreview.ts +++ b/src/main/rawPreview.ts @@ -45,33 +45,31 @@ const SCAN_BYTES = 40 << 20 */ export function findJpeg(buf: Buffer): Buffer | null { let best: { start: number; end: number } | null = null - let i = 0 - while (i < buf.length - 1) { - // SOI: ff d8 ff - if (buf[i] === 0xff && buf[i + 1] === 0xd8 && buf[i + 2] === 0xff) { - const end = findEoi(buf, i + 2) - if (end > 0) { - const size = end - i - if (!best || size > best.end - best.start) best = { start: i, end } - i = end - continue - } - } - i++ + // buf.indexOf, not a byte-by-byte JS loop (review, 2026-10-16): the loop + // cost 40-50 ms of main's one thread per 40 MB scan, every step through a + // folder of raws and every neighbour preload. Same markers, native search. + let i = buf.indexOf(SOI) + while (i >= 0) { + const eoi = buf.indexOf(EOI, i + 2) + // No end marker after this start means none after any later start either. + if (eoi < 0) break + const end = eoi + 2 + if (!best || end - i > best.end - best.start) best = { start: i, end } + i = buf.indexOf(SOI, end) } if (!best) return null - const out = buf.subarray(best.start, best.end) // A few hundred bytes is a corrupt fragment, not a picture. - return out.length > 4096 ? out : null + if (best.end - best.start <= 4096) return null + // A COPY, not a subarray (review, 2026-10-16): a subarray kept the whole + // 25-40 MB scan buffer alive behind every cached preview, four of them. + // allocUnsafeSlow: its own memory, exactly this size, never a pool slab. + const out = Buffer.allocUnsafeSlow(best.end - best.start) + buf.copy(out, 0, best.start, best.end) + return out } -/** The end-of-image marker that closes the JPEG starting before `from`. */ -function findEoi(buf: Buffer, from: number): number { - for (let i = from; i < buf.length - 1; i++) { - if (buf[i] === 0xff && buf[i + 1] === 0xd9) return i + 2 - } - return -1 -} +const SOI = Buffer.from([0xff, 0xd8, 0xff]) +const EOI = Buffer.from([0xff, 0xd9]) const cache = createLru(4) diff --git a/src/preload/index.ts b/src/preload/index.ts index 9f196b5e..85879127 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -343,6 +343,8 @@ const api = { * and streamed there: the renderer never holds the file. null when the * file has no audio, or no ffmpeg was found. */ mediaPeaks: (path: string): Promise => ipcRenderer.invoke(CALL.mediaPeaks, path), + /** The waveform's viewer moved on: stop that decode (2026-10-16). */ + cancelPeaks: (path: string): void => ipcRenderer.send(CALL.mediaPeaksCancel, path), /** Does this file's audio need Prism's own decoder (AC-3, DTS, TrueHD and * friends, none of which Chromium can play), and where is it served. */ probeMedia: (path: string): Promise => ipcRenderer.invoke(CALL.mediaProbe, path), diff --git a/src/renderer/src/components/ImageView.tsx b/src/renderer/src/components/ImageView.tsx index 41e40786..885508c8 100644 --- a/src/renderer/src/components/ImageView.tsx +++ b/src/renderer/src/components/ImageView.tsx @@ -19,6 +19,7 @@ import { encodeCopy, pngFromBlob } from '../lib/copyImage' import { loadSlideSeconds, stopsSlideshow, type SlideSeconds } from '../lib/slideshow' import { isTypingTarget } from '../lib/focusGuards' import { clamp } from '../lib/clamp' +import { imageStillLoading } from '../lib/imageLoading' // Above this resolution Chromium rasterizes a visible on the MAIN thread — // measured at 2.3s of hard freeze for a 384 MP PNG, during which nothing paints @@ -114,6 +115,10 @@ export function ImageView({ const [img, setImg] = useState(null) const canvasRef = useRef(null) const huge = !!img && img.width * img.height > CANVAS_PATH_PIXELS + /** The huge picture whose bitmap is on the canvas: until it is THIS one, + * the spinner shows (review, 2026-10-16, lib/imageLoading). */ + const [paintedHuge, setPaintedHuge] = useState(null) + const loading = imageStillLoading(loaded, huge, img, paintedHuge) /* How big the stage is, measured rather than assumed (2026-08-28). * @@ -250,6 +255,7 @@ export function ImageView({ bmp.close() } setLoaded(true) + setPaintedHuge(img) }) .catch(() => alive && setFailed(true)) return () => { @@ -509,7 +515,7 @@ export function ImageView({ {/* Buffering spinner. Mounted for the whole load; the `delayed-loader` class keeps it invisible for the first ~260ms (CSS, so it survives a main-thread stall from a huge decode) and fades it in after that. */} - {!loaded && ( + {loading && ( )} {/* Fit the stage in both directions (same reason as the video): max-w/max-h diff --git a/src/renderer/src/components/Visualizer.tsx b/src/renderer/src/components/Visualizer.tsx index ab2c727b..6e199e79 100644 --- a/src/renderer/src/components/Visualizer.tsx +++ b/src/renderer/src/components/Visualizer.tsx @@ -3,6 +3,8 @@ import type { AudioFrame, DrawFn, VizOpts, VizTheme } from '../lib/viz/core' import { clamp } from '../lib/clamp' import { styleById } from '../lib/viz/styles' import { analyzeDrops } from '../lib/viz/drops' +import { watchDrops } from '../lib/viz/dropWatch' +import { drawLoopRuns, wantsResume } from '../lib/viz/vizGuards' import { getAudioContext, mediaGraph } from '../lib/audio' import { hexToRgb } from '../lib/colour' @@ -27,10 +29,6 @@ function rgbOf(colour: string): [number, number, number] { return m ? [Number(m[0]), Number(m[1]), Number(m[2])] : [13, 15, 20] } -/** Past this, the drop analysis is skipped: its cost is the file's whole - * decoded length in memory, and nobody needs drop rings in an audiobook. */ -const DROPS_MAX_SECONDS = 15 * 60 - export function Visualizer({ media, styleId, @@ -88,31 +86,25 @@ export function Visualizer({ // costs a multiple of its size in memory - fine for a song, ruinous for an // audiobook or a DJ set, and pointless either way: drop rings are a thing // that happens in music, not in a four-hour recording. + // + // Only once the length is KNOWN (review, 2026-10-16): the run at mount saw + // a NaN duration, passed the guard and decoded audiobooks whole. See + // lib/viz/dropWatch.ts. useEffect(() => { + dropTimesRef.current = [] if (!media) return - let cancelled = false - const run = (): void => { - dropTimesRef.current = [] - if (Number.isFinite(media.duration) && media.duration > DROPS_MAX_SECONDS) return - const url = media.currentSrc || media.src - if (!url) return - void (async () => { - try { - const resp = await fetch(url) - const bytes = await resp.arrayBuffer() - const audio = await getAudioContext().decodeAudioData(bytes) - if (!cancelled) dropTimesRef.current = analyzeDrops(audio) - } catch { - /* leave empty - no drop rings for this file */ - } - })() - } - run() - media.addEventListener('loadeddata', run) - return () => { - cancelled = true - media.removeEventListener('loadeddata', run) - } + return watchDrops( + media, + async (url, signal) => { + const resp = await fetch(url, { signal }) + const bytes = await resp.arrayBuffer() + if (signal.aborted) return [] + return analyzeDrops(await getAudioContext().decodeAudioData(bytes)) + }, + (times) => { + dropTimesRef.current = times + } + ) }, [media]) // Wire the element into the graph, and keep the context resumed while it plays. @@ -130,8 +122,11 @@ export function Visualizer({ analyser.smoothingTimeConstant = 0.6 graph.gain.connect(analyser) analyserRef.current = analyser + // Only for a track that is PLAYING (review, 2026-10-16): any click + // anywhere used to wake the context idleAudioContext had just put to + // sleep, and the audio thread and device stayed up with nothing playing. const resume = (): void => { - if (ctx.state === 'suspended') void ctx.resume() + if (wantsResume(ctx.state, media.paused)) void ctx.resume() } media.addEventListener('play', resume) window.addEventListener('pointerdown', resume) @@ -151,8 +146,10 @@ export function Visualizer({ } }, [media]) - // Draw loop. + // Draw loop. None at all without an element (review, 2026-10-16): a hidden + // tab passes null, and its loop used to redraw forever (lib/viz/vizGuards). useEffect(() => { + if (!drawLoopRuns(media)) return const canvas = canvasRef.current if (!canvas) return const ctx = canvas.getContext('2d') diff --git a/src/renderer/src/hooks/useWaveform.ts b/src/renderer/src/hooks/useWaveform.ts index 6948e6f9..a205ad6d 100644 --- a/src/renderer/src/hooks/useWaveform.ts +++ b/src/renderer/src/hooks/useWaveform.ts @@ -16,16 +16,24 @@ export function useWaveform(path: string, enabled: boolean): number[] { useEffect(() => { if (!enabled || !path) return let cancelled = false + let settled = false window.prism.mediaPeaks(path).then( (peaks) => { + settled = true if (!cancelled && peaks?.length) setLoaded({ path, peaks }) }, () => { + settled = true /* no ffmpeg, or nothing to read: the fallback shape shows */ } ) return () => { cancelled = true + // Arrowing on used to leave ffmpeg decoding the whole film behind the + // viewer (review, 2026-10-16). Only while this ask is still open: a + // cancel for an answer already given would take a waiter off another + // tab's job for the same file. + if (!settled) window.prism.cancelPeaks(path) } }, [path, enabled]) return enabled && loaded.path === path ? loaded.peaks : [] diff --git a/src/renderer/src/lib/imageLoading.test.ts b/src/renderer/src/lib/imageLoading.test.ts new file mode 100644 index 00000000..b89f46a0 --- /dev/null +++ b/src/renderer/src/lib/imageLoading.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { imageStillLoading } from './imageLoading' + +describe('the image spinner (review, 2026-10-16)', () => { + const small = { id: 'small' } + const huge = { id: 'huge' } + + it('shows on the very first load', () => { + expect(imageStillLoading(false, false, null, null)).toBe(true) + }) + + it('shows while a huge picture decodes, though the one before had loaded', () => { + expect(imageStillLoading(true, true, huge, null)).toBe(true) + expect(imageStillLoading(true, true, huge, { id: 'older huge' })).toBe(true) + }) + + it('goes once the huge bitmap is painted', () => { + expect(imageStillLoading(true, true, huge, huge)).toBe(false) + }) + + it('stays off between ordinary pictures, so the one on screen never fades', () => { + expect(imageStillLoading(true, false, small, null)).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/imageLoading.ts b/src/renderer/src/lib/imageLoading.ts new file mode 100644 index 00000000..0712e831 --- /dev/null +++ b/src/renderer/src/lib/imageLoading.ts @@ -0,0 +1,18 @@ +/** + * Whether the image viewer's spinner shows. + * + * `loaded` is never reset between files (it drives the first fade-in only, + * see ImageView), which left one hole (review, 2026-10-16): stepping onto a + * picture past the canvas path's size swaps in a still-empty canvas while + * `createImageBitmap` works for seconds, and `loaded` was already true from + * the picture before - a blank checkerboard and no spinner. The huge path now + * counts as loading until ITS bitmap has been painted. + */ +export function imageStillLoading( + loaded: boolean, + huge: boolean, + img: T | null, + paintedHuge: T | null +): boolean { + return !loaded || (huge && img !== paintedHuge) +} diff --git a/src/renderer/src/lib/viz/dropWatch.test.ts b/src/renderer/src/lib/viz/dropWatch.test.ts new file mode 100644 index 00000000..8d744f94 --- /dev/null +++ b/src/renderer/src/lib/viz/dropWatch.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from 'vitest' +import { DROPS_MAX_SECONDS, dropsAllowed, watchDrops, type DropMedia } from './dropWatch' +import { drawLoopRuns, wantsResume } from './vizGuards' + +/** A media element as far as the watch can tell: settable, and it fires. */ +function fakeMedia(init: { duration: number; readyState: number; src?: string }) { + const target = new EventTarget() + const m = { + duration: init.duration, + readyState: init.readyState, + currentSrc: init.src ?? 'fsmedia://local/track.mp3', + src: init.src ?? 'fsmedia://local/track.mp3', + addEventListener: (t: string, fn: () => void) => target.addEventListener(t, fn), + removeEventListener: (t: string, fn: () => void) => target.removeEventListener(t, fn), + fire: () => target.dispatchEvent(new Event('loadedmetadata')) + } + return m as typeof m & DropMedia +} + +const flush = (): Promise => new Promise((r) => setTimeout(r, 0)) + +describe('the drop analysis guard (review, 2026-10-16)', () => { + it('refuses an UNKNOWN length, which is what the element says before its metadata', () => { + expect(dropsAllowed(NaN)).toBe(false) + expect(dropsAllowed(Infinity)).toBe(false) + expect(dropsAllowed(0)).toBe(false) + expect(dropsAllowed(180)).toBe(true) + expect(dropsAllowed(DROPS_MAX_SECONDS + 1)).toBe(false) + }) + + it('decodes nothing at mount for an element with no metadata yet', () => { + const media = fakeMedia({ duration: NaN, readyState: 0 }) + const analyse = vi.fn(async () => [1]) + watchDrops(media, analyse, () => {}) + expect(analyse).not.toHaveBeenCalled() + }) + + it('never decodes a four-hour audiobook, before or after its metadata', () => { + const media = fakeMedia({ duration: NaN, readyState: 0 }) + const analyse = vi.fn(async () => [1]) + watchDrops(media, analyse, () => {}) + media.duration = 4 * 3600 + media.readyState = 1 + media.fire() + expect(analyse).not.toHaveBeenCalled() + }) + + it('analyses a song ONCE when its metadata arrives', async () => { + const media = fakeMedia({ duration: NaN, readyState: 0 }) + const analyse = vi.fn(async () => [12.5]) + const seen: number[][] = [] + watchDrops(media, analyse, (t) => seen.push(t)) + media.duration = 200 + media.readyState = 1 + media.fire() + await flush() + expect(analyse).toHaveBeenCalledTimes(1) + expect(seen.at(-1)).toEqual([12.5]) + }) + + it('runs at once for an element already loaded', () => { + const media = fakeMedia({ duration: 200, readyState: 4 }) + const analyse = vi.fn(async () => []) + watchDrops(media, analyse, () => {}) + expect(analyse).toHaveBeenCalledTimes(1) + }) + + it('aborts the previous run when a new source loads, and on cleanup', async () => { + const media = fakeMedia({ duration: 200, readyState: 4 }) + const signals: AbortSignal[] = [] + const seen: number[][] = [] + const stop = watchDrops( + media, + (_url, signal) => { + signals.push(signal) + return new Promise(() => {}) + }, + (t) => seen.push(t) + ) + media.fire() + expect(signals[0].aborted).toBe(true) + expect(signals[1].aborted).toBe(false) + stop() + expect(signals[1].aborted).toBe(true) + }) +}) + +describe('the visualizer at rest (review, 2026-10-16)', () => { + it('wakes the audio context only for a playing track', () => { + expect(wantsResume('suspended', true)).toBe(false) + expect(wantsResume('suspended', false)).toBe(true) + expect(wantsResume('running', false)).toBe(false) + }) + + it('runs no draw loop for a hidden tab, which passes no element', () => { + expect(drawLoopRuns(null)).toBe(false) + expect(drawLoopRuns({})).toBe(true) + }) +}) diff --git a/src/renderer/src/lib/viz/dropWatch.ts b/src/renderer/src/lib/viz/dropWatch.ts new file mode 100644 index 00000000..c80f109c --- /dev/null +++ b/src/renderer/src/lib/viz/dropWatch.ts @@ -0,0 +1,68 @@ +/** Past this, the drop analysis is skipped: its cost is the file's whole + * decoded length in memory, and nobody needs drop rings in an audiobook. */ +export const DROPS_MAX_SECONDS = 15 * 60 + +/** + * Whether a track this long may be decoded whole for its drops. + * + * An UNKNOWN length is a no (review, 2026-10-16). The guard used to read + * "skip when finite and too long", so a NaN duration - the element before its + * metadata - passed it, and the first run fetched and decoded a multi-hour + * audiobook whole: exactly what the 2026-08-26 guard was written to stop. + */ +export function dropsAllowed(duration: number): boolean { + return Number.isFinite(duration) && duration > 0 && duration <= DROPS_MAX_SECONDS +} + +/** The parts of a media element the watch reads. */ +export interface DropMedia { + readonly duration: number + readonly readyState: number + readonly currentSrc: string + readonly src: string + addEventListener(type: 'loadedmetadata', fn: () => void): void + removeEventListener(type: 'loadedmetadata', fn: () => void): void +} + +const HAVE_METADATA = 1 + +/** + * Run the drop analysis once per loaded source, and only once its length is + * known (review, 2026-10-16). + * + * It used to run at mount AND on every `loadeddata`: the mount run saw NaN + * and decoded whatever it was, and a song was fetched and decoded twice. Now + * it runs when the metadata is in (at once if it already is), and each new + * run aborts the one before, as does the cleanup. + */ +export function watchDrops( + media: DropMedia, + analyse: (url: string, signal: AbortSignal) => Promise, + onDrops: (times: number[]) => void +): () => void { + let current: AbortController | null = null + const run = (): void => { + current?.abort() + current = null + onDrops([]) + if (!dropsAllowed(media.duration)) return + const url = media.currentSrc || media.src + if (!url) return + const mine = new AbortController() + current = mine + analyse(url, mine.signal).then( + (times) => { + if (!mine.signal.aborted) onDrops(times) + }, + () => { + /* leave empty - no drop rings for this file */ + } + ) + } + if (media.readyState >= HAVE_METADATA) run() + media.addEventListener('loadedmetadata', run) + return () => { + current?.abort() + media.removeEventListener('loadedmetadata', run) + } +} diff --git a/src/renderer/src/lib/viz/vizGuards.ts b/src/renderer/src/lib/viz/vizGuards.ts new file mode 100644 index 00000000..279dfdec --- /dev/null +++ b/src/renderer/src/lib/viz/vizGuards.ts @@ -0,0 +1,23 @@ +/** + * When the visualizer may spend anything (review, 2026-10-16). + */ + +/** + * Wake the shared AudioContext only for a track that is playing. The + * window-wide pointerdown used to resume it whatever the track was doing, so + * any click undid idleAudioContext and left the audio thread and the device + * running with nothing playing. + */ +export function wantsResume(state: AudioContextState, paused: boolean): boolean { + return state === 'suspended' && !paused +} + +/** + * Whether the draw loop runs at all. A hidden tab passes no element (AudioView + * gives `null` for a background tab), and the loop's idle path only knew a + * PAUSED element, never a missing one: every background audio tab redrew its + * 1px off-screen canvas at the display's full rate, all session. + */ +export function drawLoopRuns(media: unknown): boolean { + return media != null +} diff --git a/src/renderer/src/phone/prismShim.ts b/src/renderer/src/phone/prismShim.ts index 9d3e94fe..37a8d851 100644 --- a/src/renderer/src/phone/prismShim.ts +++ b/src/renderer/src/phone/prismShim.ts @@ -229,6 +229,7 @@ const implemented: Shim = { onConvertProgress: () => () => {}, cancelConvert: () => {}, mediaPeaks: () => Promise.resolve(null), + cancelPeaks: () => {}, // The Properties window's extra facts (2026-10-12) are the PC's: on the // phone each answers "not known", and a checksum never starts. propsMedia: () => Promise.resolve(null), diff --git a/src/shared/channels.ts b/src/shared/channels.ts index 7f3cc227..1261ac5d 100644 --- a/src/shared/channels.ts +++ b/src/shared/channels.ts @@ -129,6 +129,7 @@ export const CALL = { subsRead: 'subs:read', mediaPeaks: 'media:peaks', + mediaPeaksCancel: 'media:peaks-cancel', mediaProbe: 'media:probe', shellVerbStatus: 'shell:verb-status',