Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
58 changes: 58 additions & 0 deletions src/main/convertWaiters.test.ts
Original file line number Diff line number Diff line change
@@ -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([])
})
})
83 changes: 83 additions & 0 deletions src/main/convertWaiters.ts
Original file line number Diff line number Diff line change
@@ -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<ConvertAsk>
}

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<string, Entry>()
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
}
}
}
5 changes: 4 additions & 1 deletion src/main/imageDecode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,10 @@ export function imageArgs(file: string): string[] {
]
}

const cache = createLru<string, Buffer>(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<string, Buffer>(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<Buffer> {
Expand Down
29 changes: 11 additions & 18 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<string, MediaInfo | null>()
const probeCached = async (p: string): Promise<MediaInfo | null> => {
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<MediaInfo | null> =>
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
Expand Down
64 changes: 35 additions & 29 deletions src/main/ipc/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -76,15 +77,21 @@ export function registerMediaIpc(ctx: MainCtx, ready: ReadyCtx): void {
*/
ipcMain.handle(CALL.mediaPeaks, async (_e, p: string): Promise<number[] | null> => {
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<MediaProbe> => {
const none: MediaProbe = { ffmpeg: false, needed: false }
Expand Down Expand Up @@ -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<string, { out: string; viewers: number }>()
/** 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<ConvertResult> => {
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,
Expand All @@ -191,30 +204,23 @@ 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)
}
}
)
// Nobody is waiting for this any more: the viewer moved on to another
// 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
Expand Down
28 changes: 28 additions & 0 deletions src/main/lru.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Buffer>(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<string, Buffer>(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<string, Buffer>(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()
})
})
Loading
Loading