gts-x / src /lib /editor /use-editor.ts
JAMES HAN
GTS-X: Full HITL editor + QC + file converter for HuggingFace Spaces
c273232
Raw
History Blame Contribute Delete
12.9 kB
"use client";
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
import {
EditorEventBus,
type SubtitleData,
type EditorEventType,
} from "./event-bus";
import {
validateSubtitle,
type ValidationIssue,
type ValidationConfig,
DEFAULT_VALIDATION,
} from "./validation";
export interface UseEditorOptions {
initialSubtitles: SubtitleData[];
jobId: string;
resourceId?: string;
editSessionId?: string;
validationConfig?: ValidationConfig;
fps?: number;
autoSaveDelayMs?: number;
}
export type ValidationState = "none" | "validating" | "validated" | "reviewing";
export interface UseEditorReturn {
subtitles: SubtitleData[];
selectedIndex: number;
setSelectedIndex: (index: number) => void;
// Edit operations
setText: (index: number, value: string) => void;
setTimecodeIn: (index: number, value: string) => void;
setTimecodeOut: (index: number, value: string) => void;
setSpeaker: (index: number, value: string) => void;
setComment: (index: number, value: string) => void;
insertSubtitle: (atIndex: number) => void;
deleteSubtitle: (index: number) => void;
splitSubtitle: (index: number, splitPoint: number) => void;
mergeWithNext: (index: number) => void;
// Undo/redo
undo: () => void;
redo: () => void;
canUndo: boolean;
canRedo: boolean;
// Validation
validationIssues: Map<number, ValidationIssue[]>;
totalErrors: number;
totalWarnings: number;
// Save state
hasUnsavedChanges: boolean;
isSaving: boolean;
lastSavedAt: Date | null;
saveNow: () => Promise<void>;
// Rationale tracking (for diff capture)
pendingRationales: Map<number, Map<string, string>>;
setRationale: (index: number, field: string, rationale: string) => void;
// Completion workflow
editedIndices: Set<number>;
editedIndicesWithoutRationale: Set<number>;
canValidate: boolean;
validationState: ValidationState;
setValidationState: (state: ValidationState) => void;
// Event bus (for NLI integration)
eventBus: EditorEventBus;
}
export function useEditor(options: UseEditorOptions): UseEditorReturn {
const {
initialSubtitles,
jobId,
resourceId,
editSessionId,
validationConfig = DEFAULT_VALIDATION,
fps = 23.976,
autoSaveDelayMs = 5000,
} = options;
const eventBusRef = useRef<EditorEventBus>(
new EditorEventBus(initialSubtitles)
);
const eventBus = eventBusRef.current;
const [subtitles, setSubtitles] = useState<SubtitleData[]>(
() => eventBus.getSubtitles()
);
const [selectedIndex, setSelectedIndex] = useState(0);
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null);
const [pendingRationales, setPendingRationales] = useState<
Map<number, Map<string, string>>
>(new Map());
const initialSubtitlesRef = useRef<SubtitleData[]>(structuredClone(initialSubtitles));
const [validationState, setValidationState] = useState<ValidationState>("none");
const saveTimerRef = useRef<ReturnType<typeof setTimeout>>();
// Subscribe to event bus changes
useEffect(() => {
const unsubscribe = eventBus.subscribe(() => {
setSubtitles([...eventBus.getSubtitles()]);
setCanUndo(eventBus.canUndo());
setCanRedo(eventBus.canRedo());
setHasUnsavedChanges(eventBus.hasUnsavedChanges());
// If ANY edit happens after validation, reset the cycle
if (validationState !== "none") {
setValidationState("none");
}
});
return unsubscribe;
}, [eventBus, validationState]);
// Compute editedIndices by diffing current subtitles against initial state
// If undo reverts a subtitle to its original, it drops out of this set
const editedIndices = useMemo(() => {
const edited = new Set<number>();
const initial = initialSubtitlesRef.current;
const current = subtitles;
const len = Math.max(initial.length, current.length);
for (let i = 0; i < len; i++) {
const orig = initial[i];
const curr = current[i];
if (!orig || !curr) { edited.add(i); continue; }
if (orig.text !== curr.text || orig.tcIn !== curr.tcIn || orig.tcOut !== curr.tcOut ||
orig.speaker !== curr.speaker || orig.comment !== curr.comment) {
edited.add(i);
}
}
return edited;
}, [subtitles]);
// Validation
const validationIssues = useMemo(() => {
const issues = new Map<number, ValidationIssue[]>();
for (let i = 0; i < subtitles.length; i++) {
const subIssues = validateSubtitle(
subtitles[i],
i,
subtitles,
validationConfig,
fps
);
if (subIssues.length > 0) {
issues.set(i, subIssues);
}
}
return issues;
}, [subtitles, validationConfig, fps]);
const totalErrors = useMemo(() => {
let count = 0;
validationIssues.forEach((issues) => {
count += issues.filter((i) => i.severity === "error").length;
});
return count;
}, [validationIssues]);
const totalWarnings = useMemo(() => {
let count = 0;
validationIssues.forEach((issues) => {
count += issues.filter((i) => i.severity === "warning").length;
});
return count;
}, [validationIssues]);
// Completion workflow: which edited subtitles are missing rationale
const editedIndicesWithoutRationale = useMemo(() => {
const missing = new Set<number>();
for (const idx of editedIndices) {
const ratMap = pendingRationales.get(idx);
if (!ratMap || ratMap.size === 0) {
missing.add(idx);
} else {
// Check if at least one rationale entry is non-empty
const hasValue = [...ratMap.values()].some((v) => v.trim().length > 0);
if (!hasValue) missing.add(idx);
}
}
return missing;
}, [editedIndices, pendingRationales]);
// Validate is always available — missing rationales are surfaced as errors during validation, not as a blocker
const canValidate = true;
// Save function
const saveNow = useCallback(async () => {
if (!eventBus.hasUnsavedChanges()) return;
setIsSaving(true);
try {
const changes = eventBus.flushChanges();
// Build diffs from changes + rationales
const fieldMap: Record<string, string> = {
setText: "text",
setTimecodeIn: "tc_in",
setTimecodeOut: "tc_out",
setSpeaker: "speaker",
setComment: "comment",
setPosition: "position",
setStyle: "style",
};
const categoryMap: Record<string, string> = {
setText: "translation_fix",
setTimecodeIn: "timing_fix",
setTimecodeOut: "timing_fix",
setSpeaker: "other",
setComment: "other",
setPosition: "style_correction",
setStyle: "style_correction",
splitSubtitle: "split_merge",
mergeSubtitles: "split_merge",
};
// Deduplicate: per (subtitleIndex + field), keep first oldValue and last newValue
const diffMap = new Map<
string,
{
subtitleIndex: number;
field: string;
oldValue: string | null;
newValue: string | null;
changeCategory: string;
}
>();
for (const c of changes) {
const field = fieldMap[c.type];
if (!field) continue;
const key = `${c.target}:${field}`;
const existing = diffMap.get(key);
const newValue =
c.payload.value != null ? String(c.payload.value) : null;
if (existing) {
// Keep the original oldValue, update newValue to latest
existing.newValue = newValue;
} else {
diffMap.set(key, {
subtitleIndex: c.target,
field,
oldValue:
c.previousValue != null ? String(c.previousValue) : null,
newValue,
changeCategory: categoryMap[c.type] || "other",
});
}
}
// Attach rationales and filter out no-ops
const diffs = Array.from(diffMap.values())
.filter((d) => d.oldValue !== d.newValue) // Skip no-ops
.map((d) => ({
...d,
rationale:
pendingRationales.get(d.subtitleIndex)?.get(d.field) ||
pendingRationales.get(d.subtitleIndex)?.values().next().value ||
"Edit applied",
}));
// Build GTSDocument from current subtitles
const document = {
tracks: [
{
subtitles: subtitles.map((s) => ({
tcIn: s.tcIn,
tcOut: s.tcOut,
text: s.text,
speaker: s.speaker,
comment: s.comment,
position: s.position,
style: s.style,
})),
},
],
};
await fetch(`/api/jobs/${jobId}/document`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
document,
diffs: diffs.length > 0 ? diffs : undefined,
resourceId,
editSessionId,
}),
});
setLastSavedAt(new Date());
setHasUnsavedChanges(false);
// Keep rationales visible — they persist until job is submitted/locked
// The diffs already captured them server-side
} catch (err) {
console.error("Save failed:", err);
} finally {
setIsSaving(false);
}
}, [
eventBus,
subtitles,
jobId,
resourceId,
editSessionId,
pendingRationales,
]);
// Auto-save
useEffect(() => {
if (hasUnsavedChanges) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(saveNow, autoSaveDelayMs);
}
return () => clearTimeout(saveTimerRef.current);
}, [hasUnsavedChanges, saveNow, autoSaveDelayMs]);
// Edit operations
const setText = useCallback(
(index: number, value: string) =>
eventBus.dispatch({ type: "setText", target: index, payload: { value } }),
[eventBus]
);
const setTimecodeIn = useCallback(
(index: number, value: string) =>
eventBus.dispatch({
type: "setTimecodeIn",
target: index,
payload: { value },
}),
[eventBus]
);
const setTimecodeOut = useCallback(
(index: number, value: string) =>
eventBus.dispatch({
type: "setTimecodeOut",
target: index,
payload: { value },
}),
[eventBus]
);
const setSpeaker = useCallback(
(index: number, value: string) =>
eventBus.dispatch({
type: "setSpeaker",
target: index,
payload: { value },
}),
[eventBus]
);
const setComment = useCallback(
(index: number, value: string) =>
eventBus.dispatch({
type: "setComment",
target: index,
payload: { value },
}),
[eventBus]
);
const insertSubtitle = useCallback(
(atIndex: number) =>
eventBus.dispatch({
type: "insertSubtitle",
target: atIndex,
payload: {},
}),
[eventBus]
);
const deleteSubtitle = useCallback(
(index: number) =>
eventBus.dispatch({
type: "deleteSubtitle",
target: index,
payload: {},
}),
[eventBus]
);
const splitSubtitle = useCallback(
(index: number, splitPoint: number) =>
eventBus.dispatch({
type: "splitSubtitle",
target: index,
payload: { splitPoint },
}),
[eventBus]
);
const mergeWithNext = useCallback(
(index: number) => {
const next = eventBus.getSubtitle(index + 1);
eventBus.dispatch({
type: "mergeSubtitles",
target: index,
payload: { mergedSubtitle: next },
});
},
[eventBus]
);
const undo = useCallback(() => eventBus.undo(), [eventBus]);
const redo = useCallback(() => eventBus.redo(), [eventBus]);
const setRationale = useCallback(
(index: number, field: string, rationale: string) => {
setPendingRationales((prev) => {
const next = new Map(prev);
const fieldMap = next.get(index) || new Map<string, string>();
fieldMap.set(field, rationale);
next.set(index, fieldMap);
return next;
});
},
[]
);
return {
subtitles,
selectedIndex,
setSelectedIndex,
setText,
setTimecodeIn,
setTimecodeOut,
setSpeaker,
setComment,
insertSubtitle,
deleteSubtitle,
splitSubtitle,
mergeWithNext,
undo,
redo,
canUndo,
canRedo,
validationIssues,
totalErrors,
totalWarnings,
hasUnsavedChanges,
isSaving,
lastSavedAt,
saveNow,
pendingRationales,
setRationale,
editedIndices,
editedIndicesWithoutRationale,
canValidate,
validationState,
setValidationState,
eventBus,
};
}