cli: added --frames-dir --fps option for video; fast-png 6.4.0

This commit is contained in:
Steven Hugg committed 2026-10-05 10:22:30 +02:00
1 parent 0d1f77b927
commit 650c5cd7ba
7 files changed
+98 -27

No files matched your search

+2 -1
View File
@@ -12,4 +12,5 @@ config.js
chromedriver.log
nightwatch.conf.js
/src/docs/toolchains.md
LICENSES/
LICENSES/
.vscode
+1
View File
@@ -10,6 +10,7 @@ See [extension/CHANGELOG.md](extension/CHANGELOG.md).
- Vector platforms (`vector-*`, `vectrex`) render headlessly: a software rasterizer with phosphor fade draws their lines into the frame buffer, so `--png` and the VS Code emulator panel show them. The size is `--vector-size <px>` (long side, default 512), `EmuCore.vectorSize`, or the extension's `8bitworkshop.vectorSize`.
- New `8bws` command-line tool (`npm run cli`) to build and run programs headlessly: `build`, `run`, `verify-replay`, `profile`, `detect`, `list-platforms`, `list-tools`; scripted emulator control; `--png`, `--json`, `--symbols`, `--memdump`, `--info`; platform and main file detected from a project directory.
- `8bws run --frames-dir <dir>` records an animation for ffmpeg, writing every frame as `DIR/frame_NNNNN.png` (the run script adds `capture N DIR` and `png FILE`); `--fps` sets the nominal frame rate and the result includes the matching `ffmpeg` command.
- Platform detection no longer confuses the Atari 8-bit targets: `atari.h` means the 800 (it is guarded by `__ATARI__`), `atari5200.h` means the 5200, and a folder named for a platform breaks a near-tie.
- Emulators now run on a timeline: step back, seek, reverse run, `rbreak`, and deterministic replay with a replay-verification test that found determinism bugs in several platforms.
- Step Over for C and assembly sources, a Breakpoints pane, and more breakpoint conditions.
+10 -10
View File
@@ -52,7 +52,7 @@
"cors": "^2.8.5",
"esbuild": "^0.28.2",
"express": "^4.18.2",
"fast-png": "^5.0.4",
"fast-png": "^6.4.0",
"jquery": "^3.7.1",
"jsdom": "^25.0.1",
"lzg": "^1.0.x",
@@ -1696,9 +1696,9 @@
}
},
"node_modules/@types/pako": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-1.0.7.tgz",
"integrity": "sha512-YBtzT2ztNF6R/9+UXj2wTGFnC9NklAnASt3sC0h2m1bbH7G6FyBIkt4AN8ThZpNfxUo1b2iMVO0UawiJymEt8A==",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
"dev": true,
"license": "MIT"
},
@@ -3880,15 +3880,15 @@
"optional": true
},
"node_modules/fast-png": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-5.0.4.tgz",
"integrity": "sha512-vTNj6yixRnclW6sTlCeH6sNRLBOhM5ITmlo1LSU5ojKEc2e9kZkqXPo2xzBxKb61MBCXRXBcr8qJztOHr2O6WQ==",
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/pako": "^1.0.1",
"iobuffer": "^5.0.2",
"pako": "^2.0.2"
"@types/pako": "^2.0.3",
"iobuffer": "^5.3.2",
"pako": "^2.1.0"
}
},
"node_modules/fetch-vcr": {
+1 -1
View File
@@ -53,7 +53,7 @@
"cors": "^2.8.5",
"esbuild": "^0.28.2",
"express": "^4.18.2",
"fast-png": "^5.0.4",
"fast-png": "^6.4.0",
"jquery": "^3.7.1",
"jsdom": "^25.0.1",
"lzg": "^1.0.x",
+30 -5
View File
@@ -225,9 +225,13 @@ async function openTarget(args: Args, platformId: string): Promise<EmuTarget> {
/** Turn the run flags into script commands, appended to any --script/-e text. */
function buildScript(args: Args): string {
const parts: string[] = [];
if (args['frames']) parts.push(`run ${str(args, 'frames') ?? 1}`);
const frameDir = str(args, 'frames-dir');
// --frames-dir records frames instead of just advancing, so it goes last,
// after any --script/-e setup (e.g. `run 60` to reach the title screen)
if (args['frames'] && !frameDir) parts.push(`run ${str(args, 'frames') ?? 1}`);
const script = str(args, 'eval') ?? str(args, 'script');
if (script) parts.push(fs.existsSync(script) ? fs.readFileSync(script, 'utf8') : script);
if (frameDir) parts.push(`capture ${str(args, 'frames') ?? 60} ${frameDir}`);
if (args['info']) parts.push('info');
const memdump = str(args, 'memdump');
if (memdump) {
@@ -322,6 +326,12 @@ async function doRun(args: Args, positional: string[]): Promise<void> {
script.startTracing();
// `vcd FILE` writes as it goes, so a long recording doesn't sit in memory
script.openFile = openVcdFile;
// `capture N DIR` / `png FILE`: encode each frame with the same PNG writer as --png
const { encode } = await import('fast-png');
script.writeFrame = (file, video) => {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, encodePNG(encode, video));
};
try {
script.run(buildScript(args));
} finally {
@@ -329,6 +339,8 @@ async function doRun(args: Args, positional: string[]): Promise<void> {
}
const video = target.getVideo();
const frameDir = str(args, 'frames-dir');
const fps = parseInt(str(args, 'fps') ?? '') || target.frameRate;
output({
success: true,
command: 'run',
@@ -339,20 +351,31 @@ async function doRun(args: Args, positional: string[]): Promise<void> {
width: video?.width ?? null,
height: video?.height ?? null,
png: str(args, 'png') || null,
framesDir: frameDir || null,
fps: frameDir ? fps : null,
ffmpeg: frameDir
? `ffmpeg -framerate ${fps} -i ${frameDir.replace(/[\\/]+$/, '')}/frame_%05d.png -pix_fmt yuv420p out.mp4`
: null,
}
});
await writeScreenshot(video, str(args, 'png'));
}
/** Encode a frame as a 4-channel PNG (the pixel view is already RGBA bytes). */
function encodePNG(encode: typeof import('fast-png').encode, video: NonNullable<ReturnType<EmuTarget['getVideo']>>): Uint8Array {
return encode({
width: video.width, height: video.height,
data: new Uint8Array(video.pixels.buffer, video.pixels.byteOffset, video.width * video.height * 4),
channels: 4,
});
}
async function writeScreenshot(video: ReturnType<EmuTarget['getVideo']>, pngFile?: string): Promise<void> {
if (!video) return;
const showInTerminal = process.stdout.isTTY;
if (!pngFile && !showInTerminal) return;
const { encode } = await import('fast-png');
const png = encode({
width: video.width, height: video.height,
data: new Uint8Array(video.pixels.buffer), channels: 4
});
const png = encodePNG(encode, video);
if (pngFile) fs.writeFileSync(pngFile, png);
if (showInTerminal) {
const { displayImageInTerminal } = await import('./termimage');
@@ -590,6 +613,8 @@ function usage(error?: string): never {
'-e <commands>': 'inline run-script, e.g. -e "run 60; screen"',
'--script <file>': 'run-script file',
'--png <file>': 'write a screenshot of the last frame',
'--frames-dir <dir>': 'write each frame as DIR/frame_NNNNN.png, for ffmpeg',
'--fps <n>': 'frame rate for --frames-dir (default: the platform\'s)',
'--symbols <file>': 'load a .lbl/.sym file for symbolic addresses',
'--bios <file>': 'load a BIOS image',
'--vector-size <px>': 'long side of a vector platform\'s screen (default 512)',
+37 -1
View File
@@ -17,7 +17,7 @@ import { lookupSymbol } from '../common/symbols/symbolfile';
import { formatTimestamp, timestamp, Timestamp } from '../common/timeline';
import { DebugContext, DebugController, StopEvent, isCallInsn, isReturnInsn } from '../common/debugcontroller';
import { hexdump, write } from './cliformat';
import { DEFAULT_MAX_FRAMES, EmuTarget } from './emutarget';
import { DEFAULT_MAX_FRAMES, EmuTarget, VideoOutput } from './emutarget';
export const RUN_SCRIPT_HELP = [
'Execution:',
@@ -42,6 +42,9 @@ export const RUN_SCRIPT_HELP = [
'Inspection & input:',
' key KEY - press key (down, 3 frames, up)',
' keydown KEY / keyup KEY - raw key down/up events',
' png FILE - write the current frame as a PNG',
' capture N DIR - advance N frames, writing DIR/frame_NNNNN.png each',
' (a frame sequence for ffmpeg)',
' mem START [LEN] - hexdump memory (default 16 bytes)',
' screen [START] [COLS] [ROWS]- decode screen RAM to text (default $0400 40x25)',
' serial - print what the program sent to its serial port (devel-6502)',
@@ -166,7 +169,14 @@ export class RunScript {
* from elsewhere (the debug console, language model tools) can't write files.
*/
openFile?: (path: string, maxBytes?: number) => { write(chunk: string): void; close(): void; readonly full: boolean };
/**
* Writes one screenshot to `file`. Only the CLI sets this: it owns the PNG
* encoder and the file system, and scripts from elsewhere can't write files.
*/
writeFrame?: (file: string, video: VideoOutput) => void;
private vcdFile: { path: string, close(): void, full: boolean } | null = null;
// running count for the `capture` command's file names
private frameIndex = 0;
constructor(readonly target: EmuTarget, private out = write,
readonly debug = new DebugController(target)) { }
@@ -547,6 +557,30 @@ export class RunScript {
for (const e of treeChildren(root, path)) this.out(`${e.name}${e.expandable ? '/' : ''} ${e.value}\n`);
}
cmdPNG(tokens: string[]) {
if (!tokens[1]) throw new Error('usage: png FILE');
if (!this.writeFrame) throw new Error("'png' is not available here");
const video = this.target.getVideo();
if (!video) throw new Error(`'${this.target.id}' has no video to capture`);
this.writeFrame(tokens[1], video);
this.log(`wrote ${tokens[1]}`);
}
cmdCapture(tokens: string[]) {
const n = tokens[1] ? parseNum(tokens[1]) : 1;
const dir = tokens[2];
if (!dir) throw new Error('usage: capture N DIR');
if (!this.writeFrame) throw new Error("'capture' is not available here");
const base = dir.replace(/[\\/]+$/, '');
for (let i = 0; i < n; i++) {
this.advance(1);
const video = this.target.getVideo();
if (!video) throw new Error(`'${this.target.id}' has no video to capture`);
this.writeFrame(`${base}/frame_${String(this.frameIndex++).padStart(5, '0')}.png`, video);
}
this.log(`wrote ${n} frame${n == 1 ? '' : 's'} to ${dir}`);
}
cmdVcd(tokens: string[]) {
if (tokens[1] === 'off' || tokens[1] === 'stop') {
const file = this.vcdFile;
@@ -600,6 +634,8 @@ const COMMANDS: { [name: string]: Command } = {
'pc': RunScript.prototype.cmdPC,
'info': RunScript.prototype.cmdInfo,
'signals': RunScript.prototype.cmdSignals,
'png': RunScript.prototype.cmdPNG,
'capture': RunScript.prototype.cmdCapture,
'vcd': RunScript.prototype.cmdVcd,
'paddle': RunScript.prototype.cmdPaddle,
'reset': RunScript.prototype.cmdReset,
+17 -9
View File
@@ -180,6 +180,23 @@ describe('8bws CLI', function () {
cliJSON('run', '--platform', 'nes', '--frames', '10', '--png', png, 'test/roms/nes/shoot2.c.rom');
assert.ok(fs.statSync(png).size > 0);
});
it('should write a frame sequence for ffmpeg', function () {
var dir = fs.mkdtempSync(path.join(os.tmpdir(), '8bws-frames-'));
var r = cliJSON('run', '--platform', 'nes', '--frames', '5', '--frames-dir', dir, 'test/roms/nes/shoot2.c.rom');
assert.ok(r.success, r.error);
assert.equal(r.data.frames, 5);
assert.strictEqual(path.resolve(r.data.framesDir), path.resolve(dir));
var files = fs.readdirSync(dir).sort();
assert.deepEqual(files, ['frame_00000.png', 'frame_00001.png', 'frame_00002.png', 'frame_00003.png', 'frame_00004.png']);
assert.ok(fs.statSync(path.join(dir, files[0])).size > 0);
assert.ok(/ffmpeg -framerate \d+ -i .*frame_%05d\.png/.test(r.data.ffmpeg), r.data.ffmpeg);
});
it('should capture frames from a run script', function () {
var dir = fs.mkdtempSync(path.join(os.tmpdir(), '8bws-frames-'));
cli('run', '--platform', 'nes', 'test/roms/nes/shoot2.c.rom', '-e', 'run 3; capture 2 ' + dir + '; png ' + path.join(dir, 'one.png'));
var files = fs.readdirSync(dir).sort();
assert.deepEqual(files, ['frame_00000.png', 'frame_00001.png', 'one.png']);
});
it('should infer the platform from a ROM extension', function () {
var gb = path.join(os.tmpdir(), '8bws-test-infer.gb');
fs.copyFileSync('test/roms/gb/cpu_instrs.gb', gb);
@@ -405,15 +422,6 @@ describe('8bws CLI', function () {
});
});
describe('apple2 Mockingboard', function () {
it('builds and runs the music demo', function () {
this.timeout(60000);
var out = cli('run', '-p', 'apple2', 'presets/apple2/mockingboard.c', '-e', 'run 30; screen');
assert.ok(/MOCKINGBOARD DEMO/.test(out), out);
assert.ok(/ODE TO JOY/.test(out), out);
});
});
describe('legacy aliases', function () {
it('should accept compile, check and compilerun', function () {
assert.equal(cliJSON('compile', '--platform', 'gb', 'presets/gb/hello.c', '--check').command, 'check');