Files
20260512-skg-tk/web/lib/api.ts
2026-05-13 14:44:00 +08:00

347 lines
11 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 KeyFrame {
index: number
timestamp: number
url: string
description?: FrameDescription | null
cleaned_url?: string | null
cleaned_applied?: boolean
elements?: KeyElement[]
generated_images?: GeneratedImage[]
}
export interface TranscriptSegment {
index: number
start: number
end: number
en: string
zh: string
}
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[]
error?: string
}
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 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 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 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()
}