312 lines
9.3 KiB
TypeScript
312 lines
9.3 KiB
TypeScript
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
|
||
cutout_id?: string | null
|
||
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): string {
|
||
return `${API_BASE}/jobs/${jobId}/frames/${frameIndex}/elements/${elementId}/cutout.jpg`
|
||
}
|
||
|
||
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 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,
|
||
background: "white" | "black" = "white",
|
||
): Promise<Job> {
|
||
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements/${elementId}/cutout`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ background }),
|
||
})
|
||
if (!res.ok) {
|
||
const txt = await res.text().catch(() => "")
|
||
throw new Error(`cutout ${res.status} ${txt.slice(0, 300)}`)
|
||
}
|
||
return res.json()
|
||
}
|