diff --git a/daemon.ts b/daemon.ts index d50951a..b73c611 100644 --- a/daemon.ts +++ b/daemon.ts @@ -126,7 +126,12 @@ const stamp = (): string => new Date().toISOString().replace(/[:.]/g, '-').slice const record = { async start (file = path.join(opts.dir, `${opts.name}-${stamp()}.mp4`), recordOpts?: RecordOpts): Promise { if (recording) throw new Error(`already recording to ${recording.file}`) - recording = await startRecording(bot, path.resolve(file), recordOpts) + const target = path.resolve(file) + recording = await startRecording(bot, target, recordOpts, err => { + // ffmpeg went away on its own; the file is done, so drop the recording the daemon holds. + if (recording?.file === target) recording = null + log('recording ended', target, err.message) + }) log('recording', recording.file) return recording.file }, diff --git a/protocol.ts b/protocol.ts index 45b1be0..d7f3316 100644 --- a/protocol.ts +++ b/protocol.ts @@ -44,7 +44,7 @@ export interface BotStatus { lastEnd?: { at: string, reason: string } lastError?: { at: string, message: string } /** Present while a recording runs. `dropped` frames are absent from the file. */ - recording?: { file: string, dropped: number, queued: number } + recording?: { file: string, dropped: number, queued: number, ended: string | null } } /** Set when the bot's connection is gone, so a caller sees it even though the exec itself ran. */ diff --git a/record.ts b/record.ts index 3edf48c..443627e 100644 --- a/record.ts +++ b/record.ts @@ -22,6 +22,8 @@ export interface RecordingStats { dropped: number /** Bytes handed to ffmpeg's stdin and not yet taken. */ queued: number + /** Set once ffmpeg has exited: null after stop(), the reason after an exit on its own. */ + ended: string | null } export interface Recording { @@ -66,7 +68,7 @@ function ffmpeg (args: string[]): { proc: ChildProcess, done: Promise } { return { proc, done } } -export async function startRecording (bot: Bot, file: string, opts: RecordOpts = {}): Promise { +export async function startRecording (bot: Bot, file: string, opts: RecordOpts = {}, onEnd?: (err: Error) => void): Promise { const { width = 640, height = 360, fps = 20, viewDistance = 4, numWorkers = 1, duty = 0.25 } = opts await ensureDisplay(width, height) if (!bot.entity) await new Promise(resolve => bot.once('spawn', resolve)) @@ -111,6 +113,27 @@ export async function startRecording (bot: Bot, file: string, opts: RecordOpts = follow() bot.on('move', follow) + // A server transfer or dimension change makes mineflayer unload every column of the bot's world + // (bot.world is mutated in place, not replaced). WorldView has no chunkColumnUnload listener, so + // it keeps the pre-transfer geometry and never meshes the new world. login precedes the swap and + // the following spawn is when bot.entity and the new columns are ready, so resync there: drop the + // chunks the viewer still holds and reload from the current world. (Superseded once + // prismarine-viewer handles the unload itself.) + let reloginPending = false + const onLogin = (): void => { reloginPending = true } + const onSpawn = (): void => { + if (!reloginPending) return + reloginPending = false + const wv = worldView as unknown as { loadedChunks: Record, unloadChunk: (p: { x: number, z: number }) => void } + for (const key of Object.keys(wv.loadedChunks)) { + const [x, z] = key.split(',').map(Number) + wv.unloadChunk({ x, z }) + } + void worldView.init(bot.entity.position) + } + bot.on('login', onLogin) + bot.on('spawn', onSpawn) + // Frames before the atlas uploads and chunks mesh are blank sky. The first frame is // deferred until the atlas is set, some sections are meshed, and none are outstanding. const sleep = (ms: number): Promise => new Promise(r => setTimeout(r, ms)) @@ -136,6 +159,27 @@ export async function startRecording (bot: Bot, file: string, opts: RecordOpts = const video = ffmpeg([...raw, '-r', String(fps), '-i', 'pipe:0', '-vf', 'vflip', '-c:v', 'libx264', '-preset', 'veryfast', '-pix_fmt', 'yuv420p', file]) const stdin = video.proc.stdin! + // When ffmpeg exits, a write already in flight rejects async with EPIPE; the meaningful signal is + // video.done, so swallow the stream error rather than let it crash the daemon. + stdin.on('error', () => {}) + // null while recording; the reason once ffmpeg has gone. Both stop() and an exit on ffmpeg's own + // (crash, disk full, killed) route through teardown; `stopping` tells them apart. + let ended: string | null = null + let stopping = false + const teardown = (): void => { + bot.off('move', follow) + bot.off('login', onLogin) + bot.off('spawn', onSpawn) + worldView.removeListenersFromBot(bot) + try { viewer.dispose() } catch {} + destroyGl() + } + // ffmpeg exiting before stop() means the file is being abandoned; there is nothing to render into + // any more, so stop the loop and let the daemon drop the recording. + void video.done.then( + () => { if (!stopping) { ended = 'ffmpeg exited before the recording was stopped'; if (timer) clearTimeout(timer); teardown(); onEnd?.(new Error(ended)) } }, + (e: Error) => { if (!stopping) { ended = e.message; if (timer) clearTimeout(timer); teardown(); onEnd?.(e) } } + ) const pixels = Buffer.alloc(width * height * 4) let last: Buffer | null = null const frameMs = 1000 / fps @@ -147,6 +191,7 @@ export async function startRecording (bot: Bot, file: string, opts: RecordOpts = let inflight = 0 let timer: NodeJS.Timeout | null = null const tick = (): void => { + if (ended !== null || !stdin.writable) return const due = Math.floor((performance.now() - t0) / frameMs) + 1 let cost = 0 if (inflight === 0 && written < due) { @@ -177,7 +222,7 @@ export async function startRecording (bot: Bot, file: string, opts: RecordOpts = return { file, - stats: () => ({ dropped, queued: stdin.writableLength }), + stats: () => ({ dropped, queued: stdin.writableLength, ended }), async snapshot (out) { if (!last) throw new Error('no frame yet') const png = ffmpeg([...raw, '-i', 'pipe:0', '-vf', 'vflip', '-frames:v', '1', out]) @@ -186,9 +231,14 @@ export async function startRecording (bot: Bot, file: string, opts: RecordOpts = return out }, async stop () { + stopping = true if (timer) clearTimeout(timer) timer = null + // ffmpeg already gone on its own: teardown ran, nothing left to close. + if (ended !== null) return file bot.off('move', follow) + bot.off('login', onLogin) + bot.off('spawn', onSpawn) worldView.removeListenersFromBot(bot) // Closing ffmpeg's stdin writes the moov atom; it must run even if GL teardown throws. stdin.end()