1261 lines
36 KiB
TypeScript
1261 lines
36 KiB
TypeScript
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:4291"
|
||
|
||
function apiError(prefix: string, status: number, text: string) {
|
||
let detail = text
|
||
try {
|
||
const parsed = JSON.parse(text)
|
||
detail = parsed?.detail || text
|
||
} catch {
|
||
detail = text
|
||
}
|
||
return new Error(`${prefix} ${status} ${String(detail).slice(0, 300)}`)
|
||
}
|
||
|
||
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[]
|
||
subject_consensus_brief?: string
|
||
subject_consensus_brief_zh?: string
|
||
created_at?: number
|
||
}
|
||
|
||
export interface ImageRef {
|
||
kind: "keyframe" | "cutout" | "asset"
|
||
frame_idx: number
|
||
element_id?: string | null
|
||
cutout_id?: string | null
|
||
label?: string
|
||
asset_meta?: {
|
||
standard?: string
|
||
original_width?: number
|
||
original_height?: number
|
||
width?: number
|
||
height?: number
|
||
original_bytes?: number
|
||
work_bytes?: number
|
||
max_side?: number
|
||
min_long_side?: number
|
||
min_short_side?: number
|
||
quality?: number
|
||
actions?: string[]
|
||
warnings?: string[]
|
||
normalized?: boolean
|
||
}
|
||
}
|
||
|
||
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[]
|
||
subject_images?: ImageRef[]
|
||
product_fusion_shots?: ProductFusionShot[]
|
||
visual_mode?: "person_only" | "person_product" | "product_only" | "environment"
|
||
needs_product?: boolean
|
||
needs_subject?: boolean
|
||
subject_brief?: string
|
||
first_frame_plan?: string
|
||
last_frame_plan?: string
|
||
product_placement?: string
|
||
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
|
||
}
|
||
|
||
export interface RuntimeModels {
|
||
asr?: string
|
||
local_asr?: string
|
||
asr_fallback?: string
|
||
translate?: string
|
||
rewrite?: string
|
||
audio_rewrite?: string
|
||
vision?: string
|
||
product_view?: string
|
||
image?: string
|
||
image_base_url?: string
|
||
image_fallbacks?: string[]
|
||
subject_image?: string
|
||
subject_image_fallbacks?: string[]
|
||
voice_provider?: string
|
||
voice_base_url?: string
|
||
voice_tts?: string
|
||
voice_id?: string
|
||
voice_pool?: string[]
|
||
voice_configured?: boolean
|
||
voice_tts_paths?: string[]
|
||
video?: string
|
||
video_aliases?: Record<string, string>
|
||
video_provider?: string
|
||
video_base_url?: string
|
||
video_configured?: boolean
|
||
video_create_paths?: string[]
|
||
}
|
||
|
||
export interface RuntimeHealth {
|
||
ok: boolean
|
||
llm_configured?: boolean
|
||
auth_configured?: boolean
|
||
base_url?: string
|
||
models?: RuntimeModels
|
||
}
|
||
|
||
export async function getRuntimeHealth(): Promise<RuntimeHealth> {
|
||
const res = await fetch(`${API_BASE}/health`, { cache: "no-store" })
|
||
if (!res.ok) {
|
||
const txt = await res.text().catch(() => "")
|
||
throw new Error(`health ${res.status} ${txt.slice(0, 300)}`)
|
||
}
|
||
return res.json()
|
||
}
|
||
|
||
// 把 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; source_refs?: ImageRef[]; source_notes?: string[]; 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 apiError("generateProductAngleAsset", res.status, txt)
|
||
}
|
||
return res.json()
|
||
}
|
||
|
||
export interface StoryboardScriptRewriteSegment {
|
||
index: number
|
||
start: number
|
||
end: number
|
||
role: string
|
||
source: string
|
||
current_text: string
|
||
}
|
||
|
||
export async function rewriteStoryboardScript(
|
||
jobId: string,
|
||
body: {
|
||
mode: "segment" | "all"
|
||
author_intent?: string
|
||
segments: StoryboardScriptRewriteSegment[]
|
||
},
|
||
): Promise<{ items: Array<{ index: number; text: string; text_zh?: string }> }> {
|
||
const res = await fetch(`${API_BASE}/jobs/${jobId}/script/rewrite`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(body),
|
||
})
|
||
if (!res.ok) {
|
||
const txt = await res.text().catch(() => "")
|
||
throw new Error(`rewriteStoryboardScript ${res.status} ${txt.slice(0, 300)}`)
|
||
}
|
||
return res.json()
|
||
}
|
||
|
||
export interface ProductViewAnalysisItem {
|
||
index: number
|
||
view: string
|
||
background?: string
|
||
use_tags?: string[]
|
||
orientation?: {
|
||
product_left?: string
|
||
product_right?: string
|
||
top?: string
|
||
bottom?: string
|
||
inner_side?: string
|
||
outer_side?: string
|
||
opening_direction?: string
|
||
}
|
||
landmarks?: 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 saveProductRefs(jobId: string, items: ProductRefStateItem[]): Promise<Job> {
|
||
const res = await fetch(`${API_BASE}/jobs/${jobId}/product-refs`, {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ items }),
|
||
})
|
||
if (!res.ok) {
|
||
const txt = await res.text().catch(() => "")
|
||
throw new Error(`saveProductRefs ${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 listSubjectTemplates(): Promise<SubjectTemplateItem[]> {
|
||
const res = await fetch(`${API_BASE}/subject-templates`)
|
||
if (!res.ok) {
|
||
const txt = await res.text().catch(() => "")
|
||
throw new Error(`listSubjectTemplates ${res.status} ${txt.slice(0, 300)}`)
|
||
}
|
||
return res.json()
|
||
}
|
||
|
||
export function subjectTemplateImageUrl(filename: string): string {
|
||
return `${API_BASE}/subject-templates/images/${filename}`
|
||
}
|
||
|
||
export async function saveSubjectTemplate(
|
||
jobId: string,
|
||
body: {
|
||
name: string
|
||
note?: string
|
||
frame_idx: number
|
||
element_id: string
|
||
asset_ids: string[]
|
||
subject_style?: "transparent_human" | "source_actor"
|
||
},
|
||
): Promise<SubjectTemplateItem> {
|
||
const res = await fetch(`${API_BASE}/jobs/${jobId}/subject-templates`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
name: body.name,
|
||
note: body.note ?? "",
|
||
frame_idx: body.frame_idx,
|
||
element_id: body.element_id,
|
||
asset_ids: body.asset_ids,
|
||
subject_style: body.subject_style ?? "transparent_human",
|
||
}),
|
||
})
|
||
if (!res.ok) {
|
||
const txt = await res.text().catch(() => "")
|
||
throw new Error(`saveSubjectTemplate ${res.status} ${txt.slice(0, 300)}`)
|
||
}
|
||
return res.json()
|
||
}
|
||
|
||
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 SubjectProfilePreference {
|
||
mode?: "random" | "manual"
|
||
gender?: string
|
||
age?: string
|
||
wardrobe?: string
|
||
region_ethnicity?: string
|
||
skin_tone?: string
|
||
body?: string
|
||
hair?: string
|
||
mood?: string
|
||
resolved_summary?: string
|
||
prompt_summary?: string
|
||
}
|
||
|
||
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
|
||
prompt_brief?: string
|
||
prompt_brief_zh?: string
|
||
primary_image: string
|
||
images: CharacterLibraryImage[]
|
||
}
|
||
|
||
export interface SubjectTemplateImage {
|
||
id: string
|
||
view: string
|
||
label: string
|
||
filename: string
|
||
url: string
|
||
width: number
|
||
height: number
|
||
background: "white" | "black"
|
||
quality: "hd"
|
||
size: "source" | "1024" | "1536" | "2048"
|
||
source_asset_id: string
|
||
source_frame_indices: number[]
|
||
created_at: number
|
||
}
|
||
|
||
export interface SubjectTemplateItem {
|
||
id: string
|
||
name: string
|
||
description: string
|
||
note: string
|
||
prompt_brief?: string
|
||
prompt_brief_zh?: string
|
||
source: "database"
|
||
source_job_id: string
|
||
source_frame_idx: number
|
||
source_element_id: string
|
||
subject_style: "transparent_human" | "source_actor"
|
||
primary_image: string
|
||
images: SubjectTemplateImage[]
|
||
created_at: number
|
||
updated_at: number
|
||
}
|
||
|
||
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
|
||
rewritten_text_zh?: 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 ProductRefStateItem {
|
||
id: string
|
||
ref: ImageRef
|
||
view: string
|
||
background: string
|
||
useTags: string[]
|
||
orientation?: ProductViewAnalysisItem["orientation"]
|
||
landmarks: string[]
|
||
note: string
|
||
risk: string
|
||
source: "upload" | "ai" | "library"
|
||
assetMeta?: ImageRef["asset_meta"]
|
||
confidence?: 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[]
|
||
product_refs?: ProductRefStateItem[]
|
||
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
|
||
voice_tts_paths?: 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 function isRestrictedDownloadError(error?: string | null): boolean {
|
||
const text = (error ?? "").toLowerCase()
|
||
return (
|
||
text.includes("tiktok 下载需要登录态") ||
|
||
text.includes("log in for access") ||
|
||
text.includes("cookies-from-browser") ||
|
||
text.includes("ytdlp_cookies_file") ||
|
||
(text.includes("tiktok") && text.includes("cookies"))
|
||
)
|
||
}
|
||
|
||
export function formatJobError(error?: string | null): string {
|
||
if (!error) return ""
|
||
if (isRestrictedDownloadError(error)) {
|
||
return "这个 TikTok 视频需要登录态。请上传 MP4,或让后端配置 YTDLP_COOKIES_FROM_BROWSER / YTDLP_COOKIES_FILE 后重试。"
|
||
}
|
||
return error
|
||
}
|
||
|
||
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 retryJobDownload(id: string): Promise<Job> {
|
||
const res = await fetch(`${API_BASE}/jobs/${id}/download/retry`, { method: "POST" })
|
||
if (!res.ok) {
|
||
const text = await res.text().catch(() => "")
|
||
throw apiError("retryJobDownload", res.status, text)
|
||
}
|
||
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 的 URL(v2 取最新一张,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; subject_consensus_brief?: 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
|
||
subject_brief?: string
|
||
source_frame_indices?: number[]
|
||
subject_images?: ImageRef[]
|
||
product_images?: ImageRef[]
|
||
} = {},
|
||
): 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 ?? "",
|
||
subject_brief: body.subject_brief ?? "",
|
||
source_frame_indices: body.source_frame_indices ?? null,
|
||
subject_images: body.subject_images ?? [],
|
||
product_images: body.product_images ?? [],
|
||
}),
|
||
})
|
||
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[]
|
||
character_id?: string
|
||
subject_template_id?: string
|
||
subject_style?: "transparent_human" | "source_actor"
|
||
reconstruction_mode?: "same" | "similar"
|
||
subject_profile?: SubjectProfilePreference | null
|
||
prompt?: string
|
||
replace_views?: boolean
|
||
} = {},
|
||
): 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,
|
||
character_id: body.character_id ?? "",
|
||
subject_template_id: body.subject_template_id ?? "",
|
||
subject_style: body.subject_style ?? "transparent_human",
|
||
reconstruction_mode: body.reconstruction_mode ?? "same",
|
||
subject_profile: body.subject_profile ?? null,
|
||
prompt: body.prompt ?? "",
|
||
replace_views: body.replace_views ?? false,
|
||
}),
|
||
})
|
||
if (!res.ok) {
|
||
const txt = await res.text().catch(() => "")
|
||
throw apiError("subjectAssets", res.status, txt)
|
||
}
|
||
return res.json()
|
||
}
|
||
|
||
export async function deleteSubjectAsset(
|
||
jobId: string,
|
||
frameIdx: number,
|
||
elementId: string,
|
||
assetId: string,
|
||
): Promise<Job> {
|
||
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements/${elementId}/subject-assets/${assetId}`, {
|
||
method: "DELETE",
|
||
})
|
||
if (!res.ok) {
|
||
const txt = await res.text().catch(() => "")
|
||
throw new Error(`deleteSubjectAsset ${res.status} ${txt.slice(0, 300)}`)
|
||
}
|
||
return res.json()
|
||
}
|