auto-save 2026-05-14 05:05 (~6)

This commit is contained in:
2026-05-14 05:05:22 +08:00
parent f1f3a0fbe5
commit f2663eb90e
6 changed files with 312 additions and 17 deletions

View File

@@ -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>
)
})}

View File

@@ -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}
</>
) : (
"解析后这里展示关键帧、元素和视频任务;具体处理仍在点击后的工作台完成。"

View File

@@ -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) {