(frames: MotionFrame[], selector: string, canvas: Canvas)
| 136 | // the first-appear anchor uses APPEAR_OPACITY (0.5). Elements fading in between those |
| 137 | // thresholds are tracked for position but don't start the window — intentional. |
| 138 | function staysInFrame(frames: MotionFrame[], selector: string, canvas: Canvas): LayoutIssue[] { |
| 139 | if (!everMatched(frames, selector)) return [missingIssue(selector, 0)]; |
| 140 | const appear = firstAppear(frames, selector); |
| 141 | if (!appear) return []; |
| 142 | |
| 143 | for (const frame of frames) { |
| 144 | const sample = frame.data[selector]; |
| 145 | if (frame.time < appear.time || !sample || !sample.visible) continue; |
| 146 | if (!isOffFrame(sample.rect, canvas)) continue; |
| 147 | const r = sample.rect; |
| 148 | return [ |
| 149 | { |
| 150 | code: "motion_off_frame", |
| 151 | severity: "error", |
| 152 | time: frame.time, |
| 153 | selector, |
| 154 | message: `${selector} drifts off the ${canvas.width}×${canvas.height} canvas at ${round(frame.time)}s (box ${r.left},${r.top}→${r.right},${r.bottom})`, |
| 155 | rect: r, |
| 156 | fixHint: |
| 157 | "Clamp the element's motion so its box stays within the canvas for the whole shot.", |
| 158 | }, |
| 159 | ]; |
| 160 | } |
| 161 | return []; |
| 162 | } |
| 163 | |
| 164 | function keepsMoving( |
| 165 | frames: MotionFrame[], |
no test coverage detected