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
This commit is contained in:
@@ -19,6 +19,7 @@ import {
|
|||||||
import { ZUpdateTranscript } from '@cio/utils/validation/media';
|
import { ZUpdateTranscript } from '@cio/utils/validation/media';
|
||||||
import {
|
import {
|
||||||
abortHlsAssetService,
|
abortHlsAssetService,
|
||||||
|
abortHls1080RenditionService,
|
||||||
attachAssetService,
|
attachAssetService,
|
||||||
batchPresignHlsService,
|
batchPresignHlsService,
|
||||||
batchPresignHls1080Service,
|
batchPresignHls1080Service,
|
||||||
@@ -228,6 +229,22 @@ export const assetsRouter = new Hono()
|
|||||||
return handleError(c, error, 'Failed to abort HLS asset');
|
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
|
* POST /organization/assets/:assetId/hls/cookie
|
||||||
* After entitlement check, set a 15-minute HMAC-signed cookie scoped to
|
* After entitlement check, set a 15-minute HMAC-signed cookie scoped to
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
HlsOutputFormat,
|
HlsOutputFormat,
|
||||||
Input,
|
Input,
|
||||||
MpegTsOutputFormat,
|
MpegTsOutputFormat,
|
||||||
Mp4OutputFormat,
|
|
||||||
Output,
|
Output,
|
||||||
PathedTarget,
|
PathedTarget,
|
||||||
ALL_FORMATS,
|
ALL_FORMATS,
|
||||||
@@ -70,18 +69,34 @@ export const ALL_HLS_RUNGS: HlsRungConfig[] = [
|
|||||||
export const P1080_RUNG = ALL_HLS_RUNGS.find((rung) => rung.name === 'p1080')!;
|
export const P1080_RUNG = ALL_HLS_RUNGS.find((rung) => rung.name === 'p1080')!;
|
||||||
|
|
||||||
const DEFAULT_MAX_RUNG_HEIGHT = 720;
|
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. */
|
/** 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 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 AUDIO_BITRATE = 128_000;
|
||||||
const HLS_TARGET_SEGMENT_SECONDS = 4;
|
const HLS_TARGET_SEGMENT_SECONDS = 4;
|
||||||
/** Cap parallel segment PUTs — unbounded concurrency can overwhelm R2 and fail mid-upload. */
|
/** 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;
|
const HLS_PUT_MAX_ATTEMPTS = 4;
|
||||||
/** Server validation allows up to 2000 paths per presign request. */
|
/** Server validation allows up to 2000 paths per presign request. */
|
||||||
const PRESIGN_BATCH_SIZE = 1500;
|
const PRESIGN_BATCH_SIZE = 1500;
|
||||||
@@ -103,6 +118,7 @@ export interface EncodeAndUploadOptions {
|
|||||||
title?: string;
|
title?: string;
|
||||||
rungs?: HlsRungConfig[];
|
rungs?: HlsRungConfig[];
|
||||||
onProgress?: (progress: HlsEncodeProgress) => void;
|
onProgress?: (progress: HlsEncodeProgress) => void;
|
||||||
|
onAssetReserved?: (assetId: string) => void;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +129,54 @@ export interface EncodeAndUpload1080Options {
|
|||||||
signal?: AbortSignal;
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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.
|
* End-to-end: encode the file to HLS, push to R2, finalize the asset.
|
||||||
* Resolves with the new `assetId` and HLS manifest key.
|
* 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 aspectRatio = computeAspectRatioLabel(videoTrack);
|
||||||
const sourceWidth = videoTrack.displayWidth ?? 0;
|
const sourceWidth = videoTrack.displayWidth ?? 0;
|
||||||
const sourceHeight = videoTrack.displayHeight ?? 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) {
|
if (rungs.length === 0) {
|
||||||
throw new HlsEncoderError('encode_failed', 'Source resolution is too low for HLS encoding');
|
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;
|
const { assetId } = initBody.data;
|
||||||
reservedAssetId = assetId;
|
reservedAssetId = assetId;
|
||||||
|
opts.onAssetReserved?.(assetId);
|
||||||
|
|
||||||
// --- Pre-presign every file Mediabunny will write ------------------
|
// --- Pre-presign every file Mediabunny will write ------------------
|
||||||
// Segments are deterministic from rung name + segment index, so we
|
// 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 });
|
emit({ stage: 'encoding', percent: 1 });
|
||||||
const expectedPaths = predictHlsPaths(rungs, duration, Boolean(audioTrack));
|
const expectedPaths = predictHlsPaths(rungs, duration, Boolean(audioTrack));
|
||||||
const presignedUrls = await batchPresignPaths(assetId, expectedPaths);
|
const presignedUrls = await batchPresignPaths(assetId, expectedPaths);
|
||||||
|
const expectedUploadBytes = estimateHlsUploadBytes(rungs, duration, Boolean(audioTrack));
|
||||||
|
|
||||||
const uploader = new HlsUploader({
|
const uploader = new HlsUploader({
|
||||||
assetId,
|
assetId,
|
||||||
presignedUrls,
|
presignedUrls,
|
||||||
onUploaded: ({ totalBytes, count }) => {
|
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
|
signal: opts.signal
|
||||||
});
|
});
|
||||||
@@ -247,8 +320,12 @@ export async function encodeAndUploadHls(opts: EncodeAndUploadOptions): Promise<
|
|||||||
});
|
});
|
||||||
|
|
||||||
conversion.onProgress = (fraction) => {
|
conversion.onProgress = (fraction) => {
|
||||||
// Reserve 70% of the bar for the encode itself; uploads finish behind it.
|
// Reserve ~70% of the bar for encode; segment uploads continue in parallel.
|
||||||
emit({ percent: Math.min(72, 2 + Math.round(fraction * 70)) });
|
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) {
|
if (!conversion.isValid) {
|
||||||
@@ -259,30 +336,12 @@ export async function encodeAndUploadHls(opts: EncodeAndUploadOptions): Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
await conversion.execute();
|
await conversion.execute();
|
||||||
|
emit({ stage: 'uploading', percent: 73 });
|
||||||
await uploader.flush();
|
await uploader.flush();
|
||||||
|
|
||||||
// --- Audio-only output for transcription --------------------------
|
// HLS audio segments are enough for server-side Whisper prep — no second
|
||||||
let audioKey: string | undefined;
|
// full pass through the source file for a separate audio.m4a upload.
|
||||||
if (audioTrack) {
|
const audioKey = audioTrack ? `${assetId}/audio/playlist.m3u8` : undefined;
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Thumbnails ----------------------------------------------------
|
// --- Thumbnails ----------------------------------------------------
|
||||||
emit({ stage: 'thumbnail', percent: 86 });
|
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
|
// already sees the original failure, and a stuck `processing` row is
|
||||||
// recoverable via an admin sweep anyway.
|
// recoverable via an admin sweep anyway.
|
||||||
if (reservedAssetId) {
|
if (reservedAssetId) {
|
||||||
try {
|
await cleanupAbortedHlsUpload(reservedAssetId, opts.signal);
|
||||||
await classroomio.organization.assets[':assetId'].hls.$delete({ param: { assetId: reservedAssetId } });
|
|
||||||
} catch (cleanupError) {
|
|
||||||
console.warn('Failed to abort HLS asset after encode error', cleanupError);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
@@ -484,6 +539,8 @@ export async function encodeAndUploadHls1080(opts: EncodeAndUpload1080Options):
|
|||||||
const code = error instanceof HlsEncoderError ? error.code : 'encode_failed';
|
const code = error instanceof HlsEncoderError ? error.code : 'encode_failed';
|
||||||
const message = error instanceof Error ? error.message : 'Unknown encoder error';
|
const message = error instanceof Error ? error.message : 'Unknown encoder error';
|
||||||
emit({ stage: 'failed', error: `${code}: ${message}` });
|
emit({ stage: 'failed', error: `${code}: ${message}` });
|
||||||
|
|
||||||
|
await cleanupAbortedHls1080Upload(opts.assetId, opts.signal);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -512,9 +569,8 @@ interface PresignedUrl {
|
|||||||
*
|
*
|
||||||
* When the source has audio, Mediabunny's HLS output emits a separate
|
* When the source has audio, Mediabunny's HLS output emits a separate
|
||||||
* `audio/` playlist + its own segments alongside the video rungs (the
|
* `audio/` playlist + its own segments alongside the video rungs (the
|
||||||
* master playlist groups them via `#EXT-X-MEDIA`). We presign those too.
|
* master playlist groups them via `#EXT-X-MEDIA`). We presign those too;
|
||||||
* The trailing `audio.m4a` is the separate transcription-only output we
|
* the jobs worker demuxes that playlist for Whisper transcription.
|
||||||
* run after the HLS conversion.
|
|
||||||
*/
|
*/
|
||||||
function predictHlsPaths(rungs: HlsRungConfig[], duration: number, hasAudio: boolean): string[] {
|
function predictHlsPaths(rungs: HlsRungConfig[], duration: number, hasAudio: boolean): string[] {
|
||||||
const paths: string[] = ['master.m3u8'];
|
const paths: string[] = ['master.m3u8'];
|
||||||
@@ -531,7 +587,6 @@ function predictHlsPaths(rungs: HlsRungConfig[], duration: number, hasAudio: boo
|
|||||||
for (let i = 1; i <= segmentCount; i++) {
|
for (let i = 1; i <= segmentCount; i++) {
|
||||||
paths.push(`audio/seg-${pad(i, 5)}.ts`);
|
paths.push(`audio/seg-${pad(i, 5)}.ts`);
|
||||||
}
|
}
|
||||||
paths.push('audio.m4a');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return paths;
|
return paths;
|
||||||
@@ -705,8 +760,8 @@ class HlsUploader {
|
|||||||
|
|
||||||
targetFor(path: string, mimeType: string): BufferTarget {
|
targetFor(path: string, mimeType: string): BufferTarget {
|
||||||
return new BufferTarget({
|
return new BufferTarget({
|
||||||
onFinalize: async (buffer) => {
|
onFinalize: (buffer) => {
|
||||||
await this.uploadSingle(path, mimeType, buffer);
|
void this.uploadSingle(path, mimeType, buffer);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -731,9 +786,7 @@ class HlsUploader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const contentType = uploadContentTypeForPath(path, mimeType);
|
const contentType = uploadContentTypeForPath(path, mimeType);
|
||||||
const owned = new Uint8Array(u8.byteLength);
|
const body = new Blob([u8], { type: contentType });
|
||||||
owned.set(u8);
|
|
||||||
const body = new Blob([owned], { type: contentType });
|
|
||||||
presigned = await putPresignedObject({
|
presigned = await putPresignedObject({
|
||||||
path,
|
path,
|
||||||
presigned,
|
presigned,
|
||||||
@@ -748,7 +801,7 @@ class HlsUploader {
|
|||||||
});
|
});
|
||||||
this.options.presignedUrls.set(path, presigned);
|
this.options.presignedUrls.set(path, presigned);
|
||||||
|
|
||||||
this.totalBytes += owned.byteLength;
|
this.totalBytes += u8.byteLength;
|
||||||
this.uploadedCount += 1;
|
this.uploadedCount += 1;
|
||||||
this.options.onUploaded({ totalBytes: this.totalBytes, count: this.uploadedCount });
|
this.options.onUploaded({ totalBytes: this.totalBytes, count: this.uploadedCount });
|
||||||
return presigned.key;
|
return presigned.key;
|
||||||
@@ -907,6 +960,17 @@ function pad(n: number, width: number): string {
|
|||||||
return n.toString().padStart(width, '0');
|
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<string> {
|
async function fetchExistingMasterManifest(assetId: string, signal?: AbortSignal): Promise<string> {
|
||||||
await classroomio.organization.assets[':assetId'].hls.cookie.$post({ param: { assetId } });
|
await classroomio.organization.assets[':assetId'].hls.cookie.$post({ param: { assetId } });
|
||||||
const response = await fetch(`/hls/${assetId}/master.m3u8`, { credentials: 'include', signal });
|
const response = await fetch(`/hls/${assetId}/master.m3u8`, { credentials: 'include', signal });
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { VideoUploader } from '$lib/utils/services/courses/presign';
|
import { VideoUploader } from '$lib/utils/services/courses/presign';
|
||||||
import { MediaUploader } from './media-uploader';
|
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 { env as publicEnv } from '$env/dynamic/public';
|
||||||
import { Button } from '@cio/ui/base/button';
|
import { Button } from '@cio/ui/base/button';
|
||||||
import * as FileDropZone from '@cio/ui/custom/file-drop-zone';
|
import * as FileDropZone from '@cio/ui/custom/file-drop-zone';
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
let fileSize: number | undefined = $state();
|
let fileSize: number | undefined = $state();
|
||||||
let hlsProgress: HlsEncodeProgress | null = $state(null);
|
let hlsProgress: HlsEncodeProgress | null = $state(null);
|
||||||
let hlsAbortController: AbortController | null = null;
|
let hlsAbortController: AbortController | null = null;
|
||||||
|
let hlsReservedAssetId: string | null = $state(null);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HLS is the default upload path on Cloud. Self-hosted installs fall
|
* HLS is the default upload path on Cloud. Self-hosted installs fall
|
||||||
@@ -122,16 +123,21 @@
|
|||||||
|
|
||||||
if (hlsEnabled) {
|
if (hlsEnabled) {
|
||||||
hlsAbortController = new AbortController();
|
hlsAbortController = new AbortController();
|
||||||
|
hlsReservedAssetId = null;
|
||||||
try {
|
try {
|
||||||
const result = await encodeAndUploadHls({
|
const result = await encodeAndUploadHls({
|
||||||
file: videoFile,
|
file: videoFile,
|
||||||
title: videoFile.name,
|
title: videoFile.name,
|
||||||
signal: hlsAbortController.signal,
|
signal: hlsAbortController.signal,
|
||||||
|
onAssetReserved: (assetId) => {
|
||||||
|
hlsReservedAssetId = assetId;
|
||||||
|
},
|
||||||
onProgress: (p) => {
|
onProgress: (p) => {
|
||||||
hlsProgress = p;
|
hlsProgress = p;
|
||||||
$lessonVideoUpload.uploadProgress = p.percent;
|
$lessonVideoUpload.uploadProgress = p.percent;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
hlsReservedAssetId = null;
|
||||||
|
|
||||||
const lessonVideoPosition = Array.isArray(lessonApi.lesson?.videos) ? lessonApi.lesson?.videos.length : 0;
|
const lessonVideoPosition = Array.isArray(lessonApi.lesson?.videos) ? lessonApi.lesson?.videos.length : 0;
|
||||||
if (_lessonId) {
|
if (_lessonId) {
|
||||||
@@ -184,6 +190,7 @@
|
|||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('HLS encode failed', err);
|
console.error('HLS encode failed', err);
|
||||||
|
hlsReservedAssetId = null;
|
||||||
formRes = {
|
formRes = {
|
||||||
type: 'INTERNAL_ERROR',
|
type: 'INTERNAL_ERROR',
|
||||||
status: 500,
|
status: 500,
|
||||||
@@ -344,6 +351,12 @@
|
|||||||
videoUploader.abortController?.abort();
|
videoUploader.abortController?.abort();
|
||||||
hlsAbortController?.abort();
|
hlsAbortController?.abort();
|
||||||
|
|
||||||
|
const reservedAssetId = hlsReservedAssetId;
|
||||||
|
hlsReservedAssetId = null;
|
||||||
|
if (reservedAssetId) {
|
||||||
|
void cleanupAbortedHlsUpload(reservedAssetId);
|
||||||
|
}
|
||||||
|
|
||||||
cancelVideoUpload();
|
cancelVideoUpload();
|
||||||
|
|
||||||
formRes = null;
|
formRes = null;
|
||||||
|
|||||||
@@ -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 path from 'node:path';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
|
|
||||||
@@ -45,6 +45,57 @@ function transcodeToMp3(inputPath: string, outputPath: string): Promise<void> {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* 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.
|
* API limit). The transcribe step reads the object from the media bucket.
|
||||||
@@ -71,9 +122,12 @@ export async function processExtractAudio(rawData: unknown): Promise<ExtractAudi
|
|||||||
});
|
});
|
||||||
|
|
||||||
let inputPath: string | undefined;
|
let inputPath: string | undefined;
|
||||||
|
let inputTempDir: string | undefined;
|
||||||
let outputPath: string | undefined;
|
let outputPath: string | undefined;
|
||||||
try {
|
try {
|
||||||
inputPath = await downloadObjectToTempFile(videosBucket(), storageKey, 'audio-source.bin');
|
const source = await resolveAudioSourcePath(videosBucket(), storageKey);
|
||||||
|
inputPath = source.inputPath;
|
||||||
|
inputTempDir = source.tempDir;
|
||||||
|
|
||||||
const dir = path.join(tmpdir(), 'cio-jobs', 'audio');
|
const dir = path.join(tmpdir(), 'cio-jobs', 'audio');
|
||||||
await mkdir(dir, { recursive: true });
|
await mkdir(dir, { recursive: true });
|
||||||
@@ -112,7 +166,11 @@ export async function processExtractAudio(rawData: unknown): Promise<ExtractAudi
|
|||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
await safeUnlink(inputPath);
|
if (inputTempDir) {
|
||||||
|
await rm(inputTempDir, { recursive: true, force: true }).catch(() => undefined);
|
||||||
|
} else {
|
||||||
|
await safeUnlink(inputPath);
|
||||||
|
}
|
||||||
await safeUnlink(outputPath);
|
await safeUnlink(outputPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -699,7 +699,11 @@ export async function issuePublicHlsCookie(assetId: string) {
|
|||||||
|
|
||||||
// --- HLS upload lifecycle ---
|
// --- HLS upload lifecycle ---
|
||||||
|
|
||||||
async function deleteHlsAssetObjects(bucket: string, prefix: string): Promise<void> {
|
function sleepMs(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteHlsAssetObjectsOnce(bucket: string, prefix: string): Promise<void> {
|
||||||
const { DeleteObjectsCommand, ListObjectsV2Command } = await import('@aws-sdk/client-s3');
|
const { DeleteObjectsCommand, ListObjectsV2Command } = await import('@aws-sdk/client-s3');
|
||||||
const client = getS3Client();
|
const client = getS3Client();
|
||||||
|
|
||||||
@@ -721,6 +725,66 @@ async function deleteHlsAssetObjects(bucket: string, prefix: string): Promise<vo
|
|||||||
} while (continuationToken);
|
} while (continuationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteHlsAssetObjects(bucket: string, prefix: string, maxAttempts = 3): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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
|
* Abort a `processing` HLS asset: clean up any partial R2 objects under
|
||||||
* `videos/{assetId}/` and delete the asset row. Idempotent — caller can
|
* `videos/{assetId}/` and delete the asset row. Idempotent — caller can
|
||||||
@@ -729,12 +793,23 @@ async function deleteHlsAssetObjects(bucket: string, prefix: string): Promise<vo
|
|||||||
*/
|
*/
|
||||||
export async function abortHlsAssetService(orgId: string, assetId: string) {
|
export async function abortHlsAssetService(orgId: string, assetId: string) {
|
||||||
try {
|
try {
|
||||||
const asset = assertAssetExists(await getAssetById(assetId, orgId));
|
const config = getStorageConfig();
|
||||||
|
const asset = await getAssetById(assetId, orgId);
|
||||||
|
|
||||||
|
if (!asset) {
|
||||||
|
try {
|
||||||
|
await deleteHlsAssetObjects(config.bucketVideos, `${assetId}/`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('abortHlsAssetService: orphan sweep failed for missing asset row', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new AppError('Asset not found', ErrorCodes.ASSET_NOT_FOUND, 404);
|
||||||
|
}
|
||||||
|
|
||||||
if (asset.status !== 'processing') {
|
if (asset.status !== 'processing') {
|
||||||
throw new AppError('Asset is not in processing state', ErrorCodes.VALIDATION_ERROR, 409);
|
throw new AppError('Asset is not in processing state', ErrorCodes.VALIDATION_ERROR, 409);
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = getStorageConfig();
|
|
||||||
try {
|
try {
|
||||||
await deleteHlsAssetObjects(config.bucketVideos, `${assetId}/`);
|
await deleteHlsAssetObjects(config.bucketVideos, `${assetId}/`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -755,6 +830,53 @@ export async function abortHlsAssetService(orgId: string, assetId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop a failed manual p1080 rendition: delete partial `p1080/` objects,
|
||||||
|
* restore the master manifest if it was patched, and reset rendition metadata.
|
||||||
|
*/
|
||||||
|
export async function abortHls1080RenditionService(orgId: string, assetId: string) {
|
||||||
|
try {
|
||||||
|
const asset = assertAssetExists(await getAssetById(assetId, orgId));
|
||||||
|
if (asset.status !== 'active' || !asset.hlsManifestKey) {
|
||||||
|
throw new AppError('Asset has no active HLS manifest', ErrorCodes.VALIDATION_ERROR, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = getStorageConfig();
|
||||||
|
try {
|
||||||
|
await deleteHlsAssetObjects(config.bucketVideos, `${assetId}/p1080/`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('abortHls1080RenditionService: failed to delete p1080 objects', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifestKey = asset.hlsManifestKey;
|
||||||
|
const manifestBody = await readHlsManifestBody(config.bucketVideos, manifestKey);
|
||||||
|
const strippedManifest = stripP1080FromMasterManifest(manifestBody);
|
||||||
|
if (strippedManifest !== manifestBody) {
|
||||||
|
await writeHlsManifestBody(config.bucketVideos, manifestKey, strippedManifest);
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = readHlsAssetMetadata(asset);
|
||||||
|
const renditions = (metadata.hlsRenditions ?? []).filter((rendition) => 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<string, string> = {
|
const HLS_CONTENT_TYPES: Record<string, string> = {
|
||||||
'.m3u8': 'application/vnd.apple.mpegurl',
|
'.m3u8': 'application/vnd.apple.mpegurl',
|
||||||
'.ts': 'video/mp2t',
|
'.ts': 'video/mp2t',
|
||||||
|
|||||||
Reference in New Issue
Block a user