mirror of
https://github.com/sehugg/8bitworkshop.git
synced 2026-10-24 08:02:27 +00:00
cli: added --frames-dir --fps option for video; fast-png 6.4.0
This commit is contained in:
1 parent
0d1f77b927
commit
650c5cd7ba
7 files changed
+98
-27
No files matched your search
+2
-1
@@ -12,4 +12,5 @@ config.js
|
||||
chromedriver.log
|
||||
nightwatch.conf.js
|
||||
/src/docs/toolchains.md
|
||||
LICENSES/
|
||||
LICENSES/
|
||||
.vscode
|
||||
@@ -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.
|
||||
|
||||
Generated
+10
-10
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user