2026-05-18-20-18-20 对齐结果视频时长

This commit is contained in:
2026-05-18 20:22:11 +08:00
parent 88cbcc65c2
commit 5264c5c7fc
7 changed files with 154 additions and 23 deletions

View File

@@ -181,6 +181,21 @@ def _selected_frame(index: int, stride: int, selected_count: int, max_frames: in
return index % max(1, stride) == 0 and selected_count < max_frames return index % max(1, stride) == 0 and selected_count < max_frames
def _selected_frame_indices(frame_count: int, stride: int, max_frames: int) -> set[int]:
if frame_count <= 0:
return set()
candidates = list(range(0, frame_count, max(1, stride)))
if len(candidates) <= max_frames:
return set(candidates)
if max_frames <= 1:
return {candidates[0]}
last = len(candidates) - 1
return {
candidates[round(position * last / (max_frames - 1))]
for position in range(max_frames)
}
def _process_video( def _process_video(
job_path: Path, job_path: Path,
source: Path, source: Path,
@@ -197,27 +212,30 @@ def _process_video(
previous = None previous = None
frame_index = 0 frame_index = 0
selected_count = 0 selected_count = 0
written_count = 0
writer = None writer = None
result_fps = 8.0
raw_video_path = job_path / f"{method}_overlay.raw.mp4" raw_video_path = job_path / f"{method}_overlay.raw.mp4"
video_path = job_path / f"{method}_overlay.mp4" video_path = job_path / f"{method}_overlay.mp4"
source_fps = float(capture.get(cv2.CAP_PROP_FPS) or 0.0) source_fps = float(capture.get(cv2.CAP_PROP_FPS) or 0.0)
if source_fps <= 0: if source_fps <= 0:
source_fps = 12.0 source_fps = 12.0
result_fps = source_fps
frame_count = int(capture.get(cv2.CAP_PROP_FRAME_COUNT) or 0) frame_count = int(capture.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
duration = round(frame_count / source_fps, 4) if frame_count else 0.0 selected_indices = _selected_frame_indices(frame_count, frame_stride, max_frames)
try: try:
while True: while True:
ok, frame = capture.read() ok, frame = capture.read()
if not ok: if not ok:
break break
if not _selected_frame(frame_index, frame_stride, selected_count, max_frames): should_process = (
previous = frame frame_index in selected_indices
frame_index += 1 if selected_indices
continue else _selected_frame(frame_index, frame_stride, selected_count, max_frames)
)
video_frame = frame
if method == "compare": if should_process and method == "compare":
outputs = compare_frame(frame, previous, sensitivity) outputs = compare_frame(frame, previous, sensitivity)
for output in outputs: for output in outputs:
frames.append( frames.append(
@@ -228,12 +246,14 @@ def _process_video(
frame, frame,
output, output,
frame_index / source_fps, frame_index / source_fps,
selected_count / result_fps, frame_index / source_fps,
selected_count, frame_index,
) )
) )
video_output = next(item for item in outputs if item.method == "fusion") video_output = next(item for item in outputs if item.method == "fusion")
else: video_frame = video_output.overlay
selected_count += 1
elif should_process:
video_output = segment_frame(frame, method, previous, sensitivity) video_output = segment_frame(frame, method, previous, sensitivity)
frames.append( frames.append(
_save_frame_outputs( _save_frame_outputs(
@@ -243,18 +263,20 @@ def _process_video(
frame, frame,
video_output, video_output,
frame_index / source_fps, frame_index / source_fps,
selected_count / result_fps, frame_index / source_fps,
selected_count, frame_index,
) )
) )
video_frame = video_output.overlay
selected_count += 1
if writer is None: if writer is None:
height, width = video_output.overlay.shape[:2] height, width = frame.shape[:2]
fourcc = cv2.VideoWriter_fourcc(*"mp4v") fourcc = cv2.VideoWriter_fourcc(*"mp4v")
writer = cv2.VideoWriter(str(raw_video_path), fourcc, result_fps, (width, height)) writer = cv2.VideoWriter(str(raw_video_path), fourcc, result_fps, (width, height))
writer.write(video_output.overlay) writer.write(video_frame)
written_count += 1
selected_count += 1
previous = frame previous = frame
frame_index += 1 frame_index += 1
finally: finally:
@@ -266,14 +288,15 @@ def _process_video(
raise HTTPException(status_code=400, detail="视频没有可处理帧") raise HTTPException(status_code=400, detail="视频没有可处理帧")
if raw_video_path.exists(): if raw_video_path.exists():
_browser_video(raw_video_path, video_path) _browser_video(raw_video_path, video_path)
duration = round(written_count / source_fps, 4) if written_count else 0.0
return { return {
"kind": "video", "kind": "video",
"frames": frames, "frames": frames,
"video_url": _public(video_path) if video_path.exists() else None, "video_url": _public(video_path) if video_path.exists() else None,
"source_fps": round(source_fps, 4), "source_fps": round(source_fps, 4),
"result_fps": result_fps, "result_fps": round(result_fps, 4),
"duration": duration, "duration": duration,
"result_duration": round(len({frame["result_index"] for frame in frames}) / result_fps, 4) if frames else 0.0, "result_duration": duration,
} }

View File

@@ -380,11 +380,11 @@ function syncVideos(source, time) {
if (!frame) return; if (!frame) return;
currentFrame = frame; currentFrame = frame;
syncLock = true; syncLock = true;
if (source === "source" && !resultVideoPreview.hidden && Number.isFinite(frame.result_time)) { if (source === "source" && !resultVideoPreview.hidden) {
seekMedia(resultVideoPreview, frame.result_time); seekMedia(resultVideoPreview, time);
} }
if (source === "result" && !videoPreview.hidden && Number.isFinite(frame.source_time)) { if (source === "result" && !videoPreview.hidden) {
seekMedia(videoPreview, frame.source_time); seekMedia(videoPreview, time);
} }
syncLock = false; syncLock = false;
[...resultGrid.querySelectorAll(".result-card")].forEach((node) => { [...resultGrid.querySelectorAll(".result-card")].forEach((node) => {

View File

@@ -2,7 +2,7 @@ from pathlib import Path
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from backend.main import app from backend.main import ROOT, app
from scripts.generate_sample import make_frame from scripts.generate_sample import make_frame
import cv2 import cv2
@@ -62,12 +62,25 @@ def test_segment_video_and_compare_frame(tmp_path: Path):
assert payload["kind"] == "video" assert payload["kind"] == "video"
assert payload["video_url"].endswith(".mp4") assert payload["video_url"].endswith(".mp4")
assert payload["source_fps"] > 0 assert payload["source_fps"] > 0
assert payload["result_fps"] == 8.0 assert payload["result_fps"] == payload["source_fps"]
assert payload["result_duration"] == payload["duration"]
assert len(payload["frames"]) == 3 assert len(payload["frames"]) == 3
first_frame = payload["frames"][0] first_frame = payload["frames"][0]
assert first_frame["source_time"] == 0.0 assert first_frame["source_time"] == 0.0
assert first_frame["result_time"] == 0.0 assert first_frame["result_time"] == 0.0
assert first_frame["result_index"] == 0 assert first_frame["result_index"] == 0
second_frame = payload["frames"][1]
assert second_frame["result_time"] == second_frame["source_time"]
assert second_frame["result_index"] == second_frame["frame_index"]
result_path = ROOT / payload["video_url"].lstrip("/")
result_capture = cv2.VideoCapture(str(result_path))
assert result_capture.isOpened()
result_frames = int(result_capture.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
result_fps = float(result_capture.get(cv2.CAP_PROP_FPS) or 0)
result_capture.release()
assert result_frames == 18
assert abs((result_frames / result_fps) - payload["duration"]) < 0.25
with video_path.open("rb") as handle: with video_path.open("rb") as handle:
compare = client.post( compare = client.post(

View File

@@ -0,0 +1,31 @@
# 实现方案
开始时间2026-05-18-20-18-20
## 后端
1. 修改 `_process_video`
- 结果视频改为按 `source_fps` 写出完整视频时间轴。
- 每读到一帧都写入结果视频。
- 命中抽帧策略的帧执行分割并写入叠加画面。
- 未命中抽帧策略的帧写入原始画面。
2. 统一时间映射
- `result_fps = source_fps`
- `result_duration = duration`
- 每个结果帧的 `result_time = source_time`
- 每个结果帧的 `result_index = frame_index`
3. 抽帧覆盖
- 当视频总帧数可获取时,先根据 `frame_stride` 得到候选帧。
- 如果候选帧超过 `max_frames`,在候选帧中均匀抽取,避免只覆盖视频前半段。
## 前端
1. 双视频同步改为相同时间点同步
- 源视频 seek 到 `t` 时,结果视频也 seek 到 `t`
- 结果视频 seek 到 `t` 时,源视频也 seek 到 `t`
2. 当前帧选择仍按最近的已分割结果帧更新
- 用于高亮下方结果卡片。
- 用于多方法对比按钮的当前帧上下文。

View File

@@ -0,0 +1,34 @@
# 测试方案
开始时间2026-05-18-20-18-20
## 自动化测试
- `python3 -m compileall backend tests`
- `node --check frontend/app.js`
- `pytest -q`
## 接口验证
- 使用样例视频调用 `/api/segment`
- 检查返回的 `duration``result_duration` 基本一致。
- 检查返回的 `source_fps``result_fps` 一致。
- 使用 `ffprobe` 检查生成结果视频时长接近 6 秒。
## 浏览器验证
- Chrome headless 打开页面。
- 点击“加载样例”并运行分割。
- 检查左侧原始视频和右侧结果视频均能加载出画面。
- 检查两个视频 `duration` 接近一致。
- 检查拖动源视频后结果视频跳到相同时间点,反向也一致。
## 执行结果
- `python3 -m compileall backend tests`:通过。
- `node --check frontend/app.js`:通过。
- `pytest -q`5 passed。
- 样例视频调用 `/api/segment`:返回 `duration=6.0``result_duration=6.0``source_fps=12.0``result_fps=12.0`
- `ffprobe storage/jobs/4cc4901e30e9/fusion_overlay.mp4`H.264、yuv420p、12fps、72 帧、6 秒。
- Chrome headless 页面流程:左侧视频 `duration=6`,右侧结果视频 `duration=6`12 个结果帧正常出现。
- Chrome headless 同步验证:源视频 seek 到 2.25 秒后结果视频为 2.25 秒;结果视频 seek 到 4.5 秒后源视频为 4.5 秒。

View File

@@ -139,3 +139,15 @@ B. 产生问题原因:旧样式使用 CSS 无限动画模拟进度,没有和
C. 解决问题方案:移除无限动画,改为 JavaScript 按阶段设置固定进度:上传准备、抽帧、生成掩膜和叠加视频、整理结果、完成或失败。 C. 解决问题方案:移除无限动画,改为 JavaScript 按阶段设置固定进度:上传准备、抽帧、生成掩膜和叠加视频、整理结果、完成或失败。
D. 后续如何避免问题:耗时任务即使暂时没有后端实时进度,也要用单向推进的阶段式进度条,避免使用反复跳动的加载条表达处理进度。 D. 后续如何避免问题:耗时任务即使暂时没有后端实时进度,也要用单向推进的阶段式进度条,避免使用反复跳动的加载条表达处理进度。
## 2026-05-18-20-18-20 结果视频时长与原始视频对齐
### 1. 右侧结果视频只有约 1 秒
A. 具体问题:用户看到左侧原始视频为 6 秒,右侧“预览与结果视频”只有约 1 秒,两个播放器时长不一致。
B. 产生问题原因:后端把已抽取并分割的结果帧直接按固定 8fps 拼成结果视频;默认处理 12 帧时,结果视频只有约 `12 / 8 = 1.5` 秒,而不是原始 6 秒时间轴。
C. 解决问题方案:后端改为按原视频 `source_fps` 写完整结果视频;抽中的帧写入分割叠加画面,未抽中的帧写入原始画面;`result_fps``result_duration``result_time` 与源视频时间轴保持一致。前端双视频 seek 同步改为同一时间点同步。
D. 后续如何避免问题:任何面向并排对照的视频结果,都应优先保持与源视频相同时间轴;抽帧结果可以作为下方帧卡片展示,但主视频播放器不应只由抽帧结果压缩拼接。

View File

@@ -0,0 +1,18 @@
# 需求分析
开始时间2026-05-18-20-18-20
## 用户问题
用户反馈:左侧原始视频显示 6 秒,右侧预览与结果视频显示约 1 秒,询问原因。
## 问题判断
当前右侧结果视频由已抽取并完成分割的结果帧直接拼接生成。默认最多处理 12 帧,使用固定 8fps 写出,因此结果视频时长约为 `12 / 8 = 1.5` 秒,浏览器显示约 1 秒;而左侧原始样例视频是完整 6 秒。
## 期望行为
- 右侧“预览与结果视频”应与左侧原始视频保持同一时间轴和同一显示时长。
- 保留抽帧分割策略,避免为了生成完整视频而强制处理每一帧导致运行过慢。
- 抽中的帧显示导丝分割叠加结果,未抽中的帧保留原始画面。
- 拖动任一视频时,另一个视频跳到相同时间点;下方帧卡片仍表示已分割的关键帧。