Timeline Coordinate Systems¶
Sapari uses two coordinate systems for positioning edits and captions on the timeline. Understanding the distinction is critical when working with the frontend.
Main-Video-Relative Time (Backend)¶
The backend stores all edit positions relative to the main video, where 0 = the first frame of the uploaded video. This is the canonical coordinate system — it never changes regardless of what intro/outro assets exist.
All Edit records in the database use this coordinate system. The start_ms and end_ms fields always reference the main video timeline.
For multi-clip projects, the "main video" is the concatenation of all Clip rows in display_order, and edit.end_ms is bounded by SUM(ClipFile.duration_ms) across the project's clips. EditService.create and EditService.update enforce end_ms ≤ total via clip.utils.get_project_total_duration_ms. See that helper's docstring for how the ready/not-ready/empty states are handled; the short version is that edits on a project with no clips are rejected, edits on a project where any clip is still processing skip the check, and the bulk-insert worker path is intentionally unvalidated (trusted caller).
Effective Timeline (Frontend)¶
When an intro clip exists, the frontend needs to display it before the main video on the timeline. This shifts everything forward by introOffsetMs (the intro's duration).
Intro: [0ms ── 3000ms]
Main video: [3000ms ────────────────────── 63000ms]
Edit at: [8000ms ── 11000ms] (same edit, shifted by 3000ms)
Outro: [63000ms ── 66000ms]
The effective timeline is what users see and interact with. It includes intro, main video, and outro as one continuous strip.
The Shift: introOffsetMs¶
Converting Backend → Frontend (Display)¶
Add the offset to show edits at correct positions on the effective timeline:
const displayStartMs = edit.start_ms + introOffsetMs;
const displayEndMs = edit.end_ms + introOffsetMs;
Converting Frontend → Backend (Save)¶
Subtract the offset before sending to the API:
const backendStartMs = displayStartMs - introOffsetMs;
const backendEndMs = displayEndMs - introOffsetMs;
Display Data Pattern¶
All coordinate conversion happens once in Dashboard.tsx, producing display* versions of backend data:
| Raw (backend) | Display (frontend) | How |
|---|---|---|
edits |
displayEdits |
Shift non-fixed edits by introOffsetMs, add fixed-position assets at correct effective positions |
captionLines |
displayCaptionLines |
Shift timestamps by introOffsetMs |
Components receive the display* versions and never need to do offset math themselves.
Fixed-Position Assets¶
Intro, outro, watermark, and background audio have fixedPosition set. These assets must not be shifted like normal edits — they have their own positioning logic:
| Asset | Effective Timeline Position |
|---|---|
| Intro | 0 → intro.duration_ms |
| Outro | effectiveTotalDurationMs - outro.duration_ms → effectiveTotalDurationMs |
| Watermark | 0 → effectiveTotalDurationMs (spans entire timeline) |
| Background Audio | 0 → effectiveTotalDurationMs |
The displayEdits computation filters out fixed-position edits from the normal shift, then re-adds them at the correct positions:
// 1. Filter out fixed-position edits
const shiftableEdits = edits.filter(e => !e.fixedPosition);
// 2. Shift normal edits
const shifted = shiftableEdits.map(e => ({
...e,
start_ms: e.start_ms + introOffsetMs,
end_ms: e.end_ms + introOffsetMs,
}));
// 3. Add fixed assets at correct positions
const introEdit = { start_ms: 0, end_ms: introDuration, ... };
const outroEdit = { start_ms: totalDuration - outroDuration, end_ms: totalDuration, ... };
const watermarkEdit = { start_ms: 0, end_ms: totalDuration, ... };
Common Gotchas¶
Shifting fixed-position edits: Raw edits from the backend includes intro/outro/watermark. If you shift ALL edits by introOffsetMs, fixed assets end up at wrong positions. Always filter them out first.
Watermark span: When intro/outro exist, the watermark needs start_ms: 0, end_ms: effectiveTotalDurationMs — not just the main video portion.
Drag operations: When users drag edits on the timeline, the drag position is in effective-timeline coordinates. Subtract introOffsetMs before calling updateEditLocally or saving to the backend.
Seeking to edits: When seeking the video player to an edit from raw edits state, add introOffsetMs to get the correct effective-timeline position.