@pixerate/editor 0.84.0 → 0.85.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/video-editor/index.cjs +342 -37
- package/dist/video-editor/index.cjs.map +1 -1
- package/dist/video-editor/index.d.cts +118 -2
- package/dist/video-editor/index.d.ts +118 -2
- package/dist/video-editor/index.js +312 -7
- package/dist/video-editor/index.js.map +1 -1
- package/package.json +1 -1
- package/skills/pixerate-video-editor/SKILL.md +20 -3
|
@@ -435,6 +435,18 @@ function parseVideoTransition(v, path) {
|
|
|
435
435
|
audioCurve: oneOf(v.audioCurve, VIDEO_FADE_CURVES, `${path}.audioCurve`)
|
|
436
436
|
});
|
|
437
437
|
}
|
|
438
|
+
function parseVideoTranscriptWords(v, path) {
|
|
439
|
+
if (v === void 0) return void 0;
|
|
440
|
+
if (!Array.isArray(v)) return fail(path, "must be an array of { word, start, end } (ms in the source)");
|
|
441
|
+
const words = v.map((w, i) => {
|
|
442
|
+
if (!isObject(w)) return fail(`${path}[${i}]`, "must be an object { word, start, end }");
|
|
443
|
+
const word = str(w.word, `${path}[${i}].word`);
|
|
444
|
+
const start = num(w.start, `${path}[${i}].start`, { min: 0 });
|
|
445
|
+
const end = num(w.end, `${path}[${i}].end`, { min: start });
|
|
446
|
+
return { word, start, end };
|
|
447
|
+
});
|
|
448
|
+
return words.sort((a, b) => a.start - b.start);
|
|
449
|
+
}
|
|
438
450
|
function parseVideoAsset(v, path = "asset") {
|
|
439
451
|
if (!isObject(v)) return fail(path, "must be an object");
|
|
440
452
|
const kind = oneOf(v.kind, ["video", "audio", "image"], `${path}.kind`, false);
|
|
@@ -447,8 +459,10 @@ function parseVideoAsset(v, path = "asset") {
|
|
|
447
459
|
width: num(v.width, `${path}.width`, { optional: true, positive: true }),
|
|
448
460
|
height: num(v.height, `${path}.height`, { optional: true, positive: true }),
|
|
449
461
|
hasAudio: bool(v.hasAudio, `${path}.hasAudio`),
|
|
450
|
-
poster: str(v.poster, `${path}.poster`, true)
|
|
462
|
+
poster: str(v.poster, `${path}.poster`, true),
|
|
463
|
+
transcript: parseVideoTranscriptWords(v.transcript, `${path}.transcript`)
|
|
451
464
|
});
|
|
465
|
+
if (kind === "image" && asset.transcript) fail(`${path}.transcript`, "is only for video and audio assets");
|
|
452
466
|
if (kind === "image" && asset.duration !== void 0) fail(`${path}.duration`, "is only for video and audio assets");
|
|
453
467
|
return asset;
|
|
454
468
|
}
|
|
@@ -1941,6 +1955,171 @@ var VideoAudioPeaksCache = class {
|
|
|
1941
1955
|
}
|
|
1942
1956
|
};
|
|
1943
1957
|
|
|
1958
|
+
// src/video-editor/transcript.ts
|
|
1959
|
+
var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1960
|
+
function cueTime(s) {
|
|
1961
|
+
const m = s.trim().match(/^(?:(\d+):)?(\d{1,2}):(\d{1,2})[.,](\d{1,3})$/);
|
|
1962
|
+
if (!m) throw new Error(`"${s.trim()}" is not a cue time (HH:MM:SS,mmm).`);
|
|
1963
|
+
return ((Number(m[1] ?? 0) * 60 + Number(m[2])) * 60 + Number(m[3])) * 1e3 + Number(m[4].padEnd(3, "0"));
|
|
1964
|
+
}
|
|
1965
|
+
function cueWords(text, start, end) {
|
|
1966
|
+
const words = text.replace(/<[^>]+>/g, "").split(/\s+/).filter(Boolean);
|
|
1967
|
+
const step = (end - start) / Math.max(1, words.length);
|
|
1968
|
+
return words.map((word, i) => ({ word, start: Math.round(start + i * step), end: Math.round(start + (i + 1) * step) }));
|
|
1969
|
+
}
|
|
1970
|
+
function parseVideoTranscript(input) {
|
|
1971
|
+
let data = input;
|
|
1972
|
+
if (typeof input === "string") {
|
|
1973
|
+
const text = input.replace(/^/, "").trim();
|
|
1974
|
+
if (text.startsWith("{") || text.startsWith("[")) {
|
|
1975
|
+
try {
|
|
1976
|
+
data = JSON.parse(text);
|
|
1977
|
+
} catch {
|
|
1978
|
+
throw new Error("The transcript looks like JSON but is not valid JSON.");
|
|
1979
|
+
}
|
|
1980
|
+
} else {
|
|
1981
|
+
const words2 = [];
|
|
1982
|
+
for (const block of text.replace(/\r\n?/g, "\n").split(/\n{2,}/)) {
|
|
1983
|
+
const lines = block.split("\n");
|
|
1984
|
+
const at = lines.findIndex((l) => l.includes("-->"));
|
|
1985
|
+
if (at < 0) continue;
|
|
1986
|
+
const [a, b] = lines[at].split("-->");
|
|
1987
|
+
words2.push(...cueWords(lines.slice(at + 1).join(" "), cueTime(a), cueTime(b.trim().split(/\s+/)[0])));
|
|
1988
|
+
}
|
|
1989
|
+
if (!words2.length) throw new Error("No words found: expected Whisper-style JSON with word timings, SubRip (.srt) or WebVTT.");
|
|
1990
|
+
return { words: words2, approximate: true };
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
const seconds = (v) => typeof v === "number" && Number.isFinite(v) ? Math.round(v * 1e3) : NaN;
|
|
1994
|
+
const fromList = (list, toMs) => list.map((w, i) => {
|
|
1995
|
+
if (!isRecord(w)) throw new Error(`Word ${i + 1} is not an object { word, start, end }.`);
|
|
1996
|
+
const word = String(w.word ?? w.text ?? "").trim();
|
|
1997
|
+
const start = toMs(w.start);
|
|
1998
|
+
const end = toMs(w.end);
|
|
1999
|
+
if (!word || !Number.isFinite(start) || !Number.isFinite(end)) throw new Error(`Word ${i + 1} needs a word and numeric start and end.`);
|
|
2000
|
+
return { word, start, end: Math.max(start, end) };
|
|
2001
|
+
});
|
|
2002
|
+
let words = [];
|
|
2003
|
+
if (Array.isArray(data)) words = fromList(data, (v) => typeof v === "number" ? v : NaN);
|
|
2004
|
+
else if (isRecord(data) && Array.isArray(data.words)) words = fromList(data.words, seconds);
|
|
2005
|
+
else if (isRecord(data) && Array.isArray(data.segments)) {
|
|
2006
|
+
const segments = data.segments.filter(isRecord);
|
|
2007
|
+
if (segments.some((s) => Array.isArray(s.words))) words = fromList(segments.flatMap((s) => Array.isArray(s.words) ? s.words : []), seconds);
|
|
2008
|
+
else {
|
|
2009
|
+
for (const s of segments) words.push(...cueWords(String(s.text ?? ""), seconds(s.start), seconds(s.end)));
|
|
2010
|
+
if (!words.length) throw new Error("No words found in the segments.");
|
|
2011
|
+
return { words, approximate: true };
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
if (!words.length) throw new Error("No words found: expected Whisper-style JSON with word timings, SubRip (.srt) or WebVTT.");
|
|
2015
|
+
return { words: words.sort((a, b) => a.start - b.start), approximate: false };
|
|
2016
|
+
}
|
|
2017
|
+
function videoTranscriptTrack(project, track) {
|
|
2018
|
+
if (track !== void 0) {
|
|
2019
|
+
if (!project.tracks.some((t) => t.id === track)) throw new Error(`There is no track "${track}"; the tracks are ${project.tracks.map((t) => `"${t.id}"`).join(", ")}.`);
|
|
2020
|
+
return track;
|
|
2021
|
+
}
|
|
2022
|
+
const assets = videoAssetMap(project);
|
|
2023
|
+
const transcribed = (t) => t.clips.some((c) => c.type === "media" && assets.get(c.asset)?.transcript?.length);
|
|
2024
|
+
return (project.tracks.find((t) => t.kind === "audio" && transcribed(t)) ?? project.tracks.find(transcribed))?.id ?? null;
|
|
2025
|
+
}
|
|
2026
|
+
function videoTimelineTranscript(project, options = {}) {
|
|
2027
|
+
const id = videoTranscriptTrack(project, options.track);
|
|
2028
|
+
if (!id) return [];
|
|
2029
|
+
const track = project.tracks.find((t) => t.id === id);
|
|
2030
|
+
const assets = videoAssetMap(project);
|
|
2031
|
+
const out = [];
|
|
2032
|
+
for (const clip of sortedVideoClips(track)) {
|
|
2033
|
+
if (clip.type !== "media") continue;
|
|
2034
|
+
const words = assets.get(clip.asset)?.transcript;
|
|
2035
|
+
if (!words?.length) continue;
|
|
2036
|
+
const speed = videoClipSpeed(clip);
|
|
2037
|
+
const from = clip.in ?? 0;
|
|
2038
|
+
const to = from + clip.duration * speed;
|
|
2039
|
+
const toTimeline = (s) => Math.min(videoClipEnd(clip), Math.max(clip.start, clip.start + (s - from) / speed));
|
|
2040
|
+
words.forEach((w, index) => {
|
|
2041
|
+
const mid = (w.start + w.end) / 2;
|
|
2042
|
+
if (mid < from || mid >= to) return;
|
|
2043
|
+
out.push({ word: w.word, start: toTimeline(w.start), end: toTimeline(w.end), clip: clip.id, track: track.id, index });
|
|
2044
|
+
});
|
|
2045
|
+
}
|
|
2046
|
+
return out;
|
|
2047
|
+
}
|
|
2048
|
+
var normalizeVideoWord = (word) => word.toLowerCase().replace(/^[^\p{L}\p{N}']+|[^\p{L}\p{N}']+$/gu, "");
|
|
2049
|
+
var rangeOf = (words, i, n2) => ({ from: words[i].start, to: words[i + n2 - 1].end, text: words.slice(i, i + n2).map((w) => w.word).join(" ") });
|
|
2050
|
+
function findVideoTranscriptText(words, query) {
|
|
2051
|
+
const q = query.split(/\s+/).map(normalizeVideoWord).filter(Boolean);
|
|
2052
|
+
if (!q.length) throw new Error("Search for at least one word.");
|
|
2053
|
+
const norm = words.map((w) => normalizeVideoWord(w.word));
|
|
2054
|
+
const out = [];
|
|
2055
|
+
for (let i = 0; i + q.length <= norm.length; i++) if (q.every((x, k) => norm[i + k] === x)) out.push(rangeOf(words, i, q.length));
|
|
2056
|
+
return out;
|
|
2057
|
+
}
|
|
2058
|
+
var VIDEO_DEFAULT_FILLERS = ["um", "umm", "uh", "uhh", "uhm", "erm", "er", "ah", "hmm", "mm", "you know", "i mean"];
|
|
2059
|
+
function findVideoFillers(words, fillers = VIDEO_DEFAULT_FILLERS) {
|
|
2060
|
+
const phrases = fillers.map((f) => f.split(/\s+/).map(normalizeVideoWord).filter(Boolean)).filter((p) => p.length).sort((a, b) => b.length - a.length);
|
|
2061
|
+
const norm = words.map((w) => normalizeVideoWord(w.word));
|
|
2062
|
+
const out = [];
|
|
2063
|
+
for (let i = 0; i < norm.length; ) {
|
|
2064
|
+
const hit = phrases.find((p) => i + p.length <= norm.length && p.every((x, k) => norm[i + k] === x));
|
|
2065
|
+
if (hit) {
|
|
2066
|
+
out.push(rangeOf(words, i, hit.length));
|
|
2067
|
+
i += hit.length;
|
|
2068
|
+
} else i++;
|
|
2069
|
+
}
|
|
2070
|
+
return out;
|
|
2071
|
+
}
|
|
2072
|
+
function findVideoTranscriptPauses(words, options = {}) {
|
|
2073
|
+
const min = options.min ?? 700;
|
|
2074
|
+
const keep = options.keep ?? 150;
|
|
2075
|
+
const out = [];
|
|
2076
|
+
for (let i = 1; i < words.length; i++) {
|
|
2077
|
+
const a = words[i - 1];
|
|
2078
|
+
const b = words[i];
|
|
2079
|
+
if (a.clip !== b.clip || b.start - a.end < min) continue;
|
|
2080
|
+
let from = a.end + keep;
|
|
2081
|
+
let to = b.start - keep;
|
|
2082
|
+
if (options.fps) {
|
|
2083
|
+
const f = 1e3 / options.fps;
|
|
2084
|
+
from = snapToVideoFrame(Math.ceil(from / f - 1e-9) * f, options.fps);
|
|
2085
|
+
to = snapToVideoFrame(Math.floor(to / f + 1e-9) * f, options.fps);
|
|
2086
|
+
}
|
|
2087
|
+
if (to - from > VIDEO_TIME_EPSILON) out.push({ from, to, text: `(pause after "${a.word}")` });
|
|
2088
|
+
}
|
|
2089
|
+
return out;
|
|
2090
|
+
}
|
|
2091
|
+
function mergeVideoRanges(ranges) {
|
|
2092
|
+
const sorted = ranges.filter((r) => r.to - r.from > VIDEO_TIME_EPSILON).map((r) => ({ from: r.from, to: r.to })).sort((a, b) => a.from - b.from);
|
|
2093
|
+
const out = [];
|
|
2094
|
+
for (const r of sorted) {
|
|
2095
|
+
const last = out[out.length - 1];
|
|
2096
|
+
if (last && r.from <= last.to + VIDEO_TIME_EPSILON) last.to = Math.max(last.to, r.to);
|
|
2097
|
+
else out.push(r);
|
|
2098
|
+
}
|
|
2099
|
+
return out;
|
|
2100
|
+
}
|
|
2101
|
+
function videoRemoveRangesEdits(ranges, tracks) {
|
|
2102
|
+
return mergeVideoRanges(ranges).reverse().map((r) => ({ op: "extract_range", from: r.from, to: r.to, ...tracks ? { tracks } : {} }));
|
|
2103
|
+
}
|
|
2104
|
+
function videoWordsRange(words, a, b) {
|
|
2105
|
+
const [i, j] = a <= b ? [a, b] : [b, a];
|
|
2106
|
+
return { from: words[i].start, to: words[j].end };
|
|
2107
|
+
}
|
|
2108
|
+
function videoTranscriptEdit(assetId, text) {
|
|
2109
|
+
return { op: "update_asset", asset: assetId, set: { transcript: parseVideoTranscript(text).words } };
|
|
2110
|
+
}
|
|
2111
|
+
function videoWordAt(words, time) {
|
|
2112
|
+
let lo = 0;
|
|
2113
|
+
let hi = words.length - 1;
|
|
2114
|
+
while (lo <= hi) {
|
|
2115
|
+
const mid = lo + hi >> 1;
|
|
2116
|
+
if (words[mid].end <= time) lo = mid + 1;
|
|
2117
|
+
else if (words[mid].start > time) hi = mid - 1;
|
|
2118
|
+
else return mid;
|
|
2119
|
+
}
|
|
2120
|
+
return -1;
|
|
2121
|
+
}
|
|
2122
|
+
|
|
1944
2123
|
// src/video-editor/render.ts
|
|
1945
2124
|
var esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1946
2125
|
var n = (v) => String(Math.round(v * 100) / 100);
|
|
@@ -2991,6 +3170,10 @@ var issueSummary = (issues) => ({
|
|
|
2991
3170
|
notes: issues.filter((i) => i.severity === "note").length,
|
|
2992
3171
|
lines: issues.map((i) => `${i.severity}: ${i.message}`)
|
|
2993
3172
|
});
|
|
3173
|
+
var videoAssetOut = (a) => {
|
|
3174
|
+
const { transcript, ...rest } = a;
|
|
3175
|
+
return transcript ? { ...rest, transcriptWords: transcript.length } : rest;
|
|
3176
|
+
};
|
|
2994
3177
|
function describeVideoProject(project) {
|
|
2995
3178
|
return {
|
|
2996
3179
|
...project.name ? { name: project.name } : {},
|
|
@@ -2999,7 +3182,7 @@ function describeVideoProject(project) {
|
|
|
2999
3182
|
fps: project.fps,
|
|
3000
3183
|
duration: videoProjectDuration(project),
|
|
3001
3184
|
...project.background ? { background: project.background } : {},
|
|
3002
|
-
assets: project.assets,
|
|
3185
|
+
assets: project.assets.map(videoAssetOut),
|
|
3003
3186
|
tracks: project.tracks.map((t, index) => ({
|
|
3004
3187
|
id: t.id,
|
|
3005
3188
|
kind: t.kind,
|
|
@@ -3221,9 +3404,9 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
3221
3404
|
name: "update_video_asset",
|
|
3222
3405
|
title: "Change media details",
|
|
3223
3406
|
description: "Changes an asset's src, name, duration, size, sound or poster (e.g. relinking moved media). Clips that now run past its end are refused. Returns the asset.",
|
|
3224
|
-
inputSchema: object({ asset: ASSET_ID, set: object(ASSET_FIELDS, [], "Fields to replace."), remove: { type: "array", items: { type: "string", enum: ["name", "duration", "width", "height", "hasAudio", "poster"] }, description: "Fields to clear." } }, ["asset"]),
|
|
3407
|
+
inputSchema: object({ asset: ASSET_ID, set: object(ASSET_FIELDS, [], "Fields to replace."), remove: { type: "array", items: { type: "string", enum: ["name", "duration", "width", "height", "hasAudio", "poster", "transcript"] }, description: "Fields to clear (transcript removes its words)." } }, ["asset"]),
|
|
3225
3408
|
annotations: { title: "Change media details", ...WRITE_IDEMPOTENT },
|
|
3226
|
-
handler: ({ asset, set, remove }) => apply({ op: "update_asset", asset, set, remove }, "Change media", (r) => ({ asset: r.project.assets.find((a) => a.id === asset) }))
|
|
3409
|
+
handler: ({ asset, set, remove }) => apply({ op: "update_asset", asset, set, remove }, "Change media", (r) => ({ asset: videoAssetOut(r.project.assets.find((a) => a.id === asset)) }))
|
|
3227
3410
|
};
|
|
3228
3411
|
const removeAsset = {
|
|
3229
3412
|
name: "remove_video_asset",
|
|
@@ -3380,6 +3563,114 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
3380
3563
|
return apply(edits, "Copy grade", () => ({ grade: grade ?? null, clips: to }));
|
|
3381
3564
|
}
|
|
3382
3565
|
};
|
|
3566
|
+
const TRANSCRIPT_TRACK = { ...TRACK_ID, description: "Track whose words to read (default: the first audio track with a transcribed clip, else the first video track with one)." };
|
|
3567
|
+
const RANGE_OUT = (r) => ({ from: round(r.from), to: round(r.to), text: r.text });
|
|
3568
|
+
const timelineWords = (track) => {
|
|
3569
|
+
const words = videoTimelineTranscript(project(), { track });
|
|
3570
|
+
if (!words.length) throw new Error(track ? `No clip on track "${track}" plays a transcribed asset; attach one with set_video_transcript.` : "No clip plays a transcribed asset; attach a transcript to the media with set_video_transcript first.");
|
|
3571
|
+
return words;
|
|
3572
|
+
};
|
|
3573
|
+
const setTranscript = {
|
|
3574
|
+
name: "set_video_transcript",
|
|
3575
|
+
title: "Attach a transcript",
|
|
3576
|
+
description: 'Attaches what is said in a video or audio asset, word by word, so the edit can be cut by text (one undo step). Pass "text" (Whisper-style JSON with word timings, SubRip or WebVTT, as the file says it) or "words" ([{ word, start, end }] in ms of the source). Captions have no word timings, so their words are spread across each cue (approximate). Replaces any transcript the asset had. Returns the word count, whether timings are approximate, and the first and last words.',
|
|
3577
|
+
inputSchema: object(
|
|
3578
|
+
{
|
|
3579
|
+
asset: { ...ASSET_ID, description: "A video or audio asset." },
|
|
3580
|
+
text: { type: "string", minLength: 1, description: "The transcript file's contents: Whisper-style JSON (times in seconds), .srt or .vtt. Pass this or words." },
|
|
3581
|
+
words: { type: "array", minItems: 1, items: object({ word: { type: "string", minLength: 1, description: "The word as said." }, start: { type: "number", minimum: 0, description: "Start in ms of the source." }, end: { type: "number", minimum: 0, description: "End in ms of the source." } }, ["word", "start", "end"], "One word."), description: "Words with source times in ms. Pass this or text." }
|
|
3582
|
+
},
|
|
3583
|
+
["asset"]
|
|
3584
|
+
),
|
|
3585
|
+
annotations: { title: "Attach a transcript", ...WRITE_IDEMPOTENT },
|
|
3586
|
+
handler: ({ asset, text, words }) => {
|
|
3587
|
+
if (text === void 0 === (words === void 0)) throw new Error(`Pass either "text" (a transcript file's contents) or "words", not both.`);
|
|
3588
|
+
const a = project().assets.find((x) => x.id === asset);
|
|
3589
|
+
if (!a) throw new Error(`There is no asset "${asset}"; the assets are ${project().assets.map((x) => `"${x.id}"`).join(", ") || "none"}.`);
|
|
3590
|
+
if (a.kind === "image") throw new Error(`Asset "${asset}" is an image and has no sound to transcribe.`);
|
|
3591
|
+
const parsed = text !== void 0 ? parseVideoTranscript(text) : { words, approximate: false };
|
|
3592
|
+
const list = parsed.words;
|
|
3593
|
+
return apply({ op: "update_asset", asset, set: { transcript: list } }, "Attach transcript", () => ({
|
|
3594
|
+
asset,
|
|
3595
|
+
words: list.length,
|
|
3596
|
+
approximate: parsed.approximate,
|
|
3597
|
+
first: list[0],
|
|
3598
|
+
last: list[list.length - 1],
|
|
3599
|
+
...a.duration !== void 0 && list[list.length - 1].end > a.duration + VIDEO_TIME_EPSILON ? { warning: `The transcript runs to ${list[list.length - 1].end} ms, past the asset's ${a.duration} ms: is it for this asset?` } : {}
|
|
3600
|
+
}));
|
|
3601
|
+
}
|
|
3602
|
+
};
|
|
3603
|
+
const getTranscript = {
|
|
3604
|
+
name: "get_video_transcript",
|
|
3605
|
+
title: "Read the transcript",
|
|
3606
|
+
description: "Returns what the timeline says, in order, from the transcripts of the media its clips play on one track: the text, and each word with its timeline start and end (ms) and the clip that plays it, so ranges can go straight to remove_video_ranges or split_video_at. Words cut out of the edit are not included.",
|
|
3607
|
+
inputSchema: object({
|
|
3608
|
+
track: TRANSCRIPT_TRACK,
|
|
3609
|
+
from: MS("Only words from this timeline time (default 0)"),
|
|
3610
|
+
to: MS("Only words up to this timeline time (default the end)"),
|
|
3611
|
+
limit: { type: "integer", minimum: 1, maximum: 5e3, description: 'Most words to return (default 1000); "more" says if there are others.' }
|
|
3612
|
+
}),
|
|
3613
|
+
annotations: { title: "Read the transcript", ...READ },
|
|
3614
|
+
handler: ({ track, from, to, limit }) => {
|
|
3615
|
+
const all = timelineWords(track).filter((w) => w.end > (from ?? 0) && w.start < (to ?? Infinity));
|
|
3616
|
+
const words = all.slice(0, limit ?? 1e3);
|
|
3617
|
+
return { track: words[0]?.track ?? videoTranscriptTrack(project(), track), text: words.map((w) => w.word).join(" "), words: words.map((w) => ({ word: w.word, start: round(w.start), end: round(w.end), clip: w.clip })), more: all.length > words.length };
|
|
3618
|
+
}
|
|
3619
|
+
};
|
|
3620
|
+
const findText = {
|
|
3621
|
+
name: "find_video_transcript_text",
|
|
3622
|
+
title: "Find words in the transcript",
|
|
3623
|
+
description: "Lists every place a word or phrase is said on the timeline (case and punctuation ignored) as { from, to, text } ranges in timeline ms, ready for remove_video_ranges, split_video_at or seek_video.",
|
|
3624
|
+
inputSchema: object({ query: { type: "string", minLength: 1, description: "The word or phrase." }, track: TRANSCRIPT_TRACK }, ["query"]),
|
|
3625
|
+
annotations: { title: "Find words in the transcript", ...READ },
|
|
3626
|
+
handler: ({ query, track }) => ({ matches: findVideoTranscriptText(timelineWords(track), query).map(RANGE_OUT) })
|
|
3627
|
+
};
|
|
3628
|
+
const findFillers = {
|
|
3629
|
+
name: "find_video_fillers",
|
|
3630
|
+
title: "Find filler words",
|
|
3631
|
+
description: `Lists filler words and phrases on the timeline as { from, to, text } ranges in timeline ms, longest phrase first so "you know" is found whole. Default fillers: ${VIDEO_DEFAULT_FILLERS.join(", ")} ("like" and "so" are left out as too often meant). Check them, then pass the ones to cut to remove_video_ranges.`,
|
|
3632
|
+
inputSchema: object({ track: TRANSCRIPT_TRACK, fillers: { type: "array", minItems: 1, items: { type: "string", minLength: 1 }, description: "Words or phrases to treat as fillers instead of the defaults." } }),
|
|
3633
|
+
annotations: { title: "Find filler words", ...READ },
|
|
3634
|
+
handler: ({ track, fillers }) => {
|
|
3635
|
+
const found = findVideoFillers(timelineWords(track), fillers);
|
|
3636
|
+
return { fillers: found.map(RANGE_OUT), total: round(found.reduce((n2, r) => n2 + r.to - r.from, 0)) };
|
|
3637
|
+
}
|
|
3638
|
+
};
|
|
3639
|
+
const findPauses = {
|
|
3640
|
+
name: "find_video_pauses",
|
|
3641
|
+
title: "Find pauses between words",
|
|
3642
|
+
description: 'Lists the gaps between consecutive words on the timeline (within one clip) longer than "min", less "keep" next to each word so speech is not clipped, snapped inward to frames, as { from, to } ranges in timeline ms ready for remove_video_ranges. Uses word timings; find_video_silences measures the sound instead.',
|
|
3643
|
+
inputSchema: object({
|
|
3644
|
+
track: TRANSCRIPT_TRACK,
|
|
3645
|
+
min: { type: "number", exclusiveMinimum: 0, description: "Shortest gap that counts, in ms (default 700)." },
|
|
3646
|
+
keep: { type: "number", minimum: 0, description: "Silence to keep after the word before and before the word after, in ms (default 150)." }
|
|
3647
|
+
}),
|
|
3648
|
+
annotations: { title: "Find pauses between words", ...READ },
|
|
3649
|
+
handler: ({ track, min, keep }) => {
|
|
3650
|
+
const found = findVideoTranscriptPauses(timelineWords(track), { min, keep, fps: project().fps });
|
|
3651
|
+
return { pauses: found.map(RANGE_OUT), total: round(found.reduce((n2, r) => n2 + r.to - r.from, 0)) };
|
|
3652
|
+
}
|
|
3653
|
+
};
|
|
3654
|
+
const removeRanges = {
|
|
3655
|
+
name: "remove_video_ranges",
|
|
3656
|
+
title: "Cut out stretches",
|
|
3657
|
+
description: "Cuts stretches of the timeline out and closes them up, as one undo step: the ranges are merged where they touch and extracted from the last to the first, so the times from find_video_fillers, find_video_pauses, find_video_transcript_text or find_video_silences can be passed as they are. Like extract_video_range, every sync-locked track moves with the cut. Returns how many stretches and how much time were removed and the new length.",
|
|
3658
|
+
inputSchema: object(
|
|
3659
|
+
{
|
|
3660
|
+
ranges: { type: "array", minItems: 1, maxItems: 500, items: object({ from: MS("Start, on the timeline"), to: MS("End, on the timeline"), text: { type: "string", description: "Ignored: the words in the stretch, as the find tools return them." } }, ["from", "to"], "A stretch to remove."), description: "The stretches, in any order (timeline ms)." },
|
|
3661
|
+
tracks: { type: "array", items: TRACK_ID, minItems: 1, description: "Tracks to cut (default every unlocked track)." }
|
|
3662
|
+
},
|
|
3663
|
+
["ranges"]
|
|
3664
|
+
),
|
|
3665
|
+
annotations: { title: "Cut out stretches", ...DESTRUCTIVE },
|
|
3666
|
+
handler: ({ ranges, tracks }) => {
|
|
3667
|
+
const bad = ranges.findIndex((r) => !(r.to > r.from));
|
|
3668
|
+
if (bad >= 0) throw new Error(`Range ${bad + 1} is empty or backwards (${ranges[bad].from}\u2013${ranges[bad].to} ms); "to" must be after "from".`);
|
|
3669
|
+
const merged = mergeVideoRanges(ranges);
|
|
3670
|
+
const before = videoProjectDuration(project());
|
|
3671
|
+
return apply(videoRemoveRangesEdits(merged, tracks), merged.length > 1 ? `Cut ${merged.length} stretches` : "Cut stretch", (r) => ({ removed: merged.length, time: round(before - videoProjectDuration(r.project)), duration: videoProjectDuration(r.project) }));
|
|
3672
|
+
}
|
|
3673
|
+
};
|
|
3383
3674
|
const setTransition = {
|
|
3384
3675
|
name: "set_video_transition",
|
|
3385
3676
|
title: "Set a transition",
|
|
@@ -3597,8 +3888,8 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
3597
3888
|
return { asset: r.asset.id, from: r.from, to: r.to, silences, total: round(silences.reduce((n2, x) => n2 + x.end - x.start, 0)) };
|
|
3598
3889
|
}
|
|
3599
3890
|
};
|
|
3600
|
-
const reads = [getProject, getFrame, findGaps, checkProject, renderFrame, exportFfmpeg, exportLoudnessMeasure, exportEdl, audioLevels, findSilences, seek];
|
|
3601
|
-
const writes = [setSettings, addAsset, updateAsset, removeAsset, addTrack, updateTrack, removeTrack, moveTrack, addClip, moveClip, trimClip, splitClip, deleteClips, updateClip, setTransition, applyGradePreset, copyGrade, setKeyframes, removeKeyframes, slipClip, rollEdit, slideClip, liftRange, extractRange, splitAt, closeGaps, addMarker, removeMarker, editVideo, undo, redo];
|
|
3891
|
+
const reads = [getProject, getFrame, getTranscript, findText, findFillers, findPauses, findGaps, checkProject, renderFrame, exportFfmpeg, exportLoudnessMeasure, exportEdl, audioLevels, findSilences, seek];
|
|
3892
|
+
const writes = [setSettings, addAsset, updateAsset, removeAsset, addTrack, updateTrack, removeTrack, moveTrack, addClip, moveClip, trimClip, splitClip, deleteClips, updateClip, setTransition, setTranscript, removeRanges, applyGradePreset, copyGrade, setKeyframes, removeKeyframes, slipClip, rollEdit, slideClip, liftRange, extractRange, splitAt, closeGaps, addMarker, removeMarker, editVideo, undo, redo];
|
|
3602
3893
|
return {
|
|
3603
3894
|
id: options.id ?? "video",
|
|
3604
3895
|
namespace: options.namespace,
|
|
@@ -3843,6 +4134,7 @@ export {
|
|
|
3843
4134
|
VIDEO_CLIP_ANIMATION_EFFECTS,
|
|
3844
4135
|
VIDEO_CLIP_ANIMATION_LABELS,
|
|
3845
4136
|
VIDEO_CLIP_TYPES,
|
|
4137
|
+
VIDEO_DEFAULT_FILLERS,
|
|
3846
4138
|
VIDEO_DEFAULT_STILL_DURATION,
|
|
3847
4139
|
VIDEO_DEFAULT_TITLE_DURATION,
|
|
3848
4140
|
VIDEO_EASE_NAMES,
|
|
@@ -3879,14 +4171,19 @@ export {
|
|
|
3879
4171
|
ffmpegFilterValue,
|
|
3880
4172
|
findVideoAudioSilences,
|
|
3881
4173
|
findVideoClip,
|
|
4174
|
+
findVideoFillers,
|
|
3882
4175
|
findVideoGaps,
|
|
4176
|
+
findVideoTranscriptPauses,
|
|
4177
|
+
findVideoTranscriptText,
|
|
3883
4178
|
formatVideoSeconds,
|
|
3884
4179
|
formatVideoTimecode,
|
|
3885
4180
|
formatVideoTimecodeFrames,
|
|
3886
4181
|
isVideoDropFrameRate,
|
|
3887
4182
|
isVideoKeyframes,
|
|
4183
|
+
mergeVideoRanges,
|
|
3888
4184
|
nextFreeVideoSlot,
|
|
3889
4185
|
normalizeVideoGrade,
|
|
4186
|
+
normalizeVideoWord,
|
|
3890
4187
|
parseVideoAnimatable,
|
|
3891
4188
|
parseVideoAsset,
|
|
3892
4189
|
parseVideoClip,
|
|
@@ -3897,6 +4194,8 @@ export {
|
|
|
3897
4194
|
parseVideoMarker,
|
|
3898
4195
|
parseVideoProject,
|
|
3899
4196
|
parseVideoTrack,
|
|
4197
|
+
parseVideoTranscript,
|
|
4198
|
+
parseVideoTranscriptWords,
|
|
3900
4199
|
parseVideoTransform,
|
|
3901
4200
|
parseVideoTransition,
|
|
3902
4201
|
placeVideoRect,
|
|
@@ -3971,18 +4270,24 @@ export {
|
|
|
3971
4270
|
videoPeakDb,
|
|
3972
4271
|
videoProjectDuration,
|
|
3973
4272
|
videoProjectSvg,
|
|
4273
|
+
videoRemoveRangesEdits,
|
|
3974
4274
|
videoRulerTicks,
|
|
3975
4275
|
videoSnapPoints,
|
|
3976
4276
|
videoTextStyle,
|
|
3977
4277
|
videoTimecodeFrames,
|
|
4278
|
+
videoTimelineTranscript,
|
|
3978
4279
|
videoTitleFrame,
|
|
3979
4280
|
videoTrackSyncLocked,
|
|
4281
|
+
videoTranscriptEdit,
|
|
4282
|
+
videoTranscriptTrack,
|
|
3980
4283
|
videoTransformAt,
|
|
3981
4284
|
videoTransitionAudioCurve,
|
|
3982
4285
|
videoTransitionEase,
|
|
3983
4286
|
videoTransitionHasDirection,
|
|
3984
4287
|
videoValueAt,
|
|
3985
4288
|
videoWaveformPath,
|
|
3986
|
-
videoWipeRect
|
|
4289
|
+
videoWipeRect,
|
|
4290
|
+
videoWordAt,
|
|
4291
|
+
videoWordsRange
|
|
3987
4292
|
};
|
|
3988
4293
|
//# sourceMappingURL=index.js.map
|