shrink imagies

This commit is contained in:
john 2025-05-26 22:05:52 +02:00
parent 8457604da7
commit a6022d31c6
9 changed files with 131 additions and 39 deletions

1
.env
View file

@ -1 +0,0 @@
VITE_API_URL=http://localhost:5181

2
.gitignore vendored
View file

@ -22,3 +22,5 @@ dist-ssr
*.njsproj
*.sln
*.sw?
.env

View file

@ -16,6 +16,7 @@
"@tailwindcss/vite": "^4.1.5",
"immer": "^10.1.1",
"openapi-fetch": "^0.14.0",
"pica": "^9.0.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.6.0",
@ -24,6 +25,7 @@
"devDependencies": {
"@eslint/js": "^9.22.0",
"@types/node": "^22.15.19",
"@types/pica": "^9.0.5",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@types/react-router-dom": "^5.3.3",

View file

@ -1,28 +0,0 @@
import os from 'node:os'
/**
* Get the private IP addr of the dev machine to use as the API url.
* This is preferred to using localhost or 0.0.0.0 because it allows
* us to use the dev client from other devices (i.e. phones)
* @returns {string}
*/
export function getLocalApiUrl() {
const addresses = Object.values(os.networkInterfaces())
.flat()
.filter((addr) => !addr.internal)
.filter((addr) => addr.family === 'IPv4')
.map((addr) => addr.address)
let address = addresses.find((addr) => addr.startsWith('192.168')) ?? addresses.at(0)
if (address === undefined) {
console.warn("Couldn't identify the local address for the server. falling back to localhost")
address = 'localhost'
}
if (addresses.length > 1) {
console.warn(`chose API URL ${address} from possible choices: ${addresses.join(', ')}`)
}
return `http://${address}:7295`
}

View file

@ -42,7 +42,7 @@ export default function HomePage({ postsService, mediaService }: HomePageProps)
try {
const media = await Promise.all(
files.map(async ({ file, width, height }) => {
const { mediaId, url } = await mediaService.uploadFile(file)
const { mediaId, url } = await mediaService.uploadImage(file)
return {
mediaId,

View file

@ -2,7 +2,7 @@ import { ApiClient } from '../api/client.ts'
export class MediaService {
constructor(private readonly client: ApiClient) {}
async uploadFile(file: File): Promise<{ mediaId: string; url: URL }> {
async uploadImage(file: File): Promise<{ mediaId: string; url: URL }> {
const body = new FormData()
body.append('file', file)

View file

@ -2,6 +2,7 @@ import { useState } from 'react'
import FancyTextEditor, { TextInputKeyDownEvent } from './inputs/FancyTextEditor.tsx'
import Button from './buttons/Button.tsx'
import { openFileDialog } from '../utils/openFileDialog.ts'
import makePica from 'pica'
interface NewPostWidgetProps {
onSubmit: (
@ -121,11 +122,13 @@ async function createAttachment(file: File): Promise<Attachment> {
throw new Error('not an image')
}
file = await optimizeImageSize(file)
const objectUrl = URL.createObjectURL(file)
const { width, height } = await getImageFileDimensions(objectUrl)
return {
id: crypto.randomUUID(),
id: getRandomId(),
file,
objectUrl,
width,
@ -144,3 +147,82 @@ function getImageFileDimensions(objectURL: string): Promise<{ width: number; hei
img.src = objectURL
})
}
const pica = makePica()
async function optimizeImageSize(
file: File,
{
targetMaxWidth = 1920,
targetMaxHeight = 1080,
targetSizeBytes = 500 * 1024,
outputType = 'image/jpeg',
quality = 0.9,
}: {
targetMaxWidth?: number
targetMaxHeight?: number
targetSizeBytes?: number
outputType?: string
quality?: number
} = {},
): Promise<File> {
const img = document.createElement('img')
const url = URL.createObjectURL(file)
img.src = url
await img.decode()
console.debug('processing image', {
width: img.width,
height: img.height,
targetMaxWidth,
targetMaxHeight,
targetSizeBytes,
outputType,
quality,
})
const scale = Math.min(1, targetMaxWidth / img.width, targetMaxHeight / img.height)
const width = Math.floor(img.width * scale)
const height = Math.floor(img.height * scale)
const srcCanvas = document.createElement('canvas')
srcCanvas.width = img.width
srcCanvas.height = img.height
srcCanvas.getContext('2d')!.drawImage(img, 0, 0)
const dstCanvas = document.createElement('canvas')
dstCanvas.width = width
dstCanvas.height = height
await pica.resize(srcCanvas, dstCanvas)
let blob = await pica.toBlob(dstCanvas, outputType, quality)
while (blob.size > targetSizeBytes && quality > 0.1) {
quality = parseFloat((quality - 0.1).toFixed(2))
blob = await pica.toBlob(dstCanvas, outputType, quality)
}
URL.revokeObjectURL(url)
return new File([blob], file.name, { type: file.type })
}
function getRandomId() {
if (window.isSecureContext) {
return crypto.randomUUID()
}
// Fallback using getRandomValues
const bytes = new Uint8Array(16)
crypto.getRandomValues(bytes)
// Format according to RFC4122 version 4
bytes[6] = (bytes[6]! & 0x0f) | 0x40
bytes[8] = (bytes[8]! & 0x3f) | 0x80
const hex = [...bytes].map((b) => b.toString(16).padStart(2, '0'))
return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}`
}

View file

@ -3,11 +3,13 @@ import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import fs from 'node:fs'
const packageJson = JSON.parse(fs.readFileSync('./package.json', 'utf-8'))
process.env.VITE_FEMTO_VERSION = packageJson.version
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
export default defineConfig(() => {
const packageJson = JSON.parse(fs.readFileSync('./package.json', 'utf-8'))
process.env.VITE_FEMTO_VERSION = packageJson.version
return {
plugins: [react(), tailwindcss()],
}
})

View file

@ -785,6 +785,11 @@
dependencies:
undici-types "~6.21.0"
"@types/pica@^9.0.5":
version "9.0.5"
resolved "https://registry.yarnpkg.com/@types/pica/-/pica-9.0.5.tgz#a526b51d45b7cb70423b7af0223ab9afd151a26e"
integrity sha512-OSd4905yxFNtRanHuyyQAfC9AkxiYcbhlzP606Gl6rFcYRgq4vdLCZuYKokLQBihgrkNzyPkoeykvJDWcPjaCw==
"@types/react-dom@^19.0.4":
version "19.1.3"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.1.3.tgz#3f0c60804441bf34d19f8dd0d44405c0c0e21bfa"
@ -1574,6 +1579,11 @@ globals@^16.0.0:
resolved "https://registry.yarnpkg.com/globals/-/globals-16.0.0.tgz#3d7684652c5c4fbd086ec82f9448214da49382d8"
integrity sha512-iInW14XItCXET01CQFqudPOWP2jYMl7T+QRQT+UNcR/iQncN/F0UNpgd76iFkBPgNQb4+X3LV9tLJYzwh+Gl3A==
glur@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/glur/-/glur-1.1.2.tgz#f20ea36db103bfc292343921f1f91e83c3467689"
integrity sha512-l+8esYHTKOx2G/Aao4lEQ0bnHWg4fWtJbVoZZT9Knxi01pB8C80BR85nONLFwkkQoFRCmXY+BUcGZN3yZ2QsRA==
gopd@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1"
@ -1922,6 +1932,14 @@ ms@^2.1.3:
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
multimath@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/multimath/-/multimath-2.0.0.tgz#0d37acf67c328f30e3d8c6b0d3209e6082710302"
integrity sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g==
dependencies:
glur "^1.1.2"
object-assign "^4.1.1"
nanoid@^3.3.8:
version "3.3.11"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b"
@ -1942,7 +1960,7 @@ node-releases@^2.0.19:
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.19.tgz#9e445a52950951ec4d177d843af370b411caf314"
integrity sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==
object-assign@^4:
object-assign@^4, object-assign@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
@ -2052,6 +2070,16 @@ path-to-regexp@^8.0.0:
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.2.0.tgz#73990cc29e57a3ff2a0d914095156df5db79e8b4"
integrity sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==
pica@^9.0.1:
version "9.0.1"
resolved "https://registry.yarnpkg.com/pica/-/pica-9.0.1.tgz#9ba5a5e81fc09dca9800abef9fb8388434b18b2f"
integrity sha512-v0U4vY6Z3ztz9b4jBIhCD3WYoecGXCQeCsYep+sXRefViL+mVVoTL+wqzdPeE+GpBFsRUtQZb6dltvAt2UkMtQ==
dependencies:
glur "^1.1.2"
multimath "^2.0.0"
object-assign "^4.1.1"
webworkify "^1.5.0"
picocolors@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
@ -2495,6 +2523,11 @@ vite@^6.3.1:
optionalDependencies:
fsevents "~2.3.3"
webworkify@^1.5.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/webworkify/-/webworkify-1.5.0.tgz#734ad87a774de6ebdd546e1d3e027da5b8f4a42c"
integrity sha512-AMcUeyXAhbACL8S2hqqdqOLqvJ8ylmIbNwUIqQujRSouf4+eUFaXbG6F1Rbu+srlJMmxQWsiU7mOJi0nMBfM1g==
which@^2.0.1:
version "2.0.2"
resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1"