~/blog/video-drag-select-deadlock
工程2026-10-14 · 8 min

视频画面拖拽框选的两个死锁

条件渲染和 ref 测量互相等待,组件自己锁死自己——这类 overlay 组件的系统性陷阱,外加 letterbox 坐标偏移和偶数宽高纪律。

在视频画面上直接框选裁剪区域,看起来是个纯交互问题,实际上把它做对需要处理三套坐标、两个死锁和一个编码器约束。这篇记 ffmpegtool.com 裁剪页和水印页在这件事上踩过的坑,全部有 e2e 实测背书。

两个自我死锁

视频裁剪页报"拖拽框不显示"。排查先走了个弯路:headless 下 videoWidth=0——那是测试 fixture 的问题(headless Chrome for Testing 没有 H.264 解码器,测试视频必须 VP8 WebM)。换成 VP8 之后,框还是不出现。

读代码才发现是个死锁,而且锁得很标准:

// 旧代码
if (!videoEl || !content) return null
// content 依赖 rootRef 测量,root 不渲染 → ref 恒 null → 测量永远不跑 → content 永远 null

content(内容区矩形)需要挂载后对根节点做 getBoundingClientRect 测量;而根节点因为 !content 压根没有渲染,rootRef.current 恒为 null,测量函数永远拿不到基准直接返回。组件自己把自己锁死了。

修法是把"根节点"和"裁剪框"的渲染条件拆开:根 div 始终渲染(absolute inset-0),裁剪框才按 content 是否存在条件渲染。现在代码里留着注释说明这条约束:根节点必须始终渲染,它是测量的基准。

水印页的覆盖层犯了一模一样的错:if (!root) return null,于是 ResizeObserver 从未 tick,框永不出现。同一个模式在两个页面独立出现,说明这是这类组件的系统性陷阱,不是手滑。

letterbox 偏移

死锁修完,下一个问题是位置偏。覆盖层的拖拽锚点用的是 canvasBox.left/top,但 <video> 是 object-contain,视频内容在画布内居中,四周留下黑边——锚点原点和实际内容原点不一致,旋转 90 度时 E2E 实测偏了 2 度(88° vs 90°),位置也随黑边宽度漂移。

修法是抽一个共享的 fitContentRect(letterbox 感知的内容矩形),两个组件都从 contentLeft/contentTop 取原点,画布盒只做容器。CropSelector 重构时也改成复用同一个函数——坐标逻辑一处实现,两处消费,否则下次改一处偏一处。

坐标纪律:像素、clamp、偶数

三套坐标贯穿这条链路,混一次错一次:

  1. 画面像素坐标:框选坐标一律按视频像素存(letterbox 感知换算),不存 CSS 像素——CSS 像素随容器缩放,输出滤镜参数时需要回到视频像素;
  2. 输出前收敛:sanitizeCrop 把坐标 clamp 进画面、宽高取偶。取偶用的是 & ~1 位运算——libx264/yuv420p 要求宽高是偶数,奇数尺寸直接编码失败;
  3. 拖拽中的实时收敛:拖拽手柄时用 Math.min 限在画面内再取偶,Math.max(MIN_SIZE, ...) 保底最小尺寸。

验证方式

这类交互 bug 的验证必须打到命令层才算数:E2E 里从 640×360 起始框向东南拖,断言生成的滤镜参数是 crop=534:296:42:21——534/296 是拖拽值取偶的结果,42/21 是平移量,和 ffmpeg 实际收到的参数逐字节一致。只断言"框出现了"测不出坐标系错误。

还有个测试自身的教训:手柄的选择器原来找 style.right === '-7px',手柄改到框内渲染后变成了 0px,用例找不到元素——选择器断言的是实现细节而不是行为,这类断言在重构时假性失败。

复盘

这个组件族的经验浓缩成两条:

  1. ref 测量 + 条件渲染同框时警觉死锁。"没数据就不渲染、不渲染就拿不到数据"的模式,在 overlay/cropper/measure 类组件里反复出现。review 时看到 return null 和 ref.current 互相依赖,基本就是。
  2. <video> 的 CSS 盒 ≠ 内容区。object-contain、object-fit、全屏模式都会让两者分离,一切坐标都要声明自己基于哪个矩形,并收敛到一套共享的换算函数里。
RELATED TOOLS · 把文章里的东西用起来