auto-save 2026-05-13 20:29 (~9)
This commit is contained in:
@@ -2308,6 +2308,13 @@
|
|||||||
"type": "session-heartbeat",
|
"type": "session-heartbeat",
|
||||||
"message": "Codex 会话活跃 · 最近命令:codex · 2 项未提交变更 · 最近提交:auto-save 2026-05-13 20:18 (~4)",
|
"message": "Codex 会话活跃 · 最近命令:codex · 2 项未提交变更 · 最近提交:auto-save 2026-05-13 20:18 (~4)",
|
||||||
"files_changed": 2
|
"files_changed": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-05-13T20:23:53+08:00",
|
||||||
|
"type": "commit",
|
||||||
|
"message": "auto-save 2026-05-13 20:23 (~2)",
|
||||||
|
"hash": "989cc91",
|
||||||
|
"files_changed": 2
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ ASR_MODEL=whisper-1
|
|||||||
TRANSLATE_MODEL=gemini-2.5-flash
|
TRANSLATE_MODEL=gemini-2.5-flash
|
||||||
REWRITE_MODEL=gemini-2.5-pro
|
REWRITE_MODEL=gemini-2.5-pro
|
||||||
IMAGE_MODEL=gemini-3-pro-image-preview
|
IMAGE_MODEL=gemini-3-pro-image-preview
|
||||||
VIDEO_MODEL=sora-2
|
VIDEO_MODEL=seedance
|
||||||
|
VIDEO_MODEL_SEEDANCE=seedance
|
||||||
|
VIDEO_MODEL_KLING=kling
|
||||||
|
VIDEO_MODEL_VEO3=veo3
|
||||||
|
VIDEO_DURATION_FIELD=seconds
|
||||||
|
|
||||||
# 工作目录
|
# 工作目录
|
||||||
JOBS_DIR=./jobs
|
JOBS_DIR=./jobs
|
||||||
|
|||||||
157
api/main.py
157
api/main.py
@@ -1584,6 +1584,66 @@ def video_seconds(duration: float) -> str:
|
|||||||
return "12"
|
return "12"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_video_model(raw: str | None) -> str:
|
||||||
|
requested = (raw or VIDEO_MODEL or "seedance").strip()
|
||||||
|
lowered = requested.lower()
|
||||||
|
if lowered in {"sora", "sora-2", "sora_2"}:
|
||||||
|
raise HTTPException(400, "Sora 已停用,请选择 Seedance / Kling / Veo 3")
|
||||||
|
return VIDEO_MODEL_ALIASES.get(lowered, requested)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_video_status(status: str | None) -> Literal["queued", "in_progress", "completed", "failed"]:
|
||||||
|
s = (status or "queued").lower()
|
||||||
|
if s in {"completed", "complete", "succeeded", "success", "done"}:
|
||||||
|
return "completed"
|
||||||
|
if s in {"failed", "failure", "error", "cancelled", "canceled", "expired"}:
|
||||||
|
return "failed"
|
||||||
|
if s in {"running", "processing", "in_progress", "generating", "started"}:
|
||||||
|
return "in_progress"
|
||||||
|
return "queued"
|
||||||
|
|
||||||
|
|
||||||
|
def video_progress(data: dict, fallback: int) -> int:
|
||||||
|
raw = data.get("progress", data.get("percentage", data.get("percent", fallback)))
|
||||||
|
try:
|
||||||
|
value = int(float(raw))
|
||||||
|
except Exception:
|
||||||
|
value = fallback
|
||||||
|
return max(0, min(100, value))
|
||||||
|
|
||||||
|
|
||||||
|
def video_url_from_response(data: dict) -> str:
|
||||||
|
for key in ("url", "video_url", "output_url", "download_url"):
|
||||||
|
v = data.get(key)
|
||||||
|
if isinstance(v, str) and v:
|
||||||
|
return v
|
||||||
|
arr = data.get("data")
|
||||||
|
if isinstance(arr, list) and arr:
|
||||||
|
first = arr[0]
|
||||||
|
if isinstance(first, dict):
|
||||||
|
for key in ("url", "video_url", "output_url", "download_url"):
|
||||||
|
v = first.get(key)
|
||||||
|
if isinstance(v, str) and v:
|
||||||
|
return v
|
||||||
|
output = data.get("output")
|
||||||
|
if isinstance(output, dict):
|
||||||
|
for key in ("url", "video_url", "download_url"):
|
||||||
|
v = output.get(key)
|
||||||
|
if isinstance(v, str) and v:
|
||||||
|
return v
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def download_generated_video(client, base: str, headers: dict, provider_id: str, direct_url: str, out_mp4: Path) -> None:
|
||||||
|
if direct_url:
|
||||||
|
url = direct_url if direct_url.startswith("http") else f"{base}{direct_url if direct_url.startswith('/') else '/' + direct_url}"
|
||||||
|
r = client.get(url, headers=headers if url.startswith(base) else None)
|
||||||
|
else:
|
||||||
|
r = client.get(f"{base}/videos/{provider_id}/content", headers=headers)
|
||||||
|
r.raise_for_status()
|
||||||
|
out_mp4.write_bytes(r.content)
|
||||||
|
|
||||||
|
|
||||||
def render_storyboard_video(job_id: str, local_id: str, provider_id: str, ref_path: Path, prompt: str, model: str, seconds: str, size: str) -> None:
|
def render_storyboard_video(job_id: str, local_id: str, provider_id: str, ref_path: Path, prompt: str, model: str, seconds: str, size: str) -> None:
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@@ -1594,55 +1654,52 @@ def render_storyboard_video(job_id: str, local_id: str, provider_id: str, ref_pa
|
|||||||
headers = {"Authorization": f"Bearer {LLM_API_KEY}"}
|
headers = {"Authorization": f"Bearer {LLM_API_KEY}"}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
prepare_video_reference(ref_path, ref_img)
|
prepare_video_reference(ref_path, ref_img)
|
||||||
update_generated_video(job_id, local_id, status="in_progress", progress=5)
|
update_generated_video(job_id, local_id, status="in_progress", progress=5)
|
||||||
with httpx.Client(timeout=120) as client:
|
with httpx.Client(timeout=120) as client:
|
||||||
with ref_img.open("rb") as fh:
|
payload = {"model": model, "prompt": prompt, "size": size}
|
||||||
create = client.post(
|
payload[VIDEO_DURATION_FIELD] = seconds
|
||||||
f"{base}/videos",
|
with ref_img.open("rb") as fh:
|
||||||
headers=headers,
|
create = client.post(
|
||||||
data={
|
f"{base}/videos",
|
||||||
"model": model,
|
headers=headers,
|
||||||
"prompt": prompt,
|
data=payload,
|
||||||
"seconds": seconds,
|
files={"input_reference": ("reference.jpg", fh, "image/jpeg")},
|
||||||
"size": size,
|
)
|
||||||
},
|
create.raise_for_status()
|
||||||
files={"input_reference": ("reference.jpg", fh, "image/jpeg")},
|
data = create.json()
|
||||||
)
|
video_api_id = data.get("id") or provider_id or local_id
|
||||||
create.raise_for_status()
|
status = normalize_video_status(data.get("status"))
|
||||||
data = create.json()
|
progress = video_progress(data, 5)
|
||||||
video_api_id = data.get("id") or provider_id
|
direct_url = video_url_from_response(data)
|
||||||
update_generated_video(job_id, local_id, provider_id=video_api_id, status=data.get("status", "queued"), progress=int(data.get("progress") or 5))
|
update_generated_video(job_id, local_id, provider_id=video_api_id, status=status, progress=progress)
|
||||||
|
|
||||||
status = data.get("status", "queued")
|
deadline = time.time() + 420
|
||||||
progress = int(data.get("progress") or 5)
|
while status in {"queued", "in_progress"} and time.time() < deadline:
|
||||||
deadline = time.time() + 420
|
time.sleep(8)
|
||||||
while status in {"queued", "in_progress"} and time.time() < deadline:
|
poll = client.get(f"{base}/videos/{video_api_id}", headers=headers)
|
||||||
time.sleep(8)
|
poll.raise_for_status()
|
||||||
poll = client.get(f"{base}/videos/{video_api_id}", headers=headers)
|
pdata = poll.json()
|
||||||
poll.raise_for_status()
|
status = normalize_video_status(pdata.get("status"))
|
||||||
pdata = poll.json()
|
progress = video_progress(pdata, progress)
|
||||||
status = pdata.get("status", status)
|
direct_url = video_url_from_response(pdata) or direct_url
|
||||||
progress = int(pdata.get("progress") or progress)
|
update_generated_video(job_id, local_id, status=status, progress=progress)
|
||||||
update_generated_video(job_id, local_id, status=status, progress=progress)
|
|
||||||
|
|
||||||
if status != "completed":
|
if status != "completed":
|
||||||
update_generated_video(job_id, local_id, status="failed", error=f"video status: {status}", progress=progress)
|
update_generated_video(job_id, local_id, status="failed", error=f"video status: {status}", progress=progress)
|
||||||
return
|
return
|
||||||
|
|
||||||
content = client.get(f"{base}/videos/{video_api_id}/content", headers=headers)
|
download_generated_video(client, base, headers, video_api_id, direct_url, out_mp4)
|
||||||
content.raise_for_status()
|
update_generated_video(
|
||||||
out_mp4.write_bytes(content.content)
|
job_id,
|
||||||
update_generated_video(
|
local_id,
|
||||||
job_id,
|
status="completed",
|
||||||
local_id,
|
progress=100,
|
||||||
status="completed",
|
url=f"/jobs/{job_id}/storyboard-videos/{local_id}.mp4",
|
||||||
progress=100,
|
error="",
|
||||||
url=f"/jobs/{job_id}/storyboard-videos/{local_id}.mp4",
|
)
|
||||||
error="",
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
update_generated_video(job_id, local_id, status="failed", error=str(e)[:500])
|
update_generated_video(job_id, local_id, status="failed", error=str(e)[:500])
|
||||||
|
|
||||||
|
|
||||||
@app.post("/jobs/{job_id}/frames/{idx}/storyboard/video", response_model=Job)
|
@app.post("/jobs/{job_id}/frames/{idx}/storyboard/video", response_model=Job)
|
||||||
@@ -1666,7 +1723,7 @@ def generate_storyboard_video(job_id: str, idx: int, req: GenerateStoryboardVide
|
|||||||
poster = storyboard_ref_url(job_id, ref) or f"/jobs/{job_id}/frames/{idx}.jpg"
|
poster = storyboard_ref_url(job_id, ref) or f"/jobs/{job_id}/frames/{idx}.jpg"
|
||||||
|
|
||||||
local_id = uuid.uuid4().hex[:12]
|
local_id = uuid.uuid4().hex[:12]
|
||||||
model = req.model.strip() or VIDEO_MODEL
|
model = resolve_video_model(req.model)
|
||||||
seconds = video_seconds(float(req.duration or 4))
|
seconds = video_seconds(float(req.duration or 4))
|
||||||
item = GeneratedVideo(
|
item = GeneratedVideo(
|
||||||
id=local_id,
|
id=local_id,
|
||||||
@@ -1686,6 +1743,14 @@ def generate_storyboard_video(job_id: str, idx: int, req: GenerateStoryboardVide
|
|||||||
return job
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/jobs/{job_id}/storyboard-videos/{video_id}.mp4")
|
||||||
|
def get_storyboard_video(job_id: str, video_id: str):
|
||||||
|
p = job_dir(job_id) / "storyboard_videos" / video_id / "video.mp4"
|
||||||
|
if not p.exists():
|
||||||
|
raise HTTPException(404, "storyboard video not found")
|
||||||
|
return FileResponse(p, media_type="video/mp4")
|
||||||
|
|
||||||
|
|
||||||
@app.put("/jobs/{job_id}/frames/{idx}/storyboard", response_model=Job)
|
@app.put("/jobs/{job_id}/frames/{idx}/storyboard", response_model=Job)
|
||||||
def update_storyboard(job_id: str, idx: int, req: UpdateStoryboardReq) -> Job:
|
def update_storyboard(job_id: str, idx: int, req: UpdateStoryboardReq) -> Job:
|
||||||
"""更新分镜的编排字段(subject / product / scene / action / duration / reference_ids)"""
|
"""更新分镜的编排字段(subject / product / scene / action / duration / reference_ids)"""
|
||||||
|
|||||||
@@ -557,7 +557,7 @@
|
|||||||
<div class="step"><div class="num">4</div><h3>Vision 识别</h3><p>识别场景和候选元素,只是候选,不应锁死。</p></div>
|
<div class="step"><div class="num">4</div><h3>Vision 识别</h3><p>识别场景和候选元素,只是候选,不应锁死。</p></div>
|
||||||
<div class="step"><div class="num">5</div><h3>元素提取</h3><p>编辑/新增/删除元素,对元素反复生成提取图。</p></div>
|
<div class="step"><div class="num">5</div><h3>元素提取</h3><p>编辑/新增/删除元素,对元素反复生成提取图。</p></div>
|
||||||
<div class="step"><div class="num">6</div><h3>元素改造</h3><p>把参考主体、场景、动作和 SKG 产品放入分镜结构。</p></div>
|
<div class="step"><div class="num">6</div><h3>元素改造</h3><p>把参考主体、场景、动作和 SKG 产品放入分镜结构。</p></div>
|
||||||
<div class="step"><div class="num">7</div><h3>生成视频</h3><p>用分镜结构生成首帧和视频片段。当前未实现。</p></div>
|
<div class="step"><div class="num">7</div><h3>生成视频</h3><p>用分镜 4 图槽、改造目标和时长调用 Seedance / Kling / Veo 3 生视频 API,结果回写到 Video Gen 节点。</p></div>
|
||||||
<div class="step"><div class="num">8</div><h3>合成成品</h3><p>片段、字幕、配音、转场合成最终 mp4。当前未实现。</p></div>
|
<div class="step"><div class="num">8</div><h3>合成成品</h3><p>片段、字幕、配音、转场合成最终 mp4。当前未实现。</p></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -760,9 +760,9 @@ api/main.py
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class="tag green">Video Gen / Compose</span></td>
|
<td><span class="tag green">Video Gen / Compose</span></td>
|
||||||
<td>未来生成视频和合成成品。</td>
|
<td>承载生视频任务状态和完成后的 MP4。</td>
|
||||||
<td>当前只是占位,不要描述成已打通。</td>
|
<td>分镜工作台提交任务,Video Gen 节点只展示任务和结果。</td>
|
||||||
<td><code>VideoGenNode</code>、<code>ComposeNode</code>、未来模型接口</td>
|
<td><code>VideoGenNode</code>、<code>/storyboard/video</code>、<code>generated_videos</code></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -790,7 +790,7 @@ api/main.py
|
|||||||
<li>ASR:SKG 网关 audio endpoint 404 或渠道不可用。</li>
|
<li>ASR:SKG 网关 audio endpoint 404 或渠道不可用。</li>
|
||||||
<li>Translate:本身 text 通,但产品流里依赖 ASR 段落。</li>
|
<li>Translate:本身 text 通,但产品流里依赖 ASR 段落。</li>
|
||||||
<li>Rewrite:需要 SKG 产品信息模板和目标脚本结构。</li>
|
<li>Rewrite:需要 SKG 产品信息模板和目标脚本结构。</li>
|
||||||
<li>Video Gen:sora/video endpoint 未通,Seedance/Kling/Veo3 外部 key 未接。</li>
|
<li>Video Gen:已接 OpenAI-compatible <code>/videos</code> 网关;前端可选 Seedance / Kling / Veo 3,具体模型 ID 由 <code>VIDEO_MODEL_*</code> 环境变量映射。</li>
|
||||||
<li>Compose:还没做本地 ffmpeg 字幕/TTS 合成。</li>
|
<li>Compose:还没做本地 ffmpeg 字幕/TTS 合成。</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -832,14 +832,14 @@ api/main.py
|
|||||||
<div class="changelog">
|
<div class="changelog">
|
||||||
<article class="change">
|
<article class="change">
|
||||||
<header>
|
<header>
|
||||||
<h3>2026-05-13 · 分镜编排增加快速生成视频任务</h3>
|
<h3>2026-05-13 · 分镜编排接入真实生视频任务</h3>
|
||||||
<span class="tag violet">StoryboardWorkbench</span>
|
<span class="tag violet">StoryboardWorkbench</span>
|
||||||
<span class="tag rose">VideoGenNode</span>
|
<span class="tag rose">VideoGenNode</span>
|
||||||
</header>
|
</header>
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<p><strong>问题:</strong>4 图槽已经粘贴参考图后,用户需要快速交付可用于视频生成的 prompt,并在 Video Gen 节点看到结果承载。</p>
|
<p><strong>问题:</strong>4 图槽已经粘贴参考图后,用户要直接调用生视频 API,而不是只生成 prompt 或图片任务。</p>
|
||||||
<p><strong>改动:</strong>分镜编排明细区增加“快速生成视频”按钮,自动根据 4 图槽、时长和改造目标生成 SKG 产品视频 prompt;生成的任务卡展示在 <code>VideoGenNode</code> 上方,hover 可查看大图和 prompt,点击卡片复制 prompt。</p>
|
<p><strong>改动:</strong>分镜编排明细区增加 Seedance / Kling / Veo 3 模型选择和“调用模型生成视频”按钮;后端新增 <code>/jobs/{job_id}/frames/{idx}/storyboard/video</code>,提交 <code>/videos</code> 网关后轮询并保存 MP4;<code>VideoGenNode</code> 读取 <code>job.generated_videos</code> 展示排队、生成中、失败和完成视频。</p>
|
||||||
<p><strong>影响:</strong><code>web/components/storyboard-workbench.tsx</code>、<code>web/components/nodes/index.tsx</code>、<code>web/app/page.tsx</code>、<code>web/lib/api.ts</code>。当前是前端快速交付承载,后续接 Seedance / Kling / Veo 3 时替换为真实视频 URL。</p>
|
<p><strong>影响:</strong><code>api/main.py</code>、<code>api/.env.example</code>、<code>web/components/storyboard-workbench.tsx</code>、<code>web/components/nodes/index.tsx</code>、<code>web/app/page.tsx</code>、<code>web/lib/api.ts</code>。Sora 不再作为默认模型,真实模型 ID 通过 <code>VIDEO_MODEL_SEEDANCE</code>、<code>VIDEO_MODEL_KLING</code>、<code>VIDEO_MODEL_VEO3</code> 配置。</p>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<article class="change">
|
<article class="change">
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ import { StoryboardBar } from "@/components/storyboard-bar"
|
|||||||
import { StoryboardWorkbench } from "@/components/storyboard-workbench"
|
import { StoryboardWorkbench } from "@/components/storyboard-workbench"
|
||||||
import {
|
import {
|
||||||
addManualFrame, analyzeJob, createJob, getJob, uploadJob, deleteFrame, deleteGeneratedImage,
|
addManualFrame, analyzeJob, createJob, getJob, uploadJob, deleteFrame, deleteGeneratedImage,
|
||||||
effectiveFrameUrl, resolveImageRefUrl,
|
generateStoryboardVideo,
|
||||||
type Job, type ImageRef, type StoryboardScene, type GeneratedVideoDraft,
|
type Job, type ImageRef, type StoryboardScene,
|
||||||
} from "@/lib/api"
|
} from "@/lib/api"
|
||||||
import { VideoLightbox } from "@/components/video-lightbox"
|
import { VideoLightbox } from "@/components/video-lightbox"
|
||||||
|
|
||||||
@@ -77,7 +77,6 @@ export default function Home() {
|
|||||||
const [storyboardFrame, setStoryboardFrame] = useState<number | null>(null)
|
const [storyboardFrame, setStoryboardFrame] = useState<number | null>(null)
|
||||||
const [workbenchOpen, setWorkbenchOpen] = useState(false)
|
const [workbenchOpen, setWorkbenchOpen] = useState(false)
|
||||||
const [clipboard, setClipboard] = useState<ImageRef | null>(null)
|
const [clipboard, setClipboard] = useState<ImageRef | null>(null)
|
||||||
const [videoDrafts, setVideoDrafts] = useState<GeneratedVideoDraft[]>([])
|
|
||||||
const flowRef = useRef<any>(null)
|
const flowRef = useRef<any>(null)
|
||||||
|
|
||||||
// 把 setJob(prev=>...) 翻译成 setJobs 里更新当前 active
|
// 把 setJob(prev=>...) 翻译成 setJobs 里更新当前 active
|
||||||
@@ -106,7 +105,6 @@ export default function Home() {
|
|||||||
const handleSwitchJob = useCallback((id: string) => {
|
const handleSwitchJob = useCallback((id: string) => {
|
||||||
setActiveJobId(id)
|
setActiveJobId(id)
|
||||||
setSelectedFrames(new Set())
|
setSelectedFrames(new Set())
|
||||||
setVideoDrafts([])
|
|
||||||
}, [])
|
}, [])
|
||||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
@@ -217,14 +215,12 @@ export default function Home() {
|
|||||||
toast.success(`已复制:${ref.label || (ref.kind === "keyframe" ? "关键帧" : "元素")} · 到分镜头编排工作台粘贴`)
|
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
|
if (!job) return
|
||||||
const frame = job.frames.find((f) => f.index === frameIdx)
|
const frame = job.frames.find((f) => f.index === frameIdx)
|
||||||
if (!frame) return
|
if (!frame) return
|
||||||
|
|
||||||
const labelOf = (ref?: ImageRef | null, fallback = "未提供") => ref?.label || fallback
|
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 duration = scene.duration && scene.duration > 0 ? scene.duration : 5
|
||||||
const prompt = [
|
const prompt = [
|
||||||
`Vertical 9:16 short product video for SKG, ${duration.toFixed(1)} seconds.`,
|
`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.",
|
"High quality realistic commercial video, clean background, no captions, no platform UI, no TikTok watermark, no extra text.",
|
||||||
].join("\n")
|
].join("\n")
|
||||||
|
|
||||||
const draft: GeneratedVideoDraft = {
|
try {
|
||||||
id: `quick-${frameIdx}-${Date.now().toString(36)}`,
|
toast.info(`已提交 ${model} 生视频 · 分镜 ${frameIdx + 1}`)
|
||||||
frame_idx: frameIdx,
|
const updated = await generateStoryboardVideo(job.id, frameIdx, {
|
||||||
label: `分镜 ${frameIdx + 1} · 快速视频`,
|
prompt,
|
||||||
prompt,
|
duration,
|
||||||
provider: "Quick Prompt",
|
subject_image: scene.subject_image ?? null,
|
||||||
poster_url: posterUrl,
|
scene_image: scene.scene_image ?? null,
|
||||||
duration,
|
product_image: scene.product_image ?? null,
|
||||||
created_at: Date.now(),
|
action_image: scene.action_image ?? null,
|
||||||
status: "ready",
|
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))
|
}, [job, setJob])
|
||||||
void navigator.clipboard?.writeText(prompt).catch(() => {})
|
|
||||||
toast.success("已生成视频 prompt · 已显示到 Video Gen 节点")
|
|
||||||
}, [job])
|
|
||||||
|
|
||||||
// URL ?job=xxx,yyy 自动恢复多个 job
|
// URL ?job=xxx,yyy 自动恢复多个 job
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -308,8 +308,9 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
prevStatusRef.current = job.status
|
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"]
|
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 }
|
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -320,7 +321,7 @@ export default function Home() {
|
|||||||
} catch { /* silent */ }
|
} catch { /* silent */ }
|
||||||
}, 1500)
|
}, 1500)
|
||||||
return () => { if (pollRef.current) clearInterval(pollRef.current) }
|
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(() => ({
|
const nodeData: NodeData = useMemo(() => ({
|
||||||
job,
|
job,
|
||||||
@@ -332,7 +333,6 @@ export default function Home() {
|
|||||||
expandedFrame,
|
expandedFrame,
|
||||||
framePanelScale,
|
framePanelScale,
|
||||||
framePanelPinned,
|
framePanelPinned,
|
||||||
videoDrafts,
|
|
||||||
onSubmitUrl: handleSubmit,
|
onSubmitUrl: handleSubmit,
|
||||||
onUploadFile: handleUpload,
|
onUploadFile: handleUpload,
|
||||||
onAnalyze: handleAnalyze,
|
onAnalyze: handleAnalyze,
|
||||||
@@ -354,7 +354,7 @@ export default function Home() {
|
|||||||
setWorkbenchOpen(true)
|
setWorkbenchOpen(true)
|
||||||
},
|
},
|
||||||
onCopyImage: handleCopyImage,
|
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)
|
// 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag)
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node>(
|
const [nodes, setNodes, onNodesChange] = useNodesState<Node>(
|
||||||
|
|||||||
@@ -623,8 +623,8 @@ export const Dashboard = forwardRef<DashboardHandle, Props>(function Dashboard({
|
|||||||
{/* ---- VideoGen — Kanban ---- */}
|
{/* ---- VideoGen — Kanban ---- */}
|
||||||
{key === "videogen" && (
|
{key === "videogen" && (
|
||||||
<>
|
<>
|
||||||
<KanbanCard tone="violet" tags={["SKG 网关"]} title="Sora 2">
|
<KanbanCard tone="violet" tags={["SKG 网关"]} title="Seedance / Kling / Veo 3">
|
||||||
<div className="text-[11px] text-[var(--text-soft)]">/v1/videos 待开通(IT)</div>
|
<div className="text-[11px] text-[var(--text-soft)]">通过 /v1/videos 网关提交,模型 ID 走环境变量映射</div>
|
||||||
</KanbanCard>
|
</KanbanCard>
|
||||||
<KanbanCard tone="violet" tags={["外部"]} title="Seedance">
|
<KanbanCard tone="violet" tags={["外部"]} title="Seedance">
|
||||||
<div className="text-[11px] text-[var(--text-soft)]">字节跳动 · 需独立 API key</div>
|
<div className="text-[11px] text-[var(--text-soft)]">字节跳动 · 需独立 API key</div>
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import {
|
|||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell"
|
import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell"
|
||||||
import {
|
import {
|
||||||
type Job, type ImageRef, type GeneratedVideoDraft,
|
type Job, type ImageRef,
|
||||||
effectiveFrameUrl, videoUrl, hasCutout, representativeCutoutUrl,
|
apiAssetUrl, effectiveFrameUrl, videoUrl, hasCutout, representativeCutoutUrl,
|
||||||
} from "@/lib/api"
|
} from "@/lib/api"
|
||||||
import { FrameLightbox } from "@/components/lightbox"
|
import { FrameLightbox } from "@/components/lightbox"
|
||||||
|
|
||||||
@@ -23,7 +23,6 @@ export interface NodeData {
|
|||||||
expandedFrame: number | null
|
expandedFrame: number | null
|
||||||
framePanelScale?: number
|
framePanelScale?: number
|
||||||
framePanelPinned?: boolean
|
framePanelPinned?: boolean
|
||||||
videoDrafts?: GeneratedVideoDraft[]
|
|
||||||
onSubmitUrl: (url: string) => void
|
onSubmitUrl: (url: string) => void
|
||||||
onUploadFile: (file: File) => void
|
onUploadFile: (file: File) => void
|
||||||
onAnalyze: () => void
|
onAnalyze: () => void
|
||||||
@@ -943,22 +942,39 @@ export function StoryboardNode({ data, selected }: any) {
|
|||||||
============================================================ */
|
============================================================ */
|
||||||
export function VideoGenNode({ data, selected }: any) {
|
export function VideoGenNode({ data, selected }: any) {
|
||||||
const d: NodeData = data
|
const d: NodeData = data
|
||||||
const drafts = d.videoDrafts ?? []
|
const videos = d.job?.generated_videos ?? []
|
||||||
const status: NodeStatus = drafts.length > 0 ? "done" : "pending"
|
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
|
const aspect = d.job && (d.job.width ?? 0) > 0 && (d.job.height ?? 0) > 0
|
||||||
? `${d.job.width}/${d.job.height}`
|
? `${d.job.width}/${d.job.height}`
|
||||||
: "9/16"
|
: "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 (
|
return (
|
||||||
<div className="relative" style={{ width: 280 }}>
|
<div className="relative" style={{ width: 280 }}>
|
||||||
{drafts.length > 0 && (
|
{videos.length > 0 && (
|
||||||
<div
|
<div
|
||||||
className="absolute left-0 right-0 grid grid-cols-3 gap-1.5"
|
className="absolute left-0 right-0 grid grid-cols-3 gap-1.5"
|
||||||
style={{ bottom: "calc(100% + 12px)" }}
|
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
|
<div
|
||||||
key={v.id}
|
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 }}
|
style={{ aspectRatio: aspect }}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -967,17 +983,44 @@ export function VideoGenNode({ data, selected }: any) {
|
|||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
void navigator.clipboard?.writeText(v.prompt).catch(() => {})
|
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"
|
className="absolute inset-0 w-full h-full overflow-hidden rounded-md bg-black"
|
||||||
>
|
>
|
||||||
<img
|
{ready ? (
|
||||||
src={v.poster_url}
|
<video
|
||||||
alt={v.label}
|
src={videoSrc}
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
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="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-[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>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div
|
<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 className="rounded-lg overflow-hidden border-2 border-rose-300/60 bg-black shadow-2xl" style={{ width: 300 }}>
|
||||||
<div style={{ aspectRatio: aspect }}>
|
<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>
|
||||||
<div className="space-y-1 bg-black/90 px-2 py-1.5 text-white">
|
<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]">
|
<div className="flex items-center justify-between gap-2 text-[10.5px]">
|
||||||
<span className="truncate">{v.label}</span>
|
<span className="truncate">分镜 {v.frame_idx + 1}</span>
|
||||||
<span className="shrink-0 font-mono text-white/55">{v.provider}</span>
|
<span className="shrink-0 font-mono text-white/55">{modelLabel(v.model)} · {v.status}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="line-clamp-3 text-[9.5px] leading-snug text-white/55">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<NodeShell
|
<NodeShell
|
||||||
type="ai" status={status}
|
type="ai" status={status}
|
||||||
icon={<Film className="h-4 w-4" />}
|
icon={<Film className="h-4 w-4" />}
|
||||||
title="生成视频 · Video Gen"
|
title="生成视频 · Video Gen"
|
||||||
subtitle={`STEP 7 · 首帧 + 动作 prompt${drafts.length > 0 ? ` · ${drafts.length} 个任务` : ""}`}
|
subtitle={`STEP 7 · 首帧 + 动作 prompt${videos.length > 0 ? ` · ${videos.length} 个视频任务` : ""}`}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-3 gap-1.5 text-[10.5px]">
|
<div className="grid grid-cols-3 gap-1.5 text-[10.5px]">
|
||||||
@@ -1022,9 +1071,9 @@ export function VideoGenNode({ data, selected }: any) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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)]">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</NodeShell>
|
</NodeShell>
|
||||||
|
|||||||
@@ -15,13 +15,19 @@ interface Props {
|
|||||||
onJobUpdate?: (j: Job) => void
|
onJobUpdate?: (j: Job) => void
|
||||||
clipboard: ImageRef | null // 全局剪贴板(page.tsx 提供)
|
clipboard: ImageRef | null // 全局剪贴板(page.tsx 提供)
|
||||||
focusedFrame: number | null
|
focusedFrame: number | null
|
||||||
onGenerateVideo?: (frameIdx: number, scene: StoryboardScene) => void
|
onGenerateVideo?: (frameIdx: number, scene: StoryboardScene, model: string) => Promise<void> | void
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyScene = (): StoryboardScene => ({
|
const emptyScene = (): StoryboardScene => ({
|
||||||
subject: "", product: "", scene: "", action: "", duration: 0, reference_ids: [],
|
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) {
|
export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobUpdate, clipboard, focusedFrame, onGenerateVideo }: Props) {
|
||||||
const [mounted, setMounted] = useState(false)
|
const [mounted, setMounted] = useState(false)
|
||||||
useEffect(() => setMounted(true), [])
|
useEffect(() => setMounted(true), [])
|
||||||
@@ -31,6 +37,8 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
|
|||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [savedTick, setSavedTick] = useState(0)
|
const [savedTick, setSavedTick] = useState(0)
|
||||||
const [panelHeight, setPanelHeight] = useState(320)
|
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)
|
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
// Esc 关闭
|
// 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 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -271,23 +280,48 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* 快速生成:先产出视频 prompt / 任务卡,结果显示到 Video Gen 节点 */}
|
{/* 快速生成:直接调用生视频 API,结果显示到 Video Gen 节点 */}
|
||||||
<section>
|
<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
|
<button
|
||||||
disabled={!hasVideoRefs || focusedIdx === null}
|
disabled={!hasVideoRefs || focusedIdx === null || generating}
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
if (focusedIdx === null) return
|
if (focusedIdx === null) return
|
||||||
queueSave(form)
|
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"
|
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>
|
</button>
|
||||||
<div className="mt-2 text-[10.5px] text-white/35 leading-relaxed">
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -69,16 +69,19 @@ export interface StoryboardScene {
|
|||||||
reference_ids?: string[]
|
reference_ids?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GeneratedVideoDraft {
|
export interface GeneratedVideo {
|
||||||
id: string
|
id: string
|
||||||
|
provider_id?: string
|
||||||
frame_idx: number
|
frame_idx: number
|
||||||
label: string
|
|
||||||
prompt: string
|
prompt: string
|
||||||
provider: "Quick Prompt" | "Seedance" | "Kling" | "Veo 3"
|
model: string
|
||||||
poster_url: string
|
status: "queued" | "in_progress" | "completed" | "failed"
|
||||||
|
url?: string
|
||||||
|
poster_url?: string
|
||||||
duration: number
|
duration: number
|
||||||
|
progress: number
|
||||||
|
error?: string
|
||||||
created_at: number
|
created_at: number
|
||||||
status: "ready" | "queued" | "failed"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 把 ImageRef 解析成可显示的 src URL
|
// 把 ImageRef 解析成可显示的 src URL
|
||||||
@@ -139,9 +142,16 @@ export interface Job {
|
|||||||
frames: KeyFrame[]
|
frames: KeyFrame[]
|
||||||
transcript: TranscriptSegment[]
|
transcript: TranscriptSegment[]
|
||||||
storyboard_images?: StoryboardImage[]
|
storyboard_images?: StoryboardImage[]
|
||||||
|
generated_videos?: GeneratedVideo[]
|
||||||
error?: string
|
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> {
|
export async function createJob(tkUrl: string): Promise<Job> {
|
||||||
const res = await fetch(`${API_BASE}/jobs`, {
|
const res = await fetch(`${API_BASE}/jobs`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -341,6 +351,32 @@ export async function updateStoryboard(
|
|||||||
return res.json()
|
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> {
|
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}`, {
|
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements/${elementId}/cutouts/${cutoutId}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
|
|||||||
Reference in New Issue
Block a user