mirror of
https://github.com/Xevion/dotfiles.git
synced 2026-01-31 12:24:08 -06:00
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
1253 lines
36 KiB
TypeScript
Executable File
1253 lines
36 KiB
TypeScript
Executable File
#!/usr/bin/env -S bun --install=fallback
|
|
|
|
/**
|
|
* share - Upload files to R2 and copy the URL to clipboard
|
|
*
|
|
* Usage:
|
|
* share # Upload clipboard content
|
|
* share file.png # Upload specific file
|
|
* cat file.txt | share # Upload from stdin
|
|
* share -c video.mov # Convert then upload
|
|
*
|
|
* Environment Variables:
|
|
* R2_ENDPOINT S3-compatible endpoint URL
|
|
* R2_ACCESS_KEY_ID Access key ID
|
|
* R2_SECRET_ACCESS_KEY Secret access key
|
|
* R2_BUCKET Bucket name
|
|
*/
|
|
|
|
import { existsSync, fstatSync } from 'fs';
|
|
import { tmpdir, platform } from 'os';
|
|
import { join, basename, extname } from 'path';
|
|
import { parseArgs } from 'util';
|
|
import chalk from 'chalk';
|
|
import { S3Client } from '@aws-sdk/client-s3';
|
|
import { Upload } from '@aws-sdk/lib-storage';
|
|
import { nanoid } from 'nanoid';
|
|
import { fileTypeFromBuffer } from 'file-type';
|
|
import { $ } from 'bun';
|
|
|
|
interface UploadSource {
|
|
buffer: Buffer;
|
|
filename?: string;
|
|
mimeType?: string;
|
|
}
|
|
|
|
interface UploadResult {
|
|
url: string;
|
|
key: string;
|
|
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<Buffer>;
|
|
}
|
|
|
|
interface ProbeResult {
|
|
issues: MediaIssue[];
|
|
metadata: Record<string, unknown>;
|
|
}
|
|
|
|
interface VideoProbe {
|
|
duration: number | null;
|
|
codec: string | null;
|
|
hasFaststart: boolean;
|
|
}
|
|
|
|
type TimeoutHandle = ReturnType<typeof setTimeout>;
|
|
type IntervalHandle = ReturnType<typeof setInterval>;
|
|
|
|
const DOMAIN = 'https://i.xevion.dev';
|
|
|
|
const REQUIRED_ENV = ['R2_ENDPOINT', 'R2_ACCESS_KEY_ID', 'R2_SECRET_ACCESS_KEY', 'R2_BUCKET'];
|
|
const ENV = {
|
|
endpoint: process.env.R2_ENDPOINT || '',
|
|
accessKeyId: process.env.R2_ACCESS_KEY_ID || '',
|
|
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY || '',
|
|
bucket: process.env.R2_BUCKET || '',
|
|
};
|
|
|
|
const TIMING = {
|
|
spinnerFrame: 80,
|
|
requestTimeout: 30_000,
|
|
uploadTimeout: 60_000,
|
|
};
|
|
|
|
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',
|
|
error: '#E89999',
|
|
label: '#6B7280',
|
|
dim: '#9CA3AF',
|
|
progressFilled: '#A5D8DD',
|
|
progressEmpty: '#374151',
|
|
};
|
|
|
|
const MIME_EXTENSIONS: Record<string, string> = {
|
|
'image/png': 'png',
|
|
'image/jpeg': 'jpg',
|
|
'image/jpg': 'jpg',
|
|
'image/gif': 'gif',
|
|
'image/webp': 'webp',
|
|
'image/bmp': 'bmp',
|
|
'image/tiff': 'tiff',
|
|
'image/heic': 'heic',
|
|
'image/heif': 'heif',
|
|
'image/avif': 'avif',
|
|
'video/mp4': 'mp4',
|
|
'video/webm': 'webm',
|
|
'video/quicktime': 'mov',
|
|
'video/x-matroska': 'mkv',
|
|
'text/plain': 'txt',
|
|
'application/json': 'json',
|
|
'application/pdf': 'pdf',
|
|
};
|
|
|
|
const EXTENSION_MIMES: Record<string, string> = {
|
|
'png': 'image/png',
|
|
'jpg': 'image/jpeg',
|
|
'jpeg': 'image/jpeg',
|
|
'gif': 'image/gif',
|
|
'webp': 'image/webp',
|
|
'bmp': 'image/bmp',
|
|
'tiff': 'image/tiff',
|
|
'heic': 'image/heic',
|
|
'heif': 'image/heif',
|
|
'avif': 'image/avif',
|
|
'mp4': 'video/mp4',
|
|
'webm': 'video/webm',
|
|
'mov': 'video/quicktime',
|
|
'mkv': 'video/x-matroska',
|
|
'txt': 'text/plain',
|
|
'json': 'application/json',
|
|
'pdf': 'application/pdf',
|
|
'js': 'application/javascript',
|
|
'ts': 'application/typescript',
|
|
'rs': 'text/x-rust',
|
|
'py': 'text/x-python',
|
|
'md': 'text/markdown',
|
|
'html': 'text/html',
|
|
'css': 'text/css',
|
|
};
|
|
|
|
const TRUTHY = ['y', 'yes', 'true', 't', 'Y', 'YES', 'TRUE', 'T'];
|
|
const FALSY = ['n', 'no', 'false', 'f', 'N', 'NO', 'FALSE', 'F', 'deny'];
|
|
|
|
let VERBOSE = false;
|
|
let usedStdin = false;
|
|
|
|
const IS_WSL = await (async () => {
|
|
try {
|
|
const text = await Bun.file('/proc/version').text();
|
|
return text.toLowerCase().includes('microsoft');
|
|
} catch {
|
|
return false;
|
|
}
|
|
})();
|
|
|
|
const IS_WINDOWS = platform() === 'win32';
|
|
|
|
class Spinner {
|
|
private frames = ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'];
|
|
private colors = COLORS.spinner;
|
|
private index = 0;
|
|
private interval: IntervalHandle | null = null;
|
|
|
|
start(message: string) {
|
|
process.stdout.write('\x1B[?25l');
|
|
|
|
this.interval = setInterval(() => {
|
|
const colorIndex = this.index % this.colors.length;
|
|
const frame = chalk.hex(this.colors[colorIndex])(this.frames[this.index]);
|
|
process.stdout.write(`\r${frame} ${chalk.hex(COLORS.dim)(message)}`);
|
|
this.index = (this.index + 1) % this.frames.length;
|
|
}, TIMING.spinnerFrame);
|
|
}
|
|
|
|
stop(clearLine = true) {
|
|
if (this.interval) {
|
|
clearInterval(this.interval);
|
|
if (clearLine) {
|
|
process.stdout.write('\r\x1B[K');
|
|
}
|
|
process.stdout.write('\x1B[?25h');
|
|
}
|
|
}
|
|
}
|
|
|
|
function restoreCursor() {
|
|
process.stdout.write('\x1B[?25h');
|
|
}
|
|
|
|
process.on('SIGINT', () => {
|
|
restoreCursor();
|
|
process.exit(130);
|
|
});
|
|
|
|
process.on('SIGTERM', () => {
|
|
restoreCursor();
|
|
process.exit(143);
|
|
});
|
|
|
|
process.on('uncaughtException', (err) => {
|
|
restoreCursor();
|
|
console.error(err);
|
|
process.exit(1);
|
|
});
|
|
|
|
class ProgressBar {
|
|
private total: number;
|
|
private barWidth = 30;
|
|
|
|
constructor(total: number) {
|
|
this.total = Math.max(1, total);
|
|
}
|
|
|
|
update(loaded: number) {
|
|
const percentage = Math.min(100, Math.floor((loaded / this.total) * 100));
|
|
const filled = Math.floor((loaded / this.total) * this.barWidth);
|
|
const empty = this.barWidth - filled;
|
|
|
|
const bar =
|
|
chalk.hex(COLORS.progressFilled)('█'.repeat(filled)) +
|
|
chalk.hex(COLORS.progressEmpty)('░'.repeat(empty));
|
|
|
|
const stats = `${formatBytes(loaded)}/${formatBytes(this.total)}`;
|
|
|
|
process.stdout.write(`\r${bar} ${percentage}% · ${stats}`);
|
|
}
|
|
|
|
finish() {
|
|
process.stdout.write('\r\x1B[K');
|
|
}
|
|
}
|
|
|
|
function formatBytes(bytes: number): string {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
|
}
|
|
|
|
function log(message: string, type: 'success' | 'error' | 'info' | 'dim' = 'info') {
|
|
const icons = {
|
|
success: chalk.hex(COLORS.success)('✓'),
|
|
error: chalk.hex(COLORS.error)('✗'),
|
|
info: chalk.hex(COLORS.dim)('→'),
|
|
dim: chalk.hex(COLORS.dim)('·'),
|
|
};
|
|
console.log(`${icons[type]} ${message}`);
|
|
}
|
|
|
|
function debug(message: string, data?: unknown) {
|
|
if (!VERBOSE) return;
|
|
console.error(chalk.hex(COLORS.label)('[debug]'), message);
|
|
if (data !== undefined) {
|
|
console.error(chalk.hex(COLORS.dim)(JSON.stringify(data, null, 2)));
|
|
}
|
|
}
|
|
|
|
async function detectMimeType(buffer: Buffer, filename?: string): Promise<string> {
|
|
const result = await fileTypeFromBuffer(buffer);
|
|
if (result) {
|
|
debug('Detected MIME via file-type', { mime: result.mime });
|
|
return result.mime;
|
|
}
|
|
|
|
if (filename) {
|
|
const ext = extname(filename).slice(1).toLowerCase();
|
|
if (EXTENSION_MIMES[ext]) {
|
|
debug('Detected MIME via extension', { ext, mime: EXTENSION_MIMES[ext] });
|
|
return EXTENSION_MIMES[ext];
|
|
}
|
|
}
|
|
|
|
debug('Defaulting to application/octet-stream');
|
|
return 'application/octet-stream';
|
|
}
|
|
|
|
function getExtensionForMime(mime: string): string {
|
|
return MIME_EXTENSIONS[mime] || 'bin';
|
|
}
|
|
|
|
function getMimeForExtension(ext: string): string {
|
|
const normalized = ext.replace(/^\.+/, '');
|
|
return EXTENSION_MIMES[normalized] || 'application/octet-stream';
|
|
}
|
|
|
|
function isTextMime(mime: string): boolean {
|
|
return mime.startsWith('text/') ||
|
|
mime === 'application/json' ||
|
|
mime === 'application/javascript' ||
|
|
mime === 'application/typescript';
|
|
}
|
|
|
|
function isImageMime(mime: string): boolean {
|
|
return mime.startsWith('image/');
|
|
}
|
|
|
|
function isVideoMime(mime: string): boolean {
|
|
return mime.startsWith('video/');
|
|
}
|
|
|
|
async function readFromFile(path: string): Promise<UploadSource> {
|
|
const spinner = new Spinner();
|
|
spinner.start('Reading file...');
|
|
|
|
try {
|
|
if (!existsSync(path)) {
|
|
throw new Error(`File not found: ${path}`);
|
|
}
|
|
|
|
const buffer = Buffer.from(await Bun.file(path).arrayBuffer());
|
|
const filename = basename(path);
|
|
const mimeType = await detectMimeType(buffer, filename);
|
|
|
|
spinner.stop();
|
|
debug('Read file', { path, size: buffer.length, mimeType });
|
|
|
|
return { buffer, filename, mimeType };
|
|
} catch (e) {
|
|
spinner.stop();
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
function hasStdinData(): boolean {
|
|
if (Bun.stdin.isTTY === true) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const stats = fstatSync(0);
|
|
return stats.isFIFO() || stats.isSocket();
|
|
} catch (error) {
|
|
debug('fstat stdin detection failed', error instanceof Error ? error.message : String(error));
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function readFromStdin(): Promise<UploadSource> {
|
|
usedStdin = true;
|
|
const spinner = new Spinner();
|
|
spinner.start('Reading from stdin...');
|
|
|
|
try {
|
|
const chunks: Buffer[] = [];
|
|
|
|
for await (const chunk of Bun.stdin.stream()) {
|
|
chunks.push(Buffer.from(chunk));
|
|
}
|
|
|
|
const buffer = Buffer.concat(chunks);
|
|
|
|
if (buffer.length === 0) {
|
|
throw new Error('No data received from stdin');
|
|
}
|
|
|
|
const mimeType = await detectMimeType(buffer);
|
|
spinner.stop();
|
|
debug('Read stdin', { size: buffer.length, mimeType });
|
|
|
|
return { buffer, mimeType };
|
|
} catch (e) {
|
|
spinner.stop();
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
async function copyToClipboard(text: string): Promise<void> {
|
|
if (IS_WSL || IS_WINDOWS) {
|
|
const proc = Bun.spawn(['clip.exe'], { stdin: 'pipe' });
|
|
proc.stdin.write(text);
|
|
proc.stdin.end();
|
|
await proc.exited;
|
|
} else {
|
|
const proc = Bun.spawn(['xclip', '-selection', 'clipboard'], { stdin: 'pipe' });
|
|
proc.stdin.write(text);
|
|
proc.stdin.end();
|
|
await proc.exited;
|
|
}
|
|
}
|
|
|
|
async function readFromClipboard(): Promise<UploadSource> {
|
|
const spinner = new Spinner();
|
|
spinner.start('Reading clipboard...');
|
|
|
|
try {
|
|
if (IS_WSL || IS_WINDOWS) {
|
|
const result = await $`powershell.exe -NoProfile -Command "Get-Clipboard -Raw"`.text();
|
|
const text = result.trim();
|
|
|
|
if (!text) {
|
|
throw new Error('Clipboard is empty');
|
|
}
|
|
|
|
if (text.startsWith('/') && existsSync(text)) {
|
|
spinner.stop();
|
|
const shouldUpload = await confirm(`Clipboard contains a file path: ${text}\nUpload this file?`);
|
|
if (shouldUpload) {
|
|
return readFromFile(text);
|
|
}
|
|
throw new Error('Upload cancelled');
|
|
}
|
|
|
|
spinner.stop();
|
|
const buffer = Buffer.from(text, 'utf-8');
|
|
debug('Read clipboard text (WSL)', { size: buffer.length });
|
|
|
|
return { buffer, mimeType: 'text/plain' };
|
|
}
|
|
|
|
const targets = await $`xclip -selection clipboard -t TARGETS -o`.text();
|
|
const targetList = targets.trim().split('\n');
|
|
debug('Clipboard targets', targetList);
|
|
|
|
if (targetList.includes('text/uri-list') || targetList.includes('x-special/gnome-copied-files')) {
|
|
const uris = await $`xclip -selection clipboard -t text/uri-list -o`.text();
|
|
const filePath = uris.split('\n')[0].replace(/^file:\/\//, '').trim();
|
|
|
|
if (filePath && existsSync(filePath)) {
|
|
spinner.stop();
|
|
|
|
const shouldUpload = await confirm(`Clipboard contains a file path: ${filePath}\nUpload this file?`);
|
|
if (shouldUpload) {
|
|
return readFromFile(filePath);
|
|
} else {
|
|
throw new Error('Upload cancelled');
|
|
}
|
|
}
|
|
}
|
|
|
|
const imageTarget = targetList.find(t => t.startsWith('image/'));
|
|
if (imageTarget) {
|
|
const isBmp = imageTarget === 'image/bmp';
|
|
const buffer = Buffer.from(await $`xclip -selection clipboard -t ${imageTarget} -o`.arrayBuffer());
|
|
|
|
spinner.stop();
|
|
debug('Read clipboard image', { target: imageTarget, size: buffer.length });
|
|
|
|
if (isBmp) {
|
|
log('Converting BMP to PNG...', 'info');
|
|
return { buffer, mimeType: 'image/bmp', filename: 'paste.bmp' };
|
|
}
|
|
|
|
return { buffer, mimeType: imageTarget, filename: `paste.${getExtensionForMime(imageTarget)}` };
|
|
}
|
|
|
|
const text = await $`xclip -selection clipboard -o`.text();
|
|
if (!text.trim()) {
|
|
throw new Error('Clipboard is empty or contains unsupported data');
|
|
}
|
|
|
|
const trimmedText = text.trim();
|
|
if (trimmedText.startsWith('/') && existsSync(trimmedText)) {
|
|
spinner.stop();
|
|
const shouldUpload = await confirm(`Clipboard contains a file path: ${trimmedText}\nUpload this file?`);
|
|
if (shouldUpload) {
|
|
return readFromFile(trimmedText);
|
|
}
|
|
}
|
|
|
|
spinner.stop();
|
|
const buffer = Buffer.from(text, 'utf-8');
|
|
debug('Read clipboard text', { size: buffer.length });
|
|
|
|
return { buffer, mimeType: 'text/plain' };
|
|
} catch (e) {
|
|
spinner.stop();
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
async function confirm(message: string): Promise<boolean> {
|
|
process.stdout.write(`${chalk.hex(COLORS.label)('?')} ${message} ${chalk.hex(COLORS.dim)('(y/n)')}: `);
|
|
|
|
const input = await new Promise<string>((resolve) => {
|
|
process.stdin.resume();
|
|
process.stdin.once('data', (data) => {
|
|
process.stdin.pause();
|
|
resolve(data.toString().trim());
|
|
});
|
|
});
|
|
|
|
return TRUTHY.includes(input);
|
|
}
|
|
|
|
async function promptExtension(): Promise<string | null> {
|
|
while (true) {
|
|
process.stdout.write(`${chalk.hex(COLORS.label)('?')} File extension ${chalk.hex(COLORS.dim)('(default: .txt)')}: `);
|
|
|
|
const input = await new Promise<string>((resolve) => {
|
|
process.stdin.resume();
|
|
process.stdin.once('data', (data) => {
|
|
process.stdin.pause();
|
|
resolve(data.toString().trim());
|
|
});
|
|
});
|
|
|
|
if (!input) {
|
|
const useTxt = await confirm('Upload as .txt?');
|
|
if (useTxt) return 'txt';
|
|
return null;
|
|
}
|
|
|
|
if (!input.startsWith('.')) {
|
|
if (TRUTHY.includes(input)) return 'txt';
|
|
if (FALSY.includes(input)) return null;
|
|
}
|
|
|
|
let ext = input.startsWith('.') ? input.slice(1) : input;
|
|
|
|
if (ext.startsWith('.') || ext.includes('..')) {
|
|
log('Invalid extension format. Use format like "txt", ".txt", or ".ts.map"', 'error');
|
|
continue;
|
|
}
|
|
|
|
return ext;
|
|
}
|
|
}
|
|
|
|
async function cleanupTempFiles(...paths: string[]): Promise<void> {
|
|
for (const path of paths) {
|
|
try {
|
|
await $`rm -f ${path}`.quiet();
|
|
} catch {
|
|
debug('Failed to cleanup temp file', path);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Media Validation
|
|
// ============================================================================
|
|
|
|
async function hasCommand(cmd: string): Promise<boolean> {
|
|
try {
|
|
await $`which ${cmd}`.quiet();
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function writeTempFile(buffer: Buffer, ext = ''): Promise<string> {
|
|
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<VideoProbe | null> {
|
|
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<ProbeResult | null> {
|
|
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<ProbeResult | null> {
|
|
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<ProbeResult | null> {
|
|
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<ProbeResult | null> {
|
|
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<Buffer> {
|
|
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<Buffer> {
|
|
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<Buffer> {
|
|
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<Buffer> {
|
|
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<Buffer> {
|
|
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<Buffer> {
|
|
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<Buffer> {
|
|
const needsConversion = fromMime === 'image/bmp' ||
|
|
(shouldConvert && (fromMime === 'image/heic' || fromMime === 'image/heif' || fromMime === 'image/tiff'));
|
|
|
|
if (!needsConversion) return buffer;
|
|
|
|
const spinner = new Spinner();
|
|
spinner.start('Converting image...');
|
|
|
|
const inputPath = join(tmpdir(), `share-input-${nanoid(8)}`);
|
|
const outputPath = join(tmpdir(), `share-output-${nanoid(8)}.png`);
|
|
|
|
try {
|
|
await Bun.write(inputPath, buffer);
|
|
|
|
if (fromMime === 'image/heic' || fromMime === 'image/heif') {
|
|
await $`convert ${inputPath} -quality 90 ${outputPath}`.quiet();
|
|
} else {
|
|
await $`convert ${inputPath} ${outputPath}`.quiet();
|
|
}
|
|
|
|
const converted = Buffer.from(await Bun.file(outputPath).arrayBuffer());
|
|
|
|
spinner.stop();
|
|
log(`Converted to ${fromMime === 'image/bmp' ? 'PNG' : 'JPEG'}`, 'success');
|
|
|
|
return converted;
|
|
} catch (e) {
|
|
spinner.stop();
|
|
throw new Error(`Image conversion failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
} finally {
|
|
await cleanupTempFiles(inputPath, outputPath);
|
|
}
|
|
}
|
|
|
|
async function convertVideo(buffer: Buffer): Promise<Buffer> {
|
|
const spinner = new Spinner();
|
|
spinner.start('Converting video (this may take a while)...');
|
|
|
|
const inputPath = join(tmpdir(), `share-input-${nanoid(8)}`);
|
|
const outputPath = join(tmpdir(), `share-output-${nanoid(8)}.mp4`);
|
|
|
|
try {
|
|
await Bun.write(inputPath, buffer);
|
|
|
|
await $`ffmpeg -i ${inputPath} -c:v libx264 -crf 28 -preset slow -vf "scale=-2:min(720,ih)" -c:a aac -b:a 128k -movflags +faststart ${outputPath}`.quiet();
|
|
|
|
const converted = Buffer.from(await Bun.file(outputPath).arrayBuffer());
|
|
|
|
spinner.stop();
|
|
log('Converted to web-optimized MP4', 'success');
|
|
|
|
return converted;
|
|
} catch (e) {
|
|
spinner.stop();
|
|
throw new Error(`Video conversion failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
} finally {
|
|
await cleanupTempFiles(inputPath, outputPath);
|
|
}
|
|
}
|
|
|
|
async function uploadToR2(
|
|
buffer: Buffer,
|
|
filename: string,
|
|
mimeType: string
|
|
): Promise<UploadResult> {
|
|
if (!ENV.endpoint || !ENV.accessKeyId || !ENV.secretAccessKey || !ENV.bucket) {
|
|
throw new Error('Missing R2 credentials - ensure all R2_* environment variables are set');
|
|
}
|
|
|
|
debug('S3 Client Config', {
|
|
endpoint: ENV.endpoint,
|
|
bucket: ENV.bucket,
|
|
accessKeyIdLength: ENV.accessKeyId.length,
|
|
});
|
|
|
|
const client = new S3Client({
|
|
region: 'auto',
|
|
endpoint: ENV.endpoint,
|
|
credentials: {
|
|
accessKeyId: ENV.accessKeyId,
|
|
secretAccessKey: ENV.secretAccessKey,
|
|
},
|
|
requestHandler: {
|
|
requestTimeout: TIMING.requestTimeout,
|
|
},
|
|
});
|
|
|
|
const now = new Date();
|
|
const year = now.getFullYear();
|
|
const month = String(now.getMonth() + 1).padStart(2, '0');
|
|
const key = `${year}/${month}/${filename}`;
|
|
|
|
debug('Uploading to R2', { key, size: buffer.length, mimeType });
|
|
|
|
const progressBar = new ProgressBar(buffer.length);
|
|
|
|
try {
|
|
const upload = new Upload({
|
|
client,
|
|
params: {
|
|
Bucket: ENV.bucket,
|
|
Key: key,
|
|
Body: buffer,
|
|
ContentType: mimeType,
|
|
},
|
|
});
|
|
|
|
upload.on('httpUploadProgress', (progress) => {
|
|
if (progress.loaded) {
|
|
progressBar.update(progress.loaded);
|
|
}
|
|
});
|
|
|
|
console.log(`Uploading ${chalk.hex(COLORS.dim)(filename)}`);
|
|
|
|
let timeoutId: TimeoutHandle;
|
|
const uploadPromise = upload.done();
|
|
const timeoutPromise = new Promise((_, reject) => {
|
|
timeoutId = setTimeout(() => reject(new Error('Upload timeout after 60 seconds')), TIMING.uploadTimeout);
|
|
});
|
|
|
|
debug('Waiting for upload to complete...');
|
|
try {
|
|
await Promise.race([uploadPromise, timeoutPromise]);
|
|
debug('Upload promise resolved');
|
|
} finally {
|
|
clearTimeout(timeoutId!);
|
|
}
|
|
progressBar.finish();
|
|
debug('Progress bar finished');
|
|
|
|
upload.removeAllListeners();
|
|
debug('Event listeners removed');
|
|
|
|
const url = `${DOMAIN}/${key}`;
|
|
debug('Returning result');
|
|
return { url, key, size: buffer.length };
|
|
} catch (e) {
|
|
progressBar.finish();
|
|
if (VERBOSE && e instanceof Error) {
|
|
debug('Upload error details', { error: e.message, stack: e.stack });
|
|
}
|
|
throw new Error(`Upload failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
} finally {
|
|
client.destroy();
|
|
}
|
|
}
|
|
|
|
function hasNanoidSuffix(filename: string): boolean {
|
|
// nanoid default alphabet: A-Za-z0-9_-
|
|
return /-[A-Za-z0-9_-]{8}\.[^.]+$/.test(filename);
|
|
}
|
|
|
|
async function main() {
|
|
const { values, positionals } = parseArgs({
|
|
args: Bun.argv.slice(2),
|
|
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' },
|
|
},
|
|
allowPositionals: true,
|
|
});
|
|
|
|
if (values.help) {
|
|
console.log(`
|
|
${chalk.hex(COLORS.success)('share')} - Upload files to R2 and copy the URL to clipboard
|
|
|
|
${chalk.hex(COLORS.label)('Usage:')}
|
|
share [options] [file]
|
|
|
|
${chalk.hex(COLORS.label)('Arguments:')}
|
|
file Optional file path to upload
|
|
|
|
${chalk.hex(COLORS.label)('Options:')}
|
|
-v, --verbose Enable debug output
|
|
-c, --convert Convert media before upload (re-encode video, etc.)
|
|
-F, --fix <mode> Fix media issues: n=never, p=prompt (default), a=always
|
|
-y, --yes Skip confirmation prompts
|
|
-n, --name <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/re-encode then upload
|
|
share -Fa video.webm # Fix all issues automatically
|
|
|
|
${chalk.hex(COLORS.label)('Environment Variables:')}
|
|
R2_ENDPOINT S3-compatible endpoint URL
|
|
R2_ACCESS_KEY_ID Access key ID
|
|
R2_SECRET_ACCESS_KEY Secret access key
|
|
R2_BUCKET Bucket name
|
|
`);
|
|
return;
|
|
}
|
|
|
|
VERBOSE = values.verbose || false;
|
|
|
|
const missing = REQUIRED_ENV.filter(key => !process.env[key]);
|
|
if (missing.length > 0) {
|
|
log(`Missing environment variables: ${missing.join(', ')}`, 'error');
|
|
process.exit(1);
|
|
}
|
|
|
|
try {
|
|
let source: UploadSource;
|
|
|
|
if (positionals[0]) {
|
|
source = await readFromFile(positionals[0]);
|
|
} else if (hasStdinData()) {
|
|
source = await readFromStdin();
|
|
} else {
|
|
source = await readFromClipboard();
|
|
}
|
|
|
|
let { buffer, filename, mimeType } = source;
|
|
|
|
if (!mimeType) {
|
|
mimeType = await detectMimeType(buffer, filename);
|
|
}
|
|
|
|
log(`Detected: ${mimeType} (${formatBytes(buffer.length)})`, 'success');
|
|
|
|
if (isTextMime(mimeType) && !values.yes) {
|
|
const ext = await promptExtension();
|
|
if (!ext) {
|
|
log('Upload cancelled', 'error');
|
|
process.exit(0);
|
|
}
|
|
|
|
mimeType = getMimeForExtension(ext);
|
|
const baseName = values.name || (filename ? basename(filename, extname(filename)) : 'text');
|
|
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 = '';
|
|
|
|
if (isImageMime(mimeType) && buffer.length > SIZE_THRESHOLDS.image) {
|
|
needsConfirm = true;
|
|
reason = `image is larger than ${formatBytes(SIZE_THRESHOLDS.image)}`;
|
|
} else if (isVideoMime(mimeType) && buffer.length > SIZE_THRESHOLDS.video) {
|
|
needsConfirm = true;
|
|
reason = `video is larger than ${formatBytes(SIZE_THRESHOLDS.video)}`;
|
|
} else if (!isImageMime(mimeType) && !isVideoMime(mimeType) && buffer.length > SIZE_THRESHOLDS.binary) {
|
|
needsConfirm = true;
|
|
reason = `file is larger than ${formatBytes(SIZE_THRESHOLDS.binary)}`;
|
|
}
|
|
|
|
if (needsConfirm) {
|
|
const shouldContinue = await confirm(`Upload large file? (${reason})`);
|
|
if (!shouldContinue) {
|
|
log('Upload cancelled', 'error');
|
|
process.exit(0);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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';
|
|
}
|
|
|
|
if (!filename) {
|
|
const ext = getExtensionForMime(mimeType);
|
|
const baseName = values.name || 'upload';
|
|
filename = `${baseName}-${nanoid(8)}.${ext}`;
|
|
} else if (!hasNanoidSuffix(filename)) {
|
|
const ext = extname(filename);
|
|
const base = basename(filename, ext);
|
|
const finalName = values.name || base;
|
|
filename = `${finalName}-${nanoid(8)}${ext}`;
|
|
}
|
|
|
|
debug('Calling uploadToR2...');
|
|
const result = await uploadToR2(buffer, filename, mimeType);
|
|
debug('uploadToR2 returned', { url: result.url });
|
|
|
|
debug('Copying to clipboard...');
|
|
await copyToClipboard(result.url);
|
|
debug('Clipboard copy complete');
|
|
|
|
log(result.url, 'success');
|
|
log('Copied to clipboard', 'success');
|
|
|
|
debug('About to exit...');
|
|
if (usedStdin) {
|
|
process.stdin.destroy();
|
|
}
|
|
process.exit(0);
|
|
} catch (e) {
|
|
if (e instanceof Error) {
|
|
log(e.message, 'error');
|
|
} else {
|
|
log('Unknown error occurred', 'error');
|
|
}
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
main();
|