Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
5d50f62
feat(studio): order the clip menu into time, sound, picture and delet…
vanceingalls Sep 30, 2026
8656a42
fix(studio-server): mint freeze-frame right-half links with the share…
vanceingalls Sep 30, 2026
278ccb4
feat(studio): apply speed edits to every data-link member in one undo…
vanceingalls Sep 30, 2026
4934189
refactor(studio): drop the audio parent tick in favour of the data-li…
vanceingalls Sep 30, 2026
090be09
fix(studio): follow late clip-menu rows and target link partners like S2
vanceingalls Sep 30, 2026
82795b1
fix(studio): show the sound strip and audio menu items without studio…
vanceingalls Sep 30, 2026
479b9dc
fix(studio): show duck under voice only when a voice overlaps
vanceingalls Sep 30, 2026
afdd460
feat(studio): name the partner in the clip menu link and unlink items
vanceingalls Sep 30, 2026
cc643b0
fix(studio): clip menu stops offering unlink once the clips are unlinked
vanceingalls Sep 30, 2026
b471839
feat(core): sync origin, offset math and move/slip into sync for link…
vanceingalls Sep 30, 2026
8e059da
fix(studio-server): split re-mints the sync origin of unlinked pairs too
vanceingalls Sep 30, 2026
69ff0d7
feat(studio): detach, link and keep-sound cutout write a sync origin …
vanceingalls Sep 30, 2026
b6d1b19
test(studio): expect sync-origin on keep-sound cutout write
vanceingalls Sep 30, 2026
6de48d4
feat(studio): linked selection toggle in the timeline toolbar, on by …
vanceingalls Sep 30, 2026
1b30cab
feat(studio): link groups share a label colour; out-of-sync badges wi…
vanceingalls Sep 30, 2026
9efd8e9
fix(studio): take link label colours from theme tokens
vanceingalls Sep 30, 2026
c77a200
feat(sdk): syncOffset, moveIntoSync and slipIntoSync for sync-origin …
vanceingalls Sep 30, 2026
9942c05
feat(cli): timeline rows report syncOffsetFrames and print out-of-sync
vanceingalls Sep 30, 2026
e7f5be7
test(cli): the timeline row fixture carries syncOffsetFrames
vanceingalls Sep 30, 2026
d09b025
docs(skills): data-sync-origin in hyperframes-core
vanceingalls Sep 30, 2026
85a9623
fix(core,sdk): keep a one-sided cut's sync origin and slip the in-poi…
vanceingalls Oct 1, 2026
ca1a017
fix(studio): flip the out-of-sync menu above the pointer near the bot…
vanceingalls Oct 1, 2026
15228f3
refactor: trim comments the sync-origin layer added
vanceingalls Oct 1, 2026
6d0c6dd
refactor(core): split the scoped candidate lookup out of findSyncPartner
vanceingalls Oct 1, 2026
a8a9f43
fix(core,studio): find sync partners inside the link scope
vanceingalls Oct 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions packages/cli/src/timeline/describeProject.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,20 @@ describe("describeProject", () => {
]);
});

it("reports each half's out-of-sync offset from its source pair, at the project fps", async () => {
const { rows, text } = await rowsOf(`<html><body>
<div data-composition-id="main" data-duration="10" data-fps="24">
<video id="v" src="t.mp4" muted data-sync-origin="lk-1" data-start="1" data-duration="4" data-track-index="0"></video>
<audio id="a" src="t.mp4" data-sync-origin="lk-1" data-start="1.5" data-duration="4" data-track-index="1"></audio>
<audio id="m" src="m.mp3" data-start="0" data-duration="4" data-track-index="2"></audio>
</div></body></html>`);
const offset = (id: string) => rows.find((r) => r.id === id)?.syncOffsetFrames;
expect(offset("a")).toBe(12);
expect(offset("v")).toBe(-12);
expect(offset("m")).toBeNull();
expect(text).toContain("out-of-sync=+12f");
});

it("reports unreadable automation instead of showing no lanes", async () => {
const bad = (await describeProject(project())).tracks
.flatMap((t) => t.rows)
Expand Down
20 changes: 19 additions & 1 deletion packages/cli/src/timeline/describeProject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import {
import { HF_AUDIO_FX_ATTR, parseAudioFxChain } from "@hyperframes/core/audio-fx";
import { HF_AUDIO_GROUP_ATTR } from "@hyperframes/core/audio-groups";
import { byStart, type ClipFact, type ClipLane } from "@hyperframes/core/clip-facts";
import { parseNumeric } from "@hyperframes/core";
import { fpsToNumber, parseFpsWithDefault, parseNumeric } from "@hyperframes/core";
import { findSyncPartner, readLinkTiming, syncOffsetFrames } from "@hyperframes/core/media-link";
import { readCompositionFps } from "../utils/compositionFps.js";
import {
readMediaOffsetSeconds,
readPlaybackRate,
Expand Down Expand Up @@ -60,6 +62,8 @@ export interface TimelineRow extends ClipFact {
hostRow: RowPointer | null;
/** Where this row's sub-composition clips are (one level only); each is a full row in its kind's `rows`. */
children: RowPointer[];
/** Signed frames from its `data-sync-origin` partner (+ = late); `null` unpaired or rates differ. */
syncOffsetFrames: number | null;
}

export interface RowPointer {
Expand Down Expand Up @@ -137,6 +141,7 @@ interface DocScope {
withProbeSlot: <T>(fn: () => Promise<T>) => Promise<T>;
measure: MeasureMedia;
sourceOverrides: ReadonlyMap<string, string>;
fps: number;
}

/** Source length in seconds of a media file. ffprobe in production; tests pass a recorded fake. */
Expand Down Expand Up @@ -312,9 +317,15 @@ function describeRowFields(scope: DocScope, node: DomNode, timing: RowTiming): C
warnings: [],
elementId: el.id || null,
hfId: el.getAttribute("data-hf-id"),
syncOffsetFrames: syncOffsetOf(el, scope.fps),
};
}

function syncOffsetOf(el: Element, fps: number): number | null {
const partner = findSyncPartner(el);
return partner ? syncOffsetFrames(readLinkTiming(el), readLinkTiming(partner), fps) : null;
}

async function describeRow(scope: DocScope, node: DomNode, depth: number): Promise<ClipDraft> {
return describeRowFields(scope, node, await resolveRowTiming(scope, node, depth));
}
Expand Down Expand Up @@ -343,6 +354,7 @@ async function readSubComposition(
withProbeSlot: parent.withProbeSlot,
measure: parent.measure,
sourceOverrides: parent.sourceOverrides,
fps: parent.fps,
};
const rows = await Promise.all(
topLevelElements(toNode(root)).map((node) => describeRow(scope, node, 1)),
Expand Down Expand Up @@ -416,6 +428,11 @@ function flatten(top: ClipDraft[]): TimelineTrack[] {
}));
}

function projectFps(source: string): number {
const parsed = parseFpsWithDefault(readCompositionFps(source) ?? undefined);
return fpsToNumber(parsed.ok ? parsed.value : { num: 30, den: 1 });
}

/** Needs a global DOMParser (`ensureDOMParser`). Reads `index.html` and one level of sub-compositions. */
export async function describeProject(
indexPath: string,
Expand All @@ -437,6 +454,7 @@ export async function describeProject(
withProbeSlot: createProbeGate(PROBE_CONCURRENCY),
measure,
sourceOverrides,
fps: projectFps(source),
};
const rows = (
await Promise.all(topLevelElements(toNode(root)).map((node) => describeRow(scope, node, 0)))
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/timeline/formatTimeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ function durationNote(row: TimelineRow): string | false {
}
}

function syncNote(frames: number | null): string | false {
if (!frames) return false;
return `out-of-sync=${frames > 0 ? "+" : ""}${frames}f`;
}

function details(row: TimelineRow): string {
const lanes = row.lanes.map(
(l) => `${l.target}[${l.points.map((p) => `${n(p.t)}:${n(p.v)}`).join(" ")}]`,
Expand All @@ -38,6 +43,7 @@ function details(row: TimelineRow): string {
row.volume !== null && `vol=${row.volume}`,
row.playbackRate !== null && `rate=${n(row.playbackRate)}`,
row.audioGroup && `group=${row.audioGroup}`,
syncNote(row.syncOffsetFrames),
durationNote(row),
row.sourceFile && !row.children.length && "children=unread",
row.laneError && `lanes unreadable: ${row.laneError}`,
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/timeline/timeExpr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const row: TimelineRow = {
durationSource: "authored",
pendingReason: null,
laneError: null,
syncOffsetFrames: null,
};

describe("parseTimeExpression", () => {
Expand Down
120 changes: 120 additions & 0 deletions packages/core/src/mediaLink.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { describe, expect, it } from "vitest";
import {
MEDIA_LINK_ATTR,
SYNC_ORIGIN_ATTR,
findSyncPartner,
formatSyncOffset,
linkTimingMismatches,
moveIntoSyncStart,
slipIntoSyncMediaStart,
sourceZeroTime,
syncOffsetFrames,
mintLinkId,
readLinkTiming,
relinkSplitHalves,
Expand All @@ -24,6 +31,11 @@ describe("readLinkTiming", () => {
it("reads data-playback-start as the media-start alias", () => {
expect(readLinkTiming(attrs({ "data-playback-start": "1.5" })).mediaStart).toBe(1.5);
});

it("lets data-playback-start win over data-media-start, as playback does", () => {
const both = attrs({ "data-playback-start": "0", "data-media-start": "0.5" });
expect(readLinkTiming(both).mediaStart).toBe(0);
});
});

describe("linkTimingMismatches", () => {
Expand Down Expand Up @@ -86,6 +98,17 @@ describe("relinkSplitHalves", () => {
expect(link("i-split")).toBeNull();
});

it("keeps the source origin when only one member of an unlinked pair is cut", () => {
const d = doc(`
<video id="v" data-sync-origin="lk-1" data-start="0" data-duration="10"></video>
<audio id="a" data-sync-origin="lk-1" data-start="0" data-duration="4"></audio>
<audio id="a-split" data-sync-origin="lk-1" data-start="4" data-duration="6"></audio>`);
relinkSplitHalves(d, ["a-split"]);
const right = d.getElementById("a-split");
expect(right?.getAttribute(SYNC_ORIGIN_ATTR)).toBe("lk-1");
expect(right && findSyncPartner(right)?.id).toBe("v");
});

it("mints distinct ids for distinct groups", () => {
const d = doc(`
<video id="v" data-link="lk-1"></video><video id="v2" data-link="lk-1"></video>
Expand All @@ -97,3 +120,100 @@ describe("relinkSplitHalves", () => {
expect(ids).not.toContain("lk-7");
});
});

describe("relinkSplitHalves sync origin", () => {
it("gives right halves their own shared sync origin, even when unlinked", () => {
document.body.innerHTML = `
<video id="v" data-sync-origin="lk-1"></video><video id="v2" data-sync-origin="lk-1"></video>
<audio id="a" data-sync-origin="lk-1"></audio><audio id="a2" data-sync-origin="lk-1"></audio>`;
relinkSplitHalves(document, ["v2", "a2"]);
const origin = (id: string) => document.getElementById(id)?.getAttribute(SYNC_ORIGIN_ATTR);
expect(origin("v")).toBe("lk-1");
expect(origin("a")).toBe("lk-1");
expect(origin("v2")).not.toBe("lk-1");
expect(origin("v2")).toBe(origin("a2"));
});
});

describe("sync offset", () => {
const clip = (start: number, mediaStart = 0, playbackRate = 1) => ({
start,
mediaStart,
playbackRate,
});

it("places source zero at start minus media-start over rate", () => {
expect(sourceZeroTime(clip(4, 2, 2))).toBe(3);
});

it("is zero for a pair in sync, whatever their shared media start", () => {
expect(syncOffsetFrames(clip(2, 1), clip(2, 1), 30)).toBe(0);
expect(syncOffsetFrames(clip(3, 2), clip(2, 1), 30)).toBe(0);
});

it("is signed from the clip's side: late is positive, and the partner reads the negation", () => {
expect(syncOffsetFrames(clip(2 + 10 / 30), clip(2), 30)).toBe(10);
expect(syncOffsetFrames(clip(2), clip(2 + 10 / 30), 30)).toBe(-10);
});

it("counts media start: a slipped clip is out of sync in place", () => {
expect(syncOffsetFrames(clip(2, 0.5), clip(2, 0), 30)).toBe(-15);
});

it("divides media start by the shared rate", () => {
expect(syncOffsetFrames(clip(2, 1, 2), clip(2, 0, 2), 30)).toBe(-15);
});

it("has no offset when the rates differ", () => {
expect(syncOffsetFrames(clip(2, 0, 1.5), clip(2, 0, 1), 30)).toBeNull();
});

it("formats frames, then seconds:frames past one second", () => {
expect(formatSyncOffset(10, 30)).toBe("+10");
expect(formatSyncOffset(-51, 30)).toBe("-1:21");
expect(formatSyncOffset(30, 30)).toBe("+1:00");
expect(formatSyncOffset(-3, 24)).toBe("-3");
});

it("moves the clip onto its partner, or refuses before zero", () => {
expect(moveIntoSyncStart(clip(2.5, 0.5), clip(1, 0))).toBeCloseTo(1.5);
expect(syncOffsetFrames(clip(1.5, 0.5), clip(1, 0), 30)).toBe(0);
expect(moveIntoSyncStart(clip(0, 2), clip(0, 0))).toBeCloseTo(2);
expect(moveIntoSyncStart(clip(1, 0), clip(0, 2))).toBeNull();
});

it("slips the clip's media in place, scaled by rate, or refuses before the file start", () => {
expect(slipIntoSyncMediaStart(clip(3, 0), clip(2, 0))).toBeCloseTo(1);
expect(slipIntoSyncMediaStart(clip(3, 0, 2), clip(2, 0, 2))).toBeCloseTo(2);
expect(syncOffsetFrames(clip(3, 2, 2), clip(2, 0, 2), 30)).toBe(0);
expect(slipIntoSyncMediaStart(clip(1, 0), clip(2, 0))).toBeNull();
});
});

describe("findSyncPartner", () => {
it("finds the opposite kind sharing the origin, preferring the most shared timeline", () => {
document.body.innerHTML = `<div data-composition-id="m">
<video id="v" data-sync-origin="lk-1" data-start="0" data-duration="4"></video>
<video id="v2" data-sync-origin="lk-1" data-start="4" data-duration="4"></video>
<audio id="a" data-sync-origin="lk-1" data-start="4.2" data-duration="4"></audio>
<audio id="x" data-sync-origin="lk-9" data-start="4" data-duration="4"></audio></div>`;
const byId = (id: string) => document.getElementById(id);
const a = byId("a");
const x = byId("x");
expect(a && findSyncPartner(a)?.id).toBe("v2");
expect(x && findSyncPartner(x)).toBeNull();
});

it.each(['data-composition-id="child"', 'data-composition-file="child.html"'])(
"never takes a partner from a nested composition (%s) reusing the origin",
(host) => {
document.body.innerHTML = `<div data-composition-id="m">
<video id="v" data-sync-origin="lk-1" data-start="0" data-duration="4"></video>
<div ${host}>
<audio id="ca" data-sync-origin="lk-1" data-start="0" data-duration="4"></audio>
</div></div>`;
const v = document.getElementById("v");
expect(v && findSyncPartner(v)).toBeNull();
},
);
});
Loading
Loading