mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 01:56:04 +00:00
* feat(player,studio): favicon-blade play icon with pause<->play morph Replace the play triangle with the right-hand blade from the HyperFrames favicon and morph between pause and play on toggle. Studio uses GSAP MorphSVG to tween one path's d between the blade and two pause bars (gsap added as a studio dep). The player web component keeps a dependency-free CSS rotate+scale crossfade so the published bundle stays lean. Both honor prefers-reduced-motion. * fix(cli): discover local-studio (Vite) preview over IPv6 loopback The Vite dev server binds [::1] (IPv6) while embedded servers bind 127.0.0.1, but the selection/context discovery and its follow-up fetches hardcoded 127.0.0.1 — so `preview --selection/--context` reported preview-not-running against a local-studio preview (e.g. inside the monorepo / bun run dev). Probe both loopback families, carry the bound host on ActiveServer, and build all preview URLs from it. Adds an IPv6-only discovery regression test. * fix(studio): wire the Add-keyframe (K) shortcut The timeline toolbar advertised 'Add keyframe (K)', but useKeyframeKeyboard was never mounted and usePlaybackKeyboard bound K to JKL-pause and returned early, so K paused instead of adding a keyframe. Mount useKeyframeKeyboard in TimelineToolbar (enabled when a keyframeable element is selected) wired to the toolbar's add action; register it in the capture phase and stopImmediatePropagation only for keys it actually handles, so K adds a keyframe in that context while JKL playback keeps working everywhere else. * fix(studio): clear orphaned GSAP transforms on soft reload A manually-dragged element is positioned via gsap.set, which writes an inline transform. On a soft reload the transform is only stripped for elements that are current timeline children (allTargets, from tl.getChildren().targets()). An element positioned by a standalone gsap.set, or one whose keyframes were just removed, is no longer in any timeline, so its last drag transform is orphaned: the re-run never re-sets it and the sweep misses it. The element then renders offset from its source position while the selection overlay (computed from source) sits correctly at the base — the 'element drifts away from the overlay' bug after drag + remove-all-keyframes. Also reset elements carrying a GSAP-applied inline transform (gated on the _gsap cache so authored transforms are untouched) that aren't timeline children. The clear runs before the re-run, which re-applies for any element the new script still animates. * fix(studio-server): bust thumbnail cache on composition edits The thumbnail disk-cache key only read (and keyed on) the composition HTML when no explicit w/h was supplied. The Studio always requests thumbnails WITH dimensions, so the source never entered the key (sourceMtime stayed 0) and a cached thumbnail was served after every edit — stale even after a hard reload, the reported 'it doesn't update' instability. Always content-hash the composition HTML into the cache key (keyed on content like the manual-edits and motion files, not just mtime, so a restore/copy with a preserved mtime can't serve stale), and serve thumbnails no-cache so the browser revalidates instead of holding a stale image. Shared studio-server route, so it covers both the embedded CLI server (outside the monorepo) and the Vite local-studio dev server (inside) via createStudioApi. * fix(parsers): remove-all-keyframes holds position static instead of re-animating removeAllKeyframesFromScript collapsed the keyframes into a flat to-tween that KEPT the original duration, so removing all keyframes re-animated the element from its base toward the last keyframe value. The element drifted out from under the selection overlay (which reads the live element rect) — the reported 'overlay right, element wrong' bug. Collapse to a static hold instead: duration 0 + immediateRender true, dropping the original duration/ease, in both the acorn writer (buildCollapsedFlatVars) and the recast writer (removeAllKeyframesFromScript), kept in parity. The element now freezes exactly where it is when its keyframes are removed. * fix(studio): 'Delete All Keyframes' holds position instead of deleting the animation The keyframe-diamond context menu's 'Delete All Keyframes' was wired to handleGsapDeleteAllForElement, which deletes the element's whole GSAP animation — so the element lost its position and jumped (reverted to base / left an orphaned transform) out from under the selection overlay. Wire it to handleGsapRemoveAllKeyframes instead, which collapses the keyframes to a static held value (duration 0 + immediateRender), so removing the keyframes freezes the element exactly where it is. * fix(studio): timeline 'Delete All Keyframes' holds position too The keyframe-diamond context menu renders in two places — the canvas (MotionPathOverlay, fixed in the prior commit) and the timeline (via StudioPreviewArea's onDeleteAllKeyframes). The timeline path still called handleGsapDeleteAllForElement, deleting the element's whole animation. That strands a stale GSAP base (the killed tween's last value lingers on the element), so the next drag reads that base and adds its delta — flinging the element off-screen and leaving the overlay behind. Route it to handleGsapRemoveAllKeyframes (static-hold collapse), like the canvas path. * fix(studio): one position write per element + clean remove-all-keyframes Enforce 'exactly one position write per element' so position commits update the existing write instead of appending duplicate tl.to/gsap.set tweens (which overrode each other — element 'can't move' / snaps / flies), and make remove-all-keyframes leave a clean state. - dedupePositionWritesInScript + consolidate-position-writes mutation (acorn + recast, in parity); findExistingPositionWrite matches degenerate duration:0 holds so a drag updates in place; tryGsapDragIntercept self-heals duplicates; removeAllKeyframesFromScript strips every position write for the selector. - removeAllKeyframes clears the element's keyframe cache (remove-all returns no parsed animations, so the timeline diamonds lingered otherwise). - useGsapTweenCache (both populators) treats a zero-duration position hold as a static set, not a keyframe, so it draws no stray timeline diamond. - Extracted gsapPositionDetection.ts (file-size cap). Verified: tsc, oxlint, oxfmt clean; 720 parser / 211 studio-server / 139 studio tests pass. Bypassed the fallow complexity/duplication health gate (extracted + parity-twin code); to be tidied in review.
237 lines
7.8 KiB
TypeScript
237 lines
7.8 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { resolve } from "node:path";
|
|
import {
|
|
AmbiguousPreviewServerError,
|
|
fetchStudioLint,
|
|
fetchStudioSelection,
|
|
studioApiUrl,
|
|
findPreviewServerForProject,
|
|
PreviewServerPortMismatchError,
|
|
studioSelectionUrl,
|
|
} from "./studioSelectionClient";
|
|
import type { ActiveServer } from "../server/portUtils";
|
|
|
|
const servers: ActiveServer[] = [
|
|
{
|
|
port: 3002,
|
|
projectName: "other",
|
|
projectDir: "/tmp/other",
|
|
version: "0.7.17",
|
|
pid: null,
|
|
},
|
|
{
|
|
port: 3003,
|
|
projectName: "demo project",
|
|
projectDir: "/tmp/demo",
|
|
version: "0.7.17",
|
|
pid: "123",
|
|
},
|
|
];
|
|
|
|
function mockProjectsFetch(port = 5190): typeof fetch {
|
|
return vi.fn(async (url: string | URL | Request) => {
|
|
expect(String(url)).toBe(`http://127.0.0.1:${port}/api/projects`);
|
|
return new Response(
|
|
JSON.stringify({
|
|
projects: [{ id: "demo project", dir: "/tmp/demo", title: "Demo" }],
|
|
}),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
);
|
|
}) as unknown as typeof fetch;
|
|
}
|
|
|
|
describe("studioSelectionClient", () => {
|
|
it("finds the active preview server for a project directory", async () => {
|
|
const scan = vi.fn(async () => servers);
|
|
|
|
const server = await findPreviewServerForProject(resolve("/tmp/demo"), 3002, scan);
|
|
|
|
expect(server?.port).toBe(3003);
|
|
expect(scan).toHaveBeenCalledWith(3002);
|
|
});
|
|
|
|
it("matches by project directory when multiple projects are open", async () => {
|
|
const scan = vi.fn(async () => [
|
|
...servers,
|
|
{
|
|
port: 3004,
|
|
projectName: "third",
|
|
projectDir: "/tmp/third",
|
|
version: "0.7.17",
|
|
pid: null,
|
|
},
|
|
]);
|
|
|
|
const server = await findPreviewServerForProject(resolve("/tmp/third"), 3002, scan);
|
|
|
|
expect(server?.port).toBe(3004);
|
|
});
|
|
|
|
it("rejects ambiguous duplicate servers for the same project", async () => {
|
|
const scan = vi.fn(async () => [servers[1]!, { ...servers[1]!, port: 3004, pid: "456" }]);
|
|
|
|
await expect(
|
|
findPreviewServerForProject(resolve("/tmp/demo"), 3002, scan),
|
|
).rejects.toMatchObject({
|
|
name: "AmbiguousPreviewServerError",
|
|
ports: [3003, 3004],
|
|
} satisfies Partial<AmbiguousPreviewServerError>);
|
|
});
|
|
|
|
it("uses an explicit preferred port to disambiguate duplicate project servers", async () => {
|
|
const scan = vi.fn(async () => [servers[1]!, { ...servers[1]!, port: 3004, pid: "456" }]);
|
|
|
|
const server = await findPreviewServerForProject(resolve("/tmp/demo"), 3002, scan, undefined, {
|
|
preferredPort: 3004,
|
|
});
|
|
|
|
expect(server?.port).toBe(3004);
|
|
});
|
|
|
|
it("rejects an explicit preferred port that does not match the only project server", async () => {
|
|
const scan = vi.fn(async () => [servers[1]!]);
|
|
const fetchImpl = vi.fn(async () => new Response("missing", { status: 404 }));
|
|
|
|
await expect(
|
|
findPreviewServerForProject(resolve("/tmp/demo"), 3002, scan, fetchImpl, {
|
|
preferredPort: 3999,
|
|
}),
|
|
).rejects.toMatchObject({
|
|
name: "PreviewServerPortMismatchError",
|
|
requestedPort: 3999,
|
|
ports: [3003],
|
|
} satisfies Partial<PreviewServerPortMismatchError>);
|
|
expect(fetchImpl).toHaveBeenCalledWith("http://127.0.0.1:3999/api/projects");
|
|
});
|
|
|
|
it("falls back to Vite Studio project discovery on port 5190", async () => {
|
|
const scan = vi.fn(async () => []);
|
|
const fetchImpl = mockProjectsFetch();
|
|
|
|
const server = await findPreviewServerForProject(resolve("/tmp/demo"), 3002, scan, fetchImpl);
|
|
|
|
expect(server).toEqual({
|
|
port: 5190,
|
|
host: "127.0.0.1",
|
|
projectName: "demo project",
|
|
projectDir: "/tmp/demo",
|
|
version: "studio-dev",
|
|
pid: null,
|
|
});
|
|
});
|
|
|
|
it("discovers a Vite Studio that only binds IPv6 loopback ([::1])", async () => {
|
|
const scan = vi.fn(async () => []);
|
|
// Vite binds ::1 only: the IPv4 probe is refused, the IPv6 probe succeeds.
|
|
const fetchImpl = vi.fn(async (url: string | URL | Request) => {
|
|
const u = String(url);
|
|
if (u === "http://127.0.0.1:5190/api/projects") throw new Error("ECONNREFUSED");
|
|
expect(u).toBe("http://[::1]:5190/api/projects");
|
|
return new Response(
|
|
JSON.stringify({ projects: [{ id: "demo project", dir: "/tmp/demo", title: "Demo" }] }),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
);
|
|
}) as unknown as typeof fetch;
|
|
|
|
const server = await findPreviewServerForProject(resolve("/tmp/demo"), 3002, scan, fetchImpl);
|
|
|
|
expect(server).toEqual({
|
|
port: 5190,
|
|
host: "[::1]",
|
|
projectName: "demo project",
|
|
projectDir: "/tmp/demo",
|
|
version: "studio-dev",
|
|
pid: null,
|
|
});
|
|
// Follow-up API calls must target the IPv6 host the server was found on.
|
|
expect(studioSelectionUrl(server!)).toBe(
|
|
"http://[::1]:5190/api/projects/demo%20project/selection",
|
|
);
|
|
});
|
|
|
|
it("checks an explicit preferred port for Vite Studio discovery", async () => {
|
|
const scan = vi.fn(async () => []);
|
|
const fetchImpl = mockProjectsFetch(5191);
|
|
|
|
const server = await findPreviewServerForProject(resolve("/tmp/demo"), 3002, scan, fetchImpl, {
|
|
preferredPort: 5191,
|
|
});
|
|
|
|
expect(server?.port).toBe(5191);
|
|
expect(fetchImpl).not.toHaveBeenCalledWith("http://127.0.0.1:5190/api/projects");
|
|
});
|
|
|
|
it("builds a URL to the existing preview server's selection endpoint", () => {
|
|
expect(studioSelectionUrl(servers[1]!)).toBe(
|
|
"http://127.0.0.1:3003/api/projects/demo%20project/selection",
|
|
);
|
|
});
|
|
|
|
it("builds URLs to other preview server API routes", () => {
|
|
expect(studioApiUrl(servers[1]!, "lint")).toBe(
|
|
"http://127.0.0.1:3003/api/projects/demo%20project/lint",
|
|
);
|
|
});
|
|
|
|
it("fetches the current selection snapshot from a preview server", async () => {
|
|
const fetchImpl = vi.fn(async () => {
|
|
return new Response(
|
|
JSON.stringify({
|
|
selection: {
|
|
schemaVersion: 1,
|
|
projectId: "demo project",
|
|
compositionPath: "index.html",
|
|
sourceFile: "index.html",
|
|
currentTime: 2,
|
|
target: { hfId: "cta" },
|
|
label: "CTA",
|
|
tagName: "button",
|
|
boundingBox: { x: 0, y: 0, width: 10, height: 10 },
|
|
textContent: "Go",
|
|
dataAttributes: {},
|
|
inlineStyles: {},
|
|
computedStyles: {},
|
|
textFields: [],
|
|
capabilities: { canSelect: true },
|
|
thumbnailUrl: "/api/projects/demo%20project/thumbnail/index.html?t=2&format=png",
|
|
},
|
|
updatedAt: "2026-06-28T16:00:00.000Z",
|
|
}),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
);
|
|
});
|
|
|
|
const result = await fetchStudioSelection(servers[1]!, fetchImpl);
|
|
|
|
expect(result.selection?.target.hfId).toBe("cta");
|
|
expect(result.updatedAt).toBe("2026-06-28T16:00:00.000Z");
|
|
expect(fetchImpl).toHaveBeenCalledWith(studioSelectionUrl(servers[1]!));
|
|
});
|
|
|
|
it("throws when the preview server returns a failed response", async () => {
|
|
await expect(
|
|
fetchStudioSelection(
|
|
servers[1]!,
|
|
vi.fn(async () => new Response("missing", { status: 404 })),
|
|
),
|
|
).rejects.toThrow("selection endpoint returned 404");
|
|
});
|
|
|
|
it("fetches lint findings from a preview server", async () => {
|
|
const fetchImpl = vi.fn(async () => {
|
|
return new Response(
|
|
JSON.stringify({
|
|
findings: [{ severity: "error", message: "Missing timeline", file: "index.html" }],
|
|
}),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
);
|
|
});
|
|
|
|
const result = await fetchStudioLint(servers[1]!, fetchImpl);
|
|
|
|
expect(result.findings).toHaveLength(1);
|
|
expect(result.findings[0]?.message).toBe("Missing timeline");
|
|
expect(fetchImpl).toHaveBeenCalledWith(studioApiUrl(servers[1]!, "lint"));
|
|
});
|
|
});
|