auto-save 2026-05-13 00:22 (~4)

This commit is contained in:
2026-05-13 00:23:01 +08:00
parent 2d7c6cc3a6
commit 66fb1444c4
4 changed files with 394 additions and 27 deletions

View File

@@ -6,9 +6,10 @@ import {
Mic, Languages, FileEdit, Sparkles, Film, FileVideo, Loader2, Plus, Check,
ChevronDown, X,
} from "lucide-react"
import { type Job, frameUrl, videoUrl } from "@/lib/api"
import { type Job, type KeyFrame, frameUrl, videoUrl, generateImage, selectGenerated, generatedImageUrl } from "@/lib/api"
import { type NodeData } from "@/components/nodes"
import { FrameLightbox } from "@/components/lightbox"
import { toast } from "sonner"
type ColType = "input" | "process" | "ai" | "output"
const TYPE_GRAD: Record<ColType, string> = {
@@ -518,34 +519,19 @@ export function Dashboard({ data }: Props) {
</>
)}
{/* ---- ImageGen — Kanban ---- */}
{/* ---- ImageGen — 选中关键帧每张一卡,生成 + 多版本 ---- */}
{key === "imagegen" && (
<>
<KanbanCard tone="rose" tags={["推荐"]} title="nano-banana-pro">
<div className="text-[11px] text-[var(--text-soft)]">Gemini 3 Pro Image · SKG /v1/images/generations</div>
data.selectedFrames.size === 0 ? (
<KanbanCard tone="pink" tags={["待启动"]} title="未选关键帧">
<div className="text-[11px] text-[var(--text-soft)]"> 1+ 1 </div>
</KanbanCard>
<KanbanCard tone="rose" tags={["备选"]} title="gpt-image-2">
<div className="text-[11px] text-[var(--text-soft)]">OpenAI · SKG </div>
</KanbanCard>
{data.selectedFrames.size === 0 ? (
<KanbanCard tone="pink" tags={["待启动"]} title="未选关键帧">
<div className="text-[11px] text-[var(--text-soft)]"> 1+ 1 </div>
</KanbanCard>
) : (
Array.from({ length: data.selectedFrames.size }).map((_, i) => (
<KanbanCard
key={i}
tone="pink"
tags={[`生成图 ${i + 1}`]}
title={`分镜 ${i + 1} → AI 图`}
>
<div className="aspect-video bg-black/40 rounded-md flex items-center justify-center text-[11px] text-[var(--text-faint)]">
</div>
</KanbanCard>
))
)}
</>
) : (
Array.from(data.selectedFrames).sort((a, b) => a - b).map((frameIdx) => {
const f = data.job?.frames.find((x) => x.index === frameIdx)
if (!f || !data.job) return null
return <ImageGenCard key={frameIdx} job={data.job} frame={f} onJobUpdate={data.onJobUpdate} />
})
)
)}
{/* ---- VideoGen — Kanban ---- */}
@@ -584,3 +570,190 @@ export function Dashboard({ data }: Props) {
)
}
}
/* ============================================================
ImageGenCard — 单张关键帧的生图卡
============================================================ */
function ImageGenCard({ job, frame, onJobUpdate }: {
job: Job
frame: KeyFrame
onJobUpdate: (j: Job) => void
}) {
const [extra, setExtra] = useState("")
const [model, setModel] = useState("gemini-3-pro-image-preview")
const [mode, setMode] = useState<"edit" | "text">("edit")
const [generating, setGenerating] = useState(false)
const basePrompt = frame.description?.suggested_prompt ?? "(尚未识别 · 点关键帧打开 lightbox 先识别)"
const [editablePrompt, setEditablePrompt] = useState(basePrompt)
const [showPrompt, setShowPrompt] = useState(false)
// 当 vision 识别完成后更新默认 prompt
useEffect(() => {
if (frame.description?.suggested_prompt) {
setEditablePrompt(frame.description.suggested_prompt)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [frame.description?.suggested_prompt])
const handleGenerate = async () => {
if (!editablePrompt.trim()) {
toast.error("请先填写 prompt点上方关键帧识别会自动生成")
return
}
setGenerating(true)
try {
const updated = await generateImage(job.id, frame.index, {
prompt: editablePrompt,
extra_prompt: extra,
model,
mode,
})
onJobUpdate(updated)
toast.success(`分镜 ${frame.index + 1} 生成完成`)
} catch (e) {
toast.error("生图失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setGenerating(false)
}
}
const handleSelectGen = async (genId: string, currentlySelected: boolean) => {
try {
const updated = await selectGenerated(job.id, frame.index, genId, !currentlySelected)
onJobUpdate(updated)
} catch (e) {
toast.error("选用失败:" + (e instanceof Error ? e.message : String(e)))
}
}
const gens = frame.generated_images ?? []
const objects = frame.description?.objects ?? []
return (
<KanbanCard tone="rose" tags={[`分镜 ${frame.index + 1}`, `${frame.timestamp.toFixed(1)}s`]} title="生成参考图">
{/* 参考图 + 识别物体 chips */}
<div className="flex gap-2 items-start mt-1">
<img
src={frameUrl(job.id, frame.index)}
alt={`frame ${frame.index}`}
className="rounded-md object-cover flex-shrink-0"
style={{ width: 96, aspectRatio: `${job.width}/${job.height}` }}
/>
<div className="flex-1 min-w-0">
<div className="text-[10px] text-[var(--text-faint)] uppercase tracking-widest mb-1"></div>
{objects.length > 0 ? (
<div className="flex flex-wrap gap-1">
{objects.slice(0, 6).map((o, i) => (
<button
key={i}
onClick={() => setExtra((p) => p ? `${p}, ${o.name}` : o.name)}
className="text-[10px] px-1.5 py-0.5 rounded-full bg-white/[0.06] hover:bg-white/[0.12] border border-white/15 text-[var(--text-strong)]"
title="点击加入需求"
>
+ {o.name}
</button>
))}
</div>
) : (
<div className="text-[10px] text-[var(--text-faint)] italic"></div>
)}
</div>
</div>
{/* base prompt可展开编辑 */}
<div className="mt-2.5">
<button
type="button"
onClick={() => setShowPrompt((v) => !v)}
className="w-full text-[10px] text-[var(--text-faint)] hover:text-[var(--text-strong)] inline-flex items-center justify-between"
>
<span> prompt {showPrompt ? "▼" : "▶"}</span>
<span className="font-mono">{editablePrompt.length} </span>
</button>
{showPrompt && (
<textarea
value={editablePrompt}
onChange={(e) => setEditablePrompt(e.target.value)}
rows={3}
className="mt-1 w-full text-[11px] px-2 py-1.5 rounded-md bg-black/30 border border-white/10 text-[var(--text-strong)] resize-none focus:ring-1 focus:ring-rose-400/40 outline-none font-mono"
/>
)}
</div>
{/* 用户额外指令 */}
<div className="mt-2">
<div className="text-[10px] text-[var(--text-faint)] uppercase tracking-widest mb-1"></div>
<textarea
value={extra}
onChange={(e) => setExtra(e.target.value)}
rows={2}
placeholder="例:加 SKG logo、换实验室背景、删水印"
className="w-full text-[11.5px] px-2 py-1.5 rounded-md bg-black/30 border border-white/15 text-[var(--text-strong)] placeholder:text-[var(--text-faint)] resize-none focus:ring-2 focus:ring-rose-400/40 outline-none"
/>
</div>
{/* 模型 + 模式 + 生成 */}
<div className="mt-2 flex gap-1.5 items-center">
<select
value={model}
onChange={(e) => setModel(e.target.value)}
className="flex-1 text-[10.5px] px-2 py-1.5 rounded-md bg-black/40 border border-white/15 text-[var(--text-strong)]"
>
<option value="gemini-3-pro-image-preview">nano-banana-pro</option>
<option value="gemini-3.1-flash-image-preview">gemini-3.1-flash-image</option>
<option value="gemini-2.5-flash-image">gemini-2.5-flash-image</option>
</select>
<select
value={mode}
onChange={(e) => setMode(e.target.value as "edit" | "text")}
className="text-[10.5px] px-2 py-1.5 rounded-md bg-black/40 border border-white/15 text-[var(--text-strong)]"
>
<option value="edit">image-to-image</option>
<option value="text">text-only</option>
</select>
</div>
<button
onClick={handleGenerate}
disabled={generating || !editablePrompt.trim()}
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 inline-flex items-center justify-center gap-1.5 font-semibold"
>
{generating ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
{generating ? "生成中…(约 5-15 秒)" : `⚡ 生成 1 张${gens.length > 0 ? "(再来一张)" : ""}`}
</button>
{/* 生成结果网格 */}
{gens.length > 0 && (
<div className="mt-2.5">
<div className="text-[10px] text-[var(--text-faint)] uppercase tracking-widest mb-1.5">
({gens.length})
</div>
<div className="grid grid-cols-3 gap-1.5">
{gens.map((g) => (
<button
key={g.id}
onClick={() => handleSelectGen(g.id, g.selected)}
title={`${g.mode} · ${g.model}\n${g.prompt}\n${g.selected ? "已选用(点取消)" : "点击选用"}`}
className={`relative aspect-square rounded-md overflow-hidden border-2 transition ${
g.selected
? "border-emerald-400 ring-2 ring-emerald-400/40"
: "border-white/15 hover:border-white/40"
}`}
>
<img
src={generatedImageUrl(job.id, frame.index, g.id)}
alt={`gen ${g.id}`}
className="absolute inset-0 w-full h-full object-cover"
/>
{g.selected && (
<div className="absolute top-1 right-1 bg-emerald-500 text-white rounded-full p-0.5">
<Check className="h-2.5 w-2.5" />
</div>
)}
</button>
))}
</div>
</div>
)}
</KanbanCard>
)
}