feat: add task workflow and asset downloads

This commit is contained in:
inman
2026-05-29 12:32:02 +08:00
parent f9c3393f84
commit 63e62d444c
61 changed files with 2773 additions and 2181 deletions

View File

@@ -1,7 +1,7 @@
import { readFile, rename, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
import type { AppState, Asset, GenerationJob, Project, UsageEvent } from "@/lib/types";
import type { AppState, Asset, GenerationCapability, GenerationJob, GenerationStatus, Project, UsageEvent } from "@/lib/types";
import { createId } from "@/lib/server/ids";
import { dataDir, DEFAULT_OWNER_ID, ensureRuntimeDirs } from "@/lib/server/runtime";
@@ -12,6 +12,21 @@ type AssetInput = Omit<Asset, "id" | "createdAt" | "updatedAt"> & Partial<Pick<A
type JobInput = Omit<GenerationJob, "id" | "createdAt" | "updatedAt"> & Partial<Pick<GenerationJob, "id" | "createdAt" | "updatedAt">>;
type UsageInput = Omit<UsageEvent, "id" | "createdAt"> & Partial<Pick<UsageEvent, "id" | "createdAt">>;
export type GenerationJobListFilters = {
ownerId?: string;
externalClientId?: string;
status?: GenerationStatus;
capability?: GenerationCapability;
limit?: number;
before?: string;
};
export type ClaimGenerationJobsInput = {
workerId: string;
limit?: number;
lockTimeoutMs?: number;
};
export async function listAssets(ownerId = DEFAULT_OWNER_ID): Promise<Asset[]> {
const supabase = getSupabaseAdmin();
if (supabase) {
@@ -82,19 +97,37 @@ export async function deleteAsset(id: string): Promise<Asset | null> {
}
export async function listGenerationJobs(ownerId = DEFAULT_OWNER_ID, limit = 200): Promise<GenerationJob[]> {
return listGenerationJobsFiltered({ ownerId, limit });
}
export async function listGenerationJobsFiltered(filters: GenerationJobListFilters = {}): Promise<GenerationJob[]> {
const ownerId = filters.ownerId || DEFAULT_OWNER_ID;
const limit = filters.limit || 200;
const supabase = getSupabaseAdmin();
if (supabase) {
const { data, error } = await supabase
let query = supabase
.from("generation_jobs")
.select("*")
.eq("owner_id", ownerId)
.order("created_at", { ascending: false })
.limit(limit);
if (filters.externalClientId) query = query.eq("external_client_id", filters.externalClientId);
if (filters.status) query = query.eq("status", filters.status);
if (filters.capability) query = query.eq("capability", filters.capability);
if (filters.before) query = query.lt("created_at", filters.before);
const { data, error } = await query;
if (error) throw new Error(error.message);
return (data || []).map(jobFromRow);
}
const state = await readState();
return state.generationJobs.filter((job) => job.ownerId === ownerId).sort(sortNewest).slice(0, limit);
return state.generationJobs
.filter((job) => job.ownerId === ownerId)
.filter((job) => !filters.externalClientId || job.externalClientId === filters.externalClientId)
.filter((job) => !filters.status || job.status === filters.status)
.filter((job) => !filters.capability || job.capability === filters.capability)
.filter((job) => !filters.before || job.createdAt < filters.before)
.sort(sortNewest)
.slice(0, limit);
}
export async function getGenerationJob(id: string): Promise<GenerationJob | null> {
@@ -118,6 +151,11 @@ export async function createGenerationJob(input: JobInput): Promise<GenerationJo
inputUrls: input.inputUrls || [],
outputAssetIds: input.outputAssetIds || [],
requestPayload: input.requestPayload || {},
priority: input.priority ?? 0,
attempts: input.attempts ?? 0,
maxAttempts: input.maxAttempts ?? 3,
scheduledAt: input.scheduledAt || now,
webhookAttempts: input.webhookAttempts ?? 0,
createdAt: input.createdAt || now,
updatedAt: input.updatedAt || now
};
@@ -133,6 +171,100 @@ export async function createGenerationJob(input: JobInput): Promise<GenerationJo
});
}
export async function findGenerationJobByIdempotency(
externalClientId: string,
idempotencyKey: string,
ownerId = DEFAULT_OWNER_ID
): Promise<GenerationJob | null> {
const supabase = getSupabaseAdmin();
if (supabase) {
const { data, error } = await supabase
.from("generation_jobs")
.select("*")
.eq("owner_id", ownerId)
.eq("external_client_id", externalClientId)
.eq("idempotency_key", idempotencyKey)
.maybeSingle();
if (error) throw new Error(error.message);
return data ? jobFromRow(data) : null;
}
const state = await readState();
return state.generationJobs.find((job) => (
job.ownerId === ownerId &&
job.externalClientId === externalClientId &&
job.idempotencyKey === idempotencyKey
)) || null;
}
export async function claimGenerationJobs(input: ClaimGenerationJobsInput): Promise<GenerationJob[]> {
const limit = Math.max(1, Math.min(input.limit || 1, 20));
const lockTimeoutMs = input.lockTimeoutMs ?? 5 * 60 * 1000;
const supabase = getSupabaseAdmin();
if (supabase) {
const { data, error } = await supabase.rpc("claim_generation_jobs", {
p_worker_id: input.workerId,
p_limit: limit,
p_lock_timeout_seconds: Math.ceil(lockTimeoutMs / 1000)
});
if (error) throw new Error(`claim_generation_jobs failed: ${error.message}`);
return (Array.isArray(data) ? data : []).map(jobFromRow);
}
return mutateLocalState((state) => {
const now = new Date();
const nowIso = now.toISOString();
const staleBefore = new Date(now.getTime() - lockTimeoutMs).toISOString();
const selected = state.generationJobs
.filter((job) => isClaimableJob(job, nowIso, staleBefore))
.sort(sortClaimableJobs)
.slice(0, limit);
for (const job of selected) {
job.lockedAt = nowIso;
job.lockedBy = input.workerId;
if (!job.startedAt) job.startedAt = nowIso;
job.updatedAt = nowIso;
}
return selected.map((job) => ({ ...job }));
});
}
export async function clearGenerationJobLock(
id: string,
patch: Partial<GenerationJob> = {},
options: { clearProviderTaskId?: boolean } = {}
): Promise<GenerationJob> {
const updatedAt = new Date().toISOString();
const supabase = getSupabaseAdmin();
if (supabase) {
const { data, error } = await supabase
.from("generation_jobs")
.update({
...jobToRow({ ...patch, updatedAt } as GenerationJob),
locked_at: null,
locked_by: null,
...(options.clearProviderTaskId ? { provider_task_id: null } : {})
})
.eq("id", id)
.select("*")
.single();
if (error) throw new Error(error.message);
return jobFromRow(data);
}
return mutateLocalState((state) => {
const index = state.generationJobs.findIndex((job) => job.id === id);
if (index === -1) throw new Error(`Generation job not found: ${id}`);
state.generationJobs[index] = {
...state.generationJobs[index],
...patch,
lockedAt: undefined,
lockedBy: undefined,
...(options.clearProviderTaskId ? { providerTaskId: undefined } : {}),
updatedAt
};
return state.generationJobs[index];
});
}
export async function updateGenerationJob(id: string, patch: Partial<GenerationJob>): Promise<GenerationJob> {
const updatedAt = new Date().toISOString();
const supabase = getSupabaseAdmin();
@@ -252,6 +384,20 @@ function sortNewest<T extends { createdAt: string }>(a: T, b: T): number {
return b.createdAt.localeCompare(a.createdAt);
}
function isClaimableJob(job: GenerationJob, nowIso: string, staleBefore: string): boolean {
if (["succeeded", "failed", "expired", "cancelled"].includes(job.status)) return false;
if ((job.scheduledAt || job.createdAt) > nowIso) return false;
return !job.lockedAt || job.lockedAt < staleBefore;
}
function sortClaimableJobs(a: GenerationJob, b: GenerationJob): number {
const priority = (b.priority || 0) - (a.priority || 0);
if (priority !== 0) return priority;
const scheduled = (a.scheduledAt || a.createdAt).localeCompare(b.scheduledAt || b.createdAt);
if (scheduled !== 0) return scheduled;
return a.createdAt.localeCompare(b.createdAt);
}
function assetToRow(asset: Partial<Asset>) {
return {
id: asset.id,
@@ -288,6 +434,7 @@ function jobToRow(job: Partial<GenerationJob>) {
const row: Record<string, unknown> = {};
if (job.id !== undefined) row.id = job.id;
if (job.ownerId !== undefined) row.owner_id = job.ownerId;
if (job.externalClientId !== undefined) row.external_client_id = job.externalClientId;
if (job.capability !== undefined) row.capability = job.capability;
if (job.provider !== undefined) row.provider = job.provider;
if (job.reqKey !== undefined) row.req_key = job.reqKey;
@@ -301,6 +448,19 @@ function jobToRow(job: Partial<GenerationJob>) {
if (job.responsePayload !== undefined) row.response_payload = job.responsePayload;
if (job.error !== undefined) row.error = job.error;
if (job.retryOf !== undefined) row.retry_of = job.retryOf;
if (job.idempotencyKey !== undefined) row.idempotency_key = job.idempotencyKey;
if (job.idempotencyFingerprint !== undefined) row.idempotency_fingerprint = job.idempotencyFingerprint;
if (job.priority !== undefined) row.priority = job.priority;
if (job.attempts !== undefined) row.attempts = job.attempts;
if (job.maxAttempts !== undefined) row.max_attempts = job.maxAttempts;
if (job.scheduledAt !== undefined) row.scheduled_at = job.scheduledAt;
if (job.lockedAt !== undefined) row.locked_at = job.lockedAt;
if (job.lockedBy !== undefined) row.locked_by = job.lockedBy;
if (job.startedAt !== undefined) row.started_at = job.startedAt;
if (job.completedAt !== undefined) row.completed_at = job.completedAt;
if (job.webhookUrl !== undefined) row.webhook_url = job.webhookUrl;
if (job.webhookAttempts !== undefined) row.webhook_attempts = job.webhookAttempts;
if (job.webhookLastStatus !== undefined) row.webhook_last_status = job.webhookLastStatus;
if (job.createdAt !== undefined) row.created_at = job.createdAt;
if (job.updatedAt !== undefined) row.updated_at = job.updatedAt;
return row;
@@ -310,6 +470,7 @@ function jobFromRow(row: Record<string, unknown>): GenerationJob {
return {
id: String(row.id),
ownerId: String(row.owner_id),
externalClientId: optionalString(row.external_client_id),
capability: row.capability as GenerationJob["capability"],
provider: row.provider as GenerationJob["provider"],
reqKey: String(row.req_key),
@@ -323,6 +484,27 @@ function jobFromRow(row: Record<string, unknown>): GenerationJob {
responsePayload: isRecord(row.response_payload) ? row.response_payload : undefined,
error: isRecord(row.error) ? { message: String(row.error.message || "Unknown error"), code: row.error.code as string | number | undefined, retryable: Boolean(row.error.retryable) } : undefined,
retryOf: row.retry_of ? String(row.retry_of) : undefined,
idempotencyKey: optionalString(row.idempotency_key),
idempotencyFingerprint: optionalString(row.idempotency_fingerprint),
priority: optionalNumber(row.priority),
attempts: optionalNumber(row.attempts),
maxAttempts: optionalNumber(row.max_attempts),
scheduledAt: optionalString(row.scheduled_at),
lockedAt: optionalString(row.locked_at),
lockedBy: optionalString(row.locked_by),
startedAt: optionalString(row.started_at),
completedAt: optionalString(row.completed_at),
webhookUrl: optionalString(row.webhook_url),
webhookAttempts: optionalNumber(row.webhook_attempts),
webhookLastStatus: isRecord(row.webhook_last_status)
? {
ok: Boolean(row.webhook_last_status.ok),
status: optionalNumber(row.webhook_last_status.status),
error: optionalString(row.webhook_last_status.error),
attemptedAt: String(row.webhook_last_status.attemptedAt || row.webhook_last_status.attempted_at || ""),
nextAttemptAt: optionalString(row.webhook_last_status.nextAttemptAt || row.webhook_last_status.next_attempt_at)
}
: undefined,
createdAt: String(row.created_at),
updatedAt: String(row.updated_at)
};
@@ -355,3 +537,15 @@ function usageFromRow(row: Record<string, unknown>): UsageEvent {
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function optionalString(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed || undefined;
}
function optionalNumber(value: unknown): number | undefined {
if (value === undefined || value === null || value === "") return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}

View File

@@ -30,6 +30,7 @@ import { queryVisualTask, shouldMockVisualApi, submitVisualTask } from "@/lib/vo
export type SubmitImageJobInput = {
ownerId?: string;
externalClientId?: string;
capability: EnabledImageCapability;
prompt?: string;
imageUrls?: string[];
@@ -43,6 +44,11 @@ export type SubmitImageJobInput = {
resolution?: "4k" | "8k";
seed?: number;
retryOf?: string;
idempotencyKey?: string;
idempotencyFingerprint?: string;
priority?: number;
maxAttempts?: number;
webhookUrl?: string;
};
export async function submitImageJob(input: SubmitImageJobInput, origin: string): Promise<GenerationJob> {
@@ -57,6 +63,7 @@ export async function submitImageJob(input: SubmitImageJobInput, origin: string)
const reqKey = engine === "evolink" ? getEvolinkImageSettings().model : capability.reqKey;
let job = await createGenerationJob({
ownerId,
externalClientId: input.externalClientId,
capability: input.capability,
provider: mock ? "mock" : engine === "evolink" ? "evolink" : "volcengine-visual",
reqKey,
@@ -70,15 +77,30 @@ export async function submitImageJob(input: SubmitImageJobInput, origin: string)
input,
providerPayload
},
retryOf: input.retryOf
retryOf: input.retryOf,
idempotencyKey: input.idempotencyKey,
idempotencyFingerprint: input.idempotencyFingerprint,
priority: input.priority,
maxAttempts: input.maxAttempts,
webhookUrl: input.webhookUrl
});
if (mock) {
return completeMockJob(job, origin);
}
return job;
}
export async function advanceImageJob(jobId: string, origin: string): Promise<GenerationJob> {
const job = await getGenerationJob(jobId);
if (!job) throw new Error(`Generation job not found: ${jobId}`);
if (["succeeded", "failed", "expired", "cancelled"].includes(job.status)) return job;
if (job.provider === "mock") return completeMockJob(job, origin);
if (!job.providerTaskId) return dispatchImageJob(job);
return syncImageJob(job.id, origin);
}
async function dispatchImageJob(job: GenerationJob): Promise<GenerationJob> {
const providerPayload = asRecord(job.requestPayload.providerPayload);
try {
if (engine === "evolink") {
if (job.provider === "evolink") {
const response = await submitEvolinkImageTask(providerPayload);
const taskId = getEvolinkTaskId(response);
if (!taskId) {
@@ -132,7 +154,7 @@ export async function submitImageJob(input: SubmitImageJobInput, origin: string)
export async function syncImageJob(jobId: string, origin: string): Promise<GenerationJob> {
const job = await getGenerationJob(jobId);
if (!job) throw new Error(`Generation job not found: ${jobId}`);
if (["succeeded", "failed", "expired"].includes(job.status)) return job;
if (["succeeded", "failed", "expired", "cancelled"].includes(job.status)) return job;
if (job.provider === "mock") return completeMockJob(job, origin);
if (!job.providerTaskId) return job;
@@ -353,3 +375,9 @@ function sourceForCapability(capability: string) {
if (capability === "image.upscale") return "upscaled";
return "generated";
}
function asRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}

View File

@@ -0,0 +1,69 @@
import { timingSafeEqual } from "node:crypto";
export type PublicApiClient = {
id: string;
key: string;
};
export class PublicApiAuthError extends Error {
status: number;
constructor(message: string, status = 401) {
super(message);
this.name = "PublicApiAuthError";
this.status = status;
}
}
export function getPublicApiClients(): PublicApiClient[] {
const configured = process.env.ZHINIAN_API_KEYS?.trim();
if (!configured) return [];
return configured
.split(/[\n,]+/)
.map((entry) => entry.trim())
.filter(Boolean)
.map((entry) => {
const separator = entry.indexOf(":");
if (separator === -1) return { id: "default", key: entry };
return {
id: entry.slice(0, separator).trim(),
key: entry.slice(separator + 1).trim()
};
})
.filter((client) => client.id && client.key);
}
export function authenticatePublicApiRequest(request: Request): PublicApiClient {
const presented = getPresentedApiKey(request);
if (!presented) throw new PublicApiAuthError("Missing API key.");
const client = getPublicApiClients().find((candidate) => safeEqual(candidate.key, presented));
if (!client) throw new PublicApiAuthError("Invalid API key.");
return client;
}
export function assertInternalWorkerToken(request: Request) {
const expected = process.env.ZHINIAN_INTERNAL_WORKER_TOKEN?.trim();
if (!expected && process.env.NODE_ENV !== "production") return;
if (!expected) throw new PublicApiAuthError("Worker token is not configured.", 500);
const presented = request.headers.get("x-zhinian-worker-token") || bearerToken(request);
if (!presented || !safeEqual(expected, presented)) {
throw new PublicApiAuthError("Invalid worker token.", 401);
}
}
function getPresentedApiKey(request: Request): string | undefined {
return bearerToken(request) || request.headers.get("x-zhinian-api-key") || undefined;
}
function bearerToken(request: Request): string | undefined {
const authorization = request.headers.get("authorization") || "";
const match = authorization.match(/^Bearer\s+(.+)$/i);
return match?.[1]?.trim() || undefined;
}
function safeEqual(expected: string, presented: string): boolean {
const left = Buffer.from(expected);
const right = Buffer.from(presented);
if (left.length !== right.length) return false;
return timingSafeEqual(left, right);
}

View File

@@ -0,0 +1,149 @@
import { createHash } from "node:crypto";
import { assemblePrompt, type PromptAssemblyInput, type PromptMaterial } from "@/lib/prompt/assembler";
import { findGenerationJobByIdempotency } from "@/lib/server/data-store";
import { submitImageJob, type SubmitImageJobInput } from "@/lib/server/generation-service";
import { DEFAULT_OWNER_ID } from "@/lib/server/runtime";
import { submitVideoJob, type SubmitVideoJobInput } from "@/lib/server/video-generation-service";
import type { PublicApiClient } from "@/lib/server/public-api-auth";
import type { EnabledImageCapability, GenerationCapability, GenerationJob } from "@/lib/types";
export class PublicApiConflictError extends Error {
status = 409;
constructor(message: string) {
super(message);
this.name = "PublicApiConflictError";
}
}
export type PublicJobCreateBody = {
capability?: GenerationCapability;
prompt?: string;
inputUrls?: string[];
imageUrls?: string[];
inputAssetIds?: string[];
materials?: PromptMaterial[];
promptAssembly?: PromptAssemblyInput;
settings?: SubmitVideoJobInput["settings"];
priority?: number;
webhookUrl?: string;
idempotencyKey?: string;
scale?: number;
width?: number;
height?: number;
min_ratio?: number;
max_ratio?: number;
force_single?: boolean;
resolution?: "4k" | "8k";
seed?: number;
};
export async function createPublicGenerationJob(input: {
client: PublicApiClient;
body: PublicJobCreateBody;
request: Request;
origin: string;
}): Promise<{ job: GenerationJob; reused: boolean }> {
const capability = input.body.capability || "image.generate";
const idempotencyKey = input.request.headers.get("idempotency-key") || input.body.idempotencyKey;
const fingerprint = idempotencyKey ? fingerprintBody(input.body) : undefined;
if (idempotencyKey && fingerprint) {
const existing = await findGenerationJobByIdempotency(input.client.id, idempotencyKey);
if (existing) {
if (existing.idempotencyFingerprint !== fingerprint) {
throw new PublicApiConflictError("Idempotency key was already used with a different request body.");
}
return { job: existing, reused: true };
}
}
const common = {
ownerId: DEFAULT_OWNER_ID,
externalClientId: input.client.id,
idempotencyKey,
idempotencyFingerprint: fingerprint,
priority: normalizePriority(input.body.priority),
webhookUrl: normalizeWebhookUrl(input.body.webhookUrl),
maxAttempts: 3
};
if (capability === "video.generate") {
const job = await submitVideoJob({
...input.body,
...common,
mode: "video",
materials: input.body.materials || input.body.promptAssembly?.materials || []
} as SubmitVideoJobInput, input.origin);
return { job, reused: false };
}
const imageCapability = normalizeImageCapability(capability);
const assembled = input.body.promptAssembly
? assemblePrompt({
...input.body.promptAssembly,
mode: "image",
materials: input.body.materials || input.body.promptAssembly.materials || []
})
: undefined;
const materialImages = (input.body.materials || assembled?.materials || [])
.filter((material) => material.type === "image")
.map((material) => material.url);
const job = await submitImageJob({
...common,
capability: imageCapability,
prompt: input.body.prompt || assembled?.prompt,
imageUrls: input.body.imageUrls || input.body.inputUrls || materialImages,
inputAssetIds: input.body.inputAssetIds || (input.body.materials || []).map((material) => material.id).filter(Boolean) as string[],
scale: asNumber(input.body.scale),
width: asNumber(input.body.width),
height: asNumber(input.body.height),
min_ratio: asNumber(input.body.min_ratio),
max_ratio: asNumber(input.body.max_ratio),
force_single: Boolean(input.body.force_single),
resolution: input.body.resolution,
seed: asNumber(input.body.seed)
} satisfies SubmitImageJobInput, input.origin);
return { job, reused: false };
}
function normalizeImageCapability(capability: GenerationCapability): EnabledImageCapability {
if (capability === "image.generate" || capability === "image.inpaint" || capability === "image.upscale") {
return capability;
}
throw new Error(`Unsupported image capability: ${capability}`);
}
function normalizePriority(value: unknown): number {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return 0;
return Math.max(-100, Math.min(100, Math.trunc(parsed)));
}
function normalizeWebhookUrl(value: unknown): string | undefined {
if (typeof value !== "string" || !value.trim()) return undefined;
const url = new URL(value.trim());
if (!["http:", "https:"].includes(url.protocol)) throw new Error("webhookUrl must be an HTTP or HTTPS URL.");
return url.toString();
}
function asNumber(value: unknown): number | undefined {
if (value === undefined || value === null || value === "") return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
function fingerprintBody(body: PublicJobCreateBody): string {
const { idempotencyKey: _idempotencyKey, ...fingerprintSource } = body;
return createHash("sha256").update(stableStringify(fingerprintSource)).digest("hex");
}
function stableStringify(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
if (value && typeof value === "object") {
return `{${Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`)
.join(",")}}`;
}
return JSON.stringify(value);
}

View File

@@ -0,0 +1,9 @@
import { jsonError } from "@/lib/server/api";
import { PublicApiAuthError } from "@/lib/server/public-api-auth";
import { PublicApiConflictError } from "@/lib/server/public-api-jobs";
export function publicApiError(error: unknown) {
if (error instanceof PublicApiAuthError) return jsonError(error.message, error.status);
if (error instanceof PublicApiConflictError) return jsonError(error.message, error.status);
return jsonError(error);
}

View File

@@ -8,19 +8,19 @@ export function rootDir(): string {
}
export function runtimeDir(): string {
return process.env.NIANXXPLAY_RUNTIME_DIR || join(rootDir(), ".runtime");
return process.env.ZHINIAN_RUNTIME_DIR || join(rootDir(), ".runtime");
}
export function dataDir(): string {
return process.env.NIANXXPLAY_DATA_DIR || join(runtimeDir(), "data");
return process.env.ZHINIAN_DATA_DIR || join(runtimeDir(), "data");
}
export function uploadDir(): string {
return process.env.NIANXXPLAY_UPLOAD_DIR || join(runtimeDir(), "uploads");
return process.env.ZHINIAN_UPLOAD_DIR || join(runtimeDir(), "uploads");
}
export function resultDir(): string {
return process.env.NIANXXPLAY_RESULT_DIR || join(runtimeDir(), "generated-results");
return process.env.ZHINIAN_RESULT_DIR || join(runtimeDir(), "generated-results");
}
export async function ensureRuntimeDirs(): Promise<void> {
@@ -36,7 +36,7 @@ export function localRuntimePath(...parts: string[]): string {
}
export function requestOrigin(request: Request): string {
const configured = process.env.NEXT_PUBLIC_APP_URL || process.env.NIANXXPLAY_PUBLIC_BASE_URL;
const configured = process.env.NEXT_PUBLIC_APP_URL || process.env.ZHINIAN_PUBLIC_BASE_URL;
if (configured) return normalizePublicOrigin(configured);
return normalizePublicOrigin(new URL(request.url).origin);
}

View File

@@ -181,19 +181,20 @@ export async function readLocalServedFile(area: "uploads" | "generated-results",
}
}
export async function readLegacyPublicFile(pathParts: string[]): Promise<{
export async function readAssetForDownload(asset: Asset): Promise<{
bytes: Buffer;
contentType: string;
} | null> {
const filePath = join(process.cwd(), "runtime", "nianxx-play", "public", ...pathParts);
try {
return {
bytes: await readFile(filePath),
contentType: contentTypeForPath(filePath)
};
} catch {
return null;
}
const local = await readLocalAsset(asset);
if (local) return local;
if (!/^https?:\/\//i.test(asset.url)) return null;
const response = await fetch(asset.url);
if (!response.ok) return null;
return {
bytes: Buffer.from(await response.arrayBuffer()),
contentType: response.headers.get("content-type") || contentTypeForPath(new URL(asset.url).pathname)
};
}
export async function deleteStoredAsset(asset: Asset): Promise<void> {
@@ -218,6 +219,30 @@ export async function deleteStoredAsset(asset: Asset): Promise<void> {
}
}
async function readLocalAsset(asset: Asset): Promise<{
bytes: Buffer;
contentType: string;
} | null> {
const localPath = localServedPathParts(asset.storagePath) || localServedPathParts(asset.url);
if (!localPath) return null;
return readLocalServedFile(localPath.area, localPath.pathParts);
}
function localServedPathParts(value?: string): {
area: "uploads" | "generated-results";
pathParts: string[];
} | null {
if (!value) return null;
const clean = value.replace(/^\/+/, "");
if (clean.startsWith("uploads/")) {
return { area: "uploads", pathParts: clean.slice("uploads/".length).split("/").filter(Boolean) };
}
if (clean.startsWith("generated-results/")) {
return { area: "generated-results", pathParts: clean.slice("generated-results/".length).split("/").filter(Boolean) };
}
return null;
}
async function storeBuffer(input: {
bytes: Buffer;
fileName: string;

151
lib/server/task-manager.ts Normal file
View File

@@ -0,0 +1,151 @@
import { randomUUID } from "node:crypto";
import {
claimGenerationJobs,
clearGenerationJobLock,
getGenerationJob,
updateGenerationJob
} from "@/lib/server/data-store";
import { advanceImageJob } from "@/lib/server/generation-service";
import { advanceVideoJob } from "@/lib/server/video-generation-service";
import { requestOrigin } from "@/lib/server/runtime";
import { deliverJobWebhook } from "@/lib/server/webhook";
import type { GenerationJob, GenerationStatus } from "@/lib/types";
export type WorkerTickResult = {
workerId: string;
claimed: number;
jobs: Array<{
id: string;
status: GenerationStatus;
action: "processed" | "retry_scheduled" | "released" | "failed";
error?: string;
}>;
};
const TERMINAL_STATUSES = new Set<GenerationStatus>(["succeeded", "failed", "expired", "cancelled"]);
export async function runWorkerTick(input: {
request?: Request;
origin?: string;
workerId?: string;
limit?: number;
} = {}): Promise<WorkerTickResult> {
const workerId = input.workerId || `worker-${randomUUID()}`;
const origin = input.origin || (input.request ? requestOrigin(input.request) : workerOrigin());
const jobs = await claimGenerationJobs({
workerId,
limit: input.limit || workerBatchSize(),
lockTimeoutMs: workerLockTimeoutMs()
});
const result: WorkerTickResult = {
workerId,
claimed: jobs.length,
jobs: []
};
for (const job of jobs) {
try {
const advanced = await advanceClaimedJob(job, origin);
const settled = await settleAdvancedJob(advanced);
result.jobs.push({
id: settled.job.id,
status: settled.job.status,
action: settled.action
});
} catch (error) {
const failed = await updateGenerationJob(job.id, {
status: "failed",
error: {
message: error instanceof Error ? error.message : String(error),
retryable: true
}
});
const settled = await settleAdvancedJob(failed);
result.jobs.push({
id: settled.job.id,
status: settled.job.status,
action: "failed",
error: error instanceof Error ? error.message : String(error)
});
}
}
return result;
}
async function advanceClaimedJob(job: GenerationJob, origin: string): Promise<GenerationJob> {
if (job.capability === "video.generate") return advanceVideoJob(job.id, origin);
return advanceImageJob(job.id, origin);
}
async function settleAdvancedJob(job: GenerationJob): Promise<{
job: GenerationJob;
action: WorkerTickResult["jobs"][number]["action"];
}> {
const current = await getGenerationJob(job.id) || job;
const now = new Date();
if (current.status === "failed" && canRetry(current)) {
const attempts = (current.attempts || 0) + 1;
const scheduledAt = new Date(now.getTime() + retryDelayMs(attempts)).toISOString();
const retryJob = await clearGenerationJobLock(current.id, {
status: "queued",
attempts,
scheduledAt
}, { clearProviderTaskId: true });
return { job: retryJob, action: "retry_scheduled" };
}
if (TERMINAL_STATUSES.has(current.status)) {
const terminalJob = await clearGenerationJobLock(current.id, {
attempts: current.status === "failed" ? (current.attempts || 0) + 1 : current.attempts,
completedAt: current.completedAt || now.toISOString()
});
const webhook = await deliverJobWebhook(terminalJob);
if (webhook.lastStatus) {
const withWebhook = await updateGenerationJob(terminalJob.id, {
webhookAttempts: webhook.attempts,
webhookLastStatus: webhook.lastStatus
});
return { job: withWebhook, action: "processed" };
}
return { job: terminalJob, action: "processed" };
}
const scheduledAt = new Date(now.getTime() + workerPollIntervalMs()).toISOString();
const released = await clearGenerationJobLock(current.id, { scheduledAt });
return { job: released, action: "released" };
}
function canRetry(job: GenerationJob): boolean {
const attempts = job.attempts || 0;
const maxAttempts = job.maxAttempts || 3;
return Boolean(job.error?.retryable) && attempts < maxAttempts;
}
function retryDelayMs(attempts: number): number {
const base = readPositiveInt("ZHINIAN_WORKER_RETRY_BASE_MS", 10_000);
const max = readPositiveInt("ZHINIAN_WORKER_RETRY_MAX_MS", 5 * 60 * 1000);
return Math.min(max, base * 2 ** Math.max(0, attempts - 1));
}
function workerPollIntervalMs(): number {
return readPositiveInt("ZHINIAN_WORKER_POLL_INTERVAL_MS", 5_000);
}
function workerLockTimeoutMs(): number {
return readPositiveInt("ZHINIAN_WORKER_LOCK_TIMEOUT_MS", 5 * 60 * 1000);
}
function workerBatchSize(): number {
return Math.max(1, Math.min(readPositiveInt("ZHINIAN_WORKER_BATCH_SIZE", 3), 20));
}
function workerOrigin(): string {
return (process.env.NEXT_PUBLIC_APP_URL || process.env.ZHINIAN_PUBLIC_BASE_URL || "http://127.0.0.1:3000").replace(/\/$/, "");
}
function readPositiveInt(name: string, fallback: number): number {
const parsed = Number(process.env[name]);
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
}

View File

@@ -14,10 +14,16 @@ import { normalizeVideoDuration, normalizeVideoRatio, normalizeVideoResolution }
export type SubmitVideoJobInput = PromptAssemblyInput & {
ownerId?: string;
externalClientId?: string;
prompt?: string;
settings?: SeedanceSettings;
materials?: PromptMaterial[];
retryOf?: string;
idempotencyKey?: string;
idempotencyFingerprint?: string;
priority?: number;
maxAttempts?: number;
webhookUrl?: string;
};
export async function submitVideoJob(input: SubmitVideoJobInput, origin: string): Promise<GenerationJob> {
@@ -36,6 +42,7 @@ export async function submitVideoJob(input: SubmitVideoJobInput, origin: string)
const mock = shouldMockSeedance();
let job = await createGenerationJob({
ownerId,
externalClientId: input.externalClientId,
capability: "video.generate",
provider: mock ? "mock" : "seedance",
reqKey: config.model,
@@ -49,16 +56,38 @@ export async function submitVideoJob(input: SubmitVideoJobInput, origin: string)
assembled,
settings
},
retryOf: input.retryOf
retryOf: input.retryOf,
idempotencyKey: input.idempotencyKey,
idempotencyFingerprint: input.idempotencyFingerprint,
priority: input.priority,
maxAttempts: input.maxAttempts,
webhookUrl: input.webhookUrl
});
if (mock) return completeMockVideoJob(job);
return job;
}
export async function advanceVideoJob(jobId: string, origin: string): Promise<GenerationJob> {
const job = await getGenerationJob(jobId);
if (!job) throw new Error(`Generation job not found: ${jobId}`);
if (["succeeded", "failed", "cancelled", "expired"].includes(job.status)) return job;
if (job.provider === "mock") return completeMockVideoJob(job);
if (!job.providerTaskId) return dispatchVideoJob(job, origin);
return syncVideoJob(job.id, origin);
}
async function dispatchVideoJob(job: GenerationJob, origin: string): Promise<GenerationJob> {
try {
const input = asRecord(job.requestPayload.input) as SubmitVideoJobInput;
const assembled = asRecord(job.requestPayload.assembled);
const settings = asRecord(job.requestPayload.settings) as SeedanceSettings;
const materials = Array.isArray(assembled.materials)
? assembled.materials as PromptMaterial[]
: input.materials || [];
const response = await createSeedanceTask({
prompt: finalPrompt,
prompt: job.prompt || "",
settings,
materials: assembled.materials,
materials,
origin
});
return updateGenerationJob(job.id, {
@@ -140,7 +169,7 @@ async function completeMockVideoJob(job: GenerationJob): Promise<GenerationJob>
ownerId: job.ownerId,
kind: "video",
name: `mock-video-${job.id}.mp4`,
url: "/seedance-starter-assets/music_sync_ad/2-3-9-1/result.mp4",
url: "/mock/seedance-mock.mp4",
source: "generated",
tags: ["video.generate", "mock"],
metadata: {
@@ -168,3 +197,9 @@ async function completeMockVideoJob(job: GenerationJob): Promise<GenerationJob>
}
});
}
function asRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}

71
lib/server/webhook.ts Normal file
View File

@@ -0,0 +1,71 @@
import { createHmac } from "node:crypto";
import type { GenerationJob, WebhookLastStatus } from "@/lib/types";
export type JobWebhookPayload = {
jobId: string;
status: GenerationJob["status"];
capability: GenerationJob["capability"];
outputAssetIds: string[];
error?: GenerationJob["error"];
updatedAt: string;
};
const MAX_WEBHOOK_ATTEMPTS = 3;
export function buildJobWebhookPayload(job: GenerationJob): JobWebhookPayload {
return {
jobId: job.id,
status: job.status,
capability: job.capability,
outputAssetIds: job.outputAssetIds,
error: job.error,
updatedAt: job.updatedAt
};
}
export function signWebhookBody(body: string, secret = process.env.ZHINIAN_WEBHOOK_SECRET): string | undefined {
if (!secret?.trim()) return undefined;
return `sha256=${createHmac("sha256", secret.trim()).update(body).digest("hex")}`;
}
export async function deliverJobWebhook(job: GenerationJob): Promise<{
attempts: number;
lastStatus?: WebhookLastStatus;
}> {
if (!job.webhookUrl) return { attempts: job.webhookAttempts || 0 };
let attempts = job.webhookAttempts || 0;
let lastStatus: WebhookLastStatus | undefined = job.webhookLastStatus;
const payload = buildJobWebhookPayload(job);
const body = JSON.stringify(payload);
const signature = signWebhookBody(body);
while (attempts < MAX_WEBHOOK_ATTEMPTS) {
attempts += 1;
const attemptedAt = new Date().toISOString();
try {
const response = await fetch(job.webhookUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": "zhinian-aigc-webhook/1.0",
...(signature ? { "X-Zhinian-Signature": signature } : {})
},
body
});
lastStatus = {
ok: response.ok,
status: response.status,
attemptedAt
};
if (response.ok) break;
} catch (error) {
lastStatus = {
ok: false,
error: error instanceof Error ? error.message : String(error),
attemptedAt
};
}
}
return { attempts, lastStatus };
}