mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
fix(studio): enable timeline resize for all elements, improve perf and UX
Enable trim-start and trim-end for all authored timeline elements (divs, sections, compositions) — not just video/audio/img. The deterministic-window gate was overly restrictive since all non-implicit elements have authored data-start/data-duration that define their timeline window. Replace iframe reload after resize/move with direct DOM attribute patching via patchIframeDomTiming(). This eliminates playhead-jump-to-zero, visual blinking, and race conditions from file-watcher echoes. File persistence runs in a serialized background queue (persistTimelineEdit + enqueueEdit) so rapid edits don't overwrite each other. Add mediabunny-based media probe service (mediaProbe.ts) for fast metadata extraction from file headers. Timeline elements missing sourceDuration are enriched asynchronously without waiting for DOM loadedmetadata events. Tune the runtime media preloader: lower lazy threshold from 6 to 3 clips, add 3s lookbehind window for reverse scrub, adaptive promoted-clip cap. Deduplicate getTimelineEditCapabilities — computed once in TimelineCanvas and passed as a prop to TimelineClip instead of recomputing per clip. Remove dead PlaybackAdapter re-export from useTimelinePlayer — all consumers import directly from playbackTypes.
This commit is contained in:
@@ -22,7 +22,7 @@
|
||||
},
|
||||
"packages/aws-lambda": {
|
||||
"name": "@hyperframes/aws-lambda",
|
||||
"version": "0.6.22",
|
||||
"version": "0.6.27",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.700.0",
|
||||
"@aws-sdk/client-sfn": "^3.700.0",
|
||||
@@ -54,7 +54,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@hyperframes/cli",
|
||||
"version": "0.6.22",
|
||||
"version": "0.6.27",
|
||||
"bin": {
|
||||
"hyperframes": "./dist/cli.js",
|
||||
},
|
||||
@@ -97,7 +97,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@hyperframes/core",
|
||||
"version": "0.6.22",
|
||||
"version": "0.6.27",
|
||||
"dependencies": {
|
||||
"@chenglou/pretext": "^0.0.5",
|
||||
"postcss": "^8.5.8",
|
||||
@@ -124,7 +124,7 @@
|
||||
},
|
||||
"packages/engine": {
|
||||
"name": "@hyperframes/engine",
|
||||
"version": "0.6.22",
|
||||
"version": "0.6.27",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"@hyperframes/core": "workspace:^",
|
||||
@@ -142,7 +142,7 @@
|
||||
},
|
||||
"packages/player": {
|
||||
"name": "@hyperframes/player",
|
||||
"version": "0.6.22",
|
||||
"version": "0.6.27",
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.1.0",
|
||||
"gsap": "^3.12.5",
|
||||
@@ -154,7 +154,7 @@
|
||||
},
|
||||
"packages/producer": {
|
||||
"name": "@hyperframes/producer",
|
||||
"version": "0.6.22",
|
||||
"version": "0.6.27",
|
||||
"dependencies": {
|
||||
"@fontsource/archivo-black": "^5.2.8",
|
||||
"@fontsource/eb-garamond": "^5.2.7",
|
||||
@@ -194,7 +194,7 @@
|
||||
},
|
||||
"packages/shader-transitions": {
|
||||
"name": "@hyperframes/shader-transitions",
|
||||
"version": "0.6.22",
|
||||
"version": "0.6.27",
|
||||
"dependencies": {
|
||||
"html2canvas": "^1.4.1",
|
||||
},
|
||||
@@ -206,7 +206,7 @@
|
||||
},
|
||||
"packages/studio": {
|
||||
"name": "@hyperframes/studio",
|
||||
"version": "0.6.22",
|
||||
"version": "0.6.27",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.20.1",
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
@@ -221,6 +221,7 @@
|
||||
"@hyperframes/core": "workspace:*",
|
||||
"@hyperframes/player": "workspace:*",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"mediabunny": "^1.45.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hyperframes/producer": "workspace:*",
|
||||
@@ -962,6 +963,10 @@
|
||||
|
||||
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||
|
||||
"@types/dom-mediacapture-transform": ["@types/dom-mediacapture-transform@0.1.11", "", { "dependencies": { "@types/dom-webcodecs": "*" } }, "sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ=="],
|
||||
|
||||
"@types/dom-webcodecs": ["@types/dom-webcodecs@0.1.13", "", {}, "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/jsdom": ["@types/jsdom@28.0.2", "", { "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", "parse5": "^8.0.0", "undici-types": "^7.21.0" } }, "sha512-zZYItekplnGirFhVDrcB0+103TMakXfKfIp7uECxaFzFG3Ws5kYQSwVb1d4pQfJMMjQda6pfuZxueAv9CMiJbw=="],
|
||||
@@ -1504,6 +1509,8 @@
|
||||
|
||||
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
|
||||
|
||||
"mediabunny": ["mediabunny@1.45.3", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-GUCPYjR+5olLM7DRmupCXCmZkkSrKHVl1gyW2RztpObqLfrix19kWGf/9WgWzDW0g49DvfGXpl+zfArCx5HDMQ=="],
|
||||
|
||||
"meow": ["meow@13.2.0", "", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="],
|
||||
|
||||
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
||||
|
||||
@@ -43,6 +43,19 @@ function setupDOM(elements: HTMLMediaElement[]): void {
|
||||
}) as typeof document.querySelectorAll;
|
||||
}
|
||||
|
||||
function createTestFixture(
|
||||
count: number,
|
||||
options?: Parameters<typeof createMediaPreloadManager>[0],
|
||||
) {
|
||||
const elements = Array.from({ length: count }, (_, i) =>
|
||||
mockMediaElement({ start: String(i * 5), duration: "5" }),
|
||||
);
|
||||
setupDOM(elements);
|
||||
const manager = createMediaPreloadManager(options);
|
||||
manager.refresh();
|
||||
return { elements, manager };
|
||||
}
|
||||
|
||||
describe("createMediaPreloadManager", () => {
|
||||
let elements: HTMLMediaElement[];
|
||||
|
||||
@@ -50,7 +63,7 @@ describe("createMediaPreloadManager", () => {
|
||||
elements = [];
|
||||
});
|
||||
|
||||
it("is not lazy when fewer than 6 media elements", () => {
|
||||
it("is not lazy when fewer than 3 media elements", () => {
|
||||
elements = [
|
||||
mockMediaElement({ start: "0", duration: "5" }),
|
||||
mockMediaElement({ start: "5", duration: "5" }),
|
||||
@@ -63,274 +76,135 @@ describe("createMediaPreloadManager", () => {
|
||||
expect(manager.isLazy()).toBe(false);
|
||||
});
|
||||
|
||||
it("activates lazy mode at exactly LAZY_THRESHOLD (6 elements)", () => {
|
||||
elements = Array.from({ length: 6 }, (_, i) =>
|
||||
mockMediaElement({ start: String(i * 5), duration: "5" }),
|
||||
);
|
||||
setupDOM(elements);
|
||||
|
||||
const manager = createMediaPreloadManager();
|
||||
manager.refresh();
|
||||
|
||||
it("activates lazy mode at exactly LAZY_THRESHOLD (3 elements)", () => {
|
||||
const { manager } = createTestFixture(3);
|
||||
expect(manager.isLazy()).toBe(true);
|
||||
});
|
||||
|
||||
it("is not lazy with 5 elements (below threshold)", () => {
|
||||
elements = Array.from({ length: 5 }, (_, i) =>
|
||||
mockMediaElement({ start: String(i * 5), duration: "5" }),
|
||||
);
|
||||
setupDOM(elements);
|
||||
|
||||
const manager = createMediaPreloadManager();
|
||||
manager.refresh();
|
||||
|
||||
it("is not lazy with 2 elements (below threshold)", () => {
|
||||
const { manager } = createTestFixture(2);
|
||||
expect(manager.isLazy()).toBe(false);
|
||||
});
|
||||
|
||||
it("activates lazy mode with 8 media elements", () => {
|
||||
elements = Array.from({ length: 8 }, (_, i) =>
|
||||
mockMediaElement({ start: String(i * 5), duration: "5" }),
|
||||
);
|
||||
setupDOM(elements);
|
||||
|
||||
const manager = createMediaPreloadManager();
|
||||
manager.refresh();
|
||||
|
||||
const { manager } = createTestFixture(8);
|
||||
expect(manager.isLazy()).toBe(true);
|
||||
});
|
||||
|
||||
it("sync promotes clips in the lookahead window", () => {
|
||||
elements = Array.from({ length: 8 }, (_, i) =>
|
||||
mockMediaElement({ start: String(i * 5), duration: "5" }),
|
||||
);
|
||||
setupDOM(elements);
|
||||
|
||||
const manager = createMediaPreloadManager();
|
||||
manager.refresh();
|
||||
|
||||
for (const el of elements) {
|
||||
el.preload = "metadata";
|
||||
}
|
||||
|
||||
manager.sync(0);
|
||||
|
||||
expect(elements[0].preload).toBe("auto");
|
||||
expect(elements[1].preload).toBe("auto");
|
||||
expect(elements[7].preload).toBe("metadata");
|
||||
const f = createTestFixture(8);
|
||||
for (const el of f.elements) el.preload = "metadata";
|
||||
f.manager.sync(0);
|
||||
expect(f.elements[0].preload).toBe("auto");
|
||||
expect(f.elements[1].preload).toBe("auto");
|
||||
expect(f.elements[7].preload).toBe("metadata");
|
||||
});
|
||||
|
||||
it("preloadAroundTime promotes clips near seek target", () => {
|
||||
elements = Array.from({ length: 10 }, (_, i) =>
|
||||
mockMediaElement({ start: String(i * 5), duration: "5" }),
|
||||
);
|
||||
setupDOM(elements);
|
||||
|
||||
const manager = createMediaPreloadManager();
|
||||
manager.refresh();
|
||||
|
||||
for (const el of elements) {
|
||||
el.preload = "metadata";
|
||||
}
|
||||
|
||||
manager.preloadAroundTime(30);
|
||||
|
||||
expect(elements[6].preload).toBe("auto");
|
||||
expect(elements[7].preload).toBe("auto");
|
||||
expect(elements[0].preload).toBe("metadata");
|
||||
const f = createTestFixture(10);
|
||||
for (const el of f.elements) el.preload = "metadata";
|
||||
f.manager.preloadAroundTime(30);
|
||||
expect(f.elements[6].preload).toBe("auto");
|
||||
expect(f.elements[7].preload).toBe("auto");
|
||||
expect(f.elements[0].preload).toBe("metadata");
|
||||
});
|
||||
|
||||
it("sync is a no-op when not lazy", () => {
|
||||
elements = [
|
||||
mockMediaElement({ start: "0", duration: "5" }),
|
||||
mockMediaElement({ start: "5", duration: "5" }),
|
||||
];
|
||||
setupDOM(elements);
|
||||
|
||||
const manager = createMediaPreloadManager();
|
||||
manager.refresh();
|
||||
manager.sync(0);
|
||||
|
||||
expect(manager.isLazy()).toBe(false);
|
||||
const f = createTestFixture(2);
|
||||
f.manager.sync(0);
|
||||
expect(f.manager.isLazy()).toBe(false);
|
||||
});
|
||||
|
||||
it("guarantees at least LOOKAHEAD_MIN_CLIPS are promoted", () => {
|
||||
// Use 20s spacing so only 1 clip falls in the 10s lookahead window
|
||||
elements = Array.from({ length: 8 }, (_, i) =>
|
||||
mockMediaElement({ start: String(i * 20), duration: "5" }),
|
||||
);
|
||||
setupDOM(elements);
|
||||
|
||||
const manager = createMediaPreloadManager();
|
||||
manager.refresh();
|
||||
|
||||
for (const el of elements) {
|
||||
el.preload = "metadata";
|
||||
}
|
||||
|
||||
for (const el of elements) el.preload = "metadata";
|
||||
manager.sync(0);
|
||||
|
||||
const promotedCount = elements.filter((el) => el.preload === "auto").length;
|
||||
expect(promotedCount).toBeGreaterThanOrEqual(2);
|
||||
expect(elements.filter((el) => el.preload === "auto").length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("evicts clips when scrubbing away from them", () => {
|
||||
elements = Array.from({ length: 10 }, (_, i) =>
|
||||
mockMediaElement({ start: String(i * 5), duration: "5" }),
|
||||
);
|
||||
setupDOM(elements);
|
||||
|
||||
const manager = createMediaPreloadManager();
|
||||
manager.refresh();
|
||||
|
||||
for (const el of elements) {
|
||||
el.preload = "metadata";
|
||||
}
|
||||
|
||||
// Promote clips around t=0
|
||||
manager.sync(0);
|
||||
expect(elements[0].preload).toBe("auto");
|
||||
expect(elements[1].preload).toBe("auto");
|
||||
|
||||
// Scrub to t=40 — clips 0,1 should be evicted
|
||||
manager.sync(40);
|
||||
expect(elements[0].preload).toBe("metadata");
|
||||
expect(elements[0].src).toBe("");
|
||||
expect(elements[8].preload).toBe("auto");
|
||||
const f = createTestFixture(10);
|
||||
for (const el of f.elements) el.preload = "metadata";
|
||||
f.manager.sync(0);
|
||||
expect(f.elements[0].preload).toBe("auto");
|
||||
f.manager.sync(40);
|
||||
expect(f.elements[0].preload).toBe("metadata");
|
||||
expect(f.elements[0].src).toBe("");
|
||||
expect(f.elements[8].preload).toBe("auto");
|
||||
});
|
||||
|
||||
it("restores src when re-promoting a previously evicted clip", () => {
|
||||
elements = Array.from({ length: 10 }, (_, i) =>
|
||||
mockMediaElement({ start: String(i * 5), duration: "5" }),
|
||||
);
|
||||
setupDOM(elements);
|
||||
|
||||
const manager = createMediaPreloadManager();
|
||||
manager.refresh();
|
||||
|
||||
for (const el of elements) {
|
||||
el.preload = "metadata";
|
||||
}
|
||||
|
||||
const originalSrc0 = elements[0].src;
|
||||
|
||||
// Promote at t=0, scrub away, scrub back
|
||||
manager.sync(0);
|
||||
manager.sync(40);
|
||||
expect(elements[0].src).toBe("");
|
||||
|
||||
manager.sync(0);
|
||||
expect(elements[0].src).toBe(originalSrc0);
|
||||
expect(elements[0].preload).toBe("auto");
|
||||
const f = createTestFixture(10);
|
||||
for (const el of f.elements) el.preload = "metadata";
|
||||
const originalSrc0 = f.elements[0].src;
|
||||
f.manager.sync(0);
|
||||
f.manager.sync(40);
|
||||
expect(f.elements[0].src).toBe("");
|
||||
f.manager.sync(0);
|
||||
expect(f.elements[0].src).toBe(originalSrc0);
|
||||
expect(f.elements[0].preload).toBe("auto");
|
||||
});
|
||||
|
||||
it("does not exceed MAX_PROMOTED (5) clips", () => {
|
||||
// 10 clips, each 5s long, spaced 5s apart
|
||||
elements = Array.from({ length: 10 }, (_, i) =>
|
||||
mockMediaElement({ start: String(i * 5), duration: "5" }),
|
||||
);
|
||||
setupDOM(elements);
|
||||
|
||||
const manager = createMediaPreloadManager();
|
||||
manager.refresh();
|
||||
|
||||
for (const el of elements) {
|
||||
el.preload = "metadata";
|
||||
}
|
||||
|
||||
// Sync at t=0 — window covers clips 0,1,2 (0-15s lookahead)
|
||||
manager.sync(0);
|
||||
const promotedAfterFirst = elements.filter((el) => el.preload === "auto").length;
|
||||
expect(promotedAfterFirst).toBeLessThanOrEqual(5);
|
||||
|
||||
// Sync at different position — should evict old ones
|
||||
manager.sync(25);
|
||||
const totalPromoted = elements.filter((el) => el.preload === "auto").length;
|
||||
expect(totalPromoted).toBeLessThanOrEqual(5);
|
||||
const f = createTestFixture(10);
|
||||
for (const el of f.elements) el.preload = "metadata";
|
||||
f.manager.sync(0);
|
||||
expect(f.elements.filter((el) => el.preload === "auto").length).toBeLessThanOrEqual(5);
|
||||
f.manager.sync(25);
|
||||
expect(f.elements.filter((el) => el.preload === "auto").length).toBeLessThanOrEqual(5);
|
||||
});
|
||||
|
||||
it("calls load() when evicting to release buffers", () => {
|
||||
elements = Array.from({ length: 10 }, (_, i) =>
|
||||
mockMediaElement({ start: String(i * 5), duration: "5" }),
|
||||
const f = createTestFixture(10);
|
||||
for (const el of f.elements) el.preload = "metadata";
|
||||
f.manager.sync(0);
|
||||
const loadCallsBefore = (f.elements[0].load as ReturnType<typeof vi.fn>).mock.calls.length;
|
||||
f.manager.sync(40);
|
||||
expect((f.elements[0].load as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThan(
|
||||
loadCallsBefore,
|
||||
);
|
||||
setupDOM(elements);
|
||||
|
||||
const manager = createMediaPreloadManager();
|
||||
manager.refresh();
|
||||
|
||||
for (const el of elements) {
|
||||
el.preload = "metadata";
|
||||
}
|
||||
|
||||
manager.sync(0);
|
||||
const loadCallsBefore = (elements[0].load as ReturnType<typeof vi.fn>).mock.calls.length;
|
||||
|
||||
// Scrub away — eviction should call load() to release buffers
|
||||
manager.sync(40);
|
||||
const loadCallsAfter = (elements[0].load as ReturnType<typeof vi.fn>).mock.calls.length;
|
||||
expect(loadCallsAfter).toBeGreaterThan(loadCallsBefore);
|
||||
});
|
||||
|
||||
it("isLazy reports true with 6+ clips so caller can gate render-mode bypass", () => {
|
||||
elements = Array.from({ length: 6 }, (_, i) =>
|
||||
mockMediaElement({ start: String(i * 5), duration: "5" }),
|
||||
);
|
||||
setupDOM(elements);
|
||||
|
||||
const manager = createMediaPreloadManager();
|
||||
manager.refresh();
|
||||
const { manager } = createTestFixture(6);
|
||||
expect(manager.isLazy()).toBe(true);
|
||||
});
|
||||
|
||||
it("calls onActivation when lazy mode activates", () => {
|
||||
elements = Array.from({ length: 8 }, (_, i) =>
|
||||
mockMediaElement({ start: String(i * 5), duration: "5" }),
|
||||
);
|
||||
setupDOM(elements);
|
||||
|
||||
const onActivation = vi.fn();
|
||||
const manager = createMediaPreloadManager({ onActivation });
|
||||
manager.refresh();
|
||||
|
||||
createTestFixture(8, { onActivation });
|
||||
expect(onActivation).toHaveBeenCalledOnce();
|
||||
expect(onActivation).toHaveBeenCalledWith(8);
|
||||
});
|
||||
|
||||
it("does not call onActivation below threshold", () => {
|
||||
elements = [
|
||||
mockMediaElement({ start: "0", duration: "5" }),
|
||||
mockMediaElement({ start: "5", duration: "5" }),
|
||||
];
|
||||
setupDOM(elements);
|
||||
|
||||
const onActivation = vi.fn();
|
||||
const manager = createMediaPreloadManager({ onActivation });
|
||||
manager.refresh();
|
||||
|
||||
createTestFixture(2, { onActivation });
|
||||
expect(onActivation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onActivation only once across multiple refreshes", () => {
|
||||
elements = Array.from({ length: 8 }, (_, i) =>
|
||||
mockMediaElement({ start: String(i * 5), duration: "5" }),
|
||||
);
|
||||
setupDOM(elements);
|
||||
|
||||
const onActivation = vi.fn();
|
||||
const manager = createMediaPreloadManager({ onActivation });
|
||||
const { manager } = createTestFixture(8, { onActivation });
|
||||
manager.refresh();
|
||||
manager.refresh();
|
||||
manager.refresh();
|
||||
|
||||
expect(onActivation).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("respects window.__HF_LAZY_PRELOAD_THRESHOLD override", () => {
|
||||
elements = Array.from({ length: 4 }, (_, i) =>
|
||||
elements = Array.from({ length: 2 }, (_, i) =>
|
||||
mockMediaElement({ start: String(i * 5), duration: "5" }),
|
||||
);
|
||||
setupDOM(elements);
|
||||
|
||||
// 4 elements is below the default threshold (6) but at our custom one
|
||||
(window as Record<string, unknown>).__HF_LAZY_PRELOAD_THRESHOLD = 4;
|
||||
// 2 elements is below the default threshold (3) but at our custom one
|
||||
(window as Record<string, unknown>).__HF_LAZY_PRELOAD_THRESHOLD = 2;
|
||||
|
||||
const manager = createMediaPreloadManager();
|
||||
manager.refresh();
|
||||
@@ -342,7 +216,7 @@ describe("createMediaPreloadManager", () => {
|
||||
});
|
||||
|
||||
it("falls back to default threshold when __HF_LAZY_PRELOAD_THRESHOLD is not set", () => {
|
||||
elements = Array.from({ length: 4 }, (_, i) =>
|
||||
elements = Array.from({ length: 2 }, (_, i) =>
|
||||
mockMediaElement({ start: String(i * 5), duration: "5" }),
|
||||
);
|
||||
setupDOM(elements);
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { refreshRuntimeMediaCache, type RuntimeMediaClip } from "./media";
|
||||
|
||||
// Compositions with fewer than 6 timed clips rarely exceed browser memory
|
||||
// limits during eager preload. The threshold avoids preload management
|
||||
// overhead for typical compositions while catching the heavy-media case
|
||||
// (e.g., 20 clips / 6GB reported in heygen-com/hyperframes#729).
|
||||
const LAZY_THRESHOLD = 6;
|
||||
// Start lazy preload management at 3 clips to keep memory pressure low from
|
||||
// the start. The previous threshold of 6 let medium compositions (4–5 heavy
|
||||
// videos) saturate browser memory before the preloader kicked in.
|
||||
const LAZY_THRESHOLD = 3;
|
||||
const LOOKAHEAD_SECONDS = 10;
|
||||
const LOOKBEHIND_SECONDS = 3;
|
||||
const LOOKAHEAD_MIN_CLIPS = 2;
|
||||
// Cap on simultaneously promoted (buffered) clips. When the lookahead window
|
||||
// contains more clips than this (e.g., many short clips), all window clips
|
||||
// stay promoted — the cap is defense-in-depth, not a hard ceiling. The primary
|
||||
// memory bound comes from window-based eviction in syncWindow().
|
||||
const MAX_PROMOTED = 5;
|
||||
// Adaptive cap: base of 4 for small sets, clamped to 6 for larger ones.
|
||||
// The window-based eviction in syncWindow() is the primary memory bound;
|
||||
// this cap is defense-in-depth for compositions with many short clips
|
||||
// packed into the lookahead window.
|
||||
const MAX_PROMOTED_BASE = 4;
|
||||
const MAX_PROMOTED_CEIL = 6;
|
||||
|
||||
export interface MediaPreloadManager {
|
||||
refresh(): void;
|
||||
@@ -91,23 +92,23 @@ export function createMediaPreloadManager(options?: {
|
||||
windowEls.add(clip.el);
|
||||
}
|
||||
|
||||
// Evict clips no longer in window, oldest first
|
||||
for (const clip of clips) {
|
||||
if (promoted.has(clip.el) && !windowEls.has(clip.el)) {
|
||||
evictClip(clip);
|
||||
}
|
||||
}
|
||||
|
||||
// If still over budget after removing out-of-window clips,
|
||||
// evict the oldest promoted that isn't in the current window
|
||||
while (promotionOrder.length > MAX_PROMOTED) {
|
||||
const maxPromoted = Math.min(
|
||||
MAX_PROMOTED_CEIL,
|
||||
MAX_PROMOTED_BASE + Math.floor(clips.length / 10),
|
||||
);
|
||||
while (promotionOrder.length > maxPromoted) {
|
||||
const oldest = promotionOrder[0];
|
||||
if (windowEls.has(oldest)) break; // don't evict something currently needed
|
||||
if (windowEls.has(oldest)) break;
|
||||
const clip = clips.find((c) => c.el === oldest);
|
||||
if (clip) {
|
||||
evictClip(clip);
|
||||
} else {
|
||||
// Element no longer in clips list, just remove from tracking
|
||||
promoted.delete(oldest);
|
||||
promotionOrder.shift();
|
||||
}
|
||||
@@ -115,13 +116,15 @@ export function createMediaPreloadManager(options?: {
|
||||
}
|
||||
|
||||
function getClipsInWindow(timeSeconds: number): Set<RuntimeMediaClip> {
|
||||
const windowStart = timeSeconds - LOOKBEHIND_SECONDS;
|
||||
const windowEnd = timeSeconds + LOOKAHEAD_SECONDS;
|
||||
const inWindow = new Set<RuntimeMediaClip>();
|
||||
|
||||
for (const clip of clips) {
|
||||
const active = timeSeconds >= clip.start && timeSeconds < clip.end;
|
||||
const inLookahead = clip.start >= timeSeconds && clip.start <= windowEnd;
|
||||
if (active || inLookahead) {
|
||||
const inLookbehind = clip.end > windowStart && clip.end <= timeSeconds;
|
||||
if (active || inLookahead || inLookbehind) {
|
||||
inWindow.add(clip);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,8 @@
|
||||
"@codemirror/view": "6.40.0",
|
||||
"@hyperframes/core": "workspace:*",
|
||||
"@hyperframes/player": "workspace:*",
|
||||
"@phosphor-icons/react": "^2.1.10"
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"mediabunny": "^1.45.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hyperframes/producer": "workspace:*",
|
||||
|
||||
@@ -117,12 +117,9 @@ export function StudioApp() {
|
||||
});
|
||||
const editHistory = usePersistentEditHistory({ projectId });
|
||||
const domEditSaveTimestampRef = useRef(0);
|
||||
const pendingTimelineEditPathRef = useRef<string | null>(null);
|
||||
const reloadPreview = useCallback(() => {
|
||||
try {
|
||||
previewIframeRef.current?.contentWindow?.location.reload();
|
||||
} catch {
|
||||
setRefreshKey((k) => k + 1);
|
||||
}
|
||||
setRefreshKey((k) => k + 1);
|
||||
}, []);
|
||||
|
||||
const fileManager = useFileManager({
|
||||
@@ -155,6 +152,7 @@ export function StudioApp() {
|
||||
activeCompPathRef,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview: () => setRefreshKey((k) => k + 1),
|
||||
pendingTimelineEditPathRef,
|
||||
});
|
||||
|
||||
const timelineEditing = useTimelineEditing({
|
||||
@@ -166,6 +164,8 @@ export function StudioApp() {
|
||||
recordEdit: editHistory.recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
previewIframeRef,
|
||||
pendingTimelineEditPathRef,
|
||||
uploadProjectFiles: fileManager.uploadProjectFiles,
|
||||
});
|
||||
|
||||
|
||||
@@ -26,8 +26,11 @@ interface UseManifestPersistenceParams {
|
||||
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
||||
activeCompPathRef: React.MutableRefObject<string | null>;
|
||||
/** Shared timestamp ref — written by any studio save (code tab, timeline, DOM edits).
|
||||
* Used to suppress SSE echoes so we don't double-reload after our own saves. */
|
||||
* Used to suppress file-change echoes so we don't reload after our own saves. */
|
||||
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
||||
/** Tracks in-flight timeline edits that patch the iframe DOM directly. File-change
|
||||
* events for these paths are always suppressed since the preview is already up-to-date. */
|
||||
pendingTimelineEditPathRef?: React.MutableRefObject<string | null>;
|
||||
/** Called to reload the preview after undo/redo or external file changes. */
|
||||
reloadPreview: () => void;
|
||||
}
|
||||
@@ -44,6 +47,7 @@ export function useManifestPersistence({
|
||||
activeCompPathRef: _activeCompPathRef,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
pendingTimelineEditPathRef,
|
||||
}: UseManifestPersistenceParams) {
|
||||
void _showToast;
|
||||
void _recordEdit;
|
||||
@@ -162,8 +166,12 @@ export function useManifestPersistence({
|
||||
const handler = (payload?: unknown) => {
|
||||
const changedPath = readStudioFileChangePath(payload);
|
||||
if (!changedPath) return;
|
||||
const recentDomEditSave = Date.now() - domEditSaveTimestampRef.current < 1200;
|
||||
// External file change — reload unless it's an echo of our own save.
|
||||
const recentDomEditSave = Date.now() - domEditSaveTimestampRef.current < 4000;
|
||||
const pendingPath = pendingTimelineEditPathRef?.current;
|
||||
if (pendingPath && changedPath.endsWith(pendingPath)) {
|
||||
pendingTimelineEditPathRef!.current = null;
|
||||
return;
|
||||
}
|
||||
if (!recentDomEditSave) {
|
||||
reloadPreview();
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ interface UseTimelineEditingOptions {
|
||||
recordEdit: (input: RecordEditInput) => Promise<void>;
|
||||
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
||||
reloadPreview: () => void;
|
||||
previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
|
||||
pendingTimelineEditPathRef: React.MutableRefObject<string | null>;
|
||||
uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]>;
|
||||
}
|
||||
|
||||
@@ -53,6 +55,87 @@ function buildPatchTarget(element: { domId?: string; selector?: string; selector
|
||||
return null;
|
||||
}
|
||||
|
||||
function findIframeElement(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
element: { domId?: string; selector?: string; selectorIndex?: number },
|
||||
): Element | null {
|
||||
const doc = iframe?.contentDocument;
|
||||
if (!doc) return null;
|
||||
if (element.domId) return doc.getElementById(element.domId);
|
||||
if (!element.selector) return null;
|
||||
return doc.querySelectorAll(element.selector)[element.selectorIndex ?? 0] ?? null;
|
||||
}
|
||||
|
||||
const TIMING_ATTR_MAP: Record<string, string> = {
|
||||
start: "data-start",
|
||||
duration: "data-duration",
|
||||
track: "data-track-index",
|
||||
};
|
||||
|
||||
function patchIframeDomTiming(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
element: TimelineElement,
|
||||
updates: { start?: number; duration?: number; track?: number; playbackStart?: number },
|
||||
): void {
|
||||
try {
|
||||
const el = findIframeElement(iframe, element);
|
||||
if (!el) return;
|
||||
for (const [key, attr] of Object.entries(TIMING_ATTR_MAP)) {
|
||||
const val = updates[key as keyof typeof updates];
|
||||
if (val != null) el.setAttribute(attr, formatTimelineAttributeNumber(val));
|
||||
}
|
||||
if (updates.playbackStart != null) {
|
||||
const attr =
|
||||
element.playbackStartAttr === "playback-start" ? "data-playback-start" : "data-media-start";
|
||||
el.setAttribute(attr, formatTimelineAttributeNumber(updates.playbackStart));
|
||||
}
|
||||
} catch {
|
||||
// Cross-origin or mid-navigation — safe to ignore, file is already saved.
|
||||
}
|
||||
}
|
||||
|
||||
type PatchTarget = NonNullable<ReturnType<typeof buildPatchTarget>>;
|
||||
|
||||
interface PersistTimelineEditInput {
|
||||
projectId: string;
|
||||
element: TimelineElement;
|
||||
activeCompPath: string | null;
|
||||
label: string;
|
||||
buildPatches: (original: string, target: PatchTarget) => string;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
recordEdit: (input: RecordEditInput) => Promise<void>;
|
||||
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
||||
pendingTimelineEditPathRef: React.MutableRefObject<string | null>;
|
||||
}
|
||||
|
||||
async function persistTimelineEdit(input: PersistTimelineEditInput): Promise<void> {
|
||||
const targetPath = input.element.sourceFile || input.activeCompPath || "index.html";
|
||||
const originalContent = await readFileContent(input.projectId, targetPath);
|
||||
|
||||
const patchTarget = buildPatchTarget(input.element);
|
||||
if (!patchTarget) {
|
||||
throw new Error(`Timeline element ${input.element.id} is missing a patchable target`);
|
||||
}
|
||||
|
||||
const patchedContent = input.buildPatches(originalContent, patchTarget);
|
||||
if (patchedContent === originalContent) {
|
||||
throw new Error(`Unable to patch timeline element ${input.element.id} in ${targetPath}`);
|
||||
}
|
||||
|
||||
input.pendingTimelineEditPathRef.current = targetPath;
|
||||
input.domEditSaveTimestampRef.current = Date.now();
|
||||
await saveProjectFilesWithHistory({
|
||||
projectId: input.projectId,
|
||||
label: input.label,
|
||||
kind: "timeline",
|
||||
files: { [targetPath]: patchedContent },
|
||||
readFile: async () => originalContent,
|
||||
writeFile: input.writeProjectFile,
|
||||
recordEdit: input.recordEdit,
|
||||
});
|
||||
input.domEditSaveTimestampRef.current = Date.now();
|
||||
}
|
||||
|
||||
async function readFileContent(projectId: string, targetPath: string): Promise<string> {
|
||||
const response = await fetch(
|
||||
`/api/projects/${projectId}/files/${encodeURIComponent(targetPath)}`,
|
||||
@@ -78,127 +161,118 @@ export function useTimelineEditing({
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
previewIframeRef,
|
||||
pendingTimelineEditPathRef,
|
||||
uploadProjectFiles,
|
||||
}: UseTimelineEditingOptions) {
|
||||
const projectIdRef = useRef(projectId);
|
||||
projectIdRef.current = projectId;
|
||||
|
||||
const editQueueRef = useRef(Promise.resolve());
|
||||
const lastBlockedTimelineToastAtRef = useRef(0);
|
||||
|
||||
const handleTimelineElementMove = useCallback(
|
||||
async (element: TimelineElement, updates: Pick<TimelineElement, "start" | "track">) => {
|
||||
const enqueueEdit = useCallback(
|
||||
(
|
||||
element: TimelineElement,
|
||||
label: string,
|
||||
buildPatches: PersistTimelineEditInput["buildPatches"],
|
||||
) => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) throw new Error("No active project");
|
||||
|
||||
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
||||
const originalContent = await readFileContent(pid, targetPath);
|
||||
|
||||
const patchTarget = buildPatchTarget(element);
|
||||
if (!patchTarget) {
|
||||
throw new Error(`Timeline element ${element.id} is missing a patchable target`);
|
||||
}
|
||||
|
||||
let patchedContent = applyPatchByTarget(originalContent, patchTarget, {
|
||||
type: "attribute",
|
||||
property: "start",
|
||||
value: formatTimelineAttributeNumber(updates.start),
|
||||
});
|
||||
patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
|
||||
type: "attribute",
|
||||
property: "track-index",
|
||||
value: String(updates.track),
|
||||
});
|
||||
|
||||
if (patchedContent === originalContent) {
|
||||
throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
|
||||
}
|
||||
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
await saveProjectFilesWithHistory({
|
||||
projectId: pid,
|
||||
label: "Move timeline clip",
|
||||
kind: "timeline",
|
||||
files: { [targetPath]: patchedContent },
|
||||
readFile: async () => originalContent,
|
||||
writeFile: writeProjectFile,
|
||||
recordEdit,
|
||||
});
|
||||
|
||||
reloadPreview();
|
||||
if (!pid) return;
|
||||
editQueueRef.current = editQueueRef.current
|
||||
.then(() =>
|
||||
persistTimelineEdit({
|
||||
projectId: pid,
|
||||
element,
|
||||
activeCompPath,
|
||||
label,
|
||||
buildPatches,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
pendingTimelineEditPathRef,
|
||||
}),
|
||||
)
|
||||
.catch((error) => {
|
||||
console.error(`[Timeline] Failed to persist: ${label}`, error);
|
||||
});
|
||||
},
|
||||
[activeCompPath, recordEdit, writeProjectFile, domEditSaveTimestampRef, reloadPreview],
|
||||
[
|
||||
activeCompPath,
|
||||
recordEdit,
|
||||
writeProjectFile,
|
||||
domEditSaveTimestampRef,
|
||||
pendingTimelineEditPathRef,
|
||||
],
|
||||
);
|
||||
|
||||
const handleTimelineElementMove = useCallback(
|
||||
(element: TimelineElement, updates: Pick<TimelineElement, "start" | "track">) => {
|
||||
patchIframeDomTiming(previewIframeRef.current, element, updates);
|
||||
enqueueEdit(element, "Move timeline clip", (original, target) => {
|
||||
let patched = applyPatchByTarget(original, target, {
|
||||
type: "attribute",
|
||||
property: "start",
|
||||
value: formatTimelineAttributeNumber(updates.start),
|
||||
});
|
||||
return applyPatchByTarget(patched, target, {
|
||||
type: "attribute",
|
||||
property: "track-index",
|
||||
value: String(updates.track),
|
||||
});
|
||||
});
|
||||
},
|
||||
[previewIframeRef, enqueueEdit],
|
||||
);
|
||||
|
||||
const handleTimelineElementResize = useCallback(
|
||||
async (
|
||||
(
|
||||
element: TimelineElement,
|
||||
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
||||
) => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) throw new Error("No active project");
|
||||
patchIframeDomTiming(previewIframeRef.current, element, updates);
|
||||
enqueueEdit(element, "Resize timeline clip", (original, target) => {
|
||||
const playbackStartAttrName =
|
||||
element.playbackStartAttr === "playback-start" ? "playback-start" : "media-start";
|
||||
const currentPlaybackStartValue =
|
||||
readAttributeByTarget(original, target, "playback-start") ??
|
||||
readAttributeByTarget(original, target, "media-start");
|
||||
const currentPlaybackStart =
|
||||
currentPlaybackStartValue != null ? parseFloat(currentPlaybackStartValue) : undefined;
|
||||
const trimDelta = updates.start - element.start;
|
||||
const fallbackPlaybackStart =
|
||||
updates.playbackStart == null &&
|
||||
trimDelta !== 0 &&
|
||||
Number.isFinite(currentPlaybackStart) &&
|
||||
currentPlaybackStart != null
|
||||
? Math.max(
|
||||
0,
|
||||
currentPlaybackStart + trimDelta * Math.max(element.playbackRate ?? 1, 0.1),
|
||||
)
|
||||
: undefined;
|
||||
const nextPlaybackStart = updates.playbackStart ?? fallbackPlaybackStart;
|
||||
|
||||
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
||||
const originalContent = await readFileContent(pid, targetPath);
|
||||
|
||||
const patchTarget = buildPatchTarget(element);
|
||||
if (!patchTarget) {
|
||||
throw new Error(`Timeline element ${element.id} is missing a patchable target`);
|
||||
}
|
||||
|
||||
const playbackStartAttrName =
|
||||
element.playbackStartAttr === "playback-start" ? "playback-start" : "media-start";
|
||||
const currentPlaybackStartValue =
|
||||
readAttributeByTarget(originalContent, patchTarget, "playback-start") ??
|
||||
readAttributeByTarget(originalContent, patchTarget, "media-start");
|
||||
const currentPlaybackStart =
|
||||
currentPlaybackStartValue != null ? parseFloat(currentPlaybackStartValue) : undefined;
|
||||
const trimDelta = updates.start - element.start;
|
||||
const fallbackPlaybackStart =
|
||||
updates.playbackStart == null &&
|
||||
trimDelta !== 0 &&
|
||||
Number.isFinite(currentPlaybackStart) &&
|
||||
currentPlaybackStart != null
|
||||
? Math.max(0, currentPlaybackStart + trimDelta * Math.max(element.playbackRate ?? 1, 0.1))
|
||||
: undefined;
|
||||
const nextPlaybackStart = updates.playbackStart ?? fallbackPlaybackStart;
|
||||
|
||||
let patchedContent = originalContent;
|
||||
patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
|
||||
type: "attribute",
|
||||
property: "start",
|
||||
value: formatTimelineAttributeNumber(updates.start),
|
||||
});
|
||||
patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
|
||||
type: "attribute",
|
||||
property: "duration",
|
||||
value: formatTimelineAttributeNumber(updates.duration),
|
||||
});
|
||||
if (nextPlaybackStart != null) {
|
||||
patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
|
||||
let patched = applyPatchByTarget(original, target, {
|
||||
type: "attribute",
|
||||
property: playbackStartAttrName,
|
||||
value: formatTimelineAttributeNumber(nextPlaybackStart),
|
||||
property: "start",
|
||||
value: formatTimelineAttributeNumber(updates.start),
|
||||
});
|
||||
}
|
||||
|
||||
if (patchedContent === originalContent) {
|
||||
throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
|
||||
}
|
||||
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
await saveProjectFilesWithHistory({
|
||||
projectId: pid,
|
||||
label: "Resize timeline clip",
|
||||
kind: "timeline",
|
||||
files: { [targetPath]: patchedContent },
|
||||
readFile: async () => originalContent,
|
||||
writeFile: writeProjectFile,
|
||||
recordEdit,
|
||||
patched = applyPatchByTarget(patched, target, {
|
||||
type: "attribute",
|
||||
property: "duration",
|
||||
value: formatTimelineAttributeNumber(updates.duration),
|
||||
});
|
||||
if (nextPlaybackStart != null) {
|
||||
patched = applyPatchByTarget(patched, target, {
|
||||
type: "attribute",
|
||||
property: playbackStartAttrName,
|
||||
value: formatTimelineAttributeNumber(nextPlaybackStart),
|
||||
});
|
||||
}
|
||||
return patched;
|
||||
});
|
||||
|
||||
reloadPreview();
|
||||
},
|
||||
[activeCompPath, recordEdit, writeProjectFile, domEditSaveTimestampRef, reloadPreview],
|
||||
[previewIframeRef, enqueueEdit],
|
||||
);
|
||||
|
||||
const handleTimelineElementDelete = useCallback(
|
||||
|
||||
@@ -252,6 +252,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
||||
isHovered={hoveredClip === clipKey}
|
||||
isDragging={false}
|
||||
hasCustomContent={!!renderClipContent}
|
||||
capabilities={capabilities}
|
||||
theme={theme}
|
||||
trackStyle={clipStyle}
|
||||
isComposition={isComposition}
|
||||
@@ -369,6 +370,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
||||
isHovered={false}
|
||||
isDragging={true}
|
||||
hasCustomContent={!!renderClipContent}
|
||||
capabilities={getTimelineEditCapabilities(activeDraggedElement)}
|
||||
theme={theme}
|
||||
trackStyle={getTrackStyle(activeDraggedElement.tag)}
|
||||
isComposition={!!activeDraggedElement.compositionSrc}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { TimelineTrackStyle } from "./timelineTheme";
|
||||
import { memo, type ReactNode } from "react";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { defaultTimelineTheme, getClipHandleOpacity, type TimelineTheme } from "./timelineTheme";
|
||||
import { getTimelineEditCapabilities } from "./timelineEditing";
|
||||
import type { TimelineEditCapabilities } from "./timelineEditing";
|
||||
|
||||
interface TimelineClipProps {
|
||||
el: TimelineElement;
|
||||
@@ -13,6 +13,7 @@ interface TimelineClipProps {
|
||||
isHovered: boolean;
|
||||
isDragging?: boolean;
|
||||
hasCustomContent: boolean;
|
||||
capabilities: TimelineEditCapabilities;
|
||||
theme?: TimelineTheme;
|
||||
trackStyle: TimelineTrackStyle;
|
||||
isComposition: boolean;
|
||||
@@ -33,6 +34,7 @@ export const TimelineClip = memo(function TimelineClip({
|
||||
isHovered,
|
||||
isDragging = false,
|
||||
hasCustomContent,
|
||||
capabilities,
|
||||
theme = defaultTimelineTheme,
|
||||
trackStyle,
|
||||
isComposition,
|
||||
@@ -47,6 +49,7 @@ export const TimelineClip = memo(function TimelineClip({
|
||||
const leftPx = el.start * pps;
|
||||
const widthPx = Math.max(el.duration * pps, 4);
|
||||
const handleOpacity = getClipHandleOpacity({ isHovered, isSelected, isDragging });
|
||||
|
||||
const borderColor = isSelected
|
||||
? theme.clipBorderActive
|
||||
: isHovered
|
||||
@@ -59,7 +62,6 @@ export const TimelineClip = memo(function TimelineClip({
|
||||
: isHovered
|
||||
? theme.clipShadowHover
|
||||
: theme.clipShadow;
|
||||
const capabilities = getTimelineEditCapabilities(el);
|
||||
const displayLabel = el.label || el.id || el.tag;
|
||||
const showHandles = handleOpacity > 0.01;
|
||||
|
||||
|
||||
@@ -224,7 +224,7 @@ describe("getTimelineEditCapabilities", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("allows moving generic motion clips while keeping trims blocked", () => {
|
||||
it("allows full editing of generic motion clips with authored timing", () => {
|
||||
expect(
|
||||
getTimelineEditCapabilities({
|
||||
tag: "section",
|
||||
@@ -233,8 +233,8 @@ describe("getTimelineEditCapabilities", () => {
|
||||
}),
|
||||
).toEqual({
|
||||
canMove: true,
|
||||
canTrimStart: false,
|
||||
canTrimEnd: false,
|
||||
canTrimStart: true,
|
||||
canTrimEnd: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -285,7 +285,7 @@ describe("getTimelineEditCapabilities", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("allows move and end trim for patchable composition hosts", () => {
|
||||
it("allows full editing for patchable composition hosts", () => {
|
||||
expect(
|
||||
getTimelineEditCapabilities({
|
||||
tag: "div",
|
||||
@@ -295,7 +295,22 @@ describe("getTimelineEditCapabilities", () => {
|
||||
}),
|
||||
).toEqual({
|
||||
canMove: true,
|
||||
canTrimStart: false,
|
||||
canTrimStart: true,
|
||||
canTrimEnd: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows full editing of explicitly authored generic elements", () => {
|
||||
expect(
|
||||
getTimelineEditCapabilities({
|
||||
tag: "div",
|
||||
duration: 4,
|
||||
selector: "#hero-card",
|
||||
timingSource: "authored",
|
||||
}),
|
||||
).toEqual({
|
||||
canMove: true,
|
||||
canTrimStart: true,
|
||||
canTrimEnd: true,
|
||||
});
|
||||
});
|
||||
@@ -576,6 +591,40 @@ describe("resolveTimelineResize", () => {
|
||||
),
|
||||
).toEqual({ start: 0.8, duration: 3.2, playbackStart: 0 });
|
||||
});
|
||||
|
||||
it("trims generic element start without media offset", () => {
|
||||
expect(
|
||||
resolveTimelineResize(
|
||||
{
|
||||
start: 2,
|
||||
duration: 4,
|
||||
originClientX: 100,
|
||||
pixelsPerSecond: 100,
|
||||
minStart: 0,
|
||||
maxEnd: 10,
|
||||
},
|
||||
"start",
|
||||
200,
|
||||
),
|
||||
).toEqual({ start: 3, duration: 3, playbackStart: undefined });
|
||||
});
|
||||
|
||||
it("extends generic element start leftward to time zero", () => {
|
||||
expect(
|
||||
resolveTimelineResize(
|
||||
{
|
||||
start: 1,
|
||||
duration: 3,
|
||||
originClientX: 100,
|
||||
pixelsPerSecond: 100,
|
||||
minStart: 0,
|
||||
maxEnd: 10,
|
||||
},
|
||||
"start",
|
||||
-200,
|
||||
),
|
||||
).toEqual({ start: 0, duration: 4, playbackStart: undefined });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPromptCopyText", () => {
|
||||
|
||||
@@ -237,8 +237,8 @@ export function getTimelineEditCapabilities(input: {
|
||||
const hasDeterministicWindow = isDeterministicTimelineWindow(input);
|
||||
return {
|
||||
canMove: canPatch && (hasDeterministicWindow || hasFiniteDuration),
|
||||
canTrimEnd: canPatch && hasFiniteDuration && hasDeterministicWindow,
|
||||
canTrimStart: canPatch && hasFiniteDuration && canOffsetTrimClipStart(input),
|
||||
canTrimEnd: canPatch && hasFiniteDuration,
|
||||
canTrimStart: canPatch && hasFiniteDuration,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useTimelineSyncCallbacks } from "./useTimelineSyncCallbacks";
|
||||
// Re-export public API consumed by tests and external modules.
|
||||
// All of these were previously defined in this file; they now live in focused
|
||||
// sub-modules but are re-exported here so existing import sites don't change.
|
||||
export type { PlaybackAdapter, ClipManifestClip } from "../lib/playbackTypes";
|
||||
export type { ClipManifestClip } from "../lib/playbackTypes";
|
||||
export { createStaticSeekPlaybackAdapter } from "../lib/playbackAdapter";
|
||||
export {
|
||||
getTimelineElementSelector,
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
setPreviewPlaybackRate,
|
||||
shouldMutePreviewAudio,
|
||||
} from "../lib/timelineIframeHelpers";
|
||||
import { probeMediaUrl, getCachedProbe } from "../lib/mediaProbe";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
@@ -106,6 +107,31 @@ export function useTimelinePlayer() {
|
||||
if (!state.timelineReady) {
|
||||
setTimelineReady(true);
|
||||
}
|
||||
|
||||
// Asynchronously enrich media elements missing sourceDuration via mediabunny.
|
||||
// The probe reads file headers only — no full decode — so this is cheap.
|
||||
const needsProbe = mergedElements.filter(
|
||||
(el) =>
|
||||
el.src &&
|
||||
el.sourceDuration == null &&
|
||||
["video", "audio"].includes(el.tag.toLowerCase()) &&
|
||||
!getCachedProbe(el.src),
|
||||
);
|
||||
if (needsProbe.length > 0) {
|
||||
void Promise.allSettled(
|
||||
needsProbe.map(async (el) => {
|
||||
const result = await probeMediaUrl(el.src!);
|
||||
if (!result) return;
|
||||
const current = usePlayerStore.getState().elements;
|
||||
const key = el.key ?? el.id;
|
||||
const idx = current.findIndex((e) => (e.key ?? e.id) === key);
|
||||
if (idx === -1 || current[idx].sourceDuration != null) return;
|
||||
const patched = current.slice();
|
||||
patched[idx] = { ...current[idx], sourceDuration: result.duration };
|
||||
setElements(patched);
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
[setElements, setTimelineReady, setDuration],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Input, UrlSource, ALL_FORMATS } from "mediabunny";
|
||||
|
||||
export interface MediaProbeResult {
|
||||
duration: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
hasVideo: boolean;
|
||||
hasAudio: boolean;
|
||||
}
|
||||
|
||||
const cache = new Map<string, MediaProbeResult>();
|
||||
const inflight = new Map<string, Promise<MediaProbeResult | null>>();
|
||||
|
||||
function normalizeUrl(url: string): string {
|
||||
try {
|
||||
return new URL(url, window.location.href).href;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
async function probeOne(url: string): Promise<MediaProbeResult | null> {
|
||||
const input = new Input({
|
||||
source: new UrlSource(url),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
try {
|
||||
const duration = await input.getDurationFromMetadata();
|
||||
if (duration == null || !Number.isFinite(duration) || duration <= 0) return null;
|
||||
|
||||
const videoTrack = await input.getPrimaryVideoTrack();
|
||||
const audioTracks = await input.getAudioTracks();
|
||||
|
||||
const result: MediaProbeResult = {
|
||||
duration,
|
||||
width: videoTrack?.displayWidth,
|
||||
height: videoTrack?.displayHeight,
|
||||
hasVideo: videoTrack != null,
|
||||
hasAudio: audioTracks.length > 0,
|
||||
};
|
||||
return result;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
input.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedProbe(url: string): MediaProbeResult | undefined {
|
||||
return cache.get(normalizeUrl(url));
|
||||
}
|
||||
|
||||
export async function probeMediaUrl(url: string): Promise<MediaProbeResult | null> {
|
||||
const key = normalizeUrl(url);
|
||||
const cached = cache.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
let pending = inflight.get(key);
|
||||
if (pending) return pending;
|
||||
|
||||
pending = probeOne(key).then((result) => {
|
||||
inflight.delete(key);
|
||||
if (result) cache.set(key, result);
|
||||
return result;
|
||||
});
|
||||
inflight.set(key, pending);
|
||||
return pending;
|
||||
}
|
||||
Reference in New Issue
Block a user