auto-save 2026-05-14 05:05 (~6)
This commit is contained in:
@@ -3,10 +3,10 @@ import { useEffect, useRef, useState } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import { X, ChevronLeft, ChevronRight, Check, Sparkles, Wand2, Loader2, Eye, RefreshCw, Plus, Sparkle, Crop, Copy, PencilLine, Trash2, Save } from "lucide-react"
|
||||
import {
|
||||
frameUrl, cleanedFrameUrl, cutoutUrl,
|
||||
frameUrl, cleanedFrameUrl, cutoutUrl, apiAssetUrl,
|
||||
describeFrame, cleanupFrame, applyCleanedFrame, discardCleanedFrame, addElement, updateElement, deleteElement, cutoutElement, deleteCutout,
|
||||
pushStoryboardImage,
|
||||
type KeyFrame, type Job, type ImageRef,
|
||||
pushStoryboardImage, generateSceneAsset, generateSubjectAssets,
|
||||
type AssetBackground, type AssetSize, type KeyFrame, type Job, type ImageRef, type SubjectKind,
|
||||
} from "@/lib/api"
|
||||
import { toast } from "sonner"
|
||||
|
||||
@@ -24,13 +24,42 @@ interface Props {
|
||||
embedded?: boolean
|
||||
}
|
||||
|
||||
const OBJECT_VIEW_OPTIONS = [
|
||||
["front", "正面"],
|
||||
["back", "背面"],
|
||||
["left", "左侧"],
|
||||
["right", "右侧"],
|
||||
["top", "顶部"],
|
||||
["bottom", "底部"],
|
||||
]
|
||||
|
||||
const LIVING_VIEW_OPTIONS = [
|
||||
["front", "正面"],
|
||||
["back", "背面"],
|
||||
["side", "侧面"],
|
||||
["side_walk", "走路"],
|
||||
["expression_happy", "喜"],
|
||||
["expression_angry", "怒"],
|
||||
["expression_sad", "哀"],
|
||||
["expression_relaxed", "乐/放松"],
|
||||
["action_sit", "坐"],
|
||||
["action_hold", "手持"],
|
||||
["action_use", "使用"],
|
||||
]
|
||||
|
||||
export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, onChange, onToggleSelect, onJobUpdate, onSwitchPanel, onCopyImage, embedded = false }: Props) {
|
||||
const [describing, setDescribing] = useState(false)
|
||||
const [cleaning, setCleaning] = useState(false)
|
||||
const [applying, setApplying] = useState(false)
|
||||
const [cuttingId, setCuttingId] = useState<string | null>(null)
|
||||
const [sceneGenerating, setSceneGenerating] = useState(false)
|
||||
const [subjectGenerating, setSubjectGenerating] = useState<string | null>(null)
|
||||
const [addingZh, setAddingZh] = useState(false)
|
||||
const [addInput, setAddInput] = useState("")
|
||||
const [assetSize, setAssetSize] = useState<AssetSize>("source")
|
||||
const [subjectKinds, setSubjectKinds] = useState<Record<string, SubjectKind>>({})
|
||||
const [subjectBackgrounds, setSubjectBackgrounds] = useState<Record<string, AssetBackground>>({})
|
||||
const [subjectViews, setSubjectViews] = useState<Record<string, string[]>>({})
|
||||
const [editingElement, setEditingElement] = useState<{
|
||||
id: string
|
||||
name_zh: string
|
||||
@@ -87,6 +116,9 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
|
||||
const desc = f.description
|
||||
const elements = f.elements ?? []
|
||||
const hasCleaned = !!f.cleaned_url
|
||||
const latestSceneAsset = f.scene_assets?.[f.scene_assets.length - 1] ?? null
|
||||
const selectedFrameIndices = Array.from(selected).sort((a, b) => a - b)
|
||||
const sharedSubjectFrameIndices = selectedFrameIndices.length > 1 ? selectedFrameIndices : [f.index]
|
||||
|
||||
const handleDescribe = async () => {
|
||||
setDescribing(true)
|
||||
@@ -116,6 +148,50 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerateSceneAsset = async () => {
|
||||
setSceneGenerating(true)
|
||||
try {
|
||||
const updated = await generateSceneAsset(jobId, f.index, { size: assetSize })
|
||||
onJobUpdate?.(updated)
|
||||
toast.success(`分镜 ${f.index + 1} 场景图已生成`)
|
||||
} catch (e) {
|
||||
toast.error("场景图生成失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
} finally {
|
||||
setSceneGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerateSubjectPackage = async (elementId: string) => {
|
||||
const kind = subjectKinds[elementId] ?? "object"
|
||||
const defaultViews = (kind === "living" ? LIVING_VIEW_OPTIONS : OBJECT_VIEW_OPTIONS).map(([value]) => value)
|
||||
const views = subjectViews[elementId]?.length ? subjectViews[elementId] : defaultViews
|
||||
setSubjectGenerating(elementId)
|
||||
try {
|
||||
const updated = await generateSubjectAssets(jobId, f.index, elementId, {
|
||||
subject_kind: kind,
|
||||
background: subjectBackgrounds[elementId] ?? "white",
|
||||
size: assetSize,
|
||||
source_frame_indices: sharedSubjectFrameIndices,
|
||||
views,
|
||||
})
|
||||
onJobUpdate?.(updated)
|
||||
toast.success(`主体资产包已生成 · ${views.length} 张`)
|
||||
} catch (e) {
|
||||
toast.error("主体资产包生成失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
} finally {
|
||||
setSubjectGenerating(null)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSubjectView = (elementId: string, view: string, kind: SubjectKind) => {
|
||||
const defaults = (kind === "living" ? LIVING_VIEW_OPTIONS : OBJECT_VIEW_OPTIONS).map(([value]) => value)
|
||||
setSubjectViews((prev) => {
|
||||
const current = prev[elementId] ?? defaults
|
||||
const next = current.includes(view) ? current.filter((x) => x !== view) : [...current, view]
|
||||
return { ...prev, [elementId]: next }
|
||||
})
|
||||
}
|
||||
|
||||
const handleExtractRegion = async () => {
|
||||
// 提取语义只在恰好 1 个框时支持
|
||||
if (regions.length !== 1 || !extractName.trim()) return
|
||||
@@ -548,6 +624,56 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
|
||||
{cleaning ? "清洗中…(5-15 秒)" : hasCleaned ? "重新清洗" : f.cleaned_applied ? "再次清洗" : "🧹 清洗水印"}
|
||||
</button>
|
||||
|
||||
<div className="rounded-lg border border-white/10 bg-white/[0.035] p-2">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="text-[11.5px] font-semibold text-white">场景图</div>
|
||||
<select
|
||||
value={assetSize}
|
||||
onChange={(e) => setAssetSize(e.target.value as AssetSize)}
|
||||
className="rounded border border-white/10 bg-black/35 px-1.5 py-0.5 text-[10px] text-white/75 outline-none"
|
||||
title="资产输出尺寸"
|
||||
>
|
||||
<option value="source">原尺寸</option>
|
||||
<option value="1024">1024</option>
|
||||
<option value="1536">1536</option>
|
||||
<option value="2048">2048</option>
|
||||
</select>
|
||||
</div>
|
||||
{latestSceneAsset ? (
|
||||
<div className="mb-2 overflow-hidden rounded-md border border-emerald-300/25 bg-black/30">
|
||||
<img src={apiAssetUrl(latestSceneAsset.url)} alt={latestSceneAsset.label} className="max-h-36 w-full object-contain bg-black" />
|
||||
<div className="flex items-center justify-between gap-2 border-t border-white/10 px-2 py-1 text-[9.5px] text-white/50">
|
||||
<span>{latestSceneAsset.width}×{latestSceneAsset.height}</span>
|
||||
{onCopyImage && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCopyImage({ kind: "asset", frame_idx: f.index, element_id: latestSceneAsset.id, cutout_id: latestSceneAsset.id, label: latestSceneAsset.label })}
|
||||
className="inline-flex items-center gap-1 rounded bg-violet-500/70 px-1.5 py-0.5 text-white hover:bg-violet-400"
|
||||
>
|
||||
<Copy className="h-2.5 w-2.5" />
|
||||
复制
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{latestSceneAsset?.quality_report?.warnings?.length ? (
|
||||
<div className="mb-2 rounded border border-amber-300/25 bg-amber-500/10 px-2 py-1 text-[10px] leading-snug text-amber-100/85">
|
||||
{latestSceneAsset.quality_report.warnings[0]}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerateSceneAsset}
|
||||
disabled={sceneGenerating || cleaning}
|
||||
className="w-full rounded-md bg-emerald-500/65 px-2 py-1.5 text-[11px] font-medium text-white transition hover:bg-emerald-400 disabled:cursor-wait disabled:opacity-45 inline-flex items-center justify-center gap-1"
|
||||
title="生成一张去水印、高清增强后的场景参考图"
|
||||
>
|
||||
{sceneGenerating ? <Loader2 className="h-3 w-3 animate-spin" /> : <Sparkles className="h-3 w-3" />}
|
||||
{sceneGenerating ? "生成场景图中…" : latestSceneAsset ? "重新生成场景图" : "生成场景图"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => onToggleSelect(f.index)}
|
||||
className={`w-full px-3 py-1.5 rounded-md text-[12px] font-medium inline-flex items-center justify-center gap-1.5 transition ${
|
||||
@@ -673,6 +799,12 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
|
||||
const hasAny = cutouts.length > 0
|
||||
const hasRegion = !!e.region
|
||||
const isEditing = editingElement?.id === e.id
|
||||
const currentKind = subjectKinds[e.id] ?? e.subject_kind ?? "object"
|
||||
const currentBg = subjectBackgrounds[e.id] ?? e.cutout_background ?? "white"
|
||||
const viewOptions = currentKind === "living" ? LIVING_VIEW_OPTIONS : OBJECT_VIEW_OPTIONS
|
||||
const activeViews = subjectViews[e.id] ?? viewOptions.map(([value]) => value)
|
||||
const subjectAssets = e.subject_assets ?? []
|
||||
const isSubjectGenerating = subjectGenerating === e.id
|
||||
return (
|
||||
<div
|
||||
key={e.id}
|
||||
@@ -842,6 +974,99 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 rounded-md border border-violet-300/15 bg-violet-500/[0.08] p-2">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="text-[11px] font-semibold text-white/90">主体资产包</div>
|
||||
<span className="text-[9.5px] font-mono text-white/35">
|
||||
{sharedSubjectFrameIndices.length > 1 ? `${sharedSubjectFrameIndices.length} 帧参考` : "当前帧参考"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-2 grid grid-cols-3 gap-1">
|
||||
<select
|
||||
value={currentKind}
|
||||
onChange={(ev) => {
|
||||
const next = ev.target.value as SubjectKind
|
||||
setSubjectKinds((prev) => ({ ...prev, [e.id]: next }))
|
||||
setSubjectViews((prev) => ({ ...prev, [e.id]: (next === "living" ? LIVING_VIEW_OPTIONS : OBJECT_VIEW_OPTIONS).map(([value]) => value) }))
|
||||
}}
|
||||
className="rounded border border-white/10 bg-black/35 px-1.5 py-1 text-[10px] text-white/75 outline-none"
|
||||
>
|
||||
<option value="object">物体</option>
|
||||
<option value="living">人物/生物</option>
|
||||
</select>
|
||||
<select
|
||||
value={currentBg}
|
||||
onChange={(ev) => setSubjectBackgrounds((prev) => ({ ...prev, [e.id]: ev.target.value as AssetBackground }))}
|
||||
className="rounded border border-white/10 bg-black/35 px-1.5 py-1 text-[10px] text-white/75 outline-none"
|
||||
>
|
||||
<option value="white">白底</option>
|
||||
<option value="black">黑底</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleGenerateSubjectPackage(e.id)}
|
||||
disabled={isSubjectGenerating || activeViews.length === 0}
|
||||
className="rounded bg-violet-500/70 px-1.5 py-1 text-[10px] font-medium text-white transition hover:bg-violet-400 disabled:cursor-wait disabled:opacity-45 inline-flex items-center justify-center gap-1"
|
||||
title="生成多视角 / 动作 / 表情主体资产"
|
||||
>
|
||||
{isSubjectGenerating ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wand2 className="h-3 w-3" />}
|
||||
{isSubjectGenerating ? "生成" : "生成"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-2 flex flex-wrap gap-1">
|
||||
{viewOptions.map(([value, label]) => {
|
||||
const active = activeViews.includes(value)
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => toggleSubjectView(e.id, value, currentKind)}
|
||||
className={`rounded border px-1.5 py-0.5 text-[9.5px] transition ${
|
||||
active
|
||||
? "border-violet-300/60 bg-violet-500/40 text-white"
|
||||
: "border-white/10 bg-black/25 text-white/45 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{subjectAssets.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{subjectAssets.slice(-12).map((asset) => (
|
||||
<div key={asset.id} className="relative overflow-hidden rounded-md border border-white/10 bg-white" style={{ width: 88, height: 112 }}>
|
||||
<img src={apiAssetUrl(asset.url)} alt={asset.label} className="h-[82px] w-full object-contain" />
|
||||
<div className="absolute left-0 top-0 rounded-br bg-black/70 px-1 text-[8.5px] text-white">
|
||||
{asset.label.replace(`${e.name_zh} · `, "")}
|
||||
</div>
|
||||
<div className="flex h-[30px] border-t border-black/10 bg-black text-white">
|
||||
{onCopyImage && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault(); ev.stopPropagation()
|
||||
onCopyImage({
|
||||
kind: "asset",
|
||||
frame_idx: f.index,
|
||||
element_id: asset.id,
|
||||
cutout_id: asset.id,
|
||||
label: asset.label,
|
||||
})
|
||||
}}
|
||||
className="flex-1 inline-flex items-center justify-center gap-1 text-[9.5px] hover:bg-violet-500/70 transition"
|
||||
>
|
||||
<Copy className="h-2.5 w-2.5" />
|
||||
复制
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1177,6 +1177,8 @@ export function VisualLabNode({ data, selected }: any) {
|
||||
const elementCrops = collectElementCrops(job)
|
||||
const cleanedCount = frames.filter((x) => x.cleaned_url).length
|
||||
const cutoutCount = frames.reduce((s, x) => s + (x.elements?.filter((e) => hasCutout(e)).length ?? 0), 0)
|
||||
const sceneAssetCount = frames.reduce((s, x) => s + (x.scene_assets?.length ?? 0), 0)
|
||||
const subjectAssetCount = frames.reduce((s, x) => s + (x.elements?.reduce((n, e) => n + (e.subject_assets?.length ?? 0), 0) ?? 0), 0)
|
||||
const runningVideo = videos.some((v) => v.status === "queued" || v.status === "in_progress")
|
||||
const completedVideos = videos.filter((v) => v.status === "completed" && v.url)
|
||||
const failedVideo = videos.some((v) => v.status === "failed")
|
||||
@@ -1485,7 +1487,7 @@ export function VisualLabNode({ data, selected }: any) {
|
||||
<div className="mt-2 text-[10.5px] leading-snug text-[var(--text-faint)]">
|
||||
{frames.length > 0 ? (
|
||||
<>
|
||||
{cleanedCount} 已清洗 · {cutoutCount} 已抠图 · {d.selectedFrames.size}/{frames.length} 入编排 · {completedVideos.length} 已完成
|
||||
{cleanedCount} 已清洗 · {sceneAssetCount} 场景图 · {subjectAssetCount || cutoutCount} 主体素材 · {d.selectedFrames.size}/{frames.length} 入编排 · {completedVideos.length} 已完成
|
||||
</>
|
||||
) : (
|
||||
"解析后这里展示关键帧、元素和视频任务;具体处理仍在点击后的工作台完成。"
|
||||
|
||||
@@ -138,7 +138,7 @@ export type FrameExtractQuality = "auto" | "fast" | "accurate" | "ultra"
|
||||
export type AssetBackground = "white" | "black"
|
||||
export type AssetSize = "source" | "1024" | "1536" | "2048"
|
||||
export type SubjectKind = "object" | "living"
|
||||
export type SubjectView = "front" | "back" | "left" | "right" | "side" | "side_walk" | "top" | "bottom" | "expression"
|
||||
export type SubjectView = string
|
||||
|
||||
export interface QualityReport {
|
||||
width: number
|
||||
@@ -441,7 +441,7 @@ export function representativeCutoutUrl(
|
||||
|
||||
export async function pushStoryboardImage(
|
||||
jobId: string,
|
||||
body: { kind: "keyframe" | "cutout"; frame_idx: number; element_id?: string | null; cutout_id?: string | null; label?: string },
|
||||
body: { kind: "keyframe" | "cutout" | "asset"; frame_idx: number; element_id?: string | null; cutout_id?: string | null; label?: string },
|
||||
): Promise<Job> {
|
||||
const res = await fetch(`${API_BASE}/jobs/${jobId}/storyboard-images`, {
|
||||
method: "POST",
|
||||
@@ -676,6 +676,7 @@ export async function generateSubjectAssets(
|
||||
background?: AssetBackground
|
||||
size?: AssetSize
|
||||
source_frame_indices?: number[]
|
||||
views?: string[]
|
||||
} = {},
|
||||
): Promise<Job> {
|
||||
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements/${elementId}/subject-assets`, {
|
||||
@@ -687,6 +688,7 @@ export async function generateSubjectAssets(
|
||||
background: body.background ?? "white",
|
||||
size: body.size ?? "source",
|
||||
source_frame_indices: body.source_frame_indices ?? null,
|
||||
views: body.views ?? null,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
|
||||
Reference in New Issue
Block a user