From a6cd4cde07265802156d311eacbbdc73b8e22dbe Mon Sep 17 00:00:00 2001 From: michael Date: Sun, 11 Jan 2026 10:48:41 -0600 Subject: [PATCH] fix: store source video coordinates in pause points for correct re-rendering The re-render task was using pause point coordinates from the accessible video timeline (which includes freeze frame durations) instead of the original source video coordinates. This caused pause points to exceed the source video duration and get clamped incorrectly. Changes: - Add source_ms field to PausePointData model to store source video cut point - Update video_renderer.py to populate source_ms when building pause points - Update rerender_accessible_video.py to use source_ms for placement calculations - Apply user adjustments as relative offsets (delta-based adjustment) - Update API responses and TypeScript types to include source_ms - Add backward compatibility fallback for jobs without source_ms Note: Existing jobs need to be re-processed from initial render to populate the new source_ms field. Co-Authored-By: Claude Opus 4.5 --- backend/app/api/v1/routes_jobs.py | 2 + backend/app/models/job.py | 3 +- backend/app/schemas/accessible_video.py | 3 +- backend/app/services/video_renderer.py | 3 +- .../app/tasks/rerender_accessible_video.py | 38 +++++++++++++++---- frontend/src/types/api.ts | 3 +- 6 files changed, 41 insertions(+), 11 deletions(-) diff --git a/backend/app/api/v1/routes_jobs.py b/backend/app/api/v1/routes_jobs.py index 4b987f8..acfa5da 100644 --- a/backend/app/api/v1/routes_jobs.py +++ b/backend/app/api/v1/routes_jobs.py @@ -1532,6 +1532,7 @@ async def get_accessible_video_edit_state( PausePointResponse( cue_index=pp.get("cue_index"), original_ms=pp.get("original_ms"), + source_ms=pp.get("source_ms", pp.get("original_ms")), # Fallback for old data adjusted_ms=pp.get("adjusted_ms"), min_bound_ms=pp.get("min_bound_ms"), max_bound_ms=pp.get("max_bound_ms") @@ -1648,6 +1649,7 @@ async def update_pause_point( return PausePointResponse( cue_index=pause_point["cue_index"], original_ms=pause_point["original_ms"], + source_ms=pause_point.get("source_ms", pause_point["original_ms"]), # Fallback for old data adjusted_ms=pause_point["adjusted_ms"], min_bound_ms=pause_point["min_bound_ms"], max_bound_ms=pause_point["max_bound_ms"] diff --git a/backend/app/models/job.py b/backend/app/models/job.py index 56be357..d7ffa11 100644 --- a/backend/app/models/job.py +++ b/backend/app/models/job.py @@ -68,7 +68,8 @@ class RequestedOutputs(BaseModel): class PausePointData(BaseModel): """Pause point timing data for accessible video editing during QC.""" cue_index: int # AD cue index this pause point belongs to - original_ms: float # Original pause point timestamp (ms) + original_ms: float # Rendered timeline position (ms) - for UI display + source_ms: float # Source video cut point (ms) - for re-rendering adjusted_ms: Optional[float] = None # User-adjusted timestamp (ms), None = use original min_bound_ms: float # Minimum allowed value (end of previous AD segment) max_bound_ms: float # Maximum allowed value (start of next AD segment) diff --git a/backend/app/schemas/accessible_video.py b/backend/app/schemas/accessible_video.py index fee9daf..c99fa1d 100644 --- a/backend/app/schemas/accessible_video.py +++ b/backend/app/schemas/accessible_video.py @@ -130,7 +130,8 @@ class AccessibleVideoProgress(BaseModel): class PausePointResponse(BaseModel): """Pause point timing data for QC editing.""" cue_index: int = Field(..., description="AD cue index this pause point belongs to") - original_ms: float = Field(..., description="Original pause point timestamp (ms)") + original_ms: float = Field(..., description="Rendered timeline position (ms) - for display") + source_ms: float = Field(..., description="Source video cut point (ms) - for re-rendering") adjusted_ms: Optional[float] = Field(None, description="User-adjusted timestamp (ms)") min_bound_ms: float = Field(..., description="Minimum allowed value (ms)") max_bound_ms: float = Field(..., description="Maximum allowed value (ms)") diff --git a/backend/app/services/video_renderer.py b/backend/app/services/video_renderer.py index 4e5fcc5..6a8832a 100644 --- a/backend/app/services/video_renderer.py +++ b/backend/app/services/video_renderer.py @@ -874,7 +874,8 @@ class VideoRendererService: pause_point_data_list.append(PausePointData( cue_index=cue_index, - original_ms=pause_ms, + original_ms=pause_ms, # Rendered timeline position + source_ms=p["pause_point"] * 1000, # Source video cut point adjusted_ms=None, min_bound_ms=min_bound_ms, max_bound_ms=max_bound_ms diff --git a/backend/app/tasks/rerender_accessible_video.py b/backend/app/tasks/rerender_accessible_video.py index 1868d5b..268e0d4 100644 --- a/backend/app/tasks/rerender_accessible_video.py +++ b/backend/app/tasks/rerender_accessible_video.py @@ -418,24 +418,48 @@ def _build_placements_with_adjustments( """ Build placement instructions using adjusted pause points from QC edits. + Uses source_ms (source video coordinates) for pause point calculations, + applying user adjustments as relative offsets. + Args: ad_vtt_content: AD VTT content cue_durations: TTS durations per cue - pause_points: Pause point data with original and adjusted values + pause_points: Pause point data with source_ms, original_ms, and adjusted values Returns: List of placement dicts """ cues = VTTParser.parse(ad_vtt_content) - # Build lookup of adjusted pause points by cue index + # Build lookup of pause points by cue index using SOURCE coordinates adjusted_pause_by_cue = {} for pp in pause_points: cue_idx = pp.get("cue_index") - adjusted = pp.get("adjusted_ms") - original = pp.get("original_ms") - # Use adjusted if set, otherwise original (in seconds) - pause_time_s = (adjusted if adjusted is not None else original) / 1000.0 + source_ms = pp.get("source_ms") + original_ms = pp.get("original_ms") + adjusted_ms = pp.get("adjusted_ms") + + # Fallback for data without source_ms (backward compatibility) + if source_ms is None: + logger.warning( + f"Cue {cue_idx}: No source_ms found, falling back to original_ms. " + "Job may need to be re-processed from initial render." + ) + source_ms = original_ms + + # Apply user adjustment as relative offset + if adjusted_ms is not None and original_ms is not None: + # User adjusted in rendered timeline - apply same delta to source + adjustment_delta = adjusted_ms - original_ms + source_ms = source_ms + adjustment_delta + logger.info( + f"Cue {cue_idx}: Applying adjustment delta {adjustment_delta:.1f}ms " + f"(rendered: {original_ms:.1f} -> {adjusted_ms:.1f}, " + f"source: {source_ms - adjustment_delta:.1f} -> {source_ms:.1f})" + ) + + # Convert to seconds for placement + pause_time_s = source_ms / 1000.0 adjusted_pause_by_cue[cue_idx] = pause_time_s placements = [] @@ -443,7 +467,7 @@ def _build_placements_with_adjustments( if i >= len(cue_durations): break - # Get pause point: use adjusted value if available + # Get pause point: use source-based value if available, otherwise fall back to VTT pause_point = adjusted_pause_by_cue.get(i, cue.start_time) placements.append({ diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 2f229a3..e9a042d 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -336,7 +336,8 @@ export interface ReviewNotesListResponse { export interface PausePointData { cue_index: number; - original_ms: number; + original_ms: number; // Rendered timeline position (for display) + source_ms: number; // Source video cut point (for re-rendering) adjusted_ms: number | null; min_bound_ms: number; max_bound_ms: number;