Files
jhonny-editor/bm/premiere-pro-mcp-main/src/ai/caption-timing.ts
T
João Henrique b541f502ba feat: initial commit - Jhonny Editor
- Adicionado estrutura completa do projeto
- Configurado MCP server para Premiere Pro
- Adicionado documentação e skills
- Configurado Gitignore para o projeto
2026-09-08 09:59:31 -04:00

263 lines
11 KiB
TypeScript
Executable File

import { createHash } from "node:crypto";
export const MAX_CAPTION_ARTIFACT_CHARACTERS = 750_000;
export const MAX_CAPTION_CUES = 10_000;
export const DEFAULT_CAPTION_TIMING_TOLERANCE_SECONDS = 0.25;
export interface CaptionCue {
startSeconds: number;
endSeconds: number;
}
export interface CaptionTimingOptions {
targetDurationSeconds?: number;
observedOffsetSeconds?: number;
allowProportionalScaling?: boolean;
timingToleranceSeconds?: number;
}
export interface CaptionTimingSample {
position: "beginning" | "middle" | "end";
cueNumber: number;
before: { startSeconds: number; endSeconds: number };
after?: { startSeconds: number; endSeconds: number };
}
export interface CaptionTimingPlan {
schemaVersion: 1;
planId: string;
artifactFormat: "srt" | "vtt";
cueCount: number;
timeline: {
firstStartSeconds: number;
lastEndSeconds: number;
captionSpanSeconds: number;
};
status: "aligned" | "constant_offset" | "proportional_drift" | "review_required";
correction: {
kind: "none" | "shift" | "scale" | "shift_then_scale";
shiftSeconds: number;
scale: number;
proposed: boolean;
reason: string;
};
targetDurationSeconds?: number;
observedOffsetSeconds?: number;
samples: CaptionTimingSample[];
applied: false;
verificationBoundary: string;
}
function boundedFiniteNumber(
value: unknown,
label: string,
minimum: number,
maximum: number,
fallback?: number,
): number | undefined {
if (value === undefined) return fallback;
if (typeof value !== "number" || !Number.isFinite(value) || value < minimum || value > maximum) {
throw new Error(`${label} must be a finite number from ${minimum} through ${maximum}`);
}
return value;
}
function roundSeconds(value: number): number {
return Number(value.toFixed(3));
}
function parseTimecode(value: string, cueNumber: number): number {
const match = /^(?:(\d{2,}):)?(\d{2}):(\d{2})(?:[,.](\d{1,3}))?$/.exec(value.trim());
if (!match) throw new Error(`Cue ${cueNumber} has an invalid timecode`);
const hours = Number(match[1] ?? 0);
const minutes = Number(match[2]);
const seconds = Number(match[3]);
const milliseconds = Number((match[4] ?? "").padEnd(3, "0") || 0);
if (minutes > 59 || seconds > 59) throw new Error(`Cue ${cueNumber} has an invalid timecode`);
return hours * 3600 + minutes * 60 + seconds + milliseconds / 1000;
}
function timingLine(block: string, blockNumber: number): { startSeconds: number; endSeconds: number } | undefined {
const line = block.split("\n").find((entry) => entry.includes("-->"));
if (!line) return undefined;
const match = /^\s*(\S+)\s+-->\s+(\S+)(?:\s+.*)?$/.exec(line);
if (!match) throw new Error(`Caption block ${blockNumber} has an invalid timing line`);
const startSeconds = parseTimecode(match[1], blockNumber);
const endSeconds = parseTimecode(match[2], blockNumber);
if (endSeconds <= startSeconds) throw new Error(`Cue ${blockNumber} must end after it starts`);
return { startSeconds, endSeconds };
}
export function parseCaptionArtifact(content: unknown, artifactFormat: unknown): { format: "srt" | "vtt"; cues: CaptionCue[] } {
if (typeof content !== "string" || !content.trim()) throw new Error("caption_content is required");
if (content.length > MAX_CAPTION_ARTIFACT_CHARACTERS) {
throw new Error(`caption_content exceeds ${MAX_CAPTION_ARTIFACT_CHARACTERS} characters`);
}
if (artifactFormat !== "srt" && artifactFormat !== "vtt") {
throw new Error("artifact_format must be either srt or vtt");
}
const normalized = content.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n").trim();
if (artifactFormat === "vtt" && !/^WEBVTT(?:\s|$)/.test(normalized)) {
throw new Error("A VTT artifact must begin with WEBVTT");
}
const cues: CaptionCue[] = [];
const blocks = normalized.split(/\n{2,}/);
for (let index = 0; index < blocks.length; index++) {
const block = blocks[index].trim();
if (!block || /^WEBVTT(?:\s|$)/.test(block) || /^(NOTE|STYLE|REGION)(?:\s|$)/.test(block)) continue;
const timing = timingLine(block, index + 1);
if (!timing) continue;
const prior = cues.at(-1);
if (prior && timing.startSeconds < prior.endSeconds) {
throw new Error(`Cue ${index + 1} overlaps the preceding cue`);
}
cues.push(timing);
if (cues.length > MAX_CAPTION_CUES) throw new Error(`Caption artifact exceeds ${MAX_CAPTION_CUES} cues`);
}
if (!cues.length) throw new Error("Caption artifact contains no timed cues");
return { format: artifactFormat, cues };
}
function sampleIndexes(length: number): Array<{ position: CaptionTimingSample["position"]; index: number }> {
const candidates: Array<{ position: CaptionTimingSample["position"]; index: number }> = [
{ position: "beginning", index: 0 },
{ position: "middle", index: Math.floor((length - 1) / 2) },
{ position: "end", index: length - 1 },
];
const seen = new Set<number>();
return candidates.filter(({ index }) => {
if (seen.has(index)) return false;
seen.add(index);
return true;
});
}
function validTransformedRange(cues: CaptionCue[], shiftSeconds: number, scale: number): boolean {
return cues.every((cue) => {
const start = (cue.startSeconds + shiftSeconds) * scale;
const end = (cue.endSeconds + shiftSeconds) * scale;
return Number.isFinite(start) && Number.isFinite(end) && start >= 0 && end > start;
});
}
function planId(format: "srt" | "vtt", cues: CaptionCue[], options: CaptionTimingOptions): string {
const source = JSON.stringify({ format, cues, options });
return `caption-plan-${createHash("sha256").update(source).digest("hex").slice(0, 24)}`;
}
/**
* Create a review-only timing proposal. The caller owns the caption artifact;
* this function intentionally never writes it, reaches Premiere, or treats a
* timing model as proof of subtitle readability.
*/
export function buildCaptionTimingPlan(
content: unknown,
artifactFormat: unknown,
options: CaptionTimingOptions = {},
): CaptionTimingPlan {
const parsed = parseCaptionArtifact(content, artifactFormat);
const targetDurationSeconds = boundedFiniteNumber(options.targetDurationSeconds, "target_duration_seconds", 0.001, 604_800);
const observedOffsetSeconds = boundedFiniteNumber(options.observedOffsetSeconds, "observed_offset_seconds", -86_400, 86_400);
const tolerance = boundedFiniteNumber(
options.timingToleranceSeconds,
"timing_tolerance_seconds",
0.001,
10,
DEFAULT_CAPTION_TIMING_TOLERANCE_SECONDS,
)!;
if (options.allowProportionalScaling !== undefined && typeof options.allowProportionalScaling !== "boolean") {
throw new Error("allow_proportional_scaling must be a boolean");
}
const firstStartSeconds = parsed.cues[0].startSeconds;
const lastEndSeconds = parsed.cues.at(-1)!.endSeconds;
let status: CaptionTimingPlan["status"] = "aligned";
let shiftSeconds = 0;
let scale = 1;
let kind: CaptionTimingPlan["correction"]["kind"] = "none";
let proposed = false;
let reason = "The artifact has no requested correction.";
const hasObservedOffset = observedOffsetSeconds !== undefined && Math.abs(observedOffsetSeconds) > tolerance;
if (hasObservedOffset) {
shiftSeconds = -observedOffsetSeconds!;
if (!validTransformedRange(parsed.cues, shiftSeconds, 1)) {
status = "review_required";
shiftSeconds = 0;
reason = "The observed offset would move at least one cue before zero; review the source timing manually.";
} else {
status = "constant_offset";
kind = "shift";
proposed = true;
reason = "The caller supplied an observed constant synchronization offset; this preview shifts every cue by the inverse offset.";
}
}
const endAfterShift = (lastEndSeconds + shiftSeconds) * scale;
const durationDifference = targetDurationSeconds === undefined ? 0 : targetDurationSeconds - endAfterShift;
if (targetDurationSeconds !== undefined && Math.abs(durationDifference) > tolerance) {
if (options.allowProportionalScaling !== true) {
if (!proposed) status = "review_required";
reason = `${reason} The requested target duration differs by ${roundSeconds(durationDifference)} seconds; proportional scaling was not authorized.`;
} else if (firstStartSeconds + shiftSeconds > tolerance) {
status = "review_required";
shiftSeconds = 0;
scale = 1;
kind = "none";
proposed = false;
reason = "The first cue is not anchored near zero, so duration scaling could change intentional lead-in timing; review manually.";
} else {
const candidateScale = targetDurationSeconds / (lastEndSeconds + shiftSeconds);
if (!Number.isFinite(candidateScale) || candidateScale <= 0 || candidateScale < 0.5 || candidateScale > 2 || !validTransformedRange(parsed.cues, shiftSeconds, candidateScale)) {
status = "review_required";
shiftSeconds = 0;
scale = 1;
kind = "none";
proposed = false;
reason = "The requested duration would require an unsafe or implausible proportional timing scale; review manually.";
} else {
scale = candidateScale;
status = "proportional_drift";
kind = Math.abs(shiftSeconds) > 0 ? "shift_then_scale" : "scale";
proposed = true;
reason = "The caller authorized a bounded proportional timing preview to match the supplied target duration.";
}
}
}
const samples = sampleIndexes(parsed.cues.length).map(({ position, index }) => {
const cue = parsed.cues[index];
const before = { startSeconds: roundSeconds(cue.startSeconds), endSeconds: roundSeconds(cue.endSeconds) };
return {
position,
cueNumber: index + 1,
before,
...(proposed ? {
after: {
startSeconds: roundSeconds((cue.startSeconds + shiftSeconds) * scale),
endSeconds: roundSeconds((cue.endSeconds + shiftSeconds) * scale),
},
} : {}),
};
});
return {
schemaVersion: 1,
planId: planId(parsed.format, parsed.cues, options),
artifactFormat: parsed.format,
cueCount: parsed.cues.length,
timeline: {
firstStartSeconds: roundSeconds(firstStartSeconds),
lastEndSeconds: roundSeconds(lastEndSeconds),
captionSpanSeconds: roundSeconds(lastEndSeconds - firstStartSeconds),
},
status,
correction: { kind, shiftSeconds: roundSeconds(shiftSeconds), scale: Number(scale.toFixed(9)), proposed, reason },
...(targetDurationSeconds === undefined ? {} : { targetDurationSeconds: roundSeconds(targetDurationSeconds) }),
...(observedOffsetSeconds === undefined ? {} : { observedOffsetSeconds: roundSeconds(observedOffsetSeconds) }),
samples,
applied: false,
verificationBoundary: "This is a local artifact-timing preview only. It does not modify an SRT/VTT file, import captions, contact Premiere, prove caption-track structure, playback synchronization, readability, or rendered output.",
};
}