diff --git a/.filesize-allowlist b/.filesize-allowlist
deleted file mode 100644
index d2dff0d55..000000000
--- a/.filesize-allowlist
+++ /dev/null
@@ -1,13 +0,0 @@
-packages/studio/src/player/hooks/useTimelinePlayer.ts
-packages/studio/src/hooks/useManifestPersistence.ts
-packages/studio/src/player/components/PlayerControls.tsx
-packages/studio/src/components/editor/manualEdits.test.ts
-packages/studio/src/player/hooks/useTimelinePlayer.test.ts
-packages/studio/src/components/editor/manualEditsDom.ts
-packages/studio/src/utils/sourcePatcher.ts
-packages/studio/src/utils/sourcePatcher.test.ts
-packages/studio/src/App.tsx
-packages/studio/src/player/components/Timeline.tsx
-packages/studio/src/player/components/timelineEditing.test.ts
-packages/studio/src/components/editor/domEditing.test.ts
-packages/studio/src/components/editor/domEditingLayers.ts
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9852cb65d..805228230 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -469,7 +469,7 @@ jobs:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- - name: Check file sizes (max 500 lines)
+ - name: Check file sizes (max 600 lines)
# Scoped to files THIS PR changed under packages/studio. Walking the
# whole tree blamed every unrelated PR for pre-existing offenders.
# Falls back to a full scan on push events (no base ref available)
@@ -494,10 +494,9 @@ jobs:
for f in "${files[@]}"; do
[ -z "$f" ] && continue
[ -f "$f" ] || continue # skip files deleted in this PR
- if grep -qxF "$f" .filesize-allowlist 2>/dev/null; then continue; fi
lines=$(wc -l < "$f")
- if [ "$lines" -gt 500 ]; then
- echo "::error file=$f::$f has $lines lines (max 500)"
+ if [ "$lines" -gt 600 ]; then
+ echo "::error file=$f::$f has $lines lines (max 600)"
EXIT=1
fi
done
diff --git a/lefthook.yml b/lefthook.yml
index b2ea46992..7964ed691 100644
--- a/lefthook.yml
+++ b/lefthook.yml
@@ -21,18 +21,16 @@ pre-commit:
glob: "packages/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"
run: bunx fallow audit --base origin/main --fail-on-issues
filesize:
- # Scoped to packages/studio — the 500 LOC limit is a studio architecture
+ # Scoped to packages/studio — the 600 LOC limit is a studio architecture
# standard enforced as part of the App.tsx decomposition work. Player and
# other packages enforce size discipline via code review and convention.
- # Files temporarily over the limit are listed in .filesize-allowlist.
glob: "packages/studio/**/*.{ts,tsx}"
exclude: "(\\.test\\.(ts|tsx)$|\\.generated\\.)"
run: |
for f in {staged_files}; do
- if grep -qxF "$f" .filesize-allowlist 2>/dev/null; then continue; fi
lines=$(wc -l < "$f")
- if [ "$lines" -gt 500 ]; then
- echo "ERROR: $f has $lines lines (max 500) — add to .filesize-allowlist if temporarily needed"
+ if [ "$lines" -gt 600 ]; then
+ echo "ERROR: $f has $lines lines (max 600)"
exit 1
fi
done
diff --git a/packages/studio/src/components/editor/manualEditsDom.ts b/packages/studio/src/components/editor/manualEditsDom.ts
index 3c7400692..f5eb262a7 100644
--- a/packages/studio/src/components/editor/manualEditsDom.ts
+++ b/packages/studio/src/components/editor/manualEditsDom.ts
@@ -31,12 +31,6 @@ 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 ─────────────────────────────────────────────── */
@@ -221,24 +215,28 @@ function writeStudioPathOffsetVars(
// into element.style.transform (as a matrix) on every seek. When the studio's reapply hook also
// writes `translate`, both properties compose additively, doubling the visual offset. This helper
// zeroes out only the translate component (m41/m42) so the `translate` prop isn't double-counted.
+function isIdentityAfterTranslateStrip(m: DOMMatrix): boolean {
+ return m.is2D && m.a === 1 && m.b === 0 && m.c === 0 && m.d === 1;
+}
+
function stripGsapTranslateFromTransform(element: HTMLElement): void {
const transform = element.style.getPropertyValue("transform");
if (!transform || transform === "none") return;
- const win = element.ownerDocument.defaultView as (Window & typeof globalThis) | null;
- const DOMMatrixCtor = (win as unknown as { DOMMatrix?: typeof DOMMatrix })?.DOMMatrix;
+ const DOMMatrixCtor = (element.ownerDocument.defaultView as (Window & typeof globalThis) | null)
+ ?.DOMMatrix;
if (!DOMMatrixCtor) return;
try {
const m = new DOMMatrixCtor(transform);
if (m.m41 === 0 && m.m42 === 0) return;
m.m41 = 0;
m.m42 = 0;
- if (m.is2D && m.a === 1 && m.b === 0 && m.c === 0 && m.d === 1) {
+ if (isIdentityAfterTranslateStrip(m)) {
element.style.removeProperty("transform");
} else {
element.style.setProperty("transform", m.toString());
}
} catch {
- // non-parseable transform or DOMMatrix unavailable — leave as-is
+ /* non-parseable transform — leave as-is */
}
}
@@ -464,351 +462,30 @@ export function applyStudioRotationDraft(element: HTMLElement, rotation: { angle
);
}
-/* ── HTML patch builders ──────────────────────────────────────────── */
-import type { PatchOperation } from "../../utils/sourcePatcher";
-
-export function buildPathOffsetPatches(element: HTMLElement): PatchOperation[] {
- const x = element.style.getPropertyValue(STUDIO_OFFSET_X_PROP);
- const y = element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP);
- const translate = element.style.getPropertyValue("translate");
- const originalTranslate = element.getAttribute(STUDIO_ORIGINAL_TRANSLATE_ATTR);
- const originalInlineTranslate = element.getAttribute(STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR);
- const displayVal = element.style.getPropertyValue("display");
- const transformDisplayAttr = element.getAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR);
- const ops: PatchOperation[] = [];
- if (x) ops.push({ type: "inline-style", property: STUDIO_OFFSET_X_PROP, value: x });
- if (y) ops.push({ type: "inline-style", property: STUDIO_OFFSET_Y_PROP, value: y });
- if (translate) ops.push({ type: "inline-style", property: "translate", value: translate });
- ops.push({ type: "attribute", property: STUDIO_PATH_OFFSET_ATTR, value: "true" });
- if (originalTranslate !== null)
- ops.push({
- type: "attribute",
- property: STUDIO_ORIGINAL_TRANSLATE_ATTR,
- value: originalTranslate,
- });
- if (originalInlineTranslate !== null)
- ops.push({
- type: "attribute",
- property: STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR,
- value: originalInlineTranslate,
- });
- if (displayVal) ops.push({ type: "inline-style", property: "display", value: displayVal });
- if (transformDisplayAttr !== null)
- ops.push({
- type: "attribute",
- property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR,
- value: transformDisplayAttr,
- });
- return ops;
-}
-
-export function buildClearPathOffsetPatches(element: HTMLElement): PatchOperation[] {
- const originalInlineTranslate = element.getAttribute(STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR);
- const ops: PatchOperation[] = [
- { type: "inline-style", property: STUDIO_OFFSET_X_PROP, value: null },
- { type: "inline-style", property: STUDIO_OFFSET_Y_PROP, value: null },
- {
- type: "inline-style",
- property: "translate",
- value: originalInlineTranslate || null,
- },
- { type: "attribute", property: STUDIO_PATH_OFFSET_ATTR, value: null },
- { type: "attribute", property: STUDIO_ORIGINAL_TRANSLATE_ATTR, value: null },
- { type: "attribute", property: STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR, value: null },
- ];
- const origDisplay = element.getAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR);
- if (origDisplay !== null) {
- ops.push({ type: "inline-style", property: "display", value: origDisplay || null });
- ops.push({ type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, value: null });
- }
- return ops;
-}
-
-export function buildBoxSizePatches(element: HTMLElement): PatchOperation[] {
- const ops: PatchOperation[] = [];
-
- const studioWidth = element.style.getPropertyValue(STUDIO_WIDTH_PROP);
- const studioHeight = element.style.getPropertyValue(STUDIO_HEIGHT_PROP);
- if (studioWidth)
- ops.push({ type: "inline-style", property: STUDIO_WIDTH_PROP, value: studioWidth });
- if (studioHeight)
- ops.push({ type: "inline-style", property: STUDIO_HEIGHT_PROP, value: studioHeight });
-
- const width = element.style.getPropertyValue("width");
- const height = element.style.getPropertyValue("height");
- const minWidth = element.style.getPropertyValue("min-width");
- const minHeight = element.style.getPropertyValue("min-height");
- const maxWidth = element.style.getPropertyValue("max-width");
- const maxHeight = element.style.getPropertyValue("max-height");
- const flexBasis = element.style.getPropertyValue("flex-basis");
- const flexGrow = element.style.getPropertyValue("flex-grow");
- const flexShrink = element.style.getPropertyValue("flex-shrink");
- const boxSizing = element.style.getPropertyValue("box-sizing");
- const scale = element.style.getPropertyValue("scale");
- const transformOrigin = element.style.getPropertyValue("transform-origin");
- const displayVal = element.style.getPropertyValue("display");
-
- if (width) ops.push({ type: "inline-style", property: "width", value: width });
- if (height) ops.push({ type: "inline-style", property: "height", value: height });
- if (minWidth) ops.push({ type: "inline-style", property: "min-width", value: minWidth });
- if (minHeight) ops.push({ type: "inline-style", property: "min-height", value: minHeight });
- if (maxWidth) ops.push({ type: "inline-style", property: "max-width", value: maxWidth });
- if (maxHeight) ops.push({ type: "inline-style", property: "max-height", value: maxHeight });
- if (flexBasis) ops.push({ type: "inline-style", property: "flex-basis", value: flexBasis });
- if (flexGrow) ops.push({ type: "inline-style", property: "flex-grow", value: flexGrow });
- if (flexShrink) ops.push({ type: "inline-style", property: "flex-shrink", value: flexShrink });
- if (boxSizing) ops.push({ type: "inline-style", property: "box-sizing", value: boxSizing });
- if (scale) ops.push({ type: "inline-style", property: "scale", value: scale });
- if (transformOrigin)
- ops.push({ type: "inline-style", property: "transform-origin", value: transformOrigin });
- if (displayVal) ops.push({ type: "inline-style", property: "display", value: displayVal });
-
- ops.push({ type: "attribute", property: STUDIO_BOX_SIZE_ATTR, value: "true" });
-
- const origWidth = element.getAttribute(STUDIO_ORIGINAL_WIDTH_ATTR);
- const origHeight = element.getAttribute(STUDIO_ORIGINAL_HEIGHT_ATTR);
- const origMinWidth = element.getAttribute(STUDIO_ORIGINAL_MIN_WIDTH_ATTR);
- const origMinHeight = element.getAttribute(STUDIO_ORIGINAL_MIN_HEIGHT_ATTR);
- const origMaxWidth = element.getAttribute(STUDIO_ORIGINAL_MAX_WIDTH_ATTR);
- const origMaxHeight = element.getAttribute(STUDIO_ORIGINAL_MAX_HEIGHT_ATTR);
- const origFlexBasis = element.getAttribute(STUDIO_ORIGINAL_FLEX_BASIS_ATTR);
- const origFlexGrow = element.getAttribute(STUDIO_ORIGINAL_FLEX_GROW_ATTR);
- const origFlexShrink = element.getAttribute(STUDIO_ORIGINAL_FLEX_SHRINK_ATTR);
- const origBoxSizing = element.getAttribute(STUDIO_ORIGINAL_BOX_SIZING_ATTR);
- const origScale = element.getAttribute(STUDIO_ORIGINAL_SCALE_ATTR);
- const origTransformOrigin = element.getAttribute(STUDIO_ORIGINAL_TRANSFORM_ORIGIN_ATTR);
- const origDisplay = element.getAttribute(STUDIO_ORIGINAL_DISPLAY_ATTR);
- const origTransformDisplay = element.getAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR);
-
- if (origWidth !== null)
- ops.push({ type: "attribute", property: STUDIO_ORIGINAL_WIDTH_ATTR, value: origWidth });
- if (origHeight !== null)
- ops.push({ type: "attribute", property: STUDIO_ORIGINAL_HEIGHT_ATTR, value: origHeight });
- if (origMinWidth !== null)
- ops.push({ type: "attribute", property: STUDIO_ORIGINAL_MIN_WIDTH_ATTR, value: origMinWidth });
- if (origMinHeight !== null)
- ops.push({
- type: "attribute",
- property: STUDIO_ORIGINAL_MIN_HEIGHT_ATTR,
- value: origMinHeight,
- });
- if (origMaxWidth !== null)
- ops.push({ type: "attribute", property: STUDIO_ORIGINAL_MAX_WIDTH_ATTR, value: origMaxWidth });
- if (origMaxHeight !== null)
- ops.push({
- type: "attribute",
- property: STUDIO_ORIGINAL_MAX_HEIGHT_ATTR,
- value: origMaxHeight,
- });
- if (origFlexBasis !== null)
- ops.push({
- type: "attribute",
- property: STUDIO_ORIGINAL_FLEX_BASIS_ATTR,
- value: origFlexBasis,
- });
- if (origFlexGrow !== null)
- ops.push({ type: "attribute", property: STUDIO_ORIGINAL_FLEX_GROW_ATTR, value: origFlexGrow });
- if (origFlexShrink !== null)
- ops.push({
- type: "attribute",
- property: STUDIO_ORIGINAL_FLEX_SHRINK_ATTR,
- value: origFlexShrink,
- });
- if (origBoxSizing !== null)
- ops.push({
- type: "attribute",
- property: STUDIO_ORIGINAL_BOX_SIZING_ATTR,
- value: origBoxSizing,
- });
- if (origScale !== null)
- ops.push({ type: "attribute", property: STUDIO_ORIGINAL_SCALE_ATTR, value: origScale });
- if (origTransformOrigin !== null)
- ops.push({
- type: "attribute",
- property: STUDIO_ORIGINAL_TRANSFORM_ORIGIN_ATTR,
- value: origTransformOrigin,
- });
- if (origDisplay !== null)
- ops.push({ type: "attribute", property: STUDIO_ORIGINAL_DISPLAY_ATTR, value: origDisplay });
- if (origTransformDisplay !== null)
- ops.push({
- type: "attribute",
- property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR,
- value: origTransformDisplay,
- });
-
- return ops;
-}
-
-export function buildClearBoxSizePatches(element: HTMLElement): PatchOperation[] {
- const ops: PatchOperation[] = [
- { type: "inline-style", property: STUDIO_WIDTH_PROP, value: null },
- { type: "inline-style", property: STUDIO_HEIGHT_PROP, value: null },
- { type: "attribute", property: STUDIO_BOX_SIZE_ATTR, value: null },
- ];
-
- const origAttrs: Array<[string, string]> = [
- [STUDIO_ORIGINAL_WIDTH_ATTR, "width"],
- [STUDIO_ORIGINAL_HEIGHT_ATTR, "height"],
- [STUDIO_ORIGINAL_MIN_WIDTH_ATTR, "min-width"],
- [STUDIO_ORIGINAL_MIN_HEIGHT_ATTR, "min-height"],
- [STUDIO_ORIGINAL_MAX_WIDTH_ATTR, "max-width"],
- [STUDIO_ORIGINAL_MAX_HEIGHT_ATTR, "max-height"],
- [STUDIO_ORIGINAL_FLEX_BASIS_ATTR, "flex-basis"],
- [STUDIO_ORIGINAL_FLEX_GROW_ATTR, "flex-grow"],
- [STUDIO_ORIGINAL_FLEX_SHRINK_ATTR, "flex-shrink"],
- [STUDIO_ORIGINAL_BOX_SIZING_ATTR, "box-sizing"],
- [STUDIO_ORIGINAL_SCALE_ATTR, "scale"],
- [STUDIO_ORIGINAL_TRANSFORM_ORIGIN_ATTR, "transform-origin"],
- [STUDIO_ORIGINAL_DISPLAY_ATTR, "display"],
- ];
-
- for (const [attrName, styleProp] of origAttrs) {
- const origVal = element.getAttribute(attrName);
- if (origVal !== null) {
- ops.push({ type: "inline-style", property: styleProp, value: origVal || null });
- }
- ops.push({ type: "attribute", property: attrName, value: null });
- }
-
- const origTransformDisplay = element.getAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR);
- if (origTransformDisplay !== null) {
- ops.push({ type: "inline-style", property: "display", value: origTransformDisplay || null });
- ops.push({ type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, value: null });
- }
+/* ── HTML patch builders (re-exported from manualEditsDomPatches) ── */
+export {
+ buildPathOffsetPatches,
+ buildClearPathOffsetPatches,
+ buildBoxSizePatches,
+ buildClearBoxSizePatches,
+ buildRotationPatches,
+ buildClearRotationPatches,
+ buildMotionPatches,
+ buildClearMotionPatches,
+} from "./manualEditsDomPatches";
- return ops;
-}
-
-export function buildRotationPatches(element: HTMLElement): PatchOperation[] {
- const ops: PatchOperation[] = [];
-
- const studioRotation = element.style.getPropertyValue(STUDIO_ROTATION_PROP);
- const rotate = element.style.getPropertyValue("rotate");
- const transformOrigin = element.style.getPropertyValue("transform-origin");
- const displayVal = element.style.getPropertyValue("display");
-
- if (studioRotation)
- ops.push({ type: "inline-style", property: STUDIO_ROTATION_PROP, value: studioRotation });
- if (rotate) ops.push({ type: "inline-style", property: "rotate", value: rotate });
- if (transformOrigin)
- ops.push({ type: "inline-style", property: "transform-origin", value: transformOrigin });
- if (displayVal) ops.push({ type: "inline-style", property: "display", value: displayVal });
-
- ops.push({ type: "attribute", property: STUDIO_ROTATION_ATTR, value: "true" });
+/* ── Seek reapply (position + motion) ────────────────────────────── */
- const origRotate = element.getAttribute(STUDIO_ORIGINAL_ROTATE_ATTR);
- const origInlineRotate = element.getAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR);
- const origRotationTransformOrigin = element.getAttribute(
- STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR,
+function queryStudioElements(doc: Document, attr: string): HTMLElement[] {
+ const ctor = doc.defaultView?.HTMLElement;
+ if (!ctor) return [];
+ return Array.from(doc.querySelectorAll(`[${attr}="true"]`)).filter(
+ (el): el is HTMLElement => el instanceof ctor,
);
- const origTransformDisplay = element.getAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR);
-
- if (origRotate !== null)
- ops.push({ type: "attribute", property: STUDIO_ORIGINAL_ROTATE_ATTR, value: origRotate });
- if (origInlineRotate !== null)
- ops.push({
- type: "attribute",
- property: STUDIO_ORIGINAL_INLINE_ROTATE_ATTR,
- value: origInlineRotate,
- });
- if (origRotationTransformOrigin !== null)
- ops.push({
- type: "attribute",
- property: STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR,
- value: origRotationTransformOrigin,
- });
- if (origTransformDisplay !== null)
- ops.push({
- type: "attribute",
- property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR,
- value: origTransformDisplay,
- });
-
- return ops;
-}
-
-export function buildClearRotationPatches(element: HTMLElement): PatchOperation[] {
- const origInlineRotate = element.getAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR);
- const origRotationTransformOrigin = element.getAttribute(
- STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR,
- );
- const ops: PatchOperation[] = [
- { type: "inline-style", property: STUDIO_ROTATION_PROP, value: null },
- { type: "inline-style", property: "rotate", value: origInlineRotate || null },
- {
- type: "inline-style",
- property: "transform-origin",
- value: origRotationTransformOrigin !== null ? origRotationTransformOrigin || null : null,
- },
- { type: "attribute", property: STUDIO_ROTATION_ATTR, value: null },
- { type: "attribute", property: STUDIO_ROTATION_DRAFT_ATTR, value: null },
- { type: "attribute", property: STUDIO_ORIGINAL_ROTATE_ATTR, value: null },
- { type: "attribute", property: STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, value: null },
- { type: "attribute", property: STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR, value: null },
- ];
- const origTransformDisplay = element.getAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR);
- if (origTransformDisplay !== null) {
- ops.push({ type: "inline-style", property: "display", value: origTransformDisplay || null });
- ops.push({ type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, value: null });
- }
- 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;
-
- const offsetEls = Array.from(doc.querySelectorAll(`[${STUDIO_PATH_OFFSET_ATTR}="true"]`)).filter(
- (el): el is HTMLElement => el instanceof htmlElement,
- );
- for (const el of offsetEls) {
+function reapplyPathOffsets(doc: Document): void {
+ for (const el of queryStudioElements(doc, STUDIO_PATH_OFFSET_ATTR)) {
const x = el.style.getPropertyValue(STUDIO_OFFSET_X_PROP);
const y = el.style.getPropertyValue(STUDIO_OFFSET_Y_PROP);
if (x || y) {
@@ -818,28 +495,30 @@ export function reapplyPositionEditsAfterSeek(doc: Document): void {
});
}
}
+}
- const boxSizeEls = Array.from(doc.querySelectorAll(`[${STUDIO_BOX_SIZE_ATTR}="true"]`)).filter(
- (el): el is HTMLElement => el instanceof htmlElement,
- );
- for (const el of boxSizeEls) {
+function reapplyBoxSizes(doc: Document): void {
+ for (const el of queryStudioElements(doc, STUDIO_BOX_SIZE_ATTR)) {
const w = Number.parseFloat(el.style.getPropertyValue(STUDIO_WIDTH_PROP));
const h = Number.parseFloat(el.style.getPropertyValue(STUDIO_HEIGHT_PROP));
if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) {
applyStudioBoxSize(el, { width: w, height: h });
}
}
+}
- const rotationEls = Array.from(doc.querySelectorAll(`[${STUDIO_ROTATION_ATTR}="true"]`)).filter(
- (el): el is HTMLElement => el instanceof htmlElement,
- );
- for (const el of rotationEls) {
+function reapplyRotations(doc: Document): void {
+ for (const el of queryStudioElements(doc, STUDIO_ROTATION_ATTR)) {
const angle = Number.parseFloat(el.style.getPropertyValue(STUDIO_ROTATION_PROP));
if (Number.isFinite(angle)) {
applyStudioRotation(el, { angle });
}
}
+}
- // Reapply DOM-backed motion timeline after seek
+export function reapplyPositionEditsAfterSeek(doc: Document): void {
+ reapplyPathOffsets(doc);
+ reapplyBoxSizes(doc);
+ reapplyRotations(doc);
applyStudioMotionFromDom(doc);
}
diff --git a/packages/studio/src/components/editor/manualEditsDomPatches.ts b/packages/studio/src/components/editor/manualEditsDomPatches.ts
new file mode 100644
index 000000000..532920038
--- /dev/null
+++ b/packages/studio/src/components/editor/manualEditsDomPatches.ts
@@ -0,0 +1,237 @@
+import type { PatchOperation } from "../../utils/sourcePatcher";
+import {
+ STUDIO_OFFSET_X_PROP,
+ STUDIO_OFFSET_Y_PROP,
+ STUDIO_WIDTH_PROP,
+ STUDIO_HEIGHT_PROP,
+ STUDIO_ROTATION_PROP,
+ STUDIO_PATH_OFFSET_ATTR,
+ STUDIO_BOX_SIZE_ATTR,
+ STUDIO_ROTATION_ATTR,
+ STUDIO_ROTATION_DRAFT_ATTR,
+ STUDIO_ORIGINAL_TRANSLATE_ATTR,
+ STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR,
+ STUDIO_ORIGINAL_WIDTH_ATTR,
+ STUDIO_ORIGINAL_HEIGHT_ATTR,
+ STUDIO_ORIGINAL_MIN_WIDTH_ATTR,
+ STUDIO_ORIGINAL_MIN_HEIGHT_ATTR,
+ STUDIO_ORIGINAL_MAX_WIDTH_ATTR,
+ STUDIO_ORIGINAL_MAX_HEIGHT_ATTR,
+ STUDIO_ORIGINAL_FLEX_BASIS_ATTR,
+ STUDIO_ORIGINAL_FLEX_GROW_ATTR,
+ STUDIO_ORIGINAL_FLEX_SHRINK_ATTR,
+ STUDIO_ORIGINAL_BOX_SIZING_ATTR,
+ STUDIO_ORIGINAL_SCALE_ATTR,
+ STUDIO_ORIGINAL_TRANSFORM_ORIGIN_ATTR,
+ STUDIO_ORIGINAL_DISPLAY_ATTR,
+ STUDIO_ORIGINAL_ROTATE_ATTR,
+ STUDIO_ORIGINAL_INLINE_ROTATE_ATTR,
+ STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR,
+ STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR,
+} from "./manualEditsTypes";
+import {
+ STUDIO_MOTION_ATTR,
+ STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR,
+ STUDIO_MOTION_ORIGINAL_OPACITY_ATTR,
+ STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR,
+} from "./studioMotionTypes";
+
+/* ── Shared helpers ──────────────────────────────────────────────── */
+
+function collectInlineStyleOps(
+ element: HTMLElement,
+ properties: readonly string[],
+ ops: PatchOperation[],
+): void {
+ for (const prop of properties) {
+ const val = element.style.getPropertyValue(prop);
+ if (val) ops.push({ type: "inline-style", property: prop, value: val });
+ }
+}
+
+function collectAttributeOps(
+ element: HTMLElement,
+ attrNames: readonly string[],
+ ops: PatchOperation[],
+): void {
+ for (const attr of attrNames) {
+ const val = element.getAttribute(attr);
+ if (val !== null) ops.push({ type: "attribute", property: attr, value: val });
+ }
+}
+
+function appendTransformDisplayOps(element: HTMLElement, ops: PatchOperation[]): void {
+ const val = element.getAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR);
+ if (val !== null) {
+ ops.push({ type: "inline-style", property: "display", value: val || null });
+ ops.push({ type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, value: null });
+ }
+}
+
+/* ── Path offset patches ─────────────────────────────────────────── */
+
+export function buildPathOffsetPatches(element: HTMLElement): PatchOperation[] {
+ const ops: PatchOperation[] = [];
+ collectInlineStyleOps(element, [STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP, "translate"], ops);
+ ops.push({ type: "attribute", property: STUDIO_PATH_OFFSET_ATTR, value: "true" });
+ collectAttributeOps(
+ element,
+ [STUDIO_ORIGINAL_TRANSLATE_ATTR, STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR],
+ ops,
+ );
+ collectInlineStyleOps(element, ["display"], ops);
+ collectAttributeOps(element, [STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR], ops);
+ return ops;
+}
+
+export function buildClearPathOffsetPatches(element: HTMLElement): PatchOperation[] {
+ const originalInlineTranslate = element.getAttribute(STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR);
+ const ops: PatchOperation[] = [
+ { type: "inline-style", property: STUDIO_OFFSET_X_PROP, value: null },
+ { type: "inline-style", property: STUDIO_OFFSET_Y_PROP, value: null },
+ { type: "inline-style", property: "translate", value: originalInlineTranslate || null },
+ { type: "attribute", property: STUDIO_PATH_OFFSET_ATTR, value: null },
+ { type: "attribute", property: STUDIO_ORIGINAL_TRANSLATE_ATTR, value: null },
+ { type: "attribute", property: STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR, value: null },
+ ];
+ appendTransformDisplayOps(element, ops);
+ return ops;
+}
+
+/* ── Box size patches ────────────────────────────────────────────── */
+
+const BOX_SIZE_STYLE_PROPS = [
+ "width",
+ "height",
+ "min-width",
+ "min-height",
+ "max-width",
+ "max-height",
+ "flex-basis",
+ "flex-grow",
+ "flex-shrink",
+ "box-sizing",
+ "scale",
+ "transform-origin",
+ "display",
+] as const;
+
+const BOX_SIZE_ORIG_ATTRS: ReadonlyArray<[string, string]> = [
+ [STUDIO_ORIGINAL_WIDTH_ATTR, "width"],
+ [STUDIO_ORIGINAL_HEIGHT_ATTR, "height"],
+ [STUDIO_ORIGINAL_MIN_WIDTH_ATTR, "min-width"],
+ [STUDIO_ORIGINAL_MIN_HEIGHT_ATTR, "min-height"],
+ [STUDIO_ORIGINAL_MAX_WIDTH_ATTR, "max-width"],
+ [STUDIO_ORIGINAL_MAX_HEIGHT_ATTR, "max-height"],
+ [STUDIO_ORIGINAL_FLEX_BASIS_ATTR, "flex-basis"],
+ [STUDIO_ORIGINAL_FLEX_GROW_ATTR, "flex-grow"],
+ [STUDIO_ORIGINAL_FLEX_SHRINK_ATTR, "flex-shrink"],
+ [STUDIO_ORIGINAL_BOX_SIZING_ATTR, "box-sizing"],
+ [STUDIO_ORIGINAL_SCALE_ATTR, "scale"],
+ [STUDIO_ORIGINAL_TRANSFORM_ORIGIN_ATTR, "transform-origin"],
+ [STUDIO_ORIGINAL_DISPLAY_ATTR, "display"],
+ [STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, ""],
+];
+
+export function buildBoxSizePatches(element: HTMLElement): PatchOperation[] {
+ const ops: PatchOperation[] = [];
+ collectInlineStyleOps(element, [STUDIO_WIDTH_PROP, STUDIO_HEIGHT_PROP], ops);
+ collectInlineStyleOps(element, BOX_SIZE_STYLE_PROPS, ops);
+ ops.push({ type: "attribute", property: STUDIO_BOX_SIZE_ATTR, value: "true" });
+ collectAttributeOps(
+ element,
+ BOX_SIZE_ORIG_ATTRS.map(([attr]) => attr),
+ ops,
+ );
+ return ops;
+}
+
+export function buildClearBoxSizePatches(element: HTMLElement): PatchOperation[] {
+ const ops: PatchOperation[] = [
+ { type: "inline-style", property: STUDIO_WIDTH_PROP, value: null },
+ { type: "inline-style", property: STUDIO_HEIGHT_PROP, value: null },
+ { type: "attribute", property: STUDIO_BOX_SIZE_ATTR, value: null },
+ ];
+ for (const [attrName, styleProp] of BOX_SIZE_ORIG_ATTRS) {
+ const origVal = element.getAttribute(attrName);
+ if (origVal !== null && styleProp) {
+ ops.push({ type: "inline-style", property: styleProp, value: origVal || null });
+ }
+ ops.push({ type: "attribute", property: attrName, value: null });
+ }
+ return ops;
+}
+
+/* ── Rotation patches ────────────────────────────────────────────── */
+
+const ROTATION_STYLE_PROPS = [
+ STUDIO_ROTATION_PROP,
+ "rotate",
+ "transform-origin",
+ "display",
+] as const;
+
+const ROTATION_ORIG_ATTRS = [
+ STUDIO_ORIGINAL_ROTATE_ATTR,
+ STUDIO_ORIGINAL_INLINE_ROTATE_ATTR,
+ STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR,
+ STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR,
+] as const;
+
+export function buildRotationPatches(element: HTMLElement): PatchOperation[] {
+ const ops: PatchOperation[] = [];
+ collectInlineStyleOps(element, ROTATION_STYLE_PROPS, ops);
+ ops.push({ type: "attribute", property: STUDIO_ROTATION_ATTR, value: "true" });
+ collectAttributeOps(element, ROTATION_ORIG_ATTRS, ops);
+ return ops;
+}
+
+export function buildClearRotationPatches(element: HTMLElement): PatchOperation[] {
+ const origInlineRotate = element.getAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR);
+ const origRotationTransformOrigin = element.getAttribute(
+ STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR,
+ );
+ const ops: PatchOperation[] = [
+ { type: "inline-style", property: STUDIO_ROTATION_PROP, value: null },
+ { type: "inline-style", property: "rotate", value: origInlineRotate || null },
+ {
+ type: "inline-style",
+ property: "transform-origin",
+ value: origRotationTransformOrigin !== null ? origRotationTransformOrigin || null : null,
+ },
+ { type: "attribute", property: STUDIO_ROTATION_ATTR, value: null },
+ { type: "attribute", property: STUDIO_ROTATION_DRAFT_ATTR, value: null },
+ { type: "attribute", property: STUDIO_ORIGINAL_ROTATE_ATTR, value: null },
+ { type: "attribute", property: STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, value: null },
+ { type: "attribute", property: STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR, value: null },
+ ];
+ appendTransformDisplayOps(element, ops);
+ return ops;
+}
+
+/* ── Motion patches ──────────────────────────────────────────────── */
+
+const MOTION_ORIG_ATTRS = [
+ STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR,
+ STUDIO_MOTION_ORIGINAL_OPACITY_ATTR,
+ STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR,
+] as const;
+
+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 },
+ ];
+ collectAttributeOps(element, MOTION_ORIG_ATTRS, ops);
+ 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 },
+ ];
+}
diff --git a/packages/studio/src/player/components/PlayerControls.tsx b/packages/studio/src/player/components/PlayerControls.tsx
index 05a5c0ffd..50628ce7d 100644
--- a/packages/studio/src/player/components/PlayerControls.tsx
+++ b/packages/studio/src/player/components/PlayerControls.tsx
@@ -1,52 +1,319 @@
-import { useRef, useState, useCallback, useEffect, memo } from "react";
-import { useMountEffect } from "../../hooks/useMountEffect";
-import { formatFrameTime, frameToSeconds, stepFrameTime, formatTime } from "../lib/time";
+import { useRef, useCallback, useEffect, memo } from "react";
+import { formatFrameTime, formatTime, stepFrameTime } from "../lib/time";
import { shouldMutePreviewAudio } from "../lib/timelineIframeHelpers";
-import { usePlayerStore, liveTime } from "../store/playerStore";
+import { usePlayerStore } from "../store/playerStore";
import { trackStudioEvent } from "../../utils/studioTelemetry";
import { Tooltip } from "../../components/ui";
+import { ShortcutsPanel } from "./ShortcutsPanel";
+import { SpeedMenu } from "./SpeedMenu";
+import { useSeekBarDrag, resolveSeekPercent } from "./useSeekBarDrag";
+import { useState } from "react";
-const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
-const SEEK_EDGE_SNAP_PX = 8;
+export { resolveSeekPercent };
type TimeDisplayMode = "time" | "frame";
-const SHORTCUT_SECTIONS = [
- {
- title: "Playback",
- hints: [
- { key: "Space", label: "Play / Pause" },
- { key: "J", label: "Play backward" },
- { key: "K", label: "Stop" },
- { key: "L", label: "Play forward" },
- { key: "M", label: "Toggle mute" },
- { key: "⇧L", label: "Toggle loop" },
- { key: "←/→", label: "Step 1 frame" },
- { key: "⇧←/⇧→", label: "Step 10 frames" },
- { key: "F", label: "Toggle fullscreen" },
- ],
- },
- {
- title: "Work area",
- hints: [
- { key: "I", label: "Set in-point" },
- { key: "⇧I", label: "Clear in-point" },
- { key: "O", label: "Set out-point" },
- { key: "⇧O", label: "Clear out-point" },
- { key: "A", label: "Jump to in-point" },
- { key: "E", label: "Jump to out-point" },
- ],
- },
-] as const;
-export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth: number): number {
- if (!Number.isFinite(rectWidth) || rectWidth <= 0) return 0;
- const rawPercent = (clientX - rectLeft) / rectWidth;
- const clamped = Math.max(0, Math.min(1, rawPercent));
- const snapThreshold = Math.min(0.5, SEEK_EDGE_SNAP_PX / rectWidth);
- if (clamped <= snapThreshold) return 0;
- if (clamped >= 1 - snapThreshold) return 1;
- return clamped;
+/* ── Icon sub-components ─────────────────────────────────────────── */
+
+function PlayIcon() {
+ return (
+
+ );
+}
+
+function PauseIcon() {
+ return (
+
+ );
+}
+
+/* ── Button sub-components ───────────────────────────────────────── */
+
+const MuteButton = memo(function MuteButton({
+ audioMuted,
+ audioAutoMuted,
+ effectiveAudioMuted,
+ controlsDisabled,
+ setAudioMuted,
+}: {
+ audioMuted: boolean;
+ audioAutoMuted: boolean;
+ effectiveAudioMuted: boolean;
+ controlsDisabled: boolean;
+ setAudioMuted: (v: boolean) => void;
+}) {
+ const label = audioAutoMuted
+ ? "Audio muted above 1x speed"
+ : audioMuted
+ ? "Unmute audio"
+ : "Mute audio";
+ return (
+
- Jump to frame -
- -- Work area -
-- {section.title} -
-+ Jump to frame +
+ ++ Work area +
++ {section.title} +
+