Files
20260512-skg-tk/web/lib/api.ts
2026-05-14 02:58:36 +08:00

593 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:4291"
export type JobStatus =
| "created"
| "downloading"
| "downloaded"
| "splitting"
| "frames_extracted"
| "transcribing"
| "transcribed"
| "failed"
export interface FrameObject {
name: string
position?: string
color?: string
extract_prompt?: string
}
export interface FrameDescription {
scene?: string
objects?: FrameObject[]
style?: string
suggested_prompt?: string
}
export interface GeneratedImage {
id: string
prompt: string
model: string
mode: string
url: string
selected: boolean
created_at: number
}
export interface KeyElement {
id: string
name_zh: string
name_en: string
position?: string
source: "auto" | "manual" | "region"
region?: { x: number; y: number; w: number; h: number } | null
cutouts?: string[] // v2 多张提取图 id 列表
cutout_id?: string | null // v1 兼容字段
cutout_background?: "white" | "black"
created_at?: number
}
export interface ImageRef {
kind: "keyframe" | "cutout" | "asset"
frame_idx: number
element_id?: string | null
cutout_id?: string | null
label?: string
}
export interface StoryboardScene {
duration: number
first_image?: ImageRef | null
last_image?: ImageRef | null
product_images?: ImageRef[]
subject_image?: ImageRef | null
scene_image?: ImageRef | null
product_image?: ImageRef | null
action_image?: ImageRef | null
// v1 兼容
subject?: string
product?: string
scene?: string
action?: string
reference_ids?: string[]
}
export interface GeneratedVideo {
id: string
provider_id?: string
frame_idx: number
prompt: string
model: string
status: "queued" | "in_progress" | "completed" | "failed"
url?: string
poster_url?: string
duration: number
progress: number
error?: string
created_at: number
}
// 把 ImageRef 解析成可显示的 src URL
export function resolveImageRefUrl(jobId: string, ref: ImageRef): string {
if (ref.kind === "keyframe") {
return effectiveFrameUrl(jobId, { index: ref.frame_idx, cleaned_applied: false })
}
if (ref.kind === "asset" && ref.element_id) {
return `${API_BASE}/jobs/${jobId}/assets/${ref.element_id}.jpg`
}
if (ref.element_id && ref.cutout_id) {
if (ref.cutout_id === ref.element_id) {
// legacy v1
return cutoutUrl(jobId, ref.frame_idx, ref.element_id)
}
return cutoutUrl(jobId, ref.frame_idx, ref.element_id, ref.cutout_id)
}
return ""
}
export async function uploadStoryboardAsset(jobId: string, file: File): Promise<ImageRef> {
const fd = new FormData()
fd.append("file", file)
const res = await fetch(`${API_BASE}/jobs/${jobId}/assets`, { method: "POST", body: fd })
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`uploadStoryboardAsset ${res.status} ${txt.slice(0, 300)}`)
}
return res.json()
}
export interface KeyFrame {
index: number
timestamp: number
url: string
description?: FrameDescription | null
cleaned_url?: string | null
cleaned_applied?: boolean
elements?: KeyElement[]
storyboard?: StoryboardScene | null
generated_images?: GeneratedImage[]
}
export interface TranscriptSegment {
index: number
start: number
end: number
en: string
zh: string
}
export interface StoryboardImage {
ref_id: string
kind: "keyframe" | "cutout"
frame_idx: number
element_id?: string | null
cutout_id?: string | null
label?: string
created_at?: number
}
export interface Job {
id: string
url: string
status: JobStatus
progress: number
message?: string
video_url?: string
duration?: number
width?: number
height?: number
frames: KeyFrame[]
transcript: TranscriptSegment[]
storyboard_images?: StoryboardImage[]
generated_videos?: GeneratedVideo[]
error?: string
}
export interface BackendHealth {
ok: boolean
llm_configured: boolean
base_url: string
models?: {
asr?: string
translate?: string
rewrite?: string
video?: string
video_aliases?: Record<string, string>
video_base_url?: string
video_configured?: boolean
}
}
export function apiAssetUrl(path?: string | null): string {
if (!path) return ""
if (/^https?:\/\//i.test(path)) return path
return `${API_BASE}${path.startsWith("/") ? "" : "/"}${path}`
}
export async function getHealth(): Promise<BackendHealth> {
const res = await fetch(`${API_BASE}/health`)
if (!res.ok) throw new Error(`health ${res.status}`)
return res.json()
}
export async function createJob(tkUrl: string): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: tkUrl }),
})
if (!res.ok) throw new Error(`createJob ${res.status}`)
return res.json()
}
export async function uploadJob(file: File): Promise<Job> {
const fd = new FormData()
fd.append("file", file)
const res = await fetch(`${API_BASE}/jobs/upload`, {
method: "POST",
body: fd,
})
if (!res.ok) {
const text = await res.text().catch(() => "")
throw new Error(`uploadJob ${res.status} ${text.slice(0, 200)}`)
}
return res.json()
}
export async function getJob(id: string): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs/${id}`)
if (!res.ok) throw new Error(`getJob ${res.status}`)
return res.json()
}
export async function deleteJob(id: string): Promise<{ ok: boolean; id: string }> {
const res = await fetch(`${API_BASE}/jobs/${id}`, { method: "DELETE" })
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`deleteJob ${res.status} ${txt.slice(0, 200)}`)
}
return res.json()
}
export interface JobSummary {
id: string
url: string
status: JobStatus
progress: number
message: string
duration: number
width: number
height: number
video_url: string
frame_count: number
video_count: number
thumbnail: string
error: string
mtime: number
}
export async function listJobs(limit?: number): Promise<JobSummary[]> {
const qs = limit && limit > 0 ? `?limit=${limit}` : ""
const res = await fetch(`${API_BASE}/jobs${qs}`)
if (!res.ok) throw new Error(`listJobs ${res.status}`)
return res.json()
}
export async function triggerTranscribe(id: string): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs/${id}/transcribe`, { method: "POST" })
if (!res.ok) throw new Error(`transcribe ${res.status}`)
return res.json()
}
export async function analyzeJob(id: string, frames = 5): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs/${id}/analyze?frames=${frames}`, { method: "POST" })
if (!res.ok) {
const t = await res.text().catch(() => "")
throw new Error(`analyze ${res.status} ${t.slice(0, 200)}`)
}
return res.json()
}
export async function addManualFrame(id: string, t: number): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs/${id}/frames?t=${encodeURIComponent(t.toFixed(2))}`, { method: "POST" })
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`addFrame ${res.status} ${txt.slice(0, 200)}`)
}
return res.json()
}
export async function describeFrame(jobId: string, frameIdx: number): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/describe`, { method: "POST" })
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`describe ${res.status} ${txt.slice(0, 200)}`)
}
return res.json()
}
export async function translateText(text: string, target: "en" | "zh" = "en"): Promise<string> {
const res = await fetch(`${API_BASE}/translate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text, target }),
})
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`translate ${res.status} ${txt.slice(0, 200)}`)
}
const j = await res.json()
return (j.text || "").toString()
}
export async function generateImage(
jobId: string,
frameIdx: number,
body: { prompt: string; extra_prompt?: string; negative_prompt?: string; model?: string; mode?: "edit" | "text"; from_selected?: boolean },
): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`generate ${res.status} ${txt.slice(0, 300)}`)
}
return res.json()
}
export async function selectGenerated(
jobId: string,
frameIdx: number,
genId: string,
selected: boolean,
): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/gen/${genId}/select`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ selected }),
})
if (!res.ok) throw new Error(`select ${res.status}`)
return res.json()
}
export function generatedImageUrl(jobId: string, frameIdx: number, genId: string): string {
return `${API_BASE}/jobs/${jobId}/frames/${frameIdx}/gen/${genId}.jpg`
}
export function frameUrl(jobId: string, frameIndex: number): string {
return `${API_BASE}/jobs/${jobId}/frames/${frameIndex}.jpg`
}
// 接 frame 对象时返回正确版本 URL已应用清洗版时加 cache-bust
export function effectiveFrameUrl(jobId: string, frame: { index: number; cleaned_applied?: boolean | null }): string {
const base = `${API_BASE}/jobs/${jobId}/frames/${frame.index}.jpg`
return frame.cleaned_applied ? `${base}?v=applied` : base
}
export function videoUrl(jobId: string): string {
return `${API_BASE}/jobs/${jobId}/video.mp4`
}
export function cleanedFrameUrl(jobId: string, frameIndex: number, bust?: string | number): string {
const u = `${API_BASE}/jobs/${jobId}/frames/${frameIndex}/cleaned.jpg`
return bust ? `${u}?t=${bust}` : u
}
export function cutoutUrl(jobId: string, frameIndex: number, elementId: string, cutoutId?: string): string {
if (cutoutId) {
return `${API_BASE}/jobs/${jobId}/frames/${frameIndex}/elements/${elementId}/cutouts/${cutoutId}.jpg`
}
return `${API_BASE}/jobs/${jobId}/frames/${frameIndex}/elements/${elementId}/cutout.jpg`
}
// 兼容 v1 (cutout_id) / v2 (cutouts 数组) — 返回"有没有提取图"
export function hasCutout(e: KeyElement): boolean {
return (Array.isArray(e.cutouts) && e.cutouts.length > 0) || !!e.cutout_id
}
// 返回代表性 cutout 的 URLv2 取最新一张v1 用旧路径)
export function representativeCutoutUrl(
jobId: string,
frameIndex: number,
e: KeyElement,
): string | null {
if (Array.isArray(e.cutouts) && e.cutouts.length > 0) {
return cutoutUrl(jobId, frameIndex, e.id, e.cutouts[e.cutouts.length - 1])
}
if (e.cutout_id) return cutoutUrl(jobId, frameIndex, e.id)
return null
}
export async function pushStoryboardImage(
jobId: string,
body: { kind: "keyframe" | "cutout"; frame_idx: number; element_id?: string | null; cutout_id?: string | null; label?: string },
): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/storyboard-images`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`pushStoryboardImage ${res.status} ${txt.slice(0, 300)}`)
}
return res.json()
}
export async function removeStoryboardImage(jobId: string, refId: string): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/storyboard-images/${refId}`, { method: "DELETE" })
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`removeStoryboardImage ${res.status} ${txt.slice(0, 300)}`)
}
return res.json()
}
export async function updateStoryboard(
jobId: string,
frameIdx: number,
body: StoryboardScene,
): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/storyboard`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`updateStoryboard ${res.status} ${txt.slice(0, 300)}`)
}
return res.json()
}
export async function generateStoryboardVideo(
jobId: string,
frameIdx: number,
body: {
prompt: string
duration?: number
first_image?: ImageRef | null
last_image?: ImageRef | null
product_images?: ImageRef[]
subject_image?: ImageRef | null
scene_image?: ImageRef | null
product_image?: ImageRef | null
action_image?: ImageRef | null
source_ref?: { kind: "image" | "source_video"; url: string } | null
model?: string
size?: string
},
): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/storyboard/video`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
if (!res.ok) {
const txt = await res.text().catch(() => "")
let detail = txt
try {
const parsed = JSON.parse(txt)
detail = parsed?.detail || txt
} catch {}
throw new Error(detail || `generateStoryboardVideo ${res.status}`)
}
return res.json()
}
export async function deleteGeneratedVideo(jobId: string, videoId: string): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/storyboard-videos/${videoId}`, { method: "DELETE" })
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`deleteGeneratedVideo ${res.status} ${txt.slice(0, 300)}`)
}
return res.json()
}
export async function deleteCutout(jobId: string, frameIdx: number, elementId: string, cutoutId: string): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements/${elementId}/cutouts/${cutoutId}`, {
method: "DELETE",
})
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`deleteCutout ${res.status} ${txt.slice(0, 300)}`)
}
return res.json()
}
export async function cleanupFrame(
jobId: string,
frameIdx: number,
regions?: Array<{ x: number; y: number; w: number; h: number }> | null,
): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/cleanup`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ regions: regions ?? null }),
})
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`cleanup ${res.status} ${txt.slice(0, 300)}`)
}
return res.json()
}
export async function applyCleanedFrame(jobId: string, frameIdx: number): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/cleanup/apply`, { method: "POST" })
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`applyCleaned ${res.status} ${txt.slice(0, 300)}`)
}
return res.json()
}
export async function discardCleanedFrame(jobId: string, frameIdx: number): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/cleanup`, { method: "DELETE" })
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`discardCleaned ${res.status} ${txt.slice(0, 300)}`)
}
return res.json()
}
export async function addElement(
jobId: string,
frameIdx: number,
body: {
name_zh: string
name_en?: string
position?: string
source?: "auto" | "manual" | "region"
region?: { x: number; y: number; w: number; h: number } | null
},
): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source: "manual", ...body }),
})
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`addElement ${res.status} ${txt.slice(0, 300)}`)
}
return res.json()
}
export async function deleteElement(jobId: string, frameIdx: number, elementId: string): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements/${elementId}`, { method: "DELETE" })
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`deleteElement ${res.status} ${txt.slice(0, 300)}`)
}
return res.json()
}
export async function updateElement(
jobId: string,
frameIdx: number,
elementId: string,
body: { name_zh?: string; name_en?: string; position?: string },
): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements/${elementId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`updateElement ${res.status} ${txt.slice(0, 300)}`)
}
return res.json()
}
export async function deleteFrame(jobId: string, frameIdx: number): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}`, { method: "DELETE" })
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`deleteFrame ${res.status} ${txt.slice(0, 300)}`)
}
return res.json()
}
export async function deleteGeneratedImage(jobId: string, frameIdx: number, genId: string): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/gen/${genId}`, { method: "DELETE" })
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`deleteGen ${res.status} ${txt.slice(0, 300)}`)
}
return res.json()
}
export async function cutoutElement(jobId: string, frameIdx: number, elementId: string): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements/${elementId}/cutout`, {
method: "POST",
})
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`cutout ${res.status} ${txt.slice(0, 300)}`)
}
return res.json()
}