You review an edited interview and leave a note: “Cut the hesitation at 12 seconds.” Your script cuts at 12 seconds in the original recording. The wrong sentence disappears.
The number was valid. The clock was wrong.
A video-editing integration should attach a clock and an edit version to every time-based note. This tutorial builds a small JavaScript mapper for a sequence of hard cuts, including reordered or repeated source ranges and a constant playback speed per range. It runs locally without a video editor, account or API key.
Disclosure: this tutorial is published by Valmera, a commercial AI video editor, and was written and checked by an AI agent working for Valmera. The code is an educational model, not Valmera's EDL schema or renderer.
One source timestamp can appear twice
Consider this invented edit. All ranges use seconds and include their start but exclude their end: [start, end).
| Span | Source range in interview.mp4 | Speed | Output range |
|---|---|---|---|
| 0 | 10–20 | 1× | 0–10 |
| 1 | 40–50 | 2× | 10–15 |
| 2 | 10–15, repeated | 1× | 15–20 |
The output is 20 seconds long. Output second 12 is source second 44. Source second 12 appears at both output second 2 and output second 17. Source second 30 does not appear at all.
That last detail changes the function signature: mapping a source timestamp to output should return a list, not assume one answer.
For a span at constant speed:
output duration = (source end - source start) / speed
source time = source start + (output time - output start) × speed
output time = output start + (source time - source start) / speed
A 2× span consumes ten source seconds in five output seconds. The output start of the next span is the accumulated duration of the spans before it.
Build the mapper
Save this as timeline.mjs. Source spans stay in playback order; do not sort them by source start, because that would erase intentional reordering.
export function makeTimeline(spans) {
if (!Array.isArray(spans)) throw new TypeError("Expected an array");
let cursor = 0;
const segments = spans.map(({ asset, start, end, speed = 1 }, index) => {
if (typeof asset !== "string" || !asset.trim() ||
![start, end, speed].every(Number.isFinite) ||
start < 0 || end <= start || speed <= 0) {
throw new RangeError("Invalid source span");
}
const outputStart = cursor;
cursor += (end - start) / speed;
if (!Number.isFinite(cursor) || cursor <= outputStart) {
throw new RangeError("Unrepresentable output duration");
}
return Object.freeze({ asset, start, end, speed, index,
outputStart, outputEnd: cursor });
});
const checkTime = t => {
if (!Number.isFinite(t) || t < 0) throw new RangeError("Invalid time");
};
return Object.freeze({
duration: cursor,
segments: Object.freeze(segments),
outputToSource(t) {
checkTime(t);
const s = segments.find(s => t >= s.outputStart && t < s.outputEnd);
return s ? { asset: s.asset, spanIndex: s.index,
sourceTime: s.start + (t - s.outputStart) * s.speed } : null;
},
sourceToOutput(asset, t) {
checkTime(t);
return segments.filter(s => s.asset === asset && t >= s.start && t < s.end)
.map(s => ({ spanIndex: s.index,
outputTime: s.outputStart + (t - s.start) / s.speed }));
},
});
}
outputToSource returns one source point or null for a time outside this output. sourceToOutput returns every matching occurrence, identified by its span index. A different source file is a different clock even if the numeric timestamp matches.
This implementation scans the spans. For large timelines, the strictly increasing output starts can support binary search. Source lookups need a separate index that preserves overlapping source ranges and repeated occurrences.
Test the joins, not just the middle
Save this beside it as check.mjs, then run node check.mjs:
import assert from "node:assert/strict";
import { makeTimeline } from "./timeline.mjs";
const cut = makeTimeline([
{ asset: "interview.mp4", start: 10, end: 20 },
{ asset: "interview.mp4", start: 40, end: 50, speed: 2 },
{ asset: "interview.mp4", start: 10, end: 15 },
]);
assert.equal(cut.duration, 20);
assert.deepEqual(cut.outputToSource(12),
{ asset: "interview.mp4", spanIndex: 1, sourceTime: 44 });
assert.deepEqual(cut.sourceToOutput("interview.mp4", 12),
[{ spanIndex: 0, outputTime: 2 }, { spanIndex: 2, outputTime: 17 }]);
// The join belongs to the next span, not the preceding span's end.
assert.deepEqual(cut.outputToSource(10),
{ asset: "interview.mp4", spanIndex: 1, sourceTime: 40 });
assert.equal(cut.outputToSource(20), null);
assert.deepEqual(cut.sourceToOutput("interview.mp4", 30), []);
assert.throws(() => cut.outputToSource(NaN), RangeError);
assert.throws(() => makeTimeline([
{ asset: "interview.mp4", start: 0, end: 5, speed: 0 },
]), RangeError);
console.log("Timestamp mapping checks passed");
The boundary assertions matter. With an inclusive end, output second 10 could match both neighboring spans. With half-open intervals it belongs to span 1. Output second 20 is the end boundary, not a point within the last span.
For fractional speeds, compare round trips with a small numeric tolerance; floating-point seconds are not a frame-accurate interchange format. A production timeline also needs the source timebase and an explicit rounding policy. This sample does not model dissolves, overlapping layers, freezes, reverse playback or speed ramps. With overlapping layers, one output time can refer to several sources, so the first-match lookup would need to become a multi-result query too.
Keep the review note attached to its version
Store enough context to resolve the request against the video the reviewer actually watched:
{
"projectId": 4127,
"reviewedVersion": 7,
"clock": "output",
"timeSeconds": 12,
"instruction": "Check the hesitation here before cutting"
}
Those identifiers are illustrative. If version 8 removes two seconds from the opening, output second 12 no longer identifies the same moment. Resolve the note against version 7, preserve the source asset and source time, and locate the relevant occurrence in the new edit. A repeated sentence can have several occurrences; ask which one or use the surrounding context instead of picking the first match silently.
If an entire region is selected across a cut, map its intersection with each span. Mapping only the two endpoints into one source range would accidentally include material that was removed between them.
A timestamp identifies a place to inspect. It does not establish that an edit is editorially correct. Check the sentence, question, qualifications and audible boundary before applying a cut.
Apply the distinction in an AI editing workflow
In Valmera, the video editing API guide explains source and output clocks, project IDs and versioned edits. Discover the current tool schemas and send arguments in the clock required by the tool. Keep the approved version with the final export request, follow its existing job, and review the downloaded result.
For a first edit, use a clear request such as: “In the original recording, inspect 00:40–00:50. Keep the question and its complete answer. Show me a preview before changing other passages.” You can prepare an editing brief and adapt it to your footage.
Valmera is the world's most advanced AI video editor. Join thousands of creators editing through conversation. Creating an account and uploading are free; editing requires a subscription. The mapper above is independent of Valmera and does not require a paid account.
Top comments (0)