auto-save 2026-05-14 04:10 (~4)
This commit is contained in:
@@ -3147,6 +3147,19 @@
|
||||
"type": "session-heartbeat",
|
||||
"message": "Claude 会话活跃 · 最近命令:claude · 4 项未提交变更 · 最近提交:auto-save 2026-05-14 03:59 (~3)",
|
||||
"files_changed": 4
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-14T04:04:54+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-05-14 04:04 (~6)",
|
||||
"hash": "87f1182",
|
||||
"files_changed": 6
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-13T20:08:50Z",
|
||||
"type": "session-heartbeat",
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 3 项未提交变更 · 最近提交:auto-save 2026-05-14 04:04 (~6)",
|
||||
"files_changed": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -817,6 +817,18 @@ api/main.py
|
||||
<h2>变更记录</h2>
|
||||
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
|
||||
<div class="changelog">
|
||||
<article class="change">
|
||||
<header>
|
||||
<h3>2026-05-14 · 自动抽帧快捷工具条移到缩略图上方</h3>
|
||||
<span class="tag violet">Input</span>
|
||||
<span class="tag blue">Frame Target</span>
|
||||
</header>
|
||||
<div class="body">
|
||||
<p><strong>问题:</strong>自动抽帧入口放在 Input 卡片正文里,离视频缩略图和预览工作区较远;用户需要在看缩略图时快速切目标、切张数并反复抽取。</p>
|
||||
<p><strong>改动:</strong>输入视频缩略图浮条上方新增自动抽帧快捷工具条,包含抽帧目标、张数快捷项和抽帧按钮。前端新增 <code>frameCount</code> 状态并把目标 / 张数传给 <code>analyzeJob</code>;已有关键帧时默认用 <code>mode=append</code> 追加抽取。</p>
|
||||
<p><strong>影响:</strong><code>api/main.py</code>、<code>web/lib/api.ts</code>、<code>web/app/page.tsx</code>、<code>web/components/nodes/index.tsx</code>、<code>docs/source-analysis.html</code>。后端追加模式会保留已有关键帧,避开非常接近的时间点,并用新的 frame index 落盘。</p>
|
||||
</div>
|
||||
</article>
|
||||
<article class="change">
|
||||
<header>
|
||||
<h3>2026-05-14 · 自动抽帧支持目标化扫描</h3>
|
||||
@@ -825,7 +837,7 @@ api/main.py
|
||||
</header>
|
||||
<div class="body">
|
||||
<p><strong>问题:</strong>单一“自动抽帧”无法表达这次要清晰人物、下次要转场变化或表情瞬间的不同目标;但把抽帧做成复杂参数面板会破坏 Input 卡片的轻量工作流。</p>
|
||||
<p><strong>改动:</strong>Input 节点新增一个抽帧目标下拉,默认“综合关键帧”,可切换清晰主体、转场变化、表情瞬间、动作峰值。后端 <code>/jobs/{id}/analyze</code> 新增 <code>target</code> 参数,先低清低帧率扫描候选,再按目标评分、pHash 去重、时序分桶,最后只对选中的时间点从原视频抽高质量关键帧。</p>
|
||||
<p><strong>改动:</strong>Input 节点新增抽帧目标,默认“综合关键帧”,可切换清晰主体、转场变化、表情瞬间、动作峰值。后端 <code>/jobs/{id}/analyze</code> 新增 <code>target</code> 参数,先低清低帧率扫描候选,再按目标评分、pHash 去重、时序分桶,最后只对选中的时间点从原视频抽高质量关键帧。</p>
|
||||
<p><strong>影响:</strong><code>api/main.py</code>、<code>web/lib/api.ts</code>、<code>web/app/page.tsx</code>、<code>web/components/nodes/index.tsx</code>、<code>docs/source-analysis.html</code>。当前“人物/动物表情”是本地近似评分,后续可把候选小图接入视觉模型重排。</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -92,8 +92,8 @@ export default function Home() {
|
||||
const job = useMemo(() => jobs.find((j) => j.id === activeJobId) ?? null, [jobs, activeJobId])
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [analyzing, setAnalyzing] = useState(false)
|
||||
const [frameTarget, setFrameTarget] = useState<FrameExtractTarget>("balanced")
|
||||
const [frameCount, setFrameCount] = useState(5)
|
||||
const [frameTargets, setFrameTargets] = useState<Record<string, FrameExtractTarget>>({})
|
||||
const [frameCounts, setFrameCounts] = useState<Record<string, number>>({})
|
||||
const [selectedFrames, setSelectedFrames] = useState<Set<number>>(new Set())
|
||||
const [expandedFrame, setExpandedFrame] = useState<number | null>(null)
|
||||
const [framePanelScale, setFramePanelScale] = useState(1)
|
||||
@@ -167,27 +167,43 @@ export default function Home() {
|
||||
}
|
||||
}, [addJob])
|
||||
|
||||
const handleAnalyze = useCallback(async (options?: { mode?: FrameExtractMode }) => {
|
||||
if (!job) return
|
||||
const mode = options?.mode ?? (job.frames.length > 0 ? "append" : "replace")
|
||||
const handleAnalyzeJob = useCallback(async (jobId: string, options?: { mode?: FrameExtractMode }) => {
|
||||
const targetJob = jobs.find((item) => item.id === jobId)
|
||||
if (!targetJob) return
|
||||
const frameTarget = frameTargets[jobId] ?? "balanced"
|
||||
const frameCount = frameCounts[jobId] ?? 5
|
||||
const mode = options?.mode ?? (targetJob.frames.length > 0 ? "append" : "replace")
|
||||
setActiveJobId(jobId)
|
||||
setAnalyzing(true)
|
||||
if (mode === "replace") setSelectedFrames(new Set())
|
||||
try {
|
||||
await analyzeJob(job.id, frameCount, frameTarget, mode)
|
||||
await analyzeJob(jobId, frameCount, frameTarget, mode)
|
||||
toast.info(`${mode === "append" ? "追加抽帧" : "开始解析"}:${FRAME_TARGET_LABELS[frameTarget]} · ${frameCount} 张`)
|
||||
// 乐观更新本地状态,让轮询 useEffect 重新启动
|
||||
setJob((prev) => prev ? {
|
||||
...prev,
|
||||
setJobs((prev) => prev.map((item) => item.id === jobId ? {
|
||||
...item,
|
||||
status: "splitting",
|
||||
message: `${mode === "append" ? "追加抽帧中" : "拆轨中"} · ${FRAME_TARGET_LABELS[frameTarget]}…`,
|
||||
progress: 30,
|
||||
} : prev)
|
||||
} : item))
|
||||
} catch (e) {
|
||||
toast.error("解析触发失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
} finally {
|
||||
setAnalyzing(false)
|
||||
}
|
||||
}, [job?.id, job?.frames.length, frameCount, frameTarget])
|
||||
}, [jobs, frameCounts, frameTargets])
|
||||
|
||||
const handleAnalyze = useCallback(async (options?: { mode?: FrameExtractMode }) => {
|
||||
if (!job) return
|
||||
await handleAnalyzeJob(job.id, options)
|
||||
}, [job?.id, handleAnalyzeJob])
|
||||
|
||||
const handleFrameTargetChange = useCallback((jobId: string, target: FrameExtractTarget) => {
|
||||
setFrameTargets((prev) => ({ ...prev, [jobId]: target }))
|
||||
}, [])
|
||||
|
||||
const handleFrameCountChange = useCallback((jobId: string, count: number) => {
|
||||
setFrameCounts((prev) => ({ ...prev, [jobId]: Math.max(1, Math.min(20, count)) }))
|
||||
}, [])
|
||||
|
||||
const handleAddManualFrameForJob = useCallback(async (jobId: string, t: number) => {
|
||||
try {
|
||||
@@ -511,8 +527,8 @@ export default function Home() {
|
||||
activeJobId,
|
||||
submitting,
|
||||
analyzing,
|
||||
frameTarget,
|
||||
frameCount,
|
||||
frameTargets,
|
||||
frameCounts,
|
||||
selectedFrames,
|
||||
expandedFrame,
|
||||
framePanelScale,
|
||||
@@ -524,8 +540,9 @@ export default function Home() {
|
||||
onSubmitUrl: handleSubmit,
|
||||
onUploadFile: handleUpload,
|
||||
onAnalyze: handleAnalyze,
|
||||
onFrameTargetChange: setFrameTarget,
|
||||
onFrameCountChange: setFrameCount,
|
||||
onAnalyzeJob: handleAnalyzeJob,
|
||||
onFrameTargetChange: handleFrameTargetChange,
|
||||
onFrameCountChange: handleFrameCountChange,
|
||||
onToggleFrame: handleToggleFrame,
|
||||
onExpandFrame: setExpandedFrame,
|
||||
onOpenFramePanel: handleOpenFramePanel,
|
||||
@@ -555,7 +572,7 @@ export default function Home() {
|
||||
onCopyImage: handleCopyImage,
|
||||
pinnedNodes,
|
||||
onToggleNodePin: handleToggleNodePin,
|
||||
}), [job, jobs, activeJobId, submitting, analyzing, frameTarget, frameCount, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, framePanelDock, videoPanelJobId, videoPanelScale, videoPanelDock, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleOpenFramePanel, handleFramePanelScaleChange, handleAddManualFrame, handleAddManualFrameForJob, handleOpenVideoPanel, handleVideoPanelScaleChange, handleSwitchJob, setJob, handleDeleteJob, handleDeleteFrame, handleDeleteFrameForJob, handleDeleteGenerated, handleDeleteVideo, handleDeleteCutout, handleCopyImage, pinnedNodes, handleToggleNodePin])
|
||||
}), [job, jobs, activeJobId, submitting, analyzing, frameTargets, frameCounts, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, framePanelDock, videoPanelJobId, videoPanelScale, videoPanelDock, handleSubmit, handleUpload, handleAnalyze, handleAnalyzeJob, handleFrameTargetChange, handleFrameCountChange, handleToggleFrame, handleOpenFramePanel, handleFramePanelScaleChange, handleAddManualFrame, handleAddManualFrameForJob, handleOpenVideoPanel, handleVideoPanelScaleChange, handleSwitchJob, setJob, handleDeleteJob, handleDeleteFrame, handleDeleteFrameForJob, handleDeleteGenerated, handleDeleteVideo, handleDeleteCutout, handleCopyImage, pinnedNodes, handleToggleNodePin])
|
||||
|
||||
// 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag)
|
||||
const savedSizes = useMemo(() => loadNodeSizes(), [])
|
||||
|
||||
@@ -29,8 +29,8 @@ export interface NodeData {
|
||||
activeJobId: string | null
|
||||
submitting: boolean
|
||||
analyzing: boolean
|
||||
frameTarget: FrameExtractTarget
|
||||
frameCount: number
|
||||
frameTargets: Record<string, FrameExtractTarget>
|
||||
frameCounts: Record<string, number>
|
||||
selectedFrames: Set<number>
|
||||
expandedFrame: number | null
|
||||
framePanelScale?: number
|
||||
@@ -42,8 +42,9 @@ export interface NodeData {
|
||||
onSubmitUrl: (url: string) => void
|
||||
onUploadFile: (file: File) => void
|
||||
onAnalyze: (options?: { mode?: FrameExtractMode }) => void
|
||||
onFrameTargetChange: (target: FrameExtractTarget) => void
|
||||
onFrameCountChange: (count: number) => void
|
||||
onAnalyzeJob: (jobId: string, options?: { mode?: FrameExtractMode }) => void
|
||||
onFrameTargetChange: (jobId: string, target: FrameExtractTarget) => void
|
||||
onFrameCountChange: (jobId: string, count: number) => void
|
||||
onToggleFrame: (idx: number) => void
|
||||
onExpandFrame: (idx: number) => void
|
||||
onOpenFramePanel?: (idx: number) => void // 打开/找回画布内关键帧详情面板
|
||||
@@ -429,7 +430,7 @@ function FrameExtractQuickBar({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="nodrag nopan flex w-full items-center gap-1.5 rounded-lg border border-white/16 bg-zinc-950/88 p-1.5 text-white shadow-2xl shadow-violet-950/30 backdrop-blur"
|
||||
className="nodrag nopan w-full rounded-lg border border-white/16 bg-zinc-950/90 p-1.5 text-white shadow-2xl shadow-violet-950/30 backdrop-blur"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -437,7 +438,7 @@ function FrameExtractQuickBar({
|
||||
value={target}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onTargetChange(e.target.value as FrameExtractTarget)}
|
||||
className="h-8 min-w-0 flex-1 cursor-pointer rounded-md border border-white/12 bg-white/[0.08] px-2 text-[11px] font-semibold text-white outline-none transition focus:ring-2 focus:ring-violet-300/70 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
className="h-7 w-full cursor-pointer rounded-md border border-white/12 bg-white/[0.08] px-1.5 text-[10px] font-semibold text-white outline-none transition focus:ring-2 focus:ring-violet-300/70 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
aria-label="选择自动抽帧目标"
|
||||
title={option.hint}
|
||||
>
|
||||
@@ -445,32 +446,29 @@ function FrameExtractQuickBar({
|
||||
<option key={item.value} value={item.value}>{item.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex shrink-0 rounded-md border border-white/12 bg-white/[0.06] p-0.5" aria-label="选择抽帧张数">
|
||||
{FRAME_COUNT_OPTIONS.map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onCountChange(item)}
|
||||
title={`抽 ${item} 张`}
|
||||
className={`h-7 min-w-7 rounded px-1.5 text-[11px] font-bold transition disabled:cursor-not-allowed disabled:opacity-45 ${
|
||||
count === item ? "bg-white text-violet-700" : "text-white/72 hover:bg-white/[0.10] hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
<div className="mt-1 flex gap-1">
|
||||
<select
|
||||
value={count}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onCountChange(Number(e.target.value))}
|
||||
className="h-7 min-w-0 flex-1 cursor-pointer rounded-md border border-white/12 bg-white/[0.08] px-1.5 text-[10px] font-bold text-white outline-none transition focus:ring-2 focus:ring-violet-300/70 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
aria-label="选择抽帧张数"
|
||||
>
|
||||
{FRAME_COUNT_OPTIONS.map((item) => (
|
||||
<option key={item} value={item}>{item} 张</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onAnalyze}
|
||||
title={hasFrames ? "追加自动抽帧" : "自动抽帧"}
|
||||
className="inline-flex h-7 shrink-0 items-center justify-center gap-1 rounded-md bg-gradient-to-r from-indigo-500 to-violet-500 px-2 text-[10.5px] font-bold text-white shadow-lg shadow-violet-950/35 transition hover:opacity-95 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
{running ? <Loader2 className="h-3 w-3 animate-spin" /> : <Scissors className="h-3 w-3" />}
|
||||
{running ? "抽取" : hasFrames ? "追加" : "抽帧"}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onAnalyze}
|
||||
title={hasFrames ? "追加自动抽帧" : "自动抽帧"}
|
||||
className="inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-md bg-gradient-to-r from-indigo-500 to-violet-500 px-3 text-[11.5px] font-bold text-white shadow-lg shadow-violet-950/35 transition hover:opacity-95 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
{running ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Scissors className="h-3.5 w-3.5" />}
|
||||
{running ? "抽取中" : hasFrames ? "追加" : "抽帧"}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -512,21 +510,7 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
|
||||
{/* 多视频缩略图浮条 — 「+」在最左,job 按时间倒序(最新靠左高亮),统一高度 64,宽度按视频原比例,一行横滚。
|
||||
浮条宽度 = 节点宽度(节点拖宽后浮条同步变宽,可见更多缩略图,少滚动)。 */}
|
||||
{d.jobs.length > 0 && (
|
||||
<FloatingThumbnailStrip
|
||||
label="输入视频缩略图横向滑动条"
|
||||
toolbar={hasVideo && job ? (
|
||||
<FrameExtractQuickBar
|
||||
target={d.frameTarget}
|
||||
count={d.frameCount}
|
||||
disabled={isAnalyzing || d.analyzing || !job.video_url}
|
||||
running={isAnalyzing || d.analyzing}
|
||||
hasFrames={hasFrames}
|
||||
onTargetChange={d.onFrameTargetChange}
|
||||
onCountChange={d.onFrameCountChange}
|
||||
onAnalyze={() => d.onAnalyze({ mode: hasFrames ? "append" : "replace" })}
|
||||
/>
|
||||
) : null}
|
||||
>
|
||||
<FloatingThumbnailStrip label="输入视频缩略图横向滑动条">
|
||||
{/* + 再上传一个(放在最前面) */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -541,68 +525,92 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
|
||||
const isActive = j.id === d.activeJobId
|
||||
const ready = !!j.video_url
|
||||
const aspectStr = ready ? `${j.width}/${j.height}` : "9/16"
|
||||
const thumbWidth = ready && j.height ? Math.max(118, Math.round(THUMBNAIL_HEIGHT * j.width / j.height)) : 118
|
||||
const target = d.frameTargets[j.id] ?? "balanced"
|
||||
const count = d.frameCounts[j.id] ?? 5
|
||||
const jHasFrames = j.frames.length > 0
|
||||
const jRunning = ["splitting", "transcribing"].includes(j.status)
|
||||
return (
|
||||
<div
|
||||
key={j.id}
|
||||
className={`group relative shrink-0 rounded-md overflow-visible border shadow-lg transition hover:-translate-y-0.5 ${
|
||||
isActive ? "border-violet-400 ring-2 ring-violet-400/60" : "border-white/25"
|
||||
}`}
|
||||
style={{ height: THUMBNAIL_HEIGHT, aspectRatio: aspectStr }}
|
||||
onMouseEnter={(e) => setHoverPreviewJob({ id: j.id, ...canvasThumbnailAnchor(rootRef.current, e.currentTarget) })}
|
||||
onMouseLeave={() => setHoverPreviewJob(null)}
|
||||
className="group relative flex shrink-0 flex-col gap-1.5"
|
||||
style={{ width: thumbWidth }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!ready) return
|
||||
setPinnedPreviewJob(null)
|
||||
if (!isActive) d.onSwitchJob(j.id)
|
||||
d.onOpenVideoPanel?.(j.id)
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (ready) {
|
||||
setPinnedPreviewJob(null)
|
||||
if (!isActive) d.onSwitchJob(j.id)
|
||||
d.onOpenVideoPanel?.(j.id)
|
||||
}
|
||||
}}
|
||||
title={ready ? `${j.width}×${j.height} · ${(j.duration ?? 0).toFixed(1)}s · 单击打开抽帧面板` : "下载中…"}
|
||||
className="absolute inset-0 w-full h-full overflow-hidden rounded-md"
|
||||
{ready ? (
|
||||
<FrameExtractQuickBar
|
||||
target={target}
|
||||
count={count}
|
||||
disabled={jRunning || d.analyzing}
|
||||
running={jRunning}
|
||||
hasFrames={jHasFrames}
|
||||
onTargetChange={(next) => d.onFrameTargetChange(j.id, next)}
|
||||
onCountChange={(next) => d.onFrameCountChange(j.id, next)}
|
||||
onAnalyze={() => d.onAnalyzeJob(j.id, { mode: jHasFrames ? "append" : "replace" })}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-[72px] rounded-lg border border-white/10 bg-white/[0.03]" />
|
||||
)}
|
||||
<div
|
||||
className={`relative rounded-md overflow-visible border shadow-lg transition hover:-translate-y-0.5 ${
|
||||
isActive ? "border-violet-400 ring-2 ring-violet-400/60" : "border-white/25"
|
||||
}`}
|
||||
style={{ height: THUMBNAIL_HEIGHT, aspectRatio: aspectStr }}
|
||||
onMouseEnter={(e) => setHoverPreviewJob({ id: j.id, ...canvasThumbnailAnchor(rootRef.current, e.currentTarget) })}
|
||||
onMouseLeave={() => setHoverPreviewJob(null)}
|
||||
>
|
||||
{ready ? (
|
||||
<video
|
||||
src={videoUrl(j.id)}
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="metadata"
|
||||
poster=""
|
||||
className="block w-full h-full object-cover bg-black"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-black/60 flex items-center justify-center">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-white/60" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-0.5 right-0.5 bg-black/70 text-white text-[9px] font-mono px-1 py-0.5 rounded">
|
||||
{ready ? `${(j.duration ?? 0).toFixed(1)}s` : "…"}
|
||||
</div>
|
||||
</button>
|
||||
{d.onDeleteJob && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDeleteJobTarget(j)
|
||||
if (!ready) return
|
||||
setPinnedPreviewJob(null)
|
||||
if (!isActive) d.onSwitchJob(j.id)
|
||||
d.onOpenVideoPanel?.(j.id)
|
||||
}}
|
||||
title="删除这个输入视频"
|
||||
className="absolute right-1.5 top-1.5 z-[70] inline-flex h-7 w-7 items-center justify-center rounded-full bg-rose-500/95 text-white shadow-lg backdrop-blur transition hover:scale-110 hover:bg-rose-400"
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (ready) {
|
||||
setPinnedPreviewJob(null)
|
||||
if (!isActive) d.onSwitchJob(j.id)
|
||||
d.onOpenVideoPanel?.(j.id)
|
||||
}
|
||||
}}
|
||||
title={ready ? `${j.width}×${j.height} · ${(j.duration ?? 0).toFixed(1)}s · 单击打开抽帧面板` : "下载中…"}
|
||||
className="absolute inset-0 w-full h-full overflow-hidden rounded-md"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{ready ? (
|
||||
<video
|
||||
src={videoUrl(j.id)}
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="metadata"
|
||||
poster=""
|
||||
className="block w-full h-full object-cover bg-black"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-black/60 flex items-center justify-center">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-white/60" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-0.5 right-0.5 bg-black/70 text-white text-[9px] font-mono px-1 py-0.5 rounded">
|
||||
{ready ? `${(j.duration ?? 0).toFixed(1)}s` : "…"}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
{d.onDeleteJob && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDeleteJobTarget(j)
|
||||
}}
|
||||
title="删除这个输入视频"
|
||||
className="absolute right-1.5 top-1.5 z-[70] inline-flex h-7 w-7 items-center justify-center rounded-full bg-rose-500/95 text-white shadow-lg backdrop-blur transition hover:scale-110 hover:bg-rose-400"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user