auto-save 2026-05-13 21:35 (~6)
This commit is contained in:
@@ -2434,6 +2434,13 @@
|
|||||||
"type": "session-heartbeat",
|
"type": "session-heartbeat",
|
||||||
"message": "Codex 会话活跃 · 最近命令:codex · 7 项未提交变更 · 最近提交:auto-save 2026-05-13 21:24 (~6)",
|
"message": "Codex 会话活跃 · 最近命令:codex · 7 项未提交变更 · 最近提交:auto-save 2026-05-13 21:24 (~6)",
|
||||||
"files_changed": 7
|
"files_changed": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-05-13T21:30:04+08:00",
|
||||||
|
"type": "commit",
|
||||||
|
"message": "auto-save 2026-05-13 21:29 (~7)",
|
||||||
|
"hash": "7b59ed9",
|
||||||
|
"files_changed": 7
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
27
api/main.py
27
api/main.py
@@ -1770,6 +1770,7 @@ def submit_video_create(
|
|||||||
payload: dict,
|
payload: dict,
|
||||||
source_ref: VideoSourceRef | None = None,
|
source_ref: VideoSourceRef | None = None,
|
||||||
last_img: Path | None = None,
|
last_img: Path | None = None,
|
||||||
|
product_img: Path | None = None,
|
||||||
):
|
):
|
||||||
if video_uses_ark():
|
if video_uses_ark():
|
||||||
content = [{"type": "text", "text": payload["prompt"]}]
|
content = [{"type": "text", "text": payload["prompt"]}]
|
||||||
@@ -1796,6 +1797,14 @@ def submit_video_create(
|
|||||||
"role": "last_frame",
|
"role": "last_frame",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
if product_img and product_img.exists():
|
||||||
|
content.append(
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": ark_reference_data_url(product_img)},
|
||||||
|
"role": "reference_image",
|
||||||
|
}
|
||||||
|
)
|
||||||
data = {
|
data = {
|
||||||
"model": payload["model"],
|
"model": payload["model"],
|
||||||
"content": content,
|
"content": content,
|
||||||
@@ -1832,12 +1841,14 @@ def render_storyboard_video(
|
|||||||
size: str,
|
size: str,
|
||||||
source_ref: VideoSourceRef | None = None,
|
source_ref: VideoSourceRef | None = None,
|
||||||
last_ref_path: Path | None = None,
|
last_ref_path: Path | None = None,
|
||||||
|
product_ref_path: Path | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
out_dir = job_dir(job_id) / "storyboard_videos" / local_id
|
out_dir = job_dir(job_id) / "storyboard_videos" / local_id
|
||||||
ref_img = out_dir / "reference.jpg"
|
ref_img = out_dir / "reference.jpg"
|
||||||
last_img = out_dir / "last_reference.jpg"
|
last_img = out_dir / "last_reference.jpg"
|
||||||
|
product_img = out_dir / "product_reference.jpg"
|
||||||
out_mp4 = out_dir / "video.mp4"
|
out_mp4 = out_dir / "video.mp4"
|
||||||
base = video_api_base()
|
base = video_api_base()
|
||||||
headers = {"Authorization": f"Bearer {video_api_key()}"}
|
headers = {"Authorization": f"Bearer {video_api_key()}"}
|
||||||
@@ -1848,6 +1859,10 @@ def render_storyboard_video(
|
|||||||
if last_ref_path and last_ref_path.exists():
|
if last_ref_path and last_ref_path.exists():
|
||||||
prepare_video_reference(last_ref_path, last_img)
|
prepare_video_reference(last_ref_path, last_img)
|
||||||
prepared_last_img = last_img
|
prepared_last_img = last_img
|
||||||
|
prepared_product_img: Path | None = None
|
||||||
|
if product_ref_path and product_ref_path.exists():
|
||||||
|
prepare_video_reference(product_ref_path, product_img)
|
||||||
|
prepared_product_img = product_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:
|
||||||
payload = {"model": model, "prompt": prompt, "size": size}
|
payload = {"model": model, "prompt": prompt, "size": size}
|
||||||
@@ -1855,13 +1870,16 @@ def render_storyboard_video(
|
|||||||
create = None
|
create = None
|
||||||
create_errors: list[str] = []
|
create_errors: list[str] = []
|
||||||
for create_path in VIDEO_CREATE_PATHS:
|
for create_path in VIDEO_CREATE_PATHS:
|
||||||
resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, source_ref, prepared_last_img)
|
resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, source_ref, prepared_last_img, prepared_product_img)
|
||||||
if video_uses_ark() and source_ref and resp.status_code in {400, 422}:
|
if video_uses_ark() and source_ref and resp.status_code in {400, 422}:
|
||||||
create_errors.append(f"{video_path(create_path)} + reference_video -> HTTP {resp.status_code}: {resp.text[:160]}")
|
create_errors.append(f"{video_path(create_path)} + reference_video -> HTTP {resp.status_code}: {resp.text[:160]}")
|
||||||
resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, None, prepared_last_img)
|
resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, None, prepared_last_img, prepared_product_img)
|
||||||
if video_uses_ark() and prepared_last_img and resp.status_code in {400, 422}:
|
if video_uses_ark() and prepared_last_img and resp.status_code in {400, 422}:
|
||||||
create_errors.append(f"{video_path(create_path)} + last_frame -> HTTP {resp.status_code}: {resp.text[:160]}")
|
create_errors.append(f"{video_path(create_path)} + last_frame -> HTTP {resp.status_code}: {resp.text[:160]}")
|
||||||
resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, None, None)
|
resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, None, None, prepared_product_img)
|
||||||
|
if video_uses_ark() and prepared_product_img and resp.status_code in {400, 422}:
|
||||||
|
create_errors.append(f"{video_path(create_path)} + product_reference -> HTTP {resp.status_code}: {resp.text[:160]}")
|
||||||
|
resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, None, prepared_last_img, None)
|
||||||
if resp.status_code < 400:
|
if resp.status_code < 400:
|
||||||
create = resp
|
create = resp
|
||||||
break
|
break
|
||||||
@@ -1924,6 +1942,7 @@ def generate_storyboard_video(job_id: str, idx: int, req: GenerateStoryboardVide
|
|||||||
raise HTTPException(404, "reference image missing")
|
raise HTTPException(404, "reference image missing")
|
||||||
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"
|
||||||
last_ref_path = storyboard_ref_path(job_id, req.last_image)
|
last_ref_path = storyboard_ref_path(job_id, req.last_image)
|
||||||
|
product_ref_path = storyboard_ref_path(job_id, req.product_image)
|
||||||
|
|
||||||
local_id = uuid.uuid4().hex[:12]
|
local_id = uuid.uuid4().hex[:12]
|
||||||
model = resolve_video_model(req.model)
|
model = resolve_video_model(req.model)
|
||||||
@@ -1945,7 +1964,7 @@ def generate_storyboard_video(job_id: str, idx: int, req: GenerateStoryboardVide
|
|||||||
source_ref = req.source_ref
|
source_ref = req.source_ref
|
||||||
if source_ref and source_ref.kind == "source_video" and not source_ref.url:
|
if source_ref and source_ref.kind == "source_video" and not source_ref.url:
|
||||||
source_ref = None
|
source_ref = None
|
||||||
bg.add_task(render_storyboard_video, job_id, local_id, "", ref_path, prompt, model, seconds, req.size, source_ref, last_ref_path)
|
bg.add_task(render_storyboard_video, job_id, local_id, "", ref_path, prompt, model, seconds, req.size, source_ref, last_ref_path, product_ref_path)
|
||||||
return job
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -830,6 +830,18 @@ api/main.py
|
|||||||
<h2>变更记录</h2>
|
<h2>变更记录</h2>
|
||||||
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
|
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
|
||||||
<div class="changelog">
|
<div class="changelog">
|
||||||
|
<article class="change">
|
||||||
|
<header>
|
||||||
|
<h3>2026-05-13 · 首尾帧编排增加 SKG 产品槽</h3>
|
||||||
|
<span class="tag violet">StoryboardWorkbench</span>
|
||||||
|
<span class="tag blue">API</span>
|
||||||
|
</header>
|
||||||
|
<div class="body">
|
||||||
|
<p><strong>问题:</strong>首尾帧可以控制视频起止,但还需要单独指定 SKG 产品图,避免模型只模仿原视频动作而没有稳定产品外观。</p>
|
||||||
|
<p><strong>改动:</strong>分镜编排区新增 <code>SKG 产品</code> 槽,和首帧、尾帧并列;生成视频时把该槽作为 <code>product_image</code> 提交。Ark 请求会附加一张 <code>reference_image</code> 产品参考图;如果接口不接受额外参考图,后端自动回退到首尾帧生成。</p>
|
||||||
|
<p><strong>影响:</strong><code>web/components/storyboard-workbench.tsx</code>、<code>web/app/page.tsx</code>、<code>api/main.py</code>。</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
<article class="change">
|
<article class="change">
|
||||||
<header>
|
<header>
|
||||||
<h3>2026-05-13 · 分镜编排改为首尾帧生成</h3>
|
<h3>2026-05-13 · 分镜编排改为首尾帧生成</h3>
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ export default function Home() {
|
|||||||
last_image: lastRef,
|
last_image: lastRef,
|
||||||
subject_image: firstRef,
|
subject_image: firstRef,
|
||||||
scene_image: null,
|
scene_image: null,
|
||||||
product_image: null,
|
product_image: scene.product_image ?? null,
|
||||||
action_image: null,
|
action_image: null,
|
||||||
source_ref: sourceUrl ? { kind: "source_video", url: sourceUrl } : null,
|
source_ref: sourceUrl ? { kind: "source_video", url: sourceUrl } : null,
|
||||||
model,
|
model,
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
|
|||||||
const [videoModel, setVideoModel] = useState<(typeof VIDEO_MODELS)[number]["value"]>("seedance")
|
const [videoModel, setVideoModel] = useState<(typeof VIDEO_MODELS)[number]["value"]>("seedance")
|
||||||
const [generating, setGenerating] = useState(false)
|
const [generating, setGenerating] = useState(false)
|
||||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const loadedFormKey = useRef("")
|
||||||
|
|
||||||
// Esc 关闭
|
// Esc 关闭
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -68,12 +69,20 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
|
|||||||
else setFocusedIdx(null)
|
else setFocusedIdx(null)
|
||||||
}, [open, job?.id, selectedFrames, focusedIdx, job?.frames])
|
}, [open, job?.id, selectedFrames, focusedIdx, job?.frames])
|
||||||
|
|
||||||
// 切换 focused 加载表单数据
|
// 切换 focused 时加载表单数据。不要在每次 job 轮询/保存回包时重灌,
|
||||||
|
// 否则用户刚粘贴到尾帧,外部旧 job 刷新会把本地表单闪回去。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!job || focusedIdx === null) { setForm(emptyScene()); return }
|
if (!job || focusedIdx === null) {
|
||||||
|
loadedFormKey.current = ""
|
||||||
|
setForm(emptyScene())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const key = `${job.id}:${focusedIdx}`
|
||||||
|
if (loadedFormKey.current === key) return
|
||||||
const f = job.frames.find((x) => x.index === focusedIdx)
|
const f = job.frames.find((x) => x.index === focusedIdx)
|
||||||
setForm(f?.storyboard ? { ...emptyScene(), ...f.storyboard } : emptyScene())
|
setForm(f?.storyboard ? { ...emptyScene(), ...f.storyboard } : emptyScene())
|
||||||
}, [focusedIdx, job])
|
loadedFormKey.current = key
|
||||||
|
}, [focusedIdx, job?.id])
|
||||||
|
|
||||||
if (!mounted || !open || !job) return null
|
if (!mounted || !open || !job) return null
|
||||||
|
|
||||||
@@ -184,12 +193,13 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 首尾帧:图片直接参与视频生成 */}
|
{/* 首尾帧:图片直接参与视频生成 */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
{([
|
{([
|
||||||
{ key: "first_image" as const, label: "首帧", placeholder: "默认当前分镜关键帧" },
|
{ key: "first_image" as const, label: "首帧", placeholder: "默认当前分镜关键帧" },
|
||||||
{ key: "last_image" as const, label: "尾帧", placeholder: defaultLastRef ? "默认下一张已选分镜" : "粘贴一张结束画面" },
|
{ key: "last_image" as const, label: "尾帧", placeholder: defaultLastRef ? "默认下一张已选分镜" : "粘贴一张结束画面" },
|
||||||
|
{ key: "product_image" as const, label: "SKG 产品", placeholder: "粘贴产品图 / 包装 / 使用状态" },
|
||||||
]).map(({ key, label, placeholder }) => {
|
]).map(({ key, label, placeholder }) => {
|
||||||
const fallback = key === "first_image" ? defaultFirstRef : defaultLastRef
|
const fallback = key === "first_image" ? defaultFirstRef : key === "last_image" ? defaultLastRef : null
|
||||||
const ref = form[key] ?? fallback
|
const ref = form[key] ?? fallback
|
||||||
const url = ref ? resolveImageRefUrl(job.id, ref) : ""
|
const url = ref ? resolveImageRefUrl(job.id, ref) : ""
|
||||||
return (
|
return (
|
||||||
@@ -245,7 +255,7 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md border border-violet-300/20 bg-violet-500/10 px-3 py-2 text-[11px] leading-relaxed text-violet-100/75">
|
<div className="rounded-md border border-violet-300/20 bg-violet-500/10 px-3 py-2 text-[11px] leading-relaxed text-violet-100/75">
|
||||||
现在按首尾帧出片:首帧默认当前分镜,尾帧默认下一张已选分镜。需要指定结尾时,在任意关键帧或生成图点 📋,再粘贴到「尾帧」。
|
现在按首尾帧出片:首帧默认当前分镜,尾帧默认下一张已选分镜;「SKG 产品」用于锁定产品外观。需要指定素材时,在任意关键帧、产品图或生成图点 📋,再粘贴到对应槽位。
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 改造 brief:明确“借鉴参考 → 变成 SKG 产品视频”,避免直接复刻 */}
|
{/* 改造 brief:明确“借鉴参考 → 变成 SKG 产品视频”,避免直接复刻 */}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export interface StoryboardScene {
|
|||||||
duration: number
|
duration: number
|
||||||
first_image?: ImageRef | null
|
first_image?: ImageRef | null
|
||||||
last_image?: ImageRef | null
|
last_image?: ImageRef | null
|
||||||
|
product_images?: ImageRef[]
|
||||||
subject_image?: ImageRef | null
|
subject_image?: ImageRef | null
|
||||||
scene_image?: ImageRef | null
|
scene_image?: ImageRef | null
|
||||||
product_image?: ImageRef | null
|
product_image?: ImageRef | null
|
||||||
@@ -382,6 +383,7 @@ export async function generateStoryboardVideo(
|
|||||||
duration?: number
|
duration?: number
|
||||||
first_image?: ImageRef | null
|
first_image?: ImageRef | null
|
||||||
last_image?: ImageRef | null
|
last_image?: ImageRef | null
|
||||||
|
product_images?: ImageRef[]
|
||||||
subject_image?: ImageRef | null
|
subject_image?: ImageRef | null
|
||||||
scene_image?: ImageRef | null
|
scene_image?: ImageRef | null
|
||||||
product_image?: ImageRef | null
|
product_image?: ImageRef | null
|
||||||
|
|||||||
Reference in New Issue
Block a user