auto-save 2026-05-12 19:08 (~3)
This commit is contained in:
@@ -216,6 +216,13 @@
|
||||
"message": "auto-save 2026-05-12 18:57 (~2)",
|
||||
"hash": "684930d",
|
||||
"files_changed": 2
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-12T19:03:35+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-05-12 19:03 (~1)",
|
||||
"hash": "50d6390",
|
||||
"files_changed": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -308,6 +308,11 @@
|
||||
50% { box-shadow: 0 0 0 6px transparent; opacity: 0.7; }
|
||||
}
|
||||
|
||||
@keyframes drawer-in {
|
||||
from { transform: translateX(-24px); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* 覆盖 ReactFlow 内置 reserved type(input/output/default/group)的默认白底
|
||||
提高 specificity:用 .react-flow 前缀确保盖过内置 CSS 变量 */
|
||||
.react-flow .react-flow__node-input,
|
||||
|
||||
@@ -80,7 +80,6 @@ export function Dashboard({ data }: Props) {
|
||||
const [videoT, setVideoT] = useState(0)
|
||||
const [addingFrame, setAddingFrame] = useState(false)
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
||||
const [panelPos, setPanelPos] = useState<{ left: number; top: number } | null>(null)
|
||||
const tileRefs = useRef<Record<string, HTMLButtonElement | null>>({})
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
@@ -135,21 +134,11 @@ export function Dashboard({ data }: Props) {
|
||||
{ key: "compose", title: "合成", type: "output", icon: <FileVideo className="h-3.5 w-3.5" />, step: 10 },
|
||||
]
|
||||
|
||||
// 单选展开:toggle 同一 key = 收起;点其他 key = 切换;并记录 tile 位置让 panel 跟随
|
||||
// 单选展开:toggle 同一 key = 收起;点其他 key = 切换
|
||||
const toggleTile = (key: string) => {
|
||||
if (expanded.has(key)) {
|
||||
setExpanded(new Set())
|
||||
setPanelPos(null)
|
||||
return
|
||||
}
|
||||
setExpanded(new Set([key]))
|
||||
const el = tileRefs.current[key]
|
||||
if (el) {
|
||||
const r = el.getBoundingClientRect()
|
||||
setPanelPos({ left: r.left, top: r.bottom + 6 })
|
||||
}
|
||||
setExpanded((prev) => (prev.has(key) ? new Set() : new Set([key])))
|
||||
}
|
||||
const closeTile = (_key: string) => { setExpanded(new Set()); setPanelPos(null) }
|
||||
const closeTile = (_key: string) => setExpanded(new Set())
|
||||
|
||||
const Tile = ({ tkey, rowSpan }: { tkey: string; rowSpan?: boolean }) => {
|
||||
const t = TILES.find((x) => x.key === tkey)!
|
||||
@@ -202,14 +191,17 @@ export function Dashboard({ data }: Props) {
|
||||
<Tile tkey="compose" />
|
||||
</div>
|
||||
|
||||
{/* 展开面板 — 跟随被点 tile 的位置,从 tile 正下方冒出(fixed 定位) */}
|
||||
{expanded.size > 0 && panelPos && (
|
||||
<div className="fixed z-40" style={{ left: Math.max(8, Math.min(panelPos.left, window.innerWidth - 376)), top: panelPos.top, maxHeight: "calc(100vh - " + panelPos.top + "px - 16px)" }}>
|
||||
{/* 展开面板 — 从屏幕左侧滑出,竖向 sidebar drawer */}
|
||||
{expanded.size > 0 && (
|
||||
<div
|
||||
className="fixed z-40 left-4 transition-transform duration-200"
|
||||
style={{ top: 80, bottom: 16, width: 380 }}
|
||||
>
|
||||
{TILES.filter((t) => expanded.has(t.key)).map((t) => (
|
||||
<section
|
||||
key={t.key}
|
||||
className="rounded-xl border border-white/10 bg-black/40 backdrop-blur-xl overflow-hidden flex flex-col shadow-2xl"
|
||||
style={{ width: 360, maxHeight: "calc(100vh - " + panelPos.top + "px - 16px)" }}
|
||||
className="rounded-xl border border-white/10 bg-black/50 backdrop-blur-xl overflow-hidden flex flex-col shadow-2xl h-full"
|
||||
style={{ animation: "drawer-in 0.22s cubic-bezier(0.32, 0.72, 0, 1)" }}
|
||||
>
|
||||
<div className="flex items-center justify-between px-3 py-2" style={{ background: TYPE_GRAD[t.type] }}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -240,17 +232,16 @@ export function Dashboard({ data }: Props) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
|
||||
{/* ---- Input ---- */}
|
||||
{/* ---- Input — Kanban ---- */}
|
||||
{key === "input" && (
|
||||
<div className="grid grid-cols-2 gap-3 max-w-2xl">
|
||||
<MiniCard>
|
||||
<div className="text-[10px] uppercase tracking-widest text-[var(--text-faint)] mb-1.5">链接 / 上传</div>
|
||||
<>
|
||||
<KanbanCard tone="violet" tags={["链接", "上传"]} title="输入源">
|
||||
<input
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="粘贴 TikTok 链接"
|
||||
disabled={isDownloading || data.submitting}
|
||||
className="w-full text-[12px] px-2.5 py-1.5 rounded-md bg-black/40 border border-white/10 outline-none text-[var(--text-strong)] placeholder:text-[var(--text-faint)] focus:ring-2 focus:ring-[var(--ring)] disabled:opacity-40"
|
||||
className="w-full text-[12px] px-2.5 py-1.5 rounded-md bg-black/30 border border-white/15 outline-none text-[var(--text-strong)] placeholder:text-[var(--text-faint)] focus:ring-2 focus:ring-[var(--ring)] disabled:opacity-40 mt-1"
|
||||
/>
|
||||
<div className="mt-2 flex gap-1.5">
|
||||
<button
|
||||
@@ -282,71 +273,70 @@ export function Dashboard({ data }: Props) {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</MiniCard>
|
||||
</KanbanCard>
|
||||
{hasVideo && (
|
||||
<MiniCard>
|
||||
<div className="text-[10px] uppercase tracking-widest text-[var(--text-faint)] mb-1.5">下一步</div>
|
||||
<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 ${!isAnalyzing && !data.analyzing && !hasFrames ? "animate-pulse" : ""}`}
|
||||
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="mt-2 text-[10px] font-mono text-[var(--text-faint)] truncate">
|
||||
{job.url.startsWith("upload://") ? `📎 ${job.url.slice(9)}` : job.url}
|
||||
<div className="kanban-meta">
|
||||
<span className="font-mono truncate">
|
||||
{job.url.startsWith("upload://") ? `📎 ${job.url.slice(9)}` : job.url}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</MiniCard>
|
||||
</KanbanCard>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ---- Download ---- */}
|
||||
{key === "download" && hasVideo && job && (
|
||||
<div className="grid grid-cols-[auto_1fr] gap-4 max-w-4xl items-start">
|
||||
<MiniCard className="p-0 overflow-hidden" >
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoUrl(job.id)}
|
||||
controls
|
||||
onTimeUpdate={(e) => setVideoT((e.target as HTMLVideoElement).currentTime)}
|
||||
className="block bg-black"
|
||||
style={{ width: 260 }}
|
||||
/>
|
||||
</MiniCard>
|
||||
<div className="space-y-2">
|
||||
<MiniCard>
|
||||
<div className="text-[10px] uppercase tracking-widest text-[var(--text-faint)] mb-1.5">元数据</div>
|
||||
{/* ---- Download — Kanban ---- */}
|
||||
{key === "download" && (
|
||||
!hasVideo ? (
|
||||
<KanbanCard tone="orange" tags={["yt-dlp"]} title={isDownloading ? "下载中…" : "等待提交"}>
|
||||
<div className="text-[11.5px] text-[var(--text-soft)]">TikTok / yt-dlp 兼容站点</div>
|
||||
</KanbanCard>
|
||||
) : (
|
||||
<>
|
||||
<KanbanCard tone="orange" tags={["视频源"]} title={job!.url.startsWith("upload://") ? "本地上传" : "yt-dlp 下载"}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoUrl(job!.id)}
|
||||
controls
|
||||
onTimeUpdate={(e) => setVideoT((e.target as HTMLVideoElement).currentTime)}
|
||||
className="block w-full bg-black rounded-md mt-1"
|
||||
/>
|
||||
</KanbanCard>
|
||||
<KanbanCard tone="amber" tags={["元数据"]} title="视频信息">
|
||||
<div className="grid grid-cols-3 gap-3 text-[11px] font-mono">
|
||||
<div><div className="text-[var(--text-faint)] text-[9.5px]">分辨率</div><div className="text-[var(--text-strong)] text-[13px] mt-0.5">{job.width}×{job.height}</div></div>
|
||||
<div><div className="text-[var(--text-faint)] text-[9.5px]">时长</div><div className="text-[var(--text-strong)] text-[13px] mt-0.5">{job.duration.toFixed(1)}s</div></div>
|
||||
<div><div className="text-[var(--text-faint)] text-[9.5px]">来源</div><div className="text-[var(--text-strong)] text-[13px] mt-0.5">{job.url.startsWith("upload://") ? "上传" : "yt-dlp"}</div></div>
|
||||
<div><div className="text-[var(--text-faint)] text-[9.5px]">分辨率</div><div className="text-[var(--text-strong)] text-[12.5px] mt-0.5">{job!.width}×{job!.height}</div></div>
|
||||
<div><div className="text-[var(--text-faint)] text-[9.5px]">时长</div><div className="text-[var(--text-strong)] text-[12.5px] mt-0.5">{job!.duration.toFixed(1)}s</div></div>
|
||||
<div><div className="text-[var(--text-faint)] text-[9.5px]">来源</div><div className="text-[var(--text-strong)] text-[12.5px] mt-0.5">{job!.url.startsWith("upload://") ? "上传" : "TK"}</div></div>
|
||||
</div>
|
||||
</MiniCard>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{key === "download" && !hasVideo && (
|
||||
<div className="text-[11.5px] text-[var(--text-faint)] py-4 text-center">{isDownloading ? "yt-dlp 下载中…" : "等待提交"}</div>
|
||||
</KanbanCard>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* ---- Split ---- */}
|
||||
{/* ---- Split — Kanban ---- */}
|
||||
{key === "split" && (
|
||||
<div className="grid grid-cols-2 gap-3 max-w-2xl">
|
||||
<MiniCard>
|
||||
<div className="text-[10px] uppercase tracking-widest text-[var(--text-faint)] mb-1">视频流</div>
|
||||
<div className="text-[13px] text-[var(--text-strong)]">→ 关键帧抽取</div>
|
||||
<div className="text-[10.5px] text-[var(--text-faint)] mt-1">ffmpeg fast seek + Laplacian 评分</div>
|
||||
</MiniCard>
|
||||
<MiniCard>
|
||||
<div className="text-[10px] uppercase tracking-widest text-[var(--text-faint)] mb-1">音频流</div>
|
||||
<div className="text-[13px] text-[var(--text-strong)]">→ ASR (16kHz mono wav)</div>
|
||||
<div className="text-[10.5px] text-[var(--text-faint)] mt-1">ffmpeg -vn -ac 1 -ar 16000</div>
|
||||
</MiniCard>
|
||||
</div>
|
||||
<>
|
||||
<KanbanCard tone="amber" tags={["ffmpeg", "视频流"]} title="→ 关键帧抽取">
|
||||
<div className="text-[11px] text-[var(--text-soft)]">fast seek + Laplacian 方差评分</div>
|
||||
</KanbanCard>
|
||||
<KanbanCard tone="amber" tags={["ffmpeg", "音频流"]} title="→ ASR 输入">
|
||||
<div className="text-[11px] text-[var(--text-soft)]">16kHz mono · pcm_s16le wav</div>
|
||||
<div className="kanban-meta">
|
||||
<code className="text-[10px]">-vn -ac 1 -ar 16000</code>
|
||||
</div>
|
||||
</KanbanCard>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ---- Keyframe — Kanban 卡片 ---- */}
|
||||
|
||||
Reference in New Issue
Block a user