mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(studio): restore golden-branch timeline behaviors dropped by the stack rebuild
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.
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldDismissAssetPreview } from "./assetPreviewDismiss";
|
||||
|
||||
describe("shouldDismissAssetPreview", () => {
|
||||
const idle = { isPlaying: false, currentTime: 3.5, requestedSeekTime: null };
|
||||
|
||||
it("keeps the preview open while nothing moves", () => {
|
||||
expect(shouldDismissAssetPreview(3.5, idle)).toBe(false);
|
||||
});
|
||||
|
||||
it("tolerates sub-epsilon float noise in currentTime echoes", () => {
|
||||
expect(shouldDismissAssetPreview(3.5, { ...idle, currentTime: 3.5 + 1e-9 })).toBe(false);
|
||||
});
|
||||
|
||||
it("dismisses when playback starts", () => {
|
||||
expect(shouldDismissAssetPreview(3.5, { ...idle, isPlaying: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("dismisses when the playhead is scrubbed/seeked to a new time", () => {
|
||||
expect(shouldDismissAssetPreview(3.5, { ...idle, currentTime: 4.2 })).toBe(true);
|
||||
expect(shouldDismissAssetPreview(3.5, { ...idle, currentTime: 0 })).toBe(true);
|
||||
});
|
||||
|
||||
it("dismisses on a pending out-of-loop seek request", () => {
|
||||
expect(shouldDismissAssetPreview(3.5, { ...idle, requestedSeekTime: 3.5 })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Dismissal rule for the sidebar asset preview overlay (AssetPreviewOverlay):
|
||||
* the preview is a transient "look at this asset" state, so any playhead
|
||||
* activity — starting playback, or seeking/scrubbing away from where the
|
||||
* playhead sat when the preview opened — hands focus back to the canvas and
|
||||
* closes it.
|
||||
*
|
||||
* Pure — unit-tested. The overlay captures `openedTime` when the preview
|
||||
* opens and feeds every subsequent player-store snapshot through this.
|
||||
*/
|
||||
|
||||
export interface AssetPreviewDismissSnapshot {
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
/** Pending out-of-loop seek request (playerStore.requestedSeekTime). */
|
||||
requestedSeekTime: number | null;
|
||||
}
|
||||
|
||||
/** Tolerance for float noise in currentTime echoes (well under one frame). */
|
||||
const TIME_EPSILON_S = 1e-6;
|
||||
|
||||
export function shouldDismissAssetPreview(
|
||||
openedTime: number,
|
||||
snapshot: AssetPreviewDismissSnapshot,
|
||||
): boolean {
|
||||
if (snapshot.isPlaying) return true;
|
||||
if (snapshot.requestedSeekTime !== null) return true;
|
||||
return Math.abs(snapshot.currentTime - openedTime) > TIME_EPSILON_S;
|
||||
}
|
||||
@@ -1,13 +1,80 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildTimelineFileDropPlacements,
|
||||
buildTimelineAssetInsertHtml,
|
||||
extendCompositionDurationIfNeeded,
|
||||
fitTimelineAssetGeometry,
|
||||
getTimelineAssetKind,
|
||||
insertTimelineAssetIntoSource,
|
||||
resolveTimelineAssetInitialGeometry,
|
||||
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");
|
||||
@@ -16,12 +83,28 @@ describe("getTimelineAssetKind", () => {
|
||||
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,
|
||||
@@ -40,6 +123,7 @@ describe("buildTimelineAssetInsertHtml", () => {
|
||||
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,
|
||||
@@ -52,15 +136,13 @@ describe("buildTimelineAssetInsertHtml", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTimelineAssetInitialGeometry", () => {
|
||||
describe("resolveTimelineAssetCompositionSize", () => {
|
||||
it("uses the target composition dimensions for visual media", () => {
|
||||
expect(
|
||||
resolveTimelineAssetInitialGeometry(
|
||||
resolveTimelineAssetCompositionSize(
|
||||
`<div data-composition-id="main" data-width="330" data-height="228"></div>`,
|
||||
),
|
||||
).toEqual({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 330,
|
||||
height: 228,
|
||||
});
|
||||
@@ -84,7 +166,9 @@ describe("buildTimelineFileDropPlacements", () => {
|
||||
expect(buildTimelineFileDropPlacements({ start: 1.5, track: 2 }, [])).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses the dropped start and spaces multiple files by duration on the same track", () => {
|
||||
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 },
|
||||
@@ -99,52 +183,6 @@ describe("buildTimelineFileDropPlacements", () => {
|
||||
{ start: 7.7, track: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("moves the spaced sequence to a clear track when the dropped row is occupied", () => {
|
||||
expect(
|
||||
buildTimelineFileDropPlacements(
|
||||
{ start: 1.5, track: 2 },
|
||||
[1.2, 1.6, 1.1],
|
||||
[
|
||||
{ start: 0, duration: 8, track: 2 },
|
||||
{ start: 0, duration: 4, track: 5 },
|
||||
],
|
||||
),
|
||||
).toEqual([
|
||||
{ start: 1.5, track: 6 },
|
||||
{ start: 2.7, track: 6 },
|
||||
{ start: 4.3, track: 6 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a requested track above occupied rows when that track is clear", () => {
|
||||
expect(
|
||||
buildTimelineFileDropPlacements(
|
||||
{ start: 1.5, track: 8 },
|
||||
[1.2, 1.6],
|
||||
[
|
||||
{ start: 0, duration: 8, track: 2 },
|
||||
{ start: 0, duration: 4, track: 5 },
|
||||
],
|
||||
),
|
||||
).toEqual([
|
||||
{ start: 1.5, track: 8 },
|
||||
{ start: 2.7, track: 8 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("moves a default-track drop to a clear row when track 0 is occupied at time 0", () => {
|
||||
expect(
|
||||
buildTimelineFileDropPlacements(
|
||||
{ start: 0, track: 0 },
|
||||
[1.2, 1.6],
|
||||
[{ start: 0, duration: 8, track: 0 }],
|
||||
),
|
||||
).toEqual([
|
||||
{ start: 0, track: 1 },
|
||||
{ start: 1.2, track: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("insertTimelineAssetIntoSource", () => {
|
||||
@@ -159,3 +197,57 @@ describe("insertTimelineAssetIntoSource", () => {
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AUDIO_EXT, IMAGE_EXT, VIDEO_EXT } from "./mediaTypes";
|
||||
import { patchRootCompositionDuration, readRootCompositionDuration } from "./rootDuration";
|
||||
import { roundToCenti } from "./rounding";
|
||||
import { COMPOSITION_ROOT_OPEN_TAG_RE } from "./compositionPatterns";
|
||||
import { patchRootCompositionDuration, readRootCompositionDuration } from "./rootDuration";
|
||||
|
||||
export const TIMELINE_ASSET_MIME = "application/x-hyperframes-asset";
|
||||
export const TIMELINE_BLOCK_MIME = "application/x-hyperframes-block";
|
||||
@@ -49,125 +49,44 @@ export function resolveTimelineAssetSrc(targetPath: string, assetPath: string):
|
||||
return relative || assetPath.split("/").pop() || assetPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sequence one or more dropped files end-to-end starting at the drop point, all on
|
||||
* the track the user dropped onto. The clip lands where the ghost showed it — we do
|
||||
* NOT bump to a different track on overlap (that produced surprise "new tracks" and,
|
||||
* because it jumped past high indices like a grain-overlay track, wild numbers).
|
||||
* HyperFrames allows time-overlap on a track; the user can nudge if they want a gap.
|
||||
*/
|
||||
export function buildTimelineFileDropPlacements(
|
||||
placement: { start: number; track: number },
|
||||
durations: number[],
|
||||
occupiedClips: Array<{ start: number; duration: number; track: number }> = [],
|
||||
): Array<{ start: number; track: number }> {
|
||||
let nextStart = roundToCenti(Math.max(0, placement.start));
|
||||
const sequenceStart = nextStart;
|
||||
const resolvedDurations = durations.map((duration) =>
|
||||
Number.isFinite(duration) && duration > 0 ? duration : FALLBACK_TIMELINE_FILE_DROP_DURATION,
|
||||
);
|
||||
const sequenceEnd = resolvedDurations.reduce(
|
||||
(end, duration) => roundToCenti(end + duration),
|
||||
sequenceStart,
|
||||
);
|
||||
const overlapsDropTrack = occupiedClips.some((clip) => {
|
||||
if (clip.track !== placement.track) return false;
|
||||
const clipStart = Math.max(0, clip.start);
|
||||
const clipEnd = clipStart + Math.max(0, clip.duration);
|
||||
return sequenceStart < clipEnd && sequenceEnd > clipStart;
|
||||
});
|
||||
const track = overlapsDropTrack
|
||||
? Math.max(placement.track, ...occupiedClips.map((clip) => clip.track)) + 1
|
||||
: placement.track;
|
||||
|
||||
return resolvedDurations.map((duration) => {
|
||||
return durations.map((rawDuration) => {
|
||||
const duration =
|
||||
Number.isFinite(rawDuration) && rawDuration > 0
|
||||
? rawDuration
|
||||
: FALLBACK_TIMELINE_FILE_DROP_DURATION;
|
||||
const start = nextStart;
|
||||
nextStart = roundToCenti(nextStart + duration);
|
||||
return { start, track };
|
||||
return { start, track: placement.track };
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveTimelineAssetInitialGeometry(source: string): {
|
||||
left: number;
|
||||
top: number;
|
||||
export function resolveTimelineAssetCompositionSize(source: string): {
|
||||
width: number;
|
||||
height: number;
|
||||
} {
|
||||
const width = Number.parseFloat(source.match(/\bdata-width=(["'])([^"']+)\1/i)?.[2] ?? "");
|
||||
const height = Number.parseFloat(source.match(/\bdata-height=(["'])([^"']+)\1/i)?.[2] ?? "");
|
||||
|
||||
return {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: Number.isFinite(width) && width > 0 ? Math.round(width) : 640,
|
||||
height: Number.isFinite(height) && height > 0 ? Math.round(height) : 360,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTimelineAssetInsertHtml(input: {
|
||||
id: string;
|
||||
/** Stable hf-id stamped as data-hf-id by the NLE drop path (optional in the legacy path). */
|
||||
hfId?: string;
|
||||
assetPath: string;
|
||||
kind: TimelineAssetKind;
|
||||
start: number;
|
||||
duration: number;
|
||||
track: number;
|
||||
zIndex: number;
|
||||
geometry?: { left: number; top: number; width: number; height: number };
|
||||
}): string {
|
||||
const sharedAttrs = `id="${input.id}" class="clip" src="${input.assetPath}" data-start="${input.start}" data-duration="${input.duration}" data-track-index="${input.track}"`;
|
||||
const geometry = input.geometry ?? { left: 0, top: 0, width: 640, height: 360 };
|
||||
const visualStyles = `position: absolute; left: ${geometry.left}px; top: ${geometry.top}px; width: ${geometry.width}px; height: ${geometry.height}px; object-fit: contain; z-index: ${input.zIndex}`;
|
||||
|
||||
if (input.kind === "image") {
|
||||
return `<img ${sharedAttrs} style="${visualStyles}" />`;
|
||||
}
|
||||
|
||||
if (input.kind === "video") {
|
||||
return `<video ${sharedAttrs} muted playsinline style="${visualStyles}"></video>`;
|
||||
}
|
||||
|
||||
return `<audio ${sharedAttrs} style="z-index: ${input.zIndex}"></audio>`;
|
||||
}
|
||||
|
||||
export function insertTimelineAssetIntoSource(source: string, assetHtml: string): string {
|
||||
const match = COMPOSITION_ROOT_OPEN_TAG_RE.exec(source);
|
||||
if (!match || match.index == null) {
|
||||
throw new Error("No composition root found in target source");
|
||||
}
|
||||
const insertAt = match.index + match[0].length;
|
||||
const lineStart = source.lastIndexOf("\n", match.index);
|
||||
const leadingWhitespace = source.slice(lineStart + 1, match.index).match(/^(\s*)/)?.[1] ?? "";
|
||||
const childIndent = leadingWhitespace + " ";
|
||||
const indented = assetHtml
|
||||
.split("\n")
|
||||
.map((line, i) => (i === 0 ? line : childIndent + line))
|
||||
.join("\n");
|
||||
return `${source.slice(0, insertAt)}\n${childIndent}${indented}${source.slice(insertAt)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the composition root's `data-duration` to `contentEnd` (grow OR shrink) so the
|
||||
* timeline length tracks content — the content-driven counterpart to
|
||||
* extendCompositionDurationIfNeeded's grow-only ratchet. Used after edits that can
|
||||
* reduce the furthest clip end (delete/trim). No-op when `contentEnd` is not > 0, so
|
||||
* an empty timeline keeps its declared duration instead of collapsing to 0.
|
||||
*/
|
||||
export function setCompositionDurationToContent(source: string, contentEnd: number): string {
|
||||
if (!Number.isFinite(contentEnd) || contentEnd <= 0) return source;
|
||||
const rootDur = readRootCompositionDuration(source);
|
||||
if (rootDur == null) return source;
|
||||
const next = roundToCenti(contentEnd);
|
||||
if (rootDur === next) return source;
|
||||
return patchRootCompositionDuration(source, String(next));
|
||||
}
|
||||
|
||||
export function extendCompositionDurationIfNeeded(source: string, requiredEnd: number): string {
|
||||
const rootDur = readRootCompositionDuration(source);
|
||||
if (rootDur == null || !Number.isFinite(rootDur) || requiredEnd <= rootDur) return source;
|
||||
return patchRootCompositionDuration(source, String(roundToCenti(requiredEnd)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the composition root's `data-duration` to `contentEnd` (grow OR shrink) so the
|
||||
* timeline length tracks content — the content-driven counterpart to
|
||||
* extendCompositionDurationIfNeeded's grow-only ratchet. Used after edits that can
|
||||
* reduce the furthest clip end (delete/trim). No-op when `contentEnd` is not > 0, so
|
||||
* an empty timeline keeps its declared duration instead of collapsing to 0.
|
||||
* CapCut-style placement: natural size when it fits, scaled-to-fit when
|
||||
* oversized, always centered. Unknown natural size → full-frame.
|
||||
*/
|
||||
export function fitTimelineAssetGeometry(
|
||||
natural: { width: number; height: number } | null,
|
||||
@@ -187,14 +106,71 @@ export function fitTimelineAssetGeometry(
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveTimelineAssetCompositionSize(source: string): {
|
||||
width: number;
|
||||
height: number;
|
||||
} {
|
||||
const width = Number.parseFloat(source.match(/\bdata-width=(["'])([^"']+)\1/i)?.[2] ?? "");
|
||||
const height = Number.parseFloat(source.match(/\bdata-height=(["'])([^"']+)\1/i)?.[2] ?? "");
|
||||
return {
|
||||
width: Number.isFinite(width) && width > 0 ? Math.round(width) : 640,
|
||||
height: Number.isFinite(height) && height > 0 ? Math.round(height) : 360,
|
||||
};
|
||||
export function buildTimelineAssetInsertHtml(input: {
|
||||
id: string;
|
||||
hfId: string;
|
||||
assetPath: string;
|
||||
kind: TimelineAssetKind;
|
||||
start: number;
|
||||
duration: number;
|
||||
track: number;
|
||||
zIndex: number;
|
||||
geometry?: { left: number; top: number; width: number; height: number };
|
||||
}): string {
|
||||
const sharedAttrs = `id="${input.id}" data-hf-id="${input.hfId}" class="clip" src="${input.assetPath}" data-start="${input.start}" data-duration="${input.duration}" data-track-index="${input.track}"`;
|
||||
const geometry = input.geometry ?? { left: 0, top: 0, width: 640, height: 360 };
|
||||
const visualStyles = `position: absolute; left: ${geometry.left}px; top: ${geometry.top}px; width: ${geometry.width}px; height: ${geometry.height}px; object-fit: contain; z-index: ${input.zIndex}`;
|
||||
|
||||
if (input.kind === "image") {
|
||||
return `<img ${sharedAttrs} style="${visualStyles}" />`;
|
||||
}
|
||||
|
||||
if (input.kind === "video") {
|
||||
return `<video ${sharedAttrs} muted playsinline style="${visualStyles}"></video>`;
|
||||
}
|
||||
|
||||
return `<audio ${sharedAttrs} data-volume="1" style="z-index: ${input.zIndex}"></audio>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A clip inserted past the composition end would exist in the HTML but never
|
||||
* appear on the timeline or in playback. Extend the root's data-duration to
|
||||
* cover it (mirrors blockInstaller's behavior for installed blocks).
|
||||
*/
|
||||
export function extendCompositionDurationIfNeeded(source: string, requiredEnd: number): string {
|
||||
const rootDur = readRootCompositionDuration(source);
|
||||
if (rootDur == null || !Number.isFinite(rootDur) || requiredEnd <= rootDur) return source;
|
||||
return patchRootCompositionDuration(source, String(roundToCenti(requiredEnd)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the composition root's `data-duration` to `contentEnd` (grow OR shrink) so the
|
||||
* timeline length tracks content — the content-driven counterpart to
|
||||
* extendCompositionDurationIfNeeded's grow-only ratchet. Used after edits that can
|
||||
* reduce the furthest clip end (delete/trim). No-op when `contentEnd` is not > 0, so
|
||||
* an empty timeline keeps its declared duration instead of collapsing to 0.
|
||||
*/
|
||||
export function setCompositionDurationToContent(source: string, contentEnd: number): string {
|
||||
if (!Number.isFinite(contentEnd) || contentEnd <= 0) return source;
|
||||
const rootDur = readRootCompositionDuration(source);
|
||||
if (rootDur == null) return source;
|
||||
const next = roundToCenti(contentEnd);
|
||||
if (rootDur === next) return source;
|
||||
return patchRootCompositionDuration(source, String(next));
|
||||
}
|
||||
|
||||
export function insertTimelineAssetIntoSource(source: string, assetHtml: string): string {
|
||||
const match = COMPOSITION_ROOT_OPEN_TAG_RE.exec(source);
|
||||
if (!match || match.index == null) {
|
||||
throw new Error("No composition root found in target source");
|
||||
}
|
||||
const insertAt = match.index + match[0].length;
|
||||
const lineStart = source.lastIndexOf("\n", match.index);
|
||||
const leadingWhitespace = source.slice(lineStart + 1, match.index).match(/^(\s*)/)?.[1] ?? "";
|
||||
const childIndent = leadingWhitespace + " ";
|
||||
const indented = assetHtml
|
||||
.split("\n")
|
||||
.map((line, i) => (i === 0 ? line : childIndent + line))
|
||||
.join("\n");
|
||||
return `${source.slice(0, insertAt)}\n${childIndent}${indented}${source.slice(insertAt)}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user