Files
classroomio/apps/jobs/src/processors/media/extract-audio.ts
T
Rotimi Best 4eee866e85 feat: make video uploads faster
- Implemented a new endpoint to delete failed manual p1080 renditions, restoring the master manifest and updating metadata.
- Enhanced the HLS upload process with better error handling and cleanup for aborted uploads.
- Introduced tests for HLS encoder functionality to ensure correct selection of rungs based on video source properties.
- Updated video upload component to manage reserved asset IDs and perform cleanup on abo
2026-06-07 23:20:42 +01:00

177 lines
5.5 KiB
TypeScript

import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { tmpdir } from 'node:os';
import { getJobStep, updateMediaJob, upsertJobStep } from '@cio/db/queries';
import { ZExtractAudioPayload, type TExtractAudioPayload } from '@cio/jobs/payloads/media';
import {
downloadObjectToTempFile,
mediaBucket,
safeUnlink,
uploadFileToBucket,
videosBucket
} from '../../utils/storage';
import { errorMessage, throwIfCancelRequested } from '../../utils/cancel';
import { ffmpegRun } from '../../utils/ffmpeg';
import { log } from '../../utils/logger';
const STEP_KEY = 'extract-audio';
const DOMAIN = 'media';
interface ExtractAudioResult {
bucket: string;
key: string;
fileSizeBytes: number;
}
function transcodeToMp3(inputPath: string, outputPath: string): Promise<void> {
return ffmpegRun([
'-y',
'-i',
inputPath,
'-vn',
'-acodec',
'libmp3lame',
'-b:a',
'32k',
'-ac',
'1',
'-ar',
'16000',
'-f',
'mp3',
outputPath
]);
}
/**
* Download an HLS media playlist and its segment objects from the videos
* bucket into a temp directory so ffmpeg can demux the audio track.
*/
async function downloadHlsPlaylistToTempFile(
bucket: string,
playlistKey: string
): Promise<{ inputPath: string; tempDir: string }> {
const downloadedPlaylist = await downloadObjectToTempFile(bucket, playlistKey, 'playlist.m3u8');
const rawPlaylist = await readFile(downloadedPlaylist, 'utf-8');
await safeUnlink(downloadedPlaylist);
const tempDir = path.join(tmpdir(), 'cio-jobs', 'hls-audio', `${Date.now()}_${process.pid}`);
await mkdir(tempDir, { recursive: true });
const playlistPrefix = path.posix.dirname(playlistKey);
const localLines: string[] = [];
for (const line of rawPlaylist.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) {
localLines.push(line);
continue;
}
const segmentKey = trimmed.includes('/') ? trimmed : `${playlistPrefix}/${trimmed}`;
const localSegmentPath = path.join(tempDir, path.basename(trimmed));
const downloadedSegment = await downloadObjectToTempFile(bucket, segmentKey, path.basename(trimmed));
await rename(downloadedSegment, localSegmentPath);
localLines.push(path.basename(trimmed));
}
const inputPath = path.join(tempDir, 'playlist.m3u8');
await writeFile(inputPath, localLines.join('\n'));
return { inputPath, tempDir };
}
async function resolveAudioSourcePath(
bucket: string,
storageKey: string
): Promise<{ inputPath: string; tempDir?: string }> {
if (storageKey.endsWith('.m3u8')) {
return downloadHlsPlaylistToTempFile(bucket, storageKey);
}
return {
inputPath: await downloadObjectToTempFile(bucket, storageKey, 'audio-source.bin')
};
}
/**
* Extract a 16 kHz mono MP3 (32 kbps) from the source video for Whisper (25 MB
* API limit). The transcribe step reads the object from the media bucket.
*/
export async function processExtractAudio(rawData: unknown): Promise<ExtractAudioResult> {
const payload: TExtractAudioPayload = ZExtractAudioPayload.parse(rawData);
const { mediaJobId, assetId, storageKey } = payload;
const existing = await getJobStep(DOMAIN, mediaJobId, STEP_KEY);
if (existing?.status === 'completed' && existing.result) {
log.info('extract-audio-skip', { mediaJobId, reason: 'ledger-completed' });
return existing.result as unknown as ExtractAudioResult;
}
await throwIfCancelRequested(mediaJobId);
await updateMediaJob(mediaJobId, { stage: 'extracting-audio', progressPercent: 70 });
await upsertJobStep({
domain: DOMAIN,
runId: mediaJobId,
stepKey: STEP_KEY,
status: 'running',
startedAt: new Date().toISOString(),
attempt: (existing?.attempt ?? 0) + 1
});
let inputPath: string | undefined;
let inputTempDir: string | undefined;
let outputPath: string | undefined;
try {
const source = await resolveAudioSourcePath(videosBucket(), storageKey);
inputPath = source.inputPath;
inputTempDir = source.tempDir;
const dir = path.join(tmpdir(), 'cio-jobs', 'audio');
await mkdir(dir, { recursive: true });
outputPath = path.join(dir, `${mediaJobId}.mp3`);
await throwIfCancelRequested(mediaJobId);
await transcodeToMp3(inputPath, outputPath);
const audioKey = `audio/${assetId}/${mediaJobId}.mp3`;
await uploadFileToBucket(mediaBucket(), audioKey, outputPath, 'audio/mpeg', 'private, max-age=0');
const fileStat = await stat(outputPath);
const result: ExtractAudioResult = { bucket: mediaBucket(), key: audioKey, fileSizeBytes: fileStat.size };
await upsertJobStep({
domain: DOMAIN,
runId: mediaJobId,
stepKey: STEP_KEY,
status: 'completed',
finishedAt: new Date().toISOString(),
result: result as unknown as Record<string, unknown>
});
await updateMediaJob(mediaJobId, { stage: 'audio-ready', progressPercent: 80 });
log.info('extract-audio-done', { mediaJobId, audioKey });
return result;
} catch (error) {
log.error('extract-audio-failed', { mediaJobId, error: errorMessage(error) });
await upsertJobStep({
domain: DOMAIN,
runId: mediaJobId,
stepKey: STEP_KEY,
status: 'failed',
finishedAt: new Date().toISOString(),
error: { code: 'EXTRACT_AUDIO_FAILED', message: errorMessage(error) }
});
throw error;
} finally {
if (inputTempDir) {
await rm(inputTempDir, { recursive: true, force: true }).catch(() => undefined);
} else {
await safeUnlink(inputPath);
}
await safeUnlink(outputPath);
}
}