mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix: add progress logging during silent render pipeline stages (#1220)
* fix: add progress logging during silent render pipeline stages The render pipeline only updates progress at stage boundaries (5%, 10%, 25%), leaving multi-minute gaps with zero log output on low-memory hardware. This adds log.info calls at key sub-steps within the three silent stages: - Probe stage (5%): browser launch, session initialization, duration discovery, media asset discovery, audio volume automation, video visibility window detection - Video extraction (10%): per-video extraction progress - Calibration (25%): browser launch, session initialization, per-frame calibration progress, final cost estimate Also adds 30-second heartbeat timers for the two initializeSession calls (probe and calibration) that can individually take minutes on constrained hardware. Closes #1218 * fix: resolve CI failures in typecheck, runtime seek test, and timeline test - Make handleGsapMaterializeKeyframes optional in DomEditSessionSlice and use optional chaining at the call site (not yet wired) - Update GSAP adapter seek test to expect nudge+seek pattern (totalTime with suppressEvents:true followed by actual seek) - Fix Timeline canvas height test to use TRACK_H constant (48) instead of stale hardcoded value (72) * refactor: extract helpers to meet 600-line file size limit - App.tsx (603→594): extract StudioToast component - useDomEditSession.ts (688→600): extract useGsapSelectionHandlers hook - Timeline.tsx (614→557): extract useTimelineAssetDrop hook - PropertyPanel.tsx (647→584): extract TimingSection to propertyPanelTimingSection * style: fix formatting in TimelineToolbar
This commit is contained in:
@@ -210,11 +210,15 @@ export async function measureCaptureCostFromSession(
|
||||
session: CaptureSession,
|
||||
totalFrames: number,
|
||||
fps: number,
|
||||
log?: ProducerLogger,
|
||||
): Promise<{ estimate: CaptureCostEstimate; samples: CaptureCalibrationSample[] }> {
|
||||
const sampledFrames = selectCaptureCalibrationFrames(totalFrames);
|
||||
const samples: CaptureCalibrationSample[] = [];
|
||||
const totalSamples = sampledFrames.length;
|
||||
|
||||
for (const frameIndex of sampledFrames) {
|
||||
for (let i = 0; i < sampledFrames.length; i++) {
|
||||
const frameIndex = sampledFrames[i]!;
|
||||
log?.info(`Calibration: capturing test frame ${i + 1}/${totalSamples}...`);
|
||||
const time = frameIndex / fps;
|
||||
const startedAt = Date.now();
|
||||
const result = await captureFrameToBuffer(session, frameIndex, time);
|
||||
@@ -224,8 +228,13 @@ export async function measureCaptureCostFromSession(
|
||||
});
|
||||
}
|
||||
|
||||
const estimate = estimateMeasuredCaptureCostMultiplier(samples);
|
||||
if (estimate.p95Ms !== undefined) {
|
||||
log?.info(`Calibration complete, estimated cost: ${estimate.p95Ms}ms/frame (p95)`);
|
||||
}
|
||||
|
||||
return {
|
||||
estimate: estimateMeasuredCaptureCostMultiplier(samples),
|
||||
estimate,
|
||||
samples,
|
||||
};
|
||||
}
|
||||
@@ -323,6 +332,7 @@ export async function runCaptureCalibration(input: {
|
||||
sessionDir: string,
|
||||
sessionCfg: EngineConfig,
|
||||
): Promise<{ estimate: CaptureCostEstimate; samples: CaptureCalibrationSample[] }> => {
|
||||
log.info("Launching browser for capture calibration...");
|
||||
const session = await createCaptureSession(
|
||||
fileServer.url,
|
||||
sessionDir,
|
||||
@@ -332,10 +342,21 @@ export async function runCaptureCalibration(input: {
|
||||
);
|
||||
sessionRef.current = session;
|
||||
if (!session.isInitialized) {
|
||||
await initializeSession(session);
|
||||
log.info("Initializing calibration session...");
|
||||
const calInitStart = Date.now();
|
||||
const calHeartbeat = setInterval(() => {
|
||||
const elapsed = ((Date.now() - calInitStart) / 1000).toFixed(1);
|
||||
log.info(`Still waiting for browser initialization... (${elapsed}s elapsed)`);
|
||||
}, 30_000);
|
||||
try {
|
||||
await initializeSession(session);
|
||||
} finally {
|
||||
clearInterval(calHeartbeat);
|
||||
}
|
||||
}
|
||||
assertNotAborted();
|
||||
const result = await measureCaptureCostFromSession(session, totalFrames, fps);
|
||||
log.info("Calibration session ready, capturing test frames...");
|
||||
const result = await measureCaptureCostFromSession(session, totalFrames, fps, log);
|
||||
logCaptureCalibrationResult(result, log);
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
type RenderJob,
|
||||
} from "../../renderOrchestrator.js";
|
||||
import { type CompositionMetadata } from "../shared.js";
|
||||
import type { ProducerLogger } from "../../../logger.js";
|
||||
|
||||
export interface ExtractVideosStageInput {
|
||||
projectDir: string;
|
||||
@@ -58,6 +59,7 @@ export interface ExtractVideosStageInput {
|
||||
compiledDir: string;
|
||||
job: RenderJob;
|
||||
cfg: EngineConfig;
|
||||
log?: ProducerLogger;
|
||||
/** Mutated in place — audio entries auto-discovered from video files are pushed onto `composition.audios`. */
|
||||
composition: CompositionMetadata;
|
||||
abortSignal: AbortSignal | undefined;
|
||||
@@ -102,6 +104,7 @@ export async function runExtractVideosStage(
|
||||
compiledDir,
|
||||
job,
|
||||
cfg,
|
||||
log,
|
||||
composition,
|
||||
abortSignal,
|
||||
assertNotAborted,
|
||||
@@ -122,6 +125,7 @@ export async function runExtractVideosStage(
|
||||
const nativeHdrVideoIds = new Set<string>();
|
||||
const videoTransfers = new Map<string, HdrTransfer>();
|
||||
if (job.config.hdrMode !== "force-sdr" && composition.videos.length > 0) {
|
||||
log?.info("Probing video color spaces...", { videoCount: composition.videos.length });
|
||||
await Promise.all(
|
||||
composition.videos.map(async (v) => {
|
||||
// Use the shared resolver so a `<video src="../assets/foo">` in a
|
||||
@@ -175,6 +179,11 @@ export async function runExtractVideosStage(
|
||||
}
|
||||
|
||||
if (composition.videos.length > 0) {
|
||||
const totalVideos = composition.videos.length;
|
||||
for (let i = 0; i < totalVideos; i++) {
|
||||
const v = composition.videos[i]!;
|
||||
log?.info(`Extracting frames from video ${i + 1}/${totalVideos}: ${v.src}`);
|
||||
}
|
||||
extractionResult = await extractAllVideoFrames(
|
||||
composition.videos,
|
||||
projectDir,
|
||||
|
||||
@@ -144,6 +144,10 @@ export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageR
|
||||
reasons.push(`${compiled.unresolvedCompositions.length} unresolved composition(s)`);
|
||||
if (hasScriptedAudio) reasons.push("scripted audio volume");
|
||||
|
||||
log.info("Launching browser for composition probe...", {
|
||||
reasons,
|
||||
});
|
||||
|
||||
fileServer = await createFileServer({
|
||||
projectDir,
|
||||
compiledDir: join(workDir, "compiled"),
|
||||
@@ -160,6 +164,7 @@ export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageR
|
||||
quality: needsAlpha ? undefined : 80,
|
||||
deviceScaleFactor,
|
||||
};
|
||||
log.info("Browser launched, creating capture session...");
|
||||
probeSession = await createCaptureSession(
|
||||
fileServer.url,
|
||||
join(workDir, "probe"),
|
||||
@@ -167,12 +172,26 @@ export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageR
|
||||
null,
|
||||
cfg,
|
||||
);
|
||||
await initializeSession(probeSession);
|
||||
log.info("Waiting for composition to initialize...");
|
||||
const initStart = Date.now();
|
||||
const heartbeat = setInterval(() => {
|
||||
const elapsed = ((Date.now() - initStart) / 1000).toFixed(1);
|
||||
log.info(`Still waiting for browser initialization... (${elapsed}s elapsed)`);
|
||||
}, 30_000);
|
||||
try {
|
||||
await initializeSession(probeSession);
|
||||
} finally {
|
||||
clearInterval(heartbeat);
|
||||
}
|
||||
log.info("Composition ready", {
|
||||
initMs: Date.now() - initStart,
|
||||
});
|
||||
assertNotAborted();
|
||||
lastBrowserConsole = probeSession.browserConsoleBuffer;
|
||||
|
||||
// Discover root composition duration
|
||||
if (composition.duration <= 0) {
|
||||
log.info("Discovering composition duration...");
|
||||
const discoveredDuration = await getCompositionDuration(probeSession);
|
||||
assertNotAborted();
|
||||
log.info("Probed composition duration from browser", {
|
||||
@@ -210,6 +229,7 @@ export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageR
|
||||
}
|
||||
|
||||
// Discover media elements from browser DOM (catches dynamically-set src)
|
||||
log.info("Discovering media assets from browser DOM...");
|
||||
const browserMedia = await discoverMediaFromBrowser(probeSession.page);
|
||||
assertNotAborted();
|
||||
if (browserMedia.length > 0) {
|
||||
@@ -321,6 +341,9 @@ export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageR
|
||||
}
|
||||
|
||||
if (composition.audios.length > 0) {
|
||||
log.info("Discovering audio volume automation...", {
|
||||
audioCount: composition.audios.length,
|
||||
});
|
||||
const automation = await discoverAudioVolumeAutomationFromTimeline(
|
||||
probeSession.page,
|
||||
composition.audios.map((audio) => audio.id),
|
||||
@@ -344,6 +367,9 @@ export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageR
|
||||
// Runtime video discovery: for videos with auto-injected timing (data-hf-auto-start),
|
||||
// seek the GSAP timeline to find actual scene visibility windows and override start/end.
|
||||
if (composition.videos.length > 0) {
|
||||
log.info("Discovering video visibility windows...", {
|
||||
videoCount: composition.videos.length,
|
||||
});
|
||||
const visibilityWindows = await discoverVideoVisibilityFromTimeline(
|
||||
probeSession.page,
|
||||
composition.duration,
|
||||
|
||||
@@ -1600,6 +1600,7 @@ export async function executeRenderJob(
|
||||
compiledDir,
|
||||
job,
|
||||
cfg,
|
||||
log,
|
||||
composition,
|
||||
abortSignal,
|
||||
assertNotAborted,
|
||||
|
||||
@@ -44,6 +44,7 @@ import { PanelLayoutProvider } from "./contexts/PanelLayoutContext";
|
||||
import { FileManagerProvider } from "./contexts/FileManagerContext";
|
||||
import { DomEditProvider } from "./contexts/DomEditContext";
|
||||
import { StudioSplash } from "./components/StudioSplash";
|
||||
import { StudioToast } from "./components/StudioToast";
|
||||
import { useServerConnection } from "./hooks/useServerConnection";
|
||||
import {
|
||||
normalizeStudioCompositionPath,
|
||||
@@ -583,17 +584,7 @@ export function StudioApp() {
|
||||
)}
|
||||
|
||||
{dragOverlay.active && <StudioGlobalDragOverlay />}
|
||||
{appToast && (
|
||||
<div
|
||||
className={`absolute bottom-6 left-1/2 -translate-x-1/2 z-[91] px-4 py-2 rounded-lg border text-sm shadow-lg animate-in fade-in slide-in-from-bottom-2 ${
|
||||
appToast.tone === "error"
|
||||
? "bg-red-900/90 border-red-700/50 text-red-200"
|
||||
: "bg-neutral-900/95 border-neutral-700/60 text-neutral-100"
|
||||
}`}
|
||||
>
|
||||
{appToast.message}
|
||||
</div>
|
||||
)}
|
||||
{appToast && <StudioToast message={appToast.message} tone={appToast.tone} />}
|
||||
</div>
|
||||
</DomEditProvider>
|
||||
</FileManagerProvider>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
interface StudioToastProps {
|
||||
message: string;
|
||||
tone?: "error" | "info";
|
||||
}
|
||||
|
||||
export function StudioToast({ message, tone }: StudioToastProps) {
|
||||
return (
|
||||
<div
|
||||
className={`absolute bottom-6 left-1/2 -translate-x-1/2 z-[91] px-4 py-2 rounded-lg border text-sm shadow-lg animate-in fade-in slide-in-from-bottom-2 ${
|
||||
tone === "error"
|
||||
? "bg-red-900/90 border-red-700/50 text-red-200"
|
||||
: "bg-neutral-900/95 border-neutral-700/60 text-neutral-100"
|
||||
}`}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -125,8 +125,8 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
|
||||
? async () => {
|
||||
const t = usePlayerStore.getState().currentTime;
|
||||
if (kfAnim?.keyframes) {
|
||||
if (kfAnim.hasUnresolvedKeyframes && session.handleGsapMaterializeKeyframes) {
|
||||
await session.handleGsapMaterializeKeyframes(kfAnim.id);
|
||||
if (kfAnim.hasUnresolvedKeyframes) {
|
||||
await session.handleGsapMaterializeKeyframes?.(kfAnim.id);
|
||||
}
|
||||
const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
|
||||
const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo } from "react";
|
||||
import { Clock, Eye, Layers, MessageSquare, Move, X } from "../../icons/SystemIcons";
|
||||
import { Eye, Layers, MessageSquare, Move, X } from "../../icons/SystemIcons";
|
||||
import { type DomEditSelection } from "./domEditing";
|
||||
import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
|
||||
import type { ImportedFontAsset } from "./fontAssets";
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { MetricField, Section } from "./propertyPanelPrimitives";
|
||||
import { isMediaElement, MediaSection } from "./propertyPanelMediaSection";
|
||||
import { TextSection, StyleSections } from "./propertyPanelSections";
|
||||
import { TimingSection } from "./propertyPanelTimingSection";
|
||||
import { GsapAnimationSection } from "./GsapAnimationSection";
|
||||
import { KeyframeNavigation } from "./KeyframeNavigation";
|
||||
import { STUDIO_GSAP_PANEL_ENABLED, STUDIO_KEYFRAMES_ENABLED } from "./manualEditingAvailability";
|
||||
@@ -84,70 +85,6 @@ interface PropertyPanelProps {
|
||||
onSeekToTime?: (time: number) => void;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* TimingSection */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function formatTimingValue(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return "0.00s";
|
||||
return `${seconds.toFixed(2)}s`;
|
||||
}
|
||||
|
||||
function parseTimingValue(input: string): number | null {
|
||||
const cleaned = input.replace(/s$/i, "").trim();
|
||||
const parsed = Number.parseFloat(cleaned);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function TimingSection({
|
||||
element,
|
||||
onSetAttribute,
|
||||
}: {
|
||||
element: DomEditSelection;
|
||||
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
|
||||
}) {
|
||||
const start = Number.parseFloat(element.dataAttributes.start ?? "0") || 0;
|
||||
const duration =
|
||||
Number.parseFloat(
|
||||
element.dataAttributes.duration ?? element.dataAttributes["hf-authored-duration"] ?? "0",
|
||||
) || 0;
|
||||
const end = start + duration;
|
||||
|
||||
const commitStart = (nextValue: string) => {
|
||||
const parsed = parseTimingValue(nextValue);
|
||||
if (parsed == null) return;
|
||||
void onSetAttribute("start", parsed.toFixed(2));
|
||||
};
|
||||
|
||||
const commitDuration = (nextValue: string) => {
|
||||
const parsed = parseTimingValue(nextValue);
|
||||
if (parsed == null || parsed <= 0) return;
|
||||
void onSetAttribute("duration", parsed.toFixed(2));
|
||||
};
|
||||
|
||||
const commitEnd = (nextValue: string) => {
|
||||
const parsed = parseTimingValue(nextValue);
|
||||
if (parsed == null || parsed <= start) return;
|
||||
void onSetAttribute("duration", (parsed - start).toFixed(2));
|
||||
};
|
||||
|
||||
return (
|
||||
<Section title="Timing" icon={<Clock size={15} />}>
|
||||
<div className={RESPONSIVE_GRID}>
|
||||
<MetricField label="Start" value={formatTimingValue(start)} onCommit={commitStart} />
|
||||
<MetricField label="End" value={formatTimingValue(end)} onCommit={commitEnd} />
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<MetricField
|
||||
label="Duration"
|
||||
value={formatTimingValue(duration)}
|
||||
onCommit={commitDuration}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* PropertyPanel */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Clock } from "../../icons/SystemIcons";
|
||||
import type { DomEditSelection } from "./domEditing";
|
||||
import { RESPONSIVE_GRID } from "./propertyPanelHelpers";
|
||||
import { MetricField, Section } from "./propertyPanelPrimitives";
|
||||
|
||||
function formatTimingValue(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return "0.00s";
|
||||
return `${seconds.toFixed(2)}s`;
|
||||
}
|
||||
|
||||
function parseTimingValue(input: string): number | null {
|
||||
const cleaned = input.replace(/s$/i, "").trim();
|
||||
const parsed = Number.parseFloat(cleaned);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
||||
}
|
||||
|
||||
export function TimingSection({
|
||||
element,
|
||||
onSetAttribute,
|
||||
}: {
|
||||
element: DomEditSelection;
|
||||
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
|
||||
}) {
|
||||
const start = Number.parseFloat(element.dataAttributes.start ?? "0") || 0;
|
||||
const duration =
|
||||
Number.parseFloat(
|
||||
element.dataAttributes.duration ?? element.dataAttributes["hf-authored-duration"] ?? "0",
|
||||
) || 0;
|
||||
const end = start + duration;
|
||||
|
||||
const commitStart = (nextValue: string) => {
|
||||
const parsed = parseTimingValue(nextValue);
|
||||
if (parsed == null) return;
|
||||
void onSetAttribute("start", parsed.toFixed(2));
|
||||
};
|
||||
|
||||
const commitDuration = (nextValue: string) => {
|
||||
const parsed = parseTimingValue(nextValue);
|
||||
if (parsed == null || parsed <= 0) return;
|
||||
void onSetAttribute("duration", parsed.toFixed(2));
|
||||
};
|
||||
|
||||
const commitEnd = (nextValue: string) => {
|
||||
const parsed = parseTimingValue(nextValue);
|
||||
if (parsed == null || parsed <= start) return;
|
||||
void onSetAttribute("duration", (parsed - start).toFixed(2));
|
||||
};
|
||||
|
||||
return (
|
||||
<Section title="Timing" icon={<Clock size={15} />}>
|
||||
<div className={RESPONSIVE_GRID}>
|
||||
<MetricField label="Start" value={formatTimingValue(start)} onCommit={commitStart} />
|
||||
<MetricField label="End" value={formatTimingValue(end)} onCommit={commitEnd} />
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<MetricField
|
||||
label="Duration"
|
||||
value={formatTimingValue(duration)}
|
||||
onCommit={commitDuration}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
tryGsapRotationIntercept,
|
||||
} from "./gsapRuntimeBridge";
|
||||
import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit";
|
||||
import { useGsapSelectionHandlers } from "./useGsapSelectionHandlers";
|
||||
|
||||
// ── Types ──
|
||||
|
||||
@@ -308,10 +309,7 @@ export function useDomEditSession({
|
||||
buildDomSelectionFromTarget,
|
||||
});
|
||||
|
||||
// Wrap the CSS-based path offset commit with GSAP-awareness: when the
|
||||
// selected element has GSAP animations controlling x/y, read the actual
|
||||
// interpolated position from the iframe runtime and commit via the GSAP
|
||||
// script mutation path instead of the CSS translate offset.
|
||||
// GSAP-aware: intercept offset/resize/rotation to commit via script mutation when animated.
|
||||
const handleGsapAwarePathOffsetCommit = useCallback(
|
||||
async (selection: DomEditSelection, next: { x: number; y: number }) => {
|
||||
if (gsapCommitMutation) {
|
||||
@@ -406,126 +404,40 @@ export function useDomEditSession({
|
||||
],
|
||||
);
|
||||
|
||||
const handleGsapUpdateProperty = useCallback(
|
||||
(animId: string, prop: string, value: number | string) => {
|
||||
if (!domEditSelection) return;
|
||||
updateGsapProperty(domEditSelection, animId, prop, value);
|
||||
},
|
||||
[domEditSelection, updateGsapProperty],
|
||||
);
|
||||
|
||||
const handleGsapUpdateMeta = useCallback(
|
||||
(animId: string, updates: { duration?: number; ease?: string; position?: number }) => {
|
||||
if (!domEditSelection) return;
|
||||
updateGsapMeta(domEditSelection, animId, updates);
|
||||
},
|
||||
[domEditSelection, updateGsapMeta],
|
||||
);
|
||||
|
||||
const handleGsapDeleteAnimation = useCallback(
|
||||
(animId: string) => {
|
||||
if (!domEditSelection) return;
|
||||
deleteGsapAnimation(domEditSelection, animId);
|
||||
},
|
||||
[domEditSelection, deleteGsapAnimation],
|
||||
);
|
||||
|
||||
const handleGsapAddAnimation = useCallback(
|
||||
(method: "to" | "from" | "set" | "fromTo") => {
|
||||
if (!domEditSelection) return;
|
||||
addGsapAnimation(domEditSelection, method, currentTime);
|
||||
if (domEditSelection.element.hasAttribute("data-hf-studio-path-offset")) {
|
||||
handleDomManualEditsReset(domEditSelection);
|
||||
}
|
||||
},
|
||||
[domEditSelection, addGsapAnimation, currentTime, handleDomManualEditsReset],
|
||||
);
|
||||
|
||||
const handleGsapAddProperty = useCallback(
|
||||
(animId: string, prop: string) => {
|
||||
if (!domEditSelection) return;
|
||||
addGsapProperty(domEditSelection, animId, prop);
|
||||
},
|
||||
[domEditSelection, addGsapProperty],
|
||||
);
|
||||
|
||||
const handleGsapRemoveProperty = useCallback(
|
||||
(animId: string, prop: string) => {
|
||||
if (!domEditSelection) return;
|
||||
removeGsapProperty(domEditSelection, animId, prop);
|
||||
},
|
||||
[domEditSelection, removeGsapProperty],
|
||||
);
|
||||
|
||||
const handleGsapUpdateFromProperty = useCallback(
|
||||
(animId: string, prop: string, value: number | string) => {
|
||||
if (!domEditSelection) return;
|
||||
updateGsapFromProperty(domEditSelection, animId, prop, value);
|
||||
},
|
||||
[domEditSelection, updateGsapFromProperty],
|
||||
);
|
||||
|
||||
const handleGsapAddFromProperty = useCallback(
|
||||
(animId: string, prop: string) => {
|
||||
if (!domEditSelection) return;
|
||||
addGsapFromProperty(domEditSelection, animId, prop);
|
||||
},
|
||||
[domEditSelection, addGsapFromProperty],
|
||||
);
|
||||
|
||||
const handleGsapRemoveFromProperty = useCallback(
|
||||
(animId: string, prop: string) => {
|
||||
if (!domEditSelection) return;
|
||||
removeGsapFromProperty(domEditSelection, animId, prop);
|
||||
},
|
||||
[domEditSelection, removeGsapFromProperty],
|
||||
);
|
||||
|
||||
const handleGsapAddKeyframe = useCallback(
|
||||
(animId: string, percentage: number, property: string, value: number | string) => {
|
||||
if (!domEditSelection) return;
|
||||
addKeyframe(domEditSelection, animId, percentage, property, value);
|
||||
},
|
||||
[domEditSelection, addKeyframe],
|
||||
);
|
||||
|
||||
const handleGsapRemoveKeyframe = useCallback(
|
||||
(animId: string, percentage: number) => {
|
||||
if (!domEditSelection) return;
|
||||
removeKeyframe(domEditSelection, animId, percentage);
|
||||
},
|
||||
[domEditSelection, removeKeyframe],
|
||||
);
|
||||
|
||||
const handleGsapConvertToKeyframes = useCallback(
|
||||
(animId: string) => {
|
||||
if (!domEditSelection) return;
|
||||
convertToKeyframes(domEditSelection, animId);
|
||||
},
|
||||
[domEditSelection, convertToKeyframes],
|
||||
);
|
||||
|
||||
const handleGsapRemoveAllKeyframes = useCallback(
|
||||
(animId: string) => {
|
||||
if (!domEditSelection) return;
|
||||
removeAllKeyframes(domEditSelection, animId);
|
||||
},
|
||||
[domEditSelection, removeAllKeyframes],
|
||||
);
|
||||
|
||||
/**
|
||||
* Reset keyframes for the currently selected element.
|
||||
* Finds the animation with keyframes from the resolved GSAP animations
|
||||
* and sends a remove-all-keyframes mutation. Returns true if keyframes
|
||||
* were found and the mutation was dispatched.
|
||||
*/
|
||||
const handleResetSelectedElementKeyframes = useCallback((): boolean => {
|
||||
if (!domEditSelection) return false;
|
||||
const withKeyframes = selectedGsapAnimations.find((a) => a.keyframes);
|
||||
if (!withKeyframes) return false;
|
||||
removeAllKeyframes(domEditSelection, withKeyframes.id);
|
||||
return true;
|
||||
}, [domEditSelection, selectedGsapAnimations, removeAllKeyframes]);
|
||||
const {
|
||||
handleGsapUpdateProperty,
|
||||
handleGsapUpdateMeta,
|
||||
handleGsapDeleteAnimation,
|
||||
handleGsapAddAnimation,
|
||||
handleGsapAddProperty,
|
||||
handleGsapRemoveProperty,
|
||||
handleGsapUpdateFromProperty,
|
||||
handleGsapAddFromProperty,
|
||||
handleGsapRemoveFromProperty,
|
||||
handleGsapAddKeyframe,
|
||||
handleGsapRemoveKeyframe,
|
||||
handleGsapConvertToKeyframes,
|
||||
handleGsapRemoveAllKeyframes,
|
||||
handleResetSelectedElementKeyframes,
|
||||
} = useGsapSelectionHandlers({
|
||||
domEditSelection,
|
||||
updateGsapProperty,
|
||||
updateGsapMeta,
|
||||
deleteGsapAnimation,
|
||||
addGsapAnimation,
|
||||
addGsapProperty,
|
||||
removeGsapProperty,
|
||||
updateGsapFromProperty,
|
||||
addGsapFromProperty,
|
||||
removeGsapFromProperty,
|
||||
addKeyframe,
|
||||
removeKeyframe,
|
||||
convertToKeyframes,
|
||||
removeAllKeyframes,
|
||||
currentTime,
|
||||
handleDomManualEditsReset,
|
||||
selectedGsapAnimations,
|
||||
});
|
||||
|
||||
const commitAnimatedProperty = useAnimatedPropertyCommit({
|
||||
selectedGsapAnimations,
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useCallback } from "react";
|
||||
import type { DomEditSelection } from "../components/editor/domEditing";
|
||||
|
||||
/**
|
||||
* Thin useCallback wrappers that guard on `domEditSelection` before
|
||||
* delegating to the underlying GSAP script-commit functions. Extracted
|
||||
* from useDomEditSession to keep that file under the 600-line limit.
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
export function useGsapSelectionHandlers({
|
||||
domEditSelection,
|
||||
updateGsapProperty,
|
||||
updateGsapMeta,
|
||||
deleteGsapAnimation,
|
||||
addGsapAnimation,
|
||||
addGsapProperty,
|
||||
removeGsapProperty,
|
||||
updateGsapFromProperty,
|
||||
addGsapFromProperty,
|
||||
removeGsapFromProperty,
|
||||
addKeyframe,
|
||||
removeKeyframe,
|
||||
convertToKeyframes,
|
||||
removeAllKeyframes,
|
||||
currentTime,
|
||||
handleDomManualEditsReset,
|
||||
selectedGsapAnimations,
|
||||
}: {
|
||||
domEditSelection: DomEditSelection | null;
|
||||
updateGsapProperty: (
|
||||
sel: DomEditSelection,
|
||||
animId: string,
|
||||
prop: string,
|
||||
value: number | string,
|
||||
) => void;
|
||||
updateGsapMeta: (
|
||||
sel: DomEditSelection,
|
||||
animId: string,
|
||||
updates: { duration?: number; ease?: string; position?: number },
|
||||
) => void;
|
||||
deleteGsapAnimation: (sel: DomEditSelection, animId: string) => void;
|
||||
addGsapAnimation: (
|
||||
sel: DomEditSelection,
|
||||
method: "to" | "from" | "set" | "fromTo",
|
||||
time: number,
|
||||
) => void;
|
||||
addGsapProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
|
||||
removeGsapProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
|
||||
updateGsapFromProperty: (
|
||||
sel: DomEditSelection,
|
||||
animId: string,
|
||||
prop: string,
|
||||
value: number | string,
|
||||
) => void;
|
||||
addGsapFromProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
|
||||
removeGsapFromProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
|
||||
addKeyframe: (
|
||||
sel: DomEditSelection,
|
||||
animId: string,
|
||||
percentage: number,
|
||||
property: string,
|
||||
value: number | string,
|
||||
) => void;
|
||||
removeKeyframe: (sel: DomEditSelection, animId: string, percentage: number) => void;
|
||||
convertToKeyframes: (sel: DomEditSelection, animId: string) => void;
|
||||
removeAllKeyframes: (sel: DomEditSelection, animId: string) => void;
|
||||
currentTime: number;
|
||||
handleDomManualEditsReset: (sel: DomEditSelection) => void;
|
||||
selectedGsapAnimations: { id: string; keyframes?: unknown }[];
|
||||
}) {
|
||||
const handleGsapUpdateProperty = useCallback(
|
||||
(animId: string, prop: string, value: number | string) => {
|
||||
if (!domEditSelection) return;
|
||||
updateGsapProperty(domEditSelection, animId, prop, value);
|
||||
},
|
||||
[domEditSelection, updateGsapProperty],
|
||||
);
|
||||
|
||||
const handleGsapUpdateMeta = useCallback(
|
||||
(animId: string, updates: { duration?: number; ease?: string; position?: number }) => {
|
||||
if (!domEditSelection) return;
|
||||
updateGsapMeta(domEditSelection, animId, updates);
|
||||
},
|
||||
[domEditSelection, updateGsapMeta],
|
||||
);
|
||||
|
||||
const handleGsapDeleteAnimation = useCallback(
|
||||
(animId: string) => {
|
||||
if (!domEditSelection) return;
|
||||
deleteGsapAnimation(domEditSelection, animId);
|
||||
},
|
||||
[domEditSelection, deleteGsapAnimation],
|
||||
);
|
||||
|
||||
const handleGsapAddAnimation = useCallback(
|
||||
(method: "to" | "from" | "set" | "fromTo") => {
|
||||
if (!domEditSelection) return;
|
||||
addGsapAnimation(domEditSelection, method, currentTime);
|
||||
if (domEditSelection.element.hasAttribute("data-hf-studio-path-offset")) {
|
||||
handleDomManualEditsReset(domEditSelection);
|
||||
}
|
||||
},
|
||||
[domEditSelection, addGsapAnimation, currentTime, handleDomManualEditsReset],
|
||||
);
|
||||
|
||||
const handleGsapAddProperty = useCallback(
|
||||
(animId: string, prop: string) => {
|
||||
if (!domEditSelection) return;
|
||||
addGsapProperty(domEditSelection, animId, prop);
|
||||
},
|
||||
[domEditSelection, addGsapProperty],
|
||||
);
|
||||
|
||||
const handleGsapRemoveProperty = useCallback(
|
||||
(animId: string, prop: string) => {
|
||||
if (!domEditSelection) return;
|
||||
removeGsapProperty(domEditSelection, animId, prop);
|
||||
},
|
||||
[domEditSelection, removeGsapProperty],
|
||||
);
|
||||
|
||||
const handleGsapUpdateFromProperty = useCallback(
|
||||
(animId: string, prop: string, value: number | string) => {
|
||||
if (!domEditSelection) return;
|
||||
updateGsapFromProperty(domEditSelection, animId, prop, value);
|
||||
},
|
||||
[domEditSelection, updateGsapFromProperty],
|
||||
);
|
||||
|
||||
const handleGsapAddFromProperty = useCallback(
|
||||
(animId: string, prop: string) => {
|
||||
if (!domEditSelection) return;
|
||||
addGsapFromProperty(domEditSelection, animId, prop);
|
||||
},
|
||||
[domEditSelection, addGsapFromProperty],
|
||||
);
|
||||
|
||||
const handleGsapRemoveFromProperty = useCallback(
|
||||
(animId: string, prop: string) => {
|
||||
if (!domEditSelection) return;
|
||||
removeGsapFromProperty(domEditSelection, animId, prop);
|
||||
},
|
||||
[domEditSelection, removeGsapFromProperty],
|
||||
);
|
||||
|
||||
const handleGsapAddKeyframe = useCallback(
|
||||
(animId: string, percentage: number, property: string, value: number | string) => {
|
||||
if (!domEditSelection) return;
|
||||
addKeyframe(domEditSelection, animId, percentage, property, value);
|
||||
},
|
||||
[domEditSelection, addKeyframe],
|
||||
);
|
||||
|
||||
const handleGsapRemoveKeyframe = useCallback(
|
||||
(animId: string, percentage: number) => {
|
||||
if (!domEditSelection) return;
|
||||
removeKeyframe(domEditSelection, animId, percentage);
|
||||
},
|
||||
[domEditSelection, removeKeyframe],
|
||||
);
|
||||
|
||||
const handleGsapConvertToKeyframes = useCallback(
|
||||
(animId: string) => {
|
||||
if (!domEditSelection) return;
|
||||
convertToKeyframes(domEditSelection, animId);
|
||||
},
|
||||
[domEditSelection, convertToKeyframes],
|
||||
);
|
||||
|
||||
const handleGsapRemoveAllKeyframes = useCallback(
|
||||
(animId: string) => {
|
||||
if (!domEditSelection) return;
|
||||
removeAllKeyframes(domEditSelection, animId);
|
||||
},
|
||||
[domEditSelection, removeAllKeyframes],
|
||||
);
|
||||
|
||||
const handleResetSelectedElementKeyframes = useCallback((): boolean => {
|
||||
if (!domEditSelection) return false;
|
||||
const withKeyframes = selectedGsapAnimations.find((a) => a.keyframes);
|
||||
if (!withKeyframes) return false;
|
||||
removeAllKeyframes(domEditSelection, withKeyframes.id);
|
||||
return true;
|
||||
}, [domEditSelection, selectedGsapAnimations, removeAllKeyframes]);
|
||||
|
||||
return {
|
||||
handleGsapUpdateProperty,
|
||||
handleGsapUpdateMeta,
|
||||
handleGsapDeleteAnimation,
|
||||
handleGsapAddAnimation,
|
||||
handleGsapAddProperty,
|
||||
handleGsapRemoveProperty,
|
||||
handleGsapUpdateFromProperty,
|
||||
handleGsapAddFromProperty,
|
||||
handleGsapRemoveFromProperty,
|
||||
handleGsapAddKeyframe,
|
||||
handleGsapRemoveKeyframe,
|
||||
handleGsapConvertToKeyframes,
|
||||
handleGsapRemoveAllKeyframes,
|
||||
handleResetSelectedElementKeyframes,
|
||||
};
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
shouldHandleTimelineDeleteKey,
|
||||
shouldAutoScrollTimeline,
|
||||
} from "./Timeline";
|
||||
import { RULER_H, TRACK_H } from "./timelineLayout";
|
||||
import { formatTime } from "../lib/time";
|
||||
|
||||
describe("generateTicks", () => {
|
||||
@@ -230,8 +231,7 @@ describe("getTimelinePlayheadLeft", () => {
|
||||
|
||||
describe("getTimelineCanvasHeight", () => {
|
||||
it("includes bottom scroll buffer below the last track", () => {
|
||||
// RULER_H (24) + trackCount * TRACK_H (48) + scroll buffer
|
||||
expect(getTimelineCanvasHeight(3)).toBeGreaterThan(24 + 3 * 48);
|
||||
expect(getTimelineCanvasHeight(3)).toBeGreaterThan(RULER_H + 3 * TRACK_H);
|
||||
});
|
||||
|
||||
it("still keeps ruler space when there are no tracks", () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
|
||||
import { useTimelinePlayhead } from "./useTimelinePlayhead";
|
||||
import { type TrackVisualStyle, getTrackStyle } from "./timelineIcons";
|
||||
import { getTimelinePixelsPerSecond } from "./timelineZoom";
|
||||
import { TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
|
||||
import { useTimelineAssetDrop } from "./timelineDragDrop";
|
||||
import { TimelineEmptyState } from "./TimelineEmptyState";
|
||||
import { TimelineCanvas } from "./TimelineCanvas";
|
||||
import {
|
||||
@@ -19,11 +19,9 @@ import { useTimelineClipDrag } from "./useTimelineClipDrag";
|
||||
import { ClipContextMenu } from "./ClipContextMenu";
|
||||
import {
|
||||
GUTTER,
|
||||
TRACK_H,
|
||||
generateTicks,
|
||||
getTimelineCanvasHeight,
|
||||
shouldShowTimelineShortcutHint,
|
||||
resolveTimelineAssetDrop,
|
||||
} from "./timelineLayout";
|
||||
|
||||
// Re-export pure utilities so existing imports from "./Timeline" still resolve.
|
||||
@@ -364,71 +362,15 @@ export const Timeline = memo(function Timeline({
|
||||
[resizingClip],
|
||||
);
|
||||
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const handleAssetDragOver = useCallback((e: React.DragEvent) => {
|
||||
const hasFiles = e.dataTransfer.files.length > 0;
|
||||
const types = Array.from(e.dataTransfer.types);
|
||||
const hasAsset = types.includes(TIMELINE_ASSET_MIME);
|
||||
const hasBlock = types.includes(TIMELINE_BLOCK_MIME);
|
||||
if (!hasFiles && !hasAsset && !hasBlock) return;
|
||||
e.preventDefault();
|
||||
if (hasAsset || hasBlock) e.dataTransfer.dropEffect = "copy";
|
||||
setIsDragOver(true);
|
||||
}, []);
|
||||
|
||||
const handleAssetDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
const scroll = scrollRef.current;
|
||||
const rect = scroll?.getBoundingClientRect();
|
||||
const dropInput = {
|
||||
rectLeft: rect?.left ?? 0,
|
||||
rectTop: rect?.top ?? 0,
|
||||
scrollLeft: scroll?.scrollLeft ?? 0,
|
||||
scrollTop: scroll?.scrollTop ?? 0,
|
||||
pixelsPerSecond: ppsRef.current,
|
||||
duration: durationRef.current,
|
||||
trackHeight: TRACK_H,
|
||||
trackOrder: trackOrderRef.current,
|
||||
};
|
||||
if (onFileDrop && e.dataTransfer.files.length > 0) {
|
||||
void onFileDrop(
|
||||
Array.from(e.dataTransfer.files),
|
||||
scroll && rect ? resolveTimelineAssetDrop(dropInput, e.clientX, e.clientY) : undefined,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const assetPayload = e.dataTransfer.getData(TIMELINE_ASSET_MIME);
|
||||
if (assetPayload && onAssetDrop && scroll && rect) {
|
||||
try {
|
||||
const parsed = JSON.parse(assetPayload) as { path?: string };
|
||||
if (parsed.path)
|
||||
void onAssetDrop(
|
||||
parsed.path,
|
||||
resolveTimelineAssetDrop(dropInput, e.clientX, e.clientY),
|
||||
);
|
||||
} catch {
|
||||
/* ignore malformed drag payloads */
|
||||
}
|
||||
return;
|
||||
}
|
||||
const blockPayload = e.dataTransfer.getData(TIMELINE_BLOCK_MIME);
|
||||
if (blockPayload && onBlockDrop && scroll && rect) {
|
||||
try {
|
||||
const parsed = JSON.parse(blockPayload) as { name?: string };
|
||||
if (parsed.name)
|
||||
void onBlockDrop(
|
||||
parsed.name,
|
||||
resolveTimelineAssetDrop(dropInput, e.clientX, e.clientY),
|
||||
);
|
||||
} catch {
|
||||
/* ignore malformed drag payloads */
|
||||
}
|
||||
}
|
||||
},
|
||||
[onAssetDrop, onBlockDrop, onFileDrop],
|
||||
);
|
||||
const { isDragOver, setIsDragOver, handleAssetDragOver, handleAssetDrop } = useTimelineAssetDrop({
|
||||
scrollRef,
|
||||
ppsRef,
|
||||
durationRef,
|
||||
trackOrderRef,
|
||||
onFileDrop,
|
||||
onAssetDrop,
|
||||
onBlockDrop,
|
||||
});
|
||||
|
||||
if (!timelineReady || elements.length === 0) {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// fallow-ignore-file clone-families
|
||||
import { useCallback, useState, type RefObject } from "react";
|
||||
import { TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
|
||||
import { TRACK_H, resolveTimelineAssetDrop } from "./timelineLayout";
|
||||
|
||||
interface UseTimelineAssetDropOptions {
|
||||
scrollRef: RefObject<HTMLDivElement | null>;
|
||||
ppsRef: RefObject<number>;
|
||||
durationRef: RefObject<number>;
|
||||
trackOrderRef: RefObject<number[]>;
|
||||
onFileDrop?: (
|
||||
files: File[],
|
||||
placement?: { start: number; track: number },
|
||||
) => Promise<void> | void;
|
||||
onAssetDrop?: (
|
||||
assetPath: string,
|
||||
placement: { start: number; track: number },
|
||||
) => Promise<void> | void;
|
||||
onBlockDrop?: (
|
||||
blockName: string,
|
||||
placement: { start: number; track: number },
|
||||
) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export function useTimelineAssetDrop({
|
||||
scrollRef,
|
||||
ppsRef,
|
||||
durationRef,
|
||||
trackOrderRef,
|
||||
onFileDrop,
|
||||
onAssetDrop,
|
||||
onBlockDrop,
|
||||
}: UseTimelineAssetDropOptions) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
const handleAssetDragOver = useCallback((e: React.DragEvent) => {
|
||||
const hasFiles = e.dataTransfer.files.length > 0;
|
||||
const types = Array.from(e.dataTransfer.types);
|
||||
const hasAsset = types.includes(TIMELINE_ASSET_MIME);
|
||||
const hasBlock = types.includes(TIMELINE_BLOCK_MIME);
|
||||
if (!hasFiles && !hasAsset && !hasBlock) return;
|
||||
e.preventDefault();
|
||||
if (hasAsset || hasBlock) e.dataTransfer.dropEffect = "copy";
|
||||
setIsDragOver(true);
|
||||
}, []);
|
||||
|
||||
const handleAssetDrop = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
const scroll = scrollRef.current;
|
||||
const rect = scroll?.getBoundingClientRect();
|
||||
const dropInput = {
|
||||
rectLeft: rect?.left ?? 0,
|
||||
rectTop: rect?.top ?? 0,
|
||||
scrollLeft: scroll?.scrollLeft ?? 0,
|
||||
scrollTop: scroll?.scrollTop ?? 0,
|
||||
pixelsPerSecond: ppsRef.current,
|
||||
duration: durationRef.current,
|
||||
trackHeight: TRACK_H,
|
||||
trackOrder: trackOrderRef.current,
|
||||
};
|
||||
if (onFileDrop && e.dataTransfer.files.length > 0) {
|
||||
void onFileDrop(
|
||||
Array.from(e.dataTransfer.files),
|
||||
scroll && rect ? resolveTimelineAssetDrop(dropInput, e.clientX, e.clientY) : undefined,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const assetPayload = e.dataTransfer.getData(TIMELINE_ASSET_MIME);
|
||||
if (assetPayload && onAssetDrop && scroll && rect) {
|
||||
try {
|
||||
const parsed = JSON.parse(assetPayload) as { path?: string };
|
||||
if (parsed.path)
|
||||
void onAssetDrop(
|
||||
parsed.path,
|
||||
resolveTimelineAssetDrop(dropInput, e.clientX, e.clientY),
|
||||
);
|
||||
} catch {
|
||||
/* ignore malformed drag payloads */
|
||||
}
|
||||
return;
|
||||
}
|
||||
const blockPayload = e.dataTransfer.getData(TIMELINE_BLOCK_MIME);
|
||||
if (blockPayload && onBlockDrop && scroll && rect) {
|
||||
try {
|
||||
const parsed = JSON.parse(blockPayload) as { name?: string };
|
||||
if (parsed.name)
|
||||
void onBlockDrop(
|
||||
parsed.name,
|
||||
resolveTimelineAssetDrop(dropInput, e.clientX, e.clientY),
|
||||
);
|
||||
} catch {
|
||||
/* ignore malformed drag payloads */
|
||||
}
|
||||
}
|
||||
},
|
||||
[onAssetDrop, onBlockDrop, onFileDrop, scrollRef, ppsRef, durationRef, trackOrderRef],
|
||||
);
|
||||
|
||||
return { isDragOver, setIsDragOver, handleAssetDragOver, handleAssetDrop };
|
||||
}
|
||||
Reference in New Issue
Block a user