auto-save 2026-05-17 12:28 (~4)
This commit is contained in:
@@ -1,31 +1,5 @@
|
|||||||
{
|
{
|
||||||
"entries": [
|
"entries": [
|
||||||
{
|
|
||||||
"files_changed": 1,
|
|
||||||
"hash": "397bae2",
|
|
||||||
"message": "auto-save 2026-05-14 17:20 (~1)",
|
|
||||||
"ts": "2026-05-14T17:21:07+08:00",
|
|
||||||
"type": "commit"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"files_changed": 1,
|
|
||||||
"message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-14 17:20 (~1)",
|
|
||||||
"ts": "2026-05-14T09:26:14Z",
|
|
||||||
"type": "session-heartbeat"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"files_changed": 1,
|
|
||||||
"hash": "16c51fc",
|
|
||||||
"message": "auto-save 2026-05-14 17:26 (~1)",
|
|
||||||
"ts": "2026-05-14T17:26:40+08:00",
|
|
||||||
"type": "commit"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"files_changed": 1,
|
|
||||||
"message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-14 17:26 (~1)",
|
|
||||||
"ts": "2026-05-14T09:28:43Z",
|
|
||||||
"type": "session-heartbeat"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"files_changed": 1,
|
"files_changed": 1,
|
||||||
"hash": "8b1e0bd",
|
"hash": "8b1e0bd",
|
||||||
@@ -3269,6 +3243,31 @@
|
|||||||
"message": "auto-save 2026-05-17 12:01 (+1, ~1)",
|
"message": "auto-save 2026-05-17 12:01 (+1, ~1)",
|
||||||
"hash": "7d399b8",
|
"hash": "7d399b8",
|
||||||
"files_changed": 2
|
"files_changed": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-05-17T12:06:14+08:00",
|
||||||
|
"type": "commit",
|
||||||
|
"message": "refactor: merge storyboard workflow into segment board",
|
||||||
|
"hash": "652a487",
|
||||||
|
"files_changed": 6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-05-17T04:08:23Z",
|
||||||
|
"type": "session-heartbeat",
|
||||||
|
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:refactor: merge storyboard workflow into segment board",
|
||||||
|
"files_changed": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-05-17T04:18:24Z",
|
||||||
|
"type": "session-heartbeat",
|
||||||
|
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:refactor: merge storyboard workflow into segment board",
|
||||||
|
"files_changed": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-05-17T04:28:24Z",
|
||||||
|
"type": "session-heartbeat",
|
||||||
|
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 4 项未提交变更 · 最近提交:refactor: merge storyboard workflow into segment board",
|
||||||
|
"files_changed": 4
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
158
web/app/page.tsx
158
web/app/page.tsx
@@ -19,8 +19,8 @@ import { AudioStrip } from "@/components/audio-strip"
|
|||||||
import { AdRecreationBoard } from "@/components/ad-recreation-board"
|
import { AdRecreationBoard } from "@/components/ad-recreation-board"
|
||||||
import {
|
import {
|
||||||
addManualFrame, analyzeJob, createJob, getJob, listJobs, uploadJob, deleteJob, deleteFrame, deleteGeneratedImage,
|
addManualFrame, analyzeJob, createJob, getJob, listJobs, uploadJob, deleteJob, deleteFrame, deleteGeneratedImage,
|
||||||
deleteGeneratedVideo, deleteCutout, generateStoryboardVideo, triggerTranscribe,
|
deleteGeneratedVideo, deleteCutout, generateStoryboardVideo, triggerTranscribe, describeFrame, updateStoryboard, copyProductLibraryAsset,
|
||||||
type Job, type ImageRef, type ProductFusionShot, type StoryboardScene, type FrameExtractMode, type FrameExtractQuality, type FrameExtractTarget,
|
type Job, type ImageRef, type KeyFrame, type ProductFusionShot, type StoryboardScene, type FrameExtractMode, type FrameExtractQuality, type FrameExtractTarget,
|
||||||
} from "@/lib/api"
|
} from "@/lib/api"
|
||||||
import { TRANSPARENT_HUMAN_NEGATIVE_PROMPT, TRANSPARENT_HUMAN_VIDEO_PROMPT } from "@/lib/workflow-target"
|
import { TRANSPARENT_HUMAN_NEGATIVE_PROMPT, TRANSPARENT_HUMAN_VIDEO_PROMPT } from "@/lib/workflow-target"
|
||||||
|
|
||||||
@@ -53,6 +53,12 @@ const FRAME_QUALITY_LABELS: Record<FrameExtractQuality, string> = {
|
|||||||
accurate: "精细",
|
accurate: "精细",
|
||||||
ultra: "极准",
|
ultra: "极准",
|
||||||
}
|
}
|
||||||
|
const DEFAULT_PRODUCT_LIBRARY_IDS = [
|
||||||
|
"desktop-skg-product-angle-01",
|
||||||
|
"desktop-skg-product-angle-02",
|
||||||
|
"desktop-skg-product-angle-03",
|
||||||
|
"desktop-skg-product-angle-04",
|
||||||
|
]
|
||||||
|
|
||||||
const PRODUCT_FUSION_WEARING_PROMPT = [
|
const PRODUCT_FUSION_WEARING_PROMPT = [
|
||||||
"Product placement must be physically correct:",
|
"Product placement must be physically correct:",
|
||||||
@@ -150,6 +156,10 @@ export default function Home() {
|
|||||||
const [videoPanelDock, setVideoPanelDock] = useState<CanvasPanelDock>("left")
|
const [videoPanelDock, setVideoPanelDock] = useState<CanvasPanelDock>("left")
|
||||||
const [videoPanelOpenTick, setVideoPanelOpenTick] = useState(0)
|
const [videoPanelOpenTick, setVideoPanelOpenTick] = useState(0)
|
||||||
const [clipboard, setClipboard] = useState<ImageRef | null>(null)
|
const [clipboard, setClipboard] = useState<ImageRef | null>(null)
|
||||||
|
const [productionJobIds, setProductionJobIds] = useState<Set<string>>(new Set())
|
||||||
|
const [planningJobIds, setPlanningJobIds] = useState<Set<string>>(new Set())
|
||||||
|
const [defaultProductRefsByJob, setDefaultProductRefsByJob] = useState<Record<string, ImageRef[]>>({})
|
||||||
|
const autoTriggeredRef = useRef<Set<string>>(new Set())
|
||||||
const flowRef = useRef<any>(null)
|
const flowRef = useRef<any>(null)
|
||||||
const lastVideoPanelFocusKey = useRef("")
|
const lastVideoPanelFocusKey = useRef("")
|
||||||
|
|
||||||
@@ -201,8 +211,10 @@ export default function Home() {
|
|||||||
const created = await createJob(url)
|
const created = await createJob(url)
|
||||||
addJob(created)
|
addJob(created)
|
||||||
toast.success(`已创建任务 ${created.id.slice(0, 8)}`)
|
toast.success(`已创建任务 ${created.id.slice(0, 8)}`)
|
||||||
|
return created
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error("提交失败:" + (e instanceof Error ? e.message : String(e)))
|
toast.error("提交失败:" + (e instanceof Error ? e.message : String(e)))
|
||||||
|
return undefined
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
@@ -447,6 +459,116 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
}, [activeJobId, jobs, updateJobInList])
|
}, [activeJobId, jobs, updateJobInList])
|
||||||
|
|
||||||
|
const ensureDefaultProductRefs = useCallback(async (jobId: string) => {
|
||||||
|
const cached = defaultProductRefsByJob[jobId]
|
||||||
|
if (cached?.length >= 4) return cached.slice(0, 4)
|
||||||
|
const refs = await Promise.all(DEFAULT_PRODUCT_LIBRARY_IDS.map((id) => copyProductLibraryAsset(jobId, id)))
|
||||||
|
setDefaultProductRefsByJob((prev) => ({ ...prev, [jobId]: refs }))
|
||||||
|
return refs
|
||||||
|
}, [defaultProductRefsByJob])
|
||||||
|
|
||||||
|
const buildPlannedScene = useCallback((targetJob: Job, frame: KeyFrame, order: number): StoryboardScene => {
|
||||||
|
const frames = [...targetJob.frames].sort((a, b) => a.timestamp - b.timestamp)
|
||||||
|
const nextFrame = frames.find((item) => item.timestamp > frame.timestamp) ?? null
|
||||||
|
const totalDuration = Math.max(targetJob.duration || 0, frames.length * 5, 5)
|
||||||
|
const duration = Math.max(3.5, Math.min(7.5, totalDuration / Math.max(frames.length, 1)))
|
||||||
|
const audioLine = targetJob.audio_script?.rewritten_text?.trim()
|
||||||
|
|| targetJob.transcript?.slice(0, 4).map((item) => item.en || item.zh).filter(Boolean).join(" ")
|
||||||
|
|| "按原视频说话节奏生成 SKG 产品口播。"
|
||||||
|
const sceneText = frame.description?.scene?.trim()
|
||||||
|
|| `参考原视频第 ${order + 1} 个关键画面,建立一个可复刻的信息流广告分镜。`
|
||||||
|
const objectText = frame.description?.objects?.slice(0, 5).map((item) => item.name).filter(Boolean).join("、")
|
||||||
|
return {
|
||||||
|
duration: Number(duration.toFixed(1)),
|
||||||
|
first_image: { kind: "keyframe", frame_idx: frame.index, label: `分镜 ${order + 1} 首帧` },
|
||||||
|
last_image: nextFrame ? { kind: "keyframe", frame_idx: nextFrame.index, label: `分镜 ${order + 1} 尾帧` } : null,
|
||||||
|
subject: objectText ? `优先保留并改造这些可选关键元素:${objectText}。` : "保留原视频里最能驱动剧情的主体动作和镜头关系。",
|
||||||
|
scene: `${sceneText}\n音频节奏依据:${audioLine.slice(0, 220)}`,
|
||||||
|
product: "把这一镜改成 SKG 颈部/肩颈按摩仪的信息流广告表达。默认使用 SKG 四张真实产品角度图作为产品真源,产品必须外置佩戴在肩颈位置,不要变成其他物体。",
|
||||||
|
action: frame.description?.style
|
||||||
|
? `沿用原画面的镜头节奏和 ${frame.description.style},动作要从首帧自然过渡到尾帧,突出使用前紧绷、使用后放松。`
|
||||||
|
: "沿用原视频的讲话/动作节奏,动作要从首帧自然过渡到尾帧,突出使用前紧绷、使用后放松。",
|
||||||
|
reference_ids: [],
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handlePlanStoryboardJob = useCallback(async (jobId: string) => {
|
||||||
|
if (planningJobIds.has(jobId)) return
|
||||||
|
const initial = jobs.find((item) => item.id === jobId)
|
||||||
|
if (!initial || initial.frames.length === 0) return
|
||||||
|
setPlanningJobIds((prev) => new Set(prev).add(jobId))
|
||||||
|
try {
|
||||||
|
let latest = initial
|
||||||
|
const frames = [...latest.frames].sort((a, b) => a.timestamp - b.timestamp)
|
||||||
|
toast.info(`开始扫描关键元素 · ${frames.length} 个分镜`)
|
||||||
|
for (let order = 0; order < frames.length; order += 1) {
|
||||||
|
const frame = frames[order]
|
||||||
|
let currentFrame = latest.frames.find((item) => item.index === frame.index) ?? frame
|
||||||
|
if (!currentFrame.description) {
|
||||||
|
latest = await describeFrame(jobId, frame.index)
|
||||||
|
updateJobInList(latest)
|
||||||
|
currentFrame = latest.frames.find((item) => item.index === frame.index) ?? currentFrame
|
||||||
|
}
|
||||||
|
if (!currentFrame.storyboard) {
|
||||||
|
const planned = buildPlannedScene(latest, currentFrame, order)
|
||||||
|
latest = await updateStoryboard(jobId, frame.index, planned)
|
||||||
|
updateJobInList(latest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast.success("关键元素扫描和分镜初稿已生成")
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("分镜规划失败:" + (e instanceof Error ? e.message : String(e)))
|
||||||
|
} finally {
|
||||||
|
setPlanningJobIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.delete(jobId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [buildPlannedScene, jobs, planningJobIds, updateJobInList])
|
||||||
|
|
||||||
|
const handleStartProduction = useCallback(async (inputUrl?: string) => {
|
||||||
|
const trimmed = inputUrl?.trim()
|
||||||
|
const created = trimmed ? await handleSubmit(trimmed) : undefined
|
||||||
|
const target = created ?? job
|
||||||
|
if (!target) {
|
||||||
|
toast.info("先粘贴视频链接或选择一个素材任务")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setProductionJobIds((prev) => new Set(prev).add(target.id))
|
||||||
|
setAudioStripJobId(target.id)
|
||||||
|
toast.success("已进入自动生产:下载完成后会抽帧、解析音频并生成分镜初稿")
|
||||||
|
if (target.video_url && ["downloaded", "frames_extracted", "transcribed", "failed"].includes(target.status)) {
|
||||||
|
if (!target.frames.length) void handleAnalyzeJob(target.id, { mode: "replace" })
|
||||||
|
void handleTranscribeAudio(target.id, { silent: true })
|
||||||
|
if (target.frames.length) void handlePlanStoryboardJob(target.id)
|
||||||
|
}
|
||||||
|
}, [handleAnalyzeJob, handlePlanStoryboardJob, handleSubmit, handleTranscribeAudio, job])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (productionJobIds.size === 0) return
|
||||||
|
for (const item of jobs) {
|
||||||
|
if (!productionJobIds.has(item.id)) continue
|
||||||
|
const videoReady = !!item.video_url && ["downloaded", "frames_extracted", "transcribed", "failed"].includes(item.status)
|
||||||
|
if (!videoReady) continue
|
||||||
|
const audioKey = `${item.id}:audio`
|
||||||
|
if (!autoTriggeredRef.current.has(audioKey) && item.audio_script?.status !== "rewriting" && !item.audio_script?.rewritten_text) {
|
||||||
|
autoTriggeredRef.current.add(audioKey)
|
||||||
|
void handleTranscribeAudio(item.id, { silent: true })
|
||||||
|
}
|
||||||
|
const analyzeKey = `${item.id}:analyze`
|
||||||
|
if (!autoTriggeredRef.current.has(analyzeKey) && item.frames.length === 0 && item.status !== "splitting") {
|
||||||
|
autoTriggeredRef.current.add(analyzeKey)
|
||||||
|
void handleAnalyzeJob(item.id, { mode: "replace" })
|
||||||
|
}
|
||||||
|
const planKey = `${item.id}:plan:${item.frames.length}`
|
||||||
|
if (item.frames.length > 0 && !autoTriggeredRef.current.has(planKey)) {
|
||||||
|
autoTriggeredRef.current.add(planKey)
|
||||||
|
void handlePlanStoryboardJob(item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [handleAnalyzeJob, handlePlanStoryboardJob, handleTranscribeAudio, jobs, productionJobIds])
|
||||||
|
|
||||||
const handleQuickGenerateVideo = useCallback(async (frameIdx: number, scene: StoryboardScene, model: string) => {
|
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)
|
||||||
@@ -459,7 +581,7 @@ export default function Home() {
|
|||||||
label: `分镜 ${frameIdx + 1} 首帧`,
|
label: `分镜 ${frameIdx + 1} 首帧`,
|
||||||
}
|
}
|
||||||
const orderedSelected = job.frames
|
const orderedSelected = job.frames
|
||||||
.filter((f) => selectedFrames.has(f.index))
|
.filter((f) => selectedFrames.size === 0 || selectedFrames.has(f.index))
|
||||||
.sort((a, b) => a.timestamp - b.timestamp)
|
.sort((a, b) => a.timestamp - b.timestamp)
|
||||||
const nextFrame = orderedSelected.find((f) => f.timestamp > frame.timestamp) ?? null
|
const nextFrame = orderedSelected.find((f) => f.timestamp > frame.timestamp) ?? null
|
||||||
const defaultLastRef: ImageRef | null = nextFrame
|
const defaultLastRef: ImageRef | null = nextFrame
|
||||||
@@ -467,7 +589,26 @@ export default function Home() {
|
|||||||
: null
|
: null
|
||||||
const firstRef = scene.first_image ?? keyframeRef
|
const firstRef = scene.first_image ?? keyframeRef
|
||||||
const lastRef = scene.last_image ?? defaultLastRef
|
const lastRef = scene.last_image ?? defaultLastRef
|
||||||
const productRefs = (scene.product_images?.length ? scene.product_images : scene.product_image ? [scene.product_image] : [])
|
let productRefs = (scene.product_images?.length ? scene.product_images : scene.product_image ? [scene.product_image] : [])
|
||||||
|
if (productRefs.length === 0) {
|
||||||
|
try {
|
||||||
|
productRefs = await ensureDefaultProductRefs(job.id)
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("默认 SKG 产品图准备失败:" + (e instanceof Error ? e.message : String(e)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const subjectRefs: ImageRef[] = (frame.elements ?? [])
|
||||||
|
.flatMap((element) => element.subject_assets ?? [])
|
||||||
|
.slice(0, 6)
|
||||||
|
.map((asset) => ({
|
||||||
|
kind: "asset",
|
||||||
|
frame_idx: frameIdx,
|
||||||
|
element_id: asset.id,
|
||||||
|
cutout_id: asset.id,
|
||||||
|
label: asset.label,
|
||||||
|
}))
|
||||||
|
const primarySubjectRef = subjectRefs[0] ?? firstRef
|
||||||
const duration = scene.duration && scene.duration > 0 ? scene.duration : 5
|
const duration = scene.duration && scene.duration > 0 ? scene.duration : 5
|
||||||
const sourceScene = frame.description?.scene ? `参考画面识别:${frame.description.scene}` : ""
|
const sourceScene = frame.description?.scene ? `参考画面识别:${frame.description.scene}` : ""
|
||||||
const sourceStyle = frame.description?.style ? `参考风格:${frame.description.style}` : ""
|
const sourceStyle = frame.description?.style ? `参考风格:${frame.description.style}` : ""
|
||||||
@@ -507,6 +648,7 @@ export default function Home() {
|
|||||||
`首帧:${labelOf(firstRef, "当前分镜关键帧")}`,
|
`首帧:${labelOf(firstRef, "当前分镜关键帧")}`,
|
||||||
`尾帧:${labelOf(lastRef, "未指定,按首帧小幅自然运动收尾")}`,
|
`尾帧:${labelOf(lastRef, "未指定,按首帧小幅自然运动收尾")}`,
|
||||||
`SKG 产品参考:${productRefs.length ? productRefs.map((ref, i) => `${i + 1}. ${labelOf(ref, "SKG 产品角度")}`).join(";") : "SKG 产品视觉主角"}`,
|
`SKG 产品参考:${productRefs.length ? productRefs.map((ref, i) => `${i + 1}. ${labelOf(ref, "SKG 产品角度")}`).join(";") : "SKG 产品视觉主角"}`,
|
||||||
|
subjectRefs.length ? `关键元素 6 视图参考:${subjectRefs.map((ref, i) => `${i + 1}. ${labelOf(ref, "元素视图")}`).join(";")}` : "如果该分镜还没有关键元素 6 视图,优先使用首帧主体关系生成。",
|
||||||
sourceScene,
|
sourceScene,
|
||||||
sourceStyle,
|
sourceStyle,
|
||||||
sourceObjects,
|
sourceObjects,
|
||||||
@@ -528,7 +670,8 @@ export default function Home() {
|
|||||||
first_image: firstRef,
|
first_image: firstRef,
|
||||||
last_image: lastRef,
|
last_image: lastRef,
|
||||||
product_images: productRefs,
|
product_images: productRefs,
|
||||||
subject_image: firstRef,
|
subject_image: primarySubjectRef,
|
||||||
|
subject_images: subjectRefs,
|
||||||
scene_image: null,
|
scene_image: null,
|
||||||
product_image: productRefs[0] ?? null,
|
product_image: productRefs[0] ?? null,
|
||||||
action_image: null,
|
action_image: null,
|
||||||
@@ -542,7 +685,7 @@ export default function Home() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error("提交视频失败:" + (e instanceof Error ? e.message : String(e)))
|
toast.error("提交视频失败:" + (e instanceof Error ? e.message : String(e)))
|
||||||
}
|
}
|
||||||
}, [job, selectedFrames, updateJobInList])
|
}, [ensureDefaultProductRefs, job, selectedFrames, updateJobInList])
|
||||||
|
|
||||||
const handleGenerateProductFusionVideo = useCallback(async (frameIdx: number, shot: ProductFusionShot) => {
|
const handleGenerateProductFusionVideo = useCallback(async (frameIdx: number, shot: ProductFusionShot) => {
|
||||||
if (!job) return
|
if (!job) return
|
||||||
@@ -730,6 +873,7 @@ export default function Home() {
|
|||||||
videoPanelScale,
|
videoPanelScale,
|
||||||
videoPanelDock,
|
videoPanelDock,
|
||||||
onSubmitUrl: handleSubmit,
|
onSubmitUrl: handleSubmit,
|
||||||
|
onStartProduction: handleStartProduction,
|
||||||
onUploadFile: handleUpload,
|
onUploadFile: handleUpload,
|
||||||
onAnalyze: handleAnalyze,
|
onAnalyze: handleAnalyze,
|
||||||
onAnalyzeJob: handleAnalyzeJob,
|
onAnalyzeJob: handleAnalyzeJob,
|
||||||
@@ -766,7 +910,7 @@ export default function Home() {
|
|||||||
onOpenAudioStrip: handleOpenAudioStrip,
|
onOpenAudioStrip: handleOpenAudioStrip,
|
||||||
pinnedNodes,
|
pinnedNodes,
|
||||||
onToggleNodePin: handleToggleNodePin,
|
onToggleNodePin: handleToggleNodePin,
|
||||||
}), [job, jobs, activeJobId, submitting, analyzing, frameTargets, frameCounts, frameQualities, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, framePanelDock, videoPanelJobId, videoPanelScale, videoPanelDock, handleSubmit, handleUpload, handleAnalyze, handleAnalyzeJob, handleFrameTargetChange, handleFrameCountChange, handleFrameQualityChange, handleToggleFrame, handleOpenFramePanel, handleFramePanelScaleChange, handleCloseExpandedFrame, handleAddManualFrame, handleAddManualFrameForJob, handleOpenVideoPanel, handleVideoPanelScaleChange, handleSwitchJob, updateJobInList, handleDeleteJob, handleDeleteFrame, handleDeleteFrameForJob, handleDeleteGenerated, handleDeleteVideo, handleDeleteCutout, handleOpenStoryboard, handleOpenWorkbench, clipboard, handleCopyImage, handleGenerateProductFusionVideo, handleTranscribeAudio, handleOpenAudioStrip, pinnedNodes, handleToggleNodePin])
|
}), [job, jobs, activeJobId, submitting, analyzing, frameTargets, frameCounts, frameQualities, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, framePanelDock, videoPanelJobId, videoPanelScale, videoPanelDock, handleSubmit, handleStartProduction, handleUpload, handleAnalyze, handleAnalyzeJob, handleFrameTargetChange, handleFrameCountChange, handleFrameQualityChange, handleToggleFrame, handleOpenFramePanel, handleFramePanelScaleChange, handleCloseExpandedFrame, handleAddManualFrame, handleAddManualFrameForJob, handleOpenVideoPanel, handleVideoPanelScaleChange, handleSwitchJob, updateJobInList, handleDeleteJob, handleDeleteFrame, handleDeleteFrameForJob, handleDeleteGenerated, handleDeleteVideo, handleDeleteCutout, handleOpenStoryboard, handleOpenWorkbench, clipboard, handleCopyImage, handleGenerateProductFusionVideo, handleTranscribeAudio, handleOpenAudioStrip, pinnedNodes, handleToggleNodePin])
|
||||||
|
|
||||||
// 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag)
|
// 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag)
|
||||||
const savedSizes = useMemo(() => loadNodeSizes(), [])
|
const savedSizes = useMemo(() => loadNodeSizes(), [])
|
||||||
|
|||||||
@@ -9,14 +9,18 @@ import { toast } from "sonner"
|
|||||||
import {
|
import {
|
||||||
type FrameExtractQuality,
|
type FrameExtractQuality,
|
||||||
type FrameExtractTarget,
|
type FrameExtractTarget,
|
||||||
|
type FrameObject,
|
||||||
type GeneratedVideo,
|
type GeneratedVideo,
|
||||||
type Job,
|
type Job,
|
||||||
|
type KeyElement,
|
||||||
type KeyFrame,
|
type KeyFrame,
|
||||||
type StoryboardScene,
|
type StoryboardScene,
|
||||||
|
type SubjectKind,
|
||||||
addElement,
|
addElement,
|
||||||
apiAssetUrl,
|
apiAssetUrl,
|
||||||
cutoutElement,
|
cutoutElement,
|
||||||
effectiveFrameUrl,
|
effectiveFrameUrl,
|
||||||
|
generateSubjectAssets,
|
||||||
generatedImageUrl,
|
generatedImageUrl,
|
||||||
hasCutout,
|
hasCutout,
|
||||||
representativeCutoutUrl,
|
representativeCutoutUrl,
|
||||||
@@ -120,6 +124,34 @@ function countReadySegments(job: Job | null, drafts: DraftSegment[]) {
|
|||||||
return frameStoryboards + draftCount
|
return frameStoryboards + draftCount
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function guessSubjectKind(name: string): SubjectKind {
|
||||||
|
return /人|人物|模特|骨架|身体|脸|手|person|people|human|body|face|hand|character/i.test(name)
|
||||||
|
? "living"
|
||||||
|
: "object"
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFallbackScene(job: Job, frame: KeyFrame, order: number): StoryboardScene {
|
||||||
|
const frames = [...job.frames].sort((a, b) => a.timestamp - b.timestamp)
|
||||||
|
const nextFrame = frames.find((item) => item.timestamp > frame.timestamp) ?? null
|
||||||
|
const duration = Math.max(3.5, Math.min(7.5, Math.max(job.duration || 0, frames.length * 5) / Math.max(frames.length, 1)))
|
||||||
|
const audio = job.audio_script?.rewritten_text?.trim()
|
||||||
|
|| job.transcript?.slice(0, 4).map((item) => item.en || item.zh).filter(Boolean).join(" ")
|
||||||
|
|| "按原音频说话节奏改写为 SKG 产品介绍。"
|
||||||
|
const objects = frame.description?.objects?.slice(0, 5).map((item) => item.name).filter(Boolean).join("、")
|
||||||
|
return {
|
||||||
|
duration: Number(duration.toFixed(1)),
|
||||||
|
first_image: { kind: "keyframe", frame_idx: frame.index, label: `分镜 ${order + 1} 首帧` },
|
||||||
|
last_image: nextFrame ? { kind: "keyframe", frame_idx: nextFrame.index, label: `分镜 ${order + 1} 尾帧` } : null,
|
||||||
|
subject: objects ? `关键元素候选:${objects}` : "保留原视频最重要的主体动作和构图关系。",
|
||||||
|
scene: `${frame.description?.scene || `参考第 ${order + 1} 个关键画面规划 SKG 信息流广告分镜。`}\n音频节奏依据:${audio.slice(0, 220)}`,
|
||||||
|
product: "把原素材里的产品/痛点转成 SKG 颈部/肩颈按摩仪表达,默认使用 SKG 四张产品角度图做产品真源。",
|
||||||
|
action: frame.description?.style
|
||||||
|
? `沿用原画面的讲话节奏、动作节点和 ${frame.description.style},突出使用前紧绷、使用后放松。`
|
||||||
|
: "沿用原视频的讲话节奏和动作节点,突出使用前紧绷、使用后放松。",
|
||||||
|
reference_ids: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function AdRecreationBoard({
|
export function AdRecreationBoard({
|
||||||
data,
|
data,
|
||||||
onGenerateVideo,
|
onGenerateVideo,
|
||||||
@@ -132,6 +164,8 @@ export function AdRecreationBoard({
|
|||||||
const [selectedVideoIds, setSelectedVideoIds] = useState<Set<string>>(new Set())
|
const [selectedVideoIds, setSelectedVideoIds] = useState<Set<string>>(new Set())
|
||||||
const [draftSegments, setDraftSegments] = useState<DraftSegment[]>([])
|
const [draftSegments, setDraftSegments] = useState<DraftSegment[]>([])
|
||||||
const [elementBusyFrame, setElementBusyFrame] = useState<number | null>(null)
|
const [elementBusyFrame, setElementBusyFrame] = useState<number | null>(null)
|
||||||
|
const [sixViewBusyKey, setSixViewBusyKey] = useState<string | null>(null)
|
||||||
|
const [generatingAll, setGeneratingAll] = useState(false)
|
||||||
const fileRef = useRef<HTMLInputElement | null>(null)
|
const fileRef = useRef<HTMLInputElement | null>(null)
|
||||||
const selectedFrames = job
|
const selectedFrames = job
|
||||||
? job.frames.filter((frame) => data.selectedFrames.has(frame.index)).sort((a, b) => a.timestamp - b.timestamp)
|
? job.frames.filter((frame) => data.selectedFrames.has(frame.index)).sort((a, b) => a.timestamp - b.timestamp)
|
||||||
@@ -153,6 +187,12 @@ export function AdRecreationBoard({
|
|||||||
setUrl("")
|
setUrl("")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const startProduction = () => {
|
||||||
|
const trimmed = url.trim()
|
||||||
|
data.onStartProduction?.(trimmed || undefined)
|
||||||
|
if (trimmed) setUrl("")
|
||||||
|
}
|
||||||
|
|
||||||
const selectAllFrames = () => {
|
const selectAllFrames = () => {
|
||||||
if (!job) return
|
if (!job) return
|
||||||
for (const frame of job.frames) {
|
for (const frame of job.frames) {
|
||||||
@@ -195,40 +235,103 @@ export function AdRecreationBoard({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const generateElementForFrame = async (frame: KeyFrame) => {
|
const generateElementForFrame = async (frame: KeyFrame, candidate?: FrameObject, withSixViews = true) => {
|
||||||
if (!job) return
|
if (!job) return
|
||||||
setElementBusyFrame(frame.index)
|
setElementBusyFrame(frame.index)
|
||||||
|
const candidateName = candidate?.name?.trim()
|
||||||
try {
|
try {
|
||||||
const existing = frame.elements?.[0]
|
let workingJob = job
|
||||||
if (existing) {
|
let workingFrame = workingJob.frames.find((item) => item.index === frame.index) ?? frame
|
||||||
const updated = await cutoutElement(job.id, frame.index, existing.id)
|
const existing = workingFrame.elements?.find((item) =>
|
||||||
data.onJobUpdate(updated)
|
candidateName
|
||||||
toast.success(`已生成元素:${existing.name_zh || existing.name_en || "主体"}`)
|
? [item.name_zh, item.name_en].some((name) => name?.trim() === candidateName)
|
||||||
return
|
: true,
|
||||||
|
)
|
||||||
|
const sourceObject = candidate ?? workingFrame.description?.objects?.[0]
|
||||||
|
const name = candidateName || sourceObject?.name?.trim() || existing?.name_zh || existing?.name_en || "主体"
|
||||||
|
let element = existing
|
||||||
|
|
||||||
|
if (!element) {
|
||||||
|
workingJob = await addElement(job.id, frame.index, {
|
||||||
|
name_zh: name,
|
||||||
|
name_en: name,
|
||||||
|
position: sourceObject?.position,
|
||||||
|
source: "manual",
|
||||||
|
})
|
||||||
|
data.onJobUpdate(workingJob)
|
||||||
|
workingFrame = workingJob.frames.find((item) => item.index === frame.index) ?? workingFrame
|
||||||
|
element = workingFrame.elements?.[workingFrame.elements.length - 1]
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstObject = frame.description?.objects?.[0]
|
if (!element) {
|
||||||
const name = firstObject?.name?.trim() || "主体"
|
|
||||||
const added = await addElement(job.id, frame.index, {
|
|
||||||
name_zh: name,
|
|
||||||
name_en: name,
|
|
||||||
position: firstObject?.position,
|
|
||||||
source: "manual",
|
|
||||||
})
|
|
||||||
const latestFrame = added.frames.find((item) => item.index === frame.index)
|
|
||||||
const newElement = latestFrame?.elements?.[latestFrame.elements.length - 1]
|
|
||||||
if (!newElement) {
|
|
||||||
data.onJobUpdate(added)
|
|
||||||
toast.success(`已登记元素:${name}`)
|
toast.success(`已登记元素:${name}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const updated = await cutoutElement(job.id, frame.index, newElement.id)
|
|
||||||
data.onJobUpdate(updated)
|
if (!hasCutout(element)) {
|
||||||
toast.success(`已生成元素:${name}`)
|
workingJob = await cutoutElement(job.id, frame.index, element.id)
|
||||||
|
data.onJobUpdate(workingJob)
|
||||||
|
workingFrame = workingJob.frames.find((item) => item.index === frame.index) ?? workingFrame
|
||||||
|
element = workingFrame.elements?.find((item) => item.id === element?.id) ?? element
|
||||||
|
}
|
||||||
|
|
||||||
|
if (withSixViews && !element.subject_assets?.length) {
|
||||||
|
setSixViewBusyKey(`${frame.index}:${element.id}`)
|
||||||
|
workingJob = await generateSubjectAssets(job.id, frame.index, element.id, {
|
||||||
|
subject_kind: guessSubjectKind(name),
|
||||||
|
background: "white",
|
||||||
|
size: "1024",
|
||||||
|
source_frame_indices: framesForSegments.slice(0, 6).map((item) => item.index),
|
||||||
|
})
|
||||||
|
data.onJobUpdate(workingJob)
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(`已准备关键元素:${name}`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error("元素生成失败:" + (e instanceof Error ? e.message : String(e)))
|
toast.error("元素生成失败:" + (e instanceof Error ? e.message : String(e)))
|
||||||
} finally {
|
} finally {
|
||||||
setElementBusyFrame(null)
|
setElementBusyFrame(null)
|
||||||
|
setSixViewBusyKey(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateSixViewsForElement = async (frame: KeyFrame, element: KeyElement) => {
|
||||||
|
if (!job) return
|
||||||
|
setSixViewBusyKey(`${frame.index}:${element.id}`)
|
||||||
|
try {
|
||||||
|
const updated = await generateSubjectAssets(job.id, frame.index, element.id, {
|
||||||
|
subject_kind: guessSubjectKind(element.name_zh || element.name_en || "主体"),
|
||||||
|
background: "white",
|
||||||
|
size: "1024",
|
||||||
|
source_frame_indices: framesForSegments.slice(0, 6).map((item) => item.index),
|
||||||
|
})
|
||||||
|
data.onJobUpdate(updated)
|
||||||
|
toast.success(`6 视图已生成:${element.name_zh || element.name_en}`)
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("6 视图生成失败:" + (e instanceof Error ? e.message : String(e)))
|
||||||
|
} finally {
|
||||||
|
setSixViewBusyKey(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateAllVideos = async () => {
|
||||||
|
if (!job || framesForSegments.length === 0) return
|
||||||
|
setGeneratingAll(true)
|
||||||
|
try {
|
||||||
|
for (let order = 0; order < framesForSegments.length; order += 1) {
|
||||||
|
const frame = framesForSegments[order]
|
||||||
|
const scene = frame.storyboard ?? buildFallbackScene(job, frame, order)
|
||||||
|
if (!frame.storyboard) {
|
||||||
|
const updated = await updateStoryboard(job.id, frame.index, scene)
|
||||||
|
data.onJobUpdate(updated)
|
||||||
|
}
|
||||||
|
await onGenerateVideo(frame.index, scene, "seedance")
|
||||||
|
}
|
||||||
|
toast.success(`已提交 ${framesForSegments.length} 条分镜视频`)
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("批量生成失败:" + (e instanceof Error ? e.message : String(e)))
|
||||||
|
} finally {
|
||||||
|
setGeneratingAll(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,6 +363,7 @@ export function AdRecreationBoard({
|
|||||||
setUrl={setUrl}
|
setUrl={setUrl}
|
||||||
fileRef={fileRef}
|
fileRef={fileRef}
|
||||||
onSubmitUrl={submitUrl}
|
onSubmitUrl={submitUrl}
|
||||||
|
onStartProduction={startProduction}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section className="flex min-h-0 flex-col rounded-lg border border-white/10 bg-white/[0.035] shadow-2xl">
|
<section className="flex min-h-0 flex-col rounded-lg border border-white/10 bg-white/[0.035] shadow-2xl">
|
||||||
@@ -285,6 +389,10 @@ export function AdRecreationBoard({
|
|||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
追加分镜
|
追加分镜
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
|
<ActionButton disabled={!framesForSegments.length || generatingAll} onClick={generateAllVideos}>
|
||||||
|
{generatingAll ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
|
||||||
|
生成全部视频
|
||||||
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -321,9 +429,11 @@ export function AdRecreationBoard({
|
|||||||
selectedVideoIds={selectedVideoIds}
|
selectedVideoIds={selectedVideoIds}
|
||||||
videos={generatedVideos.filter((video) => video.frame_idx === frame.index)}
|
videos={generatedVideos.filter((video) => video.frame_idx === frame.index)}
|
||||||
busy={elementBusyFrame === frame.index}
|
busy={elementBusyFrame === frame.index}
|
||||||
|
sixViewBusyKey={sixViewBusyKey}
|
||||||
onToggleFrame={() => data.onToggleFrame(frame.index)}
|
onToggleFrame={() => data.onToggleFrame(frame.index)}
|
||||||
onJobUpdate={data.onJobUpdate}
|
onJobUpdate={data.onJobUpdate}
|
||||||
onGenerateElement={() => generateElementForFrame(frame)}
|
onGenerateElement={(candidate) => generateElementForFrame(frame, candidate)}
|
||||||
|
onGenerateSixViews={(element) => generateSixViewsForElement(frame, element)}
|
||||||
onGenerateVideo={onGenerateVideo}
|
onGenerateVideo={onGenerateVideo}
|
||||||
onToggleVideo={toggleVideo}
|
onToggleVideo={toggleVideo}
|
||||||
onDeleteVideo={(videoId) => data.onDeleteVideo?.(videoId)}
|
onDeleteVideo={(videoId) => data.onDeleteVideo?.(videoId)}
|
||||||
@@ -373,6 +483,7 @@ function MaterialColumn({
|
|||||||
setUrl,
|
setUrl,
|
||||||
fileRef,
|
fileRef,
|
||||||
onSubmitUrl,
|
onSubmitUrl,
|
||||||
|
onStartProduction,
|
||||||
}: {
|
}: {
|
||||||
data: NodeData
|
data: NodeData
|
||||||
jobs: Job[]
|
jobs: Job[]
|
||||||
@@ -382,6 +493,7 @@ function MaterialColumn({
|
|||||||
setUrl: (value: string) => void
|
setUrl: (value: string) => void
|
||||||
fileRef: RefObject<HTMLInputElement | null>
|
fileRef: RefObject<HTMLInputElement | null>
|
||||||
onSubmitUrl: () => void
|
onSubmitUrl: () => void
|
||||||
|
onStartProduction: () => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className="flex min-h-0 flex-col gap-3 rounded-lg border border-white/10 bg-white/[0.035] p-3 shadow-2xl">
|
<section className="flex min-h-0 flex-col gap-3 rounded-lg border border-white/10 bg-white/[0.035] p-3 shadow-2xl">
|
||||||
@@ -404,11 +516,11 @@ function MaterialColumn({
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onSubmitUrl}
|
onClick={onStartProduction}
|
||||||
disabled={data.submitting || !url.trim()}
|
disabled={data.submitting || (!url.trim() && !job)}
|
||||||
className="inline-flex h-10 items-center justify-center rounded-md bg-rose-600 px-3 text-[13px] font-semibold text-white transition hover:bg-rose-500 disabled:cursor-not-allowed disabled:opacity-45"
|
className="inline-flex h-10 items-center justify-center rounded-md bg-rose-600 px-3 text-[13px] font-semibold text-white transition hover:bg-rose-500 disabled:cursor-not-allowed disabled:opacity-45"
|
||||||
>
|
>
|
||||||
导入
|
开始
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -525,9 +637,11 @@ function StoryboardSegmentCard({
|
|||||||
selectedVideoIds,
|
selectedVideoIds,
|
||||||
videos,
|
videos,
|
||||||
busy,
|
busy,
|
||||||
|
sixViewBusyKey,
|
||||||
onToggleFrame,
|
onToggleFrame,
|
||||||
onJobUpdate,
|
onJobUpdate,
|
||||||
onGenerateElement,
|
onGenerateElement,
|
||||||
|
onGenerateSixViews,
|
||||||
onGenerateVideo,
|
onGenerateVideo,
|
||||||
onToggleVideo,
|
onToggleVideo,
|
||||||
onDeleteVideo,
|
onDeleteVideo,
|
||||||
@@ -539,9 +653,11 @@ function StoryboardSegmentCard({
|
|||||||
selectedVideoIds: Set<string>
|
selectedVideoIds: Set<string>
|
||||||
videos: GeneratedVideo[]
|
videos: GeneratedVideo[]
|
||||||
busy: boolean
|
busy: boolean
|
||||||
|
sixViewBusyKey: string | null
|
||||||
onToggleFrame: () => void
|
onToggleFrame: () => void
|
||||||
onJobUpdate: (job: Job) => void
|
onJobUpdate: (job: Job) => void
|
||||||
onGenerateElement: () => void
|
onGenerateElement: (candidate?: FrameObject) => void
|
||||||
|
onGenerateSixViews: (element: KeyElement) => void
|
||||||
onGenerateVideo: (frameIdx: number, scene: StoryboardScene, model: string) => Promise<void> | void
|
onGenerateVideo: (frameIdx: number, scene: StoryboardScene, model: string) => Promise<void> | void
|
||||||
onToggleVideo: (videoId: string) => void
|
onToggleVideo: (videoId: string) => void
|
||||||
onDeleteVideo?: (videoId: string) => void
|
onDeleteVideo?: (videoId: string) => void
|
||||||
@@ -552,7 +668,8 @@ function StoryboardSegmentCard({
|
|||||||
const [generatingVideo, setGeneratingVideo] = useState(false)
|
const [generatingVideo, setGeneratingVideo] = useState(false)
|
||||||
const elements = frame.elements ?? []
|
const elements = frame.elements ?? []
|
||||||
const generatedImages = frame.generated_images ?? []
|
const generatedImages = frame.generated_images ?? []
|
||||||
const objectNames = frame.description?.objects?.slice(0, 4).map((item) => item.name).filter(Boolean) ?? []
|
const objectCandidates = frame.description?.objects?.slice(0, 8).filter((item) => item.name?.trim()) ?? []
|
||||||
|
const objectNames = objectCandidates.map((item) => item.name)
|
||||||
const elementPreviews = elements
|
const elementPreviews = elements
|
||||||
.map((element) => ({ element, src: representativeCutoutUrl(job.id, frame.index, element) }))
|
.map((element) => ({ element, src: representativeCutoutUrl(job.id, frame.index, element) }))
|
||||||
.filter((item): item is { element: typeof elements[number]; src: string } => !!item.src)
|
.filter((item): item is { element: typeof elements[number]; src: string } => !!item.src)
|
||||||
@@ -660,11 +777,41 @@ function StoryboardSegmentCard({
|
|||||||
<span>{generatedImages.length} 张生成图</span>
|
<span>{generatedImages.length} 张生成图</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-2 flex flex-wrap gap-1">
|
<div className="mb-2 flex flex-wrap gap-1">
|
||||||
{(elements.length ? elements.map((item) => item.name_zh || item.name_en) : objectNames).slice(0, 8).map((name) => (
|
{objectCandidates.length > 0 && objectCandidates.map((candidate) => (
|
||||||
<span key={name} className="rounded-md border border-white/10 bg-white/[0.04] px-1.5 py-1 text-[10px] text-white/50">{name}</span>
|
<button
|
||||||
|
key={`${candidate.name}:${candidate.position ?? ""}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onGenerateElement(candidate)}
|
||||||
|
disabled={busy}
|
||||||
|
className="rounded-md border border-white/10 bg-white/[0.04] px-1.5 py-1 text-[10px] text-white/55 transition hover:border-cyan-300/35 hover:text-cyan-100 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
title="选择该元素并生成提取图 + 6 视图"
|
||||||
|
>
|
||||||
|
{candidate.name}
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
{!elements.length && !objectNames.length && <span className="text-[11px] text-white/32">暂无识别元素</span>}
|
{!objectCandidates.length && !elements.length && <span className="text-[11px] text-white/32">暂无识别元素</span>}
|
||||||
</div>
|
</div>
|
||||||
|
{elements.length > 0 && (
|
||||||
|
<div className="mb-2 grid gap-1">
|
||||||
|
{elements.slice(0, 5).map((element) => {
|
||||||
|
const busySix = sixViewBusyKey === `${frame.index}:${element.id}`
|
||||||
|
return (
|
||||||
|
<div key={element.id} className="flex items-center justify-between gap-2 rounded-md border border-white/10 bg-black/25 px-2 py-1.5">
|
||||||
|
<span className="min-w-0 truncate text-[11px] text-white/62">{element.name_zh || element.name_en}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onGenerateSixViews(element)}
|
||||||
|
disabled={busySix}
|
||||||
|
className="inline-flex h-7 shrink-0 items-center gap-1 rounded-md border border-white/10 px-2 text-[10px] text-white/55 transition hover:border-cyan-300/35 hover:text-cyan-100 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{busySix ? <Loader2 className="h-3 w-3 animate-spin" /> : <ImageIcon className="h-3 w-3" />}
|
||||||
|
{element.subject_assets?.length ? `${element.subject_assets.length}视图` : "6视图"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{(elementPreviews.length > 0 || generatedImages.length > 0) && (
|
{(elementPreviews.length > 0 || generatedImages.length > 0) && (
|
||||||
<div className="flex gap-1 overflow-x-auto pb-1">
|
<div className="flex gap-1 overflow-x-auto pb-1">
|
||||||
{elementPreviews.slice(0, 6).map(({ element, src }) => (
|
{elementPreviews.slice(0, 6).map(({ element, src }) => (
|
||||||
@@ -676,9 +823,9 @@ function StoryboardSegmentCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ActionButton disabled={busy} variant="ghost" onClick={onGenerateElement}>
|
<ActionButton disabled={busy} variant="ghost" onClick={() => onGenerateElement()}>
|
||||||
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
|
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
|
||||||
生成元素
|
提取+6视图
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
</SegmentBand>
|
</SegmentBand>
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ export interface NodeData {
|
|||||||
videoPanelJobId?: string | null
|
videoPanelJobId?: string | null
|
||||||
videoPanelScale?: number
|
videoPanelScale?: number
|
||||||
videoPanelDock?: CanvasPanelDock
|
videoPanelDock?: CanvasPanelDock
|
||||||
onSubmitUrl: (url: string) => void
|
onSubmitUrl: (url: string) => Promise<Job | void> | Job | void
|
||||||
|
onStartProduction?: (url?: string) => Promise<void> | void
|
||||||
onUploadFile: (file: File) => void
|
onUploadFile: (file: File) => void
|
||||||
onAnalyze: (options?: { mode?: FrameExtractMode }) => void
|
onAnalyze: (options?: { mode?: FrameExtractMode }) => void
|
||||||
onAnalyzeJob: (jobId: string, options?: { mode?: FrameExtractMode }) => void
|
onAnalyzeJob: (jobId: string, options?: { mode?: FrameExtractMode }) => void
|
||||||
|
|||||||
Reference in New Issue
Block a user