Spaces:
Sleeping
Sleeping
| /** | |
| * 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<string, unknown>; | |
| } | |
| 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<string, unknown>, | |
| 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<string, unknown>, | |
| 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<string, unknown>, | |
| 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<string, unknown>, | |
| 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<string, unknown>): 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<string, unknown>): 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, | |
| }; | |
| } | |