auto-save 2026-05-13 10:38 (~5)
This commit is contained in:
@@ -1237,6 +1237,19 @@
|
||||
"message": "auto-save 2026-05-13 10:27 (~1)",
|
||||
"hash": "e154f8b",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-13T10:33:17+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-05-13 10:33 (~2)",
|
||||
"hash": "3fee4a4",
|
||||
"files_changed": 2
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-13T02:37:36Z",
|
||||
"type": "session-heartbeat",
|
||||
"message": "Claude 会话活跃 · 最近命令:claude · 5 项未提交变更 · 最近提交:auto-save 2026-05-13 10:33 (~2)",
|
||||
"files_changed": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -453,11 +453,17 @@ export const Dashboard = forwardRef<DashboardHandle, Props>(function Dashboard({
|
||||
) : (
|
||||
job!.frames.map((f) => {
|
||||
const isSel = data.selectedFrames.has(f.index)
|
||||
const cleaned = !!f.cleaned_url
|
||||
const elCount = f.elements?.length ?? 0
|
||||
const cutCount = f.elements?.filter((e) => e.cutout_id).length ?? 0
|
||||
const tags = [`分镜 ${f.index + 1}`, `${f.timestamp.toFixed(1)}s`]
|
||||
if (cleaned) tags.push("已清洗")
|
||||
if (cutCount > 0) tags.push(`${cutCount}/${elCount} 抠图`)
|
||||
return (
|
||||
<KanbanCard
|
||||
key={f.index}
|
||||
tone={isSel ? "green" : "pink"}
|
||||
tags={[`分镜 ${f.index + 1}`, `${f.timestamp.toFixed(1)}s`]}
|
||||
tags={tags}
|
||||
className={isSel ? "ring-2 ring-emerald-400/60" : ""}
|
||||
meta={
|
||||
<button
|
||||
@@ -478,7 +484,7 @@ export const Dashboard = forwardRef<DashboardHandle, Props>(function Dashboard({
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); data.onExpandFrame(f.index) }}
|
||||
className="block w-full rounded-md overflow-hidden bg-black"
|
||||
title="点击放大"
|
||||
title="点击放大 · 清洗 / 提取元素"
|
||||
>
|
||||
<img
|
||||
src={frameUrl(job!.id, f.index)}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"use client"
|
||||
import { useEffect, useState } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import { X, ChevronLeft, ChevronRight, Check, Sparkles, Wand2, Loader2, Eye, RefreshCw, Plus } from "lucide-react"
|
||||
import { frameUrl, describeFrame, translateText, generateImage, type KeyFrame, type Job } from "@/lib/api"
|
||||
import { X, ChevronLeft, ChevronRight, Check, Sparkles, Wand2, Loader2, Eye, RefreshCw, Plus, Sparkle } from "lucide-react"
|
||||
import {
|
||||
frameUrl, cleanedFrameUrl, cutoutUrl,
|
||||
describeFrame, cleanupFrame, addElement, deleteElement, cutoutElement,
|
||||
type KeyFrame, type Job,
|
||||
} from "@/lib/api"
|
||||
import { toast } from "sonner"
|
||||
|
||||
type CustomItem = { id: string; zh: string; en: string; translating: boolean }
|
||||
|
||||
interface Props {
|
||||
jobId: string
|
||||
frames: KeyFrame[]
|
||||
@@ -16,44 +18,20 @@ interface Props {
|
||||
onChange: (idx: number) => void
|
||||
onToggleSelect: (idx: number) => void
|
||||
onJobUpdate?: (job: Job) => void
|
||||
onSwitchPanel?: (key: string) => void // 生成成功后切到目标 sidebar 节点(如 "imagegen")
|
||||
embedded?: boolean // true=嵌入到容器里(无 fixed),false=独立浮动卡(默认)
|
||||
onSwitchPanel?: (key: string) => void
|
||||
embedded?: boolean
|
||||
}
|
||||
|
||||
export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, onChange, onToggleSelect, onJobUpdate, onSwitchPanel, embedded = false }: Props) {
|
||||
const [describing, setDescribing] = useState(false)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
// 自定义提取元素 — 按 frame 隔离,切换 frame 后回到同一帧时还能看到之前加的
|
||||
const [customsByFrame, setCustomsByFrame] = useState<Record<number, CustomItem[]>>({})
|
||||
const [cleaning, setCleaning] = useState(false)
|
||||
const [cuttingId, setCuttingId] = useState<string | null>(null)
|
||||
const [addingZh, setAddingZh] = useState(false)
|
||||
const [viewCleaned, setViewCleaned] = useState(true) // 默认显示干净版(若有)
|
||||
const [addInput, setAddInput] = useState("")
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => setMounted(true), [])
|
||||
|
||||
const customs = activeIndex !== null ? (customsByFrame[activeIndex] || []) : []
|
||||
const updateCustoms = (updater: (prev: CustomItem[]) => CustomItem[]) => {
|
||||
if (activeIndex === null) return
|
||||
const key = activeIndex
|
||||
setCustomsByFrame((prev) => ({ ...prev, [key]: updater(prev[key] || []) }))
|
||||
}
|
||||
|
||||
const addCustom = async (zhRaw: string, presetEn?: string) => {
|
||||
const zh = zhRaw.trim()
|
||||
if (!zh || activeIndex === null) return
|
||||
const id = Math.random().toString(36).slice(2, 8)
|
||||
const skipTranslate = !!presetEn
|
||||
updateCustoms((prev) => [...prev, { id, zh, en: presetEn || "", translating: !skipTranslate }])
|
||||
if (skipTranslate) return
|
||||
try {
|
||||
const en = await translateText(zh, "en")
|
||||
updateCustoms((prev) => prev.map((c) => c.id === id ? { ...c, en, translating: false } : c))
|
||||
} catch (e) {
|
||||
updateCustoms((prev) => prev.map((c) => c.id === id ? { ...c, en: "", translating: false } : c))
|
||||
toast.error("翻译失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
}
|
||||
}
|
||||
|
||||
const removeCustom = (id: string) => updateCustoms((prev) => prev.filter((c) => c.id !== id))
|
||||
|
||||
useEffect(() => {
|
||||
if (activeIndex === null) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
@@ -73,44 +51,15 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
|
||||
return () => window.removeEventListener("keydown", onKey)
|
||||
}, [activeIndex, frames.length, onClose, onChange, onToggleSelect])
|
||||
|
||||
// activeIndex 是 KeyFrame.index 稳定 ID,而 frames 数组按 timestamp 排序——必须用 find 不能用 [index]
|
||||
const f = activeIndex !== null ? frames.find((x) => x.index === activeIndex) : undefined
|
||||
const arrayPos = f ? frames.findIndex((x) => x.index === f.index) : -1
|
||||
|
||||
if (activeIndex === null || !f || !mounted) return null
|
||||
const isSelected = selected.has(f.index)
|
||||
const desc = f.description
|
||||
|
||||
const handleGenerateNext = async () => {
|
||||
if (activeIndex === null || !f) return
|
||||
const base = f.description?.suggested_prompt?.trim()
|
||||
if (!base) {
|
||||
toast.error("请先识别此分镜(右上角『识别』按钮)")
|
||||
return
|
||||
}
|
||||
if (!selected.has(f.index)) onToggleSelect(f.index)
|
||||
|
||||
const extraEn = customs.filter((c) => c.en).map((c) => c.en).join(", ")
|
||||
setGenerating(true)
|
||||
try {
|
||||
const updated = await generateImage(jobId, f.index, {
|
||||
prompt: base,
|
||||
extra_prompt: extraEn,
|
||||
negative_prompt: "watermark, username text, social media handle, platform logo, overlay text, captions",
|
||||
model: "gemini-3-pro-image-preview",
|
||||
mode: "edit",
|
||||
from_selected: true, // 优先用上一轮 selected 的生成图作 reference(迭代)
|
||||
})
|
||||
onJobUpdate?.(updated)
|
||||
toast.success(`分镜 ${f.index + 1} 生成完成 → 「生图」`)
|
||||
// 自动切到「生图」节点 drawer,用户立刻看到新图
|
||||
onSwitchPanel?.("imagegen")
|
||||
} catch (e) {
|
||||
toast.error("生图失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}
|
||||
const elements = f.elements ?? []
|
||||
const hasCleaned = !!f.cleaned_url
|
||||
const showCleaned = hasCleaned && viewCleaned
|
||||
|
||||
const handleDescribe = async () => {
|
||||
setDescribing(true)
|
||||
@@ -125,6 +74,64 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
|
||||
}
|
||||
}
|
||||
|
||||
const handleCleanup = async () => {
|
||||
setCleaning(true)
|
||||
try {
|
||||
const updated = await cleanupFrame(jobId, f.index)
|
||||
onJobUpdate?.(updated)
|
||||
setViewCleaned(true)
|
||||
toast.success(`分镜 ${f.index + 1} 清洗完成`)
|
||||
} catch (e) {
|
||||
toast.error("清洗失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
} finally {
|
||||
setCleaning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddElement = async (name_zh: string, name_en?: string, position?: string, source: "auto" | "manual" = "manual") => {
|
||||
const zh = name_zh.trim()
|
||||
if (!zh) return
|
||||
setAddingZh(true)
|
||||
try {
|
||||
const updated = await addElement(jobId, f.index, { name_zh: zh, name_en, position, source })
|
||||
onJobUpdate?.(updated)
|
||||
} catch (e) {
|
||||
toast.error("加入失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
} finally {
|
||||
setAddingZh(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteElement = async (id: string) => {
|
||||
try {
|
||||
const updated = await deleteElement(jobId, f.index, id)
|
||||
onJobUpdate?.(updated)
|
||||
} catch (e) {
|
||||
toast.error("删除失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
}
|
||||
}
|
||||
|
||||
const handleCutout = async (id: string) => {
|
||||
setCuttingId(id)
|
||||
try {
|
||||
const updated = await cutoutElement(jobId, f.index, id)
|
||||
onJobUpdate?.(updated)
|
||||
toast.success("抠图完成")
|
||||
} catch (e) {
|
||||
toast.error("抠图失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
} finally {
|
||||
setCuttingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
// cleaned_url 是 /jobs/.../cleaned.jpg?t=<timestamp> 形式(后端写时带)
|
||||
// 这里直接当 absolute path 拼到 API_BASE 上即可:用 cleanedFrameUrl 但带 bust
|
||||
const cleanedSrc = (() => {
|
||||
if (!f.cleaned_url) return null
|
||||
const ts = f.cleaned_url.match(/t=(\d+)/)?.[1]
|
||||
return cleanedFrameUrl(jobId, f.index, ts)
|
||||
})()
|
||||
|
||||
const content = (
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -141,7 +148,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
|
||||
animation: "drawer-in 0.24s cubic-bezier(0.32, 0.72, 0, 1)",
|
||||
}}
|
||||
>
|
||||
{/* 顶部工具栏 — 切换 / 关闭,用 keyframe 橙红配色 */}
|
||||
{/* 顶部工具栏 */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-2 text-white"
|
||||
style={{ background: "linear-gradient(135deg, #f59e0b, #ef4444)" }}
|
||||
@@ -175,16 +182,51 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 主体 — 左大图 + 右识别面板 */}
|
||||
{/* 主体 — 左:大图 + 清洗 / 选用;右:识别 + 元素清单 */}
|
||||
<div className="flex gap-3 p-3 overflow-hidden flex-1 min-h-0">
|
||||
{/* 左侧大图 */}
|
||||
<div className="flex flex-col items-center gap-2 flex-shrink-0">
|
||||
<img
|
||||
src={frameUrl(jobId, f.index)}
|
||||
alt={`frame ${f.index}`}
|
||||
className="rounded-lg object-contain"
|
||||
style={{ maxWidth: 320, maxHeight: "70vh" }}
|
||||
/>
|
||||
<div className="flex flex-col items-stretch gap-2 flex-shrink-0" style={{ width: 320 }}>
|
||||
<div className="relative">
|
||||
<img
|
||||
src={showCleaned && cleanedSrc ? cleanedSrc : frameUrl(jobId, f.index)}
|
||||
alt={`frame ${f.index}`}
|
||||
className="rounded-lg object-contain w-full"
|
||||
style={{ maxHeight: "62vh" }}
|
||||
/>
|
||||
{/* 显示版本切换 + 状态标 */}
|
||||
{hasCleaned && (
|
||||
<div className="absolute top-2 left-2 right-2 flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setViewCleaned(false)}
|
||||
className={`text-[10px] px-2 py-1 rounded-md backdrop-blur transition ${
|
||||
!showCleaned
|
||||
? "bg-white/20 text-white"
|
||||
: "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white"
|
||||
}`}
|
||||
>原图</button>
|
||||
<button
|
||||
onClick={() => setViewCleaned(true)}
|
||||
className={`text-[10px] px-2 py-1 rounded-md backdrop-blur transition ${
|
||||
showCleaned
|
||||
? "bg-emerald-500/80 text-white"
|
||||
: "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white"
|
||||
}`}
|
||||
>✨ 干净版</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 清洗按钮 */}
|
||||
<button
|
||||
onClick={handleCleanup}
|
||||
disabled={cleaning}
|
||||
className="w-full px-3 py-1.5 rounded-md text-[11.5px] font-medium inline-flex items-center justify-center gap-1.5 transition bg-gradient-to-r from-cyan-500/80 to-emerald-500/80 hover:from-cyan-500 hover:to-emerald-500 text-white disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title="清掉水印 / @用户名 / 字幕 / 平台 logo"
|
||||
>
|
||||
{cleaning ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkle className="h-3.5 w-3.5" />}
|
||||
{cleaning ? "清洗中…(5-15 秒)" : hasCleaned ? "重新清洗" : "🧹 清洗水印"}
|
||||
</button>
|
||||
|
||||
<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 ${
|
||||
@@ -198,23 +240,21 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 右侧识别面板 */}
|
||||
<div
|
||||
className="flex flex-col gap-2.5 overflow-y-auto flex-1 min-h-0"
|
||||
>
|
||||
{/* 识别到的元素 */}
|
||||
{/* 右侧识别 + 元素清单 */}
|
||||
<div className="flex flex-col gap-2.5 overflow-y-auto flex-1 min-h-0">
|
||||
{/* 识别 */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-1.5 text-white text-[12.5px] font-semibold">
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
识别到的元素
|
||||
Vision 识别
|
||||
{desc && <span className="text-[10px] text-emerald-400 font-mono ml-1">已识别</span>}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDescribe}
|
||||
disabled={describing}
|
||||
className="text-[10.5px] text-white/70 hover:text-white px-2 py-0.5 rounded border border-white/20 hover:border-white/40 disabled:opacity-50 inline-flex items-center gap-1"
|
||||
title="调 Gemini Vision 识别"
|
||||
title="调 Gemini Vision 识别画面元素"
|
||||
>
|
||||
{describing ? <Loader2 className="h-2.5 w-2.5 animate-spin" /> : <RefreshCw className="h-2.5 w-2.5" />}
|
||||
{desc ? "重新识别" : "识别"}
|
||||
@@ -229,7 +269,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
|
||||
Gemini Vision 识别中…
|
||||
</div>
|
||||
) : (
|
||||
<>点击右上角「识别」按钮,Gemini 2.5 看图给出场景描述 / 物体列表 / 风格 / 适合下游的 prompt。</>
|
||||
<>点击「识别」让 Gemini 给出场景 / 风格 / 候选物体</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
@@ -249,25 +289,37 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
|
||||
{desc.objects && desc.objects.length > 0 && (
|
||||
<div className="rounded-md bg-pink-500/10 border border-pink-400/25 px-2.5 py-1.5">
|
||||
<div className="text-[9.5px] uppercase tracking-widest text-pink-300/90 mb-1.5">
|
||||
物体 · 点击加入自定义列表
|
||||
候选物体 · 点击加入「元素清单」
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{desc.objects.map((o, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => addCustom(o.name, o.extract_prompt)}
|
||||
title={o.position ? `位置:${o.position}` : undefined}
|
||||
className="w-full text-left rounded bg-white/[0.03] hover:bg-white/[0.08] border border-white/8 hover:border-pink-300/40 px-2 py-1 transition group/o"
|
||||
>
|
||||
<div className="flex items-center gap-1 text-white text-[11px] font-medium">
|
||||
<span className="truncate">{o.name}</span>
|
||||
<Plus className="h-2.5 w-2.5 text-pink-300/60 opacity-0 group-hover/o:opacity-100 transition shrink-0 ml-auto" />
|
||||
</div>
|
||||
{o.extract_prompt && (
|
||||
<div className="text-[9.5px] text-white/40 mt-0.5 truncate font-mono leading-tight">{o.extract_prompt}</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{desc.objects.map((o, i) => {
|
||||
const alreadyIn = elements.some((e) => e.name_zh === o.name)
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => !alreadyIn && handleAddElement(o.name, o.extract_prompt, o.position, "auto")}
|
||||
disabled={alreadyIn}
|
||||
title={alreadyIn ? "已加入元素清单" : (o.position ? `位置:${o.position}` : undefined)}
|
||||
className={`w-full text-left rounded border px-2 py-1 transition group/o ${
|
||||
alreadyIn
|
||||
? "bg-emerald-500/10 border-emerald-400/30 cursor-default"
|
||||
: "bg-white/[0.03] hover:bg-white/[0.08] border-white/8 hover:border-pink-300/40"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1 text-white text-[11px] font-medium">
|
||||
<span className="truncate">{o.name}</span>
|
||||
{alreadyIn ? (
|
||||
<Check className="h-2.5 w-2.5 text-emerald-300 shrink-0 ml-auto" />
|
||||
) : (
|
||||
<Plus className="h-2.5 w-2.5 text-pink-300/60 opacity-0 group-hover/o:opacity-100 transition shrink-0 ml-auto" />
|
||||
)}
|
||||
</div>
|
||||
{o.extract_prompt && (
|
||||
<div className="text-[9.5px] text-white/40 mt-0.5 truncate font-mono leading-tight">{o.extract_prompt}</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -275,93 +327,123 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 自定义提取 — 多条 + 中英对照 */}
|
||||
{/* 元素清单(持久化) */}
|
||||
<section>
|
||||
<div className="flex items-center gap-1.5 mb-2 text-white text-[12.5px] font-semibold">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
自定义提取元素
|
||||
{customs.length > 0 && (
|
||||
<span className="text-[10px] text-white/35 font-mono ml-0.5">· {customs.length}</span>
|
||||
元素清单
|
||||
{elements.length > 0 && (
|
||||
<span className="text-[10px] text-white/35 font-mono ml-0.5">· {elements.length}</span>
|
||||
)}
|
||||
<span className="text-[9.5px] text-white/35 font-normal ml-auto">→ 给下游生图作素材</span>
|
||||
</div>
|
||||
|
||||
{customs.length > 0 && (
|
||||
{elements.length > 0 && (
|
||||
<div className="space-y-1.5 mb-2">
|
||||
{customs.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="group/c relative rounded-md bg-white/[0.04] border border-white/10 px-2.5 py-1.5"
|
||||
>
|
||||
<div className="pr-5 text-white text-[11.5px] font-medium leading-tight truncate">{c.zh}</div>
|
||||
<div className="pr-5 mt-0.5 text-[10px] font-mono leading-tight truncate">
|
||||
{c.translating ? (
|
||||
<span className="inline-flex items-center gap-1 text-white/40">
|
||||
<Loader2 className="h-2.5 w-2.5 animate-spin" /> 翻译中…
|
||||
</span>
|
||||
) : c.en ? (
|
||||
<span className="text-white/45">{c.en}</span>
|
||||
) : (
|
||||
<span className="text-rose-300/60">翻译失败 · 点击重试</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeCustom(c.id)}
|
||||
className="absolute right-1.5 top-1.5 h-4 w-4 rounded-sm text-white/40 hover:text-white hover:bg-white/10 inline-flex items-center justify-center opacity-0 group-hover/c:opacity-100 transition"
|
||||
title="删除"
|
||||
{elements.map((e) => {
|
||||
const isCutting = cuttingId === e.id
|
||||
const hasCutout = !!e.cutout_id
|
||||
return (
|
||||
<div
|
||||
key={e.id}
|
||||
className="group/c relative rounded-md bg-white/[0.04] border border-white/10 px-2.5 py-1.5 flex items-start gap-2"
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{/* 抠图缩略图 / 占位 */}
|
||||
<div
|
||||
className="flex-shrink-0 rounded bg-black/40 border border-white/8 flex items-center justify-center overflow-hidden"
|
||||
style={{ width: 36, height: 36 }}
|
||||
>
|
||||
{hasCutout ? (
|
||||
<a
|
||||
href={cutoutUrl(jobId, f.index, e.id)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="点击查看抠图原图"
|
||||
className="block w-full h-full"
|
||||
>
|
||||
<img
|
||||
src={cutoutUrl(jobId, f.index, e.id)}
|
||||
alt={e.name_zh}
|
||||
className="w-full h-full object-contain"
|
||||
style={{ background: "repeating-conic-gradient(rgba(255,255,255,0.04) 0 25%, transparent 0 50%) 0 0 / 8px 8px" }}
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<Sparkle className="h-3.5 w-3.5 text-white/25" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 pr-5">
|
||||
<div className="flex items-center gap-1 text-white text-[11.5px] font-medium leading-tight truncate">
|
||||
<span className="truncate">{e.name_zh}</span>
|
||||
{e.source === "auto" && (
|
||||
<span className="text-[8.5px] text-pink-300/70 font-mono uppercase">auto</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[10px] font-mono leading-tight truncate text-white/45">
|
||||
{e.name_en || <span className="text-white/30">(无英文)</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 抠图按钮 */}
|
||||
<button
|
||||
onClick={() => handleCutout(e.id)}
|
||||
disabled={isCutting}
|
||||
title={hasCutout ? "重新抠图" : "调 nano-banana 抠透明背景元素"}
|
||||
className={`shrink-0 text-[10px] px-1.5 py-0.5 rounded inline-flex items-center gap-1 transition disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
hasCutout
|
||||
? "bg-emerald-500/20 text-emerald-200 hover:bg-emerald-500/30"
|
||||
: "bg-violet-500/30 text-white/90 hover:bg-violet-500/50"
|
||||
}`}
|
||||
>
|
||||
{isCutting ? <Loader2 className="h-2.5 w-2.5 animate-spin" /> : <Wand2 className="h-2.5 w-2.5" />}
|
||||
{isCutting ? "抠图中" : hasCutout ? "重抠" : "抠图"}
|
||||
</button>
|
||||
|
||||
{/* 删除 */}
|
||||
<button
|
||||
onClick={() => handleDeleteElement(e.id)}
|
||||
className="absolute right-1 top-1 h-4 w-4 rounded-sm text-white/40 hover:text-white hover:bg-white/10 inline-flex items-center justify-center opacity-0 group-hover/c:opacity-100 transition"
|
||||
title="删除该元素"
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 添加 */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
value={addInput}
|
||||
onChange={(e) => setAddInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault()
|
||||
if (addInput.trim()) {
|
||||
addCustom(addInput)
|
||||
onChange={(ev) => setAddInput(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === "Enter" && !ev.nativeEvent.isComposing) {
|
||||
ev.preventDefault()
|
||||
if (addInput.trim() && !addingZh) {
|
||||
handleAddElement(addInput)
|
||||
setAddInput("")
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder="输入中文 · 回车自动翻英文"
|
||||
placeholder="加自定义元素 · 中文回车自动翻英文"
|
||||
className="flex-1 text-[12px] px-2.5 py-1.5 rounded-md bg-black/40 border border-white/15 outline-none text-white placeholder:text-white/30 focus:ring-2 focus:ring-violet-400/40 focus:border-violet-400/40"
|
||||
/>
|
||||
<button
|
||||
onClick={() => { if (addInput.trim()) { addCustom(addInput); setAddInput("") } }}
|
||||
disabled={!addInput.trim()}
|
||||
onClick={() => { if (addInput.trim() && !addingZh) { handleAddElement(addInput); setAddInput("") } }}
|
||||
disabled={!addInput.trim() || addingZh}
|
||||
className="text-[11.5px] px-2.5 py-1.5 rounded-md bg-violet-500/60 hover:bg-violet-500/80 text-white inline-flex items-center justify-center gap-1 disabled:opacity-30 disabled:cursor-not-allowed transition shrink-0"
|
||||
title="添加"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{addingZh ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleGenerateNext}
|
||||
disabled={generating || !desc?.suggested_prompt}
|
||||
className="mt-2 w-full text-[12px] py-2 rounded-md bg-gradient-to-r from-rose-500 to-pink-500 text-white hover:opacity-95 disabled:opacity-40 disabled:cursor-not-allowed inline-flex items-center justify-center gap-1.5 font-semibold transition"
|
||||
title={!desc?.suggested_prompt ? "先识别此分镜" : `结合 ${customs.length} 条提取元素与已选图,生成下一张`}
|
||||
>
|
||||
{generating ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wand2 className="h-3.5 w-3.5" />}
|
||||
{generating ? "生成中…(5-15 秒)" : "⚡ 生成下一张"}
|
||||
</button>
|
||||
{!desc?.suggested_prompt ? (
|
||||
<div className="mt-1 text-[10px] text-white/40 text-center">先识别此分镜,再生成</div>
|
||||
) : (
|
||||
<div className="mt-1 text-[10px] text-white/40 text-center">
|
||||
基于:{f.generated_images?.some((g) => g.selected) ? "上一轮已选生成图" : "原关键帧"}
|
||||
{customs.length > 0 && ` + ${customs.length} 条提取元素`}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1.5 text-[10px] text-white/35 leading-relaxed">
|
||||
元素持久化保存 · 已抠图的元素将作为透明素材层送到下一步「生图」节点融合
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -377,6 +377,22 @@ export function KeyframeNode({ data, selected }: any) {
|
||||
{isSel && (
|
||||
<div className="absolute inset-0 bg-emerald-400/15 rounded-md pointer-events-none" />
|
||||
)}
|
||||
{/* 左上角:已清洗 + 抠图数 */}
|
||||
{(f.cleaned_url || (f.elements?.some((e) => e.cutout_id))) && (
|
||||
<div className="absolute top-0 left-0 flex items-center gap-0.5 px-1 py-0.5 rounded-br-md leading-none">
|
||||
{f.cleaned_url && (
|
||||
<span title="已清洗" className="bg-cyan-500/85 text-white text-[8px] font-bold px-1 py-0.5 rounded-sm">✨</span>
|
||||
)}
|
||||
{(() => {
|
||||
const cutN = f.elements?.filter((e) => e.cutout_id).length ?? 0
|
||||
return cutN > 0 ? (
|
||||
<span title={`${cutN} 个元素已抠图`} className="bg-violet-500/85 text-white text-[8px] font-mono font-bold px-1 py-0.5 rounded-sm">
|
||||
{cutN}
|
||||
</span>
|
||||
) : null
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
{/* 时间戳 */}
|
||||
<div className="absolute bottom-0 right-0 bg-black/70 text-white text-[8.5px] font-mono px-1 py-0.5 leading-none rounded-bl rounded-br-md">
|
||||
{f.timestamp.toFixed(1)}s
|
||||
@@ -420,20 +436,29 @@ export function KeyframeNode({ data, selected }: any) {
|
||||
<NodeShell
|
||||
type="process" status={st}
|
||||
icon={<ImageIcon className="h-4 w-4" />}
|
||||
title="关键帧 · Keyframes"
|
||||
subtitle={`STEP 2 · ffmpeg · ${frames.length ? `${d.selectedFrames.size}/${frames.length} 选用` : "等待抽取"}`}
|
||||
title="关键帧 · 清洗 + 提取"
|
||||
subtitle={`STEP 2 · ${frames.length ? `${d.selectedFrames.size}/${frames.length} 选用` : "等待抽取"}`}
|
||||
width={KEYFRAME_WIDTH}
|
||||
selected={selected}
|
||||
>
|
||||
{frames.length > 0 ? (
|
||||
<div className="text-[11.5px] leading-relaxed text-[var(--text-soft)]">
|
||||
自动 <span className="text-[var(--text-strong)] font-medium">{frames.length}</span> 张 · 选中 {d.selectedFrames.size} 张
|
||||
<br />
|
||||
<span className="text-[10.5px] text-[var(--text-faint)]">
|
||||
上方缩略图点击放大 · 在 Input 节点拖时间轴可手动加帧
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
{frames.length > 0 ? (() => {
|
||||
const cleanedCount = frames.filter((x) => x.cleaned_url).length
|
||||
const elementsCount = frames.reduce((s, x) => s + (x.elements?.length ?? 0), 0)
|
||||
const cutoutCount = frames.reduce((s, x) => s + (x.elements?.filter((e) => e.cutout_id).length ?? 0), 0)
|
||||
return (
|
||||
<div className="text-[11.5px] leading-relaxed text-[var(--text-soft)]">
|
||||
自动 <span className="text-[var(--text-strong)] font-medium">{frames.length}</span> 张
|
||||
{" · "}
|
||||
<span className={cleanedCount > 0 ? "text-cyan-300/90 font-medium" : ""}>{cleanedCount} 已清洗</span>
|
||||
{" · "}
|
||||
<span className={cutoutCount > 0 ? "text-violet-300/90 font-medium" : ""}>{cutoutCount}/{elementsCount} 已抠图</span>
|
||||
<br />
|
||||
<span className="text-[10.5px] text-[var(--text-faint)]">
|
||||
点缩略图 → 清洗水印 / 提取元素 → 抠图给下游融合
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})() : (
|
||||
<div className="text-[11.5px] text-[var(--text-faint)] py-1">
|
||||
等待解析(默认 5 张)
|
||||
</div>
|
||||
|
||||
@@ -34,11 +34,23 @@ export interface GeneratedImage {
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export interface KeyElement {
|
||||
id: string
|
||||
name_zh: string
|
||||
name_en: string
|
||||
position?: string
|
||||
source: "auto" | "manual"
|
||||
cutout_id?: string | null
|
||||
created_at?: number
|
||||
}
|
||||
|
||||
export interface KeyFrame {
|
||||
index: number
|
||||
timestamp: number
|
||||
url: string
|
||||
description?: FrameDescription | null
|
||||
cleaned_url?: string | null
|
||||
elements?: KeyElement[]
|
||||
generated_images?: GeneratedImage[]
|
||||
}
|
||||
|
||||
@@ -185,3 +197,56 @@ export function frameUrl(jobId: string, frameIndex: number): string {
|
||||
export function videoUrl(jobId: string): string {
|
||||
return `${API_BASE}/jobs/${jobId}/video.mp4`
|
||||
}
|
||||
|
||||
export function cleanedFrameUrl(jobId: string, frameIndex: number, bust?: string | number): string {
|
||||
const u = `${API_BASE}/jobs/${jobId}/frames/${frameIndex}/cleaned.jpg`
|
||||
return bust ? `${u}?t=${bust}` : u
|
||||
}
|
||||
|
||||
export function cutoutUrl(jobId: string, frameIndex: number, elementId: string): string {
|
||||
return `${API_BASE}/jobs/${jobId}/frames/${frameIndex}/elements/${elementId}/cutout.png`
|
||||
}
|
||||
|
||||
export async function cleanupFrame(jobId: string, frameIdx: number): Promise<Job> {
|
||||
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/cleanup`, { method: "POST" })
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "")
|
||||
throw new Error(`cleanup ${res.status} ${txt.slice(0, 300)}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function addElement(
|
||||
jobId: string,
|
||||
frameIdx: number,
|
||||
body: { name_zh: string; name_en?: string; position?: string; source?: "auto" | "manual" },
|
||||
): Promise<Job> {
|
||||
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ source: "manual", ...body }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "")
|
||||
throw new Error(`addElement ${res.status} ${txt.slice(0, 300)}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function deleteElement(jobId: string, frameIdx: number, elementId: string): Promise<Job> {
|
||||
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements/${elementId}`, { method: "DELETE" })
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "")
|
||||
throw new Error(`deleteElement ${res.status} ${txt.slice(0, 300)}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function cutoutElement(jobId: string, frameIdx: number, elementId: string): Promise<Job> {
|
||||
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements/${elementId}/cutout`, { method: "POST" })
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "")
|
||||
throw new Error(`cutout ${res.status} ${txt.slice(0, 300)}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user