Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
61 changes: 61 additions & 0 deletions packages/cli/src/timeline/describeProject.inPoint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { beforeAll, describe, expect, it, onTestFinished } from "vitest";
import { ensureDOMParser } from "../utils/dom.js";
import { describeProject } from "./describeProject.js";

// What Studio's split writes when a 12 s scene clip is cut at 5 s and then at 9 s.
const SPLIT_INDEX = `<!DOCTYPE html><html><head></head><body><div data-composition-id="main" data-duration="12">
<div data-playback-start="0" id="scene" class="clip" data-composition-id="scene" data-composition-src="compositions/scene.html" data-start="0" data-duration="5" data-track-index="0"></div><div data-playback-start="5" id="scene-split" class="clip" data-composition-id="scene-split" data-composition-src="compositions/scene.html" data-start="5" data-duration="4" data-track-index="0"></div><div data-playback-start="9" id="scene-split-split" class="clip" data-composition-id="scene-split-split" data-composition-src="compositions/scene.html" data-start="9" data-duration="3" data-track-index="0"></div>
</div></body></html>`;
const SCENE = `<template id="scene-template"><div data-composition-id="scene" data-duration="12">
<video id="v1" class="clip" src="../sample.mp4" muted data-start="0" data-duration="4" data-media-start="1" data-track-index="0"></video>
<video id="v2" class="clip" src="../sample.mp4" muted data-start="4" data-duration="4" data-media-start="5" data-track-index="0"></video>
<video id="v3" class="clip" src="../sample.mp4" muted data-start="8" data-duration="4" data-media-start="2" data-track-index="0"></video>
</div></template>`;

beforeAll(ensureDOMParser);

describe("a sub-composition's in-point", () => {
it("shifts each later split half's videos by its in-point and cuts them to that half", async () => {
const dir = mkdtempSync(join(tmpdir(), "hf-timeline-inpoint-"));
onTestFinished(() => rmSync(dir, { recursive: true, force: true }));
mkdirSync(join(dir, "compositions"));
writeFileSync(join(dir, "index.html"), SPLIT_INDEX);
writeFileSync(join(dir, "compositions", "scene.html"), SCENE);

const timeline = await describeProject(join(dir, "index.html"));
const videos = timeline.tracks.find((t) => t.kind === "video")!.rows;

// The first half's in-point is 0, so it shows its children's authored extent, as before.
expect(videos.map((r) => [r.host, r.id, r.absStart, r.absEnd])).toEqual([
["scene", "v1", 0, 4],
["scene", "v2", 4, 8],
["scene-split", "v2", 5, 8],
["scene", "v3", 8, 12],
["scene-split", "v3", 8, 9],
["scene-split-split", "v3", 9, 12],
]);
});

it("cuts nothing for a host whose in-point is 0, as Studio stamps on every inserted scene", async () => {
const dir = mkdtempSync(join(tmpdir(), "hf-timeline-inpoint0-"));
onTestFinished(() => rmSync(dir, { recursive: true, force: true }));
mkdirSync(join(dir, "compositions"));
writeFileSync(
join(dir, "index.html"),
`<div data-composition-id="main" data-duration="12"><div id="scene" data-composition-src="compositions/scene.html" data-start="1" data-duration="3" data-playback-start="0"></div></div>`,
);
writeFileSync(join(dir, "compositions", "scene.html"), SCENE);

const timeline = await describeProject(join(dir, "index.html"));
const videos = timeline.tracks.find((t) => t.kind === "video")!.rows;

expect(videos.map((r) => [r.id, r.absStart, r.absEnd])).toEqual([
["v1", 1, 5],
["v2", 5, 9],
["v3", 9, 13],
]);
});
});
29 changes: 24 additions & 5 deletions packages/cli/src/timeline/describeProject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,13 @@ import {
type StructureNode,
type TrackKind,
} from "@hyperframes/parsers";
import { resolveMediaStartSeconds } from "@hyperframes/core/media-timing";
import {
compositionOriginSeconds,
cutToHostSlots,
hostInPointSeconds,
resolveMediaStartSeconds,
type HostSlot,
} from "@hyperframes/core/media-timing";
import {
extractAudioMetadata,
extractMediaMetadata,
Expand All @@ -47,7 +53,8 @@ export interface TimelineRow extends ClipFact {
pendingReason: string | null;
/** Why `data-automation` / `data-fx-chain` could not be read; `null` when fine or absent. */
laneError: string | null;
/** Start and end on the main timeline, in seconds. `start`/`end` are local to the owning file's composition. */
/** Main-timeline window in seconds: the played part under an in-point host, while `duration` stays
* the authored length. `start`/`end` are local to the owning file's composition. */
absStart: number;
absEnd: number;
/** Project-relative path of the file that declares this clip. */
Expand Down Expand Up @@ -133,7 +140,7 @@ interface DocScope {
startCache: Map<Element, number>;
/** Sub-composition files must stay inside the project. */
projectDir: string;
/** Main-timeline start of this document's root: 0 for index.html, the host's start for a sub-composition. */
/** Main-timeline time of this document's local t=0: 0 for index.html, see `compositionOriginSeconds`. */
origin: number;
/** Project-relative path of this document, with `/` separators. */
file: string;
Expand Down Expand Up @@ -277,7 +284,12 @@ async function resolveRowTiming(scope: DocScope, node: DomNode, depth: number):
const authored = resolveReferencedDuration(scope.doc, el, scope.startCache, new Set());
const host = el.getAttribute("data-composition-src");
const absStart = mainTimelineStart(scope, el, start);
const children = host && depth === 0 ? await readSubComposition(host, scope, absStart) : [];
const slots: HostSlot[] =
hostInPointSeconds(el) > 0 ? [{ start: absStart, end: absStart + (authored ?? Infinity) }] : [];
const children =
host && depth === 0
? await readSubComposition(host, scope, compositionOriginSeconds(absStart, el), slots)
: [];
const kind = el.tagName.toLowerCase();
const duration = MEDIA_TAG.test(kind)
? await resolveMediaRowDuration(scope, el, kind as MediaTag, authored)
Expand Down Expand Up @@ -334,6 +346,7 @@ async function readSubComposition(
src: string,
parent: DocScope,
origin: number,
slots: readonly HostSlot[],
): Promise<ClipDraft[]> {
const authored = resolve(parent.dir, src);
const file = realFileInside(parent.projectDir, authored);
Expand All @@ -359,7 +372,13 @@ async function readSubComposition(
const rows = await Promise.all(
topLevelElements(toNode(root)).map((node) => describeRow(scope, node, 1)),
);
return rows.sort(byStart);
return rows.flatMap((row) => cutRow(row, slots)).sort(byStart);
}

function cutRow(row: ClipDraft, slots: readonly HostSlot[]): ClipDraft[] {
const kept = cutToHostSlots({ start: row.absStart, end: row.absEnd }, slots);
if (kept.end === kept.start && row.absEnd > row.absStart) return [];
return [{ ...row, absStart: roundMs(kept.start), absEnd: roundMs(kept.end) }];
}

/** The file's real path when it is a regular file inside the project (symlinks resolved), else null. */
Expand Down
37 changes: 34 additions & 3 deletions packages/core/src/mediaTiming.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration";

export const MEDIA_START_BASIS_ATTR = "data-hf-media-start-basis";

export type MediaStartBasis = "local" | "global";
Expand All @@ -20,8 +22,8 @@ export function resolveAbsoluteMediaStartSeconds(input: {
}

/** The one rule for a media element's root-timeline start; the runtime and the CLI both call it.
* With no literal start, an auto-injected start, or a host at t<=0 there is nothing for the basis
* to disambiguate, so the ordinary start resolution applies. */
* `hostStart` is the composition's origin. With no literal start, an auto-injected start, or an
* origin at 0 there is nothing for the basis to disambiguate, so ordinary resolution applies. */
export function resolveMediaStartSeconds(
input: MediaStartInput & { ordinaryStart: () => number },
): number {
Expand All @@ -48,5 +50,34 @@ export interface MediaStartInput {
function hasLiteralNestedStart(
input: MediaStartInput,
): input is MediaStartInput & { authoredStart: number } {
return !input.hasAutoStart && input.authoredStart != null && input.hostStart > 0;
return !input.hasAutoStart && input.authoredStart != null && input.hostStart !== 0;
}

/** A composition host's in-point; only a host with one shifts or cuts what it holds. */
export function hostInPointSeconds(host: Pick<Element, "getAttribute">): number {
return readMediaOffsetSeconds((name) => host.getAttribute(name));
}

/** Main-timeline time of a sub-composition's local t=0: its host's start minus its in-point. */
export function compositionOriginSeconds(
hostStart: number,
host: Pick<Element, "getAttribute">,
): number {
return hostStart - hostInPointSeconds(host);
}

export interface HostSlot {
start: number;
end: number;
}

/** Each in-point host cuts the span to its own window. */
export function cutToHostSlots(
span: { start: number; end: number },
hosts: readonly HostSlot[],
): { start: number; end: number } {
return hosts.reduce((kept, host) => {
const start = Math.max(kept.start, host.start);
return { start, end: Math.max(start, Math.min(kept.end, host.end)) };
}, span);
}
16 changes: 16 additions & 0 deletions packages/core/src/runtime/audioClockSource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,22 @@ afterEach(() => {
});

describe("the audio the playhead follows", () => {
it("does not hold the playhead on a clip an in-point host has not started yet", async () => {
mount(
`<div id="half" class="clip" data-composition-id="half" data-composition-file="compositions/scene.html"` +
` data-start="5" data-duration="5" data-playback-start="5">` +
`<div><audio id="a" data-start="2" data-duration="8" src="/assets/a.mp3"></audio></div></div>`,
);
const audio = document.getElementById("a") as HTMLAudioElement;
Object.defineProperty(audio, "readyState", { value: 0, writable: true, configurable: true });
initSandboxRuntimeModular();
await flush();
window.__player?.play();
await flush();
stepFrames(240);
expect(window.__player!.getTime()).toBeGreaterThan(3.5);
});

it("keeps following a playing voice when a clip earlier in the page starts late", async () => {
mount(
`<audio id="sfx" data-start="1" data-duration="2" src="/assets/sfx.mp3"></audio>` +
Expand Down
Loading
Loading