ai-chatbot/lib/video-canvas/task-recovery.ts

114 lines
3.7 KiB
TypeScript

import type { SmartEditCheckpoint } from "./smart-edit/execute";
import { audioCheckpointSchema } from "./audio-contract";
import { videoCheckpointSchema } from "./video-task-lifecycle";
// Resume only server-recorded provider jobs. A lost request without an upstream ID is never resubmitted.
export type RecoveryTask = {
status: string;
createdAt: string;
updatedAt?: string;
attempts?: number;
request: { kind: string; params: Record<string, unknown> };
result?: Record<string, any> | null;
};
export function getEnhanceCheckpoint(
task: Pick<RecoveryTask, "request" | "result">
) {
const upstream = task.result?.upstreamTask;
if (
task.request.kind !== "image-operation" ||
task.request.params.operation !== "enhance" ||
upstream?.operation !== "enhance" ||
typeof upstream.taskId !== "string" ||
!upstream.taskId.trim() ||
upstream.sourceImageUrl !== task.request.params.imageUrl
)
return null;
return {
operation: "enhance" as const,
taskId: upstream.taskId,
sourceImageUrl: String(upstream.sourceImageUrl),
};
}
export function getVideoCheckpoint(
task: Pick<RecoveryTask, "request" | "result">
) {
if (task.request.kind !== "video") return null;
const parsed = videoCheckpointSchema.safeParse(task.result?.upstreamTask);
if (!parsed.success) return null;
const preferred = task.request.params.preferredModelRef;
if (
preferred &&
preferred !== "auto" &&
preferred !== parsed.data.modelRef &&
preferred !== parsed.data.modelId
)
return null;
return parsed.data;
}
export function getAudioCheckpoint(
task: Pick<RecoveryTask, "request" | "result">
) {
if (task.request.kind !== "audio") return null;
const parsed = audioCheckpointSchema.safeParse(task.result?.upstreamAudio);
return parsed.success && parsed.data.modelRef === task.request.params.modelRef
? parsed.data
: null;
}
export function getSmartEditCheckpoint(
task: Pick<RecoveryTask, "request" | "result">
): SmartEditCheckpoint | null {
const value = task.result?.smartEdit;
if (
task.request.kind !== "smart-edit" ||
value?.operation !== "smart-edit" ||
value.action !== task.request.params.operation ||
!["local", "analyzed", "selected", "remote-render"].includes(value.phase)
)
return null;
if (
value.phase === "remote-render" &&
(value.action !== "render" ||
!/^[a-f0-9]{64}$/.test(value.remote?.id || "") ||
!/^[a-f0-9]{64}$/.test(value.remote?.endpointHash || ""))
)
return null;
return value as SmartEditCheckpoint;
}
export function hasRecoverableCanvasTask(task: RecoveryTask, now = Date.now()) {
const created = Date.parse(task.createdAt);
return (
Boolean(
getEnhanceCheckpoint(task) ||
getVideoCheckpoint(task) ||
getAudioCheckpoint(task) ||
getSmartEditCheckpoint(task) ||
getUpscaleCheckpoint(task)
) &&
Number.isFinite(created) &&
now >= created &&
now - created <= 86400000 &&
(task.attempts || 0) < 20
);
}
export function canResumeCanvasTask(task: RecoveryTask, now = Date.now()) {
if (task.status !== "interrupted" || !hasRecoverableCanvasTask(task, now))
return false;
const updated = Date.parse(task.updatedAt || "");
const delay = Math.min(
300000,
30000 * 2 ** Math.min(4, Math.max(0, (task.attempts || 1) - 1))
);
return Number.isFinite(updated) && now - updated >= delay;
}
export function getUpscaleCheckpoint(
task: Pick<RecoveryTask, "request" | "result">
) {
const value = task.result?.upscale?.remote;
return task.request.kind === "video-upscale" &&
/^[a-f0-9]{64}$/.test(value?.id || "") &&
/^[a-f0-9]{64}$/.test(value?.endpointHash || "")
? (value as { id: string; endpointHash: string })
: null;
}