Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
dae26e7
refactor(studio): extract shared layerOrdering module from LayersPanel
miguel-heygen Jul 8, 2026
070ee91
feat(studio): expose resolved z-index and stacking context on timelin…
miguel-heygen Jul 8, 2026
dd98069
feat(studio): unify timeline vertical reorder with z-index stacking
miguel-heygen Jul 8, 2026
5ce3622
refactor(studio): single-source the timeline stacking key + guard aud…
miguel-heygen Jul 8, 2026
03f8089
fix(studio): capture effective (computed) z-index for timeline ordering
miguel-heygen Jul 8, 2026
e5ed512
feat(studio): timeline track = stacking layer (NLE-style layering)
miguel-heygen Jul 8, 2026
eef500c
fix(studio): realm-safe isHTMLElement so timeline z-index commits land
miguel-heygen Jul 8, 2026
82fe779
fix(studio): sub-composition child clips restack via self-contained i…
miguel-heygen Jul 8, 2026
bc5fc41
feat(studio): timeline layer UI — context headers, audio lanes, drop …
miguel-heygen Jul 8, 2026
ed8bf47
feat(studio): lane-model timeline (non-overlapping clips share a row)
miguel-heygen Jul 8, 2026
5e3ca6a
feat(studio): drag a clip past the video end to extend its duration
miguel-heygen Jul 8, 2026
6b48702
fix(studio): dropping an overlapping clip on a lane restacks it, not …
miguel-heygen Jul 8, 2026
d7e7fca
feat(studio): magnetic snapping to clip edges, playhead, and bounds
miguel-heygen Jul 8, 2026
580676b
fix(studio): freeze timeline zoom during extend-drag so clips don't jump
miguel-heygen Jul 8, 2026
9f6c20e
perf(studio): grow composition duration live on extend, no preview re…
miguel-heygen Jul 9, 2026
ce0ddbc
fix(studio): keep Timeline under the 600-line cap and un-export postR…
miguel-heygen Jul 9, 2026
db6ccb8
fix(studio): stop horizontal drag from cancelling a vertical restack
miguel-heygen Jul 9, 2026
eb6a311
fix(studio): make a timeline lane a z-band so a vertical restack move…
miguel-heygen Jul 9, 2026
ffd5131
fix(studio): keep a remounted clip marked active when it stays at the…
miguel-heygen Jul 9, 2026
eb31e86
feat(studio): lanes and ruler span the full timeline width at any zoom
miguel-heygen Jul 9, 2026
6f0bf75
feat(studio): fill the ruler with ticks across the full timeline width
miguel-heygen Jul 9, 2026
51dd8a0
fix(studio): order the timing write after the z-index commit on a dia…
miguel-heygen Jul 9, 2026
6080f5a
fix(studio): propagate z-index reorder save failures and drop dead ta…
miguel-heygen Jul 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/core/src/runtime/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ function createMockDeps() {
onSetPlaybackRate: vi.fn(),
onSetColorGrading: vi.fn(),
onSetColorGradingCompare: vi.fn(),
onSetRootDuration: vi.fn(),
onEnablePickMode: vi.fn(),
onDisablePickMode: vi.fn(),
};
Expand Down Expand Up @@ -155,6 +156,13 @@ describe("installRuntimeControlBridge", () => {
expect(deps.onSetPlaybackRate).toHaveBeenCalledWith(1);
});

it("dispatches set-root-duration command with numeric seconds", () => {
const deps = createMockDeps();
const handler = installRuntimeControlBridge(deps);
handler(makeControlMessage("set-root-duration", { durationSeconds: "18.5" }));
expect(deps.onSetRootDuration).toHaveBeenCalledWith(18.5);
});

it("dispatches set-color-grading command with target and grading payload", () => {
const deps = createMockDeps();
const handler = installRuntimeControlBridge(deps);
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/runtime/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type BridgeDeps = {
onSetNativeMediaSyncDisabled: (disabled: boolean) => void;
onSetWebAudioMediaDisabled: (disabled: boolean) => void;
onSetPlaybackRate: (rate: number) => void;
onSetRootDuration: (durationSeconds: number) => void;
onSetColorGrading: (target: HfColorGradingTarget | string | null, grading: unknown) => void;
onSetColorGradingCompare: (
target: HfColorGradingTarget | string | null,
Expand Down Expand Up @@ -53,6 +54,7 @@ const CONTROL_HANDLERS: Record<string, ControlHandler> = {
"set-web-audio-media-disabled": (data, deps) =>
deps.onSetWebAudioMediaDisabled(Boolean(data.disabled)),
"set-playback-rate": (data, deps) => deps.onSetPlaybackRate(Number(data.playbackRate ?? 1)),
"set-root-duration": (data, deps) => deps.onSetRootDuration(Number(data.durationSeconds ?? 0)),
"set-color-grading": (data, deps) =>
deps.onSetColorGrading(data.target ?? null, data.grading ?? null),
"set-color-grading-compare": (data, deps) =>
Expand Down
26 changes: 26 additions & 0 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1852,6 +1852,7 @@ export function initSandboxRuntimeModular(): void {
// transport tick. A plain count misses same-count swaps (one sub-comp unloads
// as another loads), so the signature keys on id+tag in document order.
let clipTreeSignature = "";
let liveRootDurationOverrideSeconds = 0;
const computeClipTreeSignature = (): string => {
let sig = "";
for (const el of document.querySelectorAll("[data-start]")) {
Expand Down Expand Up @@ -1904,6 +1905,30 @@ export function initSandboxRuntimeModular(): void {
scheduleRootStageLayoutDiagnostics();
};

const finitePositiveDuration = (value: number): number =>
Number.isFinite(value) && value > 0 ? value : 0;

const growRootDurationLive = (durationSeconds: number) => {
const nextDuration = finitePositiveDuration(Number(durationSeconds));
if (nextDuration <= 0) return;
const rootEl = resolveRootCompositionElement();
const rootAttrDuration = finitePositiveDuration(
Number.parseFloat(rootEl?.getAttribute("data-duration") ?? ""),
);
const currentDuration = Math.max(
liveRootDurationOverrideSeconds,
finitePositiveDuration(clock.getDuration()),
rootAttrDuration,
);
if (nextDuration <= currentDuration) return;

liveRootDurationOverrideSeconds = nextDuration;
rootEl?.setAttribute("data-duration", String(nextDuration));
clock.setDuration(nextDuration);
postTimeline();
postState(true);
};

const runAdapters = (method: "discover" | "pause" | "play", timeSeconds = 0) => {
for (const adapter of state.deterministicAdapters) {
try {
Expand Down Expand Up @@ -2193,6 +2218,7 @@ export function initSandboxRuntimeModular(): void {
if (state.transportClock) state.transportClock.setRate(state.playbackRate);
applyWebAudioRate();
},
onSetRootDuration: growRootDurationLive,
onSetColorGrading: (target, grading) => {
colorGrading.setGrading(target, grading);
},
Expand Down
67 changes: 67 additions & 0 deletions packages/core/src/runtime/timeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,76 @@ describe("collectRuntimeTimelinePayload", () => {
expect(result.clips[0].id).toBe("text-1");
expect(result.clips[0].start).toBe(1);
expect(result.clips[0].duration).toBe(3);
expect(result.clips[0].track).toBe(0);
expect(result.clips[0].kind).toBe("element");
});

it("parses inline z-index for timeline clips", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "10");
document.body.appendChild(root);

const clip = document.createElement("div");
clip.id = "layered";
clip.style.zIndex = "11";
clip.setAttribute("data-start", "0");
clip.setAttribute("data-duration", "4");
root.appendChild(clip);

const result = collectRuntimeTimelinePayload(defaultParams);
expect(result.clips[0].zIndex).toBe(11);
});

it("uses zero z-index sentinel when a timeline clip has no inline z-index", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "10");
document.body.appendChild(root);

const clip = document.createElement("div");
clip.id = "auto-layer";
clip.setAttribute("data-start", "0");
clip.setAttribute("data-duration", "4");
root.appendChild(clip);

const result = collectRuntimeTimelinePayload(defaultParams);
expect(result.clips[0].zIndex).toBe(0);
});

it("assigns stacking context ids from root and nearest sub-composition", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "10");
document.body.appendChild(root);

const rootClip = document.createElement("div");
rootClip.id = "root-layer";
rootClip.setAttribute("data-start", "0");
rootClip.setAttribute("data-duration", "5");
root.appendChild(rootClip);

const scene = document.createElement("div");
scene.id = "scene-host";
scene.setAttribute("data-composition-id", "scene");
scene.setAttribute("data-start", "0");
scene.setAttribute("data-duration", "5");
root.appendChild(scene);

const nestedClip = document.createElement("div");
nestedClip.id = "nested-layer";
nestedClip.setAttribute("data-start", "0");
nestedClip.setAttribute("data-duration", "2");
scene.appendChild(nestedClip);

const result = collectRuntimeTimelinePayload(defaultParams);
const rootLayer = result.clips.find((clip) => clip.id === "root-layer");
const nestedLayer = result.clips.find((clip) => clip.id === "nested-layer");

expect(rootLayer?.stackingContextId).toBe("main");
expect(nestedLayer?.stackingContextId).toBe("scene");
});

it("identifies video clips by tag", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/runtime/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,19 @@ function parseElementEndAttr(element: Element): number | null {
);
}

function readInlineZIndex(element: Element): number {
try {
const inline = (element as HTMLElement).style?.zIndex;
if (inline && inline !== "auto") {
const parsed = parseInt(inline, 10);
if (Number.isFinite(parsed)) return parsed;
}
return 0;
} catch {
return 0;
}
}

function maxDefinedNumber(...values: Array<number | null>): number | null {
const finite = values.filter((value): value is number => Number.isFinite(value ?? null));
if (finite.length === 0) return null;
Expand Down Expand Up @@ -434,6 +447,8 @@ export function collectRuntimeTimelinePayload(params: {
node.getAttribute("data-track-index") ?? node.getAttribute("data-track") ?? String(i),
10,
) || 0,
zIndex: readInlineZIndex(node),
stackingContextId: compositionContext.parentCompositionId ?? rootCompositionId,
kind,
tagName: tag,
compositionId: node.getAttribute("data-composition-id"),
Expand Down Expand Up @@ -545,6 +560,8 @@ export function collectRuntimeTimelinePayload(params: {
el.getAttribute("data-track-index") ?? el.getAttribute("data-track") ?? "",
10,
) || gsapTrack,
zIndex: readInlineZIndex(el),
stackingContextId: rootCompositionIdForGsap,
kind: "element",
tagName: el.tagName.toLowerCase(),
compositionId: el.getAttribute("data-composition-id"),
Expand Down Expand Up @@ -602,6 +619,8 @@ export function collectRuntimeTimelinePayload(params: {
el.getAttribute("data-track-index") ?? el.getAttribute("data-track") ?? "",
10,
) || overlayTrack,
zIndex: readInlineZIndex(el),
stackingContextId: rootCompositionIdForGsap,
kind: "element",
tagName: tag,
compositionId: el.getAttribute("data-composition-id"),
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export type RuntimeBridgeControlAction =
| "set-media-output-muted"
| "set-native-media-sync-disabled"
| "set-web-audio-media-disabled"
| "set-root-duration"
| "stop-media"
| "flash-elements";

Expand All @@ -28,6 +29,7 @@ export type RuntimeBridgeControlMessage = {
frame?: number;
muted?: boolean;
volume?: number;
durationSeconds?: number;
disabled?: boolean;
playbackRate?: number;
target?: HfColorGradingTarget | string | null;
Expand All @@ -51,6 +53,8 @@ export type RuntimeTimelineClip = {
start: number;
duration: number;
track: number;
zIndex: number;
stackingContextId: string | null;
kind: "video" | "audio" | "image" | "element" | "composition";
tagName: string | null;
compositionId: string | null;
Expand Down
28 changes: 28 additions & 0 deletions packages/studio-server/src/routes/files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,18 +227,46 @@ tl.fromTo("#box", { opacity: 0, x: -50 }, { opacity: 1, x: 0, duration: 1.5, eas
});
const result = (await res.json()) as {
ok: boolean;
mutated?: boolean;
after: string;
parsed: { animations: Array<{ fromProperties?: Record<string, number | string> }> };
};

expect(res.status).toBe(200);
expect(result.ok).toBe(true);
expect(result.mutated).toBe(true);
expect(result.after).toContain("opacity: 0.2");
expect(result.parsed.animations[0].fromProperties?.opacity).toBe(0.2);
// x unchanged
expect(result.parsed.animations[0].fromProperties?.x).toBe(-50);
});

it("reports no GSAP mutation when shifting positions in a file with no GSAP script", async () => {
const projectDir = createProjectDir();
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));

const res = await app.request("http://localhost/projects/demo/gsap-mutations/index.html", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "shift-positions",
targetSelector: "#box",
delta: 1,
}),
});
const result = (await res.json()) as {
ok?: boolean;
changed?: boolean;
mutated?: boolean;
};

expect(res.status).toBe(200);
expect(result.ok).toBe(true);
expect(result.changed).toBe(false);
expect(result.mutated).toBe(false);
});

it("consolidate-position-writes leaves exactly one position write per selector", async () => {
const projectDir = createProjectDir();
const CORRUPTED = `<!DOCTYPE html><html><body><script data-hyperframes-gsap>
Expand Down
14 changes: 14 additions & 0 deletions packages/studio-server/src/routes/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2046,6 +2046,19 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
}
block = extractGsapScriptBlock(html);
}
if (!block && (body.type === "shift-positions" || body.type === "scale-positions")) {
return c.json({
ok: true,
changed: false,
mutated: false,
parsed: { animations: [], timelineVar: "tl", preamble: "", postamble: "" },
before: html,
after: html,
scriptText: "",
path: res.filePath,
backupPath: null,
});
}
if (!block) {
return c.json({ error: "no GSAP script found in file" }, 400);
}
Expand Down Expand Up @@ -2081,6 +2094,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
const responsePayload: Record<string, unknown> = {
ok: true,
changed,
mutated: changed,
parsed: freshParsed,
before: html,
after: newHtml,
Expand Down
17 changes: 7 additions & 10 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { usePanelLayout } from "./hooks/usePanelLayout";
import { useFileManager } from "./hooks/useFileManager";
import { usePreviewPersistence } from "./hooks/usePreviewPersistence";
import { useTimelineEditing } from "./hooks/useTimelineEditing";
import type { TimelineZIndexReorderCommit } from "./hooks/useTimelineEditingTypes";
import type { BlockPreviewInfo } from "./components/sidebar/BlocksTab";
import { useDomEditSession } from "./hooks/useDomEditSession";
import { useSdkSession } from "./hooks/useSdkSession";
Expand All @@ -19,7 +20,7 @@ import { useBlockHandlers } from "./hooks/useBlockHandlers";
import { useAppHotkeys } from "./hooks/useAppHotkeys";
import { useClipboard } from "./hooks/useClipboard";
import { readStudioUiPreferences, writeStudioUiPreferences } from "./utils/studioUiPreferences";
import { selectedKeyframePercentagesForElement } from "./utils/keyframeSelection";
import { deleteSelectedKeyframes } from "./hooks/timelineEditingHelpers";
import { useCaptionDetection } from "./hooks/useCaptionDetection";
import { useRenderClipContent } from "./hooks/useRenderClipContent";
import { useConsoleErrorCapture } from "./hooks/useConsoleErrorCapture";
Expand Down Expand Up @@ -140,6 +141,7 @@ export function StudioApp() {
});
const editHistory = usePersistentEditHistory({ projectId });
const domEditSaveTimestampRef = useRef(0);
const handleDomZIndexReorderCommitRef = useRef<TimelineZIndexReorderCommit | null>(null);
const pendingTimelineEditPathRef = useRef(new Set<string>());
const isGestureRecordingRef = useRef(false);
const reloadPreview = useCallback(() => setRefreshKey((k) => k + 1), []);
Expand Down Expand Up @@ -188,6 +190,7 @@ export function StudioApp() {
isRecordingRef: isGestureRecordingRef,
sdkSession: sdkHandle.session,
forceReloadSdkSession: sdkHandle.forceReload,
handleDomZIndexReorderCommitRef,
});
const {
activeBlockParams,
Expand Down Expand Up @@ -306,19 +309,12 @@ export function StudioApp() {
forceReloadSdkSession: sdkHandle.forceReload,
});
domEditSelectionBridgeRef.current = domEditSession.domEditSelection;
handleDomZIndexReorderCommitRef.current = domEditSession.handleDomZIndexReorderCommit;
clearDomSelectionRef.current = domEditSession.clearDomSelection;
handleDomEditElementDeleteRef.current = domEditSession.handleDomEditElementDelete;
resetKeyframesRef.current = domEditSession.handleResetSelectedElementKeyframes;
invalidateGsapCacheRef.current = domEditSession.invalidateGsapCache;
deleteSelectedKeyframesRef.current = () => {
const { selectedKeyframes, selectedElementId } = usePlayerStore.getState();
const a = domEditSession.selectedGsapAnimations.find((x) => x.keyframes);
if (!a) return;
// Only the active element's keyframes; a stale cross-element selection must not delete here.
for (const p of selectedKeyframePercentagesForElement(selectedKeyframes, selectedElementId)) {
domEditSession.handleGsapRemoveKeyframe(a.id, p);
}
};
deleteSelectedKeyframesRef.current = () => deleteSelectedKeyframes(domEditSession);
useSdkSelectionSync(
sdkHandle.session,
domEditSession.domEditSelection,
Expand Down Expand Up @@ -529,6 +525,7 @@ export function StudioApp() {
handleTimelineElementMove={timelineEditing.handleTimelineElementMove}
handleTimelineElementResize={timelineEditing.handleTimelineElementResize}
handleToggleTrackHidden={timelineEditing.handleToggleTrackHidden}
handleToggleElementHidden={timelineEditing.handleToggleElementHidden}
handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit}
handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit}
handleRazorSplit={timelineEditing.handleRazorSplit}
Expand Down
Loading
Loading