gts-x / src /lib /editor /nli /executors.ts
JAMES HAN
GTS-X: Full HITL editor + QC + file converter for HuggingFace Spaces
c273232
Raw
History Blame Contribute Delete
7.19 kB
/**
* 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,
};
}