feat(ux): R-8 linguist language warn, PM CC editing, timeline right-click + CC insert

R-8 — Linguist language competence:
- Add User.languages[] BCP-47 field to backend model + UserResponse schema
- Frontend: show amber warning in assign modal when selected linguist has no
  competence listed for the target language

PM VTT editing (FinalDetail):
- PM and ADMIN can now edit captions/AD in the final review stage
- VttEditor becomes read-write with onCueSave wired to updateVttMutation
- Other roles remain read-only

Timeline right-click + add pause:
- Right-click anywhere on the timeline opens a context menu showing the timestamp
- If near a pause point marker: "Edit timing" + "Regenerate TTS" options
- If on empty space: "Add AD cue at Xs" → inserts a new AD cue in the editor
- Pause point markers widened from 1px → 2px (3px on hover) for easier clicking
- Right-click on a pause point marker directly opens the editor

VttEditor insertAtTimeMs prop:
- New prop triggers programmatic insert at a specific video timestamp
- Used by the timeline right-click "Add AD cue here" action

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-04-30 10:51:31 +01:00
parent 518796c852
commit e4b350cd7d
8 changed files with 197 additions and 20 deletions

View file

@ -64,6 +64,7 @@ async def list_users(
is_active=user_doc["is_active"],
created_at=user_doc.get("created_at", datetime.utcnow()).isoformat(),
pm_client_ids=user_doc.get("pm_client_ids", []),
languages=user_doc.get("languages", []),
))
return UserListResponse(
@ -97,6 +98,7 @@ async def get_user(
is_active=user_doc["is_active"],
created_at=user_doc.get("created_at", datetime.utcnow()).isoformat(),
pm_client_ids=user_doc.get("pm_client_ids", []),
languages=user_doc.get("languages", []),
)
@ -150,6 +152,7 @@ async def create_user(
is_active=True,
created_at=user_doc["created_at"].isoformat(),
pm_client_ids=[],
languages=[],
)
@ -214,6 +217,7 @@ async def update_user(
is_active=result["is_active"],
created_at=result.get("created_at", datetime.utcnow()).isoformat(),
pm_client_ids=result.get("pm_client_ids", []),
languages=result.get("languages", []),
)

View file

@ -41,6 +41,7 @@ class User(BaseModel):
auth_provider: AuthProvider = AuthProvider.LOCAL
is_active: bool = True
pm_client_ids: list[str] = [] # Client IDs where this user is Project Manager (admin-assigned)
languages: list[str] = [] # BCP-47 language codes the user is competent in (R-8)
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
@ -66,3 +67,4 @@ class UserUpdate(BaseModel):
role: Optional[UserRole] = None
is_active: Optional[bool] = None
pm_client_ids: Optional[list[str]] = None
languages: Optional[list[str]] = None

View file

@ -54,6 +54,7 @@ class UserResponse(BaseModel):
is_active: bool
created_at: Optional[str] = None
pm_client_ids: list[str] = []
languages: list[str] = [] # BCP-47 codes for R-8 linguist competence check
class UserListResponse(BaseModel):

View file

@ -2,6 +2,13 @@ import { useState, useRef, useCallback } from 'react';
import type { VideoSegmentMetadata, PausePointData } from '../../types/api';
import { PausePointEditor } from './PausePointEditor';
interface ContextMenuState {
x: number;
y: number;
timeMs: number;
nearPausePoint: PausePointData | null;
}
interface TimelinePreviewProps {
segments: VideoSegmentMetadata[];
pausePoints: PausePointData[];
@ -13,6 +20,7 @@ interface TimelinePreviewProps {
onRegenerateTTS: (cueIndex: number) => void;
regenerationQueue: number[];
isStale?: boolean;
onPausePointAdd?: (timeMs: number) => void; // right-click → add pause at position
}
export function TimelinePreview({
@ -26,9 +34,11 @@ export function TimelinePreview({
onRegenerateTTS,
regenerationQueue,
isStale = false,
onPausePointAdd,
}: TimelinePreviewProps) {
const [selectedPausePoint, setSelectedPausePoint] = useState<PausePointData | null>(null);
const [editorPosition, setEditorPosition] = useState({ x: 0, y: 0 });
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const timelineRef = useRef<HTMLDivElement>(null);
const getPositionPercent = useCallback(
@ -69,6 +79,38 @@ export function TimelinePreview({
setSelectedPausePoint(null);
};
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
if (!timelineRef.current || totalDurationMs <= 0) return;
const rect = timelineRef.current.getBoundingClientRect();
const fraction = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const timeMs = Math.round(fraction * totalDurationMs);
// Find if click is within 1.5% of a pause point
const threshold = totalDurationMs * 0.015;
const near = pausePoints.find(pp => {
const ppMs = pp.adjusted_ms ?? pp.original_ms;
return Math.abs(ppMs - timeMs) <= threshold;
}) ?? null;
setContextMenu({ x: e.clientX, y: e.clientY, timeMs, nearPausePoint: near });
setSelectedPausePoint(null);
};
const closeContextMenu = () => setContextMenu(null);
const handleContextMenuPauseOpen = (pp: PausePointData) => {
const effectiveMs = pp.adjusted_ms ?? pp.original_ms;
setEditorPosition({ x: contextMenu!.x, y: contextMenu!.y + 8 });
setSelectedPausePoint(pp);
onPausePointClick(pp);
setContextMenu(null);
if (timelineRef.current) {
// seek video
onPausePointClick(pp);
}
// Seek video
const _ = effectiveMs; // used by consumer via onPausePointClick
};
const formatTime = (ms: number) => {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
@ -89,6 +131,7 @@ export function TimelinePreview({
<div
ref={timelineRef}
className={`relative h-16 rounded-lg overflow-hidden ${isStale ? 'bg-amber-50' : 'bg-gray-100'}`}
onContextMenu={handleContextMenu}
>
{/* Segments */}
{segments.map((segment) => {
@ -141,14 +184,13 @@ export function TimelinePreview({
return (
<div
key={`pause-${pausePoint.cue_index}`}
className={`absolute top-0 w-1 h-full cursor-pointer z-10 ${
className={`absolute top-0 w-2 h-full cursor-pointer z-10 hover:w-3 transition-all ${
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)' : ''
}`}
style={{ left: `${leftPercent}%` }}
onClick={(e) => { e.stopPropagation(); handlePausePointMarkerClick(e, pausePoint); }}
onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); handleContextMenuPauseOpen(pausePoint); }}
title={`Pause point ${pausePoint.cue_index + 1}: ${formatTime(effectiveMs)}${isAdjusted ? ' (adjusted)' : ''} — click to edit`}
/>
);
})}
@ -204,6 +246,46 @@ export function TimelinePreview({
isRegenerationQueued={regenerationQueue.includes(selectedPausePoint.cue_index)}
/>
)}
{/* Right-click context menu */}
{contextMenu && (
<>
<div className="fixed inset-0 z-40" onClick={closeContextMenu} onContextMenu={(e) => { e.preventDefault(); closeContextMenu(); }} />
<div
className="fixed z-50 bg-white border border-gray-200 rounded-lg shadow-lg py-1 min-w-[200px] text-sm"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
<div className="px-3 py-1.5 text-xs text-gray-500 border-b border-gray-100">
{formatTime(contextMenu.timeMs)}
</div>
{contextMenu.nearPausePoint ? (
<>
<button
className="w-full text-left px-3 py-2 hover:bg-gray-50 flex items-center gap-2"
onClick={() => handleContextMenuPauseOpen(contextMenu.nearPausePoint!)}
>
Edit pause point {contextMenu.nearPausePoint.cue_index + 1} timing
</button>
<button
className="w-full text-left px-3 py-2 hover:bg-gray-50 flex items-center gap-2"
onClick={() => { onRegenerateTTS(contextMenu.nearPausePoint!.cue_index); closeContextMenu(); }}
>
🔄 Regenerate TTS for cue {contextMenu.nearPausePoint.cue_index + 1}
</button>
</>
) : onPausePointAdd ? (
<button
className="w-full text-left px-3 py-2 hover:bg-gray-50 flex items-center gap-2"
onClick={() => { onPausePointAdd(contextMenu.timeMs); closeContextMenu(); }}
>
Add AD cue at {formatTime(contextMenu.timeMs)}
</button>
) : (
<div className="px-3 py-2 text-gray-400 text-xs">No pause point nearby</div>
)}
</div>
</>
)}
</div>
);
}

View file

@ -90,9 +90,11 @@ interface VttEditorProps {
readOnly?: boolean;
glossaryTerms?: GlossaryTerm[];
language?: string;
insertAtTimeMs?: number | null; // when set, auto-insert a cue at/near this timestamp
onInsertAtTimeDone?: () => void; // callback to clear insertAtTimeMs after insert
}
export function VttEditor({ vttContent, onChange, onCueSave, onCueInserted, onCueDeleted, onCuePlay, title, readOnly = false, glossaryTerms = [], language = 'en' }: VttEditorProps) {
export function VttEditor({ vttContent, onChange, onCueSave, onCueInserted, onCueDeleted, onCuePlay, title, readOnly = false, glossaryTerms = [], language = 'en', insertAtTimeMs, onInsertAtTimeDone }: VttEditorProps) {
const [cues, setCues] = useState<VTTCue[]>([]);
const [errors, setErrors] = useState<string[]>([]);
const [editingCue, setEditingCue] = useState<number | null>(null);
@ -118,6 +120,38 @@ export function VttEditor({ vttContent, onChange, onCueSave, onCueInserted, onCu
}
}, [vttContent]);
// Imperatively insert a cue near insertAtTimeMs when triggered from parent (e.g., timeline right-click)
useEffect(() => {
if (insertAtTimeMs == null || readOnly || cues.length === 0) return;
const targetS = insertAtTimeMs / 1000;
// Find the cue whose start time is nearest but after targetS, or insert at end
let insertBeforeIdx = cues.findIndex(c => c.startTime >= targetS);
if (insertBeforeIdx === -1) insertBeforeIdx = cues.length; // insert after last
// Reuse insertCue logic inline: insertBefore insertBeforeIdx
let startTime: number, endTime: number;
if (insertBeforeIdx === 0) {
startTime = Math.max(0, targetS - 1);
endTime = Math.min(targetS + 1, cues[0].startTime);
} else if (insertBeforeIdx === cues.length) {
startTime = cues[cues.length - 1].endTime + 0.1;
endTime = startTime + 2.0;
} else {
const prev = cues[insertBeforeIdx - 1];
startTime = Math.max(prev.endTime, targetS - 1);
endTime = Math.min(targetS + 1, cues[insertBeforeIdx].startTime);
}
if (endTime - startTime < 0.5) endTime = startTime + 0.5;
const newCue: VTTCue = { startTime, endTime, text: '[Enter caption text]' };
const updatedCues = [...cues];
updatedCues.splice(insertBeforeIdx, 0, newCue);
const newVtt = updateCuesLocal(updatedCues);
onCueInserted?.(insertBeforeIdx, updatedCues.length);
setEditingCue(insertBeforeIdx);
void saveCue(insertBeforeIdx, newVtt);
onInsertAtTimeDone?.();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [insertAtTimeMs]);
const updateCuesLocal = (updatedCues: VTTCue[]) => {
setCues(updatedCues);
setCueErrors(computeCueErrors(updatedCues));

View file

@ -1,9 +1,10 @@
import { useParams, useNavigate } from 'react-router-dom';
import { useState, useEffect } from 'react';
import { useJob, useCompleteJob, useJobVttContent, useRejectFinalReview, useJobValidation, useJobDownloads, useUpdateJob } from '../../hooks/useJob';
import { useJob, useCompleteJob, useJobVttContent, useRejectFinalReview, useJobValidation, useJobDownloads, useUpdateJob, useUpdateJobVtt } from '../../hooks/useJob';
import { StatusBadge } from '../../components/StatusBadge';
import { VttEditor } from '../../components/VttEditor/VttEditor';
import { VideoReviewPlayer } from '../../components/VideoReview';
import { useAuthStore } from '../../lib/auth';
import type { LangOutput } from '../../types/api';
@ -13,6 +14,7 @@ interface LanguagePreviewProps {
jobId: string;
audioUrl?: string;
primaryLanguage?: string;
canEdit?: boolean; // PM can edit VTT in final review
}
const LanguagePreview = ({
@ -20,11 +22,31 @@ const LanguagePreview = ({
assets,
jobId,
audioUrl,
primaryLanguage
primaryLanguage,
canEdit = false,
}: LanguagePreviewProps) => {
const isOriginalLanguage = primaryLanguage && language.toLowerCase() === primaryLanguage.toLowerCase();
const [previewType, setPreviewType] = useState<'captions' | 'ad'>('captions');
const { data: vttContent } = useJobVttContent(jobId, language);
const [localCaptionsVtt, setLocalCaptionsVtt] = useState<string | null>(null);
const [localAdVtt, setLocalAdVtt] = useState<string | null>(null);
const updateVttMutation = useUpdateJobVtt();
const activeCaptionsVtt = localCaptionsVtt ?? vttContent?.captions_vtt ?? null;
const activeAdVtt = localAdVtt ?? vttContent?.audio_description_vtt ?? null;
const handleCueSave = async (kind: 'captions' | 'ad', vttStr: string) => {
await updateVttMutation.mutateAsync({
id: jobId,
data: {
captions_vtt: kind === 'captions' ? vttStr : (activeCaptionsVtt ?? undefined),
audio_description_vtt: kind === 'ad' ? vttStr : (activeAdVtt ?? undefined),
language,
},
});
if (kind === 'captions') setLocalCaptionsVtt(vttStr);
else setLocalAdVtt(vttStr);
};
return (
<div className="border rounded-lg p-4 mb-4">
@ -82,21 +104,25 @@ const LanguagePreview = ({
)}
<div className="bg-gray-50 rounded-lg p-4">
{previewType === 'captions' && vttContent?.captions_vtt && (
{previewType === 'captions' && activeCaptionsVtt && (
<VttEditor
vttContent={vttContent.captions_vtt}
onChange={() => {}} // Read-only in final review
title={`${language.toUpperCase()} Captions`}
readOnly={true}
vttContent={activeCaptionsVtt}
onChange={setLocalCaptionsVtt}
onCueSave={canEdit ? (_, vtt) => handleCueSave('captions', vtt) : undefined}
title={`${language.toUpperCase()} Captions${canEdit ? '' : ' (read-only)'}`}
readOnly={!canEdit}
language={language}
/>
)}
{previewType === 'ad' && vttContent?.audio_description_vtt && (
{previewType === 'ad' && activeAdVtt && (
<VttEditor
vttContent={vttContent.audio_description_vtt}
onChange={() => {}} // Read-only in final review
title={`${language.toUpperCase()} Audio Description`}
readOnly={true}
vttContent={activeAdVtt}
onChange={setLocalAdVtt}
onCueSave={canEdit ? (_, vtt) => handleCueSave('ad', vtt) : undefined}
title={`${language.toUpperCase()} Audio Description${canEdit ? '' : ' (read-only)'}`}
readOnly={!canEdit}
language={language}
/>
)}
</div>
@ -133,6 +159,7 @@ export function FinalDetail() {
const navigate = useNavigate();
const [selectedAction, setSelectedAction] = useState<'approve' | 'reject' | null>(null);
const [reviewNotes, setReviewNotes] = useState('');
const { user: authUser } = useAuthStore();
const { data: job, isLoading, error } = useJob(id!);
const { data: validation } = useJobValidation(id!);
@ -141,6 +168,9 @@ export function FinalDetail() {
const rejectFinalMutation = useRejectFinalReview();
const updateJobMutation = useUpdateJob();
// PM and ADMIN can edit VTT during final review; other roles see read-only
const canEditVtt = authUser?.role === 'project_manager' || authUser?.role === 'admin';
const [costProjectId, setCostProjectId] = useState('');
const [costProjectIdSaved, setCostProjectIdSaved] = useState(false);
@ -285,6 +315,7 @@ export function FinalDetail() {
jobId={id!}
audioUrl={typeof downloads?.downloads?.[language] === 'object' ? downloads?.downloads?.[language]?.audio_description_mp3 : undefined}
primaryLanguage={job.source.language}
canEdit={canEditVtt}
/>
))}
</div>

View file

@ -102,6 +102,7 @@ export function QCDetail() {
const [reviewNotes, setReviewNotes] = useState('');
const [costProjectId, setCostProjectId] = useState('');
const [adInsertAtTimeMs, setAdInsertAtTimeMs] = useState<number | null>(null);
// Per-language QC state
const { data: langQcData, refetch: refetchLangQc } = useQuery({
@ -743,6 +744,11 @@ export function QCDetail() {
}
};
const handlePausePointAdd = (timeMs: number) => {
// Insert a new AD cue at this position via the VttEditor insertAtTimeMs prop
setAdInsertAtTimeMs(timeMs);
};
const handleAdCuePlay = useCallback((startTime: number) => {
setVideoMode('accessible');
setTimeout(() => {
@ -1255,6 +1261,19 @@ export function QCDetail() {
<option key={u.id} value={u.id}>{u.full_name} ({u.email})</option>
))}
</select>
{/* R-8: language competence warning for linguists */}
{assignSlot === 'linguist' && assigningUserId && (() => {
const selected = assignableUsers.find(u => u.id === assigningUserId);
if (!selected?.languages?.length) return null;
const hasLang = selected.languages.some(
l => l.toLowerCase() === assignLanguage.toLowerCase() || l.toLowerCase().startsWith(assignLanguage.toLowerCase() + '-')
);
return !hasLang ? (
<p className="mt-1.5 text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded px-2 py-1">
{selected.full_name} has no {assignLanguage.toUpperCase()} competence listed. You can still assign, but double-check.
</p>
) : null;
})()}
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Deadline (optional)</label>
@ -1569,6 +1588,7 @@ export function QCDetail() {
onRegenerateTTS={handleRegenerateTTS}
regenerationQueue={pendingRegenerations}
isStale={pausePointsModified || pendingRegenerations.length > 0 || adVttUploaded}
onPausePointAdd={handlePausePointAdd}
/>
</div>
)}
@ -1670,6 +1690,8 @@ export function QCDetail() {
readOnly={isProcessing}
glossaryTerms={glossaryTerms}
language={selectedLanguage}
insertAtTimeMs={adInsertAtTimeMs}
onInsertAtTimeDone={() => setAdInsertAtTimeMs(null)}
/>
<div className="mt-2">
<VttDiffView jobId={id!} lang={selectedLanguage} kind="ad" />

View file

@ -29,6 +29,7 @@ export interface User {
is_active: boolean;
created_at: string;
pm_client_ids?: string[];
languages?: string[]; // BCP-47 codes the user is competent in (R-8)
}
export interface Source {