All files / web/src/lib/song-share buildPreviewVideo.ts

39.65% Statements 46/116
100% Branches 1/1
33.33% Functions 1/3
39.65% Lines 46/116

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 1171x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x                           1x                                                                                                                    
/**
 * Builds the small iMessage-playable preview MP4 for a shared song.
 *
 * Produces an H.264 / yuv420p video track from a still cover PNG and an AAC
 * audio track from the song's MP3, joined into a faststart MP4 container —
 * the only format Apple's LinkPresentation will play inline (per TN3156). The
 * resulting file lives at `data/audio/songs/{songId}.mp4`, alongside the MP3
 * and the alignment JSON, and is served public-but-share-code-gated by the
 * `/api/song-share/[code]/preview.mp4` route.
 *
 * Lazy: callers invoke this on a cache miss. The function:
 * - returns immediately if the file already exists (the disk file IS the cache)
 * - serializes concurrent calls for the same songId via an in-process map so
 *   two simultaneous crawler requests don't race to ffmpeg the same file
 * - writes through a tmp path + atomic rename so a torn write (process kill,
 *   ffmpeg crash) never leaves a partial MP4 visible to readers
 */
 
import { promises as fs } from 'fs'
import { join } from 'path'
import { randomBytes } from 'crypto'
import { tmpdir } from 'os'
import ffmpeg from 'fluent-ffmpeg'
 
const SONGS_DIR = join(process.cwd(), 'data', 'audio', 'songs')
 
export interface BuildPreviewVideoOptions {
  songId: string
  /** PNG bytes for the still cover frame. */
  coverPng: Buffer
}
 
export interface BuildPreviewVideoResult {
  outputPath: string
  fileSize: number
  /** True if the file was already on disk; false if ffmpeg ran. */
  cached: boolean
}
 
const inFlight = new Map<string, Promise<BuildPreviewVideoResult>>()
 
export function previewVideoPath(songId: string): string {
  return join(SONGS_DIR, `${songId}.mp4`)
}
 
export async function buildPreviewVideo(
  opts: BuildPreviewVideoOptions
): Promise<BuildPreviewVideoResult> {
  const existing = inFlight.get(opts.songId)
  if (existing) return existing
  const promise = doBuild(opts)
  inFlight.set(opts.songId, promise)
  try {
    return await promise
  } finally {
    inFlight.delete(opts.songId)
  }
}
 
async function doBuild({
  songId,
  coverPng,
}: BuildPreviewVideoOptions): Promise<BuildPreviewVideoResult> {
  const outputPath = previewVideoPath(songId)

  try {
    const stats = await fs.stat(outputPath)
    return { outputPath, fileSize: stats.size, cached: true }
  } catch {
    // missing — fall through to generation
  }

  const mp3Path = join(SONGS_DIR, `${songId}.mp3`)
  await fs.access(mp3Path) // throws ENOENT if the source audio isn't there

  await fs.mkdir(SONGS_DIR, { recursive: true })

  const tmpId = randomBytes(8).toString('hex')
  const coverTmp = join(tmpdir(), `song-preview-cover-${songId}-${tmpId}.png`)
  const outputTmp = join(SONGS_DIR, `.${songId}.${tmpId}.mp4`)

  await fs.writeFile(coverTmp, coverPng)

  try {
    await new Promise<void>((resolve, reject) => {
      ffmpeg()
        .input(coverTmp)
        .inputOptions(['-loop 1', '-framerate 2'])
        .input(mp3Path)
        .outputOptions([
          '-c:v libx264',
          '-tune stillimage',
          '-preset veryfast',
          '-pix_fmt yuv420p',
          '-c:a aac',
          '-b:a 128k',
          '-shortest',
          '-movflags +faststart',
        ])
        .output(outputTmp)
        .on('end', () => resolve())
        .on('error', (err) => reject(err))
        .run()
    })

    await fs.rename(outputTmp, outputPath)
    const stats = await fs.stat(outputPath)
    return { outputPath, fileSize: stats.size, cached: false }
  } finally {
    await Promise.allSettled([
      fs.unlink(coverTmp),
      // outputTmp is gone after rename — this is the kill-mid-ffmpeg cleanup.
      fs.unlink(outputTmp).catch(() => {}),
    ])
  }
}