import type { TimelineItem, TimelineState, TrackId } from '../editor/types';
import type { EditorCommands } from '../editor/store';
import { itemById, serializeTimeline, type Row, type SegRow, type SilenceRow } from './serialize';
import { parseScript, type ParsedRun, type ParsedSegRow, type ParsedSilenceRow } from './parse';
import { hasOperationalTranscript } from '../transcript/types';
type Cmds = Pick<EditorCommands, 'deleteWords' | 'toggleWord' | 'removeItem' | 'moveItem' | 'setGapCap' | 'reorderTrackItems'>;
export interface ApplyScriptOptions { trackId?: TrackId }
export interface ApplyResult {
ok: true;
removed: string[];
changes: string[];
}
const norm = (s: string): string => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
function consumeRun(words: { text: string }[], wi: number, run: ParsedRun, line: number): number {
const target = norm(run.text);
if (!target) return wi;
let acc = '';
let i = wi;
while (i < words.length && acc.length < target.length) {
acc += norm(words[i].text);
i++;
}
if (acc !== target) {
throw new Error(`timeline.md 第 ${line} 行: 文本与源口播不匹配("${run.text.slice(0, 20)}")——只能删词/恢复词,不能改写口播`);
}
return i;
}
interface WordPlan { item: TimelineItem; toDelete: number[]; toRestore: number[]; removeWhole: boolean }
function planSegRows(item: TimelineItem, canonRows: SegRow[], parsedRows: ParsedSegRow[]): WordPlan {
if (!hasOperationalTranscript(item)) throw new Error(`「${item.name}」的转写已失效,请重新转写后再应用脚本编辑`);
const words = item.transcript!;
const currentDeleted = new Set(item.deletedWordIdx ?? []);
const bySn = new Map<number, ParsedSegRow>();
for (const r of parsedRows) {
if (r.occurrence !== undefined) throw new Error(`timeline.md 第 ${r.line} 行: [s${r.sn}@${r.occurrence}] 重复占位暂不支持`);
if (bySn.has(r.sn)) throw new Error(`timeline.md 第 ${r.line} 行: [s${r.sn}] 出现两次(重放暂不支持)`);
bySn.set(r.sn, r);
}
const knownSns = new Set(canonRows.map((r) => r.sn));
for (const r of parsedRows) {
if (!knownSns.has(r.sn)) throw new Error(`timeline.md 第 ${r.line} 行: [s${r.sn}] 不在当前时间线上(新增/重放暂不支持)`);
}
const parsedOrder = parsedRows.map((r) => r.sn);
const canonOrder = canonRows.map((r) => r.sn).filter((sn) => bySn.has(sn));
if (parsedOrder.join(',') !== canonOrder.join(',')) {
throw new Error(`「${item.name}」: 段落重排暂不支持(可整体移动素材行,不能在素材内部调换 [sN] 顺序)`);
}
const desiredDeleted = new Set<number>();
for (const canon of canonRows) {
const parsed = bySn.get(canon.sn);
const segWords = canon.wordGis.map((gi) => words[gi]);
if (!parsed || parsed.struck) {
for (const gi of canon.wordGis) desiredDeleted.add(gi);
continue;
}
const consumed = new Set<number>();
let wi = 0;
for (const run of parsed.runs) {
const start = wi;
wi = consumeRun(segWords, wi, run, parsed.line);
if (!run.struck) for (let k = start; k < wi; k++) consumed.add(k);
}
canon.wordGis.forEach((gi, k) => {
if (!consumed.has(k)) desiredDeleted.add(gi);
});
}
for (const gi of currentDeleted) {
const inCanon = canonRows.some((r) => r.wordGis.includes(gi));
if (!inCanon) desiredDeleted.add(gi);
}
const removeWhole = desiredDeleted.size >= words.length;
const toDelete = [...desiredDeleted].filter((gi) => !currentDeleted.has(gi));
const toRestore = [...currentDeleted].filter((gi) => !desiredDeleted.has(gi));
return { item, toDelete, toRestore, removeWhole };
}
* Plans everything first; only dispatches when the whole script is valid. */
export function applyScript(getState: () => TimelineState, commands: Cmds, md: string, options: ApplyScriptOptions = {}): ApplyResult {
const base = getState();
const parsed = parseScript(md);
if (options.trackId && parsed.trackId && options.trackId !== parsed.trackId) {
throw new Error('timeline.md 的轨道作用域与 apply_script 指定轨道不一致');
}
const trackId = options.trackId ?? parsed.trackId ?? undefined;
const { model, stamp } = serializeTimeline(base, { trackId, showSilence: parsed.showSilence });
if (!parsed.stamp) throw new Error('缺少 script-stamp 注释——请保留 read_script 输出顶部的注释行');
if (parsed.stamp !== stamp) throw new Error('时间线已被外部修改(stale)——请重新 read_script 后再改');
const items = itemById(base);
const removed: string[] = [];
const changes: string[] = [];
const wordPlans: WordPlan[] = [];
const silencePlans: { row: SilenceRow; maxMs: number | null }[] = [];
const removeIds: string[] = [];
type Token = { kind: 'gap'; frames: number } | { kind: 'item'; id: string };
const repack: { track: string; tokens: Token[] }[] = [];
for (const canonTrack of model) {
const parsedTrack = parsed.tracks.find((t) => t.track === canonTrack.track);
if (!parsedTrack) throw new Error(`缺少 ## ${canonTrack.track} 轨道段——删空轨道请显式删除行,不要删掉整段`);
const canonSegByItem = new Map<string, SegRow[]>();
const clipByKey = new Map<string, Row & { kind: 'clip' }>();
const silenceBySource = new Map<string, SilenceRow[]>();
for (const region of canonTrack.regions) {
for (const row of region.rows) {
if (row.kind === 'seg') {
const list = canonSegByItem.get(row.itemId) ?? [];
list.push(row);
canonSegByItem.set(row.itemId, list);
} else if (row.kind === 'clip') {
clipByKey.set(`${region.source}#c${row.cn}`, row);
} else if (row.kind === 'silence') {
const list = silenceBySource.get(region.source) ?? [];
list.push(row);
silenceBySource.set(region.source, list);
}
}
}
const transcriptItemBySource = new Map<string, string>();
for (const [itemId] of canonSegByItem) {
const it = items.get(itemId)!;
if (transcriptItemBySource.has(it.name)) throw new Error(`同轨有两个同名口播素材「${it.name}」——v1 暂不支持(先重命名素材)`);
transcriptItemBySource.set(it.name, itemId);
}
const tokens: Token[] = [];
const seenItems = new Set<string>();
const parsedSegByItem = new Map<string, ParsedSegRow[]>();
for (const region of parsedTrack.regions) {
if (parsed.showSilence) {
const canonical = silenceBySource.get(region.source) ?? [];
const edited = region.rows.filter((row): row is ParsedSilenceRow => row.kind === 'silence');
if (edited.length !== canonical.length) {
throw new Error(`「${region.source}」: silence 标记数量已改变——请保留标记并用 ~~...~~ 删除或用 → 压缩`);
}
edited.forEach((row, index) => {
const canon = canonical[index]!;
if (Math.abs(row.originalMs - canon.originalMs) > 1) {
throw new Error(`timeline.md 第 ${row.line} 行: silence 原始时长与源口播不一致`);
}
const desired = row.struck ? 0 : row.targetMs;
if (desired === undefined) {
if (canon.appliedMs !== canon.originalMs) silencePlans.push({ row: canon, maxMs: null });
} else if (Math.abs(desired - canon.appliedMs) > 1) {
silencePlans.push({ row: canon, maxMs: Math.min(canon.originalMs, Math.max(0, desired)) });
}
});
}
for (const row of region.rows) {
if (row.kind === 'gap') {
if (!row.struck) tokens.push({ kind: 'gap', frames: row.frames });
else removed.push(`[gap ${row.frames}f]`);
} else if (row.kind === 'clip') {
const canon = clipByKey.get(`${region.source}#c${row.cn}`);
if (!canon) throw new Error(`timeline.md 第 ${row.line} 行: [c${row.cn}] 不在「${region.source}」下(新增片段请用 add 工具)`);
if (row.struck) {
removeIds.push(canon.itemId);
removed.push(`${region.source} [c${row.cn}]`);
} else if (!seenItems.has(canon.itemId)) {
seenItems.add(canon.itemId);
tokens.push({ kind: 'item', id: canon.itemId });
}
} else if (row.kind === 'seg') {
const itemId = transcriptItemBySource.get(region.source);
if (!itemId) throw new Error(`timeline.md 第 ${row.line} 行: 「${region.source}」不是本轨的口播素材`);
const list = parsedSegByItem.get(itemId) ?? [];
list.push(row);
parsedSegByItem.set(itemId, list);
if (!row.struck && !seenItems.has(itemId)) {
seenItems.add(itemId);
tokens.push({ kind: 'item', id: itemId });
}
}
}
}
for (const [itemId, canonRows] of canonSegByItem) {
const plan = planSegRows(items.get(itemId)!, canonRows, parsedSegByItem.get(itemId) ?? []);
if (plan.removeWhole) {
removeIds.push(itemId);
removed.push(`${plan.item.name}(整段口播)`);
} else {
if (plan.toDelete.length) changes.push(`${plan.item.name}: 删 ${plan.toDelete.length} 词`);
if (plan.toRestore.length) changes.push(`${plan.item.name}: 恢复 ${plan.toRestore.length} 词`);
wordPlans.push(plan);
}
}
for (const [key, row] of clipByKey) {
const represented = parsedTrack.regions.some((rg) => rg.rows.some((r) => r.kind === 'clip' && `${rg.source}#c${r.cn}` === key));
if (!represented && !removeIds.includes(row.itemId)) {
removeIds.push(row.itemId);
removed.push(key.replace('#', ' '));
}
}
repack.push({ track: canonTrack.track, tokens: tokens.filter((t) => t.kind === 'gap' || !removeIds.includes(t.id)) });
}
for (const plan of wordPlans) {
if (plan.toDelete.length) commands.deleteWords(plan.item.id, plan.toDelete);
for (const gi of plan.toRestore) commands.toggleWord(plan.item.id, gi);
}
for (const plan of silencePlans) {
commands.setGapCap(plan.row.itemId, plan.row.afterWordIndex, plan.maxMs);
changes.push(`${items.get(plan.row.itemId)?.name ?? plan.row.itemId}: 调整停顿`);
}
for (const id of removeIds) commands.removeItem(id);
for (const { track, tokens } of repack) {
const orderedIds: string[] = [];
const starts: Record<string, number> = {};
let cursor = 0;
for (const tok of tokens) {
if (tok.kind === 'gap') {
cursor += tok.frames;
continue;
}
const live = getState().items.find((it) => it.id === tok.id);
if (!live) continue;
orderedIds.push(tok.id);
if (live.startFrame !== cursor) {
starts[tok.id] = cursor;
changes.push(`${live.name}: 移到 ${cursor}f`);
}
cursor += live.durationInFrames;
}
if (orderedIds.length >= 2 && Object.keys(starts).length) {
commands.reorderTrackItems(track, orderedIds, starts);
} else if (orderedIds.length === 1 && starts[orderedIds[0]!] !== undefined) {
commands.moveItem(orderedIds[0]!, { startFrame: starts[orderedIds[0]!]! });
}
}
return { ok: true, removed, changes };
}