mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 02:36:10 +00:00
The Studio stack rebuild (#2291) landed the remaining NLE layers but dropped or regressed several final-wave behaviors from the reviewed studio-dnd stack, and never repaired the stale timelineZones.ts that #2279 introduced. Restores: - TimelineRuler: sticky under vertical scroll, full-height gridlines removed (beat lines only), frame-number tick labels via a persisted timeDisplayMode store preference (PlayerControls toggle now store-backed) - timelineZones: stable track lanes — lane = authored data-track-index ascending; z is paint order only (replaces the stale z-driven lane pack, which broke track insert-band commits that contractually depend on it) - persistTimelineBatchEdit: a batch member whose patch is a no-op (attributes already at target values, e.g. in a track-insert renumber) is skipped instead of aborting and rolling back the whole batch — this alone made new-track creation (incl. the top insert band) fail silently - useTimelineStackingSync: unresolvable clips read as NaN again so timelineStackingSync's Number.isFinite exclusion contract holds (z=0 fabrications skewed stacking boundaries) - timelineAssetDrop: drops land on the drop track (no overlap bump to max-track+1), data-hf-id stamped, audio gets data-volume - timing edits: soft-reload the server's rewritten GSAP script instead of a full iframe remount (no all-clips flash on move/resize); full reload only when no scriptText or the soft path can't apply, and one full reload when a group edit touches non-active files (new hooks/timelineTimingSync.ts) - duration: content-driven grow-AND-shrink on move/resize/delete, synced optimistically to the store and the live root data-duration at release (was a grow-only ratchet; shrink never updated the readout) New UX: sidebar asset click opens a compact non-modal preview over the canvas (dismiss on outside click, Escape, playback, or seek), and clicking an already-added asset reveals its clip in the timeline (smooth minimal scroll to its time and lane; vertical-only in fit zoom). Verified by pointer-driving a real project: sticky ruler + gridline removal, no iframe remount on move/resize (marker survives, GSAP tween positions rewritten in place), duration readout 40->37->40 on shrink/stretch, and top-insert-band track creation renumbering lanes correctly on disk.
254 lines
8.5 KiB
TypeScript
254 lines
8.5 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
buildTimelineFileDropPlacements,
|
|
buildTimelineAssetInsertHtml,
|
|
extendCompositionDurationIfNeeded,
|
|
fitTimelineAssetGeometry,
|
|
getTimelineAssetKind,
|
|
insertTimelineAssetIntoSource,
|
|
resolveTimelineAssetCompositionSize,
|
|
resolveTimelineAssetSrc,
|
|
setCompositionDurationToContent,
|
|
} from "./timelineAssetDrop";
|
|
|
|
describe("setCompositionDurationToContent", () => {
|
|
const src = (dur: number) =>
|
|
`<div id="root" data-composition-id="c" data-duration="${dur}">x</div>`;
|
|
|
|
it("shrinks the root duration to the content end", () => {
|
|
expect(setCompositionDurationToContent(src(20), 8)).toContain('data-duration="8"');
|
|
});
|
|
|
|
it("grows the root duration to the content end", () => {
|
|
expect(setCompositionDurationToContent(src(5), 12)).toContain('data-duration="12"');
|
|
});
|
|
|
|
it("is a no-op when content end is 0 (empty timeline keeps its declared length)", () => {
|
|
expect(setCompositionDurationToContent(src(12), 0)).toBe(src(12));
|
|
});
|
|
|
|
it("is a no-op when already equal", () => {
|
|
expect(setCompositionDurationToContent(src(9), 9)).toBe(src(9));
|
|
});
|
|
|
|
// Reviewer round-2 finding #3: attribute-order and single-quote variants that
|
|
// the old order-dependent, double-quotes-only regex silently ignored.
|
|
it("patches when data-duration precedes data-composition-id", () => {
|
|
const source = `<div data-duration="20" data-composition-id="c">x</div>`;
|
|
expect(setCompositionDurationToContent(source, 8)).toBe(
|
|
`<div data-duration="8" data-composition-id="c">x</div>`,
|
|
);
|
|
});
|
|
|
|
it("patches single-quoted attributes and keeps the quote style", () => {
|
|
const source = `<div data-composition-id='c' data-duration='20'>x</div>`;
|
|
expect(setCompositionDurationToContent(source, 8)).toBe(
|
|
`<div data-composition-id='c' data-duration='8'>x</div>`,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("extendCompositionDurationIfNeeded", () => {
|
|
it("grows the root duration when a clip lands past the end", () => {
|
|
const source = `<div data-composition-id="c" data-duration="5">x</div>`;
|
|
expect(extendCompositionDurationIfNeeded(source, 8)).toBe(
|
|
`<div data-composition-id="c" data-duration="8">x</div>`,
|
|
);
|
|
});
|
|
|
|
it("is a no-op when the required end fits within the current duration", () => {
|
|
const source = `<div data-composition-id="c" data-duration="10">x</div>`;
|
|
expect(extendCompositionDurationIfNeeded(source, 8)).toBe(source);
|
|
});
|
|
|
|
it("grows even when the attribute order is swapped and quotes are single", () => {
|
|
const source = `<div data-duration='5' data-composition-id='c'>x</div>`;
|
|
expect(extendCompositionDurationIfNeeded(source, 8)).toBe(
|
|
`<div data-duration='8' data-composition-id='c'>x</div>`,
|
|
);
|
|
});
|
|
|
|
it("is a no-op when there is no composition root", () => {
|
|
const source = `<div data-duration="5">x</div>`;
|
|
expect(extendCompositionDurationIfNeeded(source, 8)).toBe(source);
|
|
});
|
|
});
|
|
|
|
describe("getTimelineAssetKind", () => {
|
|
it("detects image, video, and audio assets", () => {
|
|
expect(getTimelineAssetKind("assets/photo.png")).toBe("image");
|
|
expect(getTimelineAssetKind("assets/clip.mp4")).toBe("video");
|
|
expect(getTimelineAssetKind("assets/clip.mov")).toBe("video");
|
|
expect(getTimelineAssetKind("assets/music.mp3")).toBe("audio");
|
|
expect(getTimelineAssetKind("assets/music.wav")).toBe("audio");
|
|
});
|
|
|
|
it("classifies svg as image", () => {
|
|
expect(getTimelineAssetKind("assets/logo.svg")).toBe("image");
|
|
expect(getTimelineAssetKind("assets/ICON.SVG")).toBe("image");
|
|
});
|
|
|
|
it("classifies avif and webp as image", () => {
|
|
expect(getTimelineAssetKind("assets/photo.avif")).toBe("image");
|
|
expect(getTimelineAssetKind("assets/photo.webp")).toBe("image");
|
|
});
|
|
|
|
it("returns null for unknown extensions", () => {
|
|
expect(getTimelineAssetKind("assets/data.json")).toBeNull();
|
|
expect(getTimelineAssetKind("assets/font.woff2")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("buildTimelineAssetInsertHtml", () => {
|
|
it("builds an image clip with explicit timing and track", () => {
|
|
const html = buildTimelineAssetInsertHtml({
|
|
id: "photo_asset",
|
|
hfId: "hf-abc123",
|
|
assetPath: "assets/photo.png",
|
|
kind: "image",
|
|
start: 1.25,
|
|
duration: 3,
|
|
track: 2,
|
|
zIndex: 4,
|
|
geometry: { left: 0, top: 0, width: 1280, height: 720 },
|
|
});
|
|
|
|
expect(html).toContain('img id="photo_asset"');
|
|
expect(html).toContain("left: 0px");
|
|
expect(html).toContain("width: 1280px");
|
|
expect(html).not.toContain("inset:");
|
|
});
|
|
|
|
it("builds an audio clip without visual layout styles", () => {
|
|
const html = buildTimelineAssetInsertHtml({
|
|
id: "music_asset",
|
|
hfId: "hf-xyz789",
|
|
assetPath: "assets/music.wav",
|
|
kind: "audio",
|
|
start: 0.5,
|
|
duration: 5,
|
|
track: 0,
|
|
zIndex: 1,
|
|
});
|
|
expect(html).toContain("<audio");
|
|
expect(html).not.toContain("object-fit");
|
|
});
|
|
});
|
|
|
|
describe("resolveTimelineAssetCompositionSize", () => {
|
|
it("uses the target composition dimensions for visual media", () => {
|
|
expect(
|
|
resolveTimelineAssetCompositionSize(
|
|
`<div data-composition-id="main" data-width="330" data-height="228"></div>`,
|
|
),
|
|
).toEqual({
|
|
width: 330,
|
|
height: 228,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("resolveTimelineAssetSrc", () => {
|
|
it("keeps project-root asset paths for index.html", () => {
|
|
expect(resolveTimelineAssetSrc("index.html", "assets/photo.png")).toBe("assets/photo.png");
|
|
});
|
|
|
|
it("rewrites asset paths relative to sub-compositions", () => {
|
|
expect(resolveTimelineAssetSrc("compositions/scene-a.html", "assets/photo.png")).toBe(
|
|
"../assets/photo.png",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("buildTimelineFileDropPlacements", () => {
|
|
it("returns no placements for an empty drop set", () => {
|
|
expect(buildTimelineFileDropPlacements({ start: 1.5, track: 2 }, [])).toEqual([]);
|
|
});
|
|
|
|
it("spaces multiple files by duration and keeps every one on the dropped track", () => {
|
|
// A clip placed onto an occupied track stays there (overlap is allowed); it is
|
|
// NOT bumped to a new track — that produced surprise empty tracks for users.
|
|
expect(buildTimelineFileDropPlacements({ start: 1.5, track: 2 }, [1.2, 1.6, 1.1])).toEqual([
|
|
{ start: 1.5, track: 2 },
|
|
{ start: 2.7, track: 2 },
|
|
{ start: 4.3, track: 2 },
|
|
]);
|
|
});
|
|
|
|
it("uses fallback spacing when a duration is unavailable", () => {
|
|
expect(buildTimelineFileDropPlacements({ start: 1.5, track: 2 }, [1.2, 0, 1.1])).toEqual([
|
|
{ start: 1.5, track: 2 },
|
|
{ start: 2.7, track: 2 },
|
|
{ start: 7.7, track: 2 },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("insertTimelineAssetIntoSource", () => {
|
|
it("appends the new asset inside the root composition", () => {
|
|
const source = `<!doctype html><html><body><div id="root" data-composition-id="main"></div></body></html>`;
|
|
const html = insertTimelineAssetIntoSource(
|
|
source,
|
|
'<img id="photo_asset" data-start="0" data-duration="3" />',
|
|
);
|
|
|
|
expect(html).toContain('data-composition-id="main">');
|
|
expect(html).toContain('<img id="photo_asset" data-start="0" data-duration="3" />');
|
|
});
|
|
});
|
|
|
|
describe("buildTimelineAssetInsertHtml markup quality", () => {
|
|
const base = {
|
|
id: "clip_1",
|
|
hfId: "hf-test-1",
|
|
assetPath: "assets/a.mp4",
|
|
start: 1,
|
|
duration: 4,
|
|
track: 2,
|
|
zIndex: 3,
|
|
};
|
|
|
|
it("stamps data-hf-id on all kinds", () => {
|
|
for (const kind of ["image", "video", "audio"] as const) {
|
|
expect(buildTimelineAssetInsertHtml({ ...base, kind })).toContain('data-hf-id="hf-test-1"');
|
|
}
|
|
});
|
|
|
|
it("audio gets an explicit data-volume", () => {
|
|
expect(buildTimelineAssetInsertHtml({ ...base, kind: "audio" })).toContain('data-volume="1"');
|
|
});
|
|
});
|
|
|
|
describe("fitTimelineAssetGeometry", () => {
|
|
const comp = { width: 1920, height: 1080 };
|
|
|
|
it("centers a smaller-than-comp asset at natural size", () => {
|
|
expect(fitTimelineAssetGeometry({ width: 640, height: 360 }, comp)).toEqual({
|
|
left: 640,
|
|
top: 360,
|
|
width: 640,
|
|
height: 360,
|
|
});
|
|
});
|
|
|
|
it("scales an oversized asset down to fit, preserving aspect, centered", () => {
|
|
// 4000x1000 → capped to 1920 wide → 1920x480, centered vertically
|
|
expect(fitTimelineAssetGeometry({ width: 4000, height: 1000 }, comp)).toEqual({
|
|
left: 0,
|
|
top: 300,
|
|
width: 1920,
|
|
height: 480,
|
|
});
|
|
});
|
|
|
|
it("falls back to full-frame when natural size is unknown", () => {
|
|
expect(fitTimelineAssetGeometry(null, comp)).toEqual({
|
|
left: 0,
|
|
top: 0,
|
|
width: 1920,
|
|
height: 1080,
|
|
});
|
|
});
|
|
});
|