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 docs/plans/v1-prepare-image-layout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# V1 Prepare Image Layout

## Context

V1 needs one reliable command: `prepare-image <input> --out <file-path> [--border-px <integer>]`.
Before wiring FFmpeg, the layout math and output path rules need to be isolated and tested because they define the product behavior.

## Decisions

- Added `computePrepareImageLayout` as a pure domain function.
- Landscape sources use a `3:2` canvas capped at `2160x1440`, equal outer border, and centered cover crop.
- Portrait and square sources use a `3:4` canvas capped at `1440x1920`, centered contain fit, and no source crop.
- Small inputs shrink to the largest same-ratio canvas that does not require source upscaling.
- Configured borders scale from the full target size and round to the nearest integer, with a minimum of `1px` when the configured border is greater than `0`.
- Added `resolvePrepareImageOutputPath` for `.jpg` normalization, parent directory creation, directory rejection, and readable suffixing.

## Validation

- `bun test tests/prepare_image_layout.test.ts tests/output_path.test.ts`
36 changes: 36 additions & 0 deletions src/domain/output_path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { existsSync, mkdirSync, statSync } from "node:fs";
import { dirname, extname, join, parse } from "node:path";

export function resolvePrepareImageOutputPath(requestedPath: string): string {
if (requestedPath.trim() === "") {
throw new Error("--out must be a file path");
}

if (existsSync(requestedPath) && statSync(requestedPath).isDirectory()) {
throw new Error("--out must be a file path, not a directory");
}

const normalizedPath = normalizeJpegExtension(requestedPath);
mkdirSync(dirname(normalizedPath), { recursive: true });

if (!existsSync(normalizedPath)) {
return normalizedPath;
}

const parsed = parse(normalizedPath);
for (let index = 1; ; index += 1) {
const candidate = join(parsed.dir, `${parsed.name}-${index}.jpg`);
if (!existsSync(candidate)) {
return candidate;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

function normalizeJpegExtension(requestedPath: string): string {
const extension = extname(requestedPath);
if (extension === "") {
return `${requestedPath}.jpg`;
}
Comment on lines +31 to +33

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject directory-like --out paths before adding .jpg

When requestedPath ends with a path separator and the directory does not exist yet (for example --out exports/), this branch treats it as an extensionless file path and returns exports/.jpg instead of rejecting it as a directory target. That creates a hidden file and violates the CLI contract that --out must be a file path; users who pass a directory path by mistake will get surprising output placement rather than a clear error.

Useful? React with 👍 / 👎.


return `${requestedPath.slice(0, -extension.length)}.jpg`;
}
200 changes: 200 additions & 0 deletions src/domain/prepare_image_layout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
export type PrepareImageVariant = "landscape" | "portrait";

export type SourceCrop = {
x: number;
y: number;
width: number;
height: number;
};

export type PrepareImageLayout = {
variant: PrepareImageVariant;
outputWidth: number;
outputHeight: number;
effectiveBorderPx: number;
innerWidth: number;
innerHeight: number;
renderWidth: number;
renderHeight: number;
renderOffsetX: number;
renderOffsetY: number;
sourceCrop?: SourceCrop;
};

type PrepareImageLayoutInput = {
sourceWidth: number;
sourceHeight: number;
borderPx: number;
};

type Target = {
variant: PrepareImageVariant;
maxWidth: number;
maxHeight: number;
ratioWidth: number;
ratioHeight: number;
};

const LANDSCAPE_TARGET = {
maxHeight: 1440,
maxWidth: 2160,
ratioHeight: 2,
ratioWidth: 3,
variant: "landscape",
} as const satisfies Target;

const PORTRAIT_TARGET = {
maxHeight: 1920,
maxWidth: 1440,
ratioHeight: 4,
ratioWidth: 3,
variant: "portrait",
} as const satisfies Target;

export function computePrepareImageLayout(input: PrepareImageLayoutInput): PrepareImageLayout {
assertPositiveInteger(input.sourceWidth, "sourceWidth");
assertPositiveInteger(input.sourceHeight, "sourceHeight");
assertNonNegativeInteger(input.borderPx, "borderPx");

const target = input.sourceWidth > input.sourceHeight ? LANDSCAPE_TARGET : PORTRAIT_TARGET;

for (let outputWidth = target.maxWidth; outputWidth >= target.ratioWidth; outputWidth -= target.ratioWidth) {
const outputHeight = (outputWidth / target.ratioWidth) * target.ratioHeight;
if (outputHeight > target.maxHeight) {
continue;
}

const effectiveBorderPx = scaleBorder(input.borderPx, outputWidth / target.maxWidth);
const innerWidth = outputWidth - 2 * effectiveBorderPx;
const innerHeight = outputHeight - 2 * effectiveBorderPx;
if (innerWidth <= 0 || innerHeight <= 0) {
continue;
}

if (target.variant === "landscape") {
if (innerWidth > input.sourceWidth || innerHeight > input.sourceHeight) {
continue;
}

return createLandscapeLayout(input, {
effectiveBorderPx,
innerHeight,
innerWidth,
outputHeight,
outputWidth,
});
}

if (Math.min(innerWidth / input.sourceWidth, innerHeight / input.sourceHeight) > 1) {
continue;
}

return createPortraitLayout(input, {
effectiveBorderPx,
innerHeight,
innerWidth,
outputHeight,
outputWidth,
});
}

throw new Error("Source image is too small for the requested border");
}

function createLandscapeLayout(
input: PrepareImageLayoutInput,
dimensions: Pick<
PrepareImageLayout,
"effectiveBorderPx" | "innerHeight" | "innerWidth" | "outputHeight" | "outputWidth"
>,
): PrepareImageLayout {
const crop = computeCenteredCoverCrop({
frameHeight: dimensions.innerHeight,
frameWidth: dimensions.innerWidth,
sourceHeight: input.sourceHeight,
sourceWidth: input.sourceWidth,
});

return {
...dimensions,
renderHeight: dimensions.innerHeight,
renderOffsetX: dimensions.effectiveBorderPx,
renderOffsetY: dimensions.effectiveBorderPx,
renderWidth: dimensions.innerWidth,
sourceCrop: crop,
variant: "landscape",
};
}

function createPortraitLayout(
input: PrepareImageLayoutInput,
dimensions: Pick<
PrepareImageLayout,
"effectiveBorderPx" | "innerHeight" | "innerWidth" | "outputHeight" | "outputWidth"
>,
): PrepareImageLayout {
const renderScale = Math.min(
1,
dimensions.innerWidth / input.sourceWidth,
dimensions.innerHeight / input.sourceHeight,
);
const renderWidth = Math.min(dimensions.innerWidth, Math.round(input.sourceWidth * renderScale));
const renderHeight = Math.min(dimensions.innerHeight, Math.round(input.sourceHeight * renderScale));

return {
...dimensions,
renderHeight,
renderOffsetX: Math.round((dimensions.outputWidth - renderWidth) / 2),
renderOffsetY: Math.round((dimensions.outputHeight - renderHeight) / 2),
renderWidth,
variant: "portrait",
};
}

function computeCenteredCoverCrop(input: {
sourceWidth: number;
sourceHeight: number;
frameWidth: number;
frameHeight: number;
}): SourceCrop {
const sourceRatio = input.sourceWidth / input.sourceHeight;
const frameRatio = input.frameWidth / input.frameHeight;

if (sourceRatio > frameRatio) {
const width = Math.round(input.sourceHeight * frameRatio);
return {
height: input.sourceHeight,
width,
x: Math.round((input.sourceWidth - width) / 2),
y: 0,
};
}

const height = Math.round(input.sourceWidth / frameRatio);
return {
height,
width: input.sourceWidth,
x: 0,
y: Math.round((input.sourceHeight - height) / 2),
};
}

function scaleBorder(borderPx: number, scale: number): number {
if (borderPx === 0) {
return 0;
}

return Math.max(1, Math.round(borderPx * scale));
}

function assertPositiveInteger(value: number, name: string): void {
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer`);
}
}

function assertNonNegativeInteger(value: number, name: string): void {
if (!Number.isInteger(value) || value < 0) {
throw new Error(`${name} must be a non-negative integer`);
}
}
36 changes: 36 additions & 0 deletions tests/output_path.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, test } from "bun:test";
import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { resolvePrepareImageOutputPath } from "../src/domain/output_path";

describe("prepare image output path", () => {
test("adds .jpg when no extension is provided and creates parents", () => {
const dir = mkdtempSync(join(tmpdir(), "passepartout-output-path-"));
const outputPath = resolvePrepareImageOutputPath(join(dir, "exports", "photo"));

expect(outputPath).toBe(join(dir, "exports", "photo.jpg"));
expect(existsSync(join(dir, "exports"))).toBe(true);
});

test("replaces non-jpeg extensions with .jpg", () => {
const dir = mkdtempSync(join(tmpdir(), "passepartout-output-path-"));

expect(resolvePrepareImageOutputPath(join(dir, "photo.png"))).toBe(join(dir, "photo.jpg"));
expect(resolvePrepareImageOutputPath(join(dir, "photo.jpeg"))).toBe(join(dir, "photo.jpg"));
});

test("rejects existing directories", () => {
const dir = mkdtempSync(join(tmpdir(), "passepartout-output-path-"));

expect(() => resolvePrepareImageOutputPath(dir)).toThrow("--out must be a file path, not a directory");
});

test("suffixes existing files without overwriting", () => {
const dir = mkdtempSync(join(tmpdir(), "passepartout-output-path-"));
writeFileSync(join(dir, "photo.jpg"), "");
writeFileSync(join(dir, "photo-1.jpg"), "");

expect(resolvePrepareImageOutputPath(join(dir, "photo.jpg"))).toBe(join(dir, "photo-2.jpg"));
});
});
Loading
Loading