fix(studio): clean next alpha inspector artifacts

This commit is contained in:
Miguel Ángel
2026-05-11 15:01:54 -07:00
parent 0e0f1dd6df
commit 005e1960bb
27 changed files with 154 additions and 77 deletions
+2 -1
View File
@@ -62,7 +62,8 @@ tmp/
packages/core/src/generated/
packages/producer/src/services/fontData.generated.ts
# Test artifacts
# Local proof / test artifacts
qa-artifacts/
my-video/
examples/
packages/studio/data/
+2 -2
View File
@@ -162,7 +162,7 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
| Flag | Description |
|------|-------------|
| `--example, -e` | Example to scaffold (required in default mode, interactive in `--human-friendly`) |
| `--resolution` | Canvas preset: `landscape` (1920×1080), `portrait` (1080×1920), `landscape-4k` (3840×2160), `portrait-4k` (2160×3840). Aliases: `1080p`, `4k`, `uhd`. Default: keep template dimensions. |
| `--resolution` | Canvas preset: `landscape` (1920×1080), `portrait` (1080×1920), `landscape-4k` (3840×2160), `portrait-4k` (2160×3840), `square` (1080×1080), `square-4k` (2160×2160). Aliases: `1080p`, `4k`, `uhd`, `1080p-square`, `square-1080p`, `4k-square`. Default: keep template dimensions. |
| `--video, -V` | Path to a video file (MP4, WebM, MOV) |
| `--audio, -a` | Path to an audio file (MP3, WAV, M4A) |
| `--tailwind` | Add Tailwind CSS browser-runtime support to scaffolded HTML |
@@ -610,7 +610,7 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
| `--quality` | draft, standard, high | standard | Encoding quality preset (drives CRF/bitrate) |
| `--crf` | 0-51 | — | Override encoder CRF (lower = higher quality). Mutually exclusive with `--video-bitrate` |
| `--video-bitrate` | e.g. `10M`, `5000k` | — | Target video bitrate. Mutually exclusive with `--crf` |
| `--resolution` | landscape, portrait, landscape-4k, portrait-4k (aliases: `1080p`, `4k`, `uhd`) | — | Output resolution preset. Supersamples a smaller composition via Chrome `deviceScaleFactor` so the screenshot lands at the requested dimensions. Aspect ratio must match the composition; the scale must be an integer multiple. Not supported with `--hdr`. See [4K Rendering](/guides/4k-rendering) |
| `--resolution` | landscape, portrait, landscape-4k, portrait-4k, square, square-4k (aliases: `1080p`, `4k`, `uhd`, `1080p-square`, `square-1080p`, `4k-square`) | — | Output resolution preset. Supersamples a smaller composition via Chrome `deviceScaleFactor` so the screenshot lands at the requested dimensions. Aspect ratio must match the composition; the scale must be an integer multiple. Not supported with `--hdr`. See [4K Rendering](/guides/4k-rendering) |
| `--hdr` | — | off | Force HDR output even if no HDR sources are detected. MP4 only. See [HDR Rendering](/guides/hdr) |
| `--sdr` | — | off | Force SDR output even if HDR sources are detected |
| `--workers` | 1-8 | 4 | Parallel render workers |
+1 -1
View File
@@ -51,7 +51,7 @@ import type {
TimelineElementType, // "video" | "image" | "text" | "audio" | "composition"
CompositionSpec,
CompositionVariable,
CanvasResolution, // "landscape" | "portrait"
CanvasResolution, // "landscape" | "portrait" | "landscape-4k" | "portrait-4k" | "square" | "square-4k"
Orientation, // "16:9" | "9:16"
FrameAdapter,
FrameAdapterContext,
+32
View File
@@ -207,6 +207,38 @@ describe("applyResolutionPreset", () => {
});
});
it("swaps to square dimensions for square", () => {
withFixture((dir) => {
const file = join(dir, "index.html");
writeFileSync(file, sampleHtml, "utf-8");
applyResolutionPreset(dir, "square");
const out = readFileSync(file, "utf-8");
expect(out).toContain('data-width="1080"');
expect(out).toContain('data-height="1080"');
expect(out).toContain('data-resolution="square"');
expect(out).toContain("width: 1080px");
expect(out).toContain("height: 1080px");
});
});
it("swaps to square-4k dimensions", () => {
withFixture((dir) => {
const file = join(dir, "index.html");
writeFileSync(file, sampleHtml, "utf-8");
applyResolutionPreset(dir, "square-4k");
const out = readFileSync(file, "utf-8");
expect(out).toContain('data-width="2160"');
expect(out).toContain('data-height="2160"');
expect(out).toContain('data-resolution="square-4k"');
expect(out).toContain("width: 2160px");
expect(out).toContain("height: 2160px");
});
});
it("scaffolds a 4k project end-to-end via --resolution 4k", () => {
const dir = mkdtempSync(join(tmpdir(), "hf-init-test-"));
const target = join(dir, "proj");
+3 -2
View File
@@ -626,7 +626,7 @@ export default defineCommand({
resolution: {
type: "string",
description:
"Canvas resolution preset: landscape (1920x1080), portrait (1080x1920), landscape-4k (3840x2160), portrait-4k (2160x3840). Aliases: 1080p, 4k, uhd. Default: keep template dimensions (typically 1920x1080).",
"Canvas resolution preset: landscape (1920x1080), portrait (1080x1920), landscape-4k (3840x2160), portrait-4k (2160x3840), square (1080x1080), square-4k (2160x2160). Aliases: 1080p, 4k, uhd, 1080p-square, square-1080p, 4k-square. Default: keep template dimensions (typically 1920x1080).",
},
},
async run({ args }) {
@@ -658,7 +658,8 @@ export default defineCommand({
console.error(
c.error(
`Invalid --resolution: "${args.resolution}". ` +
`Use one of: landscape, portrait, landscape-4k, portrait-4k (or aliases 1080p, 4k, uhd).`,
`Use one of: landscape, portrait, landscape-4k, portrait-4k, square, square-4k ` +
`(or aliases 1080p, 4k, uhd, 1080p-square, square-1080p, 4k-square).`,
),
);
process.exit(1);
+3 -2
View File
@@ -186,7 +186,7 @@ export default defineCommand({
resolution: {
type: "string",
description:
"Output resolution preset: landscape (1920x1080), portrait (1080x1920), landscape-4k (3840x2160), portrait-4k (2160x3840). Aliases: 1080p, 4k, uhd. The composition is unchanged — Chrome renders at higher DPR (deviceScaleFactor) so the captured screenshot lands at the requested dimensions. Aspect ratio must match the composition; the scale must be an integer multiple. Not yet supported with --hdr.",
"Output resolution preset: landscape (1920x1080), portrait (1080x1920), landscape-4k (3840x2160), portrait-4k (2160x3840), square (1080x1080), square-4k (2160x2160). Aliases: 1080p, 4k, uhd, 1080p-square, square-1080p, 4k-square. The composition is unchanged — Chrome renders at higher DPR (deviceScaleFactor) so the captured screenshot lands at the requested dimensions. Aspect ratio must match the composition; the scale must be an integer multiple. Not yet supported with --hdr.",
},
},
async run({ args }) {
@@ -224,7 +224,8 @@ export default defineCommand({
if (!outputResolution) {
errorBox(
"Invalid resolution",
`Got "${args.resolution}". Must be one of: landscape, portrait, landscape-4k, portrait-4k (or aliases 1080p, 4k, uhd).`,
`Got "${args.resolution}". Must be one of: landscape, portrait, landscape-4k, portrait-4k, square, square-4k ` +
`(or aliases 1080p, 4k, uhd, 1080p-square, square-1080p, 4k-square).`,
);
process.exit(1);
}
+5
View File
@@ -24,6 +24,8 @@ export const CANVAS_DIMENSIONS = {
portrait: { width: 1080, height: 1920 },
"landscape-4k": { width: 3840, height: 2160 },
"portrait-4k": { width: 2160, height: 3840 },
square: { width: 1080, height: 1080 },
"square-4k": { width: 2160, height: 2160 },
} as const;
// Single source of truth: derive the type from the table so adding a preset
@@ -47,6 +49,9 @@ const RESOLUTION_ALIASES: Record<string, CanvasResolution> = {
"4k": "landscape-4k",
uhd: "landscape-4k",
"4k-portrait": "portrait-4k",
"1080p-square": "square",
"square-1080p": "square",
"4k-square": "square-4k",
};
/**
+8
View File
@@ -10,6 +10,8 @@ describe("@hyperframes/core public API exports", () => {
expect(core.CANVAS_DIMENSIONS.portrait).toEqual({ width: 1080, height: 1920 });
expect(core.CANVAS_DIMENSIONS["landscape-4k"]).toEqual({ width: 3840, height: 2160 });
expect(core.CANVAS_DIMENSIONS["portrait-4k"]).toEqual({ width: 2160, height: 3840 });
expect(core.CANVAS_DIMENSIONS.square).toEqual({ width: 1080, height: 1080 });
expect(core.CANVAS_DIMENSIONS["square-4k"]).toEqual({ width: 2160, height: 2160 });
});
it("exports VALID_CANVAS_RESOLUTIONS derived from CANVAS_DIMENSIONS", () => {
@@ -18,6 +20,8 @@ describe("@hyperframes/core public API exports", () => {
"portrait",
"landscape-4k",
"portrait-4k",
"square",
"square-4k",
]);
});
@@ -27,6 +31,10 @@ describe("@hyperframes/core public API exports", () => {
expect(core.normalizeResolutionFlag("1080p")).toBe("landscape");
expect(core.normalizeResolutionFlag("landscape-4k")).toBe("landscape-4k");
expect(core.normalizeResolutionFlag("UHD")).toBe("landscape-4k");
expect(core.normalizeResolutionFlag("square")).toBe("square");
expect(core.normalizeResolutionFlag("square-4k")).toBe("square-4k");
expect(core.normalizeResolutionFlag("1080p-square")).toBe("square");
expect(core.normalizeResolutionFlag("4k-square")).toBe("square-4k");
expect(core.normalizeResolutionFlag("8k")).toBeUndefined();
expect(core.normalizeResolutionFlag(undefined)).toBeUndefined();
});
+32 -5
View File
@@ -197,6 +197,21 @@ describe("parseHtml", () => {
expect(result.resolution).toBe("portrait");
});
it("keeps explicit portrait resolution even when dimensions are square", () => {
const html = `
<html data-resolution="portrait" data-composition-width="1080" data-composition-height="1080">
<body>
<div id="stage">
<div id="text1" data-start="0" data-end="5"><div>Hello</div></div>
</div>
</body>
</html>
`;
const result = parseHtml(html);
expect(result.resolution).toBe("portrait");
});
it("defaults to portrait when no resolution info is available", () => {
const html = `
<html>
@@ -275,10 +290,7 @@ describe("parseHtml", () => {
expect(result.resolution).toBe("landscape");
});
it("classifies square compositions as portrait by convention", () => {
// 1080×1080 has no obvious orientation. The parser collapses the tie to
// portrait — same bias the prior `w > h ? landscape : portrait` ternary
// had. Pinning so a future refactor doesn't silently flip it.
it("infers square resolution from equal width/height", () => {
const html = `
<html data-composition-width="1080" data-composition-height="1080">
<body>
@@ -290,7 +302,22 @@ describe("parseHtml", () => {
`;
const result = parseHtml(html);
expect(result.resolution).toBe("portrait");
expect(result.resolution).toBe("square");
});
it("infers square-4k from equal width/height >= 2160", () => {
const html = `
<html data-composition-width="2160" data-composition-height="2160">
<body>
<div id="stage">
<div id="text1" data-start="0" data-end="5"><div>Hello</div></div>
</div>
</body>
</html>
`;
const result = parseHtml(html);
expect(result.resolution).toBe("square-4k");
});
it("extracts x, y, scale, opacity from data attributes", () => {
+13 -11
View File
@@ -124,7 +124,9 @@ function parseResolutionFromHtml(doc: Document): CanvasResolution | null {
resolutionAttr === "landscape" ||
resolutionAttr === "portrait" ||
resolutionAttr === "landscape-4k" ||
resolutionAttr === "portrait-4k"
resolutionAttr === "portrait-4k" ||
resolutionAttr === "square" ||
resolutionAttr === "square-4k"
) {
return resolutionAttr;
}
@@ -143,17 +145,17 @@ function parseResolutionFromHtml(doc: Document): CanvasResolution | null {
}
function resolveResolutionFromDimensions(width: number, height: number): CanvasResolution {
// `width === height` (square) falls into the portrait branch by convention —
// the same bias the previous `w > h ? landscape : portrait` ternary used.
// Square compositions are rare; pick portrait-as-default so we don't surprise
// the existing call sites that depend on this behavior.
const isLandscape = width > height;
const longSide = Math.max(width, height);
// UHD cutoff is the long side of `landscape-4k` / `portrait-4k` (3840). A
// looser threshold (e.g. ≥ 2560) would silently misclassify QHD/1440p
// (2560×1440) as 4K, which is the wrong default for a common authoring
// resolution closer to 1080p than to UHD. Authors who genuinely want the
// 4K preset can still set `data-resolution="landscape-4k"` explicitly.
// UHD cutoff is the long side of the 4K presets (3840 for `landscape-4k` /
// `portrait-4k`, 2160 for `square-4k`). A looser threshold (e.g. >= 2560)
// would silently misclassify QHD/1440p (2560x1440) as 4K, which is the
// wrong default for a common authoring resolution closer to 1080p than to
// UHD. Authors who genuinely want the 4K preset can still set
// `data-resolution="..."` explicitly.
if (width === height) {
return longSide >= 2160 ? "square-4k" : "square";
}
const isLandscape = width > height;
const isUhd = longSide >= 3840;
if (isLandscape) return isUhd ? "landscape-4k" : "landscape";
return isUhd ? "portrait-4k" : "portrait";
@@ -3,6 +3,7 @@ import { Hono } from "hono";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { VALID_CANVAS_RESOLUTIONS } from "../../core.types";
import { registerRenderRoutes } from "./render";
import type { StudioApiAdapter } from "../types";
@@ -98,8 +99,8 @@ describe("POST /projects/:id/render — outputResolution forwarding", () => {
}
});
it("accepts each of the four canonical preset values", async () => {
for (const preset of ["landscape", "portrait", "landscape-4k", "portrait-4k"] as const) {
it("accepts each canonical preset value", async () => {
for (const preset of VALID_CANVAS_RESOLUTIONS) {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
@@ -778,6 +778,17 @@ describe("resolveDeviceScaleFactor", () => {
).toBe(2);
});
it("returns 2 for square 1080p → square-4k", () => {
expect(
resolveDeviceScaleFactor({
...defaults,
compositionWidth: 1080,
compositionHeight: 1080,
outputResolution: "square-4k",
}),
).toBe(2);
});
it("returns 1 when the composition already matches the requested resolution", () => {
expect(
resolveDeviceScaleFactor({
@@ -815,6 +826,25 @@ describe("resolveDeviceScaleFactor", () => {
).toThrow(/aspect ratio/);
});
it("rejects square comp → non-square 4K presets", () => {
for (const outputResolution of ["landscape-4k", "portrait-4k"] as const) {
expect(() =>
resolveDeviceScaleFactor({
...defaults,
compositionWidth: 1080,
compositionHeight: 1080,
outputResolution,
}),
).toThrow(/aspect ratio/);
}
});
it("rejects landscape comp → square-4k", () => {
expect(() => resolveDeviceScaleFactor({ ...defaults, outputResolution: "square-4k" })).toThrow(
/aspect ratio/,
);
});
it("rejects downsampling (4K composition → 1080p output)", () => {
expect(() =>
resolveDeviceScaleFactor({
+12
View File
@@ -157,6 +157,12 @@ function getTimelineElementLabel(element: TimelineElement): string {
return element.label || element.id || element.tag;
}
function confirmElementDelete(label: string, kind: "timeline clip" | "element"): boolean {
return window.confirm(
`Delete ${kind} "${label}"?\n\nThis removes it from the project source. You can use Undo to restore it.`,
);
}
type RightPanelTab = "design" | "motion" | "renders";
const GENERIC_FONT_FAMILIES = new Set([
@@ -1780,6 +1786,8 @@ export function StudioApp() {
async (element: TimelineElement) => {
const pid = projectIdRef.current;
if (!pid) throw new Error("No active project");
const label = getTimelineElementLabel(element);
if (!confirmElementDelete(label, "timeline clip")) return;
const targetPath = element.sourceFile || activeCompPath || "index.html";
try {
@@ -1877,6 +1885,7 @@ export function StudioApp() {
);
usePlayerStore.getState().setSelectedElementId(null);
setRefreshKey((k) => k + 1);
showToast(`Deleted ${label}. Use Undo to restore it.`, "info");
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to delete timeline clip";
showToast(message);
@@ -1889,6 +1898,8 @@ export function StudioApp() {
async (selection: DomEditSelection) => {
const pid = projectIdRef.current;
if (!pid) return;
const label = selection.label || selection.id || selection.selector || selection.tagName;
if (!confirmElementDelete(label, "element")) return;
const targetPath = selection.sourceFile || activeCompPath || "index.html";
try {
@@ -1946,6 +1957,7 @@ export function StudioApp() {
setDomEditGroupSelections([]);
usePlayerStore.getState().setSelectedElementId(null);
setRefreshKey((k) => k + 1);
showToast(`Deleted ${label}. Use Undo to restore it.`, "info");
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to delete element";
showToast(message);
@@ -4,10 +4,8 @@ import {
buildStrokeWidthStyleUpdates,
getClipPathInsetPx,
getCssFilterFunctionPx,
getPropertyPanelVisibleSections,
inferBoxShadowPreset,
inferClipPathPreset,
isPropertyPanelMediaLikeSelection,
normalizePanelPxValue,
setCssFilterFunctionPx,
} from "./PropertyPanel";
@@ -66,51 +64,4 @@ describe("PropertyPanel style helpers", () => {
expect(buildStrokeStyleUpdates("none", "4px")).toEqual([["border-style", "none"]]);
expect(buildStrokeStyleUpdates("solid", "4px")).toEqual([["border-style", "solid"]]);
});
it("orders the simplified default inspector sections around high-confidence edits", () => {
expect(
getPropertyPanelVisibleSections({
hasSelection: true,
canEditStyles: true,
hasTextControls: true,
hasColorControls: true,
}),
).toEqual(["Text", "Layout", "Colors", "Radius", "Shadow"]);
expect(
getPropertyPanelVisibleSections({
hasSelection: true,
canEditStyles: true,
hasTextControls: false,
hasColorControls: false,
}),
).toEqual(["Layout", "Radius", "Shadow"]);
});
it("treats media tags and background-image layers as image-like controls", () => {
expect(
isPropertyPanelMediaLikeSelection({
tagName: "img",
styles: {},
}),
).toBe(true);
expect(
isPropertyPanelMediaLikeSelection({
tagName: "div",
styles: {
"background-image": "url(/assets/studio.png)",
},
}),
).toBe(true);
expect(
isPropertyPanelMediaLikeSelection({
tagName: "div",
styles: {
"background-image": "none",
},
}),
).toBe(false);
});
});
@@ -14,8 +14,14 @@ export interface RenderJob {
// Mirrors `CanvasResolution` from @hyperframes/core. Kept local because
// studio's tsconfig doesn't include node types, and the core barrel
// transitively pulls in modules with `node:fs` imports. Drift risk is
// low (4 string literals tied to a stable enum).
export type ResolutionPreset = "landscape" | "portrait" | "landscape-4k" | "portrait-4k";
// low (6 string literals kept in sync manually with CANVAS_DIMENSIONS).
export type ResolutionPreset =
| "landscape"
| "portrait"
| "landscape-4k"
| "portrait-4k"
| "square"
| "square-4k";
export interface StartRenderOptions {
fps?: number;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB