OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-social / hyper-social-web / lib / media.js

235 lines · javascript ref:
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 }