Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
fa0c8a6
feat(studio): glue API coexistence layer for the NLE swap
ukimsanov Jul 11, 2026
57b1def
feat(studio): timeline interaction hooks and lanes component (unwired)
ukimsanov Jul 11, 2026
a496db2
feat(studio): NLE shell assembly (unwired)
ukimsanov Jul 11, 2026
5762018
feat(studio): timeline glue swap — Timeline/TimelineCanvas onto the N…
ukimsanov Jul 11, 2026
11cce5a
feat(studio): clip thumbnail modules
ukimsanov Jul 11, 2026
cf0b1ee
feat(studio): assets/blocks panel behaviors + preview helpers
ukimsanov Jul 11, 2026
21c26a5
fix(studio): restore timeline playback loop
miguel-heygen Jul 12, 2026
9ebe429
fix(studio): restore missing GSAP helpers module
miguel-heygen Jul 12, 2026
26157cb
refactor(studio): split timeline GSAP helpers
miguel-heygen Jul 12, 2026
843db4a
style(studio): keep timeline helper under size limit
miguel-heygen Jul 12, 2026
0ebe07d
fix(studio): restore timeline overlays module
miguel-heygen Jul 12, 2026
8349b2d
fix(studio): remove stale GSAP import
miguel-heygen Jul 12, 2026
a6f0b5d
fix(studio): restore canonical timeline dependencies
miguel-heygen Jul 12, 2026
2803849
style(studio): format restored timeline helpers
miguel-heygen Jul 12, 2026
1561109
style(studio): satisfy helper line limit
miguel-heygen Jul 12, 2026
f76f80f
fix(studio): repair rebuilt timeline integration
miguel-heygen Jul 12, 2026
93cf03e
feat(studio): complete rebuilt NLE cutover
miguel-heygen Jul 12, 2026
f75b647
fix(studio): guard project and timeline race boundaries
miguel-heygen Jul 12, 2026
3eb0f92
fix(studio): preserve graded resize and crop geometry
miguel-heygen Jul 12, 2026
0fa8063
fix(studio): log resize/rotate commit failures, move anchor accumulat…
miguel-heygen Jul 12, 2026
24c110d
fix(studio): treat duration-0 tweens as static holds and settle resiz…
miguel-heygen Jul 12, 2026
fe93135
feat(studio): gesture-transaction seam with commit observability
miguel-heygen Jul 13, 2026
ea56f62
fix(studio): transaction owns the undo label
miguel-heygen Jul 13, 2026
72ef9c8
fix(studio): atomic static size/position commits (no data loss)
miguel-heygen Jul 13, 2026
c0a560c
fix(core): restore timed-clip visibility after a forced timeline rebind
miguel-heygen Jul 13, 2026
a6cf2f7
fix(studio): atomic z-order/keyframe/split commits, one undo entry each
miguel-heygen Jul 13, 2026
7cc3d93
fix(studio): fold multi-step edits into one undo entry; guard text re…
miguel-heygen Jul 13, 2026
d800c44
feat(studio): batch a gesture's mutations into one atomic server write
miguel-heygen Jul 13, 2026
884c8e5
fix(studio): soften off-canvas indicator outline to 30% opacity
miguel-heygen Jul 13, 2026
9d34c1b
fix(studio): drop off-canvas indicator outline to 10% opacity
miguel-heygen Jul 13, 2026
80ec490
fix(studio): gate [hf-commit] console traces to dev only
miguel-heygen Jul 13, 2026
6828eea
fix(studio): count actual reloads, not softReload requests, in commit…
miguel-heygen Jul 13, 2026
1a36bfb
fix(studio): rotate hover + off-canvas overlays with the element; fli…
miguel-heygen Jul 13, 2026
d76b38b
fix(studio): address code-review findings across the commit-hardening…
miguel-heygen Jul 13, 2026
007e514
feat(studio): atomic z-order reorder via batch patch-element endpoint
miguel-heygen Jul 13, 2026
3397bb8
fix(studio): razor-split undo no longer silently no-ops
miguel-heygen Jul 13, 2026
73db155
feat(studio): closed-hand grab cursor on the rotate handle
miguel-heygen Jul 13, 2026
7bfe549
fix(studio): dropping a dragged element over another no longer select…
miguel-heygen Jul 13, 2026
c569bab
fix(studio): group drag is one undo entry, not one per element
miguel-heygen Jul 13, 2026
57b3c44
fix(studio): undo of a split no longer leaves a ghost clip in the tim…
miguel-heygen Jul 13, 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
67 changes: 51 additions & 16 deletions .fallowrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
// Keyframe UI components — wired dynamically via EaseCurveSection/MotionPanel.
"packages/studio/src/components/editor/KeyframeDiamond.tsx",
"packages/studio/src/components/editor/SpringEaseEditor.tsx",
// NLE notice — rendered conditionally via NLELayout when timeline is first shown.
// NLE notice — rendered conditionally via NLEContext/EditorShell when timeline is first shown.
"packages/studio/src/components/nle/TimelineEditorNotice.tsx",
// Zoom hook extracted for downstream razor-blade PRs (#1330, #1331).
"packages/studio/src/player/components/useTimelineZoom.ts",
Expand All @@ -53,9 +53,6 @@
"packages/studio/src/hooks/gsapTargetCache.ts",
// Preview helper consumed dynamically from the studio iframe bridge.
"packages/studio/src/hooks/gsapRuntimePreview.ts",
// TEMP(studio-dnd): shipped unwired ahead of the NLE integration;
// the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block.
"packages/studio/src/components/editor/CanvasContextMenu.tsx",
],
"ignorePatterns": [
"docs/**",
Expand Down Expand Up @@ -93,10 +90,52 @@
"packages/cli/src/cloud/_gen/**",
],
"ignoreExports": [
// TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
// Part of useTimelineEditing's inferred public return type; consumers invoke
// handleTimelineGroupResize without importing the change type directly.
{
"file": "packages/studio/src/components/editor/canvasContextMenuZOrder.ts",
"exports": ["readEffectiveZIndex"],
"file": "packages/studio/src/hooks/useTimelineGroupEditing.ts",
"exports": ["TimelineGroupResizeChange"],
},
{
"file": "packages/studio/src/player/components/timelineGroupEditing.ts",
"exports": [
"buildTimelineGroupResizeMembers",
"resolveTimelineGroupResizeChanges",
"applyTimelineGroupResizePreview",
],
},
{
"file": "packages/studio/src/player/components/timelineEditing.ts",
"exports": [
"selectTimelineElementsInMarquee",
"TimelineGroupResizeEdge",
"TimelineGroupTimingMember",
],
},
{
"file": "packages/studio/src/player/lib/timelineElementHelpers.ts",
"exports": ["furthestClipEndFromDocument", "furthestClipEndFromSource"],
},
{
"file": "packages/studio/src/components/sidebar/AssetContextMenu.tsx",
"exports": ["DeleteConfirm"],
},
{
"file": "packages/studio/src/utils/timelineAssetDrop.ts",
"exports": [
"setCompositionDurationToContent",
"extendCompositionDurationIfNeeded",
"fitTimelineAssetGeometry",
"resolveTimelineAssetCompositionSize",
],
},
{
"file": "packages/studio/src/components/sidebar/assetHelpers.ts",
"exports": ["truncateMiddle", "formatDuration"],
},
{
"file": "packages/studio/src/utils/studioHelpers.ts",
"exports": ["resolveDroppedAssetDimensions"],
},
// drawElementService is the bottom of the fast-capture Graphite stack
// (#1917): its consumers (frameCapture in #1919) land two PRs upstack, so
Expand Down Expand Up @@ -320,6 +359,11 @@
// and attribute tag-patchers; only the PatchOperation type gained two
// optional fields here, but the line shift makes fallow re-flag them.
"packages/studio/src/utils/sourcePatcher.ts",
// useGsapSelectionHandlers.ts: pre-existing parallel structure with
// useDomEditWiring.ts (thin useCallback wrappers guarding on selection);
// only gained two optional pass-through parameters here, but the line
// shift makes fallow re-flag the pre-existing clone.
"packages/studio/src/hooks/useGsapSelectionHandlers.ts",
// gsapParser.ts: recast/babel GSAP writer — intentional duplication between
// recast and acorn parallel implementations (pre-existing, moved from core).
"packages/parsers/src/gsapParser.ts",
Expand Down Expand Up @@ -441,13 +485,6 @@
// complexity pre-dates the computed-timeline work. Exempted at file level
// rather than refactored as scope creep.
"ignore": [
// TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage);
// removed by studio-dnd/pr22 when the final config lands.
"packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts",
"packages/studio/src/components/editor/CanvasContextMenu.tsx",
"packages/studio/src/components/editor/canvasContextMenuZOrder.test.ts",
"packages/studio/src/player/components/timelineCollision.test.ts",
"packages/studio/src/player/components/timelineStackingSync.test.ts",
// sourcePatcher.ts: resolveSourceFile / splitInlineStyleDeclarations /
// patch*InTag pre-date this PR; only the PatchOperation type gained two
// optional fields, but the line-shift fingerprint re-flags the inherited
Expand Down Expand Up @@ -587,14 +624,12 @@
"packages/studio/src/components/editor/Transform3DCube.tsx",
"packages/studio/src/components/LintModal.tsx",
"packages/studio/src/components/MediaPreview.tsx",
"packages/studio/src/components/nle/NLELayout.tsx",
"packages/studio/src/components/nle/NLEPreview.tsx",
"packages/studio/src/components/sidebar/AudioRow.tsx",
"packages/studio/src/components/sidebar/BlocksTab.tsx",
"packages/studio/src/components/sidebar/CompositionsTab.tsx",
"packages/studio/src/components/sidebar/LeftSidebar.tsx",
"packages/studio/src/components/storyboard/StoryboardLoaded.tsx",
"packages/studio/src/components/StudioPreviewArea.tsx",
"packages/studio/src/components/StudioRightPanel.tsx",
"packages/studio/src/components/StudioToast.tsx",
"packages/studio/src/components/ui/Tooltip.tsx",
Expand Down
30 changes: 30 additions & 0 deletions packages/core/src/runtime/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1564,6 +1564,36 @@ describe("initSandboxRuntimeModular", () => {
expect(seekTimes[seekTimes.length - 1]).toBe(0);
});

it("restores timed element visibility after a forced timeline rebind", () => {
document.body.innerHTML = `
<div data-composition-id="root" data-root="true" data-duration="30" data-width="1920" data-height="1080">
<div class="clip" id="clip-expired" data-start="0" data-duration="15.234"></div>
<div class="clip" id="clip-future" data-start="20.83" data-duration="3"></div>
<div class="clip" id="clip-control" data-start="10" data-duration="10"></div>
</div>
`;
const clipExpired = document.querySelector<HTMLElement>("#clip-expired");
const clipFuture = document.querySelector<HTMLElement>("#clip-future");
const clipControl = document.querySelector<HTMLElement>("#clip-control");
window.__timelines = { root: createMockTimeline(30) };

initSandboxRuntimeModular();
window.__player?.seek(16.2);

expect(clipExpired?.style.visibility).toBe("hidden");
expect(clipFuture?.style.visibility).toBe("hidden");
expect(clipControl?.style.visibility).toBe("visible");

if (clipExpired) clipExpired.style.visibility = "visible";
if (clipFuture) clipFuture.style.visibility = "visible";

window.__hfForceTimelineRebind?.();

expect(clipExpired?.style.visibility).toBe("hidden");
expect(clipFuture?.style.visibility).toBe("hidden");
expect(clipControl?.style.visibility).toBe("visible");
});

it("onSetMuted preserves authored muted attribute on video elements", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "root");
Expand Down
115 changes: 60 additions & 55 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1334,6 +1334,7 @@ export function initSandboxRuntimeModular(): void {
(window as Window & { __hfForceTimelineRebind?: () => void }).__hfForceTimelineRebind = () => {
childrenBound = false;
bindRootTimelineIfAvailable();
syncTimedElementVisibility(state.currentTime);
};

const emitRootStageLayoutDiagnostics = () => {
Expand Down Expand Up @@ -1691,6 +1692,64 @@ export function initSandboxRuntimeModular(): void {
const dataHiddenDisplayRestores = new WeakMap<HTMLElement, string>();
const dataHiddenDisplayNodes = new WeakSet<HTMLElement>();

const syncTimedElementVisibility = (currentTime: number) => {
const visibilityNodes = Array.from(document.querySelectorAll("[data-start]"));
const rootComp = resolveRootCompositionElement();
for (const rawNode of visibilityNodes) {
if (!(rawNode instanceof HTMLElement)) continue;

if (rawNode.hasAttribute("data-hidden")) {
if (!dataHiddenDisplayNodes.has(rawNode)) {
dataHiddenDisplayRestores.set(rawNode, rawNode.style.getPropertyValue("display"));
dataHiddenDisplayNodes.add(rawNode);
}
rawNode.style.display = "none";
if (rawNode instanceof HTMLVideoElement || rawNode instanceof HTMLImageElement) {
colorGradingRuntime?.setSourceVisibility(rawNode, false);
}
continue;
}

if (dataHiddenDisplayNodes.has(rawNode)) {
const previousDisplay = dataHiddenDisplayRestores.get(rawNode);
if (previousDisplay) {
rawNode.style.display = previousDisplay;
} else {
rawNode.style.removeProperty("display");
}
dataHiddenDisplayRestores.delete(rawNode);
dataHiddenDisplayNodes.delete(rawNode);
}

let isVisibleNow = isTimedElementVisibleAt(rawNode, currentTime);
// Descendants must not override a hidden ancestor clip. CSS visibility can
// otherwise leak child pixels through inactive scenes because a descendant
// with visibility:visible escapes an ancestor's visibility:hidden.
if (isVisibleNow) {
let ancestor = rawNode.parentElement;
while (ancestor) {
if (ancestor === rootComp) break;
if (ancestor instanceof HTMLElement && ancestor.hasAttribute("data-start")) {
if (!isTimedElementVisibleAt(ancestor, currentTime)) {
isVisibleNow = false;
break;
}
}
ancestor = ancestor.parentElement;
}
}
rawNode.style.visibility = isVisibleNow ? "visible" : "hidden";
if (rawNode instanceof HTMLVideoElement || rawNode instanceof HTMLImageElement) {
colorGradingRuntime?.setSourceVisibility(rawNode, isVisibleNow);
}
if (isVisibleNow) {
if (isTimedClipInFlow(rawNode)) rawNode.style.removeProperty("display");
} else if (isTimedClipInFlow(rawNode) && isTimedClipLeaf(rawNode)) {
rawNode.style.display = "none";
}
}
};

const syncMediaForCurrentState = () => {
const resolveMediaCompositionContext = (element: HTMLVideoElement | HTMLAudioElement) => {
const compositionRoot = element.closest("[data-composition-id]");
Expand Down Expand Up @@ -1773,61 +1832,7 @@ export function initSandboxRuntimeModular(): void {
},
});
}
const visibilityNodes = Array.from(document.querySelectorAll("[data-start]"));
const rootComp = resolveRootCompositionElement();
for (const rawNode of visibilityNodes) {
if (!(rawNode instanceof HTMLElement)) continue;

if (rawNode.hasAttribute("data-hidden")) {
if (!dataHiddenDisplayNodes.has(rawNode)) {
dataHiddenDisplayRestores.set(rawNode, rawNode.style.getPropertyValue("display"));
dataHiddenDisplayNodes.add(rawNode);
}
rawNode.style.display = "none";
if (rawNode instanceof HTMLVideoElement || rawNode instanceof HTMLImageElement) {
colorGradingRuntime?.setSourceVisibility(rawNode, false);
}
continue;
}

if (dataHiddenDisplayNodes.has(rawNode)) {
const previousDisplay = dataHiddenDisplayRestores.get(rawNode);
if (previousDisplay) {
rawNode.style.display = previousDisplay;
} else {
rawNode.style.removeProperty("display");
}
dataHiddenDisplayRestores.delete(rawNode);
dataHiddenDisplayNodes.delete(rawNode);
}

let isVisibleNow = isTimedElementVisibleAt(rawNode, state.currentTime);
// Descendants must not override a hidden ancestor clip. CSS visibility can
// otherwise leak child pixels through inactive scenes because a descendant
// with visibility:visible escapes an ancestor's visibility:hidden.
if (isVisibleNow) {
let ancestor = rawNode.parentElement;
while (ancestor) {
if (ancestor === rootComp) break;
if (ancestor instanceof HTMLElement && ancestor.hasAttribute("data-start")) {
if (!isTimedElementVisibleAt(ancestor, state.currentTime)) {
isVisibleNow = false;
break;
}
}
ancestor = ancestor.parentElement;
}
}
rawNode.style.visibility = isVisibleNow ? "visible" : "hidden";
if (rawNode instanceof HTMLVideoElement || rawNode instanceof HTMLImageElement) {
colorGradingRuntime?.setSourceVisibility(rawNode, isVisibleNow);
}
if (isVisibleNow) {
if (isTimedClipInFlow(rawNode)) rawNode.style.removeProperty("display");
} else if (isTimedClipInFlow(rawNode) && isTimedClipLeaf(rawNode)) {
rawNode.style.display = "none";
}
}
syncTimedElementVisibility(state.currentTime);
};

const postState = (force: boolean) => {
Expand Down
6 changes: 5 additions & 1 deletion packages/studio-server/src/helpers/sourceMutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { parseHTML } from "linkedom";
import postcss from "postcss";
import selectorParser from "postcss-selector-parser";
import { isAllowedHtmlAttribute, isSafeAttributeValue } from "@hyperframes/core/html-attr-safety";
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
import { parseStyleDecls, patchStyleAttrString } from "./sourceStyleMutation.js";

export interface SourceMutationTarget {
Expand Down Expand Up @@ -349,8 +350,11 @@ export function splitElementInHtml(
el.parentElement!.appendChild(clone);
}

const html = wrappedFragment ? document.body.innerHTML || "" : document.toString();
return {
html: wrappedFragment ? document.body.innerHTML || "" : document.toString(),
// The split owns its new nodes' stable ids. Leaving the clone unstamped makes
// the next preview request persist different bytes after history is recorded.
html: ensureHfIds(html),
matched: true,
newId,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@ describe("splitElementInHtml — hfId clone isolation", () => {
const { html, matched } = splitElementInHtml(source, { id: "clip1" }, 5, "clip2");

expect(matched).toBe(true);
const { document } = parseHTML(html);
const occurrences = (html.match(/data-hf-id="hf-abc123"/g) ?? []).length;
expect(occurrences).toBe(1);
expect(document.getElementById("clip2")?.getAttribute("data-hf-id")).toMatch(/^hf-/);
expect(document.getElementById("clip2")?.getAttribute("data-hf-id")).not.toBe("hf-abc123");
});
});

Expand Down
Loading
Loading