/** * NLI Tool Call Executors — Convert tool call payloads into EditorEventBus dispatch calls. */ import type { SubtitleData, EditorEventType } from "../event-bus"; import { tcToMs } from "../validation"; import type { NliToolCall } from "./engine"; export interface EventDispatch { type: EditorEventType; target: number; payload: Record; } export interface ExecutionResult { events: EventDispatch[]; summary: string; affectedCount: number; } function msToTimecode(ms: number, fps: number): string { const totalSec = Math.max(0, ms / 1000); const h = Math.floor(totalSec / 3600); const m = Math.floor((totalSec % 3600) / 60); const s = Math.floor(totalSec % 60); const f = Math.floor((totalSec % 1) * fps); return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}:${String(f).padStart(2, "0")}`; } export function executeToolCall( toolCall: NliToolCall, subtitles: SubtitleData[], fps: number, ): ExecutionResult { switch (toolCall.toolName) { case "shift_timecodes": return executeShiftTimecodes(toolCall.input, subtitles, fps); case "convert_fps": return executeConvertFps(toolCall.input, subtitles, fps); case "find_and_replace": return executeFindAndReplace(toolCall.input, subtitles); case "bulk_delete": return executeBulkDelete(toolCall.input, subtitles); case "edit_subtitles": return executeEditSubtitles(toolCall.input); case "set_speakers": return executeSetSpeakers(toolCall.input); default: return { events: [], summary: `Unknown tool: ${toolCall.toolName}`, affectedCount: 0 }; } } function executeShiftTimecodes( input: Record, subtitles: SubtitleData[], fps: number, ): ExecutionResult { const offsetFrames = (input.offset_frames as number) || 0; const offsetMs = (input.offset_ms as number) || Math.round((offsetFrames / fps) * 1000); const startIdx = (input.start_index as number) ?? 0; const endIdx = (input.end_index as number) ?? subtitles.length - 1; const edits: Array<{ index: number; field: string; value: string }> = []; for (let i = startIdx; i <= endIdx && i < subtitles.length; i++) { const sub = subtitles[i]; const newTcIn = msToTimecode(Math.max(0, tcToMs(sub.tcIn, fps) + offsetMs), fps); const newTcOut = msToTimecode(Math.max(0, tcToMs(sub.tcOut, fps) + offsetMs), fps); edits.push({ index: i, field: "tcIn", value: newTcIn }); edits.push({ index: i, field: "tcOut", value: newTcOut }); } const count = endIdx - startIdx + 1; return { events: [{ type: "batchEdit", target: 0, payload: { edits }, }], summary: `Shift ${count} subtitle${count !== 1 ? "s" : ""} by ${offsetFrames ? `${offsetFrames} frames` : `${offsetMs}ms`}`, affectedCount: count, }; } function executeConvertFps( input: Record, subtitles: SubtitleData[], fps: number, ): ExecutionResult { const fromFps = input.from_fps as number; const toFps = input.to_fps as number; const edits: Array<{ index: number; field: string; value: string }> = []; for (let i = 0; i < subtitles.length; i++) { const sub = subtitles[i]; const tcInMs = tcToMs(sub.tcIn, fromFps); const tcOutMs = tcToMs(sub.tcOut, fromFps); edits.push({ index: i, field: "tcIn", value: msToTimecode(tcInMs, toFps) }); edits.push({ index: i, field: "tcOut", value: msToTimecode(tcOutMs, toFps) }); } return { events: [{ type: "batchEdit", target: 0, payload: { edits }, }], summary: `Convert ${subtitles.length} subtitles from ${fromFps} to ${toFps} FPS`, affectedCount: subtitles.length, }; } function executeFindAndReplace( input: Record, subtitles: SubtitleData[], ): ExecutionResult { const findStr = input.find as string; const replaceStr = input.replace as string; const isRegex = (input.is_regex as boolean) || false; const caseSensitive = (input.case_sensitive as boolean) ?? true; const field = (input.field as string) || "text"; const flags = caseSensitive ? "g" : "gi"; const pattern = isRegex ? new RegExp(findStr, flags) : new RegExp(findStr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), flags); const edits: Array<{ index: number; field: string; value: string }> = []; for (let i = 0; i < subtitles.length; i++) { const sub = subtitles[i]; const original = field === "speaker" ? sub.speaker : sub.text; const replaced = original.replace(pattern, replaceStr); if (replaced !== original) { edits.push({ index: i, field, value: replaced }); } } return { events: edits.length > 0 ? [{ type: "batchEdit", target: 0, payload: { edits }, }] : [], summary: edits.length > 0 ? `Replaced "${findStr}" → "${replaceStr}" in ${edits.length} subtitle${edits.length !== 1 ? "s" : ""}` : `No matches found for "${findStr}"`, affectedCount: edits.length, }; } function executeBulkDelete( input: Record, subtitles: SubtitleData[], ): ExecutionResult { let indices: number[] = []; if (input.indices) { indices = (input.indices as number[]).filter((i) => i >= 0 && i < subtitles.length); } if (input.delete_empty) { for (let i = 0; i < subtitles.length; i++) { if (!subtitles[i].text.trim() && !indices.includes(i)) { indices.push(i); } } } // Sort descending so deletions don't shift indices indices.sort((a, b) => b - a); const events: EventDispatch[] = indices.map((i) => ({ type: "deleteSubtitle" as const, target: i, payload: {}, })); return { events, summary: `Delete ${indices.length} subtitle${indices.length !== 1 ? "s" : ""}`, affectedCount: indices.length, }; } function executeEditSubtitles(input: Record): ExecutionResult { const edits = input.edits as Array<{ index: number; new_text: string; reason?: string; }>; if (!edits || edits.length === 0) { return { events: [], summary: "No edits to apply", affectedCount: 0 }; } const batchEdits = edits.map((e) => ({ index: e.index, field: "text", value: e.new_text, })); return { events: [{ type: "batchEdit", target: 0, payload: { edits: batchEdits }, }], summary: `Edit text in ${edits.length} subtitle${edits.length !== 1 ? "s" : ""}`, affectedCount: edits.length, }; } function executeSetSpeakers(input: Record): ExecutionResult { const assignments = input.assignments as Array<{ index: number; speaker: string; }>; if (!assignments || assignments.length === 0) { return { events: [], summary: "No speaker changes", affectedCount: 0 }; } const batchEdits = assignments.map((a) => ({ index: a.index, field: "speaker", value: a.speaker, })); return { events: [{ type: "batchEdit", target: 0, payload: { edits: batchEdits }, }], summary: `Set speakers for ${assignments.length} subtitle${assignments.length !== 1 ? "s" : ""}`, affectedCount: assignments.length, }; }