import { spawn } from 'bare-subprocess'
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import { randomBytes } from 'bare-crypto'
import FFmpeg from 'bare-ffmpeg'
const TEMP_DIR = path.join(os.tmpdir(), 'hyper-social-media')
try { fs.mkdirSync(TEMP_DIR, { recursive: true }) } catch {}
/**
* Process an image for cross-platform POSSE publishing.
*
* - Resize to max 2048px on longest side
* - Compress to JPEG 80% quality
* - Strip EXIF/metadata (privacy)
* - Target: under 1MB (Bluesky limit)
* - Falls back to lower quality if still over 1MB
*
* @param {Buffer} input - Raw image data
* @param {string} filename - Original filename (for extension detection)
* @returns {{ buffer: Buffer, mimeType: string, width: number, height: number }}
*/
async function processImage (input, filename) {
const id = randomBytes(8).toString('hex')
const inputPath = path.join(TEMP_DIR, `${id}-input${extFor(filename)}`)
const outputPath = path.join(TEMP_DIR, `${id}-output.jpg`)
fs.writeFileSync(inputPath, input)
try {
// First pass: resize + compress to JPEG 80%
await ffmpeg([
'-i', inputPath,
'-vf', 'scale=2048:2048:force_original_aspect_ratio=decrease',
'-q:v', '4', // JPEG quality ~80%
'-map_metadata', '-1', // Strip all metadata
'-y', outputPath
])
let output = fs.readFileSync(outputPath)
// If still over 1MB (Bluesky limit), compress harder
if (output.length > 1000000) {
await ffmpeg([
'-i', inputPath,
'-vf', 'scale=1600:1600:force_original_aspect_ratio=decrease',
'-q:v', '8', // Lower quality
'-map_metadata', '-1',
'-y', outputPath
])
output = fs.readFileSync(outputPath)
}
// If STILL over 1MB, go aggressive
if (output.length > 1000000) {
await ffmpeg([
'-i', inputPath,
'-vf', 'scale=1200:1200:force_original_aspect_ratio=decrease',
'-q:v', '12',
'-map_metadata', '-1',
'-y', outputPath
])
output = fs.readFileSync(outputPath)
}
const dimensions = await getImageDimensions(outputPath)
return {
buffer: output,
mimeType: 'image/jpeg',
width: dimensions.width,
height: dimensions.height
}
} finally {
cleanup(inputPath, outputPath)
}
}
/**
* Process a video for cross-platform POSSE publishing.
*
* - Re-encode to H.264 MP4
* - Cap duration at 60 seconds (Bluesky limit)
* - Reasonable bitrate for web delivery
* - Generate thumbnail
*
* @param {Buffer} input - Raw video data
* @param {string} filename - Original filename
* @returns {{ buffer: Buffer, mimeType: string, thumbnail: Buffer, duration: number }}
*/
async function processVideo (input, filename) {
const id = randomBytes(8).toString('hex')
const inputPath = path.join(TEMP_DIR, `${id}-input${extFor(filename)}`)
const outputPath = path.join(TEMP_DIR, `${id}-output.mp4`)
const thumbPath = path.join(TEMP_DIR, `${id}-thumb.jpg`)
fs.writeFileSync(inputPath, input)
try {
// Transcode: H.264, AAC, max 60s, reasonable quality
await ffmpeg([
'-i', inputPath,
'-t', '60', // Cap at 60 seconds
'-c:v', 'libx264',
'-preset', 'medium',
'-crf', '23', // Good quality/size balance
'-c:a', 'aac',
'-b:a', '128k',
'-movflags', '+faststart', // Web-optimized
'-vf', 'scale=1920:1920:force_original_aspect_ratio=decrease:force_divisible_by=2',
'-map_metadata', '-1',
'-y', outputPath
])
// Generate thumbnail at 1 second
await ffmpeg([
'-i', inputPath,
'-ss', '1',
'-frames:v', '1',
'-vf', 'scale=800:800:force_original_aspect_ratio=decrease',
'-q:v', '4',
'-y', thumbPath
])
const output = fs.readFileSync(outputPath)
const thumbnail = fs.readFileSync(thumbPath)
const duration = await getVideoDuration(outputPath)
return {
buffer: output,
mimeType: 'video/mp4',
thumbnail,
duration
}
} finally {
cleanup(inputPath, outputPath, thumbPath)
}
}
/**
* Detect if a file is an image or video based on extension/mime.
*/
function detectMediaType (filename, mimeType) {
const ext = (path.extname(filename) || '').toLowerCase()
if (mimeType) {
if (mimeType.startsWith('image/')) return 'image'
if (mimeType.startsWith('video/')) return 'video'
}
if (['.jpg', '.jpeg', '.png', '.gif', '.webp', '.heic', '.avif'].includes(ext)) return 'image'
if (['.mp4', '.webm', '.mov', '.avi', '.mkv'].includes(ext)) return 'video'
return null
}
/**
* Process any media file — routes to image or video processor.
*/
async function processMedia (input, filename, mimeType) {
const type = detectMediaType(filename, mimeType)
if (type === 'image') return { type: 'image', ...(await processImage(input, filename)) }
if (type === 'video') return { type: 'video', ...(await processVideo(input, filename)) }
throw new Error('Unsupported media type: ' + filename)
}
// --- Internal helpers ---
function ffmpeg (args) {
return new Promise((resolve, reject) => {
const proc = spawn('ffmpeg', ['-hide_banner', '-loglevel', 'error', ...args])
let stderr = ''
proc.stderr.on('data', (d) => { stderr += d.toString() })
proc.on('exit', (code) => {
if (code !== 0) reject(new Error('ffmpeg failed: ' + stderr.slice(0, 500)))
else resolve()
})
proc.on('error', reject)
})
}
function getMediaInfo (filePath) {
try {
const io = new FFmpeg.IOContext(filePath, FFmpeg.constants.AVIO_FLAG_READ)
const ctx = new FFmpeg.InputFormatContext(io)
const info = { width: 0, height: 0, duration: 0 }
for (const stream of ctx.streams) {
const params = stream.codecpar
if (params.codecType === FFmpeg.constants.AVMEDIA_TYPE_VIDEO) {
info.width = params.width
info.height = params.height
}
}
// Duration in seconds (format context stores in microseconds or via stream timebase)
ctx.destroy()
return info
} catch {
return { width: 0, height: 0, duration: 0 }
}
}
function getImageDimensions (filePath) {
const info = getMediaInfo(filePath)
return { width: info.width, height: info.height }
}
function getVideoDuration (filePath) {
// bare-ffmpeg duration access may vary — fall back to ffprobe subprocess
return new Promise((resolve) => {
const proc = spawn('ffprobe', [
'-v', 'quiet', '-print_format', 'json', '-show_format', filePath
])
let out = ''
proc.stdout.on('data', (d) => { out += d.toString() })
proc.on('exit', () => {
try {
resolve(Math.round(parseFloat(JSON.parse(out).format?.duration || '0')))
} catch { resolve(0) }
})
proc.on('error', () => resolve(0))
})
}
function extFor (filename) {
return path.extname(filename) || '.bin'
}
function cleanup (...files) {
for (const f of files) {
try { fs.unlinkSync(f) } catch {}
}
}
export { processImage, processVideo, processMedia, detectMediaType }