auto-save 2026-05-14 10:14 (~7)

This commit is contained in:
2026-05-14 10:14:43 +08:00
parent 96784f9df1
commit ee32d83b6c
7 changed files with 2398 additions and 2330 deletions

View File

@@ -21,6 +21,7 @@ import {
deleteGeneratedVideo, deleteCutout, generateStoryboardVideo, createProductFusionGuide,
type Job, type ImageRef, type ProductFusionShot, type StoryboardScene, type FrameExtractMode, type FrameExtractQuality, type FrameExtractTarget,
} from "@/lib/api"
import { TRANSPARENT_HUMAN_NEGATIVE_PROMPT, TRANSPARENT_HUMAN_VIDEO_PROMPT } from "@/lib/workflow-target"
const NODE_TYPES = {
input: InputNode,
@@ -35,6 +36,7 @@ const KEYFRAME_PANEL_ID = "keyframe-detail-panel"
const VIDEO_FRAME_PANEL_ID = "video-frame-panel"
const FLOATING_PANEL_IDS = new Set([KEYFRAME_PANEL_ID, VIDEO_FRAME_PANEL_ID])
const FRAME_TARGET_LABELS: Record<FrameExtractTarget, string> = {
transparent_human: "透明骨架人",
balanced: "综合关键帧",
subject: "清晰主体",
transition: "转场变化",
@@ -177,7 +179,7 @@ export default function Home() {
const handleAnalyzeJob = useCallback(async (jobId: string, options?: { mode?: FrameExtractMode }) => {
const targetJob = jobs.find((item) => item.id === jobId)
if (!targetJob) return
const frameTarget = frameTargets[jobId] ?? "balanced"
const frameTarget = frameTargets[jobId] ?? "transparent_human"
const frameCount = frameCounts[jobId] ?? 5
const frameQuality = frameQualities[jobId] ?? "auto"
const mode = options?.mode ?? (targetJob.frames.length > 0 ? "append" : "replace")
@@ -400,6 +402,7 @@ export default function Home() {
"生成一段单镜头连续视频,一镜到底,从首帧平滑过渡到尾帧;不要跳切,不要突然换场景,不要突然换主体,不要蒙太奇,不要多镜头拼接。",
"如果提供了原视频链接,把它只作为节奏、镜头运动、动作顺序和画面调度参考;不要照搬原视频里的品牌、文字、水印、竞品产品或具体人物。",
"时间线0%-15% 锁住首帧构图并轻微启动15%-85% 做平滑连续运动85%-100% 缓慢贴近尾帧并稳定收住。",
TRANSPARENT_HUMAN_VIDEO_PROMPT,
`主体改造:${subjectDirection}`,
`产品替换:${productDirection} 产品必须作为颈部/肩颈按摩仪被正确佩戴或展示,不要放在脸上、手臂上、桌面当摆件,也不要变成瓶子、面霜、医疗设备或食品。`,
`场景改造:${sceneDirection}`,
@@ -416,6 +419,7 @@ export default function Home() {
"运动要求:动作幅度小而连续,速度均匀,手部和产品位置前后一致,产品外形不变形,人物表情和姿态不漂移,背景只允许轻微景深和光影变化。",
"商业质感:真实拍摄感,干净高级,柔和稳定打光,产品边缘清晰,材质真实,画面无抖动、无拉伸、无闪烁。",
"禁止:字幕、文字、平台 UI、TikTok 水印、logo 水印、免责声明、竞品包装、随机新物体、非 SKG 产品、医学骨架、夸张病症画面、恐怖元素、画面撕裂、人物或产品突然变形。",
TRANSPARENT_HUMAN_NEGATIVE_PROMPT,
].join("\n")
try {
@@ -470,10 +474,12 @@ export default function Home() {
`白底人物图:${labelOf(shot.person_image, "人物姿态参考")}。人物姿态、手部接触点和产品佩戴关系以这张图为准。`,
`场景图:${labelOf(shot.scene_image, "场景参考")}。背景、空间、光线和气氛以这张图为准,但不要改变产品框内位置。`,
`动作描述:${shot.action_text.trim()}`,
TRANSPARENT_HUMAN_VIDEO_PROMPT,
"融合要求:产品必须按引导图位置自然贴合人物或手部,尺寸可信,透视一致,边缘清晰,不能悬浮、穿帮、融化、扭曲或变成其他物体。",
"场景要求:把白底人物姿态自然放入场景图的环境中,光线方向和阴影要统一,背景不要出现水印、平台 UI、字幕或竞品包装。",
"商业质感:真实拍摄感、干净高级、产品清楚可辨、人物动作自然、镜头稳定。",
"禁止:文字、水印、随机品牌、非 SKG 产品、医学治疗承诺、夸张病症、恐怖元素、产品位置漂移、产品超过指定融合区域。",
TRANSPARENT_HUMAN_NEGATIVE_PROMPT,
].join("\n")
const updated = await generateStoryboardVideo(job.id, frameIdx, {
prompt,

View File

@@ -9,6 +9,7 @@ import {
type AssetBackground, type AssetSize, type KeyFrame, type Job, type ImageRef, type ProductFusionShot, type SceneMode, type SceneStyle, type SubjectKind,
} from "@/lib/api"
import { ProductLibraryPicker } from "@/components/product-library-picker"
import { TRANSPARENT_HUMAN_FRAME_STANDARD, TRANSPARENT_HUMAN_UI_SUMMARY } from "@/lib/workflow-target"
import { toast } from "sonner"
interface Props {
@@ -225,6 +226,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
if (activeIndex === null || !f || !mounted) return null
const desc = f.description
const transparentScore = f.transparent_human_score ?? desc?.transparent_human_assessment
const elements = f.elements ?? []
const hasCleaned = !!f.cleaned_url
const latestSceneAsset = f.scene_assets?.[f.scene_assets.length - 1] ?? null
@@ -1716,6 +1718,33 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
</button>
</div>
<div className="mb-2 rounded-md border border-cyan-300/18 bg-cyan-500/[0.06] px-2.5 py-2 text-[10.5px] leading-relaxed text-white/55">
<div className="mb-1 flex items-center justify-between gap-2">
<span className="font-semibold text-cyan-100"></span>
{transparentScore && (
<span className={`rounded px-1.5 py-0.5 text-[9px] font-mono ${
transparentScore.qualified ? "bg-emerald-400/80 text-black" : "bg-amber-400/18 text-amber-100"
}`}>
{transparentScore.qualified ? "合格" : "待复核"} · {transparentScore.total_score ?? (
(transparentScore.transparent_body_score || 0)
+ (transparentScore.skeleton_visible_score || 0)
+ (transparentScore.human_prominence_score || 0)
+ (transparentScore.clarity_score || 0)
+ (transparentScore.commercial_style_score || 0)
+ (transparentScore.product_usefulness_score || 0)
)}/100
</span>
)}
</div>
<div>{TRANSPARENT_HUMAN_UI_SUMMARY}</div>
<div className="mt-1 text-white/38">{TRANSPARENT_HUMAN_FRAME_STANDARD}</div>
{transparentScore?.reject_reason && !transparentScore.qualified && (
<div className="mt-1 rounded border border-amber-300/20 bg-amber-500/10 px-1.5 py-1 text-amber-100/80">
{transparentScore.reject_reason}
</div>
)}
</div>
{!desc ? (
<div className="rounded-lg border border-dashed border-white/15 bg-white/[0.03] p-3 text-[11.5px] text-white/50 leading-relaxed">
{describing ? (

View File

@@ -130,6 +130,7 @@ function clamp(value: number, min: number, max: number) {
const THUMBNAIL_HEIGHT = 192
const FLOATING_PANEL_EDGE_INSET = 8
const FRAME_TARGET_OPTIONS: Array<{ value: FrameExtractTarget; label: string; hint: string }> = [
{ value: "transparent_human", label: "透明骨架人", hint: "AI 验收透明身体 + 白色骨架" },
{ value: "balanced", label: "综合关键帧", hint: "清晰、去重、变化、时间覆盖" },
{ value: "subject", label: "清晰主体", hint: "人物 / 产品主体更清楚" },
{ value: "transition", label: "转场变化", hint: "切镜和画面变化优先" },
@@ -571,7 +572,7 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
const aspectStr = ready ? `${j.width}/${j.height}` : "9/16"
const thumbNaturalWidth = ready && j.height ? Math.max(96, Math.round(THUMBNAIL_HEIGHT * j.width / j.height)) : 96
const toolWidth = Math.max(148, thumbNaturalWidth)
const target = d.frameTargets[j.id] ?? "balanced"
const target = d.frameTargets[j.id] ?? "transparent_human"
const count = d.frameCounts[j.id] ?? 5
const quality = d.frameQualities[j.id] ?? "auto"
const jHasFrames = j.frames.length > 0
@@ -811,7 +812,7 @@ export function VideoFramePanelNode({ data }: any) {
const duration = panelJob.duration ?? 0
const frames = [...panelJob.frames].sort((a, b) => a.timestamp - b.timestamp)
const aspect = panelJob.width && panelJob.height ? `${panelJob.width}/${panelJob.height}` : "9/16"
const panelTarget = d.frameTargets[panelJob.id] ?? "balanced"
const panelTarget = d.frameTargets[panelJob.id] ?? "transparent_human"
const panelCount = d.frameCounts[panelJob.id] ?? 5
const panelQuality = d.frameQualities[panelJob.id] ?? "auto"
const panelRunning = ["splitting", "transcribing"].includes(panelJob.status)

View File

@@ -199,6 +199,7 @@ export interface KeyFrame {
timestamp: number
url: string
description?: FrameDescription | null
transparent_human_score?: TransparentHumanFrameScore | null
cleaned_url?: string | null
cleaned_applied?: boolean
quality_report?: QualityReport | null
@@ -208,7 +209,7 @@ export interface KeyFrame {
generated_images?: GeneratedImage[]
}
export type FrameExtractTarget = "balanced" | "subject" | "transition" | "expression" | "motion"
export type FrameExtractTarget = "transparent_human" | "balanced" | "subject" | "transition" | "expression" | "motion"
export type FrameExtractMode = "replace" | "append"
export type FrameExtractQuality = "auto" | "fast" | "accurate" | "ultra"
export type AssetBackground = "white" | "black"