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
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -846,6 +846,8 @@ jobs:
packages/studio/data/projects/timeline-virtualization
cp -R packages/studio/tests/e2e/fixtures/studio-open \
packages/studio/data/projects/studio-open
cp -R packages/studio/tests/e2e/fixtures/fade-handles \
packages/studio/data/projects/fade-handles
- name: Read the base branch's work-count ceilings
# A ceiling may not disappear, or rise without a new reason, against the branch this PR merges into.
if: github.event_name == 'pull_request'
Expand Down Expand Up @@ -953,6 +955,23 @@ jobs:
node packages/studio/tests/e2e/perf-ratchet.mjs check \
packages/studio/tests/e2e/perf-ceilings.json studio-open /tmp/studio-open-counts.json \
/tmp/base-perf-ceilings.json
- name: Measure the fade handle dots on a narrow clip
if: success() || failure()
run: |
set -euo pipefail
NODE_ENV=production bun run --cwd packages/studio dev -- --port 5316 --strictPort &
SERVER_PID=$!
trap 'kill "$SERVER_PID" 2>/dev/null || true' EXIT
for i in $(seq 1 60); do
if curl -sf "http://localhost:5316/" >/dev/null 2>&1; then break; fi
sleep 1
done
if ! curl -sf "http://localhost:5316/" >/dev/null 2>&1; then
echo "FAIL: studio dev server did not start on port 5316"
exit 1
fi
STUDIO_URL="http://localhost:5316/#project/fade-handles" \
node packages/studio/tests/e2e/fade-handles.mjs
- name: Upload gate evidence
# The gate's whole output is machine-readable evidence, and a red run is
# exactly when someone needs to read it. Keep it on failure too.
Expand Down
106 changes: 103 additions & 3 deletions packages/studio/src/player/components/TimelineClipFades.test.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
// @vitest-environment happy-dom

import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { TimelineElement } from "../store/playerStore";
import { TimelineClipFades } from "./TimelineClipFades";
import { TimelineEditProvider } from "../../contexts/TimelineEditContext";

(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

const mounted: Root[] = [];

afterEach(() => {
act(() => mounted.splice(0).forEach((root) => root.unmount()));
document.body.innerHTML = "";
});

Expand All @@ -31,6 +34,7 @@ function render(
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
mounted.push(root);
const onSetElementAttributeLive = vi.fn();
const onSetElementAttributeQuiet = vi.fn().mockResolvedValue(undefined);
const onRevertElementAttributeLive = vi.fn();
Expand Down Expand Up @@ -97,6 +101,14 @@ describe("TimelineClipFades", () => {
act(() => root.unmount());
});

it("clips the ramps to the clip's rounded corners", () => {
const { host, root } = render(clip, { showHandles: false, provide: false });
const ramps = host.querySelector<SVGElement>('[data-testid="clip-fade-ramps"]');
expect(ramps?.style.overflow).toBe("hidden");
expect(ramps?.style.borderRadius).toBe("inherit");
act(() => root.unmount());
});

it("renders nothing for a clip without fades when the handles are hidden", () => {
const { host, root } = render(
{ ...clip, fadeIn: undefined, fadeOut: undefined },
Expand All @@ -113,7 +125,7 @@ describe("TimelineClipFades", () => {
const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
if (!handle) throw new Error("expected a fade-in handle");
armCapture(handle);
// The handle sits at the 1 s mark (x=100). Drag 150 px right → 2.5 s.
// 150 px of rightward travel adds 1.5 s to the 1 s fade-in, wherever the press lands.
act(() => handle.dispatchEvent(pointer("pointerdown", 100)));
act(() => handle.dispatchEvent(pointer("pointermove", 200)));
act(() => handle.dispatchEvent(pointer("pointermove", 250)));
Expand All @@ -134,7 +146,7 @@ describe("TimelineClipFades", () => {
const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-out"]');
if (!handle) throw new Error("expected a fade-out handle");
armCapture(handle);
// Pressed at x=1000 with a 2 s fade-out. 300 px left → 5 s.
// 300 px of leftward travel adds 3 s to the 2 s fade-out, wherever the press lands.
act(() => handle.dispatchEvent(pointer("pointerdown", 1000)));
act(() => handle.dispatchEvent(pointer("pointermove", 700)));
expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-out", "5");
Expand Down Expand Up @@ -179,6 +191,30 @@ describe("TimelineClipFades", () => {
act(() => root.unmount());
});

it("cancels a drag on Escape pressed while focus is elsewhere, and saves nothing", () => {
const { host, onSetElementAttributeQuiet, onRevertElementAttributeLive } = render(clip);
const elsewhere = document.createElement("button");
document.body.append(elsewhere);
elsewhere.focus();
const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
if (!handle) throw new Error("expected a fade-in handle");
armCapture(handle);
act(() => handle.dispatchEvent(pointer("pointerdown", 100)));
act(() => handle.dispatchEvent(pointer("pointermove", 300)));
expect(document.activeElement).toBe(elsewhere);
act(() => {
document.activeElement?.dispatchEvent(
new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }),
);
});
act(() => handle.dispatchEvent(pointer("pointerup", 300)));
expect(onSetElementAttributeQuiet).not.toHaveBeenCalled();
expect(onRevertElementAttributeLive).toHaveBeenCalledWith(clip, "data-fade-in");
expect(host.querySelector('[data-testid="clip-fade-in"]')?.getAttribute("points")).toBe(
"0,0 100,0 0,100",
);
});

it.each([
["above the window", 300, -40],
["on the window's right edge", window.innerWidth, 10],
Expand All @@ -200,6 +236,70 @@ describe("TimelineClipFades", () => {
act(() => root.unmount());
});

it.each([
["in", "Fade in 0.25 s"],
["out", "Fade out 0.4 s"],
])(
"gives the fade-%s handle a 24 px target and a tooltip naming its length",
async (edge, text) => {
const { host, root } = render({ ...clip, fadeIn: 0.25, fadeOut: 0.4 });
const handle = host.querySelector<HTMLElement>(`[data-testid="clip-fade-handle-${edge}"]`);
if (!handle) throw new Error(`expected a fade-${edge} handle`);
expect(parseFloat(handle.style.width)).toBeGreaterThanOrEqual(24);
expect(parseFloat(handle.style.height)).toBeGreaterThanOrEqual(24);
expect(handle.getAttribute("title")).toBe("");
act(() => handle.focus());
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(document.querySelector('[role="tooltip"]')?.textContent).toBe(text);
act(() => root.unmount());
},
);

it.each([0.3, 3])("pins the handles to the top corners for a %s s fade-in", (fadeIn) => {
const { host } = render({ ...clip, fadeIn, fadeOut: 0.4 });
const inHandle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
const fadeOut = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-out"]');
expect(inHandle?.style.left).toBe("0px");
expect(fadeOut?.style.left).toBe(`${1000 - 24}px`);
});

it("lengthens a fade by the pointer's inward travel from wherever the press lands", () => {
const { host, onSetElementAttributeLive } = render(clip);
const fadeIn = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
const fadeOut = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-out"]');
if (!fadeIn || !fadeOut) throw new Error("expected both handles");
armCapture(fadeIn);
armCapture(fadeOut);
act(() => fadeIn.dispatchEvent(pointer("pointerdown", 12)));
act(() => fadeIn.dispatchEvent(pointer("pointermove", 162)));
expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-in", "2.5");
act(() => fadeIn.dispatchEvent(pointer("pointerup", 162)));
act(() => fadeOut.dispatchEvent(pointer("pointerdown", 988, 2)));
act(() => fadeOut.dispatchEvent(pointer("pointermove", 888, 2)));
expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-out", "3");
});

it("paints the handles above the clip's waveform and thumbnail layers", () => {
const { host } = render(clip);
const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
expect(Number(handle?.style.zIndex)).toBeGreaterThan(10);
});

it("splits a clip narrower than two targets between the handles", () => {
const { host } = render({ ...clip, duration: 0.3, fadeIn: 0.1, fadeOut: 0.1 });
const fadeIn = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
const fadeOut = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-out"]');
expect([fadeIn?.style.left, fadeIn?.style.width]).toEqual(["0px", "15px"]);
expect([fadeOut?.style.left, fadeOut?.style.width]).toEqual(["15px", "15px"]);
const dot = fadeIn?.firstElementChild as HTMLElement;
expect(getComputedStyle(dot).width).toBe("10px");
// Layout is not real here; tests/e2e/fade-handles.mjs measures the box in Chrome.
expect(getComputedStyle(dot).flexShrink).toBe("0");
expect(getComputedStyle(dot).pointerEvents).toBe("none");
});

it("does not let a press on the dot start the clip's own move gesture", () => {
const outer = vi.fn();
const { host, root } = render(clip, { onClipPointerDown: outer });
Expand Down
103 changes: 53 additions & 50 deletions packages/studio/src/player/components/TimelineClipFades.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,21 @@
import {
useEffect,
useRef,
useState,
type CSSProperties,
type KeyboardEvent,
type PointerEvent,
} from "react";
import { useEffect, useRef, useState, type CSSProperties, type PointerEvent } from "react";
import {
HF_AUDIO_FADE_IN_ATTR,
HF_AUDIO_FADE_OUT_ATTR,
clampFadesToDuration,
formatFadeSeconds,
} from "@hyperframes/core/audio-fade";
import type { TimelineElement } from "../store/playerStore";
import { Tooltip } from "../../components/ui";
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
import { releasedOutsideWindow } from "./timelinePointerRelease";

type FadeEdge = "in" | "out";

const HANDLE_SIZE = 10;
const HANDLE_HIT = 16;
const HANDLE_HIT = 24;
const HANDLE_Z_ABOVE_CLIP_CONTENT = 30;
const SUPPRESS_CLIP_NATIVE_TITLE = "";
/** Pixels of pointer travel before a press on the handle counts as a drag. */
const DRAG_THRESHOLD_PX = 2;

Expand Down Expand Up @@ -147,31 +143,38 @@ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineCli
);
};

const onHandleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const g = gesture.current;
if (e.key !== "Escape" || !g) return;
e.preventDefault();
gesture.current = null;
setDragging(null);
revertGesture(g);
};
useEffect(() => {
if (dragging === null) return;
const cancelOnWindowEscape = (e: KeyboardEvent) => {
const g = gesture.current;
if (e.key !== "Escape" || !g) return;
e.preventDefault();
e.stopPropagation();
gesture.current = null;
setDragging(null);
revertGesture(g);
};
window.addEventListener("keydown", cancelOnWindowEscape, { capture: true });
return () => window.removeEventListener("keydown", cancelOnWindowEscape, { capture: true });
});

const showIn = fades.fadeIn > 0;
const showOut = fades.fadeOut > 0;
const handlesVisible = showHandles || dragging !== null;
if (!showIn && !showOut && !handlesVisible) return null;

const handleStyle = (leftPx: number): CSSProperties => ({
const hitWidth = Math.min(HANDLE_HIT, widthPx / 2);
const handleStyle = (edge: FadeEdge): CSSProperties => ({
position: "absolute",
top: -(HANDLE_HIT - HANDLE_SIZE) / 2 + 1,
left: leftPx - HANDLE_HIT / 2,
width: HANDLE_HIT,
left: edge === "in" ? 0 : widthPx - hitWidth,
width: hitWidth,
height: HANDLE_HIT,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "ew-resize",
zIndex: 6,
zIndex: HANDLE_Z_ABOVE_CLIP_CONTENT,
opacity: handlesVisible ? 1 : 0,
pointerEvents: handlesVisible && canEdit ? "auto" : "none",
touchAction: "none",
Expand All @@ -191,7 +194,8 @@ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineCli
height: "100%",
pointerEvents: "none",
zIndex: 5,
overflow: "visible",
overflow: "hidden",
borderRadius: "inherit",
}}
viewBox={`0 0 ${Math.max(widthPx, 1)} 100`}
preserveAspectRatio="none"
Expand Down Expand Up @@ -245,25 +249,23 @@ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineCli
direction="in"
value={fades.fadeIn}
max={Math.max(0, el.duration - fades.fadeOut)}
style={handleStyle(inPx)}
style={handleStyle("in")}
dragging={dragging === "in"}
onPointerDown={onHandlePointerDown("in")}
onPointerMove={onHandlePointerMove}
onPointerUp={(e) => finish(e, false)}
onPointerCancel={(e) => finish(e, true)}
onKeyDown={onHandleKeyDown}
/>
<FadeHandle
direction="out"
value={fades.fadeOut}
max={Math.max(0, el.duration - fades.fadeIn)}
style={handleStyle(widthPx - outPx)}
style={handleStyle("out")}
dragging={dragging === "out"}
onPointerDown={onHandlePointerDown("out")}
onPointerMove={onHandlePointerMove}
onPointerUp={(e) => finish(e, false)}
onPointerCancel={(e) => finish(e, true)}
onKeyDown={onHandleKeyDown}
/>
</>
)}
Expand All @@ -281,7 +283,6 @@ function FadeHandle({
onPointerMove,
onPointerUp,
onPointerCancel,
onKeyDown,
}: {
direction: "in" | "out";
value: number;
Expand All @@ -292,31 +293,31 @@ function FadeHandle({
onPointerMove: (event: PointerEvent<HTMLDivElement>) => void;
onPointerUp: (event: PointerEvent<HTMLDivElement>) => void;
onPointerCancel: (event: PointerEvent<HTMLDivElement>) => void;
onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void;
}) {
const label = direction === "in" ? "Fade in" : "Fade out";
return (
<div
role="slider"
tabIndex={-1}
aria-label={label}
aria-valuemin={0}
aria-valuemax={max}
aria-valuenow={value}
aria-valuetext={`${formatFadeSeconds(value)}s`}
data-testid={`clip-fade-handle-${direction}`}
title={`${label}: ${formatFadeSeconds(value)}s — drag to change`}
style={style}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerCancel}
onKeyDown={onKeyDown}
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
>
<FadeDot active={dragging} />
</div>
<Tooltip label={`${label} ${formatFadeSeconds(value)} s`}>
<div
role="slider"
tabIndex={-1}
aria-label={label}
aria-valuemin={0}
aria-valuemax={max}
aria-valuenow={value}
aria-valuetext={`${formatFadeSeconds(value)}s`}
data-testid={`clip-fade-handle-${direction}`}
title={SUPPRESS_CLIP_NATIVE_TITLE}
style={style}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerCancel}
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
>
<FadeDot active={dragging} />
</div>
</Tooltip>
);
}

Expand All @@ -326,10 +327,12 @@ function FadeDot({ active }: { active: boolean }) {
aria-hidden="true"
style={{
display: "block",
flexShrink: 0,
pointerEvents: "none",
width: HANDLE_SIZE,
height: HANDLE_SIZE,
borderRadius: "50%",
background: active ? "var(--timeline-fade-dot-active)" : "var(--clip-handle)",
background: "var(--timeline-fade-dot)",
boxShadow: "0 0 0 1.5px var(--timeline-fade-dot-ring)",
transform: active ? "scale(1.15)" : undefined,
transition: "transform 80ms ease-out",
Expand Down
Loading
Loading