Browse Source

improve compression. handle mka/mkv files

imwald
Silberengel 3 weeks ago
parent
commit
4b53fa788b
  1. 16
      package-lock.json
  2. 1
      package.json
  3. 11
      src/components/MediaPlayer/index.tsx
  4. 130
      src/components/PostEditor/PostContent.tsx
  5. 16
      src/components/PostEditor/PostTextarea/ClipboardAndDropHandler.ts
  6. 18
      src/components/PostEditor/PostTextarea/index.tsx
  7. 11
      src/components/SlowConnectionHint/index.tsx
  8. 10
      src/i18n/locales/en.ts
  9. 2
      src/lib/article-media.ts
  10. 46
      src/lib/compress-image.ts
  11. 535
      src/lib/compress-upload-media.ts
  12. 3
      src/lib/image-extraction.ts
  13. 17
      src/lib/media-kind-detection.ts
  14. 5
      src/lib/url.ts
  15. 2
      src/pages/secondary/ProfileEditorPage/index.tsx
  16. 22
      src/services/media-upload.service.ts
  17. 10
      src/types/lamejs.d.ts

16
package-lock.json generated

@ -63,6 +63,7 @@ @@ -63,6 +63,7 @@
"i18next": "^24.2.0",
"i18next-browser-languagedetector": "^8.0.4",
"katex": "^0.16.25",
"lamejs": "^1.2.1",
"lru-cache": "^11.0.2",
"lucide-react": "^0.469.0",
"marked": "^17.0.5",
@ -10955,6 +10956,15 @@ @@ -10955,6 +10956,15 @@
"json-buffer": "3.0.1"
}
},
"node_modules/lamejs": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/lamejs/-/lamejs-1.2.1.tgz",
"integrity": "sha512-s7bxvjvYthw6oPLCm5pFxvA84wUROODB8jEO2+CE1adhKgrIvVOlmMgY8zyugxGrvRaDHNJanOiS21/emty6dQ==",
"license": "LGPL-3.0",
"dependencies": {
"use-strict": "1.0.1"
}
},
"node_modules/lazy-val": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz",
@ -15129,6 +15139,12 @@ @@ -15129,6 +15139,12 @@
}
}
},
"node_modules/use-strict": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/use-strict/-/use-strict-1.0.1.tgz",
"integrity": "sha512-IeiWvvEXfW5ltKVMkxq6FvNf2LojMKvB2OCeja6+ct24S1XOmQw2dGr2JyndwACWAGJva9B7yPHwAmeA9QCqAQ==",
"license": "ISC"
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",

1
package.json

@ -85,6 +85,7 @@ @@ -85,6 +85,7 @@
"i18next": "^24.2.0",
"i18next-browser-languagedetector": "^8.0.4",
"katex": "^0.16.25",
"lamejs": "^1.2.1",
"lru-cache": "^11.0.2",
"lucide-react": "^0.469.0",
"marked": "^17.0.5",

11
src/components/MediaPlayer/index.tsx

@ -42,11 +42,20 @@ export default function MediaPlayer({ @@ -42,11 +42,20 @@ export default function MediaPlayer({
const url = new URL(src)
const extension = url.pathname.split('.').pop()?.toLowerCase()
if (extension && ['mp3', 'wav', 'flac', 'aac', 'm4a', 'opus', 'wma'].includes(extension)) {
if (
extension &&
['mp3', 'wav', 'flac', 'aac', 'm4a', 'opus', 'wma', 'mka'].includes(extension)
) {
setMediaType('audio')
return
}
// Matroska is video-first for feeds; avoids waiting on metadata probe (codec support still browser-dependent).
if (extension === 'mkv') {
setMediaType('video')
return
}
const video = document.createElement('video')
video.src = src
video.preload = 'metadata'

130
src/components/PostEditor/PostContent.tsx

@ -72,6 +72,7 @@ import { @@ -72,6 +72,7 @@ import {
Film,
Laugh
} from 'lucide-react'
import { fileLooksLikeUploadableMedia } from '@/lib/compress-upload-media'
import { getMediaKindFromFile } from '@/lib/media-kind-detection'
import { hasPrivateRelays, getPrivateRelayUrls } from '@/lib/private-relays'
import mediaUpload from '@/services/media-upload.service'
@ -99,6 +100,7 @@ import { getReplaceableCoordinateFromEvent, isProtectedEvent as isEventProtected @@ -99,6 +100,7 @@ import { getReplaceableCoordinateFromEvent, isProtectedEvent as isEventProtected
import { Event, kinds } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { showPublishingFeedback, showSimplePublishSuccess, showPublishingError } from '@/lib/publishing-feedback'
import EmojiPickerDialog from '../EmojiPickerDialog'
import GifPicker from '../GifPicker'
@ -1304,6 +1306,95 @@ export default function PostContent({ @@ -1304,6 +1306,95 @@ export default function PostContent({
uploadedMediaFileMap.current.clear()
}
const inferKindFromEditorMediaUrl = (url: string): number | null => {
const path = url.split(/[?#]/)[0].toLowerCase()
if (/\.(jpg|jpeg|png|gif|webp|heic|avif|apng)$/i.test(path)) return ExtendedKind.PICTURE
if (/\.(mp3|m4a|mka|ogg|opus|wav|aac|flac)$/i.test(path)) return ExtendedKind.VOICE
if (/\.(mp4|webm|mov|mkv|m4v|ogv|avi|mpeg|mpg)$/i.test(path)) return ExtendedKind.SHORT_VIDEO
return null
}
const mimeFromUrlPathForKind = (url: string, kind: number): string => {
const path = url.split(/[?#]/)[0].toLowerCase()
if (kind === ExtendedKind.PICTURE) {
if (path.endsWith('.png')) return 'image/png'
if (path.endsWith('.webp')) return 'image/webp'
if (path.endsWith('.gif')) return 'image/gif'
return 'image/jpeg'
}
if (kind === ExtendedKind.VOICE) {
if (path.endsWith('.mka')) return 'audio/x-matroska'
if (path.endsWith('.ogg')) return 'audio/ogg'
if (path.endsWith('.webm')) return 'audio/webm'
return 'audio/mpeg'
}
if (path.endsWith('.mkv')) return 'video/x-matroska'
if (path.endsWith('.webm')) return 'video/webm'
return 'video/mp4'
}
const textLooksLikeImetaWithUrl = (s: string): boolean =>
/\bimeta\b[\s\S]{0,400}?\burl\s+https?:\/\//i.test(s)
const firstHttpUrlInNoteText = (s: string): string | undefined => {
const m = s.match(/https?:\/\/[^\s<>\])}'"]+/)
return m?.[0]
}
const canUseMediaKindFromUrlButton = useMemo(() => {
if (parentEvent || isDiscussionThread || isPublicMessage) return false
if (mediaNoteKind !== null && mediaUrl) return false
if (mediaImetaTags.length > 0) return true
if (mediaUrl) return true
if (textLooksLikeImetaWithUrl(text)) return true
const u = firstHttpUrlInNoteText(text)
return !!(u && inferKindFromEditorMediaUrl(u) !== null)
}, [
parentEvent,
isDiscussionThread,
isPublicMessage,
mediaNoteKind,
mediaUrl,
mediaImetaTags,
text
])
/** When the editor already contains a media URL (e.g. after drop/paste) but kind stayed 1. */
const handleUseMediaNoteKindFromUrl = () => {
if (parentEvent || isDiscussionThread || isPublicMessage) return
if (mediaNoteKind !== null && mediaUrl) {
toast.info(t('Already publishing as a media note'))
return
}
const raw = textareaRef.current?.getText() ?? text
const m = raw.match(/https?:\/\/[^\s<>\])}'"]+/)
const found = m?.[0]
if (!found) {
toast.info(t('No media URL in note — upload or paste a link first'))
return
}
const kind = inferKindFromEditorMediaUrl(found)
if (kind === null) {
toast.info(t('Cannot infer media type from URL — use Note type → Media Note to upload'))
return
}
setIsPoll(false)
setIsHighlight(false)
setIsLongFormArticle(false)
setIsWikiArticle(false)
setIsWikiArticleMarkdown(false)
setIsPublicationContent(false)
setIsCitationInternal(false)
setIsCitationExternal(false)
setIsCitationHardcopy(false)
setIsCitationPrompt(false)
setIsDiscussionThread(false)
setMediaUrl(found)
setMediaNoteKind(kind)
const mime = mimeFromUrlPathForKind(found, kind)
setMediaImetaTags([['imeta', `url ${found}`, `m ${mime}`]])
}
const isPlainShortNoteToolbar = useMemo(
() =>
!parentEvent &&
@ -1402,7 +1493,7 @@ export default function PostContent({ @@ -1402,7 +1493,7 @@ export default function PostContent({
const handleUploadStart = (file: File, cancel: () => void) => {
setUploadProgresses((prev) => [...prev, { file, progress: 0, cancel }])
// Track file for media upload
if (file.type.startsWith('image/') || file.type.startsWith('audio/') || file.type.startsWith('video/')) {
if (fileLooksLikeUploadableMedia(file)) {
const mapKey = `${file.name}-${file.size}-${file.lastModified}`
uploadedMediaFileMap.current.set(mapKey, file)
@ -1412,7 +1503,7 @@ export default function PostContent({ @@ -1412,7 +1503,7 @@ export default function PostContent({
const fileName = file.name.toLowerCase()
// Mobile browsers may report m4a files as audio/m4a, audio/mp4, audio/x-m4a, or even video/mp4
const isAudioMime = fileType.startsWith('audio/') || fileType === 'audio/mp4' || fileType === 'audio/x-m4a' || fileType === 'audio/m4a' || fileType === 'audio/webm' || fileType === 'audio/mpeg'
const isAudioExt = /\.(mp3|m4a|ogg|wav|opus|aac|flac|mpeg|mp4)$/i.test(fileName)
const isAudioExt = /\.(mp3|m4a|mka|ogg|wav|opus|aac|flac|mpeg|mp4)$/i.test(fileName)
// For replies/PMs, webm/ogg/mp3/m4a files should be treated as audio since the microphone button only accepts audio/*
// Even if the MIME type is incorrect, if it came through the audio uploader, it's audio
const isWebmFile = /\.webm$/i.test(fileName)
@ -1593,6 +1684,8 @@ export default function PostContent({ @@ -1593,6 +1684,8 @@ export default function PostContent({
const fileName = uploadingFile.name.toLowerCase()
if (/\.webm$/i.test(fileName)) {
mimeType = 'audio/webm'
} else if (/\.mka$/i.test(fileName)) {
mimeType = 'audio/x-matroska'
} else if (/\.mp4$/i.test(fileName)) {
mimeType = 'audio/mp4'
}
@ -1601,6 +1694,8 @@ export default function PostContent({ @@ -1601,6 +1694,8 @@ export default function PostContent({
const fileName = uploadingFile.name.toLowerCase()
if (/\.webm$/i.test(fileName)) {
mimeType = 'video/webm'
} else if (/\.mkv$/i.test(fileName)) {
mimeType = 'video/x-matroska'
} else if (/\.mp4$/i.test(fileName)) {
mimeType = 'video/mp4'
}
@ -1692,7 +1787,7 @@ export default function PostContent({ @@ -1692,7 +1787,7 @@ export default function PostContent({
// For replies/PMs, webm/ogg/mp3 files should be treated as audio since the microphone button only accepts audio/*
// Mobile browsers may report m4a files as audio/m4a, audio/mp4, audio/x-m4a, or even video/mp4
const isAudioMime = fileType.startsWith('audio/') || fileType === 'audio/mp4' || fileType === 'audio/x-m4a' || fileType === 'audio/m4a' || fileType === 'audio/webm' || fileType === 'audio/mpeg'
const isAudioExt = /\.(mp3|m4a|ogg|wav|opus|aac|flac|mpeg|mp4)$/i.test(fileName)
const isAudioExt = /\.(mp3|m4a|mka|ogg|wav|opus|aac|flac|mpeg|mp4)$/i.test(fileName)
// m4a files are always audio, even if MIME type is video/mp4 (mobile browsers sometimes report this)
const isM4aFile = /\.m4a$/i.test(fileName)
const isMp4Audio = /\.mp4$/i.test(fileName) && isAudioMime
@ -1732,6 +1827,8 @@ export default function PostContent({ @@ -1732,6 +1827,8 @@ export default function PostContent({
if (/\.m4a$/i.test(fileName)) {
// m4a files are always audio, use audio/mp4 or audio/x-m4a
mimeType = 'audio/mp4'
} else if (/\.mka$/i.test(fileName)) {
mimeType = 'audio/x-matroska'
} else if (/\.webm$/i.test(fileName) && !mimeType.startsWith('audio/')) {
mimeType = 'audio/webm'
} else if (/\.ogg$/i.test(fileName) && !mimeType.startsWith('audio/')) {
@ -2665,6 +2762,7 @@ export default function PostContent({ @@ -2665,6 +2762,7 @@ export default function PostContent({
onUploadStart={handleUploadStart}
onUploadProgress={handleUploadProgress}
onUploadEnd={handleUploadEnd}
onUploadSuccess={handleMediaUploadSuccess}
kind={getDeterminedKind}
highlightData={isHighlight ? highlightData : undefined}
pollCreateData={isPoll ? pollCreateData : undefined}
@ -2705,6 +2803,23 @@ export default function PostContent({ @@ -2705,6 +2803,23 @@ export default function PostContent({
mediaNoteKind !== null ? t('Media Note') :
t('Short Note')
return (
<div className="flex flex-wrap items-center justify-end gap-1.5">
<Button
type="button"
variant="outline"
size="sm"
className="h-8 gap-1.5 text-sm font-normal shrink-0"
disabled={!canUseMediaKindFromUrlButton}
title={
canUseMediaKindFromUrlButton
? t('Use image/audio/video note kind for the media URL in the editor')
: t('Media kind (disabled): add imeta tags, a media URL, or upload media first')
}
onClick={handleUseMediaNoteKindFromUrl}
>
<Upload className="h-3.5 w-3.5 shrink-0" />
<span className="hidden sm:inline max-w-[7.5rem] truncate">{t('Media kind')}</span>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-1.5 h-8 text-sm font-normal">
@ -2858,6 +2973,7 @@ export default function PostContent({ @@ -2858,6 +2973,7 @@ export default function PostContent({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
})() : undefined
}
@ -2962,7 +3078,7 @@ export default function PostContent({ @@ -2962,7 +3078,7 @@ export default function PostContent({
onUploadStart={handleUploadStart}
onUploadEnd={handleUploadEnd}
onProgress={handleUploadProgress}
accept="image/*,audio/*,video/*"
accept="image/*,audio/*,video/*,.mkv,.mka,video/x-matroska,audio/x-matroska"
className="sr-only"
>
<button ref={mediaUploaderBtnRef} type="button" aria-hidden="true" tabIndex={-1} />
@ -2977,7 +3093,7 @@ export default function PostContent({ @@ -2977,7 +3093,7 @@ export default function PostContent({
onUploadStart={handleUploadStart}
onUploadEnd={handleUploadEnd}
onProgress={handleUploadProgress}
accept="audio/*"
accept="audio/*,.mka,audio/x-matroska"
>
<Button
type="button"
@ -2991,9 +3107,7 @@ export default function PostContent({ @@ -2991,9 +3107,7 @@ export default function PostContent({
</Uploader>
)}
<Uploader
onUploadSuccess={({ url }) => {
textareaRef.current?.appendText(url, true)
}}
onUploadSuccess={handleMediaUploadSuccess}
onUploadStart={handleUploadStart}
onUploadEnd={handleUploadEnd}
onProgress={handleUploadProgress}

16
src/components/PostEditor/PostTextarea/ClipboardAndDropHandler.ts

@ -1,3 +1,4 @@ @@ -1,3 +1,4 @@
import { fileLooksLikeUploadableMedia } from '@/lib/compress-upload-media'
import mediaUpload from '@/services/media-upload.service'
import { Extension } from '@tiptap/core'
import { EditorView } from '@tiptap/pm/view'
@ -14,6 +15,8 @@ const DRAGOVER_CLASS_LIST = [ @@ -14,6 +15,8 @@ const DRAGOVER_CLASS_LIST = [
export interface ClipboardAndDropHandlerOptions {
onUploadStart?: (file: File, cancel: () => void) => void
/** Same contract as `Uploader` — required so drop/paste uploads set media note state (kind 20/21/22…), not only the URL in text. */
onUploadSuccess?: (result: { url: string; tags: string[][]; file: File }) => void
onUploadEnd?: (file: File) => void
onUploadProgress?: (file: File, progress: number) => void
}
@ -60,9 +63,7 @@ export const ClipboardAndDropHandler = Extension.create<ClipboardAndDropHandlerO @@ -60,9 +63,7 @@ export const ClipboardAndDropHandler = Extension.create<ClipboardAndDropHandlerO
view.dom.classList.remove(...DRAGOVER_CLASS_LIST)
const items = Array.from(event.dataTransfer?.files ?? [])
const mediaFiles = items.filter(
(item) => item.type.includes('image') || item.type.includes('video')
)
const mediaFiles = items.filter((item) => fileLooksLikeUploadableMedia(item))
if (!mediaFiles.length) return false
uploadFiles(view, mediaFiles, options)
@ -73,12 +74,9 @@ export const ClipboardAndDropHandler = Extension.create<ClipboardAndDropHandlerO @@ -73,12 +74,9 @@ export const ClipboardAndDropHandler = Extension.create<ClipboardAndDropHandlerO
let handled = false
for (const item of items) {
if (
item.kind === 'file' &&
(item.type.includes('image') || item.type.includes('video'))
) {
if (item.kind === 'file') {
const file = item.getAsFile()
if (file) {
if (file && fileLooksLikeUploadableMedia(file)) {
uploadFiles(view, [file], options)
handled = true
}
@ -176,6 +174,8 @@ async function uploadFiles( @@ -176,6 +174,8 @@ async function uploadFiles(
insertTr.setSelection(TextSelection.near(insertTr.doc.resolve(newPos)))
view.dispatch(insertTr)
}
options.onUploadSuccess?.({ url: result.url, tags: result.tags, file })
})
.catch((error) => {
logger.error('Clipboard/drop upload failed', { error, file: file.name })

18
src/components/PostEditor/PostTextarea/index.tsx

@ -13,7 +13,16 @@ import Text from '@tiptap/extension-text' @@ -13,7 +13,16 @@ import Text from '@tiptap/extension-text'
import { TextSelection } from '@tiptap/pm/state'
import { EditorContent, useEditor } from '@tiptap/react'
import { Event } from 'nostr-tools'
import { Dispatch, forwardRef, SetStateAction, useImperativeHandle, useState, useEffect, useMemo } from 'react'
import {
Dispatch,
forwardRef,
SetStateAction,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState
} from 'react'
import { useTranslation } from 'react-i18next'
import { ClipboardAndDropHandler } from './ClipboardAndDropHandler'
import Emoji from './Emoji'
@ -44,6 +53,7 @@ const PostTextarea = forwardRef< @@ -44,6 +53,7 @@ const PostTextarea = forwardRef<
onUploadStart?: (file: File, cancel: () => void) => void
onUploadProgress?: (file: File, progress: number) => void
onUploadEnd?: (file: File) => void
onUploadSuccess?: (result: { url: string; tags: string[][]; file: File }) => void
kind?: number
highlightData?: HighlightData
pollCreateData?: import('@/types').TPollCreateData
@ -73,6 +83,7 @@ const PostTextarea = forwardRef< @@ -73,6 +83,7 @@ const PostTextarea = forwardRef<
onUploadStart,
onUploadProgress,
onUploadEnd,
onUploadSuccess,
kind = 1,
highlightData,
pollCreateData,
@ -87,6 +98,8 @@ const PostTextarea = forwardRef< @@ -87,6 +98,8 @@ const PostTextarea = forwardRef<
ref
) => {
const { t } = useTranslation()
const onUploadSuccessRef = useRef(onUploadSuccess)
onUploadSuccessRef.current = onUploadSuccess
const [activeTab, setActiveTab] = useState('preview')
const [draftEventJson, setDraftEventJson] = useState<string>('')
const [isLoadingJson, setIsLoadingJson] = useState(false)
@ -150,7 +163,8 @@ const PostTextarea = forwardRef< @@ -150,7 +163,8 @@ const PostTextarea = forwardRef<
onUploadStart?.(file, cancel)
},
onUploadEnd: (file) => onUploadEnd?.(file),
onUploadProgress: (file, p) => onUploadProgress?.(file, p)
onUploadProgress: (file, p) => onUploadProgress?.(file, p),
onUploadSuccess: (result) => onUploadSuccessRef.current?.(result)
})
],
editorProps: {

11
src/components/SlowConnectionHint/index.tsx

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
import { Button } from '@/components/ui/button'
import { MEDIA_AUTO_LOAD_POLICY } from '@/constants'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import storage from '@/services/local-storage.service'
import { WifiOff, X } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -19,7 +20,13 @@ function detectConnectionStatus(): { poor: boolean; offline: boolean } { @@ -19,7 +20,13 @@ function detectConnectionStatus(): { poor: boolean; offline: boolean } {
export default function SlowConnectionHint() {
const { t } = useTranslation()
const { autoplay, setAutoplay, mediaAutoLoadPolicy, setMediaAutoLoadPolicy } = useContentPolicy()
const contentPolicy = useContentPolicyOptional()
const autoplay = contentPolicy?.autoplay ?? storage.getAutoplay()
const mediaAutoLoadPolicy =
contentPolicy?.mediaAutoLoadPolicy ?? storage.getMediaAutoLoadPolicy()
const setAutoplay = contentPolicy?.setAutoplay ?? ((v: boolean) => storage.setAutoplay(v))
const setMediaAutoLoadPolicy =
contentPolicy?.setMediaAutoLoadPolicy ?? ((p) => storage.setMediaAutoLoadPolicy(p))
const [status, setStatus] = useState(detectConnectionStatus)
const [slowDismissed, setSlowDismissed] = useState(
() => sessionStorage.getItem(SLOW_DISMISSED_KEY) === 'true'

10
src/i18n/locales/en.ts

@ -1843,6 +1843,16 @@ export default { @@ -1843,6 +1843,16 @@ export default {
'Upload Audio Comment': 'Upload Audio Comment',
'Upload Audio Message': 'Upload Audio Message',
'Upload Media': 'Upload Media',
'Media kind': 'Media kind',
'Use image/audio/video note kind for the media URL in the editor':
'Use image/audio/video note kind for the media URL in the editor',
'Already publishing as a media note': 'Already publishing as a media note',
'No media URL in note — upload or paste a link first':
'No media URL in note — upload or paste a link first',
'Cannot infer media type from URL — use Note type → Media Note to upload':
'Cannot infer media type from URL — use Note type → Media Note to upload',
'Media kind (disabled): add imeta tags, a media URL, or upload media first':
'Media kind (disabled): add imeta tags, a media URL, or upload media first',
Upvote: 'Upvote',
'User unmuted': 'User unmuted',
'Version number (optional)': 'Version number (optional)',

2
src/lib/article-media.ts

@ -62,7 +62,7 @@ function extractUrlsFromContent(content: string): string[] { @@ -62,7 +62,7 @@ function extractUrlsFromContent(content: string): string[] {
const mediaExtensions = [
'.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp', '.tiff',
'.mp4', '.webm', '.ogg', '.mov', '.avi', '.mkv', '.flv',
'.mp3', '.wav', '.flac', '.aac', '.m4a'
'.mp3', '.wav', '.flac', '.aac', '.m4a', '.mka'
]
const isMediaFile = mediaExtensions.some(ext => pathname.endsWith(ext))

46
src/lib/compress-image.ts

@ -1,19 +1,21 @@ @@ -1,19 +1,21 @@
/**
* Client-side image compression via the Canvas API.
*
* Runs only in the browser (Canvas); no external compression APIs.
* Called before every media upload to reduce bandwidth and server storage costs.
* Raster images are re-encoded (WebP/JPEG) when possible, not only when over the byte cap.
* GIFs are returned unchanged (canvas flattens animation to a single frame).
* Non-image files are returned unchanged.
*/
/** Longest edge cap before re-encoding. */
const MAX_DIMENSION_PX = 2048
/** Try WebP at this quality first — typically 30-50 % smaller than JPEG at same perceptual quality. */
const WEBP_QUALITY = 0.85
/** Starting JPEG quality; stepped down by 0.1 until the file fits. */
const JPEG_QUALITY_START = 0.82
const MAX_DIMENSION_PX = 1920
/** WebP quality — tuned for smaller uploads; JPEG ladder if still over `targetMaxBytes`. */
const WEBP_QUALITY = 0.72
/** Starting JPEG quality; stepped down until the file fits. */
const JPEG_QUALITY_START = 0.74
/** Never go below this quality during progressive reduction. */
const JPEG_QUALITY_MIN = 0.35
const JPEG_QUALITY_MIN = 0.32
function canvasToBlob(
canvas: HTMLCanvasElement,
@ -37,7 +39,6 @@ export async function compressImage(file: File, targetMaxBytes: number): Promise @@ -37,7 +39,6 @@ export async function compressImage(file: File, targetMaxBytes: number): Promise
if (!file.type.startsWith('image/')) return file
if (file.type === 'image/gif') return file // canvas strips animation
if (file.type === 'image/svg+xml') return file
if (file.size <= targetMaxBytes) return file
let bitmap: ImageBitmap
try {
@ -66,7 +67,7 @@ export async function compressImage(file: File, targetMaxBytes: number): Promise @@ -66,7 +67,7 @@ export async function compressImage(file: File, targetMaxBytes: number): Promise
const baseName = file.name.replace(/\.[^.]+$/, '')
// Try WebP first
// Try WebP first (always prefer a smaller or cap-compliant WebP)
const webpBlob = await canvasToBlob(canvas, 'image/webp', WEBP_QUALITY)
if (webpBlob && webpBlob.size <= targetMaxBytes) {
return new File([webpBlob], `${baseName}.webp`, { type: 'image/webp' })
@ -83,7 +84,34 @@ export async function compressImage(file: File, targetMaxBytes: number): Promise @@ -83,7 +84,34 @@ export async function compressImage(file: File, targetMaxBytes: number): Promise
}
}
// Return best effort result if it's at least smaller than the original
// If still over budget, shrink canvas further and retry WebP / JPEG
if (bestBlob && bestBlob.size > targetMaxBytes && (width > 640 || height > 640)) {
const factor = 0.72
const w2 = Math.max(320, Math.round(width * factor))
const h2 = Math.max(320, Math.round(height * factor))
const snap = await createImageBitmap(canvas)
canvas.width = w2
canvas.height = h2
ctx.drawImage(snap, 0, 0, w2, h2)
snap.close()
const smallWebp = await canvasToBlob(canvas, 'image/webp', WEBP_QUALITY - 0.08)
if (smallWebp && smallWebp.size < (bestBlob?.size ?? Infinity)) {
bestBlob = smallWebp
}
if (smallWebp && smallWebp.size <= targetMaxBytes) {
return new File([smallWebp], `${baseName}.webp`, { type: 'image/webp' })
}
for (let q = JPEG_QUALITY_START; q >= JPEG_QUALITY_MIN; q = Math.round((q - 0.1) * 10) / 10) {
const blob = await canvasToBlob(canvas, 'image/jpeg', q)
if (!blob) continue
if (!bestBlob || blob.size < bestBlob.size) bestBlob = blob
if (blob.size <= targetMaxBytes) {
return new File([blob], `${baseName}.jpg`, { type: 'image/jpeg' })
}
}
}
// Return best effort if smaller than original
if (bestBlob && bestBlob.size < file.size) {
const isWebp = bestBlob.type === 'image/webp'
return new File(

535
src/lib/compress-upload-media.ts

@ -0,0 +1,535 @@ @@ -0,0 +1,535 @@
/**
* Pre-upload compression for Blossom / NIP-96: images (WebP/JPEG), audio (MP3), video (WebM).
*
* All compression runs entirely on-device (Canvas, Web Audio, bundled lamejs, MediaRecorder).
* No files or pixels are sent to third-party transcoding services only your chosen upload
* step (Blossom / NIP-96) sends the already-compressed blob out.
*
* Falls back to the original file when decode, encode, or APIs fail.
*/
import { compressImage } from '@/lib/compress-image'
import logger from '@/lib/logger'
/**
* Dev always; otherwise set `localStorage.setItem('jumble-upload-log', 'true')` (e.g. for `vite preview`).
* Uses console.log (not console.info): many browsers hide the "Info" level in DevTools by default, so info looked like "no logs" even in vite dev.
*/
function uploadCompressionDiag(message: string, data?: Record<string, unknown>): void {
try {
const enabled =
import.meta.env.DEV ||
(typeof localStorage !== 'undefined' && localStorage.getItem('jumble-upload-log') === 'true')
if (!enabled) return
if (data !== undefined) console.log(`[compress-upload] ${message}`, data)
else console.log(`[compress-upload] ${message}`)
} catch {
// private mode / no storage
}
}
const AUDIO_TARGET_SAMPLE_RATE = 44100
const AUDIO_MP3_KBPS = 96
const MP3_FRAME_SAMPLES = 1152
const MAX_VIDEO_DURATION_SEC = 15 * 60
const MAX_VIDEO_WIDTH_PX = 1280
const VIDEO_TARGET_BITRATE_MAX = 2_500_000
/** Floor so short clips don’t balloon vs efficient H.264 in MP4. */
const VIDEO_TARGET_BITRATE_MIN = 450_000
const VIDEO_AUDIO_BITRATE = 96_000
/** Browsers often leave `File.type` empty for some paths; still treat as video. */
const VIDEO_FILENAME_RE = /\.(mp4|m4v|mov|mkv|webm|ogv|avi|mpeg|mpg)$/i
/** Image/audio extensions for drag/drop and paste when `File.type` is empty (common on Linux). */
const IMAGE_FILENAME_RE = /\.(jpe?g|png|gif|webp|bmp|svg|ico|heic|heif|avif)$/i
const AUDIO_FILENAME_RE = /\.(mp3|m4a|mka|wav|ogg|opus|aac|flac|mpeg)$/i
/**
* True if the file is likely a user media upload (image, video, or audio) from MIME or filename.
* Use for clipboard/drop filters where `DataTransferItem.type` / `File.type` may be empty.
*/
export function fileLooksLikeUploadableMedia(file: File): boolean {
const t = file.type
if (t.startsWith('image/') || t.startsWith('video/') || t.startsWith('audio/')) return true
if (VIDEO_FILENAME_RE.test(file.name)) return true
if (IMAGE_FILENAME_RE.test(file.name)) return true
if (AUDIO_FILENAME_RE.test(file.name)) return true
return false
}
function float32ToInt16(f32: Float32Array): Int16Array {
const out = new Int16Array(f32.length)
for (let i = 0; i < f32.length; i++) {
const s = Math.max(-1, Math.min(1, f32[i]))
out[i] = s < 0 ? (s * 0x8000) | 0 : (s * 0x7fff) | 0
}
return out
}
function fileLooksLikeMatroskaAudio(file: File): boolean {
return /\.mka$/i.test(file.name) || file.type === 'audio/x-matroska'
}
async function compressAudioToMp3(file: File, signal?: AbortSignal): Promise<File> {
if (!file.type.startsWith('audio/') && !fileLooksLikeMatroskaAudio(file)) return file
const ctx = new AudioContext()
try {
const ab = await file.arrayBuffer()
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError')
let audioBuffer: AudioBuffer
try {
audioBuffer = await ctx.decodeAudioData(ab.slice(0))
} catch {
return file
}
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError')
if (audioBuffer.duration <= 0 || !Number.isFinite(audioBuffer.duration)) return file
const length = Math.ceil(audioBuffer.duration * AUDIO_TARGET_SAMPLE_RATE)
const offline = new OfflineAudioContext(1, length, AUDIO_TARGET_SAMPLE_RATE)
const monoSrc = offline.createBuffer(1, audioBuffer.length, audioBuffer.sampleRate)
if (audioBuffer.numberOfChannels === 1) {
monoSrc.copyToChannel(audioBuffer.getChannelData(0), 0)
} else {
const m = new Float32Array(audioBuffer.length)
for (let i = 0; i < audioBuffer.length; i++) {
let s = 0
for (let c = 0; c < audioBuffer.numberOfChannels; c++) {
s += audioBuffer.getChannelData(c)[i]
}
m[i] = s / audioBuffer.numberOfChannels
}
monoSrc.copyToChannel(m, 0)
}
const src = offline.createBufferSource()
src.buffer = monoSrc
src.connect(offline.destination)
src.start(0)
const rendered = await offline.startRendering()
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError')
const pcm = float32ToInt16(rendered.getChannelData(0))
const { Mp3Encoder } = await import('lamejs')
const enc = new Mp3Encoder(1, AUDIO_TARGET_SAMPLE_RATE, AUDIO_MP3_KBPS)
const chunks: BlobPart[] = []
for (let i = 0; i < pcm.length; i += MP3_FRAME_SAMPLES) {
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError')
if (i % (MP3_FRAME_SAMPLES * 200) === 0) {
await new Promise((r) => setTimeout(r, 0))
}
const block = new Int16Array(MP3_FRAME_SAMPLES)
const take = Math.min(MP3_FRAME_SAMPLES, pcm.length - i)
block.set(pcm.subarray(i, i + take))
const mp3 = enc.encodeBuffer(block)
if (mp3.length > 0) {
chunks.push(new Uint8Array(mp3.buffer.slice(mp3.byteOffset, mp3.byteOffset + mp3.byteLength)) as BlobPart)
}
}
const tail = enc.flush()
if (tail.length > 0) {
chunks.push(new Uint8Array(tail.buffer.slice(tail.byteOffset, tail.byteOffset + tail.byteLength)) as BlobPart)
}
const blob = new Blob(chunks, { type: 'audio/mpeg' })
if (blob.size === 0 || blob.size >= file.size * 0.97) return file
const base = file.name.replace(/\.[^.]+$/, '') || 'audio'
return new File([blob], `${base}.mp3`, { type: 'audio/mpeg' })
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') throw e
return file
} finally {
await ctx.close().catch(() => {})
}
}
function pickVideoMime(): string | null {
if (typeof MediaRecorder === 'undefined') return null
// Prefer explicit audio codec where supported (better mux with captured audio).
for (const m of [
'video/webm;codecs=vp9,opus',
'video/webm;codecs=vp8,opus',
'video/webm;codecs=vp9',
'video/webm;codecs=vp8',
'video/webm',
'video/mp4'
] as const) {
if (MediaRecorder.isTypeSupported(m)) return m
}
return null
}
function isMediaRecorderAudioMuxUnsupportedError(e: unknown): boolean {
if (!(e instanceof DOMException) || e.name !== 'NotSupportedError') return false
const m = (e.message || '').toLowerCase()
return m.includes('audio') || m.includes('cannot be recorded')
}
function fileLooksLikeVideo(file: File): boolean {
return file.type.startsWith('video/') || VIDEO_FILENAME_RE.test(file.name)
}
function extensionForRecordedMime(mime: string): string {
if (mime.includes('mp4')) return '.mp4'
return '.webm'
}
type VideoElementWithCapture = HTMLVideoElement & {
captureStream?: () => MediaStream
mozCaptureStream?: () => MediaStream
}
/** Firefox historically used `mozCaptureStream`; some builds expose capture only on instances, not via `in` on prototype. */
function captureStreamFromVideoElement(video: HTMLVideoElement): MediaStream | null {
const v = video as VideoElementWithCapture
if (typeof v.captureStream === 'function') {
try {
return v.captureStream()
} catch {
/* fall through */
}
}
if (typeof v.mozCaptureStream === 'function') {
try {
return v.mozCaptureStream()
} catch {
return null
}
}
return null
}
function waitVideoEvent(el: HTMLVideoElement, name: keyof HTMLMediaElementEventMap): Promise<void> {
return new Promise((resolve, reject) => {
const onOk = () => {
cleanup()
resolve()
}
const onErr = () => {
cleanup()
reject(new Error('video error'))
}
const cleanup = () => {
el.removeEventListener(name, onOk)
el.removeEventListener('error', onErr)
}
el.addEventListener(name, onOk, { once: true })
el.addEventListener('error', onErr, { once: true })
})
}
async function compressVideoToWebm(file: File, signal?: AbortSignal): Promise<File> {
if (!fileLooksLikeVideo(file)) return file
const mime = pickVideoMime()
if (!mime) {
uploadCompressionDiag('video skip: no MediaRecorder MIME supported in this browser')
logger.debug('[compress-upload] MediaRecorder has no supported video MIME in this browser')
return file
}
if (typeof HTMLCanvasElement === 'undefined') {
return file
}
const probeCanvas = document.createElement('canvas')
if (typeof probeCanvas.captureStream !== 'function') {
uploadCompressionDiag('video skip: canvas.captureStream not available')
return file
}
const objUrl = URL.createObjectURL(file)
const video = document.createElement('video')
video.src = objUrl
video.muted = true
video.playsInline = true
video.setAttribute('playsinline', '')
try {
await waitVideoEvent(video, 'loadedmetadata')
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError')
const probeStream = captureStreamFromVideoElement(video)
if (!probeStream) {
uploadCompressionDiag(
'video skip: video.captureStream / mozCaptureStream not available (try another browser or disable strict privacy flags)'
)
return file
}
probeStream.getTracks().forEach((t) => t.stop())
const { duration, videoWidth, videoHeight } = video
if (!Number.isFinite(duration) || duration <= 0 || duration > MAX_VIDEO_DURATION_SEC) {
uploadCompressionDiag('video skip: bad or too long duration', { duration })
logger.debug('[compress-upload] video duration skip', { duration })
return file
}
if (videoWidth < 2 || videoHeight < 2) {
uploadCompressionDiag('video skip: dimensions too small', { videoWidth, videoHeight })
return file
}
const durationSec = Math.max(0.1, duration)
const sourceBitrate = (file.size * 8) / durationSec
const primaryVideoBps = Math.min(
VIDEO_TARGET_BITRATE_MAX,
Math.max(VIDEO_TARGET_BITRATE_MIN, Math.floor(sourceBitrate * 0.42))
)
const seekVideoToStart = async () => {
video.pause()
if (video.currentTime < 0.05) return
await new Promise<void>((resolve, reject) => {
const onSeeked = () => {
video.removeEventListener('seeked', onSeeked)
video.removeEventListener('error', onErr)
resolve()
}
const onErr = () => {
video.removeEventListener('seeked', onSeeked)
video.removeEventListener('error', onErr)
reject(new Error('video seek error'))
}
video.addEventListener('seeked', onSeeked, { once: true })
video.addEventListener('error', onErr, { once: true })
video.currentTime = 0
})
}
const encodePass = async (maxWidthPx: number, videoBitsPerSecond: number): Promise<File | null> => {
const scale = Math.min(1, maxWidthPx / videoWidth)
const w = Math.max(2, Math.floor((videoWidth * scale) / 2) * 2)
const h = Math.max(2, Math.floor((videoHeight * scale) / 2) * 2)
await seekVideoToStart()
const canvas = document.createElement('canvas')
canvas.width = w
canvas.height = h
const ctx = canvas.getContext('2d', { alpha: false })
if (!ctx) return null
const canvasStream = canvas.captureStream(30)
const vs = captureStreamFromVideoElement(video)
const audioTracksFromVideo = vs ? [...vs.getAudioTracks()] : []
const buildRecorder = (stream: MediaStream) => {
const chunks: Blob[] = []
const recorder = new MediaRecorder(stream, {
mimeType: mime,
videoBitsPerSecond,
...(stream.getAudioTracks().length > 0 ? { audioBitsPerSecond: VIDEO_AUDIO_BITRATE } : {})
})
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunks.push(e.data)
}
const stopped = new Promise<void>((resolve, reject) => {
recorder.onstop = () => resolve()
recorder.onerror = () => reject(new Error('MediaRecorder error'))
})
return { recorder, chunks, stopped }
}
let activeStream: MediaStream =
audioTracksFromVideo.length > 0
? new MediaStream([...canvasStream.getVideoTracks(), ...audioTracksFromVideo])
: new MediaStream([...canvasStream.getVideoTracks()])
let { recorder: rec, chunks, stopped } = buildRecorder(activeStream)
try {
rec.start(250)
} catch (startErr) {
if (audioTracksFromVideo.length > 0 && isMediaRecorderAudioMuxUnsupportedError(startErr)) {
uploadCompressionDiag(
'video pass: dropping audio (browser cannot mux source audio with this recorder codec)',
{ maxWidthPx, mime, detail: String((startErr as Error).message) }
)
audioTracksFromVideo.forEach((t) => t.stop())
activeStream = new MediaStream([...canvasStream.getVideoTracks()])
;({ recorder: rec, chunks, stopped } = buildRecorder(activeStream))
try {
rec.start(250)
} catch (e2) {
uploadCompressionDiag('video pass: MediaRecorder.start failed after video-only fallback', {
error: String(e2),
maxWidthPx
})
return null
}
} else {
uploadCompressionDiag('video pass: MediaRecorder.start failed', {
error: String(startErr),
maxWidthPx
})
return null
}
}
try {
await video.play()
} catch (e) {
uploadCompressionDiag('video pass: play() failed', { error: String(e), maxWidthPx })
logger.debug('[compress-upload] video.play() failed', { e })
rec.stop()
await stopped.catch(() => {})
return null
}
try {
await new Promise<void>((resolve, reject) => {
let settled = false
const finish = () => {
if (settled) return
settled = true
try {
ctx.drawImage(video, 0, 0, w, h)
} catch {
/* ignore */
}
resolve()
}
video.addEventListener('ended', finish, { once: true })
video.addEventListener('error', () => reject(new Error('Video playback error')), { once: true })
let frames = 0
const maxFrames = Math.min(Math.ceil(durationSec * 100) + 2000, 500_000)
const step = () => {
if (settled) return
if (signal?.aborted) {
video.pause()
reject(new DOMException('Aborted', 'AbortError'))
return
}
if (video.ended) return
try {
ctx.drawImage(video, 0, 0, w, h)
} catch {
reject(new Error('drawImage failed'))
return
}
frames++
if (frames > maxFrames) {
video.pause()
finish()
return
}
requestAnimationFrame(step)
}
requestAnimationFrame(step)
})
} catch (e) {
rec.stop()
await stopped.catch(() => {})
if (e instanceof DOMException && e.name === 'AbortError') throw e
uploadCompressionDiag('video pass: playback/draw failed', { error: String(e), maxWidthPx })
return null
}
rec.stop()
await stopped
const blob = new Blob(chunks, { type: mime })
if (blob.size === 0) {
uploadCompressionDiag('video pass: empty blob', { maxWidthPx })
return null
}
if (blob.size >= file.size) {
uploadCompressionDiag('video pass: output not smaller than source', {
maxWidthPx,
inBytes: file.size,
outBytes: blob.size
})
return null
}
const base = file.name.replace(/\.[^.]+$/, '') || 'video'
const ext = extensionForRecordedMime(mime)
return new File([blob], `${base}${ext}`, { type: mime })
}
const attempts: { maxW: number; bps: number }[] = [
{ maxW: MAX_VIDEO_WIDTH_PX, bps: primaryVideoBps },
{ maxW: 854, bps: VIDEO_TARGET_BITRATE_MIN },
{ maxW: 640, bps: VIDEO_TARGET_BITRATE_MIN }
]
for (const { maxW, bps } of attempts) {
const out = await encodePass(maxW, bps)
if (out) {
uploadCompressionDiag('video: re-encoded for upload', {
inBytes: file.size,
outBytes: out.size,
mime,
maxWidthPx: maxW,
outName: out.name
})
return out
}
}
uploadCompressionDiag('video skip: all passes failed or did not beat source size', {
inBytes: file.size,
mime
})
logger.debug('[compress-upload] video re-encode: all passes kept original')
return file
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') throw e
uploadCompressionDiag('video skip: encode pipeline error', { error: String(e) })
logger.debug('[compress-upload] video compress failed', { e })
return file
} finally {
URL.revokeObjectURL(objUrl)
video.removeAttribute('src')
video.load()
}
}
export type CompressMediaOptions = {
signal?: AbortSignal
/** Raster images are scaled/encoded until under this size when possible (default 2 MiB — fits typical profile `picture` limits). */
imageTargetMaxBytes?: number
}
/** Default cap for raster image uploads (profile pics and inline media). */
export const DEFAULT_IMAGE_UPLOAD_MAX_BYTES = 2 * 1024 * 1024
/**
* Compress media before upload. Non-media types are returned unchanged.
*/
export async function compressMediaForUpload(file: File, options?: CompressMediaOptions): Promise<File> {
const signal = options?.signal
const imageTarget = options?.imageTargetMaxBytes ?? DEFAULT_IMAGE_UPLOAD_MAX_BYTES
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError')
let branch: 'image' | 'audio' | 'video' | 'none' = 'none'
let out: File = file
if (file.type.startsWith('image/')) {
branch = 'image'
out = await compressImage(file, imageTarget)
} else if (file.type.startsWith('audio/') || fileLooksLikeMatroskaAudio(file)) {
branch = 'audio'
out = await compressAudioToMp3(file, signal)
} else if (fileLooksLikeVideo(file)) {
branch = 'video'
out = await compressVideoToWebm(file, signal)
}
uploadCompressionDiag('compressMediaForUpload result', {
branch,
inName: file.name,
inBytes: file.size,
inType: file.type || '(empty)',
outBytes: out.size,
outType: out.type || '(empty)',
outName: out.name,
changed: out !== file || out.size !== file.size || out.name !== file.name
})
return out
}

3
src/lib/image-extraction.ts

@ -76,7 +76,8 @@ export function extractAllImagesFromEvent(event: Event): TImetaInfo[] { @@ -76,7 +76,8 @@ export function extractAllImagesFromEvent(event: Event): TImetaInfo[] {
}
// 7. Extract from content - general URL patterns that look like media
const mediaUrlRegex = /https?:\/\/[^\s<>"']+\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff|ico|mp4|webm|ogg|avi|mov|wmv|flv|mkv)(?:\?[^\s<>"']*)?/gi
const mediaUrlRegex =
/https?:\/\/[^\s<>"']+\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff|ico|mp4|webm|ogg|avi|mov|wmv|flv|mkv|mka)(?:\?[^\s<>"']*)?/gi
while ((match = mediaUrlRegex.exec(event.content)) !== null) {
addMedia(match[0])
}

17
src/lib/media-kind-detection.ts

@ -18,9 +18,16 @@ export async function getMediaKindFromFile(file: File, isReply: boolean = false) @@ -18,9 +18,16 @@ export async function getMediaKindFromFile(file: File, isReply: boolean = false)
// Check if it's audio or video
// mp4, m4a, and webm files can be either audio or video, so check MIME type first
// Mobile browsers may report m4a files as audio/m4a, audio/mp4, audio/x-m4a, or even video/mp4
const isAudioMime = fileType.startsWith('audio/') || fileType === 'audio/mp4' || fileType === 'audio/x-m4a' || fileType === 'audio/m4a' || fileType === 'audio/webm' || fileType === 'audio/mpeg'
const isAudioMime =
fileType.startsWith('audio/') ||
fileType === 'audio/mp4' ||
fileType === 'audio/x-m4a' ||
fileType === 'audio/m4a' ||
fileType === 'audio/webm' ||
fileType === 'audio/mpeg' ||
fileType === 'audio/x-matroska'
const isVideoMime = fileType.startsWith('video/')
const isAudioExt = /\.(mp3|m4a|ogg|wav|opus|aac|flac|mpeg|mp4)$/i.test(fileName)
const isAudioExt = /\.(mp3|m4a|mka|ogg|wav|opus|aac|flac|mpeg|mp4)$/i.test(fileName)
const isVideoExt = /\.(mp4|ogg|mov|avi|mkv|m4v)$/i.test(fileName)
// m4a files are always audio, even if MIME type is video/mp4 (mobile browsers sometimes report this)
@ -66,7 +73,11 @@ export async function getMediaKindFromFile(file: File, isReply: boolean = false) @@ -66,7 +73,11 @@ export async function getMediaKindFromFile(file: File, isReply: boolean = false)
function getMediaDuration(file: File): Promise<number> {
return new Promise((resolve) => {
const url = URL.createObjectURL(file)
const media = document.createElement(file.type.startsWith('audio/') ? 'audio' : 'video')
const useAudio =
file.type.startsWith('audio/') ||
file.type === 'audio/x-matroska' ||
/\.mka$/i.test(file.name)
const media = document.createElement(useAudio ? 'audio' : 'video')
media.onloadedmetadata = () => {
const duration = media.duration || 0

5
src/lib/url.ts

@ -278,6 +278,8 @@ export function isMedia(url: string) { @@ -278,6 +278,8 @@ export function isMedia(url: string) {
'.webm',
'.ogg',
'.mov',
'.mkv',
'.mka',
'.mp3',
'.wav',
'.flac',
@ -304,7 +306,8 @@ export function isAudio(url: string) { @@ -304,7 +306,8 @@ export function isAudio(url: string) {
'.wma',
'.ogg', // ogg can be audio
'.webm', // webm can be audio (when uploaded via microphone button)
'.mp4' // mp4 can be audio (m4a files)
'.mp4', // mp4 can be audio (m4a files)
'.mka'
]
return audioExtensions.some((ext) => new URL(url).pathname.toLowerCase().endsWith(ext))
} catch {

2
src/pages/secondary/ProfileEditorPage/index.tsx

@ -458,7 +458,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -458,7 +458,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
onUploadStart={() => setUploadingAvatar(true)}
onUploadEnd={() => setUploadingAvatar(false)}
className="w-24 h-24 absolute bottom-0 left-4 translate-y-1/2 border-4 border-background cursor-pointer rounded-full"
accept="image/*,video/mp4,video/webm,video/quicktime"
accept="image/*,video/mp4,video/webm,video/quicktime,video/x-matroska,.mkv"
maxFileSizeMb={2}
>
<div className="w-full h-full overflow-hidden rounded-full bg-muted">

22
src/services/media-upload.service.ts

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
import { compressImage } from '@/lib/compress-image'
/** Compression runs entirely in-app before upload (`compress-upload-media`). */
import { compressMediaForUpload } from '@/lib/compress-upload-media'
import { fetchWithTimeout } from '@/lib/fetch-with-timeout'
import { simplifyUrl } from '@/lib/url'
import { TDraftEvent, TMediaUploadServiceConfig } from '@/types'
@ -33,8 +34,23 @@ class MediaUploadService { @@ -33,8 +34,23 @@ class MediaUploadService {
}
async upload(file: File, options?: UploadOptions) {
// Compress images before upload: target ≤ 4 MB, down-scale to 2048 px max edge.
const toUpload = await compressImage(file, 4 * 1024 * 1024)
const toUpload = await compressMediaForUpload(file, { signal: options?.signal })
try {
const diag =
import.meta.env.DEV ||
(typeof localStorage !== 'undefined' && localStorage.getItem('jumble-upload-log') === 'true')
if (diag) {
console.log('[media-upload] sending to server', {
backend: this.serviceConfig.type,
bytes: toUpload.size,
name: toUpload.name,
type: toUpload.type || '(empty)'
})
}
} catch {
// ignore
}
let result: { url: string; tags: string[][] }
if (this.serviceConfig.type === 'nip96') {

10
src/types/lamejs.d.ts vendored

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
declare module 'lamejs' {
export class Mp3Encoder {
constructor(channels: number, samplerate: number, kbps: number)
encodeBuffer(left: Int16Array, right?: Int16Array): Int8Array
flush(): Int8Array
}
export class WavHeader {
static readHeader(dataView: DataView): WavHeader | undefined
}
}
Loading…
Cancel
Save