promises, link
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
import { createRequire } from "node:module";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { IdDrawStyle } from "./id-config";
|
||||
|
||||
export type WorkerFont = {
|
||||
path: string;
|
||||
family: string;
|
||||
};
|
||||
|
||||
export type GenerateTask = {
|
||||
text: string;
|
||||
outPath: string;
|
||||
};
|
||||
|
||||
const nodeRequire = createRequire(path.join(process.cwd(), "package.json"));
|
||||
|
||||
function spawnWorker(workerPath: string, workerData: unknown) {
|
||||
const threads = nodeRequire("node:worker_threads") as typeof import("node:worker_threads");
|
||||
return new threads.Worker(workerPath, { workerData });
|
||||
}
|
||||
|
||||
function workerCount(taskCount: number): number {
|
||||
const cpus =
|
||||
typeof os.availableParallelism === "function"
|
||||
? os.availableParallelism()
|
||||
: os.cpus().length;
|
||||
return Math.max(1, Math.min(cpus, 8, taskCount));
|
||||
}
|
||||
|
||||
function splitTasks<T>(items: T[], parts: number): T[][] {
|
||||
const chunks: T[][] = Array.from({ length: parts }, () => []);
|
||||
items.forEach((item, index) => {
|
||||
chunks[index % parts]?.push(item);
|
||||
});
|
||||
return chunks.filter((chunk) => chunk.length > 0);
|
||||
}
|
||||
|
||||
export async function generatePngsInWorkers(options: {
|
||||
imagePath: string;
|
||||
fonts: WorkerFont[];
|
||||
style: IdDrawStyle;
|
||||
tasks: GenerateTask[];
|
||||
onProgress: (completed: number, total: number) => void;
|
||||
}): Promise<void> {
|
||||
const { imagePath, fonts, style, tasks, onProgress } = options;
|
||||
if (tasks.length === 0) return;
|
||||
|
||||
const workerPath = path.join(process.cwd(), "workers", "generate-id.cjs");
|
||||
const chunks = splitTasks(tasks, workerCount(tasks.length));
|
||||
let completed = 0;
|
||||
|
||||
await Promise.all(
|
||||
chunks.map(
|
||||
(chunk) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const worker = spawnWorker(workerPath, {
|
||||
imagePath,
|
||||
fonts,
|
||||
style,
|
||||
tasks: chunk,
|
||||
});
|
||||
|
||||
worker.on("message", (message: { type?: string }) => {
|
||||
if (message?.type !== "progress") return;
|
||||
completed += 1;
|
||||
onProgress(completed, tasks.length);
|
||||
});
|
||||
|
||||
worker.once("error", reject);
|
||||
worker.once("exit", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`Image worker stopped with code ${code}`));
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
+72
-27
@@ -1,33 +1,78 @@
|
||||
import { rm } from "node:fs/promises";
|
||||
|
||||
type Job = {
|
||||
dir: string;
|
||||
zipPath: string;
|
||||
createdAt: number;
|
||||
};
|
||||
import { mkdir, readdir, rm, stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const JOB_TTL_MS = 15 * 60 * 1000;
|
||||
const jobs = new Map<string, Job>();
|
||||
const JOBS_ROOT = path.join(tmpdir(), "kk-card-jobs");
|
||||
const JOB_ID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function isJobId(id: string): boolean {
|
||||
return JOB_ID_RE.test(id);
|
||||
}
|
||||
|
||||
export function jobDir(id: string): string {
|
||||
return path.join(JOBS_ROOT, id);
|
||||
}
|
||||
|
||||
export function jobZipPath(id: string): string {
|
||||
return path.join(jobDir(id), "ids.zip");
|
||||
}
|
||||
|
||||
export async function sweepExpiredJobs(): Promise<void> {
|
||||
let names: string[] = [];
|
||||
try {
|
||||
names = await readdir(JOBS_ROOT);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
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 });
|
||||
await Promise.all(
|
||||
names.map(async (name) => {
|
||||
const dir = path.join(JOBS_ROOT, name);
|
||||
try {
|
||||
const info = await stat(dir);
|
||||
if (info.mtimeMs >= cutoff) return;
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore missing or locked job folders
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function createJobDir(): Promise<{
|
||||
id: string;
|
||||
dir: string;
|
||||
pngDir: string;
|
||||
imagePath: string;
|
||||
zipPath: string;
|
||||
}> {
|
||||
await sweepExpiredJobs();
|
||||
const id = crypto.randomUUID();
|
||||
const dir = jobDir(id);
|
||||
const pngDir = path.join(dir, "png");
|
||||
await mkdir(pngDir, { recursive: true });
|
||||
return {
|
||||
id,
|
||||
dir,
|
||||
pngDir,
|
||||
imagePath: path.join(dir, "base"),
|
||||
zipPath: jobZipPath(id),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getJobZip(
|
||||
id: string,
|
||||
): Promise<{ zipPath: string; size: number } | null> {
|
||||
if (!isJobId(id)) return null;
|
||||
const zipPath = jobZipPath(id);
|
||||
try {
|
||||
const info = await stat(zipPath);
|
||||
if (!info.isFile() || info.size === 0) return null;
|
||||
return { zipPath, size: info.size };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user