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:
@@ -252,6 +252,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
||||
isHovered={hoveredClip === clipKey}
|
||||
isDragging={false}
|
||||
hasCustomContent={!!renderClipContent}
|
||||
capabilities={capabilities}
|
||||
theme={theme}
|
||||
trackStyle={clipStyle}
|
||||
isComposition={isComposition}
|
||||
@@ -369,6 +370,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
||||
isHovered={false}
|
||||
isDragging={true}
|
||||
hasCustomContent={!!renderClipContent}
|
||||
capabilities={getTimelineEditCapabilities(activeDraggedElement)}
|
||||
theme={theme}
|
||||
trackStyle={getTrackStyle(activeDraggedElement.tag)}
|
||||
isComposition={!!activeDraggedElement.compositionSrc}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { TimelineTrackStyle } from "./timelineTheme";
|
||||
import { memo, type ReactNode } from "react";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { defaultTimelineTheme, getClipHandleOpacity, type TimelineTheme } from "./timelineTheme";
|
||||
import { getTimelineEditCapabilities } from "./timelineEditing";
|
||||
import type { TimelineEditCapabilities } from "./timelineEditing";
|
||||
|
||||
interface TimelineClipProps {
|
||||
el: TimelineElement;
|
||||
@@ -13,6 +13,7 @@ interface TimelineClipProps {
|
||||
isHovered: boolean;
|
||||
isDragging?: boolean;
|
||||
hasCustomContent: boolean;
|
||||
capabilities: TimelineEditCapabilities;
|
||||
theme?: TimelineTheme;
|
||||
trackStyle: TimelineTrackStyle;
|
||||
isComposition: boolean;
|
||||
@@ -33,6 +34,7 @@ export const TimelineClip = memo(function TimelineClip({
|
||||
isHovered,
|
||||
isDragging = false,
|
||||
hasCustomContent,
|
||||
capabilities,
|
||||
theme = defaultTimelineTheme,
|
||||
trackStyle,
|
||||
isComposition,
|
||||
@@ -47,6 +49,7 @@ export const TimelineClip = memo(function TimelineClip({
|
||||
const leftPx = el.start * pps;
|
||||
const widthPx = Math.max(el.duration * pps, 4);
|
||||
const handleOpacity = getClipHandleOpacity({ isHovered, isSelected, isDragging });
|
||||
|
||||
const borderColor = isSelected
|
||||
? theme.clipBorderActive
|
||||
: isHovered
|
||||
@@ -59,7 +62,6 @@ export const TimelineClip = memo(function TimelineClip({
|
||||
: isHovered
|
||||
? theme.clipShadowHover
|
||||
: theme.clipShadow;
|
||||
const capabilities = getTimelineEditCapabilities(el);
|
||||
const displayLabel = el.label || el.id || el.tag;
|
||||
const showHandles = handleOpacity > 0.01;
|
||||
|
||||
|
||||
@@ -224,7 +224,7 @@ describe("getTimelineEditCapabilities", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("allows moving generic motion clips while keeping trims blocked", () => {
|
||||
it("allows full editing of generic motion clips with authored timing", () => {
|
||||
expect(
|
||||
getTimelineEditCapabilities({
|
||||
tag: "section",
|
||||
@@ -233,8 +233,8 @@ describe("getTimelineEditCapabilities", () => {
|
||||
}),
|
||||
).toEqual({
|
||||
canMove: true,
|
||||
canTrimStart: false,
|
||||
canTrimEnd: false,
|
||||
canTrimStart: true,
|
||||
canTrimEnd: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -285,7 +285,7 @@ describe("getTimelineEditCapabilities", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("allows move and end trim for patchable composition hosts", () => {
|
||||
it("allows full editing for patchable composition hosts", () => {
|
||||
expect(
|
||||
getTimelineEditCapabilities({
|
||||
tag: "div",
|
||||
@@ -295,7 +295,22 @@ describe("getTimelineEditCapabilities", () => {
|
||||
}),
|
||||
).toEqual({
|
||||
canMove: true,
|
||||
canTrimStart: false,
|
||||
canTrimStart: true,
|
||||
canTrimEnd: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows full editing of explicitly authored generic elements", () => {
|
||||
expect(
|
||||
getTimelineEditCapabilities({
|
||||
tag: "div",
|
||||
duration: 4,
|
||||
selector: "#hero-card",
|
||||
timingSource: "authored",
|
||||
}),
|
||||
).toEqual({
|
||||
canMove: true,
|
||||
canTrimStart: true,
|
||||
canTrimEnd: true,
|
||||
});
|
||||
});
|
||||
@@ -576,6 +591,40 @@ describe("resolveTimelineResize", () => {
|
||||
),
|
||||
).toEqual({ start: 0.8, duration: 3.2, playbackStart: 0 });
|
||||
});
|
||||
|
||||
it("trims generic element start without media offset", () => {
|
||||
expect(
|
||||
resolveTimelineResize(
|
||||
{
|
||||
start: 2,
|
||||
duration: 4,
|
||||
originClientX: 100,
|
||||
pixelsPerSecond: 100,
|
||||
minStart: 0,
|
||||
maxEnd: 10,
|
||||
},
|
||||
"start",
|
||||
200,
|
||||
),
|
||||
).toEqual({ start: 3, duration: 3, playbackStart: undefined });
|
||||
});
|
||||
|
||||
it("extends generic element start leftward to time zero", () => {
|
||||
expect(
|
||||
resolveTimelineResize(
|
||||
{
|
||||
start: 1,
|
||||
duration: 3,
|
||||
originClientX: 100,
|
||||
pixelsPerSecond: 100,
|
||||
minStart: 0,
|
||||
maxEnd: 10,
|
||||
},
|
||||
"start",
|
||||
-200,
|
||||
),
|
||||
).toEqual({ start: 0, duration: 4, playbackStart: undefined });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPromptCopyText", () => {
|
||||
|
||||
@@ -237,8 +237,8 @@ export function getTimelineEditCapabilities(input: {
|
||||
const hasDeterministicWindow = isDeterministicTimelineWindow(input);
|
||||
return {
|
||||
canMove: canPatch && (hasDeterministicWindow || hasFiniteDuration),
|
||||
canTrimEnd: canPatch && hasFiniteDuration && hasDeterministicWindow,
|
||||
canTrimStart: canPatch && hasFiniteDuration && canOffsetTrimClipStart(input),
|
||||
canTrimEnd: canPatch && hasFiniteDuration,
|
||||
canTrimStart: canPatch && hasFiniteDuration,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useTimelineSyncCallbacks } from "./useTimelineSyncCallbacks";
|
||||
// Re-export public API consumed by tests and external modules.
|
||||
// All of these were previously defined in this file; they now live in focused
|
||||
// sub-modules but are re-exported here so existing import sites don't change.
|
||||
export type { PlaybackAdapter, ClipManifestClip } from "../lib/playbackTypes";
|
||||
export type { ClipManifestClip } from "../lib/playbackTypes";
|
||||
export { createStaticSeekPlaybackAdapter } from "../lib/playbackAdapter";
|
||||
export {
|
||||
getTimelineElementSelector,
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
setPreviewPlaybackRate,
|
||||
shouldMutePreviewAudio,
|
||||
} from "../lib/timelineIframeHelpers";
|
||||
import { probeMediaUrl, getCachedProbe } from "../lib/mediaProbe";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
@@ -106,6 +107,31 @@ export function useTimelinePlayer() {
|
||||
if (!state.timelineReady) {
|
||||
setTimelineReady(true);
|
||||
}
|
||||
|
||||
// Asynchronously enrich media elements missing sourceDuration via mediabunny.
|
||||
// The probe reads file headers only — no full decode — so this is cheap.
|
||||
const needsProbe = mergedElements.filter(
|
||||
(el) =>
|
||||
el.src &&
|
||||
el.sourceDuration == null &&
|
||||
["video", "audio"].includes(el.tag.toLowerCase()) &&
|
||||
!getCachedProbe(el.src),
|
||||
);
|
||||
if (needsProbe.length > 0) {
|
||||
void Promise.allSettled(
|
||||
needsProbe.map(async (el) => {
|
||||
const result = await probeMediaUrl(el.src!);
|
||||
if (!result) return;
|
||||
const current = usePlayerStore.getState().elements;
|
||||
const key = el.key ?? el.id;
|
||||
const idx = current.findIndex((e) => (e.key ?? e.id) === key);
|
||||
if (idx === -1 || current[idx].sourceDuration != null) return;
|
||||
const patched = current.slice();
|
||||
patched[idx] = { ...current[idx], sourceDuration: result.duration };
|
||||
setElements(patched);
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
[setElements, setTimelineReady, setDuration],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Input, UrlSource, ALL_FORMATS } from "mediabunny";
|
||||
|
||||
export interface MediaProbeResult {
|
||||
duration: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
hasVideo: boolean;
|
||||
hasAudio: boolean;
|
||||
}
|
||||
|
||||
const cache = new Map<string, MediaProbeResult>();
|
||||
const inflight = new Map<string, Promise<MediaProbeResult | null>>();
|
||||
|
||||
function normalizeUrl(url: string): string {
|
||||
try {
|
||||
return new URL(url, window.location.href).href;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
async function probeOne(url: string): Promise<MediaProbeResult | null> {
|
||||
const input = new Input({
|
||||
source: new UrlSource(url),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
try {
|
||||
const duration = await input.getDurationFromMetadata();
|
||||
if (duration == null || !Number.isFinite(duration) || duration <= 0) return null;
|
||||
|
||||
const videoTrack = await input.getPrimaryVideoTrack();
|
||||
const audioTracks = await input.getAudioTracks();
|
||||
|
||||
const result: MediaProbeResult = {
|
||||
duration,
|
||||
width: videoTrack?.displayWidth,
|
||||
height: videoTrack?.displayHeight,
|
||||
hasVideo: videoTrack != null,
|
||||
hasAudio: audioTracks.length > 0,
|
||||
};
|
||||
return result;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
input.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedProbe(url: string): MediaProbeResult | undefined {
|
||||
return cache.get(normalizeUrl(url));
|
||||
}
|
||||
|
||||
export async function probeMediaUrl(url: string): Promise<MediaProbeResult | null> {
|
||||
const key = normalizeUrl(url);
|
||||
const cached = cache.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
let pending = inflight.get(key);
|
||||
if (pending) return pending;
|
||||
|
||||
pending = probeOne(key).then((result) => {
|
||||
inflight.delete(key);
|
||||
if (result) cache.set(key, result);
|
||||
return result;
|
||||
});
|
||||
inflight.set(key, pending);
|
||||
return pending;
|
||||
}
|
||||
Reference in New Issue
Block a user