diff --git a/README.md b/README.md index 40fb565..219306d 100644 --- a/README.md +++ b/README.md @@ -93,3 +93,10 @@ also has `camkit --help`. kept ranges with `camkit silences` before finalizing a cut. - After a rebuild the project is already cut and `.bak` holds the original; to recut, restore the `.bak` first or you'll back up the cut file. + +### Separate-media rough cuts + +For editable screen/camera/audio cuts using converted MP4/WAV media, see +[the separate-media workflow](docs/separate-media.md). Includes synchronized +source groups, conversion into a new project, and validation that refuses +unsupported existing edits. diff --git a/docs/separate-media.md b/docs/separate-media.md new file mode 100644 index 0000000..934e20d --- /dev/null +++ b/docs/separate-media.md @@ -0,0 +1,83 @@ +# Editable cuts with separate screen, camera and audio sources + +`convert-media` creates a new `.cmproj` with one editable MP4/WAV track for +each placed recording stream. It never edits the input project, Camtasia +preferences, presets or proxy cache. Keep an independent backup of the original +bundle before a production edit. + +```sh +camkit convert-media --project original.cmproj --out working.cmproj --dry-run +camkit convert-media --project original.cmproj --out working.cmproj --acknowledge-cursor-loss +``` + +Requirements: ffmpeg and ffprobe, H.264 encoding, and a decoder for the source +recording (including tscc2 for screen recordings). Conversion can take minutes +and uses additional disk space. MP4 conversion loses native cursor metadata, +including editable cursor enlargement/highlighting; the decoded screen video +may omit the cursor itself. It retains static media scale, crop, position, +audio gain and track visibility/mute settings. It includes only the recording +placed on the timeline, not unused TREC entries in the media bin. + +The initial supported input is a single uncut recording with synchronized clips +starting at zero. Effects, animations, speed changes, mixed-source UnifiedMedia, +multiple clips of a source on a track and other unsupported structures fail +before conversion. Source streams are checked against the probe's types, +dimensions and durations. Nonzero audio start times are padded with silence; +nonzero video start times are currently refused. The last decoded video frame +may be held for up to one second to cover a TREC tail whose declared duration +exceeds its decoded frames; output is trimmed to the original timeline duration. All output streams are checked +for zero start time and duration before the project is written. Output must not +already exist. A failed conversion removes only the output directory it created. + +## One cut plan for multiple sources + +Conversion writes `working.cmproj/sync-groups.json`, for example: + +```json +[{"sources":[{"src":10,"offset":0},{"src":12,"offset":0},{"src":14,"offset":0}]}] +``` + +The first source is the reference clock. `offset` means **member source time +minus reference source time**, in seconds. For manually synchronized files, a +member with `offset: 0.25` uses source time 10.25 when the reference uses 10. +The group's sources must occupy distinct tracks and each source can belong to +only one group. Supply offsets explicitly; camkit does not infer synchronization. + +Use the first source ID in the keep list (use the IDs in the generated file, +not the example numbers): + +```json +{"keep":[{"src":10,"start":1,"end":12},{"src":10,"start":14,"end":20}]} +``` + +```sh +camkit rebuild --project working.cmproj --from keep.json \ + --sync-groups working.cmproj/sync-groups.json --dry-run +camkit rebuild --project working.cmproj --from keep.json \ + --sync-groups working.cmproj/sync-groups.json +``` + +Cut boundaries are rounded once to reference video frames; offsets are rounded +to project units. Every member gets exactly the same timeline start and duration, +with globally unique clip IDs. All member source bounds are checked both before +and after rounding. Dry-run entries report the actual rounded reference times. + +Rebuild remains a source-time rough-cut operation, not a general retiming engine +for finished projects. It now refuses repeated source clips on a track and +unsupported effects/keyframes instead of cloning only the first clip and silently +losing later edits. Use the uncut original for a new cut plan. Existing lock and +`.bak` protections still apply; `--force` does not bypass timeline validation. + +Transcribe separately, select complete takes, check real silences, then supply an +explicit keep plan. Local Whisper uses full JSON for token offsets and omits +reversed token timestamps from word results while retaining segment text. +Transcripts can still have inaccurate timestamps and hallucinations during long +silences; do not treat every inferred word boundary as an exact audio cut point. + +## Validation limits + +This workflow avoids native TREC references in the generated project, motivated +by [issue #17](https://github.com/Orva-Studio/camkit/issues/17). It does not fix or +prove the absence of a Camtasia decoder bug. Validate playback, save/reopen and +native export on a project copy before adopting it for production. Keep the +original TREC for cursor features and further editing. diff --git a/packages/cli/src/camkit.ts b/packages/cli/src/camkit.ts index dc40531..22992bc 100755 --- a/packages/cli/src/camkit.ts +++ b/packages/cli/src/camkit.ts @@ -38,6 +38,7 @@ import { camtasiaDocPaths, closeProject, exportVideo, openProject, projectStatus import { exportAudio, runSilencedetect, transcribeRecording } from "./media.ts"; import { mediaProxiesDir, planPrune, proxyKey, type ProxyEntry } from "./proxies.ts"; import { listPresets, resolvePreset } from "./presets.ts"; +import { convertMedia } from "./convert.ts"; import { version } from "../package.json"; /** Load for read-only commands: --project, else the ./search.cmproj default, @@ -91,8 +92,12 @@ const HELP: Record = { "including unplaced takes).", ], }, + "convert-media": { + usage: "camkit convert-media --project PATH --out NEW.cmproj [--dry-run] [--acknowledge-cursor-loss]", + about: ["Convert one uncut TREC into separate editable MP4/WAV tracks in a new bundle.", "Requires ffmpeg/ffprobe with a decoder for the recording. Preserves static framing and gain.", "Drops native cursor data and unused media-bin items; refuses unsupported existing edits.", "Writes sync-groups.json for rebuild --sync-groups; never changes the input project or app settings."], + }, rebuild: { - usage: 'camkit rebuild [--project PATH] --keep "SRC:start-end ..." | --from FILE [--dry-run] [--force]', + usage: 'camkit rebuild [--project PATH] --keep "SRC:start-end ..." | --from FILE [--sync-groups FILE] [--dry-run] [--force]', about: [ "The core rough-cut op. Rewrites the timeline to keep only the listed", "source segments, in order, ripple-laid with no gaps (seconds → editRate", @@ -101,6 +106,9 @@ const HELP: Record = { "", ' --keep "1:159.8-179.2 2:46.3-60.0" keep src-1 159.8–179.2s, then src-2 46.3–60.0s', " --from FILE JSON [{src,start,end}] or {keep:[...]}", + " --sync-groups FILE JSON [{sources:[{src,offset}]}]; keep uses each group’s first source", + "Offsets are source seconds minus reference seconds. All members share cut boundaries.", + "Refuses already-cut clips, effects/keyframes and unsupported media to protect existing edits.", " --dry-run print the plan, write nothing (ALWAYS do this first)", " --force override a stale ~project.tscproj lock / overwrite an existing .bak", "", @@ -376,12 +384,16 @@ function cmdRebuild(argv: string[]) { segs = parseKeep(keep); } - const plan = planRebuild(doc, segs); + const syncFile = flag(argv, "--sync-groups"); + const plan = planRebuild(doc, segs, { syncGroups: syncFile ? JSON.parse(readFileSync(resolve(syncFile), "utf8")) : undefined }); console.log(`rebuild plan (${plan.segmentCount} segments, ${plan.totalSeconds.toFixed(1)}s total):`); for (const e of plan.entries) { console.log( ` src ${e.src} ${e.sourceStart.toFixed(2)}-${e.sourceEnd.toFixed(2)}s → timeline ${e.timelineStart.toFixed(2)}-${e.timelineEnd.toFixed(2)}s (${e.trackCount} track[s])`, ); + for (const member of e.sources.slice(1)) { + console.log(` synced src ${member.src}: ${member.sourceStart.toFixed(3)}-${member.sourceEnd.toFixed(3)}s`); + } } if (dryRun) { console.log("\n--dry-run: no files written."); @@ -751,6 +763,11 @@ const COMMANDS: Record void | Promise> = { clips: cmdClips, sources: cmdSources, rebuild: cmdRebuild, + "convert-media": async (argv) => { + const project = flag(argv, "--project"), out = flag(argv, "--out"); + if (!project || !out) throw new Error("convert-media needs --project PATH --out NEW.cmproj"); + console.log(JSON.stringify(await convertMedia({ project, out, dryRun: has(argv, "--dry-run"), acknowledgeCursorLoss: has(argv, "--acknowledge-cursor-loss") }), null, 2)); + }, "export-audio": cmdExportAudio, "export-video": cmdExportVideo, captions: cmdCaptions, diff --git a/packages/cli/src/convert.ts b/packages/cli/src/convert.ts new file mode 100644 index 0000000..f278c24 --- /dev/null +++ b/packages/cli/src/convert.ts @@ -0,0 +1,213 @@ +import { spawn } from "node:child_process"; +import { mkdirSync, existsSync, writeFileSync, rmSync } from "node:fs"; +import { dirname, resolve, join } from "node:path"; +import { + loadProject, + planSeparateMedia, + type ConvertedStream, +} from "@camkit/core"; + +function run(command: string, args: string[]): Promise { + return new Promise((res, rej) => { + const p = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] }); + let out = "", + err = ""; + p.stdout.on("data", (d) => (out += d)); + p.stderr.on("data", (d) => (err = (err + d).slice(-12000))); + p.on("error", rej); + p.on("close", (code) => + code === 0 + ? res(out) + : rej(new Error(`${command} exited ${code}: ${err}`)), + ); + }); +} +export async function convertMedia(opts: { + project: string; + out: string; + dryRun?: boolean; + acknowledgeCursorLoss?: boolean; +}) { + const { path, doc } = loadProject(opts.project); + const out = resolve(opts.out); + if (!out.endsWith(".cmproj")) + throw new Error("--out must be a new .cmproj directory."); + if (existsSync(out)) throw new Error(`Output already exists: ${out}`); + // First validate the project without invoking ffmpeg or creating any output. + // Only stream metadata belonging to the placed source is used (bin can contain unused recordings). + const clips = + doc.timeline?.sceneTrack?.scenes?.[0]?.csml?.tracks?.flatMap( + (t: any) => t.medias ?? [], + ) ?? []; + const first = clips[0]; + const src = first?._type === "UnifiedMedia" ? first.video?.src : first?.src; + const bin = (doc.sourceBin ?? []).find((s: any) => s.id === src); + const preliminary = planSeparateMedia( + doc, + (bin?.sourceTracks ?? []).map((t: any, n: number) => ({ + trackNumber: n, + file: `./media/stream-${n}.${t.type === 2 ? "wav" : "mp4"}`, + kind: t.type === 2 ? "audio" : "video", + width: t.trackRect?.[2], + height: t.trackRect?.[3], + sampleRate: Number(t.sampleRate), + channels: t.numChannels, + })), + ); + const input = resolve(dirname(path), preliminary.sourcePath); + const probe = JSON.parse( + await run("ffprobe", [ + "-v", + "error", + "-show_streams", + "-of", + "json", + input, + ]), + ); + const converted: ConvertedStream[] = []; + const args = [ + "-hide_banner", + "-loglevel", + "warning", + "-nostdin", + "-n", + "-copyts", + "-i", + input, + ]; + const used = preliminary.doc.sourceBin.map((s: any) => + Number(s.src.match(/stream-(\d+)/)[1]), + ); + for (const n of used) { + const st = probe.streams?.find((s: any) => s.index === n); + const sourceTrack = bin.sourceTracks[n]; + const audio = sourceTrack.type === 2; + if (!st || st.codec_type !== (audio ? "audio" : "video")) + throw new Error(`Cannot map TREC track ${n} to ffmpeg stream safely.`); + const start = Number(st.start_time), + duration = Number(st.duration); + if ( + !Number.isFinite(start) || + !Number.isFinite(duration) || + start < 0 || + start >= preliminary.durationSeconds || + duration + start < preliminary.durationSeconds + ) + throw new Error(`Stream ${n} does not cover the requested duration.`); + if ( + !audio && + (start !== 0 || + st.width !== sourceTrack.trackRect?.[2] || + st.height !== sourceTrack.trackRect?.[3]) + ) + throw new Error(`Unsupported video timing or dimensions on stream ${n}.`); + const s: ConvertedStream = { + trackNumber: n, + file: `./media/stream-${n}.${audio ? "wav" : "mp4"}`, + kind: audio ? "audio" : "video", + width: st.width, + height: st.height, + sampleRate: Number(st.sample_rate), + channels: st.channels, + }; + converted.push(s); + args.push("-map", `0:${n}`, "-t", String(preliminary.durationSeconds)); + if (audio) + args.push("-af", "aresample=async=1:first_pts=0", "-c:a", "pcm_s16le"); + else + args.push( + "-vf", + `fps=${doc.videoFormatFrameRate}:start_time=0,tpad=stop_mode=clone:stop_duration=1`, + "-c:v", + "libx264", + "-preset", + "ultrafast", + "-crf", + "18", + "-pix_fmt", + "yuv420p", + "-an", + "-movflags", + "+faststart", + ); + args.push(resolve(out, s.file)); + } + const plan = planSeparateMedia(doc, converted); + if (opts.dryRun) + return { + out, + durationSeconds: plan.durationSeconds, + syncGroups: plan.syncGroups, + warnings: plan.warnings, + ffmpeg: args, + }; + if (!opts.acknowledgeCursorLoss) + throw new Error( + "Conversion loses native cursor data; pass --acknowledge-cursor-loss after reviewing --dry-run.", + ); + // Exclusive mkdir: never overwrite an existing bundle, even when two invocations race. + mkdirSync(out); + try { + mkdirSync(join(out, "media")); + await run("ffmpeg", args); + for (const s of converted) { + const check = JSON.parse( + await run("ffprobe", [ + "-v", + "error", + "-show_streams", + "-of", + "json", + resolve(out, s.file), + ]), + ); + const st = check.streams?.[0]; + const tolerance = + s.kind === "audio" ? 1 / s.sampleRate! : 1 / doc.videoFormatFrameRate; + if ( + check.streams?.length !== 1 || + st.codec_type !== s.kind || + !Number.isFinite(Number(st.duration)) || + Math.abs(Number(st.duration) - plan.durationSeconds) > + tolerance + 1e-6 || + Math.abs(Number(st.start_time ?? 0)) > 1e-6 + ) + throw new Error( + `Converted stream failed duration/start validation: ${s.file} (start=${st?.start_time}, duration=${st?.duration}, expected=${plan.durationSeconds})`, + ); + } + writeFileSync( + join(out, "conversion.json"), + JSON.stringify( + { + sourceProject: path, + sourceMedia: input, + durationSeconds: plan.durationSeconds, + syncGroups: plan.syncGroups, + warnings: plan.warnings, + }, + null, + 2, + ), + ); + writeFileSync( + join(out, "sync-groups.json"), + JSON.stringify(plan.syncGroups, null, 2), + ); + // Write the project last so a failed conversion never looks like a usable project. + writeFileSync( + join(out, "project.tscproj"), + JSON.stringify(plan.doc, null, 2), + ); + return { + out, + durationSeconds: plan.durationSeconds, + syncGroups: plan.syncGroups, + warnings: plan.warnings, + }; + } catch (e) { + rmSync(out, { recursive: true, force: true }); // only the directory exclusively created by this invocation + throw e; + } +} diff --git a/packages/cli/src/media.ts b/packages/cli/src/media.ts index 83f11db..0e0439c 100644 --- a/packages/cli/src/media.ts +++ b/packages/cli/src/media.ts @@ -105,8 +105,8 @@ async function callWhisper(audioPath: string, model: string): Promise { */ async function callWhisperCpp(audioPath: string, modelPath: string): Promise { const outBase = audioPath.replace(/\.wav$/, ""); - // -oj writes .json with token offsets; -np keeps stdout quiet. - await run(WHISPER_BIN, ["-m", modelPath, "-f", audioPath, "-oj", "-of", outBase, "-np"]); + // Full JSON is required: ordinary -oj omits the per-token word offsets. + await run(WHISPER_BIN, ["-m", modelPath, "-f", audioPath, "-ojf", "-of", outBase, "-np"]); const jsonPath = `${outBase}.json`; const data = JSON.parse(await readFile(jsonPath, "utf8")); await unlink(jsonPath).catch(() => {}); @@ -148,7 +148,10 @@ export function shapeWhisperCpp(data: any): any { } const lastEnd = segments.length ? segments[segments.length - 1].end : null; const text = segments.map((s) => s.text).join("").trim(); - return { duration: lastEnd, text, words, segments }; + // Some whisper.cpp versions emit reversed token offsets after long pauses. + // Keep the segment text, but never expose these as usable word cut points. + const validWords = words.filter(w => Number.isFinite(w.start) && Number.isFinite(w.end) && w.start >= 0 && w.end >= w.start); + return { duration: lastEnd, text, words: validWords, segments }; } export type Engine = "openai" | "whisper-cpp" | "replicate"; diff --git a/packages/cli/test/convert.test.ts b/packages/cli/test/convert.test.ts new file mode 100644 index 0000000..c45bbe0 --- /dev/null +++ b/packages/cli/test/convert.test.ts @@ -0,0 +1,129 @@ +import { expect, test } from "bun:test"; +import { + mkdtempSync, + writeFileSync, + existsSync, + readFileSync, + rmSync, + mkdirSync, +} from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { spawnSync } from "node:child_process"; +import { convertMedia } from "../src/convert.ts"; + +const available = !!Bun.which("ffmpeg") && !!Bun.which("ffprobe"); +(available ? test : test.skip)( + "conversion writes a validated new bundle and never overwrites input/output", + async () => { + const root = mkdtempSync(join(tmpdir(), "camkit-convert-test-")); + try { + const timing = { + start: 0, + duration: 705600000, + mediaStart: 0, + mediaDuration: 705600000, + scalar: 1, + }; + const source = join(root, "project.tscproj"), + out = join(root, "result.cmproj"); + const d = { + editRate: 705600000, + videoFormatFrameRate: 30, + sourceBin: [ + { + id: 1, + src: "recording.trec", + sourceTracks: [ + { type: 0, trackRect: [0, 0, 16, 16], sampleRate: 30 }, + { + type: 2, + trackRect: [0, 0, 0, 0], + sampleRate: 16000, + numChannels: 1, + }, + ], + }, + ], + timeline: { + sceneTrack: { + scenes: [ + { + csml: { + tracks: [ + { + medias: [ + { + id: 2, + _type: "ScreenVMFile", + src: 1, + trackNumber: 0, + ...timing, + }, + ], + }, + { + medias: [ + { + id: 3, + _type: "AMFile", + src: 1, + trackNumber: 1, + ...timing, + }, + ], + }, + ], + }, + }, + ], + }, + }, + }; + const original = JSON.stringify(d); + writeFileSync(source, original); + const ff = spawnSync( + "ffmpeg", + [ + "-v", + "error", + "-f", + "lavfi", + "-i", + "color=s=16x16:r=30:d=1", + "-f", + "lavfi", + "-i", + "sine=sample_rate=16000:duration=1", + "-map", + "0:v", + "-map", + "1:a", + "-c:v", + "libx264", + "-c:a", + "aac", + "-f", + "mp4", + join(root, "recording.trec"), + ], + { encoding: "utf8" }, + ); + expect(ff.status).toBe(0); + await convertMedia({ project: source, out, dryRun: true }); + expect(existsSync(out)).toBe(false); + await expect(convertMedia({ project: source, out })).rejects.toThrow( + /cursor/, + ); + await convertMedia({ project: source, out, acknowledgeCursorLoss: true }); + expect(existsSync(join(out, "sync-groups.json"))).toBe(true); + expect(readFileSync(source, "utf8")).toBe(original); + await expect( + convertMedia({ project: source, out, acknowledgeCursorLoss: true }), + ).rejects.toThrow(/already exists/); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + 30000, +); diff --git a/packages/cli/test/media.test.ts b/packages/cli/test/media.test.ts index c8ab346..6ce30e4 100644 --- a/packages/cli/test/media.test.ts +++ b/packages/cli/test/media.test.ts @@ -85,3 +85,10 @@ test("shapeReplicateOutput fills null end times from the next chunk", () => { test("shapeReplicateOutput tolerates empty output", () => { expect(shapeReplicateOutput({})).toEqual({ duration: null, text: "", words: [], segments: [] }); }); + + +test("shapeWhisperCpp excludes reversed token offsets while retaining segment text", () => { + const raw = shapeWhisperCpp({transcription:[{offsets:{from:1000,to:3000},text:" test words",tokens:[{text:" test",offsets:{from:2000,to:1000}},{text:" words",offsets:{from:2200,to:3000}}]}]}); + expect(raw.words).toEqual([{word:" words",start:2.2,end:3}]); + expect(raw.text).toBe("test words"); +}); diff --git a/packages/core/src/convert.ts b/packages/core/src/convert.ts new file mode 100644 index 0000000..4d750c3 --- /dev/null +++ b/packages/core/src/convert.ts @@ -0,0 +1,173 @@ +/** Pure planning for converting one uncut recording to separate editable media. */ +import { assertSimpleTimeline, setTiming, type SyncGroup } from "./rebuild.ts"; +import { clipSrc, tracks } from "./project.ts"; + +export interface ConvertedStream { + trackNumber: number; + file: string; + kind: "video" | "audio"; + width?: number; + height?: number; + sampleRate?: number; + channels?: number; +} +export function planSeparateMedia(doc: any, streams: ConvertedStream[]) { + assertSimpleTimeline(doc); + const ts = tracks(doc); + const placed = ts.flatMap((t) => t.medias ?? []); + const ids = new Set(placed.map(clipSrc)); + if (ids.size !== 1 || !placed.length) + throw new Error( + "convert-media requires a single uncut recording on the timeline.", + ); + const sourceId = clipSrc(placed[0])!; + const source = doc.sourceBin.find((s: any) => s.id === sourceId); + if (!source || !/\.trec$/i.test(source.src)) + throw new Error("convert-media expects a TREC source."); + const duration = placed[0].duration; + if ( + !Number.isSafeInteger(duration) || + duration <= 0 || + !Number.isFinite(doc.editRate) || + doc.editRate <= 0 || + !Number.isFinite(doc.videoFormatFrameRate) || + doc.videoFormatFrameRate <= 0 + ) + throw new Error("Invalid recording timing."); + const leaves: { clip: any; parent: any; track: any; trackIndex: number }[] = + []; + ts.forEach((track, trackIndex) => { + if ((track.medias ?? []).length > 1) + throw new Error("convert-media requires uncut tracks."); + for (const parent of track.medias ?? []) { + if ( + parent.start !== 0 || + parent.mediaStart !== 0 || + parent.duration !== duration || + parent.mediaDuration !== duration + ) + throw new Error( + "convert-media requires aligned, untrimmed clips starting at zero.", + ); + if ( + parent._type === "UnifiedMedia" && + Object.keys(parent.parameters ?? {}).length + ) + throw new Error("Cannot flatten UnifiedMedia parent parameters."); + for (const clip of parent._type === "UnifiedMedia" + ? [parent.video, parent.audio] + : [parent]) + leaves.push({ clip, parent, track, trackIndex }); + } + }); + if (leaves.length < 2) + throw new Error("convert-media expects at least two synchronized streams."); + const out = structuredClone(doc); + const sourceBin: any[] = [], + newTracks: any[] = [], + attrs: any[] = []; + let nextId = 1; + const scan = (v: any) => { + if (!v || typeof v !== "object") return; + if (Number.isSafeInteger(v.id)) nextId = Math.max(nextId, v.id + 1); + Object.values(v).forEach(scan); + }; + scan(doc); + const syncGroup: SyncGroup = { sources: [] }; + const used = new Set(); + for (const { clip, parent, track, trackIndex } of leaves) { + const n = clip.trackNumber; + if (!Number.isInteger(n) || used.has(n)) + throw new Error("Each timeline stream must have a unique trackNumber."); + used.add(n); + const candidates = streams.filter((s) => s.trackNumber === n); + if (candidates.length !== 1) + throw new Error(`Missing or ambiguous converted stream ${n}.`); + const s = candidates[0], + audio = clip._type === "AMFile"; + if ((audio ? "audio" : "video") !== s.kind) + throw new Error(`Stream type mismatch for ${n}.`); + if (!/^\.\/media\/[a-zA-Z0-9_-]+\.(mp4|wav)$/.test(s.file)) + throw new Error("Converted media must use a relative ./media filename."); + if ( + audio + ? !( + Number.isInteger(s.sampleRate) && + s.sampleRate! > 0 && + Number.isInteger(s.channels) && + s.channels! > 0 + ) + : !( + Number.isInteger(s.width) && + s.width! > 0 && + Number.isInteger(s.height) && + s.height! > 0 + ) + ) + throw new Error("Invalid converted stream dimensions or audio format."); + const id = nextId++, + copy = structuredClone(clip); + copy.id = nextId++; + copy.src = id; + copy.trackNumber = 0; + copy._type = audio ? "AMFile" : "VMFile"; + copy.attributes = { + ...copy.attributes, + ident: `Recording ${n + 1} (${audio ? "audio" : "video"})`, + }; + if (copy.attributes.sourceFileOffset) + throw new Error("Unsupported sourceFileOffset."); + copy.metadata = { ...parent.metadata, ...copy.metadata }; + for (const k of Object.keys(copy.parameters ?? {})) + if (/cursor/i.test(k)) delete copy.parameters[k]; + setTiming(copy, 0, 0, duration); + const rate = audio ? s.sampleRate! : doc.editRate; + const rect = audio ? [0, 0, 0, 0] : [0, 0, s.width, s.height]; + const old = source.sourceTracks[n]; + sourceBin.push({ + id, + src: s.file, + rect, + loudnessNormalization: source.loudnessNormalization ?? true, + sourceTracks: [ + { + range: [0, Math.round((duration / doc.editRate) * rate)], + type: audio ? 2 : 0, + editRate: rate, + trackRect: rect, + sampleRate: audio ? s.sampleRate : doc.videoFormatFrameRate, + bitDepth: audio ? 16 : 0, + numChannels: audio ? s.channels : 0, + integratedLUFS: old?.integratedLUFS ?? 100, + peakLevel: old?.peakLevel ?? -1, + tag: 0, + metaData: s.file.split("/").pop() + ";", + parameters: {}, + }, + ], + }); + newTracks.push({ + ...structuredClone(track), + trackIndex: newTracks.length, + medias: [copy], + }); + attrs.push( + structuredClone(doc.timeline.trackAttributes?.[trackIndex] ?? {}), + ); + syncGroup.sources.push({ src: id, offset: 0 }); + } + out.sourceBin = sourceBin; // no unused TREC media-bin entries remain to trigger thumbnail decoding + out.timeline.sceneTrack.scenes[0].csml.tracks = newTracks; + out.timeline.trackAttributes = attrs; + return { + doc: out, + sourceId, + sourcePath: source.src, + durationSeconds: duration / doc.editRate, + syncGroups: [syncGroup], + warnings: [ + "Native cursor data/effects are not preserved. The decoded screen video may omit the cursor.", + "Only the placed recording is included; unused media-bin items remain in the original project.", + ], + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9eee94a..667b204 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,3 +4,5 @@ export * from "./rebuild.ts"; export * from "./silences.ts"; export * from "./transcript.ts"; export * from "./captions.ts"; + +export * from "./convert.ts"; diff --git a/packages/core/src/rebuild.ts b/packages/core/src/rebuild.ts index c4b3e5c..67ff1b0 100644 --- a/packages/core/src/rebuild.ts +++ b/packages/core/src/rebuild.ts @@ -1,24 +1,35 @@ -/** - * Rebuild planning: rewrite the timeline to keep only the listed source - * segments, in order, ripple-laid with no gaps (seconds → editRate units). - * - * A single screen recording occupies two tracks (ScreenVMFile screen capture + - * UnifiedMedia camera/audio) sharing identical timing; they must be cut - * together to stay in sync. So every track a source touches gets a clone of - * that track's template clip at the same timeline position — one template per - * (src, track), because an already-cut timeline holds many clips of the same - * source per track but each kept segment clones once per track. - */ +/** Source-time rough cuts for simple media timelines. Existing edits are never flattened silently. */ import { clipSrc, tracks } from "./project.ts"; import { secondsToFrameUnits, unitsToSeconds } from "./time.ts"; export interface KeepSeg { src: number; - start: number; // seconds - end: number; // seconds + start: number; + end: number; +} +/** First source is the reference clock; offset is source time minus reference time, in seconds. */ +export interface SyncGroup { + sources: { src: number; offset?: number }[]; +} +export interface RebuildOptions { + syncGroups?: SyncGroup[]; } -/** Parse "1:159.8-179.2 2:46.3-60.0" → [{src,start,end}, ...] (order preserved). */ +function validateSegment(s: any): asserts s is KeepSeg { + if ( + !s || + !Number.isSafeInteger(s.src) || + s.src < 0 || + !Number.isFinite(s.start) || + !Number.isFinite(s.end) || + s.start < 0 + ) { + throw new Error( + "Keep segments require a non-negative integer src and finite non-negative start/end seconds.", + ); + } + if (s.end <= s.start) throw new Error("Keep segment has end <= start."); +} export function parseKeep(spec: string): KeepSeg[] { return spec .trim() @@ -26,102 +37,288 @@ export function parseKeep(spec: string): KeepSeg[] { .filter(Boolean) .map((tok) => { const m = tok.match(/^(\d+):([\d.]+)-([\d.]+)$/); - if (!m) throw new Error(`Bad keep segment "${tok}" (want src:start-end, e.g. 2:46.3-60.0)`); - const [, src, start, end] = m; - if (+end <= +start) throw new Error(`Segment "${tok}" has end <= start`); - return { src: +src, start: +start, end: +end }; + if (!m) + throw new Error(`Bad keep segment "${tok}" (want src:start-end).`); + const s = { src: +m[1], start: +m[2], end: +m[3] }; + validateSegment(s); + return s; }); } - -/** Parse a --from file's JSON: [{src,start,end}] or {keep:[...]}. */ export function parseKeepJson(json: any): KeepSeg[] { - const arr = Array.isArray(json) ? json : json.keep; - return arr.map((s: any) => ({ src: s.src, start: s.start, end: s.end })); + const arr = Array.isArray(json) ? json : json?.keep; + if (!Array.isArray(arr)) + throw new Error("Expected a keep array or {keep:[...]}."); + return arr.map((s) => { + validateSegment(s); + return { src: s.src, start: s.start, end: s.end }; + }); } -/** Set a clip's timeline position + source in/out, recursing into video/audio. */ -export function setTiming(clip: any, startU: number, mediaStartU: number, durU: number): void { - clip.start = startU; - clip.duration = durU; - clip.mediaStart = mediaStartU; - clip.mediaDuration = durU; - for (const sub of ["video", "audio"] as const) { +export function setTiming( + clip: any, + startU: number, + mediaStartU: number, + durU: number, +): void { + Object.assign(clip, { + start: startU, + duration: durU, + mediaStart: mediaStartU, + mediaDuration: durU, + }); + for (const sub of ["video", "audio"] as const) if (clip[sub]) setTiming(clip[sub], startU, mediaStartU, durU); +} + +function populated(value: any): boolean { + return ( + value != null && + (typeof value === "object" ? Object.keys(value).length > 0 : !!value) + ); +} +/** Fail closed for timing-dependent properties we cannot safely retime. Static framing/gain is supported. */ +export function assertSimpleClip(m: any): void { + if ( + !["ScreenVMFile", "VMFile", "AMFile", "UnifiedMedia"].includes(m?._type) + ) { + throw new Error( + `Unsupported media ${m?._type}; rebuild cannot preserve this timeline.`, + ); + } + if ( + (m.scalar ?? 1) !== 1 || + populated(m.effects) || + populated(m.animationTracks) || + populated(m.transitions) || + populated(m.markers) || + populated(m.metadata?.audiateLinkedSession) + ) { + throw new Error( + "Rebuild cannot preserve effects, animations, transitions, speed changes, markers or Audiate links.", + ); + } + for (const p of Object.values(m.parameters ?? {}) as any[]) { + if ( + p && + typeof p === "object" && + Object.keys(p).some( + (k) => !["type", "defaultValue", "interp"].includes(k), + ) + ) { + throw new Error("Rebuild cannot preserve keyframed parameters."); + } + } + if (m._type === "UnifiedMedia") { + if (!m.video || !m.audio || m.video.src !== m.audio.src) + throw new Error("Unsupported mixed-source UnifiedMedia."); + for (const sub of [m.video, m.audio]) { + assertSimpleClip(sub); + for (const k of ["start", "duration", "mediaStart", "mediaDuration"]) { + if (sub[k] !== m[k]) + throw new Error( + "UnifiedMedia timing differs between its parent and child streams.", + ); + } + } + } +} +export function assertSimpleTimeline(doc: any): void { + if (doc.timeline?.sceneTrack?.scenes?.length !== 1) + throw new Error("Only single-scene timelines are supported."); + if ( + populated(doc.timeline.markers) || + populated(doc.timeline.captionData) || + populated(doc.timeline.parameters) + ) { + throw new Error("Unsupported timeline markers, captions or parameters."); + } + for (const t of tracks(doc)) { + const seen = new Set(); + for (const m of t.medias ?? []) { + assertSimpleClip(m); + const src = clipSrc(m); + if (src == null) throw new Error("Media has no source."); + if (seen.has(src)) + throw new Error( + "Already-cut timeline: multiple clips of one source on a track. Use the uncut original.", + ); + seen.add(src); + } } } export interface PlanEntry { src: number; - sourceStart: number; // seconds - sourceEnd: number; // seconds - timelineStart: number; // seconds - timelineEnd: number; // seconds + sourceStart: number; + sourceEnd: number; + timelineStart: number; + timelineEnd: number; trackCount: number; + sources: { src: number; sourceStart: number; sourceEnd: number }[]; } - export interface RebuildPlan { entries: PlanEntry[]; - /** trackIdx → replacement medias array */ newMedias: Record; totalUnits: number; totalSeconds: number; segmentCount: number; } -/** Compute the rebuild without touching the doc or any files. */ -export function planRebuild(doc: any, segs: KeepSeg[]): RebuildPlan { - if (!segs.length) throw new Error("No keep segments given."); - const ed = doc.editRate; - const fps = doc.videoFormatFrameRate; - if (!fps) throw new Error("Project has no videoFormatFrameRate; can't snap cuts to frames."); - const ts = tracks(doc); - - // Map each source id → the timeline clips that reference it (a src can appear - // on multiple tracks, e.g. a screen capture + its synced camera/audio). - const templates: Record = {}; - ts.forEach((t: any, trackIdx: number) => { - for (const m of t.medias ?? []) { - const src = clipSrc(m); - if (src == null) continue; - const list = (templates[src] ??= []); - if (!list.some((t) => t.trackIdx === trackIdx)) list.push({ trackIdx, clip: m }); +export function planRebuild( + doc: any, + segs: KeepSeg[], + opts: RebuildOptions = {}, +): RebuildPlan { + if (!Array.isArray(segs) || !segs.length) + throw new Error("No keep segments given."); + segs.forEach(validateSegment); + const ed = doc.editRate, + fps = doc.videoFormatFrameRate; + if (!Number.isFinite(ed) || ed <= 0 || !Number.isFinite(fps) || fps <= 0) + throw new Error("Invalid editRate or videoFormatFrameRate."); + assertSimpleTimeline(doc); + const templates = new Map(); + tracks(doc).forEach((t, trackIdx) => { + for (const clip of t.medias ?? []) { + const src = clipSrc(clip)!; + templates.set(src, [...(templates.get(src) ?? []), { trackIdx, clip }]); } }); - for (const s of segs) { - if (!templates[s.src]) throw new Error(`No timeline clip uses src ${s.src}; can't rebuild from it.`); + const groups = new Map(); + const grouped = new Set(); + if (opts.syncGroups != null && !Array.isArray(opts.syncGroups)) + throw new Error("syncGroups must be an array."); + for (const group of opts.syncGroups ?? []) { + if (!Array.isArray(group?.sources) || group.sources.length < 2) + throw new Error("A sync group needs at least two sources."); + const occupied = new Set(); + for (const s of group.sources) { + if ( + !Number.isSafeInteger(s.src) || + grouped.has(s.src) || + !Number.isFinite(s.offset ?? 0) + ) + throw new Error("Invalid or repeated synchronized source."); + if (!templates.has(s.src)) + throw new Error(`No timeline clip uses src ${s.src}.`); + for (const t of templates.get(s.src)!) { + if (occupied.has(t.trackIdx)) + throw new Error("Synchronized sources must occupy distinct tracks."); + occupied.add(t.trackIdx); + } + grouped.add(s.src); + } + if ((group.sources[0].offset ?? 0) !== 0) + throw new Error("The reference source offset must be zero."); + groups.set(group.sources[0].src, group); + } + // Source media ranges must be known; never infer file length from an edited clip. + function checkBounds(clip: any, lo: number, hi: number) { + for (const m of clip._type === "UnifiedMedia" + ? [clip.video, clip.audio] + : [clip]) { + const bins = (doc.sourceBin ?? []).filter((s: any) => s.id === m.src); + if (bins.length !== 1) + throw new Error(`Missing or duplicate source ${m.src}.`); + const sts = bins[0].sourceTracks ?? []; + const selected = m.trackNumber == null ? sts : [sts[m.trackNumber]]; + if (!selected.length) + throw new Error(`Unknown duration for source ${m.src}.`); + for (const st of selected) { + if ( + !st || + !Array.isArray(st.range) || + st.range.length !== 2 || + !Number.isFinite(st.editRate) || + st.editRate <= 0 || + !st.range.every(Number.isFinite) + ) + throw new Error(`Invalid range for source ${m.src}.`); + if ( + lo < st.range[0] / st.editRate - 1e-9 || + hi > st.range[1] / st.editRate + 1e-9 + ) + throw new Error(`Keep range exceeds source ${m.src} bounds.`); + } + } + } + let nextId = 1; + function scan(v: any) { + if (!v || typeof v !== "object") return; + if (Number.isSafeInteger(v.id)) nextId = Math.max(nextId, v.id + 1); + Object.values(v).forEach(scan); + } + scan(doc); + function reId(m: any) { + m.id = nextId++; + for (const sub of ["video", "audio"]) if (m[sub]) reId(m[sub]); } - - // Fresh ids so cloned clips never collide. - let nextId = 1 + Math.max(...JSON.stringify(doc).match(/"id"\s*:\s*(\d+)/g)!.map((m) => +m.replace(/\D/g, ""))); - const reId = (clip: any) => { - clip.id = nextId++; - for (const sub of ["video", "audio"] as const) if (clip[sub]) clip[sub].id = nextId++; - }; - const newMedias: Record = {}; - ts.forEach((_: any, i: number) => (newMedias[i] = [])); - let posU = 0; + tracks(doc).forEach((_, i) => (newMedias[i] = [])); const entries: PlanEntry[] = []; + let posU = 0; for (const s of segs) { - const startU = secondsToFrameUnits(s.start, ed, fps); - const durU = secondsToFrameUnits(s.end, ed, fps) - startU; - for (const { trackIdx, clip } of templates[s.src]) { - const clone = JSON.parse(JSON.stringify(clip)); - reId(clone); - setTiming(clone, posU, startU, durU); - newMedias[trackIdx].push(clone); + if (!templates.has(s.src)) + throw new Error( + `No timeline clip uses src ${s.src}; can't rebuild from it.`, + ); + if (grouped.has(s.src) && !groups.has(s.src)) + throw new Error( + "Keep ranges must use the sync group's reference source.", + ); + const lo = secondsToFrameUnits(s.start, ed, fps), + hi = secondsToFrameUnits(s.end, ed, fps); + const dur = hi - lo; + if ( + !Number.isSafeInteger(lo) || + !Number.isSafeInteger(hi) || + dur <= 0 || + !Number.isSafeInteger(posU + dur) + ) + throw new Error( + "Keep range collapses after frame rounding or exceeds safe timing precision.", + ); + const sources = []; + let trackCount = 0; + for (const member of groups.get(s.src)?.sources ?? [ + { src: s.src, offset: 0 }, + ]) { + // Round offset once in project units, never independently round source in/out. + const offsetU = Math.round((member.offset ?? 0) * ed); + const sourceLo = lo + offsetU, + sourceHi = sourceLo + dur; + if (!Number.isSafeInteger(sourceLo) || !Number.isSafeInteger(sourceHi)) + throw new Error("Invalid synchronized timing."); + for (const { trackIdx, clip } of templates.get(member.src)!) { + checkBounds( + clip, + s.start + (member.offset ?? 0), + s.end + (member.offset ?? 0), + ); + checkBounds(clip, sourceLo / ed, sourceHi / ed); + const copy = structuredClone(clip); + reId(copy); + setTiming(copy, posU, sourceLo, dur); + newMedias[trackIdx].push(copy); + trackCount++; + } + sources.push({ + src: member.src, + sourceStart: sourceLo / ed, + sourceEnd: sourceHi / ed, + }); } entries.push({ src: s.src, - sourceStart: s.start, - sourceEnd: s.end, - timelineStart: unitsToSeconds(posU, ed), - timelineEnd: unitsToSeconds(posU + durU, ed), - trackCount: templates[s.src].length, + sourceStart: lo / ed, + sourceEnd: hi / ed, + timelineStart: posU / ed, + timelineEnd: (posU + dur) / ed, + trackCount, + sources, }); - posU += durU; + posU += dur; } - return { entries, newMedias, @@ -130,8 +327,6 @@ export function planRebuild(doc: any, segs: KeepSeg[]): RebuildPlan { segmentCount: segs.length, }; } - -/** Mutate the doc's tracks to the planned medias. */ export function applyRebuild(doc: any, plan: RebuildPlan): void { - tracks(doc).forEach((t: any, i: number) => (t.medias = plan.newMedias[i])); + tracks(doc).forEach((t, i) => (t.medias = plan.newMedias[i])); } diff --git a/packages/core/test/convert.test.ts b/packages/core/test/convert.test.ts new file mode 100644 index 0000000..cc06b98 --- /dev/null +++ b/packages/core/test/convert.test.ts @@ -0,0 +1,133 @@ +import { expect, test } from "bun:test"; +import { planSeparateMedia, type ConvertedStream } from "../src/convert.ts"; +import { planRebuild } from "../src/rebuild.ts"; + +export function fixture(): any { + const timing = { + start: 0, + duration: 705600000, + mediaStart: 0, + mediaDuration: 705600000, + scalar: 1, + }; + return { + version: "10.0", + editRate: 705600000, + videoFormatFrameRate: 30, + width: 16, + height: 16, + sourceBin: [ + { + id: 1, + src: "recording.trec", + sourceTracks: [ + { + type: 0, + range: [0, 1000], + editRate: 1000, + trackRect: [0, 0, 16, 16], + sampleRate: 30, + }, + { + type: 2, + range: [0, 16000], + editRate: 16000, + trackRect: [0, 0, 0, 0], + sampleRate: 16000, + numChannels: 1, + }, + ], + }, + ], + timeline: { + trackAttributes: [{ videoHidden: false }, { audioMuted: false }], + sceneTrack: { + scenes: [ + { + csml: { + tracks: [ + { + trackIndex: 0, + medias: [ + { + id: 2, + _type: "ScreenVMFile", + src: 1, + trackNumber: 0, + ...timing, + parameters: { + scale0: { + type: "double", + defaultValue: 0.5, + interp: "eioe", + }, + cursorScale: 2, + }, + effects: [], + }, + ], + }, + { + trackIndex: 1, + medias: [ + { + id: 3, + _type: "AMFile", + src: 1, + trackNumber: 1, + ...timing, + attributes: { gain: 0.7 }, + parameters: {}, + }, + ], + }, + ], + }, + }, + ], + }, + }, + }; +} +const streams: ConvertedStream[] = [ + { + trackNumber: 0, + file: "./media/stream-0.mp4", + kind: "video", + width: 16, + height: 16, + }, + { + trackNumber: 1, + file: "./media/stream-1.wav", + kind: "audio", + sampleRate: 16000, + channels: 1, + }, +]; +test("conversion preserves framing, gain and track flags with no TREC reference", () => { + const d = fixture(), + before = JSON.stringify(d); + const p = planSeparateMedia(d, streams); + expect(JSON.stringify(d)).toBe(before); + expect(JSON.stringify(p.doc)).not.toContain(".trec"); + const ts = p.doc.timeline.sceneTrack.scenes[0].csml.tracks; + expect(ts[0].medias[0].parameters.scale0.defaultValue).toBe(0.5); + expect(ts[0].medias[0].parameters.cursorScale).toBeUndefined(); + expect(ts[1].medias[0].attributes.gain).toBe(0.7); + expect(p.doc.timeline.trackAttributes).toEqual(d.timeline.trackAttributes); + const r = planRebuild( + p.doc, + [{ src: p.syncGroups[0].sources[0].src, start: 0, end: 0.5 }], + { syncGroups: p.syncGroups }, + ); + expect(r.newMedias[0][0].duration).toBe(r.newMedias[1][0].duration); +}); +test("conversion refuses incomplete stream maps and trimmed timelines", () => { + expect(() => planSeparateMedia(fixture(), streams.slice(0, 1))).toThrow( + /Missing/, + ); + const d = fixture(); + d.timeline.sceneTrack.scenes[0].csml.tracks[0].medias[0].mediaStart = 100; + expect(() => planSeparateMedia(d, streams)).toThrow(/untrimmed/); +}); diff --git a/packages/core/test/rebuild.test.ts b/packages/core/test/rebuild.test.ts index cbe4a9e..98f4aca 100644 --- a/packages/core/test/rebuild.test.ts +++ b/packages/core/test/rebuild.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "bun:test"; -import { parseKeep, parseKeepJson, planRebuild, applyRebuild } from "../src/rebuild.ts"; +import { + parseKeep, + parseKeepJson, + planRebuild, + applyRebuild, +} from "../src/rebuild.ts"; import { tracks } from "../src/project.ts"; const ED = 705600000; @@ -26,8 +31,24 @@ function makeDoc(): any { duration: 200 * ED, mediaStart: 0, mediaDuration: 200 * ED, - video: { _type: "VMFile", id: 12, src: 1, start: 0, duration: 200 * ED, mediaStart: 0, mediaDuration: 200 * ED }, - audio: { _type: "AMFile", id: 13, src: 1, start: 0, duration: 200 * ED, mediaStart: 0, mediaDuration: 200 * ED }, + video: { + _type: "VMFile", + id: 12, + src: 1, + start: 0, + duration: 200 * ED, + mediaStart: 0, + mediaDuration: 200 * ED, + }, + audio: { + _type: "AMFile", + id: 13, + src: 1, + start: 0, + duration: 200 * ED, + mediaStart: 0, + mediaDuration: 200 * ED, + }, }; const solo = { _type: "ScreenVMFile", @@ -44,8 +65,16 @@ function makeDoc(): any { height: 1080, videoFormatFrameRate: 30, sourceBin: [ - { id: 1, src: "rec1.trec", sourceTracks: [{ range: [0, 200 * 44100], editRate: 44100 }] }, - { id: 2, src: "rec2.trec", sourceTracks: [{ range: [0, 100 * 44100], editRate: 44100 }] }, + { + id: 1, + src: "rec1.trec", + sourceTracks: [{ range: [0, 200 * 44100], editRate: 44100 }], + }, + { + id: 2, + src: "rec2.trec", + sourceTracks: [{ range: [0, 100 * 44100], editRate: 44100 }], + }, ], timeline: { sceneTrack: { @@ -130,19 +159,21 @@ describe("planRebuild", () => { expect(t1.audio.duration).toBe(5 * ED); }); - test("dedups templates per (src, track) on an already-cut timeline", () => { + test("refuses an already-cut timeline instead of flattening later clip settings", () => { const doc = makeDoc(); // simulate a prior cut: two clips of src 1 on track 0 - const second = JSON.parse(JSON.stringify(doc.timeline.sceneTrack.scenes[0].csml.tracks[0].medias[0])); + const second = JSON.parse( + JSON.stringify( + doc.timeline.sceneTrack.scenes[0].csml.tracks[0].medias[0], + ), + ); second.id = 99; second.start = 50 * ED; doc.timeline.sceneTrack.scenes[0].csml.tracks[0].medias.push(second); - const plan = planRebuild(doc, [{ src: 1, start: 0, end: 5 }]); - // one clone per track, not per existing clip - expect(plan.entries[0].trackCount).toBe(2); - expect(plan.newMedias[0]).toHaveLength(1); - expect(plan.newMedias[1]).toHaveLength(1); + expect(() => planRebuild(doc, [{ src: 1, start: 0, end: 5 }])).toThrow( + /Already-cut/, + ); }); test("assigns fresh non-colliding ids, including nested video/audio", () => { @@ -172,7 +203,9 @@ describe("planRebuild", () => { }); test("rejects a src with no timeline clip", () => { - expect(() => planRebuild(makeDoc(), [{ src: 7, start: 0, end: 1 }])).toThrow(/No timeline clip uses src 7/); + expect(() => + planRebuild(makeDoc(), [{ src: 7, start: 0, end: 1 }]), + ).toThrow(/No timeline clip uses src 7/); }); test("rejects an empty segment list", () => { @@ -198,3 +231,109 @@ describe("applyRebuild", () => { expect(ts[0].medias[0].duration).toBe(5 * ED); }); }); + +describe("validation and synchronized sources", () => { + test("rejects malformed JSON, numeric strings, NaN and zero-frame cuts", () => { + for (const input of [ + null, + {}, + { keep: [{}] }, + [{ src: 1, start: "0", end: 2 }], + [{ src: 1, start: NaN, end: 2 }], + ]) + expect(() => parseKeepJson(input)).toThrow(); + expect(() => parseKeep("1:1..2-4")).toThrow(); + expect(() => + planRebuild(makeDoc(), [{ src: 1, start: 0.001, end: 0.002 }]), + ).toThrow(/rounding/); + expect(() => + planRebuild(makeDoc(), [{ src: 1, start: 0, end: 201 }]), + ).toThrow(/bounds/); + }); + test("rejects effects, keyframes, captions, speed changes and extra scenes", () => { + for (const update of [ + (m: any) => (m.effects = [{ id: 30 }]), + (m: any) => (m.animationTracks = { x: [1] }), + (m: any) => (m.scalar = 2), + (m: any) => (m._type = "Callout"), + (m: any) => (m.parameters = { scale: { keyframes: [1] } }), + ]) { + const d = makeDoc(); + update(tracks(d)[0].medias[0]); + expect(() => planRebuild(d, [{ src: 1, start: 0, end: 1 }])).toThrow(); + } + const d = makeDoc(); + d.timeline.sceneTrack.scenes.push({ csml: { tracks: [] } }); + expect(() => planRebuild(d, [{ src: 1, start: 0, end: 1 }])).toThrow( + /single-scene/, + ); + }); + function separate(): any { + const d = makeDoc(); + tracks(d)[0].medias.pop(); + const audio = structuredClone(tracks(d)[1].medias[0].audio); + audio.src = 2; + audio.trackNumber = 0; + tracks(d)[1].medias = [audio]; + return d; + } + test("offset audio shares duration and timeline boundaries without serializing sources", () => { + const d = separate(); + const p = planRebuild( + d, + [ + { src: 1, start: 1, end: 2 }, + { src: 1, start: 4, end: 6 }, + ], + { syncGroups: [{ sources: [{ src: 1 }, { src: 2, offset: 0.007 }] }] }, + ); + expect(p.totalSeconds).toBe(3); + expect(p.newMedias[1].map((m) => m.start)).toEqual( + p.newMedias[0].map((m) => m.start), + ); + expect(p.newMedias[1][0].mediaStart - p.newMedias[0][0].mediaStart).toBe( + Math.round(0.007 * ED), + ); + expect( + new Set( + Object.values(p.newMedias) + .flat() + .map((m) => m.id), + ).size, + ).toBe(4); + expect(p.entries[0].sources).toHaveLength(2); + }); + test("validates every member's bounds, duplicate membership and shared tracks", () => { + const d = separate(); + expect(() => + planRebuild(d, [{ src: 1, start: 99, end: 101 }], { + syncGroups: [{ sources: [{ src: 1 }, { src: 2 }] }], + }), + ).toThrow(/bounds/); + expect(() => + planRebuild(d, [{ src: 1, start: 0, end: 1 }], { + syncGroups: [{ sources: [{ src: 1 }, { src: 2, offset: -1 }] }], + }), + ).toThrow(/bounds/); + expect(() => + planRebuild(d, [{ src: 1, start: 0, end: 1 }], { + syncGroups: [{ sources: [{ src: 1 }, { src: 1 }] }], + }), + ).toThrow(); + expect(() => + planRebuild(makeDoc(), [{ src: 1, start: 0, end: 1 }], { + syncGroups: [{ sources: [{ src: 1 }, { src: 2 }] }], + }), + ).toThrow(/distinct tracks/); + expect(() => + planRebuild(d, [{ src: 2, start: 0, end: 1 }], { + syncGroups: [{ sources: [{ src: 1 }, { src: 2 }] }], + }), + ).toThrow(/reference/); + }); + test("reports actual snapped boundaries", () => { + const p = planRebuild(makeDoc(), [{ src: 1, start: 1.01, end: 2.01 }]); + expect(p.entries[0].sourceStart).toBe(1); + expect(p.entries[0].sourceEnd).toBe(2); + }); +});