mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 00:56:23 +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.
177 lines
7.2 KiB
TypeScript
177 lines
7.2 KiB
TypeScript
import { AUDIO_EXT, IMAGE_EXT, VIDEO_EXT } from "./mediaTypes";
|
|
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";
|
|
const FALLBACK_TIMELINE_FILE_DROP_DURATION = 5;
|
|
|
|
export type TimelineAssetKind = "image" | "video" | "audio";
|
|
|
|
export function getTimelineAssetKind(assetPath: string): TimelineAssetKind | null {
|
|
if (IMAGE_EXT.test(assetPath)) return "image";
|
|
if (VIDEO_EXT.test(assetPath)) return "video";
|
|
if (AUDIO_EXT.test(assetPath)) return "audio";
|
|
return null;
|
|
}
|
|
|
|
export function buildTimelineAssetId(assetPath: string, existingIds: Iterable<string>): string {
|
|
const baseName = assetPath.split("/").pop() ?? "asset";
|
|
const normalized = baseName
|
|
.replace(/\.[^.]+$/, "")
|
|
.replace(/[^a-zA-Z0-9_-]+/g, "_")
|
|
.replace(/^_+|_+$/g, "")
|
|
.toLowerCase();
|
|
const baseId = normalized || "asset";
|
|
const ids = new Set(existingIds);
|
|
if (!ids.has(baseId)) return baseId;
|
|
let suffix = 2;
|
|
while (ids.has(`${baseId}_${suffix}`)) suffix += 1;
|
|
return `${baseId}_${suffix}`;
|
|
}
|
|
|
|
export function resolveTimelineAssetSrc(targetPath: string, assetPath: string): string {
|
|
const targetDir = targetPath.includes("/")
|
|
? targetPath.slice(0, targetPath.lastIndexOf("/"))
|
|
: "";
|
|
if (!targetDir) return assetPath;
|
|
|
|
const fromParts = targetDir.split("/").filter(Boolean);
|
|
const toParts = assetPath.split("/").filter(Boolean);
|
|
while (fromParts.length > 0 && toParts.length > 0 && fromParts[0] === toParts[0]) {
|
|
fromParts.shift();
|
|
toParts.shift();
|
|
}
|
|
|
|
const up = fromParts.map(() => "..");
|
|
const relative = [...up, ...toParts].join("/");
|
|
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[],
|
|
): Array<{ start: number; track: number }> {
|
|
let nextStart = roundToCenti(Math.max(0, placement.start));
|
|
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: placement.track };
|
|
});
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
comp: { width: number; height: number },
|
|
): { left: number; top: number; width: number; height: number } {
|
|
if (!natural || natural.width <= 0 || natural.height <= 0) {
|
|
return { left: 0, top: 0, width: comp.width, height: comp.height };
|
|
}
|
|
const scale = Math.min(1, comp.width / natural.width, comp.height / natural.height);
|
|
const width = Math.round(natural.width * scale);
|
|
const height = Math.round(natural.height * scale);
|
|
return {
|
|
left: Math.round((comp.width - width) / 2),
|
|
top: Math.round((comp.height - height) / 2),
|
|
width,
|
|
height,
|
|
};
|
|
}
|
|
|
|
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)}`;
|
|
}
|