auto-save 2026-05-17 22:14 (~3)
This commit is contained in:
@@ -1,18 +1,5 @@
|
|||||||
{
|
{
|
||||||
"entries": [
|
"entries": [
|
||||||
{
|
|
||||||
"files_changed": 1,
|
|
||||||
"hash": "6346329",
|
|
||||||
"message": "auto-save 2026-05-15 13:34 (~1)",
|
|
||||||
"ts": "2026-05-15T13:34:39+08:00",
|
|
||||||
"type": "commit"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"files_changed": 1,
|
|
||||||
"message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-15 13:34 (~1)",
|
|
||||||
"ts": "2026-05-15T05:34:45Z",
|
|
||||||
"type": "session-heartbeat"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"files_changed": 1,
|
"files_changed": 1,
|
||||||
"hash": "765745d",
|
"hash": "765745d",
|
||||||
@@ -3260,6 +3247,19 @@
|
|||||||
"type": "session-heartbeat",
|
"type": "session-heartbeat",
|
||||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:auto-save 2026-05-17 21:58 (~2)",
|
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:auto-save 2026-05-17 21:58 (~2)",
|
||||||
"files_changed": 1
|
"files_changed": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-05-17T22:03:37+08:00",
|
||||||
|
"type": "commit",
|
||||||
|
"message": "auto-save 2026-05-17 22:03 (~3)",
|
||||||
|
"hash": "87015e9",
|
||||||
|
"files_changed": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-05-17T14:08:30Z",
|
||||||
|
"type": "session-heartbeat",
|
||||||
|
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:auto-save 2026-05-17 22:03 (~3)",
|
||||||
|
"files_changed": 1
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -649,8 +649,8 @@ api/main.py
|
|||||||
</div>
|
</div>
|
||||||
<div class="flow-row">
|
<div class="flow-row">
|
||||||
<div><strong>你看到的区域</strong><span>音频解析结果表</span></div>
|
<div><strong>你看到的区域</strong><span>音频解析结果表</span></div>
|
||||||
<div><strong>主要源码</strong><span><code>AudioIntakePanel</code> / <code>AudioIntakeStatus</code> / <code>SourceReferenceBuildPanel</code> in <code>web/components/ad-recreation-board.tsx</code>;复用 <code>triggerTranscribe</code>、<code>AudioScript</code>、<code>analyzeJob</code> 和 <code>generateSubjectAssets</code>。</span></div>
|
<div><strong>主要源码</strong><span><code>AudioIntakePanel</code> / <code>AudioIntakeStatus</code> / <code>SourceReferenceBuildPanel</code> in <code>web/components/ad-recreation-board.tsx</code>;复用 <code>triggerTranscribe</code>、<code>AudioScript</code>、<code>analyzeJob</code>、<code>addManualFrame</code>、<code>deleteFrame</code> 和 <code>generateSubjectAssets</code>。</span></div>
|
||||||
<div><strong>适合怎么描述</strong><span>“原视频播放、12 张关键帧选择、相似主角 6 白底视图、连续响度波形、逐句时间轴滚动、高亮和跳转联动还需要怎么调整”。</span></div>
|
<div><strong>适合怎么描述</strong><span>“原视频播放尺寸、当前播放点手动抽帧、关键帧删除、相似主角 6 白底视图、连续响度波形、逐句时间轴滚动、高亮和跳转联动还需要怎么调整”。</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flow-row">
|
<div class="flow-row">
|
||||||
<div><strong>你看到的区域</strong><span>信息流复刻分镜工作台</span></div>
|
<div><strong>你看到的区域</strong><span>信息流复刻分镜工作台</span></div>
|
||||||
@@ -879,7 +879,8 @@ ProductRefStateItem {
|
|||||||
<tr><td>分镜脚本改写</td><td><code>POST /jobs/{id}/script/rewrite</code></td><td><code>rewriteStoryboardScript</code></td><td>根据原参考文案、当前新口播、分镜角色、时间段和作者想法改写中文口播。<code>mode=segment</code> 只改一段;<code>mode=all</code> 一次改完整片,要求整片前后连贯。接口只返回 <code>items[index,text]</code>,前端暂存在当前页面状态里,生成本条视频时写入 <code>StoryboardScene.action</code>。</td></tr>
|
<tr><td>分镜脚本改写</td><td><code>POST /jobs/{id}/script/rewrite</code></td><td><code>rewriteStoryboardScript</code></td><td>根据原参考文案、当前新口播、分镜角色、时间段和作者想法改写中文口播。<code>mode=segment</code> 只改一段;<code>mode=all</code> 一次改完整片,要求整片前后连贯。接口只返回 <code>items[index,text]</code>,前端暂存在当前页面状态里,生成本条视频时写入 <code>StoryboardScene.action</code>。</td></tr>
|
||||||
<tr><td>原始音频文件</td><td><code>GET /jobs/{id}/audio.wav</code></td><td><code>sourceAudioUrl</code></td><td>返回拆轨得到的 wav;当前主界面不再渲染底部吸附音频条,右侧复刻工作表会读取该文件生成参考图式横向响度波形,并和原视频、逐句时间轴联动。</td></tr>
|
<tr><td>原始音频文件</td><td><code>GET /jobs/{id}/audio.wav</code></td><td><code>sourceAudioUrl</code></td><td>返回拆轨得到的 wav;当前主界面不再渲染底部吸附音频条,右侧复刻工作表会读取该文件生成参考图式横向响度波形,并和原视频、逐句时间轴联动。</td></tr>
|
||||||
<tr><td>改写配音文件</td><td><code>GET /jobs/{id}/audio-script.mp3</code></td><td><code>apiAssetUrl(job.audio_script.voice_url)</code></td><td>后续新配音阶段保留的 MiniMax T2A 产物。当前第一步不默认生成该文件。</td></tr>
|
<tr><td>改写配音文件</td><td><code>GET /jobs/{id}/audio-script.mp3</code></td><td><code>apiAssetUrl(job.audio_script.voice_url)</code></td><td>后续新配音阶段保留的 MiniMax T2A 产物。当前第一步不默认生成该文件。</td></tr>
|
||||||
<tr><td>手动加帧</td><td><code>POST /jobs/{id}/frames?t=</code></td><td><code>addManualFrame</code></td><td>按视频时间戳抽一帧,index 递增但 frames 按 timestamp 排序。</td></tr>
|
<tr><td>手动加帧</td><td><code>POST /jobs/{id}/frames?t=</code></td><td><code>addManualFrame</code></td><td>按视频时间戳抽一帧,index 递增但 frames 按 timestamp 排序。当前主界面会把原版视频播放秒数传给 <code>SourceReferenceBuildPanel</code> 的“当前点抽帧”。</td></tr>
|
||||||
|
<tr><td>删除关键帧</td><td><code>DELETE /jobs/{id}/frames/{idx}</code></td><td><code>deleteFrame</code></td><td>删除单张关键帧并清掉对应选择态;当前主界面每张缩略图右下角提供删除入口,方便手动抽错后直接修正。</td></tr>
|
||||||
<tr><td>Vision 识别</td><td><code>POST /frames/{idx}/describe</code></td><td><code>describeFrame</code></td><td>写入 frame.description,后续可从 objects 加候选元素。</td></tr>
|
<tr><td>Vision 识别</td><td><code>POST /frames/{idx}/describe</code></td><td><code>describeFrame</code></td><td>写入 frame.description,后续可从 objects 加候选元素。</td></tr>
|
||||||
<tr><td>清洗水印</td><td><code>POST /frames/{idx}/cleanup</code></td><td><code>cleanupFrame</code></td><td>支持全图和区域清洗,生成 cleaned 待应用版本;前端批量清洗会顺序调用该接口,不自动覆盖原图。单帧清洗状态按 frame.index 隔离,清洗某一张不会禁用其他关键帧的清洗按钮。</td></tr>
|
<tr><td>清洗水印</td><td><code>POST /frames/{idx}/cleanup</code></td><td><code>cleanupFrame</code></td><td>支持全图和区域清洗,生成 cleaned 待应用版本;前端批量清洗会顺序调用该接口,不自动覆盖原图。单帧清洗状态按 frame.index 隔离,清洗某一张不会禁用其他关键帧的清洗按钮。</td></tr>
|
||||||
<tr><td>应用清洗版</td><td><code>POST /frames/{idx}/cleanup/apply</code></td><td><code>applyCleanedFrame</code></td><td>把 cleaned 待应用版本覆盖到原关键帧,并保留首次原图备份;前端“一键替换待应用”会顺序调用该接口应用所有已有清洗版。</td></tr>
|
<tr><td>应用清洗版</td><td><code>POST /frames/{idx}/cleanup/apply</code></td><td><code>applyCleanedFrame</code></td><td>把 cleaned 待应用版本覆盖到原关键帧,并保留首次原图备份;前端“一键替换待应用”会顺序调用该接口应用所有已有清洗版。</td></tr>
|
||||||
@@ -1002,6 +1003,18 @@ ProductRefStateItem {
|
|||||||
<h2>变更记录</h2>
|
<h2>变更记录</h2>
|
||||||
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
|
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
|
||||||
<div class="changelog">
|
<div class="changelog">
|
||||||
|
<article class="change">
|
||||||
|
<header>
|
||||||
|
<h3>2026-05-17 · 原版视频放大并支持当前点抽帧删除</h3>
|
||||||
|
<span class="tag rose">UI</span>
|
||||||
|
<span class="tag cyan">Workflow</span>
|
||||||
|
</header>
|
||||||
|
<div class="body">
|
||||||
|
<p><strong>问题:</strong>原版视频区域偏小,只能看和播放;如果需要补某个具体画面,用户必须重新跑 12 帧抽取,并且抽错后缺少直接删除入口。</p>
|
||||||
|
<p><strong>改动:</strong><code>AudioIntakePanel</code> 放大原视频列和视频高度,并把逐句时间轴压成较窄侧栏;<code>SourceReferenceBuildPanel</code> 接收当前播放秒数,新增“当前点抽帧”按钮,复用 <code>addManualFrame</code>;关键帧缩略图右下角新增删除按钮,复用 <code>deleteFrame</code>,并展示全部已抽帧而不只截取前 12 张。</p>
|
||||||
|
<p><strong>影响:</strong><code>web/components/ad-recreation-board.tsx</code>、<code>docs/source-analysis.html</code>。后续描述这里时,应把原版视频旁的关键帧区理解为“人物参考帧池”,支持自动抽人物、按播放点补帧和单帧删除。</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
<article class="change">
|
<article class="change">
|
||||||
<header>
|
<header>
|
||||||
<h3>2026-05-17 · 主动抽帧改成人物主体并压缩时间轴侧栏</h3>
|
<h3>2026-05-17 · 主动抽帧改成人物主体并压缩时间轴侧栏</h3>
|
||||||
|
|||||||
@@ -851,6 +851,8 @@ export function AdRecreationBoard({
|
|||||||
selectedFrames={data.selectedFrames}
|
selectedFrames={data.selectedFrames}
|
||||||
onToggleFrame={data.onToggleFrame}
|
onToggleFrame={data.onToggleFrame}
|
||||||
onJobUpdate={data.onJobUpdate}
|
onJobUpdate={data.onJobUpdate}
|
||||||
|
onAddFrame={data.onAddManualFrameForJob}
|
||||||
|
onDeleteFrame={data.onDeleteFrameForJob}
|
||||||
/>
|
/>
|
||||||
<AudioStoryboardPlanPanel
|
<AudioStoryboardPlanPanel
|
||||||
job={job}
|
job={job}
|
||||||
@@ -990,11 +992,15 @@ function AudioIntakePanel({
|
|||||||
selectedFrames,
|
selectedFrames,
|
||||||
onToggleFrame,
|
onToggleFrame,
|
||||||
onJobUpdate,
|
onJobUpdate,
|
||||||
|
onAddFrame,
|
||||||
|
onDeleteFrame,
|
||||||
}: {
|
}: {
|
||||||
job: Job | null
|
job: Job | null
|
||||||
selectedFrames: Set<number>
|
selectedFrames: Set<number>
|
||||||
onToggleFrame: (idx: number) => void
|
onToggleFrame: (idx: number) => void
|
||||||
onJobUpdate: (job: Job) => void
|
onJobUpdate: (job: Job) => void
|
||||||
|
onAddFrame?: (jobId: string, t: number) => Promise<void> | void
|
||||||
|
onDeleteFrame?: (jobId: string, idx: number) => Promise<void> | void
|
||||||
}) {
|
}) {
|
||||||
const [currentTime, setCurrentTime] = useState(0)
|
const [currentTime, setCurrentTime] = useState(0)
|
||||||
const [mediaDuration, setMediaDuration] = useState(0)
|
const [mediaDuration, setMediaDuration] = useState(0)
|
||||||
@@ -1126,7 +1132,7 @@ function AudioIntakePanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2 xl:grid-cols-[300px_minmax(340px,1fr)_430px] 2xl:grid-cols-[340px_minmax(520px,1fr)_480px]">
|
<div className="grid gap-2 xl:grid-cols-[340px_minmax(340px,1fr)_320px] 2xl:grid-cols-[410px_minmax(500px,1fr)_320px]">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="mb-2 flex items-center justify-between gap-3">
|
<div className="mb-2 flex items-center justify-between gap-3">
|
||||||
<SectionTitle icon={<Play className="h-4 w-4" />} title="原版视频" />
|
<SectionTitle icon={<Play className="h-4 w-4" />} title="原版视频" />
|
||||||
@@ -1138,7 +1144,7 @@ function AudioIntakePanel({
|
|||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
controls
|
controls
|
||||||
playsInline
|
playsInline
|
||||||
className="h-[238px] w-full bg-black object-contain"
|
className="h-[270px] w-full bg-black object-contain 2xl:h-[300px]"
|
||||||
src={videoSrcUrl}
|
src={videoSrcUrl}
|
||||||
onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime)}
|
onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime)}
|
||||||
onSeeked={(event) => setCurrentTime(event.currentTarget.currentTime)}
|
onSeeked={(event) => setCurrentTime(event.currentTarget.currentTime)}
|
||||||
@@ -1152,7 +1158,7 @@ function AudioIntakePanel({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-[238px] items-center justify-center text-[12px] text-white/38">等待原视频</div>
|
<div className="flex h-[270px] items-center justify-center text-[12px] text-white/38 2xl:h-[300px]">等待原视频</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1162,6 +1168,10 @@ function AudioIntakePanel({
|
|||||||
selectedFrames={selectedFrames}
|
selectedFrames={selectedFrames}
|
||||||
onToggleFrame={onToggleFrame}
|
onToggleFrame={onToggleFrame}
|
||||||
onJobUpdate={onJobUpdate}
|
onJobUpdate={onJobUpdate}
|
||||||
|
currentTime={currentTime}
|
||||||
|
duration={timelineDuration}
|
||||||
|
onAddFrame={onAddFrame}
|
||||||
|
onDeleteFrame={onDeleteFrame}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -1176,7 +1186,7 @@ function AudioIntakePanel({
|
|||||||
<div>原文</div>
|
<div>原文</div>
|
||||||
<div>中文</div>
|
<div>中文</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[238px] overflow-y-auto">
|
<div className="max-h-[270px] overflow-y-auto 2xl:max-h-[300px]">
|
||||||
{job.transcript.map((segment) => {
|
{job.transcript.map((segment) => {
|
||||||
const active = activeSegment?.index === segment.index
|
const active = activeSegment?.index === segment.index
|
||||||
return (
|
return (
|
||||||
@@ -1213,14 +1223,24 @@ function SourceReferenceBuildPanel({
|
|||||||
selectedFrames,
|
selectedFrames,
|
||||||
onToggleFrame,
|
onToggleFrame,
|
||||||
onJobUpdate,
|
onJobUpdate,
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
onAddFrame,
|
||||||
|
onDeleteFrame,
|
||||||
}: {
|
}: {
|
||||||
job: Job
|
job: Job
|
||||||
selectedFrames: Set<number>
|
selectedFrames: Set<number>
|
||||||
onToggleFrame: (idx: number) => void
|
onToggleFrame: (idx: number) => void
|
||||||
onJobUpdate: (job: Job) => void
|
onJobUpdate: (job: Job) => void
|
||||||
|
currentTime: number
|
||||||
|
duration: number
|
||||||
|
onAddFrame?: (jobId: string, t: number) => Promise<void> | void
|
||||||
|
onDeleteFrame?: (jobId: string, idx: number) => Promise<void> | void
|
||||||
}) {
|
}) {
|
||||||
const [extracting, setExtracting] = useState(false)
|
const [extracting, setExtracting] = useState(false)
|
||||||
|
const [manualBusy, setManualBusy] = useState(false)
|
||||||
const [subjectBusy, setSubjectBusy] = useState(false)
|
const [subjectBusy, setSubjectBusy] = useState(false)
|
||||||
|
const [deletingFrame, setDeletingFrame] = useState<number | null>(null)
|
||||||
const frames = useMemo(() => [...job.frames].sort((a, b) => a.timestamp - b.timestamp), [job.frames])
|
const frames = useMemo(() => [...job.frames].sort((a, b) => a.timestamp - b.timestamp), [job.frames])
|
||||||
const selectedReferenceFrames = useMemo(
|
const selectedReferenceFrames = useMemo(
|
||||||
() => frames.filter((frame) => selectedFrames.has(frame.index)),
|
() => frames.filter((frame) => selectedFrames.has(frame.index)),
|
||||||
@@ -1239,6 +1259,7 @@ function SourceReferenceBuildPanel({
|
|||||||
return null
|
return null
|
||||||
}, [frames, selectedReferenceFrames])
|
}, [frames, selectedReferenceFrames])
|
||||||
const actorAssets = actorSource?.element.subject_assets ?? []
|
const actorAssets = actorSource?.element.subject_assets ?? []
|
||||||
|
const manualFrameTime = clampNumber(currentTime, 0, Math.max(duration, 1))
|
||||||
|
|
||||||
const extractKeyframes = async () => {
|
const extractKeyframes = async () => {
|
||||||
setExtracting(true)
|
setExtracting(true)
|
||||||
@@ -1301,6 +1322,26 @@ function SourceReferenceBuildPanel({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const addFrameAtCurrentTime = async () => {
|
||||||
|
if (!onAddFrame) return
|
||||||
|
setManualBusy(true)
|
||||||
|
try {
|
||||||
|
await onAddFrame(job.id, manualFrameTime)
|
||||||
|
} finally {
|
||||||
|
setManualBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteReferenceFrame = async (idx: number) => {
|
||||||
|
if (!onDeleteFrame) return
|
||||||
|
setDeletingFrame(idx)
|
||||||
|
try {
|
||||||
|
await onDeleteFrame(job.id, idx)
|
||||||
|
} finally {
|
||||||
|
setDeletingFrame(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="mb-2 flex items-center justify-between gap-3">
|
<div className="mb-2 flex items-center justify-between gap-3">
|
||||||
@@ -1309,8 +1350,8 @@ function SourceReferenceBuildPanel({
|
|||||||
{frames.length ? `${frames.length} 张` : "待抽帧"} · 已选 {selectedReferenceFrames.length}
|
{frames.length ? `${frames.length} 张` : "待抽帧"} · 已选 {selectedReferenceFrames.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-[238px] overflow-y-auto rounded-md border border-white/10 bg-black/32 p-2">
|
<div className="h-[270px] overflow-y-auto rounded-md border border-white/10 bg-black/32 p-2 2xl:h-[300px]">
|
||||||
<div className="grid grid-cols-2 gap-1.5">
|
<div className="grid grid-cols-3 gap-1.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void extractKeyframes()}
|
onClick={() => void extractKeyframes()}
|
||||||
@@ -1320,6 +1361,16 @@ function SourceReferenceBuildPanel({
|
|||||||
{extracting || job.status === "splitting" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Scissors className="h-3.5 w-3.5" />}
|
{extracting || job.status === "splitting" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Scissors className="h-3.5 w-3.5" />}
|
||||||
抽人物 12 帧
|
抽人物 12 帧
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void addFrameAtCurrentTime()}
|
||||||
|
disabled={!job.video_url || !onAddFrame || manualBusy || job.status === "splitting"}
|
||||||
|
title={`按当前播放位置手动抽帧:${manualFrameTime.toFixed(1)}s`}
|
||||||
|
className="inline-flex h-7 items-center justify-center gap-1 rounded-md border border-white/10 bg-white/[0.055] px-2 text-[10.5px] font-semibold text-white/66 transition hover:border-emerald-300/35 hover:text-emerald-100 disabled:cursor-not-allowed disabled:opacity-35"
|
||||||
|
>
|
||||||
|
{manualBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
|
||||||
|
当前点抽帧
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void generateSimilarActor()}
|
onClick={() => void generateSimilarActor()}
|
||||||
@@ -1332,29 +1383,48 @@ function SourceReferenceBuildPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 grid grid-cols-4 gap-1.5">
|
<div className="mt-2 grid grid-cols-4 gap-1.5">
|
||||||
{frames.slice(0, 12).map((frame, index) => {
|
{frames.map((frame, index) => {
|
||||||
const selected = selectedFrames.has(frame.index)
|
const selected = selectedFrames.has(frame.index)
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
key={frame.index}
|
key={frame.index}
|
||||||
type="button"
|
|
||||||
onClick={() => onToggleFrame(frame.index)}
|
|
||||||
className={`group relative h-12 overflow-hidden rounded border bg-black transition ${
|
className={`group relative h-12 overflow-hidden rounded border bg-black transition ${
|
||||||
selected ? "border-emerald-300/70" : "border-white/10 hover:border-cyan-300/40"
|
selected ? "border-emerald-300/70" : "border-white/10 hover:border-cyan-300/40"
|
||||||
}`}
|
}`}
|
||||||
title={`关键帧 ${index + 1} · ${frame.timestamp.toFixed(1)}s`}
|
title={`关键帧 ${index + 1} · ${frame.timestamp.toFixed(1)}s`}
|
||||||
>
|
>
|
||||||
<img src={effectiveFrameUrl(job.id, frame)} alt="" className="h-full w-full object-cover" />
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggleFrame(frame.index)}
|
||||||
|
className="absolute inset-0 cursor-pointer overflow-hidden focus:outline-none focus:ring-1 focus:ring-cyan-200/70"
|
||||||
|
>
|
||||||
|
<img src={effectiveFrameUrl(job.id, frame)} alt="" className="h-full w-full object-cover" />
|
||||||
|
</button>
|
||||||
<span className="absolute left-1 top-1 rounded bg-black/72 px-1 font-mono text-[9px] text-white/70">{String(index + 1).padStart(2, "0")}</span>
|
<span className="absolute left-1 top-1 rounded bg-black/72 px-1 font-mono text-[9px] text-white/70">{String(index + 1).padStart(2, "0")}</span>
|
||||||
<span className="absolute right-1 top-1 rounded-full bg-black/72 p-0.5">
|
<span className="absolute right-1 top-1 rounded-full bg-black/72 p-0.5">
|
||||||
{selected ? <Check className="h-3 w-3 text-emerald-200" /> : <Circle className="h-3 w-3 text-white/50" />}
|
{selected ? <Check className="h-3 w-3 text-emerald-200" /> : <Circle className="h-3 w-3 text-white/50" />}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
{onDeleteFrame && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
void deleteReferenceFrame(frame.index)
|
||||||
|
}}
|
||||||
|
disabled={deletingFrame === frame.index}
|
||||||
|
className="absolute bottom-1 right-1 inline-flex h-6 w-6 items-center justify-center rounded-full border border-rose-200/25 bg-black/78 text-rose-100 opacity-80 transition hover:border-rose-200/55 hover:bg-rose-500/25 hover:opacity-100 focus:opacity-100 focus:outline-none focus:ring-1 focus:ring-rose-100/70 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
aria-label={`删除关键帧 ${index + 1}`}
|
||||||
|
title="删除这张关键帧"
|
||||||
|
>
|
||||||
|
{deletingFrame === frame.index ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{!frames.length && (
|
{!frames.length && (
|
||||||
<div className="col-span-4 flex h-[106px] items-center justify-center rounded border border-dashed border-white/12 text-[11px] text-white/34">
|
<div className="col-span-4 flex h-[106px] items-center justify-center rounded border border-dashed border-white/12 text-[11px] text-white/34">
|
||||||
点击“抽人物 12 帧”后,这里会展示原视频里主体人物清晰的关键画面。
|
点击“抽人物 12 帧”,或播放到指定时间后用“当前点抽帧”补充人物参考。
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user