auto-save 2026-05-14 07:39 (~4)

This commit is contained in:
2026-05-14 07:40:07 +08:00
parent 19813b55d4
commit c263af24fb
4 changed files with 96 additions and 17 deletions

View File

@@ -1,19 +1,5 @@
{ {
"entries": [ "entries": [
{
"files_changed": 1,
"hash": "dd3d7b2",
"message": "auto-save 2026-05-12 21:38 (~1)",
"ts": "2026-05-12T21:38:30+08:00",
"type": "commit"
},
{
"files_changed": 1,
"hash": "d2d1e25",
"message": "auto-save 2026-05-12 21:44 (~1)",
"ts": "2026-05-12T21:44:22+08:00",
"type": "commit"
},
{ {
"files_changed": 1, "files_changed": 1,
"hash": "f8cd466", "hash": "f8cd466",
@@ -3336,6 +3322,19 @@
"type": "session-heartbeat", "type": "session-heartbeat",
"message": "Claude 会话活跃 · 最近命令claude · 1 项未提交变更 · 最近提交auto-save 2026-05-14 07:28 (~6)", "message": "Claude 会话活跃 · 最近命令claude · 1 项未提交变更 · 最近提交auto-save 2026-05-14 07:28 (~6)",
"files_changed": 1 "files_changed": 1
},
{
"ts": "2026-05-14T07:34:24+08:00",
"type": "commit",
"message": "auto-save 2026-05-14 07:34 (~1)",
"hash": "19813b5",
"files_changed": 1
},
{
"ts": "2026-05-13T23:38:52Z",
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 2 项未提交变更 · 最近提交auto-save 2026-05-14 07:34 (~1)",
"files_changed": 2
} }
] ]
} }

View File

@@ -2540,6 +2540,10 @@ class GenerateStoryboardVideoReq(BaseModel):
size: str = "720x1280" size: str = "720x1280"
class ProductFusionDescriptionReq(BaseModel):
shots: list[ProductFusionShot] = Field(default_factory=list)
def video_seconds(duration: float) -> str: def video_seconds(duration: float) -> str:
if video_uses_ark(): if video_uses_ark():
if duration <= 0: if duration <= 0:
@@ -2997,6 +3001,59 @@ def create_product_fusion_guide(job_id: str, req: ProductFusionShot) -> dict:
} }
def fallback_product_fusion_descriptions() -> list[str]:
return [
"人物双手拿起 SKG 颈部按摩仪,准备戴到脖子上,镜头轻微推近产品。",
"人物把 SKG 按摩仪贴合到肩颈位置,手部轻轻调整两侧机身角度。",
"人物坐在场景中轻按侧边控制区,产品保持在画框指定区域内清晰可见。",
"人物闭眼放松,肩颈从紧绷变舒展,产品佩戴位置稳定不漂移。",
"镜头靠近展示 SKG 产品材质、按键和内侧触点,手部不要遮挡产品主体。",
"使用后的放松状态收尾,人物自然抬头,产品仍保持白色 U 形外观和真实比例。",
]
@app.post("/jobs/{job_id}/product-fusion/descriptions")
def generate_product_fusion_descriptions(job_id: str, req: ProductFusionDescriptionReq) -> dict:
if job_id not in JOBS:
raise HTTPException(404, "job not found")
fallback = fallback_product_fusion_descriptions()
shots = (req.shots or [])[:6]
if not LLM_API_KEY:
return {"descriptions": fallback, "mode": "fallback"}
shot_lines = []
for i, shot in enumerate(shots, start=1):
product = (shot.product_image or {}).get("label") or "SKG 产品图"
person = (shot.person_image or {}).get("label") or "白底人物姿态图"
scene = (shot.scene_image or {}).get("label") or "场景图"
region = shot.product_region
region_text = f"x={region.x:.2f}, y={region.y:.2f}, w={region.w:.2f}, h={region.h:.2f}" if region else "未画区域"
shot_lines.append(f"{i}. 产品={product};人物={person};区域={region_text};场景={scene};已有描述={shot.action_text or ''}")
prompt = (
"你是 SKG 产品短视频分镜导演。请为 6 条产品融合镜头各写一条中文动作描述,"
"每条 20-40 字,必须说明人物在做什么、产品如何佩戴/展示、动作如何自然连续。"
"产品是 SKG 白色 U 形颈部/肩颈按摩仪,不要写医疗治疗承诺,不要出现竞品。"
"输出 JSON{\"descriptions\":[\"...\", \"...\"]}。\n\n"
+ "\n".join(shot_lines)
)
try:
resp = llm().chat.completions.create(
model=REWRITE_MODEL,
messages=[
{"role": "system", "content": "只输出合法 JSON不要解释。"},
{"role": "user", "content": prompt},
],
temperature=0.5,
)
text = resp.choices[0].message.content or ""
data = json.loads(text)
descriptions = [str(x).strip() for x in data.get("descriptions", []) if str(x).strip()]
if len(descriptions) < 6:
descriptions = (descriptions + fallback)[:6]
return {"descriptions": descriptions[:6], "mode": "llm"}
except Exception:
return {"descriptions": fallback, "mode": "fallback"}
@app.get("/jobs/{job_id}/assets/{asset_id}.jpg") @app.get("/jobs/{job_id}/assets/{asset_id}.jpg")
def get_storyboard_asset(job_id: str, asset_id: str): def get_storyboard_asset(job_id: str, asset_id: str):
p = job_dir(job_id) / "assets" / f"{asset_id}.jpg" p = job_dir(job_id) / "assets" / f"{asset_id}.jpg"

View File

@@ -5,7 +5,7 @@ import { X, ChevronLeft, ChevronRight, Check, Sparkles, Wand2, Loader2, Eye, Ref
import { import {
frameUrl, cleanedFrameUrl, apiAssetUrl, frameUrl, cleanedFrameUrl, apiAssetUrl,
describeFrame, cleanupFrame, applyCleanedFrame, discardCleanedFrame, addElement, updateElement, deleteElement, describeFrame, cleanupFrame, applyCleanedFrame, discardCleanedFrame, addElement, updateElement, deleteElement,
generateSceneAsset, generateSubjectAssets, resolveImageRefUrl, uploadStoryboardAsset, updateStoryboard, generateSceneAsset, generateSubjectAssets, generateProductFusionDescriptions, resolveImageRefUrl, uploadStoryboardAsset, updateStoryboard,
type AssetBackground, type AssetSize, type KeyFrame, type Job, type ImageRef, type ProductFusionShot, type SceneMode, type SceneStyle, type SubjectKind, type AssetBackground, type AssetSize, type KeyFrame, type Job, type ImageRef, type ProductFusionShot, type SceneMode, type SceneStyle, type SubjectKind,
} from "@/lib/api" } from "@/lib/api"
import { ProductLibraryPicker } from "@/components/product-library-picker" import { ProductLibraryPicker } from "@/components/product-library-picker"
@@ -347,7 +347,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
fusionFileInputRef.current?.click() fusionFileInputRef.current?.click()
} }
const draftFusionDescriptions = () => { const draftFusionDescriptions = async () => {
const actions = [ const actions = [
"人物双手拿起 SKG 颈部按摩仪,准备戴到脖子上,镜头轻微推近产品。", "人物双手拿起 SKG 颈部按摩仪,准备戴到脖子上,镜头轻微推近产品。",
"人物把 SKG 按摩仪贴合到肩颈位置,手部轻轻调整两侧机身角度。", "人物把 SKG 按摩仪贴合到肩颈位置,手部轻轻调整两侧机身角度。",
@@ -356,9 +356,16 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
"镜头靠近展示 SKG 产品材质、按键和内侧触点,手部不要遮挡产品主体。", "镜头靠近展示 SKG 产品材质、按键和内侧触点,手部不要遮挡产品主体。",
"使用后的放松状态收尾,人物自然抬头,产品仍保持白色 U 形外观和真实比例。", "使用后的放松状态收尾,人物自然抬头,产品仍保持白色 U 形外观和真实比例。",
] ]
let descriptions = actions
try {
const result = await generateProductFusionDescriptions(jobId, fusionShots)
descriptions = result.descriptions.length ? result.descriptions : actions
} catch (e) {
toast.error("AI 描述生成失败,已使用本地草稿")
}
const next = fusionShots.map((shot, i) => ({ const next = fusionShots.map((shot, i) => ({
...shot, ...shot,
action_text: shot.action_text?.trim() || actions[i], action_text: shot.action_text?.trim() || descriptions[i] || actions[i],
})) }))
setFusionShots(next) setFusionShots(next)
void persistFusionShots(next) void persistFusionShots(next)

View File

@@ -177,6 +177,22 @@ export async function createProductFusionGuide(
return res.json() 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 { export interface KeyFrame {
index: number index: number
timestamp: number timestamp: number