auto-save 2026-05-13 20:29 (~9)
This commit is contained in:
@@ -17,8 +17,8 @@ import { StoryboardBar } from "@/components/storyboard-bar"
|
||||
import { StoryboardWorkbench } from "@/components/storyboard-workbench"
|
||||
import {
|
||||
addManualFrame, analyzeJob, createJob, getJob, uploadJob, deleteFrame, deleteGeneratedImage,
|
||||
effectiveFrameUrl, resolveImageRefUrl,
|
||||
type Job, type ImageRef, type StoryboardScene, type GeneratedVideoDraft,
|
||||
generateStoryboardVideo,
|
||||
type Job, type ImageRef, type StoryboardScene,
|
||||
} from "@/lib/api"
|
||||
import { VideoLightbox } from "@/components/video-lightbox"
|
||||
|
||||
@@ -77,7 +77,6 @@ export default function Home() {
|
||||
const [storyboardFrame, setStoryboardFrame] = useState<number | null>(null)
|
||||
const [workbenchOpen, setWorkbenchOpen] = useState(false)
|
||||
const [clipboard, setClipboard] = useState<ImageRef | null>(null)
|
||||
const [videoDrafts, setVideoDrafts] = useState<GeneratedVideoDraft[]>([])
|
||||
const flowRef = useRef<any>(null)
|
||||
|
||||
// 把 setJob(prev=>...) 翻译成 setJobs 里更新当前 active
|
||||
@@ -106,7 +105,6 @@ export default function Home() {
|
||||
const handleSwitchJob = useCallback((id: string) => {
|
||||
setActiveJobId(id)
|
||||
setSelectedFrames(new Set())
|
||||
setVideoDrafts([])
|
||||
}, [])
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
@@ -217,14 +215,12 @@ export default function Home() {
|
||||
toast.success(`已复制:${ref.label || (ref.kind === "keyframe" ? "关键帧" : "元素")} · 到分镜头编排工作台粘贴`)
|
||||
}, [])
|
||||
|
||||
const handleQuickGenerateVideo = useCallback((frameIdx: number, scene: StoryboardScene) => {
|
||||
const handleQuickGenerateVideo = useCallback(async (frameIdx: number, scene: StoryboardScene, model: string) => {
|
||||
if (!job) return
|
||||
const frame = job.frames.find((f) => f.index === frameIdx)
|
||||
if (!frame) return
|
||||
|
||||
const labelOf = (ref?: ImageRef | null, fallback = "未提供") => ref?.label || fallback
|
||||
const posterRef = scene.product_image ?? scene.subject_image ?? scene.scene_image ?? scene.action_image ?? null
|
||||
const posterUrl = posterRef ? resolveImageRefUrl(job.id, posterRef) : effectiveFrameUrl(job.id, frame)
|
||||
const duration = scene.duration && scene.duration > 0 ? scene.duration : 5
|
||||
const prompt = [
|
||||
`Vertical 9:16 short product video for SKG, ${duration.toFixed(1)} seconds.`,
|
||||
@@ -240,21 +236,25 @@ export default function Home() {
|
||||
"High quality realistic commercial video, clean background, no captions, no platform UI, no TikTok watermark, no extra text.",
|
||||
].join("\n")
|
||||
|
||||
const draft: GeneratedVideoDraft = {
|
||||
id: `quick-${frameIdx}-${Date.now().toString(36)}`,
|
||||
frame_idx: frameIdx,
|
||||
label: `分镜 ${frameIdx + 1} · 快速视频`,
|
||||
prompt,
|
||||
provider: "Quick Prompt",
|
||||
poster_url: posterUrl,
|
||||
duration,
|
||||
created_at: Date.now(),
|
||||
status: "ready",
|
||||
try {
|
||||
toast.info(`已提交 ${model} 生视频 · 分镜 ${frameIdx + 1}`)
|
||||
const updated = await generateStoryboardVideo(job.id, frameIdx, {
|
||||
prompt,
|
||||
duration,
|
||||
subject_image: scene.subject_image ?? null,
|
||||
scene_image: scene.scene_image ?? null,
|
||||
product_image: scene.product_image ?? null,
|
||||
action_image: scene.action_image ?? null,
|
||||
model,
|
||||
size: "720x1280",
|
||||
})
|
||||
setJob(updated)
|
||||
void navigator.clipboard?.writeText(prompt).catch(() => {})
|
||||
toast.success("视频任务已进入 Video Gen 节点")
|
||||
} catch (e) {
|
||||
toast.error("提交视频失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
}
|
||||
setVideoDrafts((prev) => [draft, ...prev.filter((x) => x.id !== draft.id)].slice(0, 8))
|
||||
void navigator.clipboard?.writeText(prompt).catch(() => {})
|
||||
toast.success("已生成视频 prompt · 已显示到 Video Gen 节点")
|
||||
}, [job])
|
||||
}, [job, setJob])
|
||||
|
||||
// URL ?job=xxx,yyy 自动恢复多个 job
|
||||
useEffect(() => {
|
||||
@@ -308,8 +308,9 @@ export default function Home() {
|
||||
}
|
||||
prevStatusRef.current = job.status
|
||||
|
||||
const runningVideo = !!job.generated_videos?.some((v) => v.status === "queued" || v.status === "in_progress")
|
||||
const TERMINAL: Job["status"][] = ["downloaded", "frames_extracted", "transcribed", "failed"]
|
||||
if (TERMINAL.includes(job.status)) {
|
||||
if (TERMINAL.includes(job.status) && !runningVideo) {
|
||||
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null }
|
||||
return
|
||||
}
|
||||
@@ -320,7 +321,7 @@ export default function Home() {
|
||||
} catch { /* silent */ }
|
||||
}, 1500)
|
||||
return () => { if (pollRef.current) clearInterval(pollRef.current) }
|
||||
}, [job?.id, job?.status])
|
||||
}, [job?.id, job?.status, job?.generated_videos?.map((v) => `${v.id}:${v.status}:${v.progress}`).join("|")])
|
||||
|
||||
const nodeData: NodeData = useMemo(() => ({
|
||||
job,
|
||||
@@ -332,7 +333,6 @@ export default function Home() {
|
||||
expandedFrame,
|
||||
framePanelScale,
|
||||
framePanelPinned,
|
||||
videoDrafts,
|
||||
onSubmitUrl: handleSubmit,
|
||||
onUploadFile: handleUpload,
|
||||
onAnalyze: handleAnalyze,
|
||||
@@ -354,7 +354,7 @@ export default function Home() {
|
||||
setWorkbenchOpen(true)
|
||||
},
|
||||
onCopyImage: handleCopyImage,
|
||||
}), [job, jobs, activeJobId, submitting, analyzing, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, videoDrafts, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleOpenFramePanel, handleFramePanelScaleChange, handleAddManualFrame, handleSwitchJob, setJob, handleDeleteFrame, handleDeleteGenerated, handleCopyImage])
|
||||
}), [job, jobs, activeJobId, submitting, analyzing, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleOpenFramePanel, handleFramePanelScaleChange, handleAddManualFrame, handleSwitchJob, setJob, handleDeleteFrame, handleDeleteGenerated, handleCopyImage])
|
||||
|
||||
// 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag)
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node>(
|
||||
|
||||
@@ -623,8 +623,8 @@ export const Dashboard = forwardRef<DashboardHandle, Props>(function Dashboard({
|
||||
{/* ---- VideoGen — Kanban ---- */}
|
||||
{key === "videogen" && (
|
||||
<>
|
||||
<KanbanCard tone="violet" tags={["SKG 网关"]} title="Sora 2">
|
||||
<div className="text-[11px] text-[var(--text-soft)]">/v1/videos 待开通(IT)</div>
|
||||
<KanbanCard tone="violet" tags={["SKG 网关"]} title="Seedance / Kling / Veo 3">
|
||||
<div className="text-[11px] text-[var(--text-soft)]">通过 /v1/videos 网关提交,模型 ID 走环境变量映射</div>
|
||||
</KanbanCard>
|
||||
<KanbanCard tone="violet" tags={["外部"]} title="Seedance">
|
||||
<div className="text-[11px] text-[var(--text-soft)]">字节跳动 · 需独立 API key</div>
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
} from "lucide-react"
|
||||
import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell"
|
||||
import {
|
||||
type Job, type ImageRef, type GeneratedVideoDraft,
|
||||
effectiveFrameUrl, videoUrl, hasCutout, representativeCutoutUrl,
|
||||
type Job, type ImageRef,
|
||||
apiAssetUrl, effectiveFrameUrl, videoUrl, hasCutout, representativeCutoutUrl,
|
||||
} from "@/lib/api"
|
||||
import { FrameLightbox } from "@/components/lightbox"
|
||||
|
||||
@@ -23,7 +23,6 @@ export interface NodeData {
|
||||
expandedFrame: number | null
|
||||
framePanelScale?: number
|
||||
framePanelPinned?: boolean
|
||||
videoDrafts?: GeneratedVideoDraft[]
|
||||
onSubmitUrl: (url: string) => void
|
||||
onUploadFile: (file: File) => void
|
||||
onAnalyze: () => void
|
||||
@@ -943,22 +942,39 @@ export function StoryboardNode({ data, selected }: any) {
|
||||
============================================================ */
|
||||
export function VideoGenNode({ data, selected }: any) {
|
||||
const d: NodeData = data
|
||||
const drafts = d.videoDrafts ?? []
|
||||
const status: NodeStatus = drafts.length > 0 ? "done" : "pending"
|
||||
const videos = d.job?.generated_videos ?? []
|
||||
const running = videos.some((v) => v.status === "queued" || v.status === "in_progress")
|
||||
const completed = videos.filter((v) => v.status === "completed" && v.url)
|
||||
const failed = videos.some((v) => v.status === "failed")
|
||||
const status: NodeStatus = running ? "running" : completed.length > 0 ? "done" : failed ? "failed" : "pending"
|
||||
const aspect = d.job && (d.job.width ?? 0) > 0 && (d.job.height ?? 0) > 0
|
||||
? `${d.job.width}/${d.job.height}`
|
||||
: "9/16"
|
||||
const modelLabel = (model: string) => {
|
||||
const m = model.toLowerCase()
|
||||
if (m.includes("kling")) return "Kling"
|
||||
if (m.includes("veo")) return "Veo 3"
|
||||
if (m.includes("seedance")) return "Seedance"
|
||||
return model || "Video"
|
||||
}
|
||||
return (
|
||||
<div className="relative" style={{ width: 280 }}>
|
||||
{drafts.length > 0 && (
|
||||
{videos.length > 0 && (
|
||||
<div
|
||||
className="absolute left-0 right-0 grid grid-cols-3 gap-1.5"
|
||||
style={{ bottom: "calc(100% + 12px)" }}
|
||||
>
|
||||
{drafts.slice(0, 6).map((v, i) => (
|
||||
{videos.slice(0, 6).map((v, i) => {
|
||||
const videoSrc = apiAssetUrl(v.url)
|
||||
const posterSrc = apiAssetUrl(v.poster_url)
|
||||
const ready = v.status === "completed" && !!videoSrc
|
||||
const progress = Math.max(0, Math.min(100, v.progress || 0))
|
||||
return (
|
||||
<div
|
||||
key={v.id}
|
||||
className="group relative rounded-md border border-rose-300/55 transition shadow-lg hover:-translate-y-0.5 bg-black"
|
||||
className={`group relative rounded-md border transition shadow-lg hover:-translate-y-0.5 bg-black ${
|
||||
ready ? "border-emerald-300/60" : v.status === "failed" ? "border-rose-300/70" : "border-violet-300/55"
|
||||
}`}
|
||||
style={{ aspectRatio: aspect }}
|
||||
>
|
||||
<button
|
||||
@@ -967,17 +983,44 @@ export function VideoGenNode({ data, selected }: any) {
|
||||
e.stopPropagation()
|
||||
void navigator.clipboard?.writeText(v.prompt).catch(() => {})
|
||||
}}
|
||||
title={`${v.label} · 点击复制视频 prompt`}
|
||||
title={`分镜 ${v.frame_idx + 1} · ${modelLabel(v.model)} · 点击复制 prompt`}
|
||||
className="absolute inset-0 w-full h-full overflow-hidden rounded-md bg-black"
|
||||
>
|
||||
<img
|
||||
src={v.poster_url}
|
||||
alt={v.label}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
{ready ? (
|
||||
<video
|
||||
src={videoSrc}
|
||||
poster={posterSrc}
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="metadata"
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
onMouseEnter={(e) => (e.target as HTMLVideoElement).play().catch(() => {})}
|
||||
onMouseLeave={(e) => {
|
||||
const el = e.target as HTMLVideoElement
|
||||
el.pause()
|
||||
el.currentTime = 0
|
||||
}}
|
||||
/>
|
||||
) : posterSrc ? (
|
||||
<img src={posterSrc} alt="" className="absolute inset-0 h-full w-full object-cover opacity-75" />
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-violet-950/50" />
|
||||
)}
|
||||
{!ready && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/35">
|
||||
{v.status === "failed" ? (
|
||||
<X className="h-4 w-4 text-rose-200" />
|
||||
) : (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-white/85" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/90 to-transparent px-1.5 py-1 text-left">
|
||||
<div className="truncate text-[9.5px] font-semibold text-white">视频 {i + 1}</div>
|
||||
<div className="truncate text-[8.5px] font-mono text-white/60">{v.duration.toFixed(1)}s</div>
|
||||
<div className="truncate text-[8.5px] font-mono text-white/60">
|
||||
{ready ? `${v.duration.toFixed(0)}s` : v.status === "failed" ? "failed" : `${progress}%`}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
@@ -991,28 +1034,34 @@ export function VideoGenNode({ data, selected }: any) {
|
||||
>
|
||||
<div className="rounded-lg overflow-hidden border-2 border-rose-300/60 bg-black shadow-2xl" style={{ width: 300 }}>
|
||||
<div style={{ aspectRatio: aspect }}>
|
||||
<img src={v.poster_url} alt="" className="w-full h-full object-cover" />
|
||||
{ready ? (
|
||||
<video src={videoSrc} poster={posterSrc} muted loop autoPlay playsInline controls className="h-full w-full object-cover" />
|
||||
) : posterSrc ? (
|
||||
<img src={posterSrc} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="h-full w-full bg-violet-950/60" />
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1 bg-black/90 px-2 py-1.5 text-white">
|
||||
<div className="flex items-center justify-between gap-2 text-[10.5px]">
|
||||
<span className="truncate">{v.label}</span>
|
||||
<span className="shrink-0 font-mono text-white/55">{v.provider}</span>
|
||||
<span className="truncate">分镜 {v.frame_idx + 1}</span>
|
||||
<span className="shrink-0 font-mono text-white/55">{modelLabel(v.model)} · {v.status}</span>
|
||||
</div>
|
||||
<div className="line-clamp-3 text-[9.5px] leading-snug text-white/55">
|
||||
{v.prompt}
|
||||
{v.status === "failed" ? (v.error || "生成失败") : v.prompt}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)})}
|
||||
</div>
|
||||
)}
|
||||
<NodeShell
|
||||
type="ai" status={status}
|
||||
icon={<Film className="h-4 w-4" />}
|
||||
title="生成视频 · Video Gen"
|
||||
subtitle={`STEP 7 · 首帧 + 动作 prompt${drafts.length > 0 ? ` · ${drafts.length} 个任务` : ""}`}
|
||||
subtitle={`STEP 7 · 首帧 + 动作 prompt${videos.length > 0 ? ` · ${videos.length} 个视频任务` : ""}`}
|
||||
selected={selected}
|
||||
>
|
||||
<div className="grid grid-cols-3 gap-1.5 text-[10.5px]">
|
||||
@@ -1022,9 +1071,9 @@ export function VideoGenNode({ data, selected }: any) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{drafts.length > 0 && (
|
||||
{videos.length > 0 && (
|
||||
<div className="mt-2 rounded-md border border-rose-300/25 bg-rose-500/10 px-2 py-1.5 text-[10.5px] text-[var(--text-soft)]">
|
||||
已生成 {drafts.length} 条视频 prompt · 点上方卡片复制
|
||||
已提交 {videos.length} 个视频任务 · 完成 {completed.length} 个{running ? " · 生成中" : ""}
|
||||
</div>
|
||||
)}
|
||||
</NodeShell>
|
||||
|
||||
@@ -15,13 +15,19 @@ interface Props {
|
||||
onJobUpdate?: (j: Job) => void
|
||||
clipboard: ImageRef | null // 全局剪贴板(page.tsx 提供)
|
||||
focusedFrame: number | null
|
||||
onGenerateVideo?: (frameIdx: number, scene: StoryboardScene) => void
|
||||
onGenerateVideo?: (frameIdx: number, scene: StoryboardScene, model: string) => Promise<void> | void
|
||||
}
|
||||
|
||||
const emptyScene = (): StoryboardScene => ({
|
||||
subject: "", product: "", scene: "", action: "", duration: 0, reference_ids: [],
|
||||
})
|
||||
|
||||
const VIDEO_MODELS = [
|
||||
{ value: "seedance", label: "Seedance" },
|
||||
{ value: "kling", label: "Kling" },
|
||||
{ value: "veo3", label: "Veo 3" },
|
||||
] as const
|
||||
|
||||
export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobUpdate, clipboard, focusedFrame, onGenerateVideo }: Props) {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => setMounted(true), [])
|
||||
@@ -31,6 +37,8 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [savedTick, setSavedTick] = useState(0)
|
||||
const [panelHeight, setPanelHeight] = useState(320)
|
||||
const [videoModel, setVideoModel] = useState<(typeof VIDEO_MODELS)[number]["value"]>("seedance")
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// Esc 关闭
|
||||
@@ -115,6 +123,7 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
|
||||
}
|
||||
|
||||
const hasVideoRefs = !!(form.subject_image || form.scene_image || form.product_image || form.action_image)
|
||||
const currentModelLabel = VIDEO_MODELS.find((m) => m.value === videoModel)?.label ?? "Seedance"
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -271,23 +280,48 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 快速生成:先产出视频 prompt / 任务卡,结果显示到 Video Gen 节点 */}
|
||||
{/* 快速生成:直接调用生视频 API,结果显示到 Video Gen 节点 */}
|
||||
<section>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div className="text-[12.5px] font-semibold text-white">生成视频</div>
|
||||
<div className="flex items-center gap-1 rounded-md border border-white/10 bg-black/35 p-0.5">
|
||||
{VIDEO_MODELS.map((m) => (
|
||||
<button
|
||||
key={m.value}
|
||||
type="button"
|
||||
onClick={() => setVideoModel(m.value)}
|
||||
className={`h-6 rounded px-2 text-[10.5px] transition ${
|
||||
videoModel === m.value
|
||||
? "bg-violet-500 text-white shadow"
|
||||
: "text-white/50 hover:bg-white/10 hover:text-white"
|
||||
}`}
|
||||
title={`使用 ${m.label} 生成视频`}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
disabled={!hasVideoRefs || focusedIdx === null}
|
||||
onClick={() => {
|
||||
disabled={!hasVideoRefs || focusedIdx === null || generating}
|
||||
onClick={async () => {
|
||||
if (focusedIdx === null) return
|
||||
queueSave(form)
|
||||
onGenerateVideo?.(focusedIdx, form)
|
||||
setGenerating(true)
|
||||
try {
|
||||
await onGenerateVideo?.(focusedIdx, form, videoModel)
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}}
|
||||
className="w-full py-3 rounded-lg text-[13.5px] font-semibold inline-flex items-center justify-center gap-2 bg-gradient-to-r from-rose-500 to-violet-500 text-white border border-violet-300/40 shadow-lg shadow-violet-500/20 hover:from-rose-400 hover:to-violet-400 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title={hasVideoRefs ? "根据当前 4 图槽和改造目标生成视频 prompt,并推送到 Video Gen 节点" : "先粘贴至少一张参考图"}
|
||||
title={hasVideoRefs ? `调用 ${currentModelLabel} 生视频 API,结果进入 Video Gen 节点` : "先粘贴至少一张参考图"}
|
||||
>
|
||||
<Wand2 className="h-4 w-4" />
|
||||
快速生成视频
|
||||
{generating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Wand2 className="h-4 w-4" />}
|
||||
调用 {currentModelLabel} 生成视频
|
||||
</button>
|
||||
<div className="mt-2 text-[10.5px] text-white/35 leading-relaxed">
|
||||
当前先生成可交付的视频 prompt / 任务卡并显示到 Video Gen 节点;后续接 Seedance / Kling / Veo 3 时直接替换为真实视频输出。
|
||||
用当前 4 图槽、改造目标和时长提交生视频 API;生成中的进度和完成后的 MP4 会显示在 Video Gen 节点。
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -69,16 +69,19 @@ export interface StoryboardScene {
|
||||
reference_ids?: string[]
|
||||
}
|
||||
|
||||
export interface GeneratedVideoDraft {
|
||||
export interface GeneratedVideo {
|
||||
id: string
|
||||
provider_id?: string
|
||||
frame_idx: number
|
||||
label: string
|
||||
prompt: string
|
||||
provider: "Quick Prompt" | "Seedance" | "Kling" | "Veo 3"
|
||||
poster_url: string
|
||||
model: string
|
||||
status: "queued" | "in_progress" | "completed" | "failed"
|
||||
url?: string
|
||||
poster_url?: string
|
||||
duration: number
|
||||
progress: number
|
||||
error?: string
|
||||
created_at: number
|
||||
status: "ready" | "queued" | "failed"
|
||||
}
|
||||
|
||||
// 把 ImageRef 解析成可显示的 src URL
|
||||
@@ -139,9 +142,16 @@ export interface Job {
|
||||
frames: KeyFrame[]
|
||||
transcript: TranscriptSegment[]
|
||||
storyboard_images?: StoryboardImage[]
|
||||
generated_videos?: GeneratedVideo[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
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 createJob(tkUrl: string): Promise<Job> {
|
||||
const res = await fetch(`${API_BASE}/jobs`, {
|
||||
method: "POST",
|
||||
@@ -341,6 +351,32 @@ export async function updateStoryboard(
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function generateStoryboardVideo(
|
||||
jobId: string,
|
||||
frameIdx: number,
|
||||
body: {
|
||||
prompt: string
|
||||
duration?: number
|
||||
subject_image?: ImageRef | null
|
||||
scene_image?: ImageRef | null
|
||||
product_image?: ImageRef | null
|
||||
action_image?: ImageRef | 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(() => "")
|
||||
throw new Error(`generateStoryboardVideo ${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",
|
||||
|
||||
Reference in New Issue
Block a user