mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
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:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user