Skip to content
Open
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
7 changes: 6 additions & 1 deletion daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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
},
Expand Down
2 changes: 1 addition & 1 deletion protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
54 changes: 52 additions & 2 deletions record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -66,7 +68,7 @@ function ffmpeg (args: string[]): { proc: ChildProcess, done: Promise<void> } {
return { proc, done }
}

export async function startRecording (bot: Bot, file: string, opts: RecordOpts = {}): Promise<Recording> {
export async function startRecording (bot: Bot, file: string, opts: RecordOpts = {}, onEnd?: (err: Error) => void): Promise<Recording> {
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<void>(resolve => bot.once('spawn', resolve))
Expand Down Expand Up @@ -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<string, boolean>, 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<void> => new Promise(r => setTimeout(r, ms))
Expand All @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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])
Expand All @@ -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()
Expand Down