files: seek anywhere in a re-encode; it restarts from there

A re-encode's playlist is now the whole film on a fixed 4 s grid. A segment
that isn't on disk and isn't coming soon moves the encode there; runs stop
short of segments already made, and gaps are filled after. Every run is cut
the same way (-ss, -output_ts_offset start+10, frag_discont), so segments
from different runs join. Repackaging keeps its growing playlist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-10-20 13:08:52 -07:00
co-authored by Claude Opus 5.5
parent ff1373e838
commit 450a9d3327
8 changed files with 660 additions and 45 deletions
+10 -2
View File
@@ -3,6 +3,8 @@
// picker: codec and size, and the machine re-encodes it to HLS on its own GPU (or CPU),
// which plays natively on Safari and through hls.js everywhere else. The same picker is
// one tap away even when the file plays, for a 36 Mbps capture over a slow link.
// A re-encode's timeline is the whole film from the start: seek anywhere, and the machine
// starts encoding from there (repackaging, which is near-instant, grows as it goes instead).
import { useCallback, useEffect, useRef, useState } from 'react'
import { api, apiPath, PlayCaps, PlayStatus, PlayStream } from './api'
import { Btn, Spinner } from './ui'
@@ -13,6 +15,7 @@ const QUALITY_KEY = 'hamlab.play.quality'
const remember = (k: string, v: string) => { try { localStorage.setItem(k, v) } catch { /* private mode */ } }
const recall = (k: string, d: string) => { try { return localStorage.getItem(k) || d } catch { return d } }
const clock = (sec: number) => { const t = Math.floor(sec), h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60), x = t % 60; return `${h ? `${h}:${String(m).padStart(2, '0')}` : m}:${String(x).padStart(2, '0')}` }
const fmtMbps = (bps?: number) => (bps ? `${(bps / 1e6).toFixed(bps >= 10e6 ? 0 : 1)} Mbps` : '')
const describe = (s: PlayStream) => {
const v = s.source.video
@@ -72,7 +75,10 @@ export default function Player({ path, sudo, name }: { path: string; sudo: boole
const { default: Hls } = await import('hls.js')
if (gone) return
if (!Hls.isSupported()) { setErr('this browser has neither HLS nor MediaSource — nothing can play here'); return }
const h = new Hls({ maxBufferLength: 60, backBufferLength: 30 })
// a segment the machine hasn't made yet is made when asked for (a seek): its first
// byte can take as long as the re-encode takes to start there and write it
const slow = { maxTimeToFirstByteMs: 50000, maxLoadTimeMs: 120000, timeoutRetry: { maxNumRetry: 2, retryDelayMs: 0, maxRetryDelayMs: 0 }, errorRetry: { maxNumRetry: 3, retryDelayMs: 1000, maxRetryDelayMs: 8000 } }
const h = new Hls({ maxBufferLength: 60, backBufferLength: 30, fragLoadPolicy: { default: slow } })
hls = h
h.on(Hls.Events.ERROR, (_e, d) => { if (d.fatal) setErr(`playback failed: ${d.details}`) })
h.loadSource(url)
@@ -131,7 +137,9 @@ export default function Player({ path, sudo, name }: { path: string; sudo: boole
<>
<div className="play-bar">
<span title={describe(stream.s)}>{describe(stream.s)}</span>
{status && status.running && <span>· {status.speed ? `${status.speed.toFixed(1)}×` : ''} {stream.s.source.duration ? `${Math.min(100, Math.round(status.progress / stream.s.source.duration * 100))}% through` : `${Math.round(status.progress)} s done`} — seek within what's done</span>}
{status && status.running && (stream.s.seekable
? <span>· {status.speed ? `${status.speed.toFixed(1)}× ` : ''}from {clock(status.from ?? 0)} · {Math.min(100, Math.round(status.progress / stream.s.source.duration * 100))}% ready — seek anywhere</span>
: <span>· {status.speed ? `${status.speed.toFixed(1)}×` : ''} {stream.s.source.duration ? `${Math.min(100, Math.round(status.progress / stream.s.source.duration * 100))}% through` : `${Math.round(status.progress)} s done`} — seek within what's done</span>)}
{status?.done && <span>· all of it is ready</span>}
{status?.error && <span className="error">· stopped early: {status.error}</span>}
<span className="spacer" />
+4 -2
View File
@@ -252,8 +252,10 @@ export interface ARCView { detail: ARCDetail; live: ARCLive | null }
export interface FileEntry { name: string; type: 'dir' | 'file' | 'link' | 'other'; size: number; mode: string; perm: string; mtime: number; user: string; group: string; target?: string; broken?: boolean; preview?: 'image' | 'video' | 'audio' }
export interface PlayCaps { codecs: { id: string; label: string; engine?: string }[]; qualities: { id: string; label: string; height: number }[]; error?: string }
export interface PlayTrack { codec: string; profile?: string; pix_fmt?: string; width?: number; height?: number; fps?: number; channels?: number }
export interface PlayStream { id: string; codec: string; quality: string; engine: string; width: number; height: number; kbps: number; source: { container: string; duration: number; bit_rate?: number; video?: PlayTrack; audio?: PlayTrack } }
export interface PlayStatus { running: boolean; done: boolean; error?: string; progress: number; speed: number; segments: number }
export interface PlayStream { id: string; codec: string; quality: string; engine: string; width: number; height: number; kbps: number; seekable?: boolean; source: { container: string; duration: number; bit_rate?: number; video?: PlayTrack; audio?: PlayTrack } }
// progress: seconds written (repackaged), or seconds of the film on disk (a seekable re-encode,
// whose run now going started at `from`)
export interface PlayStatus { running: boolean; done: boolean; error?: string; progress: number; speed: number; segments: number; from?: number }
export interface ThumbGPU { api: string; device: string; name?: string; codecs?: string[]; reason?: string; permission?: boolean }
// How previews get made on the machine: ok with an engine, or why not and what would fix it.
export interface ThumbStatus { ok: boolean; engine: 'gpu' | 'cpu' | ''; summary: string; ffmpeg?: string; gpus: ThumbGPU[]; unusable?: ThumbGPU[]; error?: string; install?: { pm: string; package: string }; gpu_fix?: boolean; user: string; cache: { path: string; entries: number; bytes: number } }