diff --git a/apps/api/src/routes/organization/assets.ts b/apps/api/src/routes/organization/assets.ts index 917d2962..e856c880 100644 --- a/apps/api/src/routes/organization/assets.ts +++ b/apps/api/src/routes/organization/assets.ts @@ -19,6 +19,7 @@ import { import { ZUpdateTranscript } from '@cio/utils/validation/media'; import { abortHlsAssetService, + abortHls1080RenditionService, attachAssetService, batchPresignHlsService, batchPresignHls1080Service, @@ -228,6 +229,22 @@ export const assetsRouter = new Hono() return handleError(c, error, 'Failed to abort HLS asset'); } }) + /** + * DELETE /organization/assets/:assetId/hls/1080 + * Drop a failed manual p1080 rendition: delete partial segments, restore + * the master manifest, and reset rendition metadata. + */ + .delete('/:assetId/hls/1080', authMiddleware, orgMemberMiddleware, zValidator('param', ZAssetGetParam), async (c) => { + try { + const orgId = c.req.header('cio-org-id')!; + const { assetId } = c.req.valid('param'); + const updated = await abortHls1080RenditionService(orgId, assetId); + + return c.json({ success: true, data: updated }, 200); + } catch (error) { + return handleError(c, error, 'Failed to abort 1080p HLS rendition'); + } + }) /** * POST /organization/assets/:assetId/hls/cookie * After entitlement check, set a 15-minute HMAC-signed cookie scoped to diff --git a/apps/dashboard/src/lib/features/course/components/lesson/video/hls-encoder.test.ts b/apps/dashboard/src/lib/features/course/components/lesson/video/hls-encoder.test.ts new file mode 100644 index 00000000..3d4aa909 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/lesson/video/hls-encoder.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('$lib/utils/services/api', () => ({ + classroomio: {} +})); + +import { ALL_HLS_RUNGS, selectRungsForSource } from './hls-encoder'; + +function mockTrack(height: number) { + return { displayHeight: height, displayWidth: Math.round(height * (16 / 9)) }; +} + +describe('selectRungsForSource', () => { + it('returns 360p and 720p for HD sources under 100MB', () => { + const rungs = selectRungsForSource(mockTrack(1080) as never, 720, 50 * 1024 * 1024); + + expect(rungs.map((rung) => rung.name)).toEqual(['p360', 'p720']); + }); + + it('returns only the highest rung for large HD sources', () => { + const rungs = selectRungsForSource(mockTrack(1080) as never, 720, 195 * 1024 * 1024); + + expect(rungs).toEqual([ALL_HLS_RUNGS.find((rung) => rung.name === 'p720')]); + }); + + it('returns a single 360p rung for low-resolution sources', () => { + const rungs = selectRungsForSource(mockTrack(480) as never, 720, 200 * 1024 * 1024); + + expect(rungs.map((rung) => rung.name)).toEqual(['p360']); + }); +}); diff --git a/apps/dashboard/src/lib/features/course/components/lesson/video/hls-encoder.ts b/apps/dashboard/src/lib/features/course/components/lesson/video/hls-encoder.ts index 5fe3c838..5b194a28 100644 --- a/apps/dashboard/src/lib/features/course/components/lesson/video/hls-encoder.ts +++ b/apps/dashboard/src/lib/features/course/components/lesson/video/hls-encoder.ts @@ -16,7 +16,6 @@ import { HlsOutputFormat, Input, MpegTsOutputFormat, - Mp4OutputFormat, Output, PathedTarget, ALL_FORMATS, @@ -70,18 +69,34 @@ export const ALL_HLS_RUNGS: HlsRungConfig[] = [ export const P1080_RUNG = ALL_HLS_RUNGS.find((rung) => rung.name === 'p1080')!; const DEFAULT_MAX_RUNG_HEIGHT = 720; +/** Above this size, encode a single ladder rung to avoid doubling encode time on large sources. */ +const LARGE_FILE_SINGLE_RUNG_BYTES = 100 * 1024 * 1024; /** Source-capped rungs up to `maxHeight` — never upscales beyond the source. */ -export function selectRungsForSource(track: InputVideoTrack, maxHeight = DEFAULT_MAX_RUNG_HEIGHT): HlsRungConfig[] { +export function selectRungsForSource( + track: InputVideoTrack, + maxHeight = DEFAULT_MAX_RUNG_HEIGHT, + fileSizeBytes?: number +): HlsRungConfig[] { const sourceHeight = track.displayHeight ?? 0; + const eligible = ALL_HLS_RUNGS.filter((rung) => rung.height <= Math.min(maxHeight, sourceHeight)); - return ALL_HLS_RUNGS.filter((rung) => rung.height <= Math.min(maxHeight, sourceHeight)); + if (!eligible.length) return []; + + // Large uploads spend most of the wait in WebCodecs. A single rung (typically + // 720p) finishes roughly twice as fast as a 360p+720p ladder while still + // delivering HD playback; smaller files keep the full ABR ladder. + if (fileSizeBytes != null && fileSizeBytes >= LARGE_FILE_SINGLE_RUNG_BYTES && eligible.length > 1) { + return [eligible[eligible.length - 1]!]; + } + + return eligible; } const AUDIO_BITRATE = 128_000; const HLS_TARGET_SEGMENT_SECONDS = 4; /** Cap parallel segment PUTs — unbounded concurrency can overwhelm R2 and fail mid-upload. */ -const HLS_UPLOAD_CONCURRENCY = 8; +const HLS_UPLOAD_CONCURRENCY = 16; const HLS_PUT_MAX_ATTEMPTS = 4; /** Server validation allows up to 2000 paths per presign request. */ const PRESIGN_BATCH_SIZE = 1500; @@ -103,6 +118,7 @@ export interface EncodeAndUploadOptions { title?: string; rungs?: HlsRungConfig[]; onProgress?: (progress: HlsEncodeProgress) => void; + onAssetReserved?: (assetId: string) => void; signal?: AbortSignal; } @@ -113,6 +129,54 @@ export interface EncodeAndUpload1080Options { signal?: AbortSignal; } +const CLEANUP_MAX_ATTEMPTS = 3; + +/** Best-effort cleanup for a reserved `processing` HLS asset after failure or cancel. */ +export async function cleanupAbortedHlsUpload(assetId: string, signal?: AbortSignal): Promise { + for (let attempt = 1; attempt <= CLEANUP_MAX_ATTEMPTS; attempt += 1) { + try { + const response = await classroomio.organization.assets[':assetId'].hls.$delete({ + param: { assetId }, + signal + }); + + if (response.ok) return; + + if (response.status === 404 || response.status === 409) return; + } catch (error) { + if (attempt === CLEANUP_MAX_ATTEMPTS) { + console.warn('Failed to abort HLS asset after encode error', error); + return; + } + } + + await sleep(Math.min(500 * 2 ** (attempt - 1), 4000)); + } +} + +/** Best-effort cleanup for a failed manual p1080 rendition on an active asset. */ +export async function cleanupAbortedHls1080Upload(assetId: string, signal?: AbortSignal): Promise { + for (let attempt = 1; attempt <= CLEANUP_MAX_ATTEMPTS; attempt += 1) { + try { + const response = await classroomio.organization.assets[':assetId'].hls['1080'].$delete({ + param: { assetId }, + signal + }); + + if (response.ok) return; + + if (response.status === 404 || response.status === 409) return; + } catch (error) { + if (attempt === CLEANUP_MAX_ATTEMPTS) { + console.warn('Failed to abort partial 1080p HLS rendition', error); + return; + } + } + + await sleep(Math.min(500 * 2 ** (attempt - 1), 4000)); + } +} + /** * End-to-end: encode the file to HLS, push to R2, finalize the asset. * Resolves with the new `assetId` and HLS manifest key. @@ -148,7 +212,7 @@ export async function encodeAndUploadHls(opts: EncodeAndUploadOptions): Promise< const aspectRatio = computeAspectRatioLabel(videoTrack); const sourceWidth = videoTrack.displayWidth ?? 0; const sourceHeight = videoTrack.displayHeight ?? 0; - const rungs = opts.rungs ?? selectRungsForSource(videoTrack, DEFAULT_MAX_RUNG_HEIGHT); + const rungs = opts.rungs ?? selectRungsForSource(videoTrack, DEFAULT_MAX_RUNG_HEIGHT, opts.file.size); if (rungs.length === 0) { throw new HlsEncoderError('encode_failed', 'Source resolution is too low for HLS encoding'); } @@ -168,6 +232,7 @@ export async function encodeAndUploadHls(opts: EncodeAndUploadOptions): Promise< } const { assetId } = initBody.data; reservedAssetId = assetId; + opts.onAssetReserved?.(assetId); // --- Pre-presign every file Mediabunny will write ------------------ // Segments are deterministic from rung name + segment index, so we @@ -177,12 +242,20 @@ export async function encodeAndUploadHls(opts: EncodeAndUploadOptions): Promise< emit({ stage: 'encoding', percent: 1 }); const expectedPaths = predictHlsPaths(rungs, duration, Boolean(audioTrack)); const presignedUrls = await batchPresignPaths(assetId, expectedPaths); + const expectedUploadBytes = estimateHlsUploadBytes(rungs, duration, Boolean(audioTrack)); const uploader = new HlsUploader({ assetId, presignedUrls, onUploaded: ({ totalBytes, count }) => { - emit({ uploadedBytes: totalBytes, uploadedCount: count }); + const uploadFraction = Math.min(1, totalBytes / expectedUploadBytes); + const uploadPercent = 73 + Math.round(uploadFraction * 22); + emit({ + uploadedBytes: totalBytes, + uploadedCount: count, + stage: uploadFraction > 0 ? 'uploading' : progress.stage, + percent: Math.max(progress.percent, uploadPercent) + }); }, signal: opts.signal }); @@ -247,8 +320,12 @@ export async function encodeAndUploadHls(opts: EncodeAndUploadOptions): Promise< }); conversion.onProgress = (fraction) => { - // Reserve 70% of the bar for the encode itself; uploads finish behind it. - emit({ percent: Math.min(72, 2 + Math.round(fraction * 70)) }); + // Reserve ~70% of the bar for encode; segment uploads continue in parallel. + const encodePercent = Math.min(72, 2 + Math.round(fraction * 70)); + emit({ + stage: progress.stage === 'uploading' ? 'uploading' : 'encoding', + percent: Math.max(progress.percent, encodePercent) + }); }; if (!conversion.isValid) { @@ -259,30 +336,12 @@ export async function encodeAndUploadHls(opts: EncodeAndUploadOptions): Promise< } await conversion.execute(); + emit({ stage: 'uploading', percent: 73 }); await uploader.flush(); - // --- Audio-only output for transcription -------------------------- - let audioKey: string | undefined; - if (audioTrack) { - const audioOutput = new Output({ - format: new Mp4OutputFormat(), - target: new BufferTarget({ - onFinalize: async (buffer) => { - audioKey = await uploader.uploadSingle('audio.m4a', 'audio/mp4', buffer); - } - }) - }); - const audioConversion = await Conversion.init({ - input, - output: audioOutput, - video: { discard: true }, - audio: { codec: 'aac', bitrate: AUDIO_BITRATE, numberOfChannels: 1, sampleRate: 16_000 }, - showWarnings: false - }); - if (audioConversion.isValid) { - await audioConversion.execute(); - } - } + // HLS audio segments are enough for server-side Whisper prep — no second + // full pass through the source file for a separate audio.m4a upload. + const audioKey = audioTrack ? `${assetId}/audio/playlist.m3u8` : undefined; // --- Thumbnails ---------------------------------------------------- emit({ stage: 'thumbnail', percent: 86 }); @@ -339,11 +398,7 @@ export async function encodeAndUploadHls(opts: EncodeAndUploadOptions): Promise< // already sees the original failure, and a stuck `processing` row is // recoverable via an admin sweep anyway. if (reservedAssetId) { - try { - await classroomio.organization.assets[':assetId'].hls.$delete({ param: { assetId: reservedAssetId } }); - } catch (cleanupError) { - console.warn('Failed to abort HLS asset after encode error', cleanupError); - } + await cleanupAbortedHlsUpload(reservedAssetId, opts.signal); } throw error; @@ -484,6 +539,8 @@ export async function encodeAndUploadHls1080(opts: EncodeAndUpload1080Options): const code = error instanceof HlsEncoderError ? error.code : 'encode_failed'; const message = error instanceof Error ? error.message : 'Unknown encoder error'; emit({ stage: 'failed', error: `${code}: ${message}` }); + + await cleanupAbortedHls1080Upload(opts.assetId, opts.signal); throw error; } } @@ -512,9 +569,8 @@ interface PresignedUrl { * * When the source has audio, Mediabunny's HLS output emits a separate * `audio/` playlist + its own segments alongside the video rungs (the - * master playlist groups them via `#EXT-X-MEDIA`). We presign those too. - * The trailing `audio.m4a` is the separate transcription-only output we - * run after the HLS conversion. + * master playlist groups them via `#EXT-X-MEDIA`). We presign those too; + * the jobs worker demuxes that playlist for Whisper transcription. */ function predictHlsPaths(rungs: HlsRungConfig[], duration: number, hasAudio: boolean): string[] { const paths: string[] = ['master.m3u8']; @@ -531,7 +587,6 @@ function predictHlsPaths(rungs: HlsRungConfig[], duration: number, hasAudio: boo for (let i = 1; i <= segmentCount; i++) { paths.push(`audio/seg-${pad(i, 5)}.ts`); } - paths.push('audio.m4a'); } return paths; @@ -705,8 +760,8 @@ class HlsUploader { targetFor(path: string, mimeType: string): BufferTarget { return new BufferTarget({ - onFinalize: async (buffer) => { - await this.uploadSingle(path, mimeType, buffer); + onFinalize: (buffer) => { + void this.uploadSingle(path, mimeType, buffer); } }); } @@ -731,9 +786,7 @@ class HlsUploader { } const contentType = uploadContentTypeForPath(path, mimeType); - const owned = new Uint8Array(u8.byteLength); - owned.set(u8); - const body = new Blob([owned], { type: contentType }); + const body = new Blob([u8], { type: contentType }); presigned = await putPresignedObject({ path, presigned, @@ -748,7 +801,7 @@ class HlsUploader { }); this.options.presignedUrls.set(path, presigned); - this.totalBytes += owned.byteLength; + this.totalBytes += u8.byteLength; this.uploadedCount += 1; this.options.onUploaded({ totalBytes: this.totalBytes, count: this.uploadedCount }); return presigned.key; @@ -907,6 +960,17 @@ function pad(n: number, width: number): string { return n.toString().padStart(width, '0'); } +/** Rough byte budget for progress UI — actual HLS output varies with content complexity. */ +function estimateHlsUploadBytes(rungs: HlsRungConfig[], durationSeconds: number, hasAudio: boolean): number { + const segmentCount = Math.max(1, Math.ceil(durationSeconds / HLS_TARGET_SEGMENT_SECONDS)) + 5; + const playlistOverhead = 32 * 1024 * (rungs.length + (hasAudio ? 2 : 1)); + const videoBytes = + rungs.reduce((sum, rung) => sum + rung.videoBitrate * durationSeconds, 0) / 8 + segmentCount * rungs.length * 4096; + const audioBytes = hasAudio ? (AUDIO_BITRATE * durationSeconds) / 8 + segmentCount * 4096 : 0; + + return Math.max(1, Math.round(playlistOverhead + videoBytes + audioBytes)); +} + async function fetchExistingMasterManifest(assetId: string, signal?: AbortSignal): Promise { await classroomio.organization.assets[':assetId'].hls.cookie.$post({ param: { assetId } }); const response = await fetch(`/hls/${assetId}/master.m3u8`, { credentials: 'include', signal }); diff --git a/apps/dashboard/src/lib/features/course/components/lesson/video/upload-video.svelte b/apps/dashboard/src/lib/features/course/components/lesson/video/upload-video.svelte index 629ed2d7..8e31eaa6 100644 --- a/apps/dashboard/src/lib/features/course/components/lesson/video/upload-video.svelte +++ b/apps/dashboard/src/lib/features/course/components/lesson/video/upload-video.svelte @@ -7,7 +7,7 @@ import { t } from '$lib/utils/functions/translations'; import { VideoUploader } from '$lib/utils/services/courses/presign'; import { MediaUploader } from './media-uploader'; - import { encodeAndUploadHls, type HlsEncodeProgress } from './hls-encoder'; + import { encodeAndUploadHls, cleanupAbortedHlsUpload, type HlsEncodeProgress } from './hls-encoder'; import { env as publicEnv } from '$env/dynamic/public'; import { Button } from '@cio/ui/base/button'; import * as FileDropZone from '@cio/ui/custom/file-drop-zone'; @@ -27,6 +27,7 @@ let fileSize: number | undefined = $state(); let hlsProgress: HlsEncodeProgress | null = $state(null); let hlsAbortController: AbortController | null = null; + let hlsReservedAssetId: string | null = $state(null); /** * HLS is the default upload path on Cloud. Self-hosted installs fall @@ -122,16 +123,21 @@ if (hlsEnabled) { hlsAbortController = new AbortController(); + hlsReservedAssetId = null; try { const result = await encodeAndUploadHls({ file: videoFile, title: videoFile.name, signal: hlsAbortController.signal, + onAssetReserved: (assetId) => { + hlsReservedAssetId = assetId; + }, onProgress: (p) => { hlsProgress = p; $lessonVideoUpload.uploadProgress = p.percent; } }); + hlsReservedAssetId = null; const lessonVideoPosition = Array.isArray(lessonApi.lesson?.videos) ? lessonApi.lesson?.videos.length : 0; if (_lessonId) { @@ -184,6 +190,7 @@ return; } catch (err) { console.error('HLS encode failed', err); + hlsReservedAssetId = null; formRes = { type: 'INTERNAL_ERROR', status: 500, @@ -344,6 +351,12 @@ videoUploader.abortController?.abort(); hlsAbortController?.abort(); + const reservedAssetId = hlsReservedAssetId; + hlsReservedAssetId = null; + if (reservedAssetId) { + void cleanupAbortedHlsUpload(reservedAssetId); + } + cancelVideoUpload(); formRes = null; diff --git a/apps/jobs/src/processors/media/extract-audio.ts b/apps/jobs/src/processors/media/extract-audio.ts index 46af424b..2a744902 100644 --- a/apps/jobs/src/processors/media/extract-audio.ts +++ b/apps/jobs/src/processors/media/extract-audio.ts @@ -1,4 +1,4 @@ -import { mkdir, stat } from 'node:fs/promises'; +import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { tmpdir } from 'node:os'; @@ -45,6 +45,57 @@ function transcodeToMp3(inputPath: string, outputPath: string): Promise { ]); } +/** + * 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. @@ -71,9 +122,12 @@ export async function processExtractAudio(rawData: unknown): Promise undefined); + } else { + await safeUnlink(inputPath); + } await safeUnlink(outputPath); } } diff --git a/packages/core/src/services/assets/assets.ts b/packages/core/src/services/assets/assets.ts index 90bf0379..92297c83 100644 --- a/packages/core/src/services/assets/assets.ts +++ b/packages/core/src/services/assets/assets.ts @@ -699,7 +699,11 @@ export async function issuePublicHlsCookie(assetId: string) { // --- HLS upload lifecycle --- -async function deleteHlsAssetObjects(bucket: string, prefix: string): Promise { +function sleepMs(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function deleteHlsAssetObjectsOnce(bucket: string, prefix: string): Promise { const { DeleteObjectsCommand, ListObjectsV2Command } = await import('@aws-sdk/client-s3'); const client = getS3Client(); @@ -721,6 +725,66 @@ async function deleteHlsAssetObjects(bucket: string, prefix: string): Promise { + let lastError: unknown; + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + await deleteHlsAssetObjectsOnce(bucket, prefix); + return; + } catch (error) { + lastError = error; + console.error(`deleteHlsAssetObjects attempt ${attempt}/${maxAttempts} failed for prefix ${prefix}`, error); + if (attempt < maxAttempts) { + await sleepMs(Math.min(500 * 2 ** (attempt - 1), 4000)); + } + } + } + + throw lastError instanceof Error ? lastError : new Error(`Failed to delete HLS objects under ${prefix}`); +} + +async function writeHlsManifestBody(bucket: string, key: string, body: string): Promise { + const { PutObjectCommand } = await import('@aws-sdk/client-s3'); + const client = getS3Client(); + await client.send( + new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: body, + ContentType: 'application/vnd.apple.mpegurl' + }) + ); +} + +function stripP1080FromMasterManifest(manifest: string): string { + const lines = manifest.split('\n'); + const result: string[] = []; + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]?.trim() ?? ''; + if (line === 'p1080/playlist.m3u8') { + const previous = result[result.length - 1]?.trim() ?? ''; + if (previous.startsWith('#EXT-X-STREAM-INF')) { + result.pop(); + } + continue; + } + + if (line.startsWith('#EXT-X-STREAM-INF')) { + const nextLine = lines[index + 1]?.trim() ?? ''; + if (nextLine === 'p1080/playlist.m3u8') { + index += 1; + continue; + } + } + + result.push(lines[index]!); + } + + return `${result.join('\n').trimEnd()}\n`; +} + /** * Abort a `processing` HLS asset: clean up any partial R2 objects under * `videos/{assetId}/` and delete the asset row. Idempotent — caller can @@ -729,12 +793,23 @@ async function deleteHlsAssetObjects(bucket: string, prefix: string): Promise rendition !== 'p1080'); + const mergedMetadata = { + ...(typeof asset.metadata === 'object' && asset.metadata !== null && !Array.isArray(asset.metadata) + ? asset.metadata + : {}), + hlsRenditions: renditions, + hls1080Status: 'none' as THls1080Status + }; + + const updated = await updateAsset(assetId, orgId, { metadata: mergedMetadata }); + return assertAssetExists(updated); + } catch (error) { + if (error instanceof AppError) throw error; + throw new AppError( + error instanceof Error ? error.message : 'Failed to abort 1080p HLS rendition', + ErrorCodes.ASSET_UPDATE_FAILED, + 500 + ); + } +} + const HLS_CONTENT_TYPES: Record = { '.m3u8': 'application/vnd.apple.mpegurl', '.ts': 'video/mp2t',