Files
ChatApp/apps/desktop/src/lib/bannerUpload.ts
T
byGalax 825160ee46 feat(desktop): port v0.11.4-v0.15.2 from Tauri to Electron + Discord-parity audio (v0.16.0)
Chronological port of every Tauri release commit (v0.11.4 -> v0.15.2)
into the Electron rebuild, plus Discord-style audio handling that goes
beyond the Tauri original.

Highlights:
  - All 17 Tauri release commits ported (audio fixes, custom notification
    sound, Discord-style chat UX, profile banner, changelog page,
    Discord-parity call UX, screen-share echo + re-watch UX, audio-loop
    fix).
  - Native napi-rs audio-loopback addon with WASAPI process-loopback:
      * EXCLUDE_TARGET_PROCESS_TREE for full-screen shares -> peers
        never hear themselves echoed back through the capture.
      * INCLUDE_TARGET_PROCESS_TREE for window shares -> only the
        picked window's audio is captured, not the whole OS mixer
        (Discord parity).
      * HWND -> PID resolution via Win32 GetWindowThreadProcessId.
  - Discord-style screen-source picker (thumbnail grid, screens vs
    apps tabs, live-refreshing thumbnails).
  - Hash routing fix for packaged builds (file:// can't resolve
    BrowserRouter paths).
  - Tauri sources removed (apps/desktop/src-tauri).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 23:35:01 +02:00

112 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { supabase } from './supabase';
const BUCKET = 'profile-banners';
// 3:1 hero crop. 1500x500 hits the sweet spot between crisp on a wide
// settings preview and a payload that stays well under the 8 MB pre-encode
// budget we accept from the user (post-WebP that's typically ~150300 KB).
const TARGET_WIDTH = 1500;
const TARGET_HEIGHT = 500;
const QUALITY = 0.82;
const MAX_INPUT_BYTES = 8 * 1024 * 1024;
// Resizes the source image to a centred 3:1 crop at TARGET_WIDTH x TARGET_HEIGHT
// and re-encodes as WebP (JPEG fallback if WebP encode unsupported).
async function resizeToBanner(file: File): Promise<Blob> {
const url = URL.createObjectURL(file);
try {
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
const i = new Image();
i.onload = () => resolve(i);
i.onerror = () => reject(new Error('image load failed'));
i.src = url;
});
const srcRatio = img.naturalWidth / img.naturalHeight;
const targetRatio = TARGET_WIDTH / TARGET_HEIGHT;
let sx = 0;
let sy = 0;
let sw = img.naturalWidth;
let sh = img.naturalHeight;
if (srcRatio > targetRatio) {
// Source is wider than 3:1 — crop horizontally, keep full height.
sw = Math.round(img.naturalHeight * targetRatio);
sx = Math.round((img.naturalWidth - sw) / 2);
} else if (srcRatio < targetRatio) {
// Source is taller than 3:1 — crop vertically, keep full width.
sh = Math.round(img.naturalWidth / targetRatio);
sy = Math.round((img.naturalHeight - sh) / 2);
}
// Don't upscale — if the source crop is smaller than the target, render
// at the source crop size so we don't waste bytes on synthetic detail.
const outWidth = Math.min(TARGET_WIDTH, sw);
const outHeight = Math.min(TARGET_HEIGHT, sh);
const canvas = document.createElement('canvas');
canvas.width = outWidth;
canvas.height = outHeight;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('canvas context unavailable');
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, outWidth, outHeight);
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, 'image/webp', QUALITY),
);
if (blob) return blob;
const jpeg = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, 'image/jpeg', QUALITY),
);
if (!jpeg) throw new Error('canvas toBlob returned null');
return jpeg;
} finally {
URL.revokeObjectURL(url);
}
}
export async function uploadBanner(userId: string, file: File): Promise<string> {
if (!file.type.startsWith('image/')) {
throw new Error('only image files are accepted');
}
if (file.size > MAX_INPUT_BYTES) {
throw new Error('image must be 8 MB or smaller');
}
const blob = await resizeToBanner(file);
return uploadBannerBlob(userId, blob);
}
// Upload an already-cropped Blob (e.g. from ImageCropDialog). Skips the
// legacy center-crop path so the user-chosen framing is preserved.
export async function uploadBannerBlob(userId: string, blob: Blob): Promise<string> {
const ext = blob.type === 'image/webp' ? 'webp' : 'jpg';
// Random filename so old uploads don't get overwritten before we update
// the profile row — Supabase Storage CDN caches by URL, so a fresh path
// also forces clients to fetch the new image.
const name =
userId + '/' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8) + '.' + ext;
const { error: upErr } = await supabase.storage.from(BUCKET).upload(name, blob, {
contentType: blob.type,
cacheControl: '604800',
upsert: false,
});
if (upErr) throw upErr;
const { data: pub } = supabase.storage.from(BUCKET).getPublicUrl(name);
return pub.publicUrl;
}
export const BANNER_TARGET_WIDTH = TARGET_WIDTH;
export const BANNER_TARGET_HEIGHT = TARGET_HEIGHT;
export const BANNER_MAX_INPUT_BYTES = MAX_INPUT_BYTES;
export async function deleteBannerObject(publicUrl: string): Promise<void> {
// Public URLs look like
// https://<host>/storage/v1/object/public/profile-banners/<path>
// Extract <path> and remove.
const marker = '/object/public/' + BUCKET + '/';
const idx = publicUrl.indexOf(marker);
if (idx === -1) return;
const path = publicUrl.slice(idx + marker.length);
const { error } = await supabase.storage.from(BUCKET).remove([path]);
if (error) throw error;
}