Skip to content

Commit f571f11

Browse files
Added custom aspect ratio option
1 parent 00ecd19 commit f571f11

File tree

3 files changed

+192
-135
lines changed

3 files changed

+192
-135
lines changed

src/app.tsx

Lines changed: 106 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,46 @@
1-
import { useEffect, useRef, useState } from "preact/hooks";
2-
import Header from "./components/Header";
3-
import OptionsDisplay from "./components/OptionsDisplay";
4-
import WebFont from "webfontloader";
1+
import { useEffect, useRef, useState } from 'preact/hooks';
2+
import Header from './components/Header';
3+
import OptionsDisplay from './components/OptionsDisplay';
4+
import WebFont from 'webfontloader';
55

6-
export type TextAlign = "left" | "center" | "right";
7-
export type TextBaseLine = "top" | "middle" | "bottom";
6+
export type TextAlign = 'left' | 'center' | 'right';
7+
export type TextBaseLine = 'top' | 'middle' | 'bottom';
88

99
const hexToRgb = (hex: string): [number, number, number] => {
1010
const match = hex.match(/^#([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i);
1111
if (!match) return [0, 0, 0];
12-
return [
13-
parseInt(match[1], 16),
14-
parseInt(match[2], 16),
15-
parseInt(match[3], 16),
16-
];
12+
return [parseInt(match[1], 16), parseInt(match[2], 16), parseInt(match[3], 16)];
13+
};
14+
15+
const MAX_CANVAS_WIDTH = 1000;
16+
const MAX_CANVAS_HEIGHT = 500;
17+
18+
const getCanvasSizeFromCustomAspectRatio = (aw: number, ah: number) => {
19+
if (aw <= 0 || ah <= 0) return { width: MAX_CANVAS_WIDTH, height: MAX_CANVAS_HEIGHT };
20+
const scale = Math.min(MAX_CANVAS_WIDTH / aw, MAX_CANVAS_HEIGHT / ah);
21+
return {
22+
width: Math.round(aw * scale),
23+
height: Math.round(ah * scale),
24+
};
25+
};
26+
27+
const getDefaultFontSizeFromCustomAspectRatio = (
28+
aspectRatioWidth: number,
29+
aspectRatioHeight: number
30+
) => {
31+
const aspectRatio = aspectRatioWidth / aspectRatioHeight;
32+
if (aspectRatio >= 1.5) return 120; // landscape
33+
if (aspectRatio <= 0.7) return 50; // portrait
34+
return 80; // square-ish
1735
};
1836

1937
export default function App() {
2038
const DEFAULT_TEXT_SIZE = 120;
2139
const DEFAULT_BG_DIM = 0.4;
2240
const DEFAULT_TEXT_PADDING = 0.05;
2341

24-
const camvasSizes: Record<
25-
"cover" | "poster",
42+
const canvasSizes: Record<
43+
'cover' | 'poster',
2644
{ width: number; height: number; defaultFontSize: number }
2745
> = {
2846
cover: {
@@ -38,21 +56,47 @@ export default function App() {
3856
};
3957

4058
const canvasRef = useRef<HTMLCanvasElement>(null);
41-
const [title, setTitle] = useState("Movies");
59+
const [title, setTitle] = useState('Movies');
4260
const [image, setImage] = useState<HTMLImageElement | null>(null);
4361
const [textSize, setTextSize] = useState(DEFAULT_TEXT_SIZE);
4462
const [bgDim, setBgDim] = useState(DEFAULT_BG_DIM);
45-
const [fontName, setFontName] = useState("Montserrat");
46-
const [imageType, setImageType] = useState<"cover" | "poster">("cover");
47-
const [textColor, setTextColor] = useState("#ffffff");
48-
const [dimColor, setDimColor] = useState("#000000");
49-
const [textAlign, setTextAlign] = useState<TextAlign>("center");
50-
const [textBaseline, setTextBaseline] = useState<TextBaseLine>("middle");
63+
const [fontName, setFontName] = useState('Montserrat');
64+
const [imageType, setImageType] = useState<'cover' | 'poster' | 'custom'>('cover');
65+
const [customAspectRatioWidth, setCustomAspectRatioWidth] = useState(0.75);
66+
const [customAspectRatioHeight, setCustomAspectRatioHeight] = useState(1);
67+
const [textColor, setTextColor] = useState('#ffffff');
68+
const [dimColor, setDimColor] = useState('#000000');
69+
const [textAlign, setTextAlign] = useState<TextAlign>('center');
70+
const [textBaseline, setTextBaseline] = useState<TextBaseLine>('middle');
5171
const [textPadding, setTextPadding] = useState(DEFAULT_TEXT_PADDING);
5272

53-
const getCanvasWidth = () => camvasSizes[imageType].width;
54-
const getCanvasHeight = () => camvasSizes[imageType].height;
55-
const getDefaultFontSize = () => camvasSizes[imageType].defaultFontSize;
73+
const getCanvasWidth = () => {
74+
if (imageType === 'custom')
75+
return getCanvasSizeFromCustomAspectRatio(
76+
customAspectRatioWidth,
77+
customAspectRatioHeight
78+
).width;
79+
return canvasSizes[imageType].width;
80+
};
81+
82+
const getCanvasHeight = () => {
83+
if (imageType === 'custom')
84+
return getCanvasSizeFromCustomAspectRatio(
85+
customAspectRatioWidth,
86+
customAspectRatioHeight
87+
).height;
88+
return canvasSizes[imageType].height;
89+
};
90+
91+
const getDefaultFontSize = () => {
92+
if (imageType === 'custom') {
93+
return getDefaultFontSizeFromCustomAspectRatio(
94+
customAspectRatioWidth,
95+
customAspectRatioHeight
96+
);
97+
}
98+
return canvasSizes[imageType].defaultFontSize;
99+
};
56100

57101
const handleImageUpload = (e: Event) => {
58102
const input = e.target as HTMLInputElement;
@@ -77,19 +121,18 @@ export default function App() {
77121
ctx.textAlign = align;
78122

79123
let x: number = getCanvasWidth() / 2;
80-
if (align === "left") x = maxWidth * textPadding;
81-
else if (align === "right")
82-
x = getCanvasWidth() - maxWidth * textPadding;
124+
if (align === 'left') x = maxWidth * textPadding;
125+
else if (align === 'right') x = getCanvasWidth() - maxWidth * textPadding;
83126

84-
const words = text.split(" ");
127+
const words = text.split(' ');
85128
const lines: string[] = [];
86129
let currentLine = words[0];
87130

88131
for (let i = 1; i < words.length; i++) {
89132
const word = words[i];
90-
const width = ctx.measureText(currentLine + " " + word).width;
133+
const width = ctx.measureText(currentLine + ' ' + word).width;
91134
if (width < maxWidth) {
92-
currentLine += " " + word;
135+
currentLine += ' ' + word;
93136
} else {
94137
lines.push(currentLine);
95138
currentLine = word;
@@ -101,15 +144,15 @@ export default function App() {
101144

102145
const padding = getCanvasHeight() * textPadding;
103146
let y: number;
104-
if (baseline === "top") {
147+
if (baseline === 'top') {
105148
y = padding;
106-
ctx.textBaseline = "top";
107-
} else if (baseline === "middle") {
149+
ctx.textBaseline = 'top';
150+
} else if (baseline === 'middle') {
108151
y = getCanvasHeight() / 2 - totalHeight / 2;
109-
ctx.textBaseline = "top";
152+
ctx.textBaseline = 'top';
110153
} else {
111154
y = getCanvasHeight() - totalHeight - padding;
112-
ctx.textBaseline = "top";
155+
ctx.textBaseline = 'top';
113156
}
114157

115158
for (const line of lines) {
@@ -119,9 +162,12 @@ export default function App() {
119162
};
120163

121164
const drawCanvas = (img: HTMLImageElement, titleText: string) => {
165+
console.log('Custom Aspect Ratio:', customAspectRatioWidth, ':', customAspectRatioHeight);
166+
console.log('Canvas Size:', getCanvasWidth(), 'x', getCanvasHeight());
167+
122168
const canvas = canvasRef.current;
123169
if (!canvas) return;
124-
const ctx = canvas.getContext("2d");
170+
const ctx = canvas.getContext('2d');
125171
if (!ctx) return;
126172

127173
canvas.width = getCanvasWidth();
@@ -181,18 +227,18 @@ export default function App() {
181227

182228
const formatTitleForFileName = (title: string) => {
183229
return title
184-
.replace(/[^a-zA-Z0-9\s]/g, "")
185-
.replace(/\s+/g, "-")
230+
.replace(/[^a-zA-Z0-9\s]/g, '')
231+
.replace(/\s+/g, '-')
186232
.toLowerCase();
187233
};
188234

189235
const downloadImage = () => {
190236
const canvas = canvasRef.current;
191237
if (!canvas) return;
192238

193-
const link = document.createElement("a");
239+
const link = document.createElement('a');
194240
link.download = `jellyfin-cover-${formatTitleForFileName(title)}.png`;
195-
link.href = canvas.toDataURL("image/png");
241+
link.href = canvas.toDataURL('image/png');
196242
link.click();
197243
};
198244

@@ -212,13 +258,15 @@ export default function App() {
212258
textAlign,
213259
textBaseline,
214260
textPadding,
261+
customAspectRatioWidth,
262+
customAspectRatioHeight,
215263
]);
216264

217265
useEffect(() => {
218266
if (!fontName) return;
219267
WebFont.load({
220268
google: {
221-
families: [fontName + ":400,700"],
269+
families: [fontName + ':400,700'],
222270
},
223271
active: () => {
224272
if (image) {
@@ -233,12 +281,19 @@ export default function App() {
233281
defaultImg.onload = () => {
234282
setImage(defaultImg);
235283
};
236-
defaultImg.src = "/default-bg.webp";
284+
defaultImg.src = '/default-bg.webp';
237285
}, []);
238286

239-
const handleImageTypeChange = (type: "cover" | "poster") => {
287+
const handleImageTypeChange = (type: 'cover' | 'poster' | 'custom') => {
240288
setImageType(type);
241-
setTextSize(camvasSizes[type].defaultFontSize);
289+
setTextSize(
290+
type === 'custom'
291+
? getDefaultFontSizeFromCustomAspectRatio(
292+
customAspectRatioWidth,
293+
customAspectRatioHeight
294+
)
295+
: canvasSizes[type].defaultFontSize
296+
);
242297
};
243298

244299
return (
@@ -254,6 +309,10 @@ export default function App() {
254309
setImage={handleImageUpload}
255310
imageType={imageType}
256311
setImageType={handleImageTypeChange}
312+
customAspectRatioWidth={customAspectRatioWidth}
313+
setCustomAspectRatioWidth={setCustomAspectRatioWidth}
314+
customAspectRatioHeight={customAspectRatioHeight}
315+
setCustomAspectRatioHeight={setCustomAspectRatioHeight}
257316
bgDim={bgDim}
258317
setBgDim={setBgDim}
259318
defaultBgDim={DEFAULT_BG_DIM}
@@ -275,15 +334,14 @@ export default function App() {
275334

276335
<div
277336
className={
278-
"flex items-center justify-center grow " +
279-
(imageType === "poster" ? "flex-col" : "")
337+
'flex items-center justify-center grow max-h-[500px] ' +
338+
(imageType === 'poster' ? 'flex-col' : '')
280339
}
281340
>
282341
<canvas
283-
className={
284-
"rounded-md border border-input border-solid grow"
285-
}
342+
className={'rounded-md border border-input border-solid'}
286343
style={{
344+
maxHeight: '500px',
287345
aspectRatio: `${getCanvasWidth()} / ${getCanvasHeight()}`,
288346
}}
289347
ref={canvasRef}

src/components/ImageTypeSelect.tsx

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,26 @@
1-
import { RectangleHorizontal, RectangleVertical } from "lucide-preact";
2-
import type { FC } from "preact/compat";
1+
import { RectangleHorizontal, RectangleVertical } from 'lucide-preact';
2+
import type { FC } from 'preact/compat';
33

44
interface ImageTypeSelectProps {
5-
value: "cover" | "poster";
6-
onChange: (value: "cover" | "poster") => void;
5+
value: 'cover' | 'poster' | 'custom';
6+
onChange: (value: 'cover' | 'poster' | 'custom') => void;
77
}
88

99
const SelectOption: FC<{
1010
value: string;
1111
selected: boolean;
1212
onClick: () => void;
1313
}> = ({ value, selected, onClick }) => {
14-
const selectedClasses = "border-2 border-primary";
14+
const selectedClasses = 'border-2 border-primary';
1515

1616
return (
1717
<button
1818
onClick={onClick}
1919
className={`card grow flex flex-col items-center justify-center gap-1 ${
20-
selected ? selectedClasses : ""
20+
selected ? selectedClasses : ''
2121
}`}
2222
>
23-
{value === "poster" ? (
24-
<RectangleVertical />
25-
) : (
26-
<RectangleHorizontal />
27-
)}
23+
{value === 'poster' ? <RectangleVertical /> : <RectangleHorizontal />}
2824
<span>{value.charAt(0).toUpperCase() + value.slice(1)}</span>
2925
</button>
3026
);
@@ -33,13 +29,18 @@ const ImageTypeSelect: FC<ImageTypeSelectProps> = ({ value, onChange }) => (
3329
<div className="flex items-center gap-2">
3430
<SelectOption
3531
value="cover"
36-
selected={value === "cover"}
37-
onClick={() => onChange("cover")}
32+
selected={value === 'cover'}
33+
onClick={() => onChange('cover')}
3834
/>
3935
<SelectOption
4036
value="poster"
41-
selected={value === "poster"}
42-
onClick={() => onChange("poster")}
37+
selected={value === 'poster'}
38+
onClick={() => onChange('poster')}
39+
/>
40+
<SelectOption
41+
value="custom"
42+
selected={value === 'custom'}
43+
onClick={() => onChange('custom')}
4344
/>
4445
</div>
4546
);

0 commit comments

Comments
 (0)