progress indicator added
This commit is contained in:
@@ -0,0 +1,34 @@
|
|||||||
|
import { createReadStream } from "node:fs";
|
||||||
|
import { rm } from "node:fs/promises";
|
||||||
|
import { Readable } from "node:stream";
|
||||||
|
import { takeJob } from "@/lib/jobs";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: Request,
|
||||||
|
context: { params: Promise<{ jobId: string }> },
|
||||||
|
): Promise<Response> {
|
||||||
|
const { jobId } = await context.params;
|
||||||
|
const job = takeJob(jobId);
|
||||||
|
if (!job) {
|
||||||
|
return Response.json({ error: "Download expired or not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = createReadStream(job.zipPath);
|
||||||
|
file.on("close", () => {
|
||||||
|
void rm(job.dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
file.on("error", () => {
|
||||||
|
void rm(job.dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(Readable.toWeb(file) as ReadableStream, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/zip",
|
||||||
|
"Content-Disposition": 'attachment; filename="ids.zip"',
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
+97
-65
@@ -1,8 +1,9 @@
|
|||||||
import { createCanvas, loadImage, GlobalFonts } from "@napi-rs/canvas";
|
import { createCanvas, loadImage, GlobalFonts } from "@napi-rs/canvas";
|
||||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { createWriteStream } from "node:fs";
|
||||||
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { PassThrough, Readable } from "node:stream";
|
import { finished } from "node:stream/promises";
|
||||||
import { createZipArchive } from "@/lib/create-zip";
|
import { createZipArchive } from "@/lib/create-zip";
|
||||||
import { drawIdText } from "@/lib/draw-id";
|
import { drawIdText } from "@/lib/draw-id";
|
||||||
import { isPresetFont } from "@/lib/fonts";
|
import { isPresetFont } from "@/lib/fonts";
|
||||||
@@ -15,6 +16,8 @@ import {
|
|||||||
sanitizeFilename,
|
sanitizeFilename,
|
||||||
toIdDrawStyle,
|
toIdDrawStyle,
|
||||||
} from "@/lib/id-config";
|
} from "@/lib/id-config";
|
||||||
|
import { saveJob } from "@/lib/jobs";
|
||||||
|
import type { GenerateProgressEvent } from "@/lib/progress";
|
||||||
import { registerPresetFonts } from "@/lib/register-fonts";
|
import { registerPresetFonts } from "@/lib/register-fonts";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
@@ -26,8 +29,6 @@ function jsonError(message: string, status = 400): Response {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: Request): Promise<Response> {
|
export async function POST(req: Request): Promise<Response> {
|
||||||
let tmpDir: string | null = null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const form = await req.formData();
|
const form = await req.formData();
|
||||||
const imageFile = form.get("image");
|
const imageFile = form.get("image");
|
||||||
@@ -67,79 +68,110 @@ export async function POST(req: Request): Promise<Response> {
|
|||||||
return jsonError("Unknown font family");
|
return jsonError("Unknown font family");
|
||||||
}
|
}
|
||||||
|
|
||||||
tmpDir = await mkdtemp(path.join(tmpdir(), "kk-card-"));
|
const encoder = new TextEncoder();
|
||||||
registerPresetFonts();
|
const stream = new ReadableStream<Uint8Array>({
|
||||||
|
async start(controller) {
|
||||||
|
let tmpDir: string | null = null;
|
||||||
|
const send = (event: GenerateProgressEvent) => {
|
||||||
|
controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`));
|
||||||
|
};
|
||||||
|
|
||||||
const style = toIdDrawStyle(config);
|
try {
|
||||||
|
tmpDir = await mkdtemp(path.join(tmpdir(), "kk-card-"));
|
||||||
|
const pngDir = path.join(tmpDir, "png");
|
||||||
|
await mkdir(pngDir);
|
||||||
|
|
||||||
if (config.useCustomFont && fontFile instanceof File) {
|
registerPresetFonts();
|
||||||
const ext = fontFile.name.toLowerCase().endsWith(".otf") ? ".otf" : ".ttf";
|
const style = toIdDrawStyle(config);
|
||||||
const fontPath = path.join(tmpDir, `custom${ext}`);
|
|
||||||
await writeFile(fontPath, Buffer.from(await fontFile.arrayBuffer()));
|
|
||||||
GlobalFonts.registerFromPath(fontPath, CUSTOM_FONT_FAMILY);
|
|
||||||
}
|
|
||||||
|
|
||||||
const image = await loadImage(
|
if (config.useCustomFont && fontFile instanceof File) {
|
||||||
Buffer.from(await imageFile.arrayBuffer()),
|
const ext = fontFile.name.toLowerCase().endsWith(".otf")
|
||||||
);
|
? ".otf"
|
||||||
const canvas = createCanvas(image.width, image.height);
|
: ".ttf";
|
||||||
const ctx = canvas.getContext("2d");
|
const fontPath = path.join(tmpDir, `custom${ext}`);
|
||||||
|
await writeFile(fontPath, Buffer.from(await fontFile.arrayBuffer()));
|
||||||
const passThrough = new PassThrough();
|
GlobalFonts.registerFromPath(fontPath, CUSTOM_FONT_FAMILY);
|
||||||
const archive = createZipArchive();
|
|
||||||
archive.on("error", (error: Error) => {
|
|
||||||
passThrough.destroy(error);
|
|
||||||
});
|
|
||||||
archive.pipe(passThrough);
|
|
||||||
|
|
||||||
const usedNames = new Set<string>();
|
|
||||||
|
|
||||||
const generate = async () => {
|
|
||||||
try {
|
|
||||||
for (let i = 0; i < config.count; i += 1) {
|
|
||||||
const id = formatUserId(
|
|
||||||
config.prefix,
|
|
||||||
config.start + i,
|
|
||||||
config.pad,
|
|
||||||
config.suffix,
|
|
||||||
);
|
|
||||||
let name = `${sanitizeFilename(id)}.png`;
|
|
||||||
if (usedNames.has(name)) {
|
|
||||||
name = `${sanitizeFilename(id)}-${i}.png`;
|
|
||||||
}
|
}
|
||||||
usedNames.add(name);
|
|
||||||
|
|
||||||
ctx.drawImage(image, 0, 0);
|
const image = await loadImage(
|
||||||
drawIdText(ctx, id, image.width, image.height, style);
|
Buffer.from(await imageFile.arrayBuffer()),
|
||||||
const png = await canvas.encode("png");
|
);
|
||||||
archive.append(Buffer.from(png), { name });
|
const canvas = createCanvas(image.width, image.height);
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
const usedNames = new Set<string>();
|
||||||
|
const files: { name: string; path: string }[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < config.count; i += 1) {
|
||||||
|
const id = formatUserId(
|
||||||
|
config.prefix,
|
||||||
|
config.start + i,
|
||||||
|
config.pad,
|
||||||
|
config.suffix,
|
||||||
|
);
|
||||||
|
let name = `${sanitizeFilename(id)}.png`;
|
||||||
|
if (usedNames.has(name)) {
|
||||||
|
name = `${sanitizeFilename(id)}-${i}.png`;
|
||||||
|
}
|
||||||
|
usedNames.add(name);
|
||||||
|
|
||||||
|
ctx.drawImage(image, 0, 0);
|
||||||
|
drawIdText(ctx, id, image.width, image.height, style);
|
||||||
|
const png = await canvas.encode("png");
|
||||||
|
const filePath = path.join(pngDir, name);
|
||||||
|
await writeFile(filePath, png);
|
||||||
|
files.push({ name, path: filePath });
|
||||||
|
send({ phase: "generate", current: i + 1, total: config.count });
|
||||||
|
}
|
||||||
|
|
||||||
|
send({ phase: "zip", current: 0, total: files.length });
|
||||||
|
|
||||||
|
const zipPath = path.join(tmpDir, "ids.zip");
|
||||||
|
const output = createWriteStream(zipPath);
|
||||||
|
const archive = createZipArchive();
|
||||||
|
archive.on("error", (error: Error) => {
|
||||||
|
output.destroy(error);
|
||||||
|
});
|
||||||
|
archive.on("progress", (progress) => {
|
||||||
|
send({
|
||||||
|
phase: "zip",
|
||||||
|
current: progress.entries.processed,
|
||||||
|
total: Math.max(progress.entries.total, files.length),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
archive.pipe(output);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
archive.file(file.path, { name: file.name });
|
||||||
|
}
|
||||||
|
|
||||||
|
await archive.finalize();
|
||||||
|
await finished(output);
|
||||||
|
send({ phase: "zip", current: files.length, total: files.length });
|
||||||
|
|
||||||
|
const id = saveJob(tmpDir, zipPath);
|
||||||
|
tmpDir = null;
|
||||||
|
send({ phase: "done", id });
|
||||||
|
controller.close();
|
||||||
|
} catch (error) {
|
||||||
|
if (tmpDir) {
|
||||||
|
await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Failed to generate images";
|
||||||
|
send({ phase: "error", message });
|
||||||
|
controller.close();
|
||||||
}
|
}
|
||||||
await archive.finalize();
|
},
|
||||||
} catch (error) {
|
});
|
||||||
archive.abort();
|
|
||||||
passThrough.destroy(
|
|
||||||
error instanceof Error ? error : new Error("Failed to generate zip"),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
if (tmpDir) {
|
|
||||||
await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
void generate();
|
return new Response(stream, {
|
||||||
|
|
||||||
return new Response(Readable.toWeb(passThrough) as ReadableStream, {
|
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/zip",
|
"Content-Type": "application/x-ndjson",
|
||||||
"Content-Disposition": 'attachment; filename="ids.zip"',
|
|
||||||
"Cache-Control": "no-store",
|
"Cache-Control": "no-store",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (tmpDir) {
|
|
||||||
await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
||||||
}
|
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : "Failed to generate images";
|
error instanceof Error ? error.message : "Failed to generate images";
|
||||||
return jsonError(message, 500);
|
return jsonError(message, 500);
|
||||||
|
|||||||
+139
-4
@@ -29,10 +29,60 @@ import {
|
|||||||
type GenerateConfig,
|
type GenerateConfig,
|
||||||
type TextAlign,
|
type TextAlign,
|
||||||
} from "@/lib/id-config";
|
} from "@/lib/id-config";
|
||||||
|
import {
|
||||||
|
isProgressEvent,
|
||||||
|
type GenerateProgressEvent,
|
||||||
|
} from "@/lib/progress";
|
||||||
|
|
||||||
const IMAGE_ACCEPT = "image/png,image/jpeg,image/webp";
|
const IMAGE_ACCEPT = "image/png,image/jpeg,image/webp";
|
||||||
const FONT_ACCEPT = ".ttf,.otf,font/ttf,font/otf";
|
const FONT_ACCEPT = ".ttf,.otf,font/ttf,font/otf";
|
||||||
|
|
||||||
|
async function readGenerateProgress(
|
||||||
|
response: Response,
|
||||||
|
onEvent: (event: Extract<GenerateProgressEvent, { phase: "generate" | "zip" }>) => void,
|
||||||
|
): Promise<string> {
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error("No progress stream from server");
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
let jobId: string | null = null;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split("\n");
|
||||||
|
buffer = lines.pop() ?? "";
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isProgressEvent(parsed)) continue;
|
||||||
|
if (parsed.phase === "error") {
|
||||||
|
throw new Error(parsed.message);
|
||||||
|
}
|
||||||
|
if (parsed.phase === "done") {
|
||||||
|
jobId = parsed.id;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
onEvent(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!jobId) {
|
||||||
|
throw new Error("Generation finished without a download");
|
||||||
|
}
|
||||||
|
return jobId;
|
||||||
|
}
|
||||||
|
|
||||||
function canvasPoint(
|
function canvasPoint(
|
||||||
canvas: HTMLCanvasElement,
|
canvas: HTMLCanvasElement,
|
||||||
clientX: number,
|
clientX: number,
|
||||||
@@ -60,6 +110,11 @@ export function Editor() {
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [status, setStatus] = useState<string | null>(null);
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
|
const [progress, setProgress] = useState<{
|
||||||
|
phase: "generate" | "zip" | "download";
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
} | null>(null);
|
||||||
const [cursor, setCursor] = useState<"grab" | "grabbing" | "default">(
|
const [cursor, setCursor] = useState<"grab" | "grabbing" | "default">(
|
||||||
"default",
|
"default",
|
||||||
);
|
);
|
||||||
@@ -298,7 +353,8 @@ export function Editor() {
|
|||||||
|
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setStatus(`Generating ${config.count} images on the server…`);
|
setStatus(null);
|
||||||
|
setProgress({ phase: "generate", current: 0, total: config.count });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
@@ -324,7 +380,23 @@ export function Editor() {
|
|||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
const blob = await response.blob();
|
const jobId = await readGenerateProgress(response, (event) => {
|
||||||
|
if (event.phase === "generate" || event.phase === "zip") {
|
||||||
|
setProgress({
|
||||||
|
phase: event.phase,
|
||||||
|
current: event.current,
|
||||||
|
total: event.total,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setProgress({ phase: "download", current: 1, total: 1 });
|
||||||
|
const zipResponse = await fetch(`/api/download/${jobId}`);
|
||||||
|
if (!zipResponse.ok) {
|
||||||
|
throw new Error("Could not download the zip file");
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await zipResponse.blob();
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
link.href = url;
|
||||||
@@ -333,8 +405,10 @@ export function Editor() {
|
|||||||
link.click();
|
link.click();
|
||||||
link.remove();
|
link.remove();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
|
setProgress(null);
|
||||||
setStatus(`Downloaded ids.zip with ${config.count} PNGs`);
|
setStatus(`Downloaded ids.zip with ${config.count} PNGs`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
setProgress(null);
|
||||||
setStatus(null);
|
setStatus(null);
|
||||||
setError(err instanceof Error ? err.message : "Generation failed");
|
setError(err instanceof Error ? err.message : "Generation failed");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -686,7 +760,8 @@ export function Editor() {
|
|||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{status && !error ? (
|
{progress ? <ProgressPanel progress={progress} /> : null}
|
||||||
|
{status && !error && !progress ? (
|
||||||
<p className="rounded-lg border border-indigo-900 bg-indigo-950/50 px-3 py-2 text-sm text-indigo-100">
|
<p className="rounded-lg border border-indigo-900 bg-indigo-950/50 px-3 py-2 text-sm text-indigo-100">
|
||||||
{status}
|
{status}
|
||||||
</p>
|
</p>
|
||||||
@@ -698,7 +773,9 @@ export function Editor() {
|
|||||||
disabled={busy || !imageFile}
|
disabled={busy || !imageFile}
|
||||||
className="rounded-xl bg-indigo-500 px-4 py-3 text-sm font-semibold text-white transition hover:bg-indigo-400 disabled:cursor-not-allowed disabled:bg-zinc-700 disabled:text-zinc-400"
|
className="rounded-xl bg-indigo-500 px-4 py-3 text-sm font-semibold text-white transition hover:bg-indigo-400 disabled:cursor-not-allowed disabled:bg-zinc-700 disabled:text-zinc-400"
|
||||||
>
|
>
|
||||||
{busy ? "Generating…" : `Generate ${config.count} images`}
|
{busy
|
||||||
|
? progressLabel(progress, config.count)
|
||||||
|
: `Generate ${config.count} images`}
|
||||||
</button>
|
</button>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
@@ -706,6 +783,64 @@ export function Editor() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function progressLabel(
|
||||||
|
progress: {
|
||||||
|
phase: "generate" | "zip" | "download";
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
} | null,
|
||||||
|
fallbackTotal: number,
|
||||||
|
): string {
|
||||||
|
if (!progress) return `Generating 0 / ${fallbackTotal}`;
|
||||||
|
if (progress.phase === "zip") {
|
||||||
|
return `Zipping ${progress.current} / ${progress.total}`;
|
||||||
|
}
|
||||||
|
if (progress.phase === "download") {
|
||||||
|
return "Downloading zip…";
|
||||||
|
}
|
||||||
|
return `Generating ${progress.current} / ${progress.total}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressPanel({
|
||||||
|
progress,
|
||||||
|
}: {
|
||||||
|
progress: {
|
||||||
|
phase: "generate" | "zip" | "download";
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
const percent =
|
||||||
|
progress.total > 0
|
||||||
|
? Math.min(100, Math.round((progress.current / progress.total) * 100))
|
||||||
|
: 0;
|
||||||
|
const title =
|
||||||
|
progress.phase === "zip"
|
||||||
|
? "Zipping"
|
||||||
|
: progress.phase === "download"
|
||||||
|
? "Downloading"
|
||||||
|
: "Generating";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-indigo-900 bg-indigo-950/50 px-3 py-3 text-sm text-indigo-100">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span>{title}</span>
|
||||||
|
<span className="font-mono text-xs text-indigo-200">
|
||||||
|
{progress.phase === "download"
|
||||||
|
? "ids.zip"
|
||||||
|
: `${progress.current} / ${progress.total}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-2 overflow-hidden rounded-full bg-zinc-800">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-indigo-400 transition-[width] duration-150"
|
||||||
|
style={{ width: `${percent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function FieldGroup({
|
function FieldGroup({
|
||||||
title,
|
title,
|
||||||
children,
|
children,
|
||||||
|
|||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
import { rm } from "node:fs/promises";
|
||||||
|
|
||||||
|
type Job = {
|
||||||
|
dir: string;
|
||||||
|
zipPath: string;
|
||||||
|
createdAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const JOB_TTL_MS = 15 * 60 * 1000;
|
||||||
|
const jobs = new Map<string, Job>();
|
||||||
|
|
||||||
|
function sweepExpiredJobs(): void {
|
||||||
|
const cutoff = Date.now() - JOB_TTL_MS;
|
||||||
|
for (const [id, job] of jobs) {
|
||||||
|
if (job.createdAt > cutoff) continue;
|
||||||
|
jobs.delete(id);
|
||||||
|
void rm(job.dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveJob(dir: string, zipPath: string): string {
|
||||||
|
sweepExpiredJobs();
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
jobs.set(id, { dir, zipPath, createdAt: Date.now() });
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function takeJob(id: string): Job | null {
|
||||||
|
const job = jobs.get(id);
|
||||||
|
if (!job) return null;
|
||||||
|
jobs.delete(id);
|
||||||
|
return job;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export type GenerateProgressEvent =
|
||||||
|
| { phase: "generate"; current: number; total: number }
|
||||||
|
| { phase: "zip"; current: number; total: number }
|
||||||
|
| { phase: "done"; id: string }
|
||||||
|
| { phase: "error"; message: string };
|
||||||
|
|
||||||
|
export function isProgressEvent(value: unknown): value is GenerateProgressEvent {
|
||||||
|
if (!value || typeof value !== "object") return false;
|
||||||
|
const phase = (value as { phase?: unknown }).phase;
|
||||||
|
return (
|
||||||
|
phase === "generate" ||
|
||||||
|
phase === "zip" ||
|
||||||
|
phase === "done" ||
|
||||||
|
phase === "error"
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user