From 3342678c9dd34acfebbf9c6fa627b9646fb21377 Mon Sep 17 00:00:00 2001 From: Xevion Date: Mon, 29 Dec 2025 18:15:31 -0600 Subject: [PATCH] feat: add automatic media validation and fixing to share tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detects and fixes common media issues before upload: - Missing video duration/metadata (remux) - MP4 streaming optimization (faststart) - Incompatible codecs (H.265/AV1 → H.264) - HEIC/AVIF browser compatibility Adds -F/--fix flag with modes: never, prompt (default), always --- home/dot_local/bin/executable_share.ts | 448 ++++++++++++++++++++++++- 1 file changed, 439 insertions(+), 9 deletions(-) diff --git a/home/dot_local/bin/executable_share.ts b/home/dot_local/bin/executable_share.ts index 447e95a..4909430 100755 --- a/home/dot_local/bin/executable_share.ts +++ b/home/dot_local/bin/executable_share.ts @@ -39,6 +39,27 @@ interface UploadResult { size: number; } +type FixMode = 'never' | 'prompt' | 'always'; + +interface MediaIssue { + id: string; + description: string; + severity: 'error' | 'warning'; + autoFix: boolean; // true = instant/lossless, false = slow/lossy + fix: (buffer: Buffer) => Promise; +} + +interface ProbeResult { + issues: MediaIssue[]; + metadata: Record; +} + +interface VideoProbe { + duration: number | null; + codec: string | null; + hasFaststart: boolean; +} + type TimeoutHandle = ReturnType; type IntervalHandle = ReturnType; @@ -62,8 +83,11 @@ const SIZE_THRESHOLDS = { image: 10 * 1024 * 1024, video: 50 * 1024 * 1024, binary: 100 * 1024 * 1024, + reencodeAuto: 10 * 1024 * 1024, // Auto re-encode incompatible codecs below this size }; +const INCOMPATIBLE_VIDEO_CODECS = ['hevc', 'h265', 'av1']; + const COLORS = { spinner: ['#A5D8DD', '#9DCCB4', '#B8D99A', '#E8D4A2', '#F4B8A4', '#F5A6A6'], success: '#9DCCB4', @@ -84,6 +108,7 @@ const MIME_EXTENSIONS: Record = { 'image/tiff': 'tiff', 'image/heic': 'heic', 'image/heif': 'heif', + 'image/avif': 'avif', 'video/mp4': 'mp4', 'video/webm': 'webm', 'video/quicktime': 'mov', @@ -103,6 +128,7 @@ const EXTENSION_MIMES: Record = { 'tiff': 'image/tiff', 'heic': 'image/heic', 'heif': 'image/heif', + 'avif': 'image/avif', 'mp4': 'video/mp4', 'webm': 'video/webm', 'mov': 'video/quicktime', @@ -508,6 +534,368 @@ async function cleanupTempFiles(...paths: string[]): Promise { } } +// ============================================================================ +// Media Validation +// ============================================================================ + +async function hasCommand(cmd: string): Promise { + try { + await $`which ${cmd}`.quiet(); + return true; + } catch { + return false; + } +} + +async function writeTempFile(buffer: Buffer, ext = ''): Promise { + const path = join(tmpdir(), `share-probe-${nanoid(8)}${ext}`); + await Bun.write(path, buffer); + return path; +} + +function parseFixMode(value: string | undefined): FixMode { + if (!value) return 'prompt'; + const v = value.toLowerCase(); + if (v.startsWith('n')) return 'never'; + if (v.startsWith('a')) return 'always'; + return 'prompt'; +} + +function isAudioMime(mime: string): boolean { + return mime.startsWith('audio/'); +} + +async function probeVideoMetadata(buffer: Buffer): Promise { + if (!await hasCommand('ffprobe')) { + debug('ffprobe not found, skipping video validation'); + return null; + } + + const tmpPath = await writeTempFile(buffer); + try { + const result = await $`ffprobe -v error -show_format -show_streams -of json ${tmpPath}`.json(); + const format = result.format || {}; + const videoStream = result.streams?.find((s: { codec_type: string }) => s.codec_type === 'video'); + + // Check for faststart by looking at format tags or probing atom order + // A proper check would require parsing the file, but we can infer from tags + const hasFaststart = format.tags?.major_brand === 'isom' || + format.format_name?.includes('mov') && format.tags?.compatible_brands?.includes('isom'); + + return { + duration: format.duration ? parseFloat(format.duration) : null, + codec: videoStream?.codec_name?.toLowerCase() || null, + hasFaststart: !!hasFaststart, + }; + } catch (e) { + debug('ffprobe failed', e instanceof Error ? e.message : String(e)); + return null; + } finally { + await cleanupTempFiles(tmpPath); + } +} + +async function probeVideo(buffer: Buffer): Promise { + const probe = await probeVideoMetadata(buffer); + if (!probe) return null; + + const issues: MediaIssue[] = []; + + // Missing duration metadata - remux fixes this and also adds faststart + if (probe.duration === null) { + issues.push({ + id: 'missing-duration', + description: 'Missing duration metadata', + severity: 'error', + autoFix: true, + fix: remuxVideo, + }); + // Skip faststart check since remux handles it + } else if (!probe.hasFaststart) { + // Only check faststart if duration exists (otherwise remux already handles it) + issues.push({ + id: 'missing-faststart', + description: 'MP4 not optimized for streaming', + severity: 'warning', + autoFix: true, + fix: addFaststart, + }); + } + + // Incompatible codec - this is separate since it requires re-encoding + if (probe.codec && INCOMPATIBLE_VIDEO_CODECS.includes(probe.codec)) { + const isSmall = buffer.length < SIZE_THRESHOLDS.reencodeAuto; + issues.push({ + id: 'incompatible-codec', + description: `Codec '${probe.codec}' has limited browser/Discord support`, + severity: 'warning', + autoFix: isSmall, + fix: reencodeVideo, + }); + } + + return { issues, metadata: probe }; +} + +async function probeImage(_buffer: Buffer, mime: string): Promise { + const issues: MediaIssue[] = []; + + if (mime === 'image/heic' || mime === 'image/heif') { + issues.push({ + id: 'heic-compat', + description: 'HEIC not supported in browsers', + severity: 'error', + autoFix: true, + fix: convertImageToJpeg, + }); + } + + if (mime === 'image/avif') { + issues.push({ + id: 'avif-compat', + description: 'AVIF has limited browser support', + severity: 'warning', + autoFix: true, + fix: convertImageToWebp, + }); + } + + return issues.length > 0 ? { issues, metadata: { originalMime: mime } } : null; +} + +async function probeAudio(buffer: Buffer): Promise { + if (!await hasCommand('ffprobe')) { + debug('ffprobe not found, skipping audio validation'); + return null; + } + + const tmpPath = await writeTempFile(buffer); + try { + const result = await $`ffprobe -v error -show_format -of json ${tmpPath}`.json(); + const format = result.format || {}; + const issues: MediaIssue[] = []; + + if (!format.duration || format.duration === 'N/A') { + issues.push({ + id: 'missing-duration', + description: 'Missing duration metadata', + severity: 'error', + autoFix: true, + fix: remuxAudio, + }); + } + + return issues.length > 0 ? { issues, metadata: format } : null; + } catch (e) { + debug('ffprobe failed for audio', e instanceof Error ? e.message : String(e)); + return null; + } finally { + await cleanupTempFiles(tmpPath); + } +} + +async function validateMedia(buffer: Buffer, mimeType: string): Promise { + if (isVideoMime(mimeType)) { + return probeVideo(buffer); + } else if (isImageMime(mimeType)) { + return probeImage(buffer, mimeType); + } else if (isAudioMime(mimeType)) { + return probeAudio(buffer); + } + return null; +} + +async function applyFixes( + buffer: Buffer, + mimeType: string, + issues: MediaIssue[], + mode: FixMode, + skipPrompts: boolean +): Promise<{ buffer: Buffer; mimeType: string; applied: string[] }> { + if (mode === 'never' || issues.length === 0) { + for (const issue of issues) { + log(`Skipped: ${issue.description}`, 'dim'); + } + return { buffer, mimeType, applied: [] }; + } + + const applied: string[] = []; + + for (const issue of issues) { + const shouldAuto = mode === 'always' || (mode === 'prompt' && issue.autoFix); + + if (shouldAuto) { + log(`Fixing: ${issue.description}`, 'info'); + buffer = await issue.fix(buffer); + applied.push(issue.id); + } else if (mode === 'prompt') { + if (skipPrompts) { + log(`Skipped: ${issue.description}`, 'dim'); + continue; + } + + const proceed = await confirm(`Fix: ${issue.description}?`); + if (proceed) { + buffer = await issue.fix(buffer); + applied.push(issue.id); + } + } + } + + // Update mimeType based on applied fixes + if (applied.includes('heic-compat')) { + mimeType = 'image/jpeg'; + } else if (applied.includes('avif-compat')) { + mimeType = 'image/webp'; + } else if (applied.includes('incompatible-codec')) { + mimeType = 'video/mp4'; + } + + return { buffer, mimeType, applied }; +} + +// ============================================================================ +// Media Fix Functions +// ============================================================================ + +async function remuxVideo(buffer: Buffer): Promise { + const spinner = new Spinner(); + spinner.start('Remuxing video...'); + + const inputPath = await writeTempFile(buffer); + const outputPath = join(tmpdir(), `share-remux-${nanoid(8)}.webm`); + + try { + // Remux without re-encoding, adding faststart for MP4 + await $`ffmpeg -y -i ${inputPath} -c copy -fflags +genpts ${outputPath}`.quiet(); + + const result = Buffer.from(await Bun.file(outputPath).arrayBuffer()); + spinner.stop(); + log('Fixed video metadata', 'success'); + return result; + } catch (e) { + spinner.stop(); + throw new Error(`Video remux failed: ${e instanceof Error ? e.message : String(e)}`); + } finally { + await cleanupTempFiles(inputPath, outputPath); + } +} + +async function addFaststart(buffer: Buffer): Promise { + const spinner = new Spinner(); + spinner.start('Optimizing for streaming...'); + + const inputPath = await writeTempFile(buffer); + const outputPath = join(tmpdir(), `share-faststart-${nanoid(8)}.mp4`); + + try { + await $`ffmpeg -y -i ${inputPath} -c copy -movflags +faststart ${outputPath}`.quiet(); + + const result = Buffer.from(await Bun.file(outputPath).arrayBuffer()); + spinner.stop(); + log('Optimized for streaming', 'success'); + return result; + } catch (e) { + spinner.stop(); + throw new Error(`Faststart optimization failed: ${e instanceof Error ? e.message : String(e)}`); + } finally { + await cleanupTempFiles(inputPath, outputPath); + } +} + +async function reencodeVideo(buffer: Buffer): Promise { + const spinner = new Spinner(); + spinner.start('Re-encoding video for compatibility...'); + + const inputPath = await writeTempFile(buffer); + const outputPath = join(tmpdir(), `share-reencode-${nanoid(8)}.mp4`); + + try { + await $`ffmpeg -y -i ${inputPath} -c:v libx264 -crf 23 -preset medium -c:a aac -b:a 128k -movflags +faststart ${outputPath}`.quiet(); + + const result = Buffer.from(await Bun.file(outputPath).arrayBuffer()); + spinner.stop(); + log('Re-encoded to H.264', 'success'); + return result; + } catch (e) { + spinner.stop(); + throw new Error(`Video re-encode failed: ${e instanceof Error ? e.message : String(e)}`); + } finally { + await cleanupTempFiles(inputPath, outputPath); + } +} + +async function remuxAudio(buffer: Buffer): Promise { + const spinner = new Spinner(); + spinner.start('Fixing audio metadata...'); + + const inputPath = await writeTempFile(buffer); + // Detect format and use same extension + const outputPath = join(tmpdir(), `share-audio-${nanoid(8)}.mka`); + + try { + await $`ffmpeg -y -i ${inputPath} -c copy ${outputPath}`.quiet(); + + const result = Buffer.from(await Bun.file(outputPath).arrayBuffer()); + spinner.stop(); + log('Fixed audio metadata', 'success'); + return result; + } catch (e) { + spinner.stop(); + throw new Error(`Audio remux failed: ${e instanceof Error ? e.message : String(e)}`); + } finally { + await cleanupTempFiles(inputPath, outputPath); + } +} + +async function convertImageToJpeg(buffer: Buffer): Promise { + const spinner = new Spinner(); + spinner.start('Converting to JPEG...'); + + const inputPath = await writeTempFile(buffer); + const outputPath = join(tmpdir(), `share-convert-${nanoid(8)}.jpg`); + + try { + await $`convert ${inputPath} -quality 90 ${outputPath}`.quiet(); + + const result = Buffer.from(await Bun.file(outputPath).arrayBuffer()); + spinner.stop(); + log('Converted to JPEG', 'success'); + return result; + } catch (e) { + spinner.stop(); + throw new Error(`JPEG conversion failed: ${e instanceof Error ? e.message : String(e)}`); + } finally { + await cleanupTempFiles(inputPath, outputPath); + } +} + +async function convertImageToWebp(buffer: Buffer): Promise { + const spinner = new Spinner(); + spinner.start('Converting to WebP...'); + + const inputPath = await writeTempFile(buffer); + const outputPath = join(tmpdir(), `share-convert-${nanoid(8)}.webp`); + + try { + await $`convert ${inputPath} -quality 85 ${outputPath}`.quiet(); + + const result = Buffer.from(await Bun.file(outputPath).arrayBuffer()); + spinner.stop(); + log('Converted to WebP', 'success'); + return result; + } catch (e) { + spinner.stop(); + throw new Error(`WebP conversion failed: ${e instanceof Error ? e.message : String(e)}`); + } finally { + await cleanupTempFiles(inputPath, outputPath); + } +} + +// ============================================================================ +// Original Conversion Functions (kept for -c flag compatibility) +// ============================================================================ + async function convertImage(buffer: Buffer, fromMime: string, shouldConvert: boolean): Promise { const needsConversion = fromMime === 'image/bmp' || (shouldConvert && (fromMime === 'image/heic' || fromMime === 'image/heif' || fromMime === 'image/tiff')); @@ -668,6 +1056,7 @@ async function main() { options: { verbose: { type: 'boolean', short: 'v' }, convert: { type: 'boolean', short: 'c' }, + fix: { type: 'string', short: 'F' }, yes: { type: 'boolean', short: 'y' }, name: { type: 'string', short: 'n' }, help: { type: 'boolean', short: 'h' }, @@ -687,16 +1076,32 @@ ${chalk.hex(COLORS.label)('Arguments:')} ${chalk.hex(COLORS.label)('Options:')} -v, --verbose Enable debug output - -c, --convert Convert media before upload (ImageMagick/ffmpeg) + -c, --convert Convert media before upload (re-encode video, etc.) + -F, --fix Fix media issues: n=never, p=prompt (default), a=always -y, --yes Skip confirmation prompts -n, --name Custom filename (without extension) -h, --help Show this help +${chalk.hex(COLORS.label)('Fix Modes:')} + -Fn, --fix=never Skip all fixes, upload as-is + -Fp, --fix=prompt Auto-fix quick/lossless, prompt for slow/lossy (default) + -Fa, --fix=always Apply all fixes automatically + +${chalk.hex(COLORS.label)('Media Fixes (auto-applied by default):')} + • Missing video duration/metadata (remux) + • MP4 streaming optimization (faststart) + • HEIC/AVIF browser compatibility (convert to JPEG/WebP) + +${chalk.hex(COLORS.label)('Media Fixes (prompted or with -Fa):')} + • Video re-encoding for codec compatibility (H.265/AV1 → H.264) + • Large file optimizations + ${chalk.hex(COLORS.label)('Examples:')} share # Upload clipboard content share screenshot.png # Upload specific file cat file.txt | share # Upload from stdin - share -c large-video.mov # Convert then upload + share -c large-video.mov # Convert/re-encode then upload + share -Fa video.webm # Fix all issues automatically ${chalk.hex(COLORS.label)('Environment Variables:')} R2_ENDPOINT S3-compatible endpoint URL @@ -746,6 +1151,33 @@ ${chalk.hex(COLORS.label)('Environment Variables:')} filename = `${baseName}-${nanoid(8)}.${ext}`; } + // Determine fix mode: -c implies -Fa (always fix), otherwise parse -F flag + const fixMode = values.convert ? 'always' : parseFixMode(values.fix); + + // Validate and fix media issues + if (fixMode !== 'never') { + const probeResult = await validateMedia(buffer, mimeType); + + if (probeResult && probeResult.issues.length > 0) { + debug('Media issues detected', probeResult.metadata); + + const fixResult = await applyFixes( + buffer, + mimeType, + probeResult.issues, + fixMode, + values.yes || false + ); + + buffer = fixResult.buffer; + mimeType = fixResult.mimeType; + + if (fixResult.applied.length > 0) { + debug('Applied fixes', fixResult.applied); + } + } + } + if (!values.yes) { let needsConfirm = false; let reason = ''; @@ -770,14 +1202,12 @@ ${chalk.hex(COLORS.label)('Environment Variables:')} } } - if (isImageMime(mimeType)) { - buffer = await convertImage(buffer, mimeType, values.convert || false); - if (mimeType === 'image/bmp') { - mimeType = 'image/png'; - } else if (values.convert && (mimeType === 'image/heic' || mimeType === 'image/heif')) { - mimeType = 'image/jpeg'; - } + // Legacy conversion path: BMP always converts, -c triggers full re-encode + if (isImageMime(mimeType) && mimeType === 'image/bmp') { + buffer = await convertImage(buffer, mimeType, false); + mimeType = 'image/png'; } else if (isVideoMime(mimeType) && values.convert) { + // Full video conversion (re-encode with scaling, etc.) only with -c buffer = await convertVideo(buffer); mimeType = 'video/mp4'; }