Repository navigation
feat: add prepare image layout math #57
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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` |
| 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; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| function normalizeJpegExtension(requestedPath: string): string { | ||
| const extension = extname(requestedPath); | ||
| if (extension === "") { | ||
| return `${requestedPath}.jpg`; | ||
| } | ||
|
Comment on lines
+31
to
+33
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
|
|
||
| return `${requestedPath.slice(0, -extension.length)}.jpg`; | ||
| } | ||
| 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`); | ||
| } | ||
| } |
| 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")); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.