- 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>
201 lines
6.9 KiB
TypeScript
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>
|
|
);
|
|
}
|