perplexica / OpenReels /src /cli /progress.ts
abc1181's picture
Motion Factory: OpenReels edition
1da8cb0 verified
Raw History Blame Contribute Delete
4.07 kB
type StageStatus = "pending" | "running" | "done" | "failed" | "skipped";
interface StageInfo {
name: string;
status: StageStatus;
duration?: number;
detail?: string;
}
const SPINNER_FRAMES = [
"\u280b",
"\u2819",
"\u2839",
"\u2838",
"\u283c",
"\u2834",
"\u2826",
"\u2827",
"\u2807",
"\u280f",
];
export class ProgressDisplay {
private stages: StageInfo[] = [];
private startTime: number = Date.now();
private hasRendered: boolean = false;
private tickInterval: ReturnType<typeof setInterval> | null = null;
private spinnerFrame: number = 0;
private extraLines: number = 0;
addStage(name: string): number {
const index = this.stages.length;
this.stages.push({ name, status: "pending" });
return index;
}
start(index: number): void {
const stage = this.stages[index];
if (stage) {
stage.status = "running";
this.render();
this.ensureTick();
}
}
complete(index: number, detail?: string): void {
const stage = this.stages[index];
if (stage) {
stage.status = "done";
stage.duration = (Date.now() - this.startTime) / 1000;
stage.detail = detail;
this.render();
this.maybeStopTick();
}
}
fail(index: number, detail?: string): void {
const stage = this.stages[index];
if (stage) {
stage.status = "failed";
stage.duration = (Date.now() - this.startTime) / 1000;
stage.detail = detail;
this.render();
this.maybeStopTick();
}
}
skip(index: number, detail?: string): void {
const stage = this.stages[index];
if (stage) {
stage.status = "skipped";
stage.detail = detail;
this.render();
this.maybeStopTick();
}
}
private ensureTick(): void {
if (this.tickInterval) return;
this.tickInterval = setInterval(() => {
this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length;
this.render();
}, 80);
}
private maybeStopTick(): void {
if (!this.stages.some((s) => s.status === "running") && this.tickInterval) {
clearInterval(this.tickInterval);
this.tickInterval = null;
}
}
/** Call after printing extra output (e.g. cost estimate) between renders
* so the next render moves the cursor past those lines too. */
addExtraLines(count: number): void {
this.extraLines += count;
}
private render(): void {
// Move cursor up to overwrite previous output (only after first render)
if (this.hasRendered) {
const lines = this.stages.length + this.extraLines;
this.extraLines = 0;
if (lines > 0) {
process.stdout.write(`\x1b[${lines}A`);
}
}
this.hasRendered = true;
const now = Date.now();
for (const stage of this.stages) {
let icon: string;
let time: string;
if (stage.status === "running") {
icon = SPINNER_FRAMES[this.spinnerFrame] ?? SPINNER_FRAMES[0]!;
time = `${((now - this.startTime) / 1000).toFixed(1)}s`;
} else if (stage.status === "pending") {
icon = " ";
time = " ";
} else {
icon = STATUS_ICONS[stage.status];
time = stage.duration ? `${stage.duration.toFixed(1)}s` : "";
}
const detail = stage.detail ? ` (${stage.detail})` : "";
const line = `${icon} ${stage.name.padEnd(14)} ${time.padStart(6)}${detail}`;
process.stdout.write(`\x1b[2K${line}\n`);
}
}
summary(): void {
if (this.tickInterval) {
clearInterval(this.tickInterval);
this.tickInterval = null;
}
const elapsed = ((Date.now() - this.startTime) / 1000).toFixed(1);
const done = this.stages.filter((s) => s.status === "done").length;
const failed = this.stages.filter((s) => s.status === "failed").length;
console.log(
`\nCompleted ${done}/${this.stages.length} stages in ${elapsed}s${failed > 0 ? ` (${failed} failed)` : ""}`,
);
}
}
const STATUS_ICONS: Record<StageStatus, string> = {
done: "\u2714",
failed: "\u2718",
skipped: "\u23ed",
pending: " ",
running: "",
};