@resciencelab/shader-cli
Advanced tools
| export declare class Spinner { | ||
| private interval?; | ||
| private frame; | ||
| private start; | ||
| private text; | ||
| private isTty; | ||
| constructor(text: string); | ||
| spin(): this; | ||
| update(text: string): void; | ||
| succeed(text?: string): void; | ||
| fail(text?: string): void; | ||
| } | ||
| export declare class ProgressBar { | ||
| private _label; | ||
| private total; | ||
| private width; | ||
| private isTty; | ||
| private start; | ||
| constructor(_label: string, total: number); | ||
| update(current: number): void; | ||
| done(): void; | ||
| } |
| const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; | ||
| export class Spinner { | ||
| interval; | ||
| frame = 0; | ||
| start = Date.now(); | ||
| text = ""; | ||
| isTty; | ||
| constructor(text) { | ||
| this.text = text; | ||
| this.isTty = process.stderr.isTTY ?? false; | ||
| } | ||
| spin() { | ||
| if (!this.isTty) { | ||
| process.stderr.write(` ${this.text}...\n`); | ||
| return this; | ||
| } | ||
| this.start = Date.now(); | ||
| this.interval = setInterval(() => { | ||
| const f = FRAMES[this.frame++ % FRAMES.length]; | ||
| process.stderr.write(`\r \x1b[36m${f}\x1b[0m ${this.text}...`); | ||
| }, 80); | ||
| return this; | ||
| } | ||
| update(text) { | ||
| this.text = text; | ||
| } | ||
| succeed(text) { | ||
| if (this.interval) | ||
| clearInterval(this.interval); | ||
| const elapsed = ((Date.now() - this.start) / 1000).toFixed(1); | ||
| if (this.isTty) | ||
| process.stderr.write("\r\x1b[K"); | ||
| process.stderr.write(` \x1b[32m✓\x1b[0m ${text ?? this.text} \x1b[90m(${elapsed}s)\x1b[0m\n`); | ||
| } | ||
| fail(text) { | ||
| if (this.interval) | ||
| clearInterval(this.interval); | ||
| if (this.isTty) | ||
| process.stderr.write("\r\x1b[K"); | ||
| process.stderr.write(` \x1b[31m✗\x1b[0m ${text ?? this.text}\n`); | ||
| } | ||
| } | ||
| export class ProgressBar { | ||
| _label; | ||
| total; | ||
| width = 20; | ||
| isTty; | ||
| start = Date.now(); | ||
| constructor(_label, total) { | ||
| this._label = _label; | ||
| this.total = total; | ||
| this.isTty = process.stderr.isTTY ?? false; | ||
| } | ||
| update(current) { | ||
| if (!this.isTty) | ||
| return; | ||
| const ratio = Math.min(current / this.total, 1); | ||
| const filled = Math.round(this.width * ratio); | ||
| const bar = "\u25B0".repeat(filled) + "\u25B1".repeat(this.width - filled); | ||
| const elapsed = (Date.now() - this.start) / 1000; | ||
| const eta = elapsed > 0.5 && ratio > 0 ? Math.max(0, (elapsed / ratio) - elapsed) : NaN; | ||
| const etaStr = isFinite(eta) ? ` \x1b[90m· ETA ${eta.toFixed(0)}s\x1b[0m` : ""; | ||
| process.stderr.write(`\r ${bar} ${(ratio * 100).toFixed(0)}%${etaStr} `); | ||
| } | ||
| done() { | ||
| if (this.isTty) | ||
| process.stderr.write("\r\x1b[K"); | ||
| } | ||
| } |
| import { success, error } from "../utils/output.js"; | ||
| import { Spinner, ProgressBar } from "../utils/spinner.js"; | ||
| import * as path from "node:path"; | ||
@@ -22,5 +23,8 @@ import * as fs from "node:fs"; | ||
| async function renderWithPlaywright(projectJson, runtimeUrl, opts) { | ||
| const sp1 = new Spinner("Starting Playwright").spin(); | ||
| const pw = await loadPlaywright(); | ||
| sp1.succeed("Playwright ready"); | ||
| const downloadDir = path.dirname(path.resolve(opts.outputPath)); | ||
| fs.mkdirSync(downloadDir, { recursive: true }); | ||
| const sp2 = new Spinner("Launching Chrome (WebGPU)").spin(); | ||
| const browser = await pw.chromium.launch({ | ||
@@ -36,8 +40,20 @@ headless: false, | ||
| }); | ||
| sp2.succeed("Chrome ready"); | ||
| try { | ||
| const context = await browser.newContext({ acceptDownloads: true }); | ||
| const page = await context.newPage(); | ||
| console.log(` Loading runtime: ${runtimeUrl}`); | ||
| let hostname; | ||
| try { | ||
| hostname = new URL(runtimeUrl).hostname; | ||
| } | ||
| catch { | ||
| hostname = runtimeUrl; | ||
| } | ||
| const sp3 = new Spinner(`Loading ${hostname}`).spin(); | ||
| await page.goto(runtimeUrl, { waitUntil: "networkidle" }); | ||
| await page.waitForTimeout(3000); | ||
| sp3.succeed("Runtime loaded"); | ||
| const project = JSON.parse(projectJson); | ||
| const layerCount = project.layers?.length ?? 0; | ||
| const sp4 = new Spinner(`Injecting project (${layerCount} layers, ${opts.duration}s)`).spin(); | ||
| await page.evaluate((json) => { | ||
@@ -66,3 +82,5 @@ const projectFile = JSON.parse(json); | ||
| await page.waitForTimeout(2000); | ||
| sp4.succeed("Project injected"); | ||
| if (opts.format === "png") { | ||
| const sp5 = new Spinner("Exporting PNG").spin(); | ||
| const downloadPromise = page.waitForEvent("download", { timeout: 30000 }); | ||
@@ -82,5 +100,12 @@ await page.evaluate((_time) => { | ||
| const download = await downloadPromise; | ||
| sp5.succeed("PNG captured"); | ||
| const sp6 = new Spinner(`Saving to ${path.basename(opts.outputPath)}`).spin(); | ||
| await download.saveAs(opts.outputPath); | ||
| sp6.succeed(`Saved ${path.basename(opts.outputPath)}`); | ||
| } | ||
| else { | ||
| const formatLabel = opts.format.toUpperCase(); | ||
| const expectedMs = opts.duration * 1000 * 2; | ||
| const pb = new ProgressBar(`Recording ${formatLabel}`, expectedMs); | ||
| const spRec = new Spinner(`Recording ${formatLabel} (${opts.duration}s @ ${opts.fps}fps)`).spin(); | ||
| const downloadPromise = page.waitForEvent("download", { timeout: 120000 }); | ||
@@ -105,4 +130,11 @@ await page.evaluate((format) => { | ||
| }, opts.format); | ||
| const recStart = Date.now(); | ||
| const tick = setInterval(() => pb.update(Date.now() - recStart), 200); | ||
| const download = await downloadPromise; | ||
| clearInterval(tick); | ||
| pb.done(); | ||
| spRec.succeed(`Recording complete`); | ||
| const sp6 = new Spinner(`Saving to ${path.basename(opts.outputPath)}`).spin(); | ||
| await download.saveAs(opts.outputPath); | ||
| sp6.succeed(`Saved ${path.basename(opts.outputPath)}`); | ||
| } | ||
@@ -130,3 +162,2 @@ return opts.outputPath; | ||
| const runtimeUrl = resolveRuntime(program); | ||
| console.log(` Exporting ${opts.format.toUpperCase()} video...`); | ||
| const projectJson = JSON.stringify(project); | ||
@@ -158,3 +189,2 @@ const outputPath = await renderWithPlaywright(projectJson, runtimeUrl, { | ||
| const runtimeUrl = resolveRuntime(program); | ||
| console.log(" Exporting PNG..."); | ||
| const projectJson = JSON.stringify(project); | ||
@@ -161,0 +191,0 @@ const outputPath = await renderWithPlaywright(projectJson, runtimeUrl, { |
+1
-1
@@ -18,3 +18,3 @@ #!/usr/bin/env node | ||
| .description("Agent-native CLI for Shader Lab — compose and export WebGPU shader scenes from the terminal") | ||
| .version("0.1.0") | ||
| .version("0.1.1") | ||
| .option("--json", "Output as JSON (agent-friendly)") | ||
@@ -21,0 +21,0 @@ .option("--project <path>", "Open a .lab project file") |
+1
-1
| { | ||
| "name": "@resciencelab/shader-cli", | ||
| "version": "0.1.0", | ||
| "version": "0.1.1", | ||
| "description": "Agent-native CLI for Shader Lab — compose and export WebGPU shader scenes from the terminal", | ||
@@ -5,0 +5,0 @@ "type": "module", |
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 1 instance in 1 package
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 1 instance in 1 package
85625
5.3%30
7.14%1831
7.08%3
50%