diff --git a/src/lib/atproto/settings.ts b/src/lib/atproto/settings.ts index 40faed7..c79ecf5 100644 --- a/src/lib/atproto/settings.ts +++ b/src/lib/atproto/settings.ts @@ -13,7 +13,7 @@ export const permissions = { // example: only allow create and delete // collections: ['xyz.statusphere.status?action=create&action=update'], - collections: ['pics.atmo.image'], + collections: ['pics.atmo.image', 'pics.atmo.video'], // what types of authenticated proxied requests you can make to services @@ -27,7 +27,7 @@ export const permissions = { // blobs: ['video/*', 'text/html'] // example: allowing all blob types // blobs: ['*/*'] - blobs: ['image/*'] + blobs: ['image/*', 'video/*'] } as const satisfies Permissions; // Extract base collection name (before any query params) diff --git a/src/lib/atproto/video-helper.ts b/src/lib/atproto/video-helper.ts new file mode 100644 index 0000000..8360a73 --- /dev/null +++ b/src/lib/atproto/video-helper.ts @@ -0,0 +1,84 @@ +/** + * Extracts width and height from a video file by loading it into a temporary video element. + */ +export function getVideoAspectRatio( + file: File | Blob +): Promise<{ width: number; height: number }> { + return new Promise((resolve, reject) => { + const video = document.createElement('video'); + video.preload = 'metadata'; + + const url = URL.createObjectURL(file); + video.src = url; + + video.onloadedmetadata = () => { + URL.revokeObjectURL(url); + resolve({ width: video.videoWidth, height: video.videoHeight }); + }; + + video.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error('Failed to load video metadata')); + }; + }); +} + +/** + * Generates a thumbnail from the first frame of a video file. + * Returns a WebP blob and the video's aspect ratio. + */ +export function generateVideoThumbnail( + file: File | Blob +): Promise<{ blob: Blob; aspectRatio: { width: number; height: number } }> { + return new Promise((resolve, reject) => { + const video = document.createElement('video'); + video.preload = 'auto'; + video.muted = true; + video.playsInline = true; + + const url = URL.createObjectURL(file); + video.src = url; + + video.onloadeddata = () => { + // Seek to the first frame + video.currentTime = 0; + }; + + video.onseeked = () => { + const canvas = document.createElement('canvas'); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + + const ctx = canvas.getContext('2d'); + if (!ctx) { + URL.revokeObjectURL(url); + return reject(new Error('Failed to get canvas context')); + } + + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + + canvas.toBlob( + (blob) => { + URL.revokeObjectURL(url); + if (!blob) { + return reject(new Error('Failed to generate thumbnail')); + } + resolve({ + blob, + aspectRatio: { + width: video.videoWidth, + height: video.videoHeight + } + }); + }, + 'image/webp', + 0.8 + ); + }; + + video.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error('Failed to load video')); + }; + }); +} diff --git a/src/lib/index.ts b/src/lib/index.ts index b20ef4a..81ac813 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -3,11 +3,17 @@ import { resolve } from '$app/paths'; import { parseUri } from './atproto'; -export function getShareLinkFromUri(uri: string): string { +export function getShareLinkFromUri(uri: string, handle?: string): string { const parts = parseUri(uri); if (!parts || !parts.rkey) return ''; - return getShareLink(parts.repo, parts.rkey); + const repo = handle || parts.repo; + + if (parts.collection === 'pics.atmo.video') { + return getVideoShareLink(repo, parts.rkey); + } + + return getShareLink(repo, parts.rkey); } export function getShareLink(repo: string, rkey: string): string { @@ -15,4 +21,11 @@ export function getShareLink(repo: string, rkey: string): string { repo: repo, rkey: rkey })}`; -} \ No newline at end of file +} + +export function getVideoShareLink(repo: string, rkey: string): string { + return `${window.location.origin}${resolve('/v/[repo]/[rkey]', { + repo: repo, + rkey: rkey + })}`; +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 9e3f241..081ec45 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -10,35 +10,53 @@ parseUri } from '$lib/atproto'; import { compressImage, getImageFromRecord } from '$lib/atproto/image-helper'; + import { generateVideoThumbnail } from '$lib/atproto/video-helper'; import Avatar from '$lib/atproto/UI/Avatar.svelte'; import Button from '$lib/atproto/UI/Button.svelte'; import { loginModalState } from '$lib/atproto/UI/LoginModal.svelte'; import { resolve } from '$app/paths'; - import { getShareLink, getShareLinkFromUri } from '$lib'; - type ImageRecord = { + import { getShareLinkFromUri } from '$lib'; + + type MediaRecord = { uri: string; value: Record; + type: 'image' | 'video'; }; let fileInput: HTMLInputElement | undefined = $state(); let isUploading = $state(false); + let uploadingMessage = $state(''); let feedbackMessage = $state(''); - let images = $state([]); + let items = $state([]); let loadingImages = $state(false); let isDragging = $state(false); let dragCounter = 0; - async function loadImages() { + async function loadItems() { if (!user.did) return; loadingImages = true; try { - const records = await listRecords({ - collection: 'pics.atmo.image', - limit: 0 + const [imageRecords, videoRecords] = await Promise.all([ + listRecords({ collection: 'pics.atmo.image', limit: 0 }), + listRecords({ collection: 'pics.atmo.video', limit: 0 }) + ]); + const imageItems: MediaRecord[] = (imageRecords as any[]).map((r) => ({ + ...r, + type: 'image' as const + })); + const videoItems: MediaRecord[] = (videoRecords as any[]).map((r) => ({ + ...r, + type: 'video' as const + })); + const all = [...imageItems, ...videoItems]; + all.sort((a, b) => { + const dateA = (a.value as any).createdAt || ''; + const dateB = (b.value as any).createdAt || ''; + return dateB.localeCompare(dateA); }); - images = records as ImageRecord[]; + items = all; } catch (error) { - console.error('Failed to load images:', error); + console.error('Failed to load items:', error); } finally { loadingImages = false; } @@ -46,15 +64,75 @@ $effect(() => { if (user.isLoggedIn) { - loadImages(); + loadItems(); } else { - images = []; + items = []; } }); + async function uploadVideo(file: File) { + isUploading = true; + uploadingMessage = 'Processing video...'; + feedbackMessage = ''; + + try { + const { blob: thumbnailBlob, aspectRatio } = await generateVideoThumbnail(file); + + uploadingMessage = 'Uploading video...'; + + const [videoBlobInfo, thumbnailBlobInfo] = await Promise.all([ + uploadBlob({ blob: file }), + uploadBlob({ blob: thumbnailBlob }) + ]); + + const rkey = createTID(); + + const record = { + $type: 'pics.atmo.video', + video: videoBlobInfo, + thumbnail: thumbnailBlobInfo, + aspectRatio, + createdAt: new Date().toISOString() + }; + + await putRecord({ + collection: 'pics.atmo.video', + rkey, + record + }); + + const newItem: MediaRecord = { + uri: `at://${user.did}/pics.atmo.video/${rkey}`, + value: record, + type: 'video' + }; + items = [newItem, ...items]; + + const shareLink = getShareLinkFromUri(newItem.uri, user.profile?.handle); + try { + await navigator.clipboard.writeText(shareLink); + feedbackMessage = 'Link copied to clipboard!'; + } catch { + feedbackMessage = 'Video uploaded!'; + } + } catch (error) { + console.error('Upload failed:', error); + feedbackMessage = 'Upload failed. Please try again.'; + } finally { + isUploading = false; + uploadingMessage = ''; + if (fileInput) { + fileInput.value = ''; + } + } + } + async function uploadImage(file: File) { + if (file.type.startsWith('video/')) { + return uploadVideo(file); + } if (!file.type.startsWith('image/')) { - feedbackMessage = 'Please drop an image file'; + feedbackMessage = 'Please drop an image or video file'; return; } @@ -80,14 +158,15 @@ }); // Add the new image to the list - const newImage: ImageRecord = { + const newImage: MediaRecord = { uri: `at://${user.did}/pics.atmo.image/${rkey}`, - value: record + value: record, + type: 'image' }; - images = [newImage, ...images]; + items = [newImage, ...items]; // Copy link to clipboard - const shareLink = getShareLinkFromUri(newImage.uri); + const shareLink = getShareLinkFromUri(newImage.uri, user.profile?.handle); try { await navigator.clipboard.writeText(shareLink); feedbackMessage = 'Link copied to clipboard!'; @@ -137,7 +216,7 @@ isDragging = false; if (!user.isLoggedIn) { - feedbackMessage = 'Please log in to upload images'; + feedbackMessage = 'Please log in to upload'; return; } @@ -146,16 +225,16 @@ } function handlePaste(event: ClipboardEvent) { - const items = event.clipboardData?.items; - if (!items) return; + const pasteItems = event.clipboardData?.items; + if (!pasteItems) return; - for (const item of items) { - if (item.type.startsWith('image/')) { + for (const item of pasteItems) { + if (item.type.startsWith('image/') || item.type.startsWith('video/')) { const file = item.getAsFile(); if (!file) return; if (!user.isLoggedIn) { - feedbackMessage = 'Please log in to upload images'; + feedbackMessage = 'Please log in to upload'; return; } @@ -165,16 +244,28 @@ } } - function getImageUrl(record: ImageRecord): string | undefined { + function getThumbnailUrl(record: MediaRecord): string | undefined { if (!user.did) return; + if (record.type === 'video') { + return getImageFromRecord(record.value, user.did, 'thumbnail'); + } return getImageFromRecord(record.value, user.did); } + function getItemLink(item: MediaRecord): string { + const rkey = parseUri(item.uri)?.rkey ?? ''; + const handle = user.profile?.handle ?? user.did ?? ''; + if (item.type === 'video') { + return resolve('/v/[repo]/[rkey]', { repo: handle, rkey }); + } + return resolve('/i/[repo]/[rkey]', { repo: handle, rkey }); + } + async function copyLink(event: Event, uri: string) { event.stopPropagation(); event.preventDefault(); try { - await navigator.clipboard.writeText(getShareLinkFromUri(uri)); + await navigator.clipboard.writeText(getShareLinkFromUri(uri, user.profile?.handle)); feedbackMessage = 'Link copied!'; setTimeout(() => { if (feedbackMessage === 'Link copied!') feedbackMessage = ''; @@ -184,31 +275,32 @@ } } - async function deleteImage(event: Event, uri: string) { + async function deleteItem(event: Event, item: MediaRecord) { event.stopPropagation(); event.preventDefault(); - if (!confirm('Delete this image?')) return; + const isVideo = item.type === 'video'; + if (!confirm(`Delete this ${isVideo ? 'video' : 'image'}?`)) return; - const parsed = parseUri(uri); + const parsed = parseUri(item.uri); if (!parsed?.rkey) { - feedbackMessage = 'Failed to delete image'; + feedbackMessage = `Failed to delete ${isVideo ? 'video' : 'image'}`; return; } try { await deleteRecord({ - collection: 'pics.atmo.image', + collection: isVideo ? 'pics.atmo.video' : 'pics.atmo.image', rkey: parsed.rkey }); - images = images.filter((img) => img.uri !== uri); - feedbackMessage = 'Image deleted'; + items = items.filter((i) => i.uri !== item.uri); + feedbackMessage = `${isVideo ? 'Video' : 'Image'} deleted`; setTimeout(() => { - if (feedbackMessage === 'Image deleted') feedbackMessage = ''; + if (feedbackMessage.includes('deleted')) feedbackMessage = ''; }, 2000); } catch (error) { - console.error('Failed to delete image:', error); - feedbackMessage = 'Failed to delete image'; + console.error('Failed to delete:', error); + feedbackMessage = `Failed to delete ${isVideo ? 'video' : 'image'}`; } } @@ -241,7 +333,7 @@ d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" /> - Uploading... + {uploadingMessage || 'Uploading...'} {/if} @@ -264,14 +356,14 @@ d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" /> - Drop image here to upload + Drop file here to upload {/if}

atmo.pics

-

atproto image sharing

+

atproto image and video sharing

+ {#if item.type === 'video'} +
+
+ + + +
+
+ {/if}
diff --git a/src/routes/i/[repo]/[rkey]/+page.server.ts b/src/routes/i/[repo]/[rkey]/+page.server.ts index 269b010..a8bfbdf 100644 --- a/src/routes/i/[repo]/[rkey]/+page.server.ts +++ b/src/routes/i/[repo]/[rkey]/+page.server.ts @@ -1,4 +1,4 @@ -import { getRecord, resolveHandle } from '$lib/atproto'; +import { getBlobURL, getRecord, resolveHandle } from '$lib/atproto'; import { isDid, isHandle } from '@atcute/lexicons/syntax'; import { error } from '@sveltejs/kit'; @@ -20,5 +20,8 @@ export async function load({ params }) { if (!blob || blob.$type !== 'blob') { throw error(404, 'Image not found'); } - return { record, did, blob }; + + const imageUrl = await getBlobURL({ did, blob }); + + return { record, did, blob, imageUrl }; } diff --git a/src/routes/i/[repo]/[rkey]/+page.svelte b/src/routes/i/[repo]/[rkey]/+page.svelte index f5bd654..ca01c4f 100644 --- a/src/routes/i/[repo]/[rkey]/+page.svelte +++ b/src/routes/i/[repo]/[rkey]/+page.svelte @@ -1,10 +1,10 @@ + + + + + + + shared video + + + + + diff --git a/src/routes/v/[repo]/[rkey]/og.png/+server.ts b/src/routes/v/[repo]/[rkey]/og.png/+server.ts new file mode 100644 index 0000000..5ad6467 --- /dev/null +++ b/src/routes/v/[repo]/[rkey]/og.png/+server.ts @@ -0,0 +1,52 @@ +import { getCDNImageBlobUrl, getRecord, resolveHandle } from '$lib/atproto'; +import { isDid, isHandle } from '@atcute/lexicons/syntax'; +import { error } from '@sveltejs/kit'; +import { ImageResponse } from '@ethercorps/sveltekit-og'; + +export async function GET({ params }) { + const repo = params.repo; + + const did = isDid(repo) + ? repo + : isHandle(repo) + ? await resolveHandle({ handle: repo }) + : undefined; + + if (!did) throw error(404, 'User not found'); + + const record = await getRecord({ did, collection: 'pics.atmo.video', rkey: params.rkey }); + + const aspectRatio = record.value.aspectRatio as { + width: number; + height: number; + }; + + const blob = record.value.thumbnail as { $type: 'blob'; ref: { $link: string } } | undefined; + + if (!blob || blob.$type !== 'blob') { + throw error(404, 'Video thumbnail not found'); + } + + const ogWidth = 1200; + const ogHeight = 630; + + let imgWidth: number; + let imgHeight: number; + + if (aspectRatio.width / aspectRatio.height > ogWidth / ogHeight) { + imgWidth = ogWidth; + imgHeight = ogWidth / (aspectRatio.width / aspectRatio.height); + } else { + imgHeight = ogHeight; + imgWidth = ogHeight * (aspectRatio.width / aspectRatio.height); + } + + const htmlString = `
+ +
`; + + return new ImageResponse(htmlString, { + width: ogWidth, + height: ogHeight + }); +}