feat: Persist Studio manual edits via manifest (#593)

## Summary

Studio manual geometry edits now persist as a project-local manifest instead of being baked into composition source on each gesture.

The manifest lives at:

```text
.hyperframes/studio-manual-edits.json
```

It is the source of truth for manual drag, resize, rotation, inspector geometry edits, group moves, and selected-layer reset.

## Architecture

- **Manifest-backed edits**: each edit stores a kind (`path-offset`, `box-size`, `rotation`), a source-scoped target, and the edit values.
- **Source-scoped resolution**: targets include `sourceFile`, `id`, `selector`, and `selectorIndex`, so duplicate selectors in nested compositions resolve against the owning source file.
- **Additive CSS layer**: move uses CSS `translate`, resize writes stable dimensions/flex sizing, and rotation uses CSS `rotate` over the authored base.
- **Shared replay runtime**: Studio preview, thumbnails, frame capture, producer renders, and CLI Studio renders/thumbnails all use the same core manual-edit render script.
- **Animation-safe replay**: Studio reapplies the manual layer after load, refresh, timeline seeks, player operations, playback frames, thumbnail seeks, and render seeks instead of rewriting GSAP timelines.
- **History and handoff**: the manifest is a normal project file, so undo/redo and agent edits can preserve, modify, or remove manual visual edits explicitly.

## User Impact

Users can move, resize, rotate, group-move, and reset supported layers from the canvas or inspector, then refresh, capture thumbnails/screenshots, play animated compositions, and render videos without manual edits drifting away from the edited state.

## Main Files

- `packages/studio/src/components/editor/manualEdits.ts`
- `packages/studio/src/components/editor/DomEditOverlay.tsx`
- `packages/studio/src/components/editor/PropertyPanel.tsx`
- `packages/studio/src/App.tsx`
- `packages/core/src/studio-api/helpers/manualEditsRenderScript.ts`
- `packages/studio/vite.config.ts`
- `packages/cli/src/server/studioServer.ts`
- `packages/core/src/compiler/htmlBundler.ts`
- `packages/producer/src/services/htmlCompiler.ts`
- `packages/core/src/studio-api/routes/thumbnail.ts`
- `packages/producer/src/services/fileServer.ts`
- `packages/producer/src/services/renderOrchestrator.ts`

## Test Plan

```bash
volta run --node 22.20.0 bun run build
volta run --node 22.20.0 bun run --filter @hyperframes/core test -- src/studio-api/helpers/manualEditsRenderScript.test.ts
volta run --node 22.20.0 bun run --filter @hyperframes/core typecheck
volta run --node 22.20.0 bun run --filter @hyperframes/studio typecheck
volta run --node 22.20.0 bun run --filter @hyperframes/cli typecheck
volta run --node 22.20.0 bunx oxlint <changed files>
volta run --node 22.20.0 bunx oxfmt --check <changed files>
git diff --check
```
This commit is contained in:
Vance Ingalls
2026-05-03 23:06:11 -07:00
committed by GitHub
parent 1d15845a13
commit d0abe90a82
82 changed files with 15351 additions and 717 deletions
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { buildCompositionThumbnailUrl } from "./CompositionThumbnail";
describe("buildCompositionThumbnailUrl", () => {
it("includes selector and occurrence index for precise element thumbnails", () => {
expect(
buildCompositionThumbnailUrl({
previewUrl: "/api/projects/demo/preview",
seekTime: 1,
duration: 2,
selector: ".card",
selectorIndex: 2,
origin: "http://localhost:3000",
}),
).toBe(
"http://localhost:3000/api/projects/demo/thumbnail/index.html?t=2.00&v=v3&selector=.card&selectorIndex=2",
);
});
});
@@ -7,6 +7,7 @@ interface CompositionThumbnailProps {
labelColor: string;
accentColor?: string;
selector?: string;
selectorIndex?: number;
seekTime?: number;
duration?: number;
width?: number;
@@ -14,7 +15,38 @@ interface CompositionThumbnailProps {
}
const CLIP_HEIGHT = 66;
const THUMBNAIL_URL_VERSION = "v2";
const THUMBNAIL_URL_VERSION = "v3";
export function buildCompositionThumbnailUrl({
previewUrl,
seekTime = 2,
duration = 5,
selector,
selectorIndex,
origin,
}: {
previewUrl: string;
seekTime?: number;
duration?: number;
selector?: string;
selectorIndex?: number;
origin: string;
}): string {
const thumbnailBase = previewUrl
.replace("/preview/comp/", "/thumbnail/")
.replace(/\/preview$/, "/thumbnail/index.html");
const midTime = seekTime + duration / 2;
const thumbnailUrl = new URL(thumbnailBase, origin);
thumbnailUrl.searchParams.set("t", midTime.toFixed(2));
thumbnailUrl.searchParams.set("v", THUMBNAIL_URL_VERSION);
if (selector) {
thumbnailUrl.searchParams.set("selector", selector);
if (selectorIndex != null && selectorIndex > 0) {
thumbnailUrl.searchParams.set("selectorIndex", String(selectorIndex));
}
}
return thumbnailUrl.toString();
}
export const CompositionThumbnail = memo(function CompositionThumbnail({
previewUrl,
@@ -22,6 +54,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
labelColor,
accentColor = "#6B7280",
selector,
selectorIndex,
seekTime = 2,
duration = 5,
}: CompositionThumbnailProps) {
@@ -48,15 +81,14 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
roRef.current?.disconnect();
});
const thumbnailBase = previewUrl
.replace("/preview/comp/", "/thumbnail/")
.replace(/\/preview$/, "/thumbnail/index.html");
const midTime = seekTime + duration / 2;
const thumbnailUrl = new URL(thumbnailBase, window.location.origin);
thumbnailUrl.searchParams.set("t", midTime.toFixed(2));
thumbnailUrl.searchParams.set("v", THUMBNAIL_URL_VERSION);
if (selector) thumbnailUrl.searchParams.set("selector", selector);
const url = thumbnailUrl.toString();
const url = buildCompositionThumbnailUrl({
previewUrl,
seekTime,
duration,
selector,
selectorIndex,
origin: window.location.origin,
});
const frameW = Math.max(48, Math.round(CLIP_HEIGHT * aspect));
const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
@@ -66,7 +98,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
src={url}
alt=""
draggable={false}
loading="lazy"
loading="eager"
onLoad={(e) => {
const img = e.currentTarget;
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
@@ -3,6 +3,7 @@ import { useMountEffect } from "../../hooks/useMountEffect";
import { usePlayerStore } from "../store/playerStore";
import { formatTime } from "../lib/time";
import { buildPromptCopyText, buildTimelineAgentPrompt } from "./timelineEditing";
import { copyTextToClipboard } from "../../utils/clipboard";
interface EditPopoverProps {
rangeStart: number;
@@ -62,16 +63,8 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
}, [start, end, elementsInRange, prompt]);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(buildClipboardText());
} catch {
const ta = document.createElement("textarea");
ta.value = buildClipboardText();
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
}
const copied = await copyTextToClipboard(buildClipboardText());
if (!copied) return;
setCopiedAgentPrompt(true);
setTimeout(() => {
setCopiedAgentPrompt(false);
@@ -82,16 +75,8 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
const handleCopyPrompt = useCallback(async () => {
const promptText = buildPromptCopyText(prompt);
if (!promptText) return;
try {
await navigator.clipboard.writeText(promptText);
} catch {
const ta = document.createElement("textarea");
ta.value = promptText;
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
}
const copied = await copyTextToClipboard(promptText);
if (!copied) return;
setCopiedPromptOnly(true);
setTimeout(() => {
setCopiedPromptOnly(false);
@@ -1,21 +1,30 @@
import { forwardRef, useRef, useState } from "react";
import { isLottieAnimationLoaded } from "@hyperframes/core/runtime/lottie-readiness";
import { useMountEffect } from "../../hooks/useMountEffect";
// NOTE: importing "@hyperframes/player" registers a class extending HTMLElement
// at module load, which throws under SSR. Defer the import to the mount effect
// so it only runs in the browser.
interface PlayerProps {
projectId?: string;
directUrl?: string;
onLoad: () => void;
portrait?: boolean;
style?: React.CSSProperties;
}
interface HyperframesPlayerElement extends HTMLElement {
iframeElement: HTMLIFrameElement;
}
function enableInteractiveIframe(player: HyperframesPlayerElement): void {
const root = player.shadowRoot;
if (!root) return;
const container = root.querySelector<HTMLElement>(".hfp-container");
const iframe = root.querySelector<HTMLIFrameElement>(".hfp-iframe");
container?.style.setProperty("pointer-events", "auto");
iframe?.style.setProperty("pointer-events", "auto");
}
// Assets are considered ready when every `<video>`/`<audio>` has enough data
// to play through without buffering, and every registered Lottie animation has
// finished loading.
@@ -49,18 +58,9 @@ function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean): bool
}
}
/**
* Renders a composition preview using the <hyperframes-player> web component.
*
* The web component handles iframe scaling, dimension detection, and
* ResizeObserver internally. This wrapper bridges its inner iframe to the
* forwarded ref so useTimelinePlayer can access it for clip manifest parsing,
* timeline probing, and DOM inspection.
*/
export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
({ projectId, directUrl, onLoad, portrait }, ref) => {
({ projectId, directUrl, onLoad, portrait, style }, ref) => {
const containerRef = useRef<HTMLDivElement>(null);
const loadCountRef = useRef(0);
const assetPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [assetsLoading, setAssetsLoading] = useState(false);
@@ -71,11 +71,9 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
let canceled = false;
let cleanup: (() => void) | undefined;
// Dynamic import registers the custom element in the browser only.
import("@hyperframes/player").then(() => {
if (canceled) return;
// Create the web component imperatively to avoid JSX custom-element typing.
const player = document.createElement("hyperframes-player") as HyperframesPlayerElement;
const src = directUrl || `/api/projects/${projectId}/preview`;
player.setAttribute("src", src);
@@ -85,8 +83,8 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
player.style.height = "100%";
player.style.display = "block";
container.appendChild(player);
enableInteractiveIframe(player);
// Bridge the inner iframe to the forwarded ref for useTimelinePlayer.
const iframe = player.iframeElement;
if (typeof ref === "function") {
ref(iframe);
@@ -94,35 +92,12 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
(ref as React.MutableRefObject<HTMLIFrameElement | null>).current = iframe;
}
// Prevent the web component's built-in click-to-toggle behavior.
// The studio manages playback exclusively via useTimelinePlayer.
const preventToggle = (e: Event) => e.stopImmediatePropagation();
player.addEventListener("click", preventToggle, { capture: true });
// Forward the iframe's native load event to the studio's onIframeLoad.
const handleLoad = () => {
loadCountRef.current++;
// Reveal animation on reload (hot-reload, composition switch)
if (loadCountRef.current > 1) {
container.classList.remove("preview-revealing");
void container.offsetWidth;
container.classList.add("preview-revealing");
const onEnd = () => container.classList.remove("preview-revealing");
container.addEventListener("animationend", onEnd, { once: true });
}
onLoad();
// Show a loading overlay until every `<video>`/`<audio>` and Lottie
// asset is ready. Without this users can click play before audio has
// buffered — the runtime is resilient (queued play() resolves once
// data arrives), but the overlay communicates why the first frame
// or first audio beat may lag.
//
// Poll with a 10 s safety cap (100 ticks × 100 ms). If the cap
// trips we hide the overlay so the UI doesn't appear stuck forever,
// but we log a debug warning so the case is diagnosable — a long
// cold video or a broken asset can legitimately exceed 10 s on a
// slow network.
if (assetPollRef.current) clearInterval(assetPollRef.current);
let lastUnloaded = hasUnloadedAssets(iframe, false);
if (lastUnloaded) {
@@ -135,11 +110,6 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
if (assetPollRef.current) clearInterval(assetPollRef.current);
assetPollRef.current = null;
setAssetsLoading(false);
if (lastUnloaded) {
console.debug(
"[Player] Asset-loading overlay timed out after 10s; hiding anyway. Check network or asset integrity.",
);
}
}
}, 100);
} else {
@@ -154,7 +124,6 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
if (assetPollRef.current) clearInterval(assetPollRef.current);
assetPollRef.current = null;
container.removeChild(player);
// Clear the forwarded ref
if (typeof ref === "function") {
ref(null);
} else if (ref) {
@@ -170,7 +139,10 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
});
return (
<div className="relative w-full h-full max-w-full max-h-full overflow-hidden bg-black flex items-center justify-center">
<div
className="relative w-full h-full max-w-full max-h-full overflow-hidden bg-black flex items-center justify-center"
style={style}
>
<div ref={containerRef} className="w-full h-full" />
{assetsLoading && (
<div className="absolute inset-0 bg-black/80 flex flex-col items-center justify-center z-20 pointer-events-none">
@@ -8,6 +8,7 @@ import {
getTimelinePlayheadLeft,
getTimelineScrollLeftForZoomAnchor,
getTimelineScrollLeftForZoomTransition,
shouldShowTimelineShortcutHint,
shouldHandleTimelineDeleteKey,
shouldAutoScrollTimeline,
} from "./Timeline";
@@ -237,6 +238,17 @@ describe("getTimelineCanvasHeight", () => {
});
});
describe("shouldShowTimelineShortcutHint", () => {
it("shows the hint when the timeline does not vertically overflow", () => {
expect(shouldShowTimelineShortcutHint(220, 220)).toBe(true);
expect(shouldShowTimelineShortcutHint(220.5, 220)).toBe(true);
});
it("hides the hint when timeline tracks need vertical scrolling", () => {
expect(shouldShowTimelineShortcutHint(221.5, 220)).toBe(false);
});
});
describe("shouldHandleTimelineDeleteKey", () => {
it("handles Delete and Backspace when focus is not in an editor", () => {
expect(shouldHandleTimelineDeleteKey({ key: "Delete" })).toBe(true);
@@ -35,7 +35,7 @@ const TRACK_H = 72;
const RULER_H = 24;
const CLIP_Y = 3; // vertical inset inside track
const CLIP_HANDLE_W = 18;
const TIMELINE_SCROLL_BUFFER = 24;
const TIMELINE_SCROLL_BUFFER = 20;
interface TrackVisualStyle extends TimelineTrackStyle {
icon: ReactNode;
@@ -216,6 +216,14 @@ export function getTimelineCanvasHeight(trackCount: number): number {
return RULER_H + Math.max(0, trackCount) * TRACK_H + TIMELINE_SCROLL_BUFFER;
}
export function shouldShowTimelineShortcutHint(
scrollHeight: number,
clientHeight: number,
): boolean {
if (!Number.isFinite(scrollHeight) || !Number.isFinite(clientHeight)) return true;
return scrollHeight - clientHeight <= 1;
}
export function shouldHandleTimelineDeleteKey(input: {
key: string;
metaKey?: boolean;
@@ -279,7 +287,6 @@ export function resolveTimelineAssetDrop(
track: getDefaultDroppedTrack(input.trackOrder, rowIndex),
};
}
/* ── Component ──────────────────────────────────────────────────── */
interface TimelineProps {
/** Called when user seeks via ruler/track click or playhead drag */
@@ -427,30 +434,51 @@ export const Timeline = memo(function Timeline({
onDeleteElementRef.current = onDeleteElement;
const suppressClickRef = useRef(false);
const [showPopover, setShowPopover] = useState(false);
const [showShortcutHint, setShowShortcutHint] = useState(true);
const [viewportWidth, setViewportWidth] = useState(0);
const roRef = useRef<ResizeObserver | null>(null);
const shortcutHintRafRef = useRef(0);
const syncShortcutHintVisibility = useCallback(() => {
const scroll = scrollRef.current;
setShowShortcutHint(
scroll ? shouldShowTimelineShortcutHint(scroll.scrollHeight, scroll.clientHeight) : true,
);
}, []);
const scheduleShortcutHintVisibilitySync = useCallback(() => {
if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
shortcutHintRafRef.current = requestAnimationFrame(() => {
shortcutHintRafRef.current = 0;
syncShortcutHintVisibility();
});
}, [syncShortcutHintVisibility]);
// Callback ref: sets up ResizeObserver when the DOM element actually mounts.
// useMountEffect can't work here because the component returns null on first
// render (timelineReady=false), so containerRef.current is null when the
// effect fires and the ResizeObserver is never created.
const setContainerRef = useCallback((el: HTMLDivElement | null) => {
if (roRef.current) {
roRef.current.disconnect();
roRef.current = null;
}
containerRef.current = el;
if (!el) return;
setViewportWidth(el.clientWidth);
roRef.current = new ResizeObserver(([entry]) => {
setViewportWidth(entry.contentRect.width);
});
roRef.current.observe(el);
}, []);
const setContainerRef = useCallback(
(el: HTMLDivElement | null) => {
if (roRef.current) {
roRef.current.disconnect();
roRef.current = null;
}
containerRef.current = el;
if (!el) return;
setViewportWidth(el.clientWidth);
scheduleShortcutHintVisibilitySync();
roRef.current = new ResizeObserver(([entry]) => {
setViewportWidth(entry.contentRect.width);
scheduleShortcutHintVisibilitySync();
});
roRef.current.observe(el);
},
[scheduleShortcutHintVisibilitySync],
);
// Clean up ResizeObserver on unmount
useMountEffect(() => () => {
roRef.current?.disconnect();
if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
});
// Effective duration: max of store duration and the furthest element end.
@@ -495,6 +523,7 @@ export const Timeline = memo(function Timeline({
}
return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b);
}, [draggedClip, trackOrder]);
const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
const selectedElement = useMemo(
() => elements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null,
[elements, selectedElementId],
@@ -544,7 +573,6 @@ export const Timeline = memo(function Timeline({
);
previousZoomModeRef.current = zoomMode;
}, [zoomMode]);
useMountEffect(() => {
const unsub = liveTime.subscribe((t) => {
const dur = durationRef.current;
@@ -1012,6 +1040,10 @@ export const Timeline = memo(function Timeline({
);
const majorTickInterval =
major.length >= 2 ? Math.max(0.25, major[1] - major[0]) : effectiveDuration;
useEffect(() => {
syncShortcutHintVisibility();
}, [syncShortcutHintVisibility, timelineReady, elements.length, totalH]);
const getPreviewElement = useCallback(
(element: TimelineElement): TimelineElement => {
if (
@@ -1239,7 +1271,6 @@ export const Timeline = memo(function Timeline({
);
}
const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
const draggedElement = draggedClip?.element ?? null;
const activeDraggedElement =
draggedClip?.started === true && draggedElement
@@ -1313,7 +1344,7 @@ export const Timeline = memo(function Timeline({
<div
ref={setContainerRef}
aria-label="Timeline"
className={`border-t select-none h-full overflow-hidden ${shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
className={`relative border-t select-none h-full overflow-hidden ${shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
style={{
touchAction: "pan-x pan-y",
background: theme.shellBackground,
@@ -1652,8 +1683,8 @@ export const Timeline = memo(function Timeline({
</div>
</div>
{/* Keyboard shortcut hint — always visible */}
{!showPopover && !rangeSelection && (
{/* Keyboard shortcut hint */}
{showShortcutHint && !showPopover && !rangeSelection && (
<div className="absolute bottom-2 right-3 pointer-events-none z-20">
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md border"
@@ -63,6 +63,25 @@ export const TimelineClip = memo(function TimelineClip({
const capabilities = getTimelineEditCapabilities(el);
const displayLabel = el.label || el.id || el.tag;
const showHandles = handleOpacity > 0.01;
const baseBackgroundImage = isSelected ? theme.clipBackgroundActive : theme.clipBackground;
const glossBackgroundImage = isSelected
? "linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0))"
: "linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0))";
const accentBackgroundImage = `linear-gradient(120deg, ${trackStyle.accent}${
isSelected ? "22" : "1e"
}, transparent 28%)`;
const compositionStripeBackgroundImage =
isComposition && !hasCustomContent
? "repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.05) 3px, rgba(255,255,255,0.05) 6px)"
: undefined;
const clipBackgroundImage = [
compositionStripeBackgroundImage,
glossBackgroundImage,
accentBackgroundImage,
baseBackgroundImage,
]
.filter(Boolean)
.join(", ");
return (
<div
@@ -76,13 +95,7 @@ export const TimelineClip = memo(function TimelineClip({
top: clipY,
bottom: clipY,
borderRadius: theme.clipRadius,
background: isSelected
? `linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0)), linear-gradient(120deg, ${trackStyle.accent}22, transparent 28%), ${theme.clipBackgroundActive}`
: `linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0)), linear-gradient(120deg, ${trackStyle.accent}1e, transparent 28%), ${theme.clipBackground}`,
backgroundImage:
isComposition && !hasCustomContent
? `repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.05) 3px, rgba(255,255,255,0.05) 6px)`
: undefined,
backgroundImage: clipBackgroundImage,
border: `1px solid ${borderColor}`,
boxShadow,
transition:
@@ -248,7 +248,7 @@ describe("getTimelineEditCapabilities", () => {
});
});
it("disables move and trims for generic motion clips even when patchable", () => {
it("allows moving generic motion clips while keeping trims blocked", () => {
expect(
getTimelineEditCapabilities({
tag: "section",
@@ -256,7 +256,7 @@ describe("getTimelineEditCapabilities", () => {
selector: ".feature-card",
}),
).toEqual({
canMove: false,
canMove: true,
canTrimStart: false,
canTrimEnd: false,
});
@@ -428,7 +428,6 @@ describe("buildClipRangeSelection", () => {
});
});
});
describe("resolveTimelineAutoScroll", () => {
it("does not scroll when the pointer stays away from the edges", () => {
expect(
@@ -512,7 +511,6 @@ describe("buildTimelineElementAgentPrompt", () => {
).toContain("If this clip is animated with GSAP");
});
});
describe("resolveTimelineResize", () => {
it("shrinks clip duration from the right edge", () => {
expect(
@@ -233,7 +233,7 @@ export function getTimelineEditCapabilities(input: {
const hasFiniteDuration = Number.isFinite(input.duration) && input.duration > 0;
const hasDeterministicWindow = isDeterministicTimelineWindow(input);
return {
canMove: canPatch && hasDeterministicWindow,
canMove: canPatch && (hasDeterministicWindow || hasFiniteDuration),
canTrimEnd: canPatch && hasFiniteDuration && hasDeterministicWindow,
canTrimStart: canPatch && hasFiniteDuration && canOffsetTrimClipStart(input),
};
@@ -273,7 +273,6 @@ export function buildClipRangeSelection(
anchorY: anchor.anchorY,
};
}
export function buildTimelineAgentPrompt({
rangeStart,
rangeEnd,
@@ -347,7 +346,6 @@ export function buildTimelineElementAgentPrompt(element: {
return lines.join("\n");
}
export function formatTimelineAttributeNumber(value: number): string {
return Number(roundToCentiseconds(value).toFixed(2)).toString();
}