Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
118 changes: 117 additions & 1 deletion packages/core/src/studio-api/helpers/manualEditsRenderScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,14 @@ function studioPositionSeekReapplyRuntime(): void {
const ROTATION_ATTR = "data-hf-studio-rotation";
const ORIGINAL_TRANSLATE_ATTR = "data-hf-studio-original-translate";
const ORIGINAL_ROTATE_ATTR = "data-hf-studio-original-rotate";
const MOTION_ATTR = "data-hf-studio-motion";
const MOTION_TL_KEY = "studio-motion";
const WRAPPED_PROP = "__hfStudioPositionSeekReapplyWrapped";

if (
!document.querySelector("[" + PATH_OFFSET_ATTR + '="true"]') &&
!document.querySelector("[" + ROTATION_ATTR + '="true"]')
!document.querySelector("[" + ROTATION_ATTR + '="true"]') &&
!document.querySelector("[" + MOTION_ATTR + "]")
)
return;

Expand Down Expand Up @@ -77,6 +80,117 @@ function studioPositionSeekReapplyRuntime(): void {
return "calc(" + original + " + " + rotationValue + ")";
};

let lastSeekTime = 0;
let cachedMotionKey = "";

const finiteNum = (v: unknown): number | null =>
typeof v === "number" && Number.isFinite(v) ? v : null;

const computeMotionKey = (motionEls: NodeListOf<Element>): string => {
let key = "";
for (let i = 0; i < motionEls.length; i++) {
const json = (motionEls[i] as HTMLElement).getAttribute?.(MOTION_ATTR);
if (json) key += (key ? "\n" : "") + json;
}
return key;
};

const reapplyMotionTimeline = (): void => {
const motionEls = document.querySelectorAll("[" + MOTION_ATTR + "]");
if (motionEls.length === 0) {
cachedMotionKey = "";
return;
}
const win = window as Window & {
gsap?: {
timeline?: (opts: Record<string, unknown>) => Record<string, unknown>;
set?: (el: HTMLElement, vars: Record<string, unknown>) => void;
registerPlugin?: (plugin: unknown) => void;
};
CustomEase?: { create?: (id: string, data: string) => void };
__timelines?: Record<string, Record<string, unknown>>;
};
const gsap = win.gsap;
if (!gsap || typeof gsap.timeline !== "function") return;
win.__timelines = win.__timelines || {};

// Cache the timeline keyed by the concatenated motion JSON strings.
// On each seek, if the key hasn't changed, just seek the existing timeline
// instead of rebuilding it (avoids kill+recreate on every frame).
const motionKey = computeMotionKey(motionEls);
const existing = win.__timelines[MOTION_TL_KEY];
if (
motionKey &&
motionKey === cachedMotionKey &&
existing &&
typeof existing.totalTime === "function"
) {
(existing.totalTime as (t: number, s: boolean) => void)(lastSeekTime, false);
return;
}

if (existing && typeof existing.kill === "function") (existing.kill as () => void)();
const tl = gsap.timeline({ paused: true, defaults: { overwrite: "auto" } });
const fromTo = tl.fromTo as (
el: HTMLElement,
from: Record<string, unknown>,
to: Record<string, unknown>,
pos: number,
) => void;
if (typeof fromTo !== "function") return;
let applied = 0;
for (let i = 0; i < motionEls.length; i++) {
const el = motionEls[i] as HTMLElement;
if (!(el instanceof HTMLElement)) continue;
const json = el.getAttribute(MOTION_ATTR);
if (!json) continue;
try {
const m = JSON.parse(json) as Record<string, unknown>;
const start = finiteNum(m.start);
const duration = finiteNum(m.duration);
if (start == null || duration == null || duration <= 0) continue;
const ease = typeof m.ease === "string" ? m.ease : "none";
const from = (m.from && typeof m.from === "object" ? m.from : {}) as Record<
string,
unknown
>;
const to = (m.to && typeof m.to === "object" ? m.to : {}) as Record<string, unknown>;
const customEase = m.customEase as { id?: string; data?: string } | null | undefined;
let resolvedEase = ease;
if (customEase?.id && customEase?.data && win.CustomEase?.create) {
try {
gsap.registerPlugin?.(win.CustomEase);
win.CustomEase.create(customEase.id, customEase.data);
resolvedEase = customEase.id;
} catch {
/* use default ease */
}
}
fromTo.call(
tl,
el,
{ ...from },
{ ...to, duration, ease: resolvedEase, overwrite: "auto", immediateRender: false },
start,
);
applied += 1;
} catch {
/* malformed JSON — skip */
}
}
if (applied === 0) {
cachedMotionKey = "";
if (typeof (tl as { kill?: () => void }).kill === "function")
(tl as { kill: () => void }).kill();
return;
}
cachedMotionKey = motionKey;
win.__timelines[MOTION_TL_KEY] = tl;
if (typeof tl.pause === "function") (tl.pause as () => void)();
if (typeof tl.totalTime === "function")
(tl.totalTime as (t: number, s: boolean) => void)(lastSeekTime, false);
};

const reapplyAll = (): void => {
const offsetEls = document.querySelectorAll("[" + PATH_OFFSET_ATTR + '="true"]');
for (let i = 0; i < offsetEls.length; i++) {
Expand Down Expand Up @@ -104,6 +218,7 @@ function studioPositionSeekReapplyRuntime(): void {
el.style.setProperty("rotate", composeRotation(el, "var(" + ROTATION_PROP + ", 0deg)"));
}
}
reapplyMotionTimeline();
};

const runtimeWindow = window as Window & {
Expand Down Expand Up @@ -139,6 +254,7 @@ function studioPositionSeekReapplyRuntime(): void {
return true;
}
const wrapped = function (this: unknown, time: number): unknown {
lastSeekTime = typeof time === "number" && Number.isFinite(time) ? Math.max(0, time) : 0;
const result = seek.call(this, time);
reapplyAll();
return result;
Expand Down
6 changes: 5 additions & 1 deletion packages/producer/src/services/htmlCompiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1000,7 +1000,11 @@ export async function compileForRender(
// GSAP overwrites the `translate` CSS property on every frame seek; this script
// re-asserts the CSS custom property var() form after each seek so dragged
// positions survive frame-by-frame rendering without a JSON sidecar.
const HF_POSITION_ATTRS = ['data-hf-studio-path-offset="true"', 'data-hf-studio-rotation="true"'];
const HF_POSITION_ATTRS = [
'data-hf-studio-path-offset="true"',
'data-hf-studio-rotation="true"',
'data-hf-studio-motion="',
];
const hasPositionEdits = HF_POSITION_ATTRS.some((attr) => htmlWithAssets.includes(attr));
const html = hasPositionEdits
? htmlWithAssets.replace(
Expand Down
11 changes: 2 additions & 9 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
STUDIO_INSPECTOR_PANELS_ENABLED,
STUDIO_MOTION_PANEL_ENABLED,
} from "./components/editor/manualEditingAvailability";
import { getStudioMotionForSelection } from "./components/editor/studioMotion";
import { readStudioMotionFromElement } from "./components/editor/studioMotion";
import type { DomEditSelection } from "./components/editor/domEditing";
import { AskAgentModal } from "./components/AskAgentModal";
import { StudioGlobalDragOverlay } from "./components/StudioGlobalDragOverlay";
Expand Down Expand Up @@ -200,9 +200,6 @@ export function StudioApp() {
showToast,
refreshPreviewDocumentVersion,
queueDomEditSave: manifestPersistence.queueDomEditSave,
commitStudioMotionManifestOptimistically:
manifestPersistence.commitStudioMotionManifestOptimistically,
applyCurrentStudioMotionToPreview: manifestPersistence.applyCurrentStudioMotionToPreview,
readProjectFile: fileManager.readProjectFile,
writeProjectFile: fileManager.writeProjectFile,
domEditSaveTimestampRef,
Expand All @@ -215,7 +212,6 @@ export function StudioApp() {
refreshKey,
rightPanelTab: panelLayout.rightPanelTab,
applyStudioManualEditsToPreviewRef: manifestPersistence.applyStudioManualEditsToPreviewRef,
applyStudioMotionToPreviewRef: manifestPersistence.applyStudioMotionToPreviewRef,
syncPreviewHistoryHotkey: appHotkeys.syncPreviewHistoryHotkey,
reloadPreview,
setRefreshKey,
Expand Down Expand Up @@ -292,10 +288,7 @@ export function StudioApp() {

const selectedStudioMotion =
STUDIO_INSPECTOR_PANELS_ENABLED && domEditSession.domEditSelection
? getStudioMotionForSelection(
manifestPersistence.studioMotionManifestRef.current,
domEditSession.domEditSelection,
)
? readStudioMotionFromElement(domEditSession.domEditSelection.element)
: null;
const layersPanelActive =
STUDIO_INSPECTOR_PANELS_ENABLED && panelLayout.rightPanelTab === "layers";
Expand Down
5 changes: 4 additions & 1 deletion packages/studio/src/components/StudioRightPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import {
STUDIO_INSPECTOR_PANELS_ENABLED,
STUDIO_MOTION_PANEL_ENABLED,
} from "./editor/manualEditingAvailability";

/** Motion data without targeting metadata. */
type StudioMotionData = Omit<StudioGsapMotion, "kind" | "target" | "updatedAt">;
import { useCallback } from "react";
import { resolveDomEditSelection, type DomEditLayerItem } from "./editor/domEditing";
import { useStudioContext } from "../contexts/StudioContext";
Expand All @@ -17,7 +20,7 @@ import { useFileManagerContext } from "../contexts/FileManagerContext";
import { useDomEditContext } from "../contexts/DomEditContext";

export interface StudioRightPanelProps {
selectedStudioMotion: StudioGsapMotion | null;
selectedStudioMotion: StudioMotionData | null;
designPanelActive: boolean;
motionPanelActive: boolean;
}
Expand Down
16 changes: 8 additions & 8 deletions packages/studio/src/components/editor/MotionPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,14 @@ import {
} from "./MotionPanelFields";
import { EaseCurveEditor } from "./EaseCurveEditor";

/** Motion data without targeting metadata (kind/target/updatedAt are derived from context). */
type StudioMotionData = Omit<StudioGsapMotion, "kind" | "target" | "updatedAt">;

interface MotionPanelProps {
element: DomEditSelection | null;
motion: StudioGsapMotion | null;
motion: StudioMotionData | null;
onClearSelection: () => void;
onSetMotion: (
element: DomEditSelection,
motion: Omit<StudioGsapMotion, "kind" | "target" | "updatedAt">,
) => void;
onSetMotion: (element: DomEditSelection, motion: StudioMotionData) => void;
onClearMotion: (element: DomEditSelection) => void;
}

Expand All @@ -43,19 +43,19 @@ const MOTION_PRESET_OPTIONS: Array<{ label: string; value: StudioGsapMotionPrese

const MOTION_DIRECTION_OPTIONS: StudioGsapMotionDirection[] = ["up", "down", "left", "right"];

function motionValueDistance(motion: StudioGsapMotion | null): number {
function motionValueDistance(motion: StudioMotionData | null): number {
if (!motion) return 32;
return Math.max(Math.abs(motion.from.x ?? 0), Math.abs(motion.from.y ?? 0), 1);
}

function inferMotionPreset(motion: StudioGsapMotion | null): StudioGsapMotionPreset {
function inferMotionPreset(motion: StudioMotionData | null): StudioGsapMotionPreset {
if (!motion) return "fade-up";
if (motion.from.scale != null || motion.to.scale != null) return "pop";
if (motion.from.x != null || motion.to.x != null) return "slide";
return "fade-up";
}

function inferMotionDirection(motion: StudioGsapMotion | null): StudioGsapMotionDirection {
function inferMotionDirection(motion: StudioMotionData | null): StudioGsapMotionDirection {
if (!motion) return "up";
const x = motion.from.x ?? 0;
const y = motion.from.y ?? 0;
Expand Down
2 changes: 2 additions & 0 deletions packages/studio/src/components/editor/manualEdits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export {
clearStudioRotation,
clearStudioBoxSize,
reapplyPositionEditsAfterSeek,
buildMotionPatches,
buildClearMotionPatches,
} from "./manualEditsDom";

export {
Expand Down
56 changes: 56 additions & 0 deletions packages/studio/src/components/editor/manualEditsDom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ import {
STUDIO_ROTATION_TRANSFORM_ORIGIN,
} from "./manualEditsTypes";
import { roundRotationAngle } from "./manualEditsParsing";
import {
STUDIO_MOTION_ATTR,
STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR,
STUDIO_MOTION_ORIGINAL_OPACITY_ATTR,
STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR,
} from "./studioMotionTypes";
import { applyStudioMotionFromDom } from "./studioMotion";

/* ── Gesture tracking ─────────────────────────────────────────────── */
let studioManualEditGestureId = 0;
Expand Down Expand Up @@ -755,6 +762,52 @@ export function buildClearRotationPatches(element: HTMLElement): PatchOperation[
return ops;
}

/* ── Motion HTML patch builders ──────────────────────────────────── */

export function buildMotionPatches(element: HTMLElement): PatchOperation[] {
const motionJson = element.getAttribute(STUDIO_MOTION_ATTR);
if (!motionJson) return [];
const ops: PatchOperation[] = [
{ type: "attribute", property: STUDIO_MOTION_ATTR, value: motionJson },
];
const origTransform = element.getAttribute(STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR);
if (origTransform !== null) {
ops.push({
type: "attribute",
property: STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR,
value: origTransform,
});
}
const origOpacity = element.getAttribute(STUDIO_MOTION_ORIGINAL_OPACITY_ATTR);
if (origOpacity !== null) {
ops.push({
type: "attribute",
property: STUDIO_MOTION_ORIGINAL_OPACITY_ATTR,
value: origOpacity,
});
}
const origVisibility = element.getAttribute(STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR);
if (origVisibility !== null) {
ops.push({
type: "attribute",
property: STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR,
value: origVisibility,
});
}
return ops;
}

export function buildClearMotionPatches(_element: HTMLElement): PatchOperation[] {
return [
{ type: "attribute", property: STUDIO_MOTION_ATTR, value: null },
{ type: "attribute", property: STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR, value: null },
{ type: "attribute", property: STUDIO_MOTION_ORIGINAL_OPACITY_ATTR, value: null },
{ type: "attribute", property: STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR, value: null },
];
}

/* ── Seek reapply (position + motion) ────────────────────────────── */

export function reapplyPositionEditsAfterSeek(doc: Document): void {
const htmlElement = doc.defaultView?.HTMLElement;
if (!htmlElement) return;
Expand Down Expand Up @@ -793,4 +846,7 @@ export function reapplyPositionEditsAfterSeek(doc: Document): void {
applyStudioRotation(el, { angle });
}
}

// Reapply DOM-backed motion timeline after seek
applyStudioMotionFromDom(doc);
}
Loading
Loading