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:
Miguel Ángel
2026-05-19 20:40:39 -04:00
parent 4237165517
commit 89f9ca196d
13 changed files with 460 additions and 346 deletions
@@ -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();
}
+175 -101
View File
@@ -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(