diff --git a/.memory/worklog.json b/.memory/worklog.json
index 0a80c3f..056c1d9 100644
--- a/.memory/worklog.json
+++ b/.memory/worklog.json
@@ -1,18 +1,5 @@
{
"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,
"hash": "765745d",
@@ -3260,6 +3247,19 @@
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:auto-save 2026-05-17 21:58 (~2)",
"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
}
]
}
diff --git a/docs/source-analysis.html b/docs/source-analysis.html
index 0acad47..00e00c6 100644
--- a/docs/source-analysis.html
+++ b/docs/source-analysis.html
@@ -649,8 +649,8 @@ api/main.py
你看到的区域音频解析结果表
-
主要源码AudioIntakePanel / AudioIntakeStatus / SourceReferenceBuildPanel in web/components/ad-recreation-board.tsx;复用 triggerTranscribe、AudioScript、analyzeJob 和 generateSubjectAssets。
-
适合怎么描述“原视频播放、12 张关键帧选择、相似主角 6 白底视图、连续响度波形、逐句时间轴滚动、高亮和跳转联动还需要怎么调整”。
+
主要源码AudioIntakePanel / AudioIntakeStatus / SourceReferenceBuildPanel in web/components/ad-recreation-board.tsx;复用 triggerTranscribe、AudioScript、analyzeJob、addManualFrame、deleteFrame 和 generateSubjectAssets。
+
适合怎么描述“原视频播放尺寸、当前播放点手动抽帧、关键帧删除、相似主角 6 白底视图、连续响度波形、逐句时间轴滚动、高亮和跳转联动还需要怎么调整”。
你看到的区域信息流复刻分镜工作台
@@ -879,7 +879,8 @@ ProductRefStateItem {
| 分镜脚本改写 | POST /jobs/{id}/script/rewrite | rewriteStoryboardScript | 根据原参考文案、当前新口播、分镜角色、时间段和作者想法改写中文口播。mode=segment 只改一段;mode=all 一次改完整片,要求整片前后连贯。接口只返回 items[index,text],前端暂存在当前页面状态里,生成本条视频时写入 StoryboardScene.action。 |
| 原始音频文件 | GET /jobs/{id}/audio.wav | sourceAudioUrl | 返回拆轨得到的 wav;当前主界面不再渲染底部吸附音频条,右侧复刻工作表会读取该文件生成参考图式横向响度波形,并和原视频、逐句时间轴联动。 |
| 改写配音文件 | GET /jobs/{id}/audio-script.mp3 | apiAssetUrl(job.audio_script.voice_url) | 后续新配音阶段保留的 MiniMax T2A 产物。当前第一步不默认生成该文件。 |
-
| 手动加帧 | POST /jobs/{id}/frames?t= | addManualFrame | 按视频时间戳抽一帧,index 递增但 frames 按 timestamp 排序。 |
+
| 手动加帧 | POST /jobs/{id}/frames?t= | addManualFrame | 按视频时间戳抽一帧,index 递增但 frames 按 timestamp 排序。当前主界面会把原版视频播放秒数传给 SourceReferenceBuildPanel 的“当前点抽帧”。 |
+
| 删除关键帧 | DELETE /jobs/{id}/frames/{idx} | deleteFrame | 删除单张关键帧并清掉对应选择态;当前主界面每张缩略图右下角提供删除入口,方便手动抽错后直接修正。 |
| Vision 识别 | POST /frames/{idx}/describe | describeFrame | 写入 frame.description,后续可从 objects 加候选元素。 |
| 清洗水印 | POST /frames/{idx}/cleanup | cleanupFrame | 支持全图和区域清洗,生成 cleaned 待应用版本;前端批量清洗会顺序调用该接口,不自动覆盖原图。单帧清洗状态按 frame.index 隔离,清洗某一张不会禁用其他关键帧的清洗按钮。 |
| 应用清洗版 | POST /frames/{idx}/cleanup/apply | applyCleanedFrame | 把 cleaned 待应用版本覆盖到原关键帧,并保留首次原图备份;前端“一键替换待应用”会顺序调用该接口应用所有已有清洗版。 |
@@ -1002,6 +1003,18 @@ ProductRefStateItem {
变更记录
这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。
+
+
+ 2026-05-17 · 原版视频放大并支持当前点抽帧删除
+ UI
+ Workflow
+
+
+
问题:原版视频区域偏小,只能看和播放;如果需要补某个具体画面,用户必须重新跑 12 帧抽取,并且抽错后缺少直接删除入口。
+
改动:AudioIntakePanel 放大原视频列和视频高度,并把逐句时间轴压成较窄侧栏;SourceReferenceBuildPanel 接收当前播放秒数,新增“当前点抽帧”按钮,复用 addManualFrame;关键帧缩略图右下角新增删除按钮,复用 deleteFrame,并展示全部已抽帧而不只截取前 12 张。
+
影响:web/components/ad-recreation-board.tsx、docs/source-analysis.html。后续描述这里时,应把原版视频旁的关键帧区理解为“人物参考帧池”,支持自动抽人物、按播放点补帧和单帧删除。
+
+
2026-05-17 · 主动抽帧改成人物主体并压缩时间轴侧栏
diff --git a/web/components/ad-recreation-board.tsx b/web/components/ad-recreation-board.tsx
index abb953d..2ad7e50 100644
--- a/web/components/ad-recreation-board.tsx
+++ b/web/components/ad-recreation-board.tsx
@@ -851,6 +851,8 @@ export function AdRecreationBoard({
selectedFrames={data.selectedFrames}
onToggleFrame={data.onToggleFrame}
onJobUpdate={data.onJobUpdate}
+ onAddFrame={data.onAddManualFrameForJob}
+ onDeleteFrame={data.onDeleteFrameForJob}
/>
onToggleFrame: (idx: number) => void
onJobUpdate: (job: Job) => void
+ onAddFrame?: (jobId: string, t: number) => Promise | void
+ onDeleteFrame?: (jobId: string, idx: number) => Promise | void
}) {
const [currentTime, setCurrentTime] = useState(0)
const [mediaDuration, setMediaDuration] = useState(0)
@@ -1126,7 +1132,7 @@ function AudioIntakePanel({
/>
-
+
} title="原版视频" />
@@ -1138,7 +1144,7 @@ function AudioIntakePanel({
ref={videoRef}
controls
playsInline
- className="h-[238px] w-full bg-black object-contain"
+ className="h-[270px] w-full bg-black object-contain 2xl:h-[300px]"
src={videoSrcUrl}
onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime)}
onSeeked={(event) => setCurrentTime(event.currentTarget.currentTime)}
@@ -1152,7 +1158,7 @@ function AudioIntakePanel({
}}
/>
) : (
-
等待原视频
+
等待原视频
)}
@@ -1162,6 +1168,10 @@ function AudioIntakePanel({
selectedFrames={selectedFrames}
onToggleFrame={onToggleFrame}
onJobUpdate={onJobUpdate}
+ currentTime={currentTime}
+ duration={timelineDuration}
+ onAddFrame={onAddFrame}
+ onDeleteFrame={onDeleteFrame}
/>
@@ -1176,7 +1186,7 @@ function AudioIntakePanel({
原文
中文
-
+
{job.transcript.map((segment) => {
const active = activeSegment?.index === segment.index
return (
@@ -1213,14 +1223,24 @@ function SourceReferenceBuildPanel({
selectedFrames,
onToggleFrame,
onJobUpdate,
+ currentTime,
+ duration,
+ onAddFrame,
+ onDeleteFrame,
}: {
job: Job
selectedFrames: Set
onToggleFrame: (idx: number) => void
onJobUpdate: (job: Job) => void
+ currentTime: number
+ duration: number
+ onAddFrame?: (jobId: string, t: number) => Promise | void
+ onDeleteFrame?: (jobId: string, idx: number) => Promise | void
}) {
const [extracting, setExtracting] = useState(false)
+ const [manualBusy, setManualBusy] = useState(false)
const [subjectBusy, setSubjectBusy] = useState(false)
+ const [deletingFrame, setDeletingFrame] = useState(null)
const frames = useMemo(() => [...job.frames].sort((a, b) => a.timestamp - b.timestamp), [job.frames])
const selectedReferenceFrames = useMemo(
() => frames.filter((frame) => selectedFrames.has(frame.index)),
@@ -1239,6 +1259,7 @@ function SourceReferenceBuildPanel({
return null
}, [frames, selectedReferenceFrames])
const actorAssets = actorSource?.element.subject_assets ?? []
+ const manualFrameTime = clampNumber(currentTime, 0, Math.max(duration, 1))
const extractKeyframes = async () => {
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 (
@@ -1309,8 +1350,8 @@ function SourceReferenceBuildPanel({
{frames.length ? `${frames.length} 张` : "待抽帧"} · 已选 {selectedReferenceFrames.length}
-
-
+
+
+
- {frames.slice(0, 12).map((frame, index) => {
+ {frames.map((frame, index) => {
const selected = selectedFrames.has(frame.index)
return (
-
+ {onDeleteFrame && (
+
{
+ 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 ? : }
+
+ )}
+
)
})}
{!frames.length && (
- 点击“抽人物 12 帧”后,这里会展示原视频里主体人物清晰的关键画面。
+ 点击“抽人物 12 帧”,或播放到指定时间后用“当前点抽帧”补充人物参考。
)}