video-accessibility/frontend/src/components/TimelinePreview/TimelinePreview.tsx
michael aa6777d2c2 feat: add QC accessible video review and editing capabilities
- Reorder workflow: translations now happen BEFORE QC Review step
- Add language tabs to switch between translated languages in QC
- Add video mode tabs (Original Video / Accessible Video)
- Add interactive timeline preview showing video segments and AD cues
- Enable pause point adjustment with millisecond precision
- Add TTS regeneration queue for selective cue re-synthesis
- Add re-render controls with optional Whisper refinement
- Persist video segments and TTS MP3s to GCS for editability
- Add new RENDERING_QC job status for re-render operations
- Create 5 new API endpoints for accessible video editing
- Add rerender_accessible_video.py Celery task

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 08:32:27 -06:00

201 lines
6.9 KiB
TypeScript

import { useState, useRef, useCallback } from 'react';
import type { VideoSegmentMetadata, PausePointData } from '../../types/api';
import { PausePointEditor } from './PausePointEditor';
interface TimelinePreviewProps {
segments: VideoSegmentMetadata[];
pausePoints: PausePointData[];
totalDurationMs: number;
currentTimeMs: number;
onSegmentClick: (segment: VideoSegmentMetadata) => void;
onPausePointClick: (pausePoint: PausePointData) => void;
onPausePointUpdate: (cueIndex: number, adjustedMs: number) => void;
onRegenerateTTS: (cueIndex: number) => void;
regenerationQueue: number[];
}
export function TimelinePreview({
segments,
pausePoints,
totalDurationMs,
currentTimeMs,
onSegmentClick,
onPausePointClick,
onPausePointUpdate,
onRegenerateTTS,
regenerationQueue,
}: TimelinePreviewProps) {
const [selectedPausePoint, setSelectedPausePoint] = useState<PausePointData | null>(null);
const [editorPosition, setEditorPosition] = useState({ x: 0, y: 0 });
const timelineRef = useRef<HTMLDivElement>(null);
const getPositionPercent = useCallback(
(ms: number) => (totalDurationMs > 0 ? (ms / totalDurationMs) * 100 : 0),
[totalDurationMs]
);
const handlePausePointMarkerClick = (
e: React.MouseEvent,
pausePoint: PausePointData
) => {
e.stopPropagation();
const rect = (e.target as HTMLElement).getBoundingClientRect();
setEditorPosition({ x: rect.left, y: rect.bottom + 8 });
setSelectedPausePoint(pausePoint);
onPausePointClick(pausePoint);
};
const handleSegmentClick = (segment: VideoSegmentMetadata) => {
onSegmentClick(segment);
if (segment.is_freeze_frame && segment.cue_index !== null) {
// Highlight the AD cue
const pausePoint = pausePoints.find(pp => pp.cue_index === segment.cue_index);
if (pausePoint) {
onPausePointClick(pausePoint);
}
}
};
const handleEditorSave = (adjustedMs: number) => {
if (selectedPausePoint) {
onPausePointUpdate(selectedPausePoint.cue_index, adjustedMs);
setSelectedPausePoint(null);
}
};
const handleEditorClose = () => {
setSelectedPausePoint(null);
};
const formatTime = (ms: number) => {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
const milliseconds = Math.floor(ms % 1000);
return `${minutes}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(3, '0')}`;
};
return (
<div className="relative">
{/* Timeline container */}
<div
ref={timelineRef}
className="relative h-16 bg-gray-100 rounded-lg overflow-hidden"
>
{/* Segments */}
{segments.map((segment) => {
const leftPercent = getPositionPercent(segment.start_ms);
const widthPercent = getPositionPercent(segment.duration_ms);
const isRegenerationQueued =
segment.is_freeze_frame &&
segment.cue_index !== null &&
regenerationQueue.includes(segment.cue_index);
return (
<div
key={segment.segment_index}
className={`absolute top-0 h-full cursor-pointer transition-all hover:opacity-90 ${
segment.is_freeze_frame
? isRegenerationQueued
? 'bg-amber-400'
: 'bg-orange-400'
: 'bg-blue-400'
}`}
style={{
left: `${leftPercent}%`,
width: `${Math.max(widthPercent, 0.5)}%`,
}}
onClick={() => handleSegmentClick(segment)}
title={
segment.is_freeze_frame
? `AD Cue ${segment.cue_index}${isRegenerationQueued ? ' (Regenerate queued)' : ''}`
: `Video segment ${segment.segment_index}`
}
>
{/* Cue index label for freeze frames */}
{segment.is_freeze_frame && segment.cue_index !== null && widthPercent > 2 && (
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xs font-bold text-white drop-shadow">
{segment.cue_index}
</span>
</div>
)}
</div>
);
})}
{/* Pause point markers */}
{pausePoints.map((pausePoint) => {
const effectiveMs = pausePoint.adjusted_ms ?? pausePoint.original_ms;
const leftPercent = getPositionPercent(effectiveMs);
const isAdjusted = pausePoint.adjusted_ms !== null;
return (
<div
key={`pause-${pausePoint.cue_index}`}
className={`absolute top-0 w-1 h-full cursor-pointer z-10 ${
isAdjusted ? 'bg-purple-600' : 'bg-red-600'
} hover:w-2 transition-all`}
style={{ left: `${leftPercent}%` }}
onClick={(e) => handlePausePointMarkerClick(e, pausePoint)}
title={`Pause point ${pausePoint.cue_index}: ${formatTime(effectiveMs)}${
isAdjusted ? ' (adjusted)' : ''
}`}
/>
);
})}
{/* Current time indicator */}
<div
className="absolute top-0 w-0.5 h-full bg-green-500 z-20 pointer-events-none"
style={{ left: `${getPositionPercent(currentTimeMs)}%` }}
/>
</div>
{/* Time labels */}
<div className="flex justify-between mt-1 text-xs text-gray-500">
<span>0:00</span>
<span>{formatTime(totalDurationMs)}</span>
</div>
{/* Legend */}
<div className="flex gap-4 mt-2 text-xs">
<div className="flex items-center gap-1">
<div className="w-3 h-3 bg-blue-400 rounded" />
<span>Video</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 bg-orange-400 rounded" />
<span>AD Audio</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 bg-amber-400 rounded" />
<span>Regenerate Queued</span>
</div>
<div className="flex items-center gap-1">
<div className="w-1 h-3 bg-red-600" />
<span>Pause Point</span>
</div>
<div className="flex items-center gap-1">
<div className="w-1 h-3 bg-purple-600" />
<span>Adjusted</span>
</div>
</div>
{/* Pause point editor popover */}
{selectedPausePoint && (
<PausePointEditor
pausePoint={selectedPausePoint}
position={editorPosition}
onSave={handleEditorSave}
onCancel={handleEditorClose}
onRegenerateTTS={() => {
onRegenerateTTS(selectedPausePoint.cue_index);
setSelectedPausePoint(null);
}}
isRegenerationQueued={regenerationQueue.includes(selectedPausePoint.cue_index)}
/>
)}
</div>
);
}