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
4 changes: 2 additions & 2 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -630,7 +630,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. Default: keep template dimensions (typically 1920x1080).",
},
},
async run({ args }) {
Expand Down Expand Up @@ -670,7 +670,7 @@ 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).`,
),
);
process.exit(1);
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,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. 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 }) {
Expand Down Expand Up @@ -263,7 +263,7 @@ 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).`,
);
process.exit(1);
}
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/core.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,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
Expand All @@ -172,6 +174,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",
};

/**
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -18,6 +20,8 @@ describe("@hyperframes/core public API exports", () => {
"portrait",
"landscape-4k",
"portrait-4k",
"square",
"square-4k",
]);
});

Expand All @@ -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();
});
Expand Down
22 changes: 17 additions & 5 deletions packages/core/src/parsers/htmlParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,10 +275,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>
Expand All @@ -290,7 +287,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", () => {
Expand Down
24 changes: 13 additions & 11 deletions packages/core/src/parsers/htmlParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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 (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="..."` 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";
Expand Down
33 changes: 33 additions & 0 deletions packages/producer/src/services/renderOrchestrator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -839,4 +839,37 @@ describe("resolveDeviceScaleFactor", () => {
}),
).toThrow(/aspect ratio|non-integer/);
});

it("returns 1 for a square comp matching the square preset", () => {
expect(
resolveDeviceScaleFactor({
...defaults,
compositionWidth: 1080,
compositionHeight: 1080,
outputResolution: "square",
}),
).toBe(1);
});

it("returns 2 for square 1080 → square-4k", () => {
expect(
resolveDeviceScaleFactor({
...defaults,
compositionWidth: 1080,
compositionHeight: 1080,
outputResolution: "square-4k",
}),
).toBe(2);
});

it("rejects landscape preset on a square composition", () => {
expect(() =>
resolveDeviceScaleFactor({
...defaults,
compositionWidth: 1080,
compositionHeight: 1080,
outputResolution: "landscape",
}),
).toThrow(/aspect ratio/);
});
});
25 changes: 24 additions & 1 deletion packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { useMountEffect } from "./hooks/useMountEffect";
import { NLELayout } from "./components/nle/NLELayout";
import { SourceEditor } from "./components/editor/SourceEditor";
import { LeftSidebar } from "./components/sidebar/LeftSidebar";
import { RenderQueue } from "./components/renders/RenderQueue";
import { RenderQueue, type CompositionDimensions } from "./components/renders/RenderQueue";
import { useRenderQueue } from "./components/renders/useRenderQueue";
import { CompositionThumbnail, VideoThumbnail, liveTime, usePlayerStore } from "./player";
import { AudioWaveform } from "./player/components/AudioWaveform";
Expand Down Expand Up @@ -276,6 +276,28 @@ export function StudioApp() {
setRightCollapsed(!captionHasSelection);
}
}, [captionHasSelection, captionEditMode]);

// Track the active composition's authored dimensions so the render
// dropdown can derive landscape vs portrait. The runtime emits
// `stage-size` after `applyCompositionSizing` resolves the authoritative
// dims, so we use that instead of re-parsing the iframe DOM.
const [compositionDimensions, setCompositionDimensions] = useState<CompositionDimensions | null>(
null,
);
useMountEffect(() => {
const handleMessage = (e: MessageEvent) => {
const data = e.data;
if (data?.source !== "hf-preview" || data?.type !== "stage-size") return;
const { width, height } = data as { width: number; height: number };
if (!(width > 0) || !(height > 0)) return;
setCompositionDimensions((prev) =>
prev && prev.width === width && prev.height === height ? prev : { width, height },
);
};
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
});

const [globalDragOver, setGlobalDragOver] = useState(false);
const [appToast, setAppToast] = useState<AppToast | null>(null);
const [timelineVisible, setTimelineVisible] = useState(true);
Expand Down Expand Up @@ -1687,6 +1709,7 @@ export function StudioApp() {
onStartRender={(format, quality, resolution) =>
renderQueue.startRender({ format, quality, resolution })
}
compositionDimensions={compositionDimensions}
isRendering={renderQueue.isRendering}
/>
)}
Expand Down
Loading
Loading