/** * Neon Step3 JSON parser. * Extracts transcription, shot changes, FN events, and SDH events * into a normalized format for the GTS-X source panel + AI QC context. */ export interface NeonTranscriptionEntry { id: number; index: number; content: string; cleanText: string; startTimeMs: number; endTimeMs: number; speakerLabel: string; metadataType: string; // "Dialogue" | "On-Screen Text" | etc. subPosition: string; // "bottom" | "top" faceDetection: string; lipMovement: string; ostType: string; noiseType: string; emtInfo?: { label: string; gender: string; emotion: string; listener: string; }; } export interface NeonShotChange { id: number; startMs: number; endMs: number; startFrame: number; endFrame: number; } export interface NeonFNEvent { text: string; startTimeMs: number; endTimeMs: number; source: string; ocrType: string; ostType: string; significance: string; } export interface NeonSourceData { transcription: NeonTranscriptionEntry[]; shotChanges: NeonShotChange[]; fnEvents: NeonFNEvent[]; sdhEvents: any[]; summary: { scriptSummary: string; speakerSummary: any[] }; } /** Parse SRT-style timestamp "HH:MM:SS,mmm" to milliseconds */ function parseSrtTime(time: string): number { if (!time) return 0; const match = time.match(/(\d{2}):(\d{2}):(\d{2})[,.](\d{3})/); if (!match) return 0; return ( parseInt(match[1]) * 3600000 + parseInt(match[2]) * 60000 + parseInt(match[3]) * 1000 + parseInt(match[4]) ); } export function parseNeonStep3(json: any): NeonSourceData { const transcription: NeonTranscriptionEntry[] = (json.transcription || []).map( (t: any) => ({ id: t.id, index: t.index ?? t.id - 1, content: t.content || "", cleanText: t.clean_text || t.content || "", startTimeMs: parseSrtTime(t.start_time), endTimeMs: parseSrtTime(t.end_time), speakerLabel: t.speaker_label || "", metadataType: t.metadata_type || "Dialogue", subPosition: t.sub_position || "bottom", faceDetection: t.face_detection || "", lipMovement: t.lip_movement || "", ostType: t.ost_type || "none", noiseType: t.noise_type || "none", emtInfo: t.emt_additional_info ? { label: t.emt_additional_info.label || "", gender: t.emt_additional_info.gender || "", emotion: t.emt_additional_info.emotion || "", listener: t.emt_additional_info.listener || "", } : undefined, }), ); const shotChanges: NeonShotChange[] = (json.shot_changes || []).map( (sc: any) => ({ id: sc.id, startMs: sc.start_timestamp_millis, endMs: sc.end_timestamp_millis, startFrame: sc.start_frame, endFrame: sc.end_frame, }), ); const fnEvents: NeonFNEvent[] = (json.fn_events || []).map((fn: any) => ({ text: fn.text || fn.content || "", startTimeMs: (fn.start_time || 0) * 1000, endTimeMs: (fn.end_time || 0) * 1000, source: fn.source || "", ocrType: fn.ocr_type || "", ostType: fn.ost_type || "", significance: fn.narrative_significance || "", })); return { transcription, shotChanges, fnEvents, sdhEvents: json.sdh_events || [], summary: { scriptSummary: json.summary?.script_summary || "", speakerSummary: json.summary?.speaker_summary || [], }, }; } /** * Find Neon source entries that overlap with a given time range. * Used for time-aligned source panel — shows which Neon entries * correspond to a given output subtitle. */ export function findOverlappingEntries( transcription: NeonTranscriptionEntry[], startMs: number, endMs: number, ): NeonTranscriptionEntry[] { return transcription.filter( (t) => t.startTimeMs < endMs && t.endTimeMs > startMs, ); } /** * Build a formatted source text block for a given time range. * Shows all overlapping Neon entries with speaker labels. */ export function getSourceTextForTimeRange( transcription: NeonTranscriptionEntry[], startMs: number, endMs: number, ): string { const entries = findOverlappingEntries(transcription, startMs, endMs); if (entries.length === 0) return ""; return entries .map((e) => { const speaker = e.speakerLabel ? `[${e.speakerLabel}] ` : ""; const type = e.metadataType !== "Dialogue" ? ` (${e.metadataType})` : ""; return `${speaker}${e.content}${type}`; }) .join("\n"); }