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:
ukimsanov
2026-07-13 16:48:51 -07:00
parent d04bcbf7c4
commit 54f41b41b6
25 changed files with 1752 additions and 1394 deletions
+84 -108
View File
@@ -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)}`;
}