auto-save 2026-05-17 16:54 (~4)
This commit is contained in:
@@ -103,6 +103,8 @@ const PRODUCT_VIEW_SLOTS = [
|
||||
{ value: "back_bottom", label: "背面/底部", hint: "底面、背部闭合结构、补缺" },
|
||||
] as const
|
||||
|
||||
const MAX_PRODUCT_REFS_PER_VIDEO = 6
|
||||
|
||||
const controlClass =
|
||||
"h-10 rounded-md border border-white/10 bg-black/55 px-3 text-[12px] text-white outline-none transition focus:border-cyan-300/60 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
|
||||
@@ -355,12 +357,44 @@ function productReferenceNotes(items: ProductRefItem[]) {
|
||||
.join(";")
|
||||
}
|
||||
|
||||
function selectProductItemsForRow(row: AudioStoryboardRow, items: ProductRefItem[]) {
|
||||
if (!items.length) return []
|
||||
const priorityByRole: Record<string, string[]> = {
|
||||
"开场钩子": ["front", "left_45", "right_45", "side_thickness"],
|
||||
"痛点推进": ["front", "side_thickness", "left_45", "right_45"],
|
||||
"利益证明": ["front", "inner_contacts", "side_thickness", "left_45", "right_45", "back_bottom"],
|
||||
"方案过渡": ["front", "left_45", "right_45", "inner_contacts", "side_thickness"],
|
||||
"转化收口": ["front", "left_45", "right_45", "back_bottom", "inner_contacts"],
|
||||
"节奏承接": ["front", "left_45", "right_45", "side_thickness"],
|
||||
}
|
||||
const priority = priorityByRole[row.role] ?? priorityByRole["节奏承接"]
|
||||
const picked: ProductRefItem[] = []
|
||||
const pickedIds = new Set<string>()
|
||||
const add = (item?: ProductRefItem) => {
|
||||
if (!item || pickedIds.has(item.id) || picked.length >= MAX_PRODUCT_REFS_PER_VIDEO) return
|
||||
picked.push(item)
|
||||
pickedIds.add(item.id)
|
||||
}
|
||||
|
||||
for (const view of priority) {
|
||||
const matches = items.filter((item) => item.view === view)
|
||||
add(matches[row.index % Math.max(matches.length, 1)])
|
||||
}
|
||||
|
||||
for (let i = 0; picked.length < Math.min(MAX_PRODUCT_REFS_PER_VIDEO, items.length) && i < items.length; i += 1) {
|
||||
add(items[(row.index + i) % items.length])
|
||||
}
|
||||
|
||||
return picked
|
||||
}
|
||||
|
||||
function buildStoryboardSceneFromAudioRow(row: AudioStoryboardRow, frame: KeyFrame, nextFrame?: KeyFrame | null, productItems: ProductRefItem[] = []): StoryboardScene {
|
||||
const productRefs = productItems.map((item) => item.ref)
|
||||
const notes = productReferenceNotes(productItems)
|
||||
const selectedProductItems = selectProductItemsForRow(row, productItems)
|
||||
const productRefs = selectedProductItems.map((item) => item.ref)
|
||||
const notes = productReferenceNotes(selectedProductItems)
|
||||
const productGuidance = productItems.length
|
||||
? `产品白底图已上传:生成时必须同时参考各视角备注。视角备注:${notes}。保留左右非对称细节,不要把两边做成镜像对称;肩颈产品大小必须贴近真实佩戴比例,不能缩成耳机,也不能放大成护颈枕。`
|
||||
: "未上传产品白底图时使用默认 SKG 产品图;生成前建议补 5 张白底图锁定左右差异、厚度和佩戴比例。"
|
||||
? `产品素材池共有 ${productItems.length} 张,本条只选用 ${selectedProductItems.length} 张最相关参考图,不要把未选素材混入本条画面。视角备注:${notes}。保留左右非对称细节,不要把两边做成镜像对称;肩颈产品大小必须贴近真实佩戴比例,不能缩成耳机,也不能放大成护颈枕。`
|
||||
: "未上传产品白底图时使用默认 SKG 产品图;生成前建议先建立产品素材池,锁定左右差异、厚度和佩戴比例。"
|
||||
return {
|
||||
duration: Number(Math.max(3.2, Math.min(6.5, row.end - row.start || 4.5)).toFixed(1)),
|
||||
first_image: { kind: "keyframe", frame_idx: frame.index, label: `分镜 ${row.index + 1} 参考帧` },
|
||||
@@ -1034,11 +1068,10 @@ function AudioStoryboardPlanPanel({
|
||||
|
||||
const completeMissingProductAngles = async (seedItems: ProductRefItem[]) => {
|
||||
if (!job || !seedItems.length) return seedItems
|
||||
let working = seedItems.slice(0, 6)
|
||||
let working = seedItems
|
||||
const failures: string[] = []
|
||||
const missing = PRODUCT_VIEW_SLOTS
|
||||
.filter((slot) => !working.some((item) => item.view === slot.value))
|
||||
.slice(0, Math.max(0, 6 - working.length))
|
||||
|
||||
for (const slot of missing) {
|
||||
setProductAngleBusy(slot.value)
|
||||
@@ -1051,7 +1084,7 @@ function AudioStoryboardPlanPanel({
|
||||
working = [
|
||||
...working,
|
||||
createProductRefItem(ref, working.length, "ai", slot.value, `AI 补齐:${slot.hint}`, 1),
|
||||
].slice(0, 6)
|
||||
]
|
||||
setProductItems(working)
|
||||
} catch (e) {
|
||||
failures.push(`${slot.label}:${e instanceof Error ? e.message : String(e)}`)
|
||||
@@ -1067,17 +1100,16 @@ function AudioStoryboardPlanPanel({
|
||||
|
||||
const analyzeAndCompleteProductViews = async (refs: ImageRef[]) => {
|
||||
if (!job || !refs.length) return
|
||||
const limitedRefs = refs.slice(0, 6)
|
||||
setProductAnalyzing(true)
|
||||
setProductItems(limitedRefs.map((ref, index) => createProductRefItem(ref, index, itemSourceForRef(ref), undefined, "正在自动识别视角...")))
|
||||
setProductItems(refs.map((ref, index) => createProductRefItem(ref, index, itemSourceForRef(ref), undefined, "正在自动识别视角...")))
|
||||
try {
|
||||
const analysis = await analyzeProductViews(job.id, limitedRefs)
|
||||
const analyzed = buildAnalyzedProductItems(limitedRefs, analysis.items)
|
||||
const analysis = await analyzeProductViews(job.id, refs)
|
||||
const analyzed = buildAnalyzedProductItems(refs, analysis.items)
|
||||
setProductItems(analyzed)
|
||||
const completed = await completeMissingProductAngles(analyzed)
|
||||
toast.success(completed.length > analyzed.length ? "产品视角已自动识别并补齐缺失角度" : "产品视角已自动识别")
|
||||
} catch (e) {
|
||||
const fallback = limitedRefs.map((ref, index) => createProductRefItem(ref, index, itemSourceForRef(ref)))
|
||||
const fallback = refs.map((ref, index) => createProductRefItem(ref, index, itemSourceForRef(ref)))
|
||||
setProductItems(fallback)
|
||||
await completeMissingProductAngles(fallback)
|
||||
toast.warning("产品视角识别失败,已按默认顺序标注并尝试自动补图:" + (e instanceof Error ? e.message : String(e)))
|
||||
@@ -1089,16 +1121,11 @@ function AudioStoryboardPlanPanel({
|
||||
|
||||
const uploadProductImages = async (files: FileList | null) => {
|
||||
if (!job || !files?.length) return
|
||||
const remaining = Math.max(0, 6 - productItems.length)
|
||||
if (remaining === 0) {
|
||||
toast.info("产品白底图最多保留 6 张")
|
||||
return
|
||||
}
|
||||
const selected = Array.from(files).slice(0, remaining)
|
||||
const selected = Array.from(files)
|
||||
setProductUploading(true)
|
||||
try {
|
||||
const refs = await Promise.all(selected.map((file) => uploadStoryboardAsset(job.id, file)))
|
||||
const nextRefs = [...productItems.map((item) => item.ref), ...refs].slice(0, 6)
|
||||
const nextRefs = [...productItems.map((item) => item.ref), ...refs]
|
||||
await analyzeAndCompleteProductViews(nextRefs)
|
||||
toast.success(`已上传 ${refs.length} 张产品白底图`)
|
||||
} catch (e) {
|
||||
@@ -1131,7 +1158,7 @@ function AudioStoryboardPlanPanel({
|
||||
target_view: slot.label,
|
||||
note: slot.hint,
|
||||
})
|
||||
setProductItems((prev) => [...prev, createProductRefItem(ref, prev.length, "ai", slot.value, `AI 补齐:${slot.hint}`, 1)].slice(0, 6))
|
||||
setProductItems((prev) => [...prev, createProductRefItem(ref, prev.length, "ai", slot.value, `AI 补齐:${slot.hint}`, 1)])
|
||||
toast.success(`AI 已补全产品视角:${slot.label}`)
|
||||
} catch (e) {
|
||||
toast.error("AI 补角度失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
@@ -1178,7 +1205,7 @@ function AudioStoryboardPlanPanel({
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<SectionTitle icon={<Package className="h-4 w-4" />} title="产品白底图 / 视角补全" />
|
||||
<span className="rounded-md border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[10px] text-white/42">建议 5 张,最多 6 张</span>
|
||||
<span className="rounded-md border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[10px] text-white/42">{productItems.length ? `${productItems.length} 张素材` : "素材池不限量"}</span>
|
||||
{(productAnalyzing || productAngleBusy) && (
|
||||
<span className="inline-flex items-center gap-1 rounded-md border border-cyan-300/20 bg-cyan-300/[0.08] px-2 py-0.5 text-[10px] text-cyan-100/75">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
@@ -1187,7 +1214,7 @@ function AudioStoryboardPlanPanel({
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 max-w-[760px] text-[11px] leading-snug text-white/42">
|
||||
上传后自动识别每张图的角度和视角,并自动补齐缺失角度;人只需要检查备注。重点锁住左右非对称、厚度、内侧触点和肩颈真实佩戴比例。
|
||||
上传后自动识别每张图的角度和视角,并自动补齐缺失角度;产品素材池不限制数量。每条视频生成时只自动挑选最多 {MAX_PRODUCT_REFS_PER_VIDEO} 张相关产品图,避免把所有素材都塞给模型。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
@@ -1203,7 +1230,7 @@ function AudioStoryboardPlanPanel({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => productFileRef.current?.click()}
|
||||
disabled={!job || productUploading || productAnalyzing || !!productAngleBusy || productItems.length >= 6}
|
||||
disabled={!job || productUploading || productAnalyzing || !!productAngleBusy}
|
||||
className="inline-flex h-9 items-center justify-center gap-1 rounded-md border border-white/10 bg-white/[0.055] px-2.5 text-[11px] font-semibold text-white/72 transition hover:border-white/25 hover:bg-white/[0.1] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{productUploading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Upload className="h-3.5 w-3.5" />}
|
||||
@@ -1223,7 +1250,7 @@ function AudioStoryboardPlanPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2 2xl:grid-cols-3">
|
||||
<div className="grid max-h-[360px] gap-2 overflow-y-auto pr-1 md:grid-cols-2 2xl:grid-cols-3">
|
||||
{productItems.map((item) => (
|
||||
<ProductReferenceCard
|
||||
key={item.id}
|
||||
@@ -1237,7 +1264,7 @@ function AudioStoryboardPlanPanel({
|
||||
<MissingProductViewSlot
|
||||
key={slot.value}
|
||||
slot={slot}
|
||||
canGenerate={!!productItems.length && productItems.length < 6}
|
||||
canGenerate={!!productItems.length}
|
||||
busy={productAngleBusy === slot.value}
|
||||
blocked={productAnalyzing || !!productAngleBusy}
|
||||
onGenerate={() => generateMissingProductAngle(slot)}
|
||||
|
||||
Reference in New Issue
Block a user