Skip to content

Commit 686d669

Browse files
committed
feat(agent): generalize filler removal into removeWords
Any cut chosen by transcript word IDs (fillers, a flubbed phrase, a duplicate take) now goes through one tool. Words consecutive in the transcript become a single trim, so a phrase leaves no inter-word gaps playing.
1 parent ee08cea commit 686d669

6 files changed

Lines changed: 137 additions & 62 deletions

File tree

‎electron/ai-edition/agent-tools.test.ts‎

Lines changed: 58 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ describe("the mutating-tool table", () => {
183183
"removeClip",
184184
"removeModifier",
185185
"removeTrim",
186-
"removeFillerWords",
186+
"removeWords",
187187
"replaceTimeline",
188188
"setAnnotation",
189189
"setAudio",
@@ -2385,7 +2385,7 @@ describe("getTranscriptWords", () => {
23852385
});
23862386
});
23872387

2388-
describe("removeFillerWords", () => {
2388+
describe("removeWords", () => {
23892389
function repeatedWordDocument(): AxcutDocument {
23902390
const base = fixtureDocument();
23912391
return {
@@ -2409,7 +2409,7 @@ describe("removeFillerWords", () => {
24092409
"filler",
24102410
"meaningful",
24112411
]);
2412-
const result = run(before, "removeFillerWords", { wordIds: ["filler"] });
2412+
const result = run(before, "removeWords", { wordIds: ["filler"] });
24132413
expect(result.ok).toBe(true);
24142414
const next = result.document as AxcutDocument;
24152415
const trim = next.timeline.trimRanges.at(-1);
@@ -2438,6 +2438,52 @@ describe("removeFillerWords", () => {
24382438
expect(next.timeline.trimRanges).toHaveLength(before.timeline.trimRanges.length + 1);
24392439
});
24402440

2441+
it("cuts consecutive words as one span, gaps included, and the others apart", () => {
2442+
const base = fixtureDocument();
2443+
const before: AxcutDocument = {
2444+
...base,
2445+
transcripts: [
2446+
{
2447+
...base.transcripts[0],
2448+
words: [
2449+
{ id: "word_1", segmentId: "seg_1", startSec: 1, endSec: 1.2, text: "so" },
2450+
{ id: "word_2", segmentId: "seg_1", startSec: 1.4, endSec: 1.5, text: "I" },
2451+
{ id: "word_3", segmentId: "seg_1", startSec: 1.6, endSec: 1.9, text: "mean" },
2452+
{ id: "word_4", segmentId: "seg_1", startSec: 2.2, endSec: 2.4, text: "the" },
2453+
{ id: "word_5", segmentId: "seg_1", startSec: 3, endSec: 3.2, text: "um" },
2454+
],
2455+
},
2456+
],
2457+
};
2458+
const result = run(before, "removeWords", { wordIds: ["word_3", "word_5", "word_2"] });
2459+
expect(result.ok).toBe(true);
2460+
const added = result.document?.timeline.trimRanges.slice(before.timeline.trimRanges.length);
2461+
expect(added?.map(({ startSec, endSec }) => [startSec, endSec])).toEqual([
2462+
[1.4, 1.9],
2463+
[3, 3.2],
2464+
]);
2465+
const removed = JSON.parse(result.resultJson).removed;
2466+
expect(removed.map((word: { wordId: string }) => word.wordId)).toEqual([
2467+
"word_2",
2468+
"word_3",
2469+
"word_5",
2470+
]);
2471+
expect(removed[1].trimRangeId).toBe(removed[0].trimRangeId);
2472+
expect(removed[2].trimRangeId).not.toBe(removed[0].trimRangeId);
2473+
});
2474+
2475+
it("refuses a duplicated or already-cut word without cutting anything", () => {
2476+
const before = repeatedWordDocument();
2477+
const duplicated = run(before, "removeWords", { wordIds: ["filler", "filler"] });
2478+
expect(duplicated.ok).toBe(false);
2479+
expect(duplicated.document).toBeUndefined();
2480+
const cut = run(before, "removeWords", { wordIds: ["filler"] }).document as AxcutDocument;
2481+
const again = run(cut, "removeWords", { wordIds: ["meaningful", "filler"] });
2482+
expect(again.ok).toBe(false);
2483+
expect(again.document).toBeUndefined();
2484+
expect(again.resultJson).toContain("already cut");
2485+
});
2486+
24412487
it("preserves addTrim warnings when removing a filler cuts a zoom transition", () => {
24422488
const before = repeatedWordDocument();
24432489
before.timeline.trimRanges = [];
@@ -2452,14 +2498,14 @@ describe("removeFillerWords", () => {
24522498
});
24532499
const expected = JSON.parse(directTrim.resultJson).cutTransitions;
24542500
expect(expected.length).toBeGreaterThan(0);
2455-
const removed = run(zoomed, "removeFillerWords", { assetId: "asset_1", wordIds: ["filler"] });
2501+
const removed = run(zoomed, "removeWords", { assetId: "asset_1", wordIds: ["filler"] });
24562502
expect(removed.ok).toBe(true);
24572503
expect(JSON.parse(removed.resultJson).cutTransitions).toEqual(expected);
24582504
});
24592505

24602506
it("refuses an invalid ID atomically, including when another ID is valid", () => {
24612507
const before = repeatedWordDocument();
2462-
const result = run(before, "removeFillerWords", { wordIds: ["filler", "missing"] });
2508+
const result = run(before, "removeWords", { wordIds: ["filler", "missing"] });
24632509
expect(result.ok).toBe(false);
24642510
expect(result.document).toBeUndefined();
24652511
expect(result.resultJson).toContain("Unknown transcript word ID: missing");
@@ -2479,7 +2525,7 @@ describe("removeFillerWords", () => {
24792525
assetId: "asset_2",
24802526
words: [{ id: "other_filler", segmentId: "seg_1", startSec: 4, endSec: 4.2, text: "um" }],
24812527
});
2482-
const result = run(before, "removeFillerWords", { wordIds: ["other_filler"] });
2528+
const result = run(before, "removeWords", { wordIds: ["other_filler"] });
24832529
expect(result.ok).toBe(true);
24842530
expect(result.document?.timeline.trimRanges.at(-1)).toMatchObject({
24852531
assetId: "asset_2",
@@ -2503,7 +2549,7 @@ describe("removeFillerWords", () => {
25032549
});
25042550
const read = JSON.parse(run(before, "getTranscriptWords", { assetId: "asset_2" }).resultJson);
25052551
expect(read.words[0].id).toBe("word_1");
2506-
const result = run(before, "removeFillerWords", {
2552+
const result = run(before, "removeWords", {
25072553
assetId: "asset_2",
25082554
wordIds: [read.words[0].id],
25092555
});
@@ -2516,10 +2562,10 @@ describe("removeFillerWords", () => {
25162562
result.document?.timeline.trimRanges.filter((trim) => trim.assetId === "asset_1"),
25172563
).toEqual(before.timeline.trimRanges);
25182564
expect(result.document?.transcripts).toEqual(before.transcripts);
2519-
const ambiguous = run(before, "removeFillerWords", { wordIds: ["word_1"] });
2565+
const ambiguous = run(before, "removeWords", { wordIds: ["word_1"] });
25202566
expect(ambiguous.ok).toBe(false);
25212567
expect(ambiguous.document).toBeUndefined();
2522-
const unknownAsset = run(before, "removeFillerWords", {
2568+
const unknownAsset = run(before, "removeWords", {
25232569
assetId: "missing_asset",
25242570
wordIds: ["word_1"],
25252571
});
@@ -2530,7 +2576,7 @@ describe("removeFillerWords", () => {
25302576
it("refuses invalid timestamps without guessing a span", () => {
25312577
const before = repeatedWordDocument();
25322578
before.transcripts[0].words[0].endSec = Number.NaN;
2533-
const result = run(before, "removeFillerWords", { wordIds: ["filler"] });
2579+
const result = run(before, "removeWords", { wordIds: ["filler"] });
25342580
expect(result.ok).toBe(false);
25352581
expect(result.document).toBeUndefined();
25362582
expect(result.resultJson).toContain("invalid source timestamps");
@@ -2542,7 +2588,7 @@ describe("removeFillerWords", () => {
25422588
...before.timeline.clips[0],
25432589
id: "duplicate_clip",
25442590
});
2545-
const result = run(before, "removeFillerWords", { wordIds: ["filler"] });
2591+
const result = run(before, "removeWords", { wordIds: ["filler"] });
25462592
expect(result.ok).toBe(false);
25472593
expect(result.document).toBeUndefined();
25482594
expect(result.resultJson).toContain("exactly one clip; found 2");
@@ -2551,7 +2597,7 @@ describe("removeFillerWords", () => {
25512597
it("honours project edit consent", () => {
25522598
const result = executeAgentTool(
25532599
repeatedWordDocument(),
2554-
"removeFillerWords",
2600+
"removeWords",
25552601
JSON.stringify({ wordIds: ["filler"] }),
25562602
{ editsAllowed: false },
25572603
);

‎electron/ai-edition/agent-tools.ts‎

Lines changed: 63 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -425,7 +425,7 @@ export const addTrimsArgs = z.object({
425425
ranges: z.array(z.union([addTrimArgs, z.unknown()])).min(1),
426426
});
427427

428-
export const removeFillerWordsArgs = z.object({
428+
export const removeWordsArgs = z.object({
429429
assetId: z.string().min(1).optional(),
430430
wordIds: z.array(z.string().min(1)).min(1),
431431
});
@@ -625,7 +625,7 @@ export const OPENSCREEN_TOOL_NAMES = [
625625
"getTranscriptWords",
626626
"getCursorTrack",
627627
"setWordText",
628-
"removeFillerWords",
628+
"removeWords",
629629
"addTrim",
630630
"addTrims",
631631
"setTrim",
@@ -697,7 +697,7 @@ export const MUTATING_TOOL_NAMES: ReadonlySet<string> = new Set([
697697
// Writes the transcript, not the timeline — but it writes the document, so it is a
698698
// consented edit like any other.
699699
"setWordText",
700-
"removeFillerWords",
700+
"removeWords",
701701
"addTrim",
702702
"addTrims",
703703
"addZooms",
@@ -1527,8 +1527,8 @@ export function executeAgentTool(
15271527
};
15281528
}
15291529

1530-
case "removeFillerWords": {
1531-
const parsed = removeFillerWordsArgs.safeParse(args);
1530+
case "removeWords": {
1531+
const parsed = removeWordsArgs.safeParse(args);
15321532
if (!parsed.success) return failure(parsed.error.message);
15331533
const { assetId: selectedAssetId, wordIds } = parsed.data;
15341534
if (new Set(wordIds).size !== wordIds.length) {
@@ -1549,6 +1549,7 @@ export function executeAgentTool(
15491549
text: string;
15501550
assetId: string;
15511551
clipId: string;
1552+
index: number;
15521553
startSec: number;
15531554
endSec: number;
15541555
}> = [];
@@ -1558,9 +1559,9 @@ export function executeAgentTool(
15581559
(transcript) => selectedAssetId === undefined || transcript.assetId === selectedAssetId,
15591560
)
15601561
.flatMap((transcript) =>
1561-
transcript.words
1562-
.filter((word) => word.id === wordId)
1563-
.map((word) => ({ assetId: transcript.assetId, word })),
1562+
transcript.words.flatMap((word, index) =>
1563+
word.id === wordId ? [{ assetId: transcript.assetId, word, index }] : [],
1564+
),
15641565
);
15651566
if (matches.length !== 1) {
15661567
return failure(
@@ -1569,7 +1570,7 @@ export function executeAgentTool(
15691570
: `Word ID ${wordId} appears more than once in the transcripts. Pass the assetId used for getTranscriptWords. Nothing was modified.`,
15701571
);
15711572
}
1572-
const { assetId, word } = matches[0];
1573+
const { assetId, word, index } = matches[0];
15731574
const asset = document.assets.find((candidate) => candidate.id === assetId);
15741575
if (!asset || isGeneratedAssetId(assetId) || word.source === "synth") {
15751576
return failure(`Word ${wordId} has no recorded source asset. Nothing was modified.`);
@@ -1609,61 +1610,88 @@ export function executeAgentTool(
16091610
) {
16101611
return failure(`Word ${wordId} is already cut by a trim. Nothing was modified.`);
16111612
}
1612-
selected.push({ wordId, text: word.text, assetId, clipId: clip.id, startSec, endSec });
1613+
selected.push({
1614+
wordId,
1615+
text: word.text,
1616+
assetId,
1617+
clipId: clip.id,
1618+
index,
1619+
startSec,
1620+
endSec,
1621+
});
1622+
}
1623+
1624+
// Words that follow one another in the transcript go out as ONE cut: a phrase or a
1625+
// flubbed take cut word by word would leave the gaps between its words playing.
1626+
selected.sort((a, b) => a.assetId.localeCompare(b.assetId) || a.index - b.index);
1627+
const runs: Array<typeof selected> = [];
1628+
for (const word of selected) {
1629+
const last = runs.at(-1)?.at(-1);
1630+
if (
1631+
last?.assetId === word.assetId &&
1632+
last.clipId === word.clipId &&
1633+
last.index + 1 === word.index
1634+
) {
1635+
runs.at(-1)?.push(word);
1636+
} else {
1637+
runs.push([word]);
1638+
}
16131639
}
16141640

16151641
let current = document;
16161642
const removed: Array<Record<string, unknown>> = [];
1617-
for (const word of selected) {
1643+
for (const run of runs) {
1644+
const first = run[0];
1645+
const ids = run.map((word) => word.wordId).join(", ");
16181646
const execution = executeAgentTool(
16191647
current,
16201648
"addTrim",
16211649
JSON.stringify({
1622-
assetId: word.assetId,
1623-
clipId: word.clipId,
1624-
startSec: word.startSec,
1625-
endSec: word.endSec,
1626-
reason: "filler word",
1650+
assetId: first.assetId,
1651+
clipId: first.clipId,
1652+
startSec: first.startSec,
1653+
endSec: run[run.length - 1].endSec,
1654+
reason: "removed words",
16271655
}),
16281656
options,
16291657
);
16301658
if (!execution.ok || !execution.document) {
1631-
return failure(
1632-
`Could not cut word ${word.wordId}: ${execution.resultJson}. Nothing was modified.`,
1633-
);
1659+
return failure(`Could not cut ${ids}: ${execution.resultJson}. Nothing was modified.`);
16341660
}
16351661
const { trimRangeId } = JSON.parse(execution.resultJson) as { trimRangeId: string };
16361662
const trim = execution.document.timeline.trimRanges.find(
16371663
(range) => range.id === trimRangeId,
16381664
);
1639-
const clip = current.timeline.clips.find((candidate) => candidate.id === word.clipId);
1640-
const asset = current.assets.find((candidate) => candidate.id === word.assetId);
1665+
const clip = current.timeline.clips.find((candidate) => candidate.id === first.clipId);
1666+
const asset = current.assets.find((candidate) => candidate.id === first.assetId);
16411667
const clipEnd = clip?.sourceEndSec ?? asset?.durationSec;
16421668
if (
16431669
!trim ||
16441670
!clip ||
16451671
clipEnd === undefined ||
1646-
trim.clipId !== word.clipId ||
1672+
trim.clipId !== first.clipId ||
16471673
!Number.isFinite(trim.startSec) ||
16481674
!Number.isFinite(trim.endSec) ||
16491675
trim.startSec < clip.sourceStartSec ||
16501676
trim.endSec > clipEnd ||
16511677
trim.endSec <= trim.startSec
16521678
) {
1653-
return failure(`Cut for word ${word.wordId} left its clip. Nothing was modified.`);
1679+
return failure(`Cut for ${ids} left its clip. Nothing was modified.`);
16541680
}
16551681
current = execution.document;
1656-
removed.push({
1657-
wordId: word.wordId,
1658-
text: word.text,
1659-
assetId: word.assetId,
1660-
clipId: word.clipId,
1661-
wordStartSec: word.startSec,
1662-
wordEndSec: word.endSec,
1663-
trimRangeId,
1664-
startSec: trim.startSec,
1665-
endSec: trim.endSec,
1666-
});
1682+
for (const word of run) {
1683+
removed.push({
1684+
wordId: word.wordId,
1685+
text: word.text,
1686+
assetId: word.assetId,
1687+
clipId: word.clipId,
1688+
wordStartSec: word.startSec,
1689+
wordEndSec: word.endSec,
1690+
trimRangeId,
1691+
startSec: trim.startSec,
1692+
endSec: trim.endSec,
1693+
});
1694+
}
16671695
}
16681696
return {
16691697
ok: true,
@@ -1674,7 +1702,7 @@ export function executeAgentTool(
16741702
removed,
16751703
...cutTransitionsReport(document, current),
16761704
}),
1677-
summary: `removed ${removed.length} filler word${removed.length === 1 ? "" : "s"}`,
1705+
summary: `removed ${removed.length} word${removed.length === 1 ? "" : "s"}`,
16781706
};
16791707
}
16801708

‎electron/ai-edition/deep-agent/service.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ const ARGS: Record<string, unknown> = {
6060
getTranscriptWords: {},
6161
getCursorTrack: {},
6262
setWordText: { wordId: "word_1", text: "Hullo" },
63-
removeFillerWords: { wordIds: ["word_1"] },
63+
removeWords: { wordIds: ["word_1"] },
6464
addTrim: { startSec: 1, endSec: 2 },
6565
addTrims: { ranges: [{ startSec: 1, endSec: 2 }] },
6666
setTrim: { trimRangeId: "trim_1", startSec: 1, endSec: 2 },

0 commit comments

Comments
 (0)