auto-save 2026-05-12 19:42 (~3)

This commit is contained in:
2026-05-12 19:42:27 +08:00
parent 902c3ed936
commit f901b71d66
3 changed files with 73 additions and 57 deletions

View File

@@ -258,6 +258,13 @@
"message": "auto-save 2026-05-12 19:31 (~2)",
"hash": "ecef988",
"files_changed": 2
},
{
"ts": "2026-05-12T19:36:53+08:00",
"type": "commit",
"message": "auto-save 2026-05-12 19:36 (~3)",
"hash": "902c3ed",
"files_changed": 3
}
]
}

View File

@@ -8,7 +8,7 @@ import {
} from "@xyflow/react"
import { Toaster, toast } from "sonner"
import {
InputNode, DownloadNode, SplitNode, KeyframeNode, ASRNode,
InputNode, KeyframeNode, ASRNode,
TranslateNode, RewriteNode, ImageGenNode, VideoGenNode, ComposeNode,
type NodeData,
} from "@/components/nodes"
@@ -19,8 +19,6 @@ import { FrameLightbox } from "@/components/lightbox"
const NODE_TYPES = {
input: InputNode,
download: DownloadNode,
split: SplitNode,
keyframe: KeyframeNode,
asr: ASRNode,
translate: TranslateNode,
@@ -30,27 +28,23 @@ const NODE_TYPES = {
compose: ComposeNode,
}
// 手布局DAG从左到右
// 拆分后两路:上路 video → keyframe → imagegen → videogen ↘
// 下路 audio → asr → translate → rewrite ────→ compose
// 合并 input + download + split 为一个节点
// 分叉:上路 input → keyframe → imagegen → videogen ↘
// 下路 input → asr → translate → rewrite ────→ compose
const LAYOUT: Array<{ id: string; type: keyof typeof NODE_TYPES; x: number; y: number }> = [
{ id: "input", type: "input", x: 40, y: 240 },
{ id: "download", type: "download", x: 400, y: 240 },
{ id: "split", type: "split", x: 720, y: 240 },
{ id: "keyframe", type: "keyframe", x: 1060, y: 60 },
{ id: "asr", type: "asr", x: 1060, y: 440 },
{ id: "translate", type: "translate", x: 1440, y: 440 },
{ id: "imagegen", type: "imagegen", x: 1480, y: 60 },
{ id: "rewrite", type: "rewrite", x: 1820, y: 440 },
{ id: "videogen", type: "videogen", x: 1860, y: 60 },
{ id: "compose", type: "compose", x: 2240, y: 240 },
{ id: "keyframe", type: "keyframe", x: 460, y: 60 },
{ id: "asr", type: "asr", x: 460, y: 440 },
{ id: "translate", type: "translate", x: 840, y: 440 },
{ id: "imagegen", type: "imagegen", x: 880, y: 60 },
{ id: "rewrite", type: "rewrite", x: 1220, y: 440 },
{ id: "videogen", type: "videogen", x: 1260, y: 60 },
{ id: "compose", type: "compose", x: 1640, y: 240 },
]
const EDGES_RAW: Array<[string, string]> = [
["input", "download"],
["download", "split"],
["split", "keyframe"],
["split", "asr"],
["input", "keyframe"],
["input", "asr"],
["asr", "translate"],
["translate", "rewrite"],
["keyframe", "imagegen"],
@@ -213,9 +207,7 @@ export default function Home() {
// 边的 animated 状态跟 Job 进度联动
useEffect(() => {
const doneOf: Record<string, boolean> = {
input: !!job,
download: !!job?.video_url,
split: !!job && ["frames_extracted", "transcribing", "transcribed"].includes(job.status),
input: !!job?.video_url,
keyframe: !!job && job.frames.length > 0,
asr: !!job && job.transcript.length > 0,
translate: !!job && (job.transcript.some((s) => s.zh) ?? false),

View File

@@ -98,9 +98,14 @@ export function Dashboard({ data }: Props) {
const isFailed = job?.status === "failed"
const colState: Record<string, ColState> = {
input: !job ? "pending" : "done",
download: !job ? "pending" : isDownloading ? "running" : hasVideo ? "done" : isFailed && job.progress < 30 ? "failed" : "pending",
split: !job ? "pending" : isSplitting ? "running" : hasFrames ? "done" : isFailed && job.progress >= 30 && job.progress < 50 ? "failed" : "pending",
// input 状态合并:覆盖原 input + download + split
input: !job ? "pending"
: isDownloading ? "running"
: isSplitting ? "running"
: hasFrames || hasTranscript ? "done"
: hasVideo ? "done"
: isFailed && job.progress < 50 ? "failed"
: "pending",
keyframe: !job ? "pending" : (isSplitting && !hasFrames) ? "running" : hasFrames ? "done" : isFailed && job.progress >= 50 && job.progress < 70 ? "failed" : "pending",
asr: !job ? "pending" : job.status === "transcribing" ? "running" : hasTranscript ? "done" : isFailed && job.progress >= 70 ? "failed" : "pending",
translate: !job ? "pending" : job.status === "transcribing" ? "running" : hasZh ? "done" : "pending",
@@ -112,9 +117,7 @@ export function Dashboard({ data }: Props) {
/* 每列摘要 = tile 副标题 */
const colSummary: Record<string, string> = {
input: job ? (job.url.startsWith("upload://") ? "📎 上传" : "🔗 链接") : "等待",
download: hasVideo && job ? `${job.width}×${job.height} · ${job.duration.toFixed(1)}s` : isDownloading ? "下载中…" : "—",
split: hasFrames ? "wav 已生成" : isSplitting ? "拆轨中…" : "—",
input: !job ? "等待" : isDownloading ? "下载中…" : hasVideo ? `${job.width}×${job.height} · ${job.duration.toFixed(1)}s` : "",
keyframe: hasFrames ? `${data.selectedFrames.size}/${job!.frames.length} 选用` : "—",
asr: hasTranscript ? `${job!.transcript.length}` : "—",
translate: hasZh ? `${job!.transcript.filter((s) => s.zh).length}` : "—",
@@ -126,15 +129,13 @@ export function Dashboard({ data }: Props) {
const TILES: Array<{ key: string; title: string; type: ColType; icon: ReactNode; step: number }> = [
{ key: "input", title: "输入", type: "input", icon: <Link2 className="h-3.5 w-3.5" />, step: 1 },
{ key: "download", title: "下载", type: "process", icon: <Download className="h-3.5 w-3.5" />, step: 2 },
{ key: "split", title: "拆分", type: "process", icon: <Scissors className="h-3.5 w-3.5" />, step: 3 },
{ key: "keyframe", title: "关键帧", type: "ai", icon: <ImageIcon className="h-3.5 w-3.5" />, step: 4 },
{ key: "asr", title: "转录", type: "ai", icon: <Mic className="h-3.5 w-3.5" />, step: 5 },
{ key: "translate", title: "翻译", type: "ai", icon: <Languages className="h-3.5 w-3.5" />, step: 6 },
{ key: "rewrite", title: "改写", type: "ai", icon: <FileEdit className="h-3.5 w-3.5" />, step: 7 },
{ key: "imagegen", title: "生图", type: "ai", icon: <Sparkles className="h-3.5 w-3.5" />, step: 8 },
{ key: "videogen", title: "生视频", type: "ai", icon: <Film className="h-3.5 w-3.5" />, step: 9 },
{ key: "compose", title: "合成", type: "output", icon: <FileVideo className="h-3.5 w-3.5" />, step: 10 },
{ key: "keyframe", title: "关键帧", type: "ai", icon: <ImageIcon className="h-3.5 w-3.5" />, step: 2 },
{ key: "asr", title: "转录", type: "ai", icon: <Mic className="h-3.5 w-3.5" />, step: 3 },
{ key: "translate", title: "翻译", type: "ai", icon: <Languages className="h-3.5 w-3.5" />, step: 4 },
{ key: "rewrite", title: "改写", type: "ai", icon: <FileEdit className="h-3.5 w-3.5" />, step: 5 },
{ key: "imagegen", title: "生图", type: "ai", icon: <Sparkles className="h-3.5 w-3.5" />, step: 6 },
{ key: "videogen", title: "生视频", type: "ai", icon: <Film className="h-3.5 w-3.5" />, step: 7 },
{ key: "compose", title: "合成", type: "output", icon: <FileVideo className="h-3.5 w-3.5" />, step: 8 },
]
// 单选展开toggle 同一 key = 收起;点其他 key = 切换
@@ -169,10 +170,8 @@ export function Dashboard({ data }: Props) {
return (
<div className="h-full flex flex-col gap-1 p-1.5">
{/* 主线input / download / split */}
{/* 起点:输入(含下载+拆分) */}
<Tile tkey="input" />
<Tile tkey="download" />
<Tile tkey="split" />
{/* 分叉:上路 关键帧/生图/生视频 */}
<div className="border-l-2 border-violet-400/30 pl-1.5 ml-1 space-y-1">
<Tile tkey="keyframe" />
@@ -275,24 +274,42 @@ export function Dashboard({ data }: Props) {
/>
</div>
</KanbanCard>
{hasVideo && (
<KanbanCard tone="green" tags={["下一步"]} title="解析视频">
<button
type="button"
disabled={isAnalyzing || data.analyzing}
onClick={data.onAnalyze}
className={`w-full text-[12.5px] py-2 rounded-md bg-gradient-to-r from-indigo-500 to-violet-500 text-white hover:opacity-95 disabled:opacity-40 inline-flex items-center justify-center gap-1.5 font-semibold shadow-lg shadow-violet-500/30 mt-1 ${!isAnalyzing && !data.analyzing && !hasFrames ? "animate-pulse" : ""}`}
>
{(isAnalyzing || data.analyzing) ? <><Loader2 className="h-3.5 w-3.5 animate-spin" /> </> : hasFrames ? "重新解析" : "▶ 开始解析"}
</button>
{job && (
<div className="kanban-meta">
<span className="font-mono truncate">
{job.url.startsWith("upload://") ? `📎 ${job.url.slice(9)}` : job.url}
</span>
{hasVideo && job && (
<>
{/* 元数据 */}
<KanbanCard tone="orange" tags={["视频源", job.url.startsWith("upload://") ? "上传" : "yt-dlp"]} title={`${job.width}×${job.height} · ${job.duration.toFixed(1)}s`}>
<div className="text-[10.5px] text-[var(--text-soft)] truncate font-mono">
{job.url.startsWith("upload://") ? `📎 ${job.url.slice(9)}` : `🔗 ${job.url}`}
</div>
)}
</KanbanCard>
</KanbanCard>
{/* 拆分流 */}
<KanbanCard tone="amber" tags={["ffmpeg"]} title="拆分音视频流">
<div className="grid grid-cols-2 gap-2 text-[11px]">
<div className="rounded-md bg-violet-500/10 border border-violet-400/20 px-2 py-1.5">
<div className="text-[9.5px] uppercase tracking-widest text-violet-300"></div>
<div className="text-[var(--text-strong)]"> </div>
</div>
<div className="rounded-md bg-pink-500/10 border border-pink-400/20 px-2 py-1.5">
<div className="text-[9.5px] uppercase tracking-widest text-pink-300"></div>
<div className="text-[var(--text-strong)]"> ASR (wav)</div>
</div>
</div>
</KanbanCard>
{/* 解析按钮 */}
<KanbanCard tone="green" tags={["下一步"]} title={hasFrames ? "重新解析" : "启动全流程"}>
<button
type="button"
disabled={isAnalyzing || data.analyzing}
onClick={data.onAnalyze}
className={`w-full text-[12.5px] py-2 rounded-md bg-gradient-to-r from-indigo-500 to-violet-500 text-white hover:opacity-95 disabled:opacity-40 inline-flex items-center justify-center gap-1.5 font-semibold shadow-lg shadow-violet-500/30 mt-1 ${!isAnalyzing && !data.analyzing && !hasFrames ? "animate-pulse" : ""}`}
>
{(isAnalyzing || data.analyzing) ? <><Loader2 className="h-3.5 w-3.5 animate-spin" /> </> : hasFrames ? "↻ 重新解析" : "▶ 开始解析(全流程)"}
</button>
<div className="kanban-meta">
= ASR
</div>
</KanbanCard>
</>
)}
</>
)}