Files
20260512-skg-tk/web/lib/api.ts
2026-05-17 19:32:19 +08:00

954 lines
28 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
transparent_human_assessment?: TransparentHumanFrameScore
}
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"
subject_kind?: SubjectKind
subject_assets?: SubjectAsset[]
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 ProductFusionRegion {
x: number
y: number
w: number
h: number
}
export interface ProductFusionShot {
id: string
first_image?: ImageRef | null
last_image?: ImageRef | null
product_images?: ImageRef[]
product_image?: ImageRef | null
character_id?: string
character_name?: string
subject_image?: ImageRef | null
subject_images?: ImageRef[]
person_image?: ImageRef | null
product_region?: ProductFusionRegion | null
scene_image?: ImageRef | null
action_text?: string
duration?: number
image_model?: "gpt-image-2"
video_model?: "seedance"
guide_image?: ImageRef | null
}
export interface StoryboardScene {
duration: number
first_image?: ImageRef | null
last_image?: ImageRef | null
product_images?: ImageRef[]
product_fusion_shots?: ProductFusionShot[]
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 async function generateProductAngleAsset(
jobId: string,
body: { source_ref: ImageRef; target_view: string; note?: string },
): Promise<ImageRef> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/assets/product-angle`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`generateProductAngleAsset ${res.status} ${txt.slice(0, 300)}`)
}
return res.json()
}
export interface ProductViewAnalysisItem {
index: number
view: string
background?: string
use_tags?: string[]
note: string
risk?: string
confidence: number
}
export async function analyzeProductViews(
jobId: string,
refs: ImageRef[],
): Promise<{ items: ProductViewAnalysisItem[]; missing_views: string[] }> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/assets/product-views/analyze`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refs }),
})
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`analyzeProductViews ${res.status} ${txt.slice(0, 300)}`)
}
return res.json()
}
export async function listProductLibrary(): Promise<ProductLibraryItem[]> {
const res = await fetch(`${API_BASE}/product-library/skg`)
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`listProductLibrary ${res.status} ${txt.slice(0, 300)}`)
}
return res.json()
}
export async function copyProductLibraryAsset(jobId: string, productId: string): Promise<ImageRef> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/assets/product-library`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ product_id: productId }),
})
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`copyProductLibraryAsset ${res.status} ${txt.slice(0, 300)}`)
}
return res.json()
}
export async function listCharacterLibrary(): Promise<CharacterLibraryItem[]> {
const res = await fetch(`${API_BASE}/character-library/skg`)
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`listCharacterLibrary ${res.status} ${txt.slice(0, 300)}`)
}
return res.json()
}
export function characterLibraryImageUrl(filename: string): string {
return `${API_BASE}/character-library/skg/images/${filename}`
}
export async function copyCharacterLibraryAssets(jobId: string, characterId: string): Promise<{ character_id: string; character_name: string; images: ImageRef[] }> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/assets/character-library`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ character_id: characterId }),
})
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`copyCharacterLibraryAssets ${res.status} ${txt.slice(0, 300)}`)
}
return res.json()
}
export async function createProductFusionGuide(
jobId: string,
body: ProductFusionShot,
): Promise<ImageRef> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/product-fusion/guide`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`createProductFusionGuide ${res.status} ${txt.slice(0, 300)}`)
}
return res.json()
}
export async function generateProductFusionDescriptions(
jobId: string,
shots: ProductFusionShot[],
): Promise<{ descriptions: string[]; mode: "llm" | "fallback" }> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/product-fusion/descriptions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ shots }),
})
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`generateProductFusionDescriptions ${res.status} ${txt.slice(0, 300)}`)
}
return res.json()
}
export interface KeyFrame {
index: number
timestamp: number
url: string
description?: FrameDescription | null
transparent_human_score?: TransparentHumanFrameScore | null
cleaned_url?: string | null
cleaned_applied?: boolean
quality_report?: QualityReport | null
scene_assets?: SceneAsset[]
elements?: KeyElement[]
storyboard?: StoryboardScene | null
generated_images?: GeneratedImage[]
}
export type FrameExtractTarget = "transparent_human" | "balanced" | "subject" | "transition" | "expression" | "motion"
export type FrameExtractMode = "replace" | "append"
export type FrameExtractQuality = "auto" | "fast" | "accurate" | "ultra"
export type AssetBackground = "white" | "black"
export type AssetSize = "source" | "1024" | "1536" | "2048"
export type SubjectKind = "object" | "living"
export type SubjectView = string
export type SceneMode = "remove_subject" | "similar" | "style"
export type SceneStyle = "source" | "premium_product" | "clean_studio" | "warm_lifestyle" | "cinematic"
export type SceneAssetRole = "scene" | "first_frame" | "last_frame"
export interface QualityReport {
width: number
height: number
short_side: number
sharpness: number
risk: "ok" | "warn" | "bad"
warnings: string[]
}
export interface TransparentHumanFrameScore {
transparent_body_score: number
skeleton_visible_score: number
human_prominence_score: number
clarity_score: number
commercial_style_score: number
product_usefulness_score: number
total_score?: number
qualified?: boolean
reject_reason?: string
notes?: string
}
export interface SceneAsset {
id: string
label: string
url: string
width: number
height: number
quality: "hd"
size: AssetSize
scene_mode?: SceneMode
scene_style?: SceneStyle
asset_role?: SceneAssetRole
quality_report?: QualityReport | null
created_at: number
}
export interface SubjectAsset {
id: string
view: SubjectView
label: string
url: string
width: number
height: number
background: AssetBackground
quality: "hd"
size: AssetSize
source_frame_indices?: number[]
ai_completed?: boolean
created_at: number
}
export interface ProductLibraryItem {
id: string
handle: string
title: string
product_type: string
image_type: string
image_index: number
filename: string
url: string
width: number
height: number
source_path: string
white_score: number
near_white_score: number
has_people: boolean
tags: string[]
}
export interface CharacterLibraryImage {
id: string
view: string
label: string
filename: string
url: string
width: number
height: number
source_path: string
}
export interface CharacterLibraryItem {
id: string
name: string
folder: string
description: string
primary_image: string
images: CharacterLibraryImage[]
}
export interface TranscriptSegment {
index: number
start: number
end: number
en: string
zh: string
}
export interface AudioScript {
status: "idle" | "rewriting" | "completed" | "failed"
source_text: string
source_zh: string
rewritten_text: string
speaker_profile: string
rhythm_profile: string
background_audio_profile: string
product_brief: string
rewrite_model: string
voice_provider: string
voice_model: string
voice_id: string
voice_url: string
error: string
created_at: number
}
export interface StoryboardImage {
ref_id: string
kind: "keyframe" | "cutout" | "asset"
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
source_audio_url?: string
frames: KeyFrame[]
transcript: TranscriptSegment[]
audio_script?: AudioScript
storyboard_images?: StoryboardImage[]
generated_videos?: GeneratedVideo[]
error?: string
}
export interface BackendHealth {
ok: boolean
llm_configured: boolean
auth_configured?: boolean
base_url: string
models?: {
asr?: string
translate?: string
rewrite?: string
audio_rewrite?: string
minimax_tts?: string
minimax_voice?: string
minimax_configured?: boolean
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 = 12,
target: FrameExtractTarget = "balanced",
mode: FrameExtractMode = "replace",
quality: FrameExtractQuality = "auto",
): Promise<Job> {
const qs = new URLSearchParams({ frames: String(frames), target, mode, quality })
const res = await fetch(`${API_BASE}/jobs/${id}/analyze?${qs.toString()}`, { 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 sourceAudioUrl(jobId: string): string {
return `${API_BASE}/jobs/${jobId}/audio.wav`
}
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`
}
export function jobAssetUrl(jobId: string, assetId: string): string {
return `${API_BASE}/jobs/${jobId}/assets/${assetId}.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" | "asset"; 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_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()
}
export async function generateSceneAsset(
jobId: string,
frameIdx: number,
body: {
size?: AssetSize
scene_mode?: SceneMode
scene_style?: SceneStyle
asset_role?: SceneAssetRole
prompt?: string
source_frame_indices?: number[]
} = {},
): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/scene-asset`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
quality: "hd",
size: body.size ?? "source",
scene_mode: body.scene_mode ?? "remove_subject",
scene_style: body.scene_style ?? "source",
asset_role: body.asset_role ?? "scene",
prompt: body.prompt ?? "",
source_frame_indices: body.source_frame_indices ?? null,
}),
})
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`sceneAsset ${res.status} ${txt.slice(0, 300)}`)
}
return res.json()
}
export async function generateSubjectAssets(
jobId: string,
frameIdx: number,
elementId: string,
body: {
subject_kind?: SubjectKind
background?: AssetBackground
size?: AssetSize
source_frame_indices?: number[]
views?: string[]
} = {},
): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements/${elementId}/subject-assets`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
quality: "hd",
subject_kind: body.subject_kind ?? "object",
background: body.background ?? "white",
size: body.size ?? "source",
source_frame_indices: body.source_frame_indices ?? null,
views: body.views ?? null,
}),
})
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`subjectAssets ${res.status} ${txt.slice(0, 300)}`)
}
return res.json()
}