feat: HLS streaming, transcripts, agent course generation & landing-page course previews (#663)
New features: - HLS video encoding/streaming (hls routes, hls-encoder) and a media transcripts pipeline (transcripts route + media-transcript queries) - AI assistant chat vs. run model: agent-course-generation worker/queue, run-monitor pane, chat-changes card, content ask-ai bar - Per-theme course-page previews across org landing-page themes (bold, classic, corporate, editorial, etc.) with a shared course token/component set - View-as-student mode and login-link / link-invite invite flows Refactor: - Extract packages/core consolidating config (env, storage, dashboard-url), services and utils previously duplicated under apps/api/src/config - Remove legacy agent service modules (chat-tools, chat-context, context-window, document, fetch-url, lesson-content) - Relax Cross-Origin-Resource-Policy to allow cross-origin media embedding - Add format script to tenant-router so turbo run format covers it
This commit is contained in:
@@ -723,6 +723,10 @@ export class LessonApi extends BaseApiWithErrors {
|
||||
* reflects the asset's new thumbnail without waiting for a reload.
|
||||
*/
|
||||
updateLessonVideoThumbnail(videoIndex: number, thumbnailUrl: string) {
|
||||
this.updateLessonVideoMetadata(videoIndex, { thumbnailUrl });
|
||||
}
|
||||
|
||||
updateLessonVideoMetadata(videoIndex: number, patch: Record<string, unknown>) {
|
||||
if (!this.lesson) return;
|
||||
|
||||
const videos = Array.isArray(this.lesson.videos) ? [...this.lesson.videos] : [];
|
||||
@@ -736,7 +740,7 @@ export class LessonApi extends BaseApiWithErrors {
|
||||
|
||||
videos[videoIndex] = {
|
||||
...target,
|
||||
metadata: { ...metadata, thumbnailUrl }
|
||||
metadata: { ...metadata, ...patch }
|
||||
};
|
||||
|
||||
this.lesson = { ...this.lesson, videos };
|
||||
|
||||
+25
-13
@@ -1,5 +1,13 @@
|
||||
<script module lang="ts">
|
||||
let pending: Promise<typeof import('@cio/ui/base/chart')> | null = null;
|
||||
function loadChart() {
|
||||
pending ??= import('@cio/ui/base/chart');
|
||||
return pending;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import * as Chart from '@cio/ui/base/chart';
|
||||
import type * as Chart from '@cio/ui/base/chart';
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import BarChartIcon from '@lucide/svelte/icons/bar-chart-3';
|
||||
@@ -135,15 +143,17 @@
|
||||
<Spinner class="ui:text-muted-foreground size-6" />
|
||||
</div>
|
||||
{:else if hasStudentData}
|
||||
<Chart.ChartContainer class="h-[280px] w-full" config={progressChartConfig}>
|
||||
<Chart.BarChart
|
||||
data={progressChartData}
|
||||
x="group"
|
||||
axis="x"
|
||||
props={progressChartProps}
|
||||
series={progressSeries}
|
||||
/>
|
||||
</Chart.ChartContainer>
|
||||
{#await loadChart() then C}
|
||||
<C.ChartContainer class="h-[280px] w-full" config={progressChartConfig}>
|
||||
<C.BarChart
|
||||
data={progressChartData}
|
||||
x="group"
|
||||
axis="x"
|
||||
props={progressChartProps}
|
||||
series={progressSeries}
|
||||
/>
|
||||
</C.ChartContainer>
|
||||
{/await}
|
||||
{:else}
|
||||
<Empty icon={BarChartIcon} title={$t('analytics.no_progress_data')} class="h-[280px]" />
|
||||
{/if}
|
||||
@@ -160,9 +170,11 @@
|
||||
<Spinner class="ui:text-muted-foreground size-6" />
|
||||
</div>
|
||||
{:else if hasStudentData}
|
||||
<Chart.ChartContainer class="h-[280px] w-full" config={gradeChartConfig}>
|
||||
<Chart.BarChart data={gradeChartData} x="group" axis="x" props={gradeChartProps} series={gradeSeries} />
|
||||
</Chart.ChartContainer>
|
||||
{#await loadChart() then C}
|
||||
<C.ChartContainer class="h-[280px] w-full" config={gradeChartConfig}>
|
||||
<C.BarChart data={gradeChartData} x="group" axis="x" props={gradeChartProps} series={gradeSeries} />
|
||||
</C.ChartContainer>
|
||||
{/await}
|
||||
{:else}
|
||||
<Empty icon={BarChartIcon} title={$t('analytics.no_grade_data')} class="h-[280px]" />
|
||||
{/if}
|
||||
|
||||
@@ -1,44 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { Separator } from '@cio/ui/base/separator';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import BellIcon from '@lucide/svelte/icons/bell';
|
||||
import EyeIcon from '@lucide/svelte/icons/eye';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import * as Popover from '@cio/ui/base/popover';
|
||||
import { HoverableItem, PreviewIcon } from '@cio/ui/custom/moving-icons';
|
||||
import RefreshCcwIcon from '@lucide/svelte/icons/refresh-ccw';
|
||||
import * as Empty from '@cio/ui/base/empty';
|
||||
import { page } from '$app/state';
|
||||
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
|
||||
import { isStudentExperience } from '$lib/utils/store/app';
|
||||
import SparklesIcon from '@lucide/svelte/icons/sparkles';
|
||||
import { setupProgressApi } from '$features/setup/api/setup-progress.svelte';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { openCoursePreview } from '$features/course/utils/course-preview';
|
||||
import { getActiveCourseNavKey } from '$features/course/utils/functions';
|
||||
import { toggleAiAssistant } from '$features/ai-assistant/utils/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import CoursePublishBadge from './course-publish-badge.svelte';
|
||||
import CoursePublicBadge from './course-public-badge.svelte';
|
||||
import ViewAsStudentModal from './view-as-student-modal.svelte';
|
||||
|
||||
const siteName = $derived($currentOrg.siteName);
|
||||
const showCoursePublishBadge = $derived(!$isStudentExperience);
|
||||
const isPublicCourse = $derived(courseApi.course?.type === 'PUBLIC');
|
||||
const activeNavKey = $derived(getActiveCourseNavKey(page.url.pathname, courseApi.course?.id ?? ''));
|
||||
|
||||
let viewAsStudentOpen = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (!siteName) return;
|
||||
|
||||
setupProgressApi.fetchSetupProgress(siteName);
|
||||
});
|
||||
|
||||
function handlePreview() {
|
||||
openCoursePreview({
|
||||
courseId: courseApi.course?.id ?? '',
|
||||
courseSlug: courseApi.course?.slug,
|
||||
currentOrgDomain: $currentOrgDomain
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<header
|
||||
@@ -74,42 +63,23 @@
|
||||
{$t('course.navItems.nav_ai_assistant')}
|
||||
</Button>
|
||||
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<Button variant="outline" size="icon">
|
||||
<BellIcon class="custom rounded-full" />
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content>
|
||||
<Empty.Root class="ui:from-muted/50 ui:to-background ui:h-full ui:bg-gradient-to-b ui:from-30%">
|
||||
<Empty.Header>
|
||||
<Empty.Media variant="icon">
|
||||
<BellIcon />
|
||||
</Empty.Media>
|
||||
<Empty.Title>No Notifications</Empty.Title>
|
||||
<Empty.Description>You're all caught up. New notifications will appear here.</Empty.Description>
|
||||
</Empty.Header>
|
||||
<Empty.Content>
|
||||
<Button variant="outline" size="sm">
|
||||
<RefreshCcwIcon />
|
||||
{$t('common.refresh')}
|
||||
</Button>
|
||||
</Empty.Content>
|
||||
</Empty.Root>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
||||
<HoverableItem>
|
||||
{#snippet children(isHovered)}
|
||||
<IconButton
|
||||
onclick={handlePreview}
|
||||
disabled={!courseApi.course?.id}
|
||||
tooltip={$t('course.header.preview')}
|
||||
aria-label={$t('course.header.preview')}
|
||||
>
|
||||
<PreviewIcon {isHovered} size={16} />
|
||||
</IconButton>
|
||||
{/snippet}
|
||||
</HoverableItem>
|
||||
{#if !$isStudentExperience}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => (viewAsStudentOpen = true)}
|
||||
disabled={!courseApi.course?.id}
|
||||
aria-label={$t('course.header.view_as_student')}
|
||||
>
|
||||
<EyeIcon size={14} />
|
||||
<span class="hidden sm:inline">{$t('course.header.view_as_student')}</span>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ViewAsStudentModal
|
||||
bind:open={viewAsStudentOpen}
|
||||
courseSlug={courseApi.course?.slug}
|
||||
currentOrgDomain={$currentOrgDomain}
|
||||
/>
|
||||
|
||||
@@ -400,7 +400,6 @@
|
||||
onValueChange={(nextValue) => onQuestionTypeChange(question.id, nextValue)}
|
||||
triggerQuestionType={question?.questionType}
|
||||
types={availableQuestionTypes}
|
||||
{selfPacedCourse}
|
||||
/>
|
||||
{/snippet}
|
||||
</ExerciseQuestion.QuestionRenderer>
|
||||
|
||||
+72
-39
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { PREMIUM_QUESTION_TYPE_KEYS, QUESTION_TYPES } from '$features/ui/question/constants';
|
||||
import { PremiumIcon } from '@cio/ui/custom/moving-icons';
|
||||
import { getExerciseEditorQuestionTypeLabel } from './question-type-utils';
|
||||
@@ -16,51 +15,85 @@
|
||||
onValueChange: (value: string) => void;
|
||||
triggerQuestionType: Question['questionType'] | undefined;
|
||||
types: QuestionTypeEntry[];
|
||||
selfPacedCourse?: boolean;
|
||||
}
|
||||
|
||||
let { value, onValueChange, triggerQuestionType, types, selfPacedCourse = false }: Props = $props();
|
||||
let { value, onValueChange, triggerQuestionType, types }: Props = $props();
|
||||
|
||||
const autoGradableTypes = $derived(types.filter((typeEntry) => typeEntry.autoGradable));
|
||||
const manuallyGradedTypes = $derived(types.filter((typeEntry) => !typeEntry.autoGradable));
|
||||
</script>
|
||||
|
||||
{#snippet sectionTitle({ title, description })}
|
||||
<div>
|
||||
<Select.Label>
|
||||
<p class="font-semibold">
|
||||
{$t(title)}
|
||||
</p>
|
||||
<p class="ui:text-muted-foreground ui:mt-1 ui:text-xs">
|
||||
{$t(description)}
|
||||
</p>
|
||||
</Select.Label>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet upgradeButton({ typeEntry })}
|
||||
<button
|
||||
type="button"
|
||||
class="ui:flex ui:w-full ui:cursor-pointer ui:select-none ui:items-center ui:gap-2 ui:rounded-sm ui:py-1.5 ui:pl-2 ui:pr-8 ui:text-sm ui:outline-hidden ui:hover:bg-accent ui:hover:text-accent-foreground ui:relative"
|
||||
onclick={(clickEvent) => {
|
||||
clickEvent.preventDefault();
|
||||
clickEvent.stopPropagation();
|
||||
openUpgradeModal();
|
||||
}}
|
||||
title={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.premium_question_type')}
|
||||
>
|
||||
<PremiumIcon size={16} class="ui:text-blue-700 ui:dark:text-white ui:shrink-0" />
|
||||
<span>{getExerciseEditorQuestionTypeLabel(typeEntry)}</span>
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
<Select.Root type="single" {value} {onValueChange}>
|
||||
<Select.Trigger class="w-full min-w-0 sm:w-[180px]">
|
||||
{getExerciseEditorQuestionTypeLabel(triggerQuestionType)}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each types as typeEntry (typeEntry.key)}
|
||||
{#if $isFreePlan && PREMIUM_QUESTION_TYPE_KEYS.has(typeEntry.key)}
|
||||
<button
|
||||
type="button"
|
||||
class="ui:flex ui:w-full ui:cursor-pointer ui:select-none ui:items-center ui:gap-2 ui:rounded-sm ui:py-1.5 ui:pl-2 ui:pr-8 ui:text-sm ui:outline-hidden ui:hover:bg-accent ui:hover:text-accent-foreground ui:relative"
|
||||
onclick={(clickEvent) => {
|
||||
clickEvent.preventDefault();
|
||||
clickEvent.stopPropagation();
|
||||
openUpgradeModal();
|
||||
}}
|
||||
title={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.premium_question_type')}
|
||||
>
|
||||
<PremiumIcon size={16} class="ui:text-blue-700 ui:dark:text-white ui:shrink-0" />
|
||||
<span>{getExerciseEditorQuestionTypeLabel(typeEntry)}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<Select.Item value={typeEntry.id.toString()} label={getExerciseEditorQuestionTypeLabel(typeEntry)}>
|
||||
{#snippet children({ selected: _sel })}
|
||||
<span class="flex w-full min-w-0 items-center justify-between gap-2">
|
||||
<span class="truncate">{getExerciseEditorQuestionTypeLabel(typeEntry)}</span>
|
||||
{#if selfPacedCourse}
|
||||
<Badge
|
||||
variant={typeEntry.autoGradable ? 'success' : 'warning'}
|
||||
class="ui:shrink-0 ui:text-[10px] ui:font-normal"
|
||||
>
|
||||
{typeEntry.autoGradable
|
||||
? $t('course.navItem.lessons.exercises.all_exercises.edit_mode.question_type_auto_gradable')
|
||||
: $t('course.navItem.lessons.exercises.all_exercises.edit_mode.question_type_manual_grading')}
|
||||
</Badge>
|
||||
{/if}
|
||||
</span>
|
||||
{/snippet}
|
||||
</Select.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
<Select.Content class="max-h-[300px]!">
|
||||
<Select.Group>
|
||||
{@render sectionTitle({
|
||||
title: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_type_auto_gradable',
|
||||
description: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_type_auto_gradable_description'
|
||||
})}
|
||||
|
||||
{#each autoGradableTypes as typeEntry (typeEntry.key)}
|
||||
{#if $isFreePlan && PREMIUM_QUESTION_TYPE_KEYS.has(typeEntry.key)}
|
||||
{@render upgradeButton({ typeEntry })}
|
||||
{:else}
|
||||
<Select.Item value={typeEntry.id.toString()} label={getExerciseEditorQuestionTypeLabel(typeEntry)}>
|
||||
{getExerciseEditorQuestionTypeLabel(typeEntry)}
|
||||
</Select.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
</Select.Group>
|
||||
|
||||
{#if manuallyGradedTypes.length > 0}
|
||||
<Select.Separator />
|
||||
|
||||
<Select.Group>
|
||||
{@render sectionTitle({
|
||||
title: 'course.navItem.lessons.exercises.all_exercises.edit_mode.question_type_manual_grading',
|
||||
description:
|
||||
'course.navItem.lessons.exercises.all_exercises.edit_mode.question_type_manual_grading_description'
|
||||
})}
|
||||
|
||||
{#each manuallyGradedTypes as typeEntry (typeEntry.key)}
|
||||
{#if $isFreePlan && PREMIUM_QUESTION_TYPE_KEYS.has(typeEntry.key)}
|
||||
{@render upgradeButton({ typeEntry })}
|
||||
{:else}
|
||||
<Select.Item value={typeEntry.id.toString()} label={getExerciseEditorQuestionTypeLabel(typeEntry)}>
|
||||
{getExerciseEditorQuestionTypeLabel(typeEntry)}
|
||||
</Select.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
</Select.Group>
|
||||
{/if}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
|
||||
@@ -0,0 +1,830 @@
|
||||
/**
|
||||
* Browser-side HLS encoder driven by Mediabunny.
|
||||
*
|
||||
* Default upload generates a source-capped 360p/720p ABR ladder. A manual
|
||||
* follow-up flow can add a p1080 rendition later without re-encoding the
|
||||
* base ladder. All transcoding happens on the user's machine via WebCodecs —
|
||||
* we never spend a server CPU-second on encoding, and the source MP4 is
|
||||
* never uploaded. The data path is `File → WebCodecs → R2`.
|
||||
*/
|
||||
|
||||
import {
|
||||
BlobSource,
|
||||
BufferTarget,
|
||||
CanvasSink,
|
||||
Conversion,
|
||||
HlsOutputFormat,
|
||||
Input,
|
||||
MpegTsOutputFormat,
|
||||
Mp4OutputFormat,
|
||||
Output,
|
||||
PathedTarget,
|
||||
ALL_FORMATS,
|
||||
type ConversionVideoOptions,
|
||||
type InputVideoTrack
|
||||
} from 'mediabunny';
|
||||
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
|
||||
export type HlsEncodeStage = 'probing' | 'encoding' | 'uploading' | 'thumbnail' | 'finalizing' | 'done' | 'failed';
|
||||
|
||||
export interface HlsEncodeProgress {
|
||||
stage: HlsEncodeStage;
|
||||
/** 0–100 overall completion estimate. */
|
||||
percent: number;
|
||||
/** Total bytes uploaded so far (segments + playlists + audio + thumbs). */
|
||||
uploadedBytes: number;
|
||||
/** Current rung label for the encoding stage, e.g. "p720". */
|
||||
rung?: string;
|
||||
/** Files uploaded so far / total files uploaded. */
|
||||
uploadedCount: number;
|
||||
/** Optional error message when stage === 'failed'. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type Hls1080Status = 'none' | 'generating' | 'ready' | 'failed';
|
||||
|
||||
export interface HlsEncodeResult {
|
||||
assetId: string;
|
||||
manifestKey: string;
|
||||
thumbnailUrl?: string;
|
||||
sourceWidth: number;
|
||||
sourceHeight: number;
|
||||
hlsRenditions: string[];
|
||||
hls1080Status: Hls1080Status;
|
||||
}
|
||||
|
||||
export interface HlsRungConfig {
|
||||
name: string;
|
||||
width: number;
|
||||
height: number;
|
||||
videoBitrate: number;
|
||||
}
|
||||
|
||||
export const ALL_HLS_RUNGS: HlsRungConfig[] = [
|
||||
{ name: 'p360', width: 640, height: 360, videoBitrate: 800_000 },
|
||||
{ name: 'p720', width: 1280, height: 720, videoBitrate: 2_500_000 },
|
||||
{ name: 'p1080', width: 1920, height: 1080, videoBitrate: 5_000_000 }
|
||||
];
|
||||
|
||||
export const P1080_RUNG = ALL_HLS_RUNGS.find((rung) => rung.name === 'p1080')!;
|
||||
|
||||
const DEFAULT_MAX_RUNG_HEIGHT = 720;
|
||||
|
||||
/** Source-capped rungs up to `maxHeight` — never upscales beyond the source. */
|
||||
export function selectRungsForSource(track: InputVideoTrack, maxHeight = DEFAULT_MAX_RUNG_HEIGHT): HlsRungConfig[] {
|
||||
const sourceHeight = track.displayHeight ?? 0;
|
||||
|
||||
return ALL_HLS_RUNGS.filter((rung) => rung.height <= Math.min(maxHeight, sourceHeight));
|
||||
}
|
||||
|
||||
const AUDIO_BITRATE = 128_000;
|
||||
const HLS_TARGET_SEGMENT_SECONDS = 4;
|
||||
const THUMBNAIL_FRACTIONS = [0.3, 0.5, 0.7];
|
||||
/** Hard ceiling for a generated thumbnail (matches the server job). */
|
||||
const THUMBNAIL_MAX_BYTES = 500 * 1024;
|
||||
/** JPEG quality steps tried (best first) until a frame fits under the size cap. */
|
||||
const THUMBNAIL_QUALITY_LADDER = [0.9, 0.8, 0.7, 0.6, 0.5];
|
||||
|
||||
export interface EncodeAndUploadOptions {
|
||||
file: File;
|
||||
title?: string;
|
||||
rungs?: HlsRungConfig[];
|
||||
onProgress?: (progress: HlsEncodeProgress) => void;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface EncodeAndUpload1080Options {
|
||||
file: File;
|
||||
assetId: string;
|
||||
onProgress?: (progress: HlsEncodeProgress) => void;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* End-to-end: encode the file to HLS, push to R2, finalize the asset.
|
||||
* Resolves with the new `assetId` and HLS manifest key.
|
||||
*/
|
||||
export async function encodeAndUploadHls(opts: EncodeAndUploadOptions): Promise<HlsEncodeResult> {
|
||||
const progress: HlsEncodeProgress = {
|
||||
stage: 'probing',
|
||||
percent: 0,
|
||||
uploadedBytes: 0,
|
||||
uploadedCount: 0
|
||||
};
|
||||
|
||||
function emit(patch: Partial<HlsEncodeProgress>) {
|
||||
Object.assign(progress, patch);
|
||||
opts.onProgress?.({ ...progress });
|
||||
}
|
||||
|
||||
let reservedAssetId: string | null = null;
|
||||
|
||||
try {
|
||||
if (typeof VideoEncoder === 'undefined') {
|
||||
throw new HlsEncoderError('webcodecs_unsupported', 'Browser missing WebCodecs encoder support');
|
||||
}
|
||||
|
||||
// --- Probe input ---------------------------------------------------
|
||||
const input = new Input({ source: new BlobSource(opts.file), formats: ALL_FORMATS });
|
||||
const duration = await input.computeDuration();
|
||||
const videoTrack = await input.getPrimaryVideoTrack();
|
||||
const audioTrack = await input.getPrimaryAudioTrack();
|
||||
if (!videoTrack) {
|
||||
throw new HlsEncoderError('encode_failed', 'No video track found in source');
|
||||
}
|
||||
const aspectRatio = computeAspectRatioLabel(videoTrack);
|
||||
const sourceWidth = videoTrack.displayWidth ?? 0;
|
||||
const sourceHeight = videoTrack.displayHeight ?? 0;
|
||||
const rungs = opts.rungs ?? selectRungsForSource(videoTrack, DEFAULT_MAX_RUNG_HEIGHT);
|
||||
if (rungs.length === 0) {
|
||||
throw new HlsEncoderError('encode_failed', 'Source resolution is too low for HLS encoding');
|
||||
}
|
||||
|
||||
// --- Reserve assetId ----------------------------------------------
|
||||
const init = await classroomio.organization.assets.hls.init.$post({
|
||||
json: {
|
||||
fileName: opts.file.name,
|
||||
byteSize: opts.file.size,
|
||||
mimeType: opts.file.type || 'application/octet-stream',
|
||||
title: opts.title
|
||||
}
|
||||
});
|
||||
const initBody = await init.json();
|
||||
if (!('success' in initBody) || !initBody.success) {
|
||||
throw new HlsEncoderError('encode_failed', 'Failed to init HLS asset');
|
||||
}
|
||||
const { assetId } = initBody.data;
|
||||
reservedAssetId = assetId;
|
||||
|
||||
// --- Pre-presign every file Mediabunny will write ------------------
|
||||
// Segments are deterministic from rung name + segment index, so we
|
||||
// can predict the full path list before encoding starts and ask the
|
||||
// server for all presigned URLs in one round-trip. The upload step
|
||||
// then becomes a synchronous map lookup — no more one-POST-per-segment.
|
||||
emit({ stage: 'encoding', percent: 1 });
|
||||
const expectedPaths = predictHlsPaths(rungs, duration, Boolean(audioTrack));
|
||||
const presignedUrls = await batchPresignPaths(assetId, expectedPaths);
|
||||
|
||||
const uploader = new HlsUploader({
|
||||
assetId,
|
||||
presignedUrls,
|
||||
onUploaded: ({ totalBytes, count }) => {
|
||||
emit({ uploadedBytes: totalBytes, uploadedCount: count });
|
||||
},
|
||||
signal: opts.signal
|
||||
});
|
||||
|
||||
// Map a video playlist → rung folder name. Mediabunny counts the
|
||||
// audio playlist as its own entry, so `n` from `HlsOutputPlaylistInfo`
|
||||
// doesn't line up 1:1 with our rung array. We assign rung names in
|
||||
// the order video playlists arrive (which mirrors the order we passed
|
||||
// them into `Conversion.init`'s `video` array). Audio playlists land
|
||||
// under `audio/`. The Map keys by track identity so a duplicate
|
||||
// callback invocation for the same playlist resolves to the same path.
|
||||
const playlistNames = new Map<unknown, string>();
|
||||
let videoPlaylistsAssigned = 0;
|
||||
function resolvePlaylistFolder(tracks: { isVideoTrack: () => boolean }[]): string {
|
||||
const videoTrack = tracks.find((track) => track.isVideoTrack());
|
||||
if (!videoTrack) return 'audio';
|
||||
|
||||
const existing = playlistNames.get(videoTrack);
|
||||
if (existing) return existing;
|
||||
|
||||
const rung = rungs[videoPlaylistsAssigned];
|
||||
videoPlaylistsAssigned += 1;
|
||||
const name = rung?.name ?? `v${videoPlaylistsAssigned}`;
|
||||
playlistNames.set(videoTrack, name);
|
||||
return name;
|
||||
}
|
||||
|
||||
const hlsOutput = new Output({
|
||||
format: new HlsOutputFormat({
|
||||
segmentFormat: new MpegTsOutputFormat(),
|
||||
targetDuration: HLS_TARGET_SEGMENT_SECONDS,
|
||||
getPlaylistPath: ({ tracks }) => `${resolvePlaylistFolder(tracks)}/playlist.m3u8`,
|
||||
// Segment paths are relative to the containing playlist's path.
|
||||
// Just the file name puts segments alongside their playlist
|
||||
// (e.g. `p720/seg-00001.ts`) rather than nested under a duplicate
|
||||
// rung folder.
|
||||
getSegmentPath: ({ n }) => `seg-${pad(n, 5)}.ts`
|
||||
}),
|
||||
target: new PathedTarget('master.m3u8', (request) => uploader.targetFor(request.path, request.mimeType))
|
||||
});
|
||||
|
||||
const videoVariants: ConversionVideoOptions[] = rungs.map((rung) => ({
|
||||
width: rung.width,
|
||||
height: rung.height,
|
||||
fit: 'contain',
|
||||
bitrate: rung.videoBitrate,
|
||||
// Keyframe at least every targetDuration so HLS segment boundaries land on key frames.
|
||||
keyFrameInterval: HLS_TARGET_SEGMENT_SECONDS,
|
||||
// Prefer the platform's hardware H.264 encoder (VideoToolbox / Media
|
||||
// Foundation / VAAPI) — typically several times faster than the
|
||||
// browser's software encoder. Falls back to software automatically
|
||||
// when no hardware encoder can satisfy the config.
|
||||
hardwareAcceleration: 'prefer-hardware'
|
||||
}));
|
||||
|
||||
const conversion = await Conversion.init({
|
||||
input,
|
||||
output: hlsOutput,
|
||||
video: videoVariants,
|
||||
audio: audioTrack ? { codec: 'aac', bitrate: AUDIO_BITRATE, numberOfChannels: 2 } : { discard: true },
|
||||
showWarnings: false
|
||||
});
|
||||
|
||||
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)) });
|
||||
};
|
||||
|
||||
if (!conversion.isValid) {
|
||||
throw new HlsEncoderError(
|
||||
'encode_failed',
|
||||
`Conversion invalid (${conversion.discardedTracks.length} discarded tracks)`
|
||||
);
|
||||
}
|
||||
|
||||
await conversion.execute();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Thumbnails ----------------------------------------------------
|
||||
emit({ stage: 'thumbnail', percent: 86 });
|
||||
const thumbnailUrls = await extractAndUploadThumbnails({
|
||||
videoTrack,
|
||||
duration,
|
||||
file: opts.file
|
||||
});
|
||||
|
||||
// --- Finalize -----------------------------------------------------
|
||||
emit({ stage: 'finalizing', percent: 96 });
|
||||
const finalize = await classroomio.organization.assets[':assetId'].hls.finalize.$post({
|
||||
param: { assetId },
|
||||
json: {
|
||||
manifestKey: `${assetId}/master.m3u8`,
|
||||
audioKey,
|
||||
durationSeconds: duration,
|
||||
aspectRatio,
|
||||
byteSize: progress.uploadedBytes,
|
||||
videoCodec: 'avc',
|
||||
audioCodec: audioTrack ? 'aac' : undefined,
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
hlsRenditions: rungs.map((rung) => rung.name),
|
||||
hls1080Status: 'none',
|
||||
thumbnailUrl: thumbnailUrls[0],
|
||||
thumbnailCandidateUrls: thumbnailUrls,
|
||||
title: opts.title
|
||||
}
|
||||
});
|
||||
const finalizeBody = await finalize.json();
|
||||
if (!('success' in finalizeBody) || !finalizeBody.success) {
|
||||
throw new HlsEncoderError('finalize_failed', 'Server rejected finalize payload');
|
||||
}
|
||||
|
||||
emit({ stage: 'done', percent: 100 });
|
||||
reservedAssetId = null;
|
||||
return {
|
||||
assetId,
|
||||
manifestKey: `${assetId}/master.m3u8`,
|
||||
thumbnailUrl: thumbnailUrls[0],
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
hlsRenditions: rungs.map((rung) => rung.name),
|
||||
hls1080Status: 'none'
|
||||
};
|
||||
} catch (error) {
|
||||
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}` });
|
||||
|
||||
// Best-effort cleanup: tell the server to drop the reserved row + any
|
||||
// partial R2 objects it managed to write. Swallow errors — the user
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and upload only the p1080 rendition for an existing HLS asset,
|
||||
* patch the master manifest, and finalize rendition metadata server-side.
|
||||
*/
|
||||
export async function encodeAndUploadHls1080(opts: EncodeAndUpload1080Options): Promise<{
|
||||
hlsRenditions: string[];
|
||||
hls1080Status: Hls1080Status;
|
||||
}> {
|
||||
const progress: HlsEncodeProgress = {
|
||||
stage: 'probing',
|
||||
percent: 0,
|
||||
uploadedBytes: 0,
|
||||
uploadedCount: 0
|
||||
};
|
||||
|
||||
function emit(patch: Partial<HlsEncodeProgress>) {
|
||||
Object.assign(progress, patch);
|
||||
opts.onProgress?.({ ...progress });
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof VideoEncoder === 'undefined') {
|
||||
throw new HlsEncoderError('webcodecs_unsupported', 'Browser missing WebCodecs encoder support');
|
||||
}
|
||||
|
||||
const input = new Input({ source: new BlobSource(opts.file), formats: ALL_FORMATS });
|
||||
const duration = await input.computeDuration();
|
||||
const videoTrack = await input.getPrimaryVideoTrack();
|
||||
if (!videoTrack) {
|
||||
throw new HlsEncoderError('encode_failed', 'No video track found in source');
|
||||
}
|
||||
|
||||
const sourceHeight = videoTrack.displayHeight ?? 0;
|
||||
if (sourceHeight < 1080) {
|
||||
throw new HlsEncoderError('encode_failed', 'Source video does not support 1080p');
|
||||
}
|
||||
|
||||
const rungs = [P1080_RUNG];
|
||||
emit({ stage: 'encoding', percent: 1, rung: 'p1080' });
|
||||
|
||||
const expectedPaths = predictHlsPaths(rungs, duration, false);
|
||||
const presignedUrls = await batchPresign1080Paths(opts.assetId, expectedPaths);
|
||||
|
||||
const capturedFiles = new Map<string, Uint8Array>();
|
||||
const uploader = new HlsUploader({
|
||||
assetId: opts.assetId,
|
||||
presignedUrls,
|
||||
presignEndpoint: '1080',
|
||||
skipPaths: new Set(['master.m3u8']),
|
||||
capturedFiles,
|
||||
onUploaded: ({ totalBytes, count }) => {
|
||||
emit({ uploadedBytes: totalBytes, uploadedCount: count });
|
||||
},
|
||||
signal: opts.signal
|
||||
});
|
||||
|
||||
const hlsOutput = new Output({
|
||||
format: new HlsOutputFormat({
|
||||
segmentFormat: new MpegTsOutputFormat(),
|
||||
targetDuration: HLS_TARGET_SEGMENT_SECONDS,
|
||||
getPlaylistPath: () => 'p1080/playlist.m3u8',
|
||||
getSegmentPath: ({ n }) => `seg-${pad(n, 5)}.ts`
|
||||
}),
|
||||
target: new PathedTarget('master.m3u8', (request) => uploader.targetFor(request.path, request.mimeType))
|
||||
});
|
||||
|
||||
const conversion = await Conversion.init({
|
||||
input,
|
||||
output: hlsOutput,
|
||||
video: [
|
||||
{
|
||||
width: P1080_RUNG.width,
|
||||
height: P1080_RUNG.height,
|
||||
fit: 'contain',
|
||||
bitrate: P1080_RUNG.videoBitrate,
|
||||
keyFrameInterval: HLS_TARGET_SEGMENT_SECONDS,
|
||||
hardwareAcceleration: 'prefer-hardware'
|
||||
}
|
||||
],
|
||||
audio: { discard: true },
|
||||
showWarnings: false
|
||||
});
|
||||
|
||||
conversion.onProgress = (fraction) => {
|
||||
emit({ percent: Math.min(72, 2 + Math.round(fraction * 70)), rung: 'p1080' });
|
||||
};
|
||||
|
||||
if (!conversion.isValid) {
|
||||
throw new HlsEncoderError(
|
||||
'encode_failed',
|
||||
`Conversion invalid (${conversion.discardedTracks.length} discarded tracks)`
|
||||
);
|
||||
}
|
||||
|
||||
await conversion.execute();
|
||||
await uploader.flush();
|
||||
|
||||
emit({ stage: 'uploading', percent: 78 });
|
||||
const generatedMaster = capturedFiles.get('master.m3u8');
|
||||
if (!generatedMaster) {
|
||||
throw new HlsEncoderError('encode_failed', 'Encoder did not produce a master manifest');
|
||||
}
|
||||
|
||||
const existingMaster = await fetchExistingMasterManifest(opts.assetId, opts.signal);
|
||||
const mergedMaster = mergeP1080IntoMaster(existingMaster, new TextDecoder().decode(generatedMaster));
|
||||
const mergedBytes = new TextEncoder().encode(mergedMaster);
|
||||
const uploadedBytesBeforeMaster = progress.uploadedBytes;
|
||||
await uploader.uploadSingle('master.m3u8', 'application/vnd.apple.mpegurl', mergedBytes, { force: true });
|
||||
|
||||
emit({ stage: 'finalizing', percent: 96 });
|
||||
const finalize = await classroomio.organization.assets[':assetId'].hls['1080'].finalize.$post({
|
||||
param: { assetId: opts.assetId },
|
||||
json: {
|
||||
additionalByteSize: uploadedBytesBeforeMaster,
|
||||
sourceWidth: videoTrack.displayWidth ?? undefined,
|
||||
sourceHeight: videoTrack.displayHeight ?? undefined
|
||||
}
|
||||
});
|
||||
const finalizeBody = await finalize.json();
|
||||
if (!('success' in finalizeBody) || !finalizeBody.success) {
|
||||
throw new HlsEncoderError('finalize_failed', 'Server rejected 1080p finalize payload');
|
||||
}
|
||||
|
||||
const metadata = finalizeBody.data.metadata as { hlsRenditions?: string[]; hls1080Status?: Hls1080Status } | null;
|
||||
emit({ stage: 'done', percent: 100 });
|
||||
|
||||
return {
|
||||
hlsRenditions: metadata?.hlsRenditions ?? ['p360', 'p720', 'p1080'],
|
||||
hls1080Status: metadata?.hls1080Status ?? 'ready'
|
||||
};
|
||||
} catch (error) {
|
||||
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}` });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
class HlsEncoderError extends Error {
|
||||
constructor(
|
||||
public code: 'webcodecs_unsupported' | 'encode_failed' | 'upload_failed' | 'finalize_failed',
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'HlsEncoderError';
|
||||
}
|
||||
}
|
||||
|
||||
interface PresignedUrl {
|
||||
url: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Predict every path Mediabunny will write so we can presign them all in
|
||||
* a single request before encoding starts. Segment counts come from
|
||||
* duration / target-duration with a small safety margin — over-presigning
|
||||
* is free (unused presigned URLs simply expire) while under-presigning
|
||||
* forces a fallback per-file presign at upload time.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
function predictHlsPaths(rungs: HlsRungConfig[], duration: number, hasAudio: boolean): string[] {
|
||||
const paths: string[] = ['master.m3u8'];
|
||||
const segmentCount = Math.max(1, Math.ceil(duration / HLS_TARGET_SEGMENT_SECONDS)) + 5;
|
||||
|
||||
for (const rung of rungs) {
|
||||
paths.push(`${rung.name}/playlist.m3u8`);
|
||||
for (let i = 1; i <= segmentCount; i++) {
|
||||
paths.push(`${rung.name}/seg-${pad(i, 5)}.ts`);
|
||||
}
|
||||
}
|
||||
if (hasAudio) {
|
||||
paths.push('audio/playlist.m3u8');
|
||||
for (let i = 1; i <= segmentCount; i++) {
|
||||
paths.push(`audio/seg-${pad(i, 5)}.ts`);
|
||||
}
|
||||
paths.push('audio.m4a');
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
async function batchPresignPaths(assetId: string, paths: string[]): Promise<Map<string, PresignedUrl>> {
|
||||
const response = await classroomio.organization.assets[':assetId'].hls.presign.$post({
|
||||
param: { assetId },
|
||||
json: { paths }
|
||||
});
|
||||
return parsePresignResponse(paths, response);
|
||||
}
|
||||
|
||||
async function batchPresign1080Paths(assetId: string, paths: string[]): Promise<Map<string, PresignedUrl>> {
|
||||
const response = await classroomio.organization.assets[':assetId'].hls['1080'].presign.$post({
|
||||
param: { assetId },
|
||||
json: { paths }
|
||||
});
|
||||
return parsePresignResponse(paths, response);
|
||||
}
|
||||
|
||||
async function parsePresignResponse(paths: string[], response: Response): Promise<Map<string, PresignedUrl>> {
|
||||
const map = new Map<string, PresignedUrl>();
|
||||
const body = await response.json();
|
||||
if (!('success' in body) || !body.success) {
|
||||
throw new HlsEncoderError('upload_failed', 'Failed to presign HLS uploads');
|
||||
}
|
||||
|
||||
for (const path of paths) {
|
||||
const url = body.data.urls[path];
|
||||
const key = body.data.keys[path];
|
||||
if (url && key) map.set(path, { url, key });
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads files Mediabunny emits while encoding. All expected paths are
|
||||
* pre-presigned at start; PathedTarget.onFinalize is a synchronous map
|
||||
* lookup. The rare path that wasn't predicted (e.g. encoder produced one
|
||||
* extra segment) falls back to a single per-file presign.
|
||||
*/
|
||||
class HlsUploader {
|
||||
private totalBytes = 0;
|
||||
private uploadedCount = 0;
|
||||
private inflight: Promise<void>[] = [];
|
||||
private uploadFailures: unknown[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly options: {
|
||||
assetId: string;
|
||||
presignedUrls: Map<string, PresignedUrl>;
|
||||
onUploaded: (state: { totalBytes: number; count: number }) => void;
|
||||
signal?: AbortSignal;
|
||||
presignEndpoint?: 'default' | '1080';
|
||||
skipPaths?: Set<string>;
|
||||
capturedFiles?: Map<string, Uint8Array>;
|
||||
}
|
||||
) {}
|
||||
|
||||
targetFor(path: string, mimeType: string): BufferTarget {
|
||||
return new BufferTarget({
|
||||
onFinalize: async (buffer) => {
|
||||
await this.uploadSingle(path, mimeType, buffer);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async uploadSingle(
|
||||
path: string,
|
||||
mimeType: string,
|
||||
buffer: ArrayBuffer | Uint8Array,
|
||||
options?: { force?: boolean }
|
||||
): Promise<string> {
|
||||
const upload = (async () => {
|
||||
const u8 = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
|
||||
if (!options?.force && this.options.skipPaths?.has(path)) {
|
||||
this.options.capturedFiles?.set(path, u8);
|
||||
return `${this.options.assetId}/${path}`;
|
||||
}
|
||||
|
||||
let presigned = this.options.presignedUrls.get(path);
|
||||
if (!presigned) {
|
||||
presigned = await this.fallbackPresign(path);
|
||||
this.options.presignedUrls.set(path, presigned);
|
||||
}
|
||||
|
||||
const owned = new Uint8Array(u8.byteLength);
|
||||
owned.set(u8);
|
||||
const body = new Blob([owned], { type: mimeType });
|
||||
const res = await fetch(presigned.url, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': mimeType },
|
||||
body,
|
||||
signal: this.options.signal
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new HlsEncoderError('upload_failed', `PUT ${path} → ${res.status}`);
|
||||
}
|
||||
|
||||
this.totalBytes += owned.byteLength;
|
||||
this.uploadedCount += 1;
|
||||
this.options.onUploaded({ totalBytes: this.totalBytes, count: this.uploadedCount });
|
||||
return presigned.key;
|
||||
})();
|
||||
|
||||
const tracked = upload.then(
|
||||
() => undefined,
|
||||
(error) => {
|
||||
this.uploadFailures.push(error);
|
||||
throw error;
|
||||
}
|
||||
);
|
||||
this.inflight.push(tracked);
|
||||
tracked.finally(() => {
|
||||
const index = this.inflight.indexOf(tracked);
|
||||
if (index >= 0) this.inflight.splice(index, 1);
|
||||
});
|
||||
|
||||
return upload;
|
||||
}
|
||||
|
||||
private async fallbackPresign(path: string): Promise<PresignedUrl> {
|
||||
const response =
|
||||
this.options.presignEndpoint === '1080'
|
||||
? await classroomio.organization.assets[':assetId'].hls['1080'].presign.$post({
|
||||
param: { assetId: this.options.assetId },
|
||||
json: { paths: [path] }
|
||||
})
|
||||
: await classroomio.organization.assets[':assetId'].hls.presign.$post({
|
||||
param: { assetId: this.options.assetId },
|
||||
json: { paths: [path] }
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!('success' in body) || !body.success) {
|
||||
throw new HlsEncoderError('upload_failed', `Failed to presign ${path}`);
|
||||
}
|
||||
const url = body.data.urls[path];
|
||||
const key = body.data.keys[path];
|
||||
if (!url || !key) {
|
||||
throw new HlsEncoderError('upload_failed', `Presign returned no url for ${path}`);
|
||||
}
|
||||
return { url, key };
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
while (this.inflight.length) {
|
||||
const pending = this.inflight.slice();
|
||||
const results = await Promise.allSettled(pending);
|
||||
const failure = results.find((result) => result.status === 'rejected');
|
||||
if (failure?.status === 'rejected') {
|
||||
throw failure.reason;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.uploadFailures.length > 0) {
|
||||
throw this.uploadFailures[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function extractAndUploadThumbnails(input: {
|
||||
videoTrack: InputVideoTrack;
|
||||
duration: number;
|
||||
file: File;
|
||||
}): Promise<string[]> {
|
||||
if (!input.duration || input.duration <= 0) return [];
|
||||
|
||||
const sink = new CanvasSink(input.videoTrack, { width: 1280, fit: 'contain', poolSize: 1 });
|
||||
const timestamps = THUMBNAIL_FRACTIONS.map((f) => input.duration * f);
|
||||
const candidates: { url: string; score: number }[] = [];
|
||||
|
||||
let index = 0;
|
||||
for await (const wrapped of sink.canvasesAtTimestamps(timestamps)) {
|
||||
if (!wrapped) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const canvas = wrapped.canvas as HTMLCanvasElement | OffscreenCanvas;
|
||||
const blob = await canvasToJpegBlobUnderCap(canvas, THUMBNAIL_MAX_BYTES);
|
||||
if (!blob) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const score = await averageLuma(canvas);
|
||||
const fileName = input.file.name.replace(/\.[^.]+$/, '') + `-thumb-${index}.jpg`;
|
||||
const file = new File([blob], fileName, { type: 'image/jpeg' });
|
||||
try {
|
||||
const response = await classroomio.media.image.$post({ form: { file } });
|
||||
const body = await response.json();
|
||||
if ('success' in body && body.success && body.url) {
|
||||
candidates.push({ url: body.url, score });
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Thumbnail upload failed', error);
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => b.score - a.score);
|
||||
return candidates.map((c) => c.url);
|
||||
}
|
||||
|
||||
async function canvasToJpegBlob(canvas: HTMLCanvasElement | OffscreenCanvas, quality: number): Promise<Blob | null> {
|
||||
if (canvas instanceof OffscreenCanvas) {
|
||||
return canvas.convertToBlob({ type: 'image/jpeg', quality });
|
||||
}
|
||||
return new Promise((resolve) => canvas.toBlob((blob) => resolve(blob), 'image/jpeg', quality));
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode the canvas as JPEG, stepping quality down until the blob fits under
|
||||
* `maxBytes`. Returns the first blob within the cap, or the smallest produced
|
||||
* if even the lowest quality overshoots.
|
||||
*/
|
||||
async function canvasToJpegBlobUnderCap(
|
||||
canvas: HTMLCanvasElement | OffscreenCanvas,
|
||||
maxBytes: number
|
||||
): Promise<Blob | null> {
|
||||
let smallest: Blob | null = null;
|
||||
for (const quality of THUMBNAIL_QUALITY_LADDER) {
|
||||
const blob = await canvasToJpegBlob(canvas, quality);
|
||||
if (!blob) continue;
|
||||
|
||||
if (!smallest || blob.size < smallest.size) smallest = blob;
|
||||
if (blob.size <= maxBytes) return blob;
|
||||
}
|
||||
|
||||
return smallest;
|
||||
}
|
||||
|
||||
async function averageLuma(canvas: HTMLCanvasElement | OffscreenCanvas): Promise<number> {
|
||||
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null;
|
||||
if (!ctx) return 0;
|
||||
|
||||
const { width, height } = canvas;
|
||||
const sampleSize = Math.min(64, width, height);
|
||||
const data = ctx.getImageData(0, 0, sampleSize, sampleSize).data;
|
||||
let sum = 0;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
sum += 0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2];
|
||||
}
|
||||
return sum / (data.length / 4);
|
||||
}
|
||||
|
||||
function computeAspectRatioLabel(track: InputVideoTrack): string {
|
||||
const w = track.displayWidth;
|
||||
const h = track.displayHeight;
|
||||
if (!w || !h) return '16:9';
|
||||
|
||||
const gcd = (a: number, b: number): number => (b === 0 ? a : gcd(b, a % b));
|
||||
const g = gcd(w, h);
|
||||
return `${w / g}:${h / g}`;
|
||||
}
|
||||
|
||||
function pad(n: number, width: number): string {
|
||||
return n.toString().padStart(width, '0');
|
||||
}
|
||||
|
||||
async function fetchExistingMasterManifest(assetId: string, signal?: AbortSignal): Promise<string> {
|
||||
await classroomio.organization.assets[':assetId'].hls.cookie.$post({ param: { assetId } });
|
||||
const response = await fetch(`/hls/${assetId}/master.m3u8`, { credentials: 'include', signal });
|
||||
if (!response.ok) {
|
||||
throw new HlsEncoderError('upload_failed', `Failed to fetch master manifest (${response.status})`);
|
||||
}
|
||||
|
||||
return response.text();
|
||||
}
|
||||
|
||||
function extractAudioGroupId(manifest: string): string | null {
|
||||
const match = manifest.match(/#EXT-X-MEDIA:TYPE=AUDIO[^\n]*GROUP-ID="([^"]+)"/);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
function mergeP1080IntoMaster(existingManifest: string, generatedManifest: string): string {
|
||||
if (existingManifest.includes('p1080/playlist.m3u8')) {
|
||||
return existingManifest;
|
||||
}
|
||||
|
||||
const generatedLines = generatedManifest.split('\n');
|
||||
let streamInf: string | null = null;
|
||||
for (let index = 0; index < generatedLines.length; index += 1) {
|
||||
const line = generatedLines[index]?.trim() ?? '';
|
||||
if (!line.startsWith('#EXT-X-STREAM-INF')) continue;
|
||||
|
||||
const playlistLine = generatedLines[index + 1]?.trim() ?? '';
|
||||
if (playlistLine === 'p1080/playlist.m3u8') {
|
||||
streamInf = line;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const audioGroupId = extractAudioGroupId(existingManifest);
|
||||
if (!streamInf) {
|
||||
streamInf = `#EXT-X-STREAM-INF:BANDWIDTH=${P1080_RUNG.videoBitrate},RESOLUTION=${P1080_RUNG.width}x${P1080_RUNG.height},CODECS="avc1.640028,mp4a.40.2"`;
|
||||
}
|
||||
|
||||
if (audioGroupId && !streamInf.includes('AUDIO=')) {
|
||||
streamInf = `${streamInf},AUDIO="${audioGroupId}"`;
|
||||
}
|
||||
|
||||
const lines = existingManifest.trimEnd().split('\n');
|
||||
lines.push(streamInf);
|
||||
lines.push('p1080/playlist.m3u8');
|
||||
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
+5
@@ -24,6 +24,11 @@ class LessonVideoBus {
|
||||
this.seekFn(seconds);
|
||||
}
|
||||
|
||||
/** Replace the active transcript (e.g. after an edit is saved). */
|
||||
setTranscript(transcript: AssetTranscriptPayload | null) {
|
||||
this.transcript = transcript;
|
||||
}
|
||||
|
||||
/** Reset all per-lesson state (called when the upload asset changes or the player unmounts). */
|
||||
reset(opts?: { keepAssetId?: boolean }) {
|
||||
if (!opts?.keepAssetId) {
|
||||
|
||||
+245
-54
@@ -1,29 +1,206 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { MediaPlayer } from '@cio/ui/custom/media-player';
|
||||
import { presignApi } from '$features/course/api/presign.svelte';
|
||||
import { mediaApi } from '$features/media/api';
|
||||
import { jobsApi, JobPoller, type MediaJobEnvelope } from '$features/jobs';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { sidePanel } from '$features/side-panel';
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
import type { AssetTranscriptPayload } from '$features/media/utils/types';
|
||||
import type { LessonVideo } from './video-card-utils';
|
||||
import { lessonVideoBus } from './lesson-video-bus.svelte';
|
||||
import { TRANSCRIPT_PANEL_ID } from './transcript-panel-definition';
|
||||
|
||||
/**
|
||||
* HLS playback flag — set by the upload flow when the asset was encoded
|
||||
* via Mediabunny and stored as a manifest. `video.metadata` is untyped
|
||||
* `Record<string, unknown>` at the call site, so the cast keeps the
|
||||
* boolean lookup honest.
|
||||
*/
|
||||
function isHlsVideo(v: LessonVideo): boolean {
|
||||
return Boolean((v.metadata as { hls?: boolean } | undefined)?.hls);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fully-qualified URL for an HLS manifest or segment from the
|
||||
* relative `/hls/{assetId}/{rest}` form stored in `lesson.videos[].link`.
|
||||
* Uses the typed Hono client so the URL goes through the same base every
|
||||
* other API call uses (PUBLIC_SERVER_URL locally, `${origin}/proxy/...`
|
||||
* in Cloud production — the tenant-router Worker intercepts the latter
|
||||
* and streams directly from R2).
|
||||
*/
|
||||
function resolveHlsUrl(rawUrl: string): string {
|
||||
if (/^https?:\/\//i.test(rawUrl)) return rawUrl;
|
||||
const match = rawUrl.match(/^\/?hls\/([^/]+)\/(.+)$/);
|
||||
if (!match) return rawUrl;
|
||||
|
||||
const [, assetId, rest] = match;
|
||||
const built = classroomio.hls[':assetId']['*'].$url({ param: { assetId } });
|
||||
return built.toString().replace(/\/\*$/, '') + '/' + rest;
|
||||
}
|
||||
|
||||
async function mintHlsCookie(assetId: string): Promise<void> {
|
||||
await classroomio.organization.assets[':assetId'].hls.cookie.$post({
|
||||
param: { assetId }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the transcript VTT through the typed RPC client and turn it
|
||||
* into a `blob:` URL. We can't point the `<track>` element directly at
|
||||
* the api URL: `<video crossorigin>` would force CORS on every video
|
||||
* subresource (poster on r2.dev, Plyr's `blank.mp4`, …) and break
|
||||
* those. Without `crossorigin` the browser refuses cross-origin
|
||||
* tracks. `blob:` URLs side-step both: same-origin from the browser's
|
||||
* POV, and the fetch itself runs through the same auth'd path as
|
||||
* every other API call.
|
||||
*/
|
||||
let blobTrackUrl = $state<string | null>(null);
|
||||
let revokeBlobTrackUrl: (() => void) | null = null;
|
||||
|
||||
async function loadTranscriptTrack(rawUrl: string): Promise<void> {
|
||||
const match = rawUrl.match(/^\/?transcripts\/([^/]+)\/track\.vtt$/);
|
||||
if (!match) {
|
||||
blobTrackUrl = /^https?:\/\//i.test(rawUrl) ? rawUrl : null;
|
||||
return;
|
||||
}
|
||||
|
||||
const [, assetId] = match;
|
||||
try {
|
||||
const response = await classroomio.transcripts[':assetId']['track.vtt'].$get({ param: { assetId } });
|
||||
if (!response.ok) return;
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
revokeBlobTrackUrl?.();
|
||||
revokeBlobTrackUrl = () => URL.revokeObjectURL(url);
|
||||
blobTrackUrl = url;
|
||||
} catch (error) {
|
||||
console.warn('Failed to load transcript track', error);
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
video: LessonVideo;
|
||||
}
|
||||
|
||||
let { video }: Props = $props();
|
||||
|
||||
let localTranscript = $state<AssetTranscriptPayload | null>(null);
|
||||
let localTranscriptLoading = $state(false);
|
||||
|
||||
// The parent keys this component on the upload assetId / link, so each
|
||||
// mount maps to a single asset; lifecycle is plain onMount/onDestroy.
|
||||
const uploadAssetId =
|
||||
video.type === 'upload' ? ((video as LessonVideo & { assetId?: string }).assetId ?? null) : null;
|
||||
const uploadStorageKey =
|
||||
video.type === 'upload' && typeof video.key === 'string' && video.key.length > 0 ? video.key : null;
|
||||
|
||||
/** Refresh ~10 minutes before server presign expiry (1 hour). */
|
||||
const PLAYBACK_URL_REFRESH_MS = 50 * 60 * 1000;
|
||||
|
||||
let isMounted = true;
|
||||
const isHls = $derived(isHlsVideo(video));
|
||||
let playbackUrl = $state(isHlsVideo(video) ? resolveHlsUrl(video.link) : video.link);
|
||||
let playbackUrlIssuedAt = $state(Date.now());
|
||||
let playbackRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let vttRefetchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let jobPoller: JobPoller<MediaJobEnvelope> | null = null;
|
||||
let hasAutoOpenedTranscript = false;
|
||||
|
||||
let localSeekFn: (seconds: number) => void = () => {};
|
||||
|
||||
function ownsVideoBus(): boolean {
|
||||
return uploadAssetId !== null && lessonVideoBus.assetId === uploadAssetId;
|
||||
}
|
||||
|
||||
function syncVideoBus(): void {
|
||||
if (!uploadAssetId) return;
|
||||
|
||||
lessonVideoBus.assetId = uploadAssetId;
|
||||
lessonVideoBus.transcript = localTranscript;
|
||||
lessonVideoBus.transcriptLoading = localTranscriptLoading;
|
||||
lessonVideoBus.setSeekFn(localSeekFn);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const rawUrl = localTranscript?.vttUrl;
|
||||
if (!rawUrl) {
|
||||
revokeBlobTrackUrl?.();
|
||||
revokeBlobTrackUrl = null;
|
||||
blobTrackUrl = null;
|
||||
return;
|
||||
}
|
||||
void loadTranscriptTrack(rawUrl);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
playbackUrl = isHlsVideo(video) ? resolveHlsUrl(video.link) : video.link;
|
||||
playbackUrlIssuedAt = Date.now();
|
||||
schedulePlaybackUrlRefresh();
|
||||
});
|
||||
|
||||
function clearPlaybackRefreshTimer() {
|
||||
if (playbackRefreshTimer) {
|
||||
clearTimeout(playbackRefreshTimer);
|
||||
playbackRefreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePlaybackUrlRefresh() {
|
||||
clearPlaybackRefreshTimer();
|
||||
if (!uploadStorageKey || isHls) return;
|
||||
|
||||
const delayMs = PLAYBACK_URL_REFRESH_MS - (Date.now() - playbackUrlIssuedAt);
|
||||
if (delayMs <= 0) {
|
||||
void refreshPlaybackUrlIfIdle();
|
||||
return;
|
||||
}
|
||||
|
||||
playbackRefreshTimer = setTimeout(() => {
|
||||
void refreshPlaybackUrlIfIdle();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
async function refreshPlaybackUrl(): Promise<string | null> {
|
||||
if (!uploadStorageKey || !isMounted) return null;
|
||||
|
||||
const urls = await presignApi.getVideoDownloadUrls([uploadStorageKey]);
|
||||
const freshUrl = urls[uploadStorageKey];
|
||||
|
||||
if (!freshUrl || !isMounted) return null;
|
||||
|
||||
playbackUrl = freshUrl;
|
||||
playbackUrlIssuedAt = Date.now();
|
||||
schedulePlaybackUrlRefresh();
|
||||
|
||||
return freshUrl;
|
||||
}
|
||||
|
||||
async function refreshPlaybackUrlIfIdle(): Promise<string | null> {
|
||||
if (lessonVideoBus.hasPlayed) return null;
|
||||
|
||||
return refreshPlaybackUrl();
|
||||
}
|
||||
|
||||
async function handlePlaybackReload(): Promise<boolean> {
|
||||
pendingResumeSeconds = lessonVideoBus.currentTimeSeconds;
|
||||
|
||||
if (uploadStorageKey) {
|
||||
return Boolean(await refreshPlaybackUrl());
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
let pendingResumeSeconds: number | null = null;
|
||||
|
||||
function handleSourceLoaded(element: HTMLVideoElement) {
|
||||
if (pendingResumeSeconds == null || pendingResumeSeconds <= 0) return;
|
||||
|
||||
element.currentTime = pendingResumeSeconds;
|
||||
pendingResumeSeconds = null;
|
||||
}
|
||||
|
||||
function clearVttRefetchTimer() {
|
||||
if (vttRefetchTimer) {
|
||||
@@ -58,7 +235,10 @@
|
||||
async function loadTranscript(): Promise<void> {
|
||||
if (!uploadAssetId) {
|
||||
if (isMounted) {
|
||||
lessonVideoBus.transcript = null;
|
||||
localTranscript = null;
|
||||
if (ownsVideoBus()) {
|
||||
lessonVideoBus.transcript = null;
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
@@ -66,24 +246,33 @@
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
lessonVideoBus.transcriptLoading = true;
|
||||
localTranscriptLoading = true;
|
||||
if (ownsVideoBus()) {
|
||||
lessonVideoBus.transcriptLoading = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await mediaApi.getAssetTranscript(uploadAssetId);
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
lessonVideoBus.transcript = data;
|
||||
localTranscript = data;
|
||||
scheduleVttRefetch(data?.vttUrlExpiresAt);
|
||||
if (ownsVideoBus()) {
|
||||
lessonVideoBus.transcript = data;
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
lessonVideoBus.transcriptLoading = false;
|
||||
localTranscriptLoading = false;
|
||||
if (ownsVideoBus()) {
|
||||
lessonVideoBus.transcriptLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function watchJobsUntilTerminalThenReload(): Promise<void> {
|
||||
if (!uploadAssetId || lessonVideoBus.transcript || !isMounted) return;
|
||||
if (!uploadAssetId || localTranscript || !isMounted) return;
|
||||
|
||||
stopJobPoller();
|
||||
|
||||
@@ -122,8 +311,9 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
lessonVideoBus.reset();
|
||||
lessonVideoBus.assetId = uploadAssetId;
|
||||
if (lessonVideoBus.assetId === null || lessonVideoBus.assetId === uploadAssetId) {
|
||||
syncVideoBus();
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
await loadTranscript();
|
||||
@@ -135,19 +325,24 @@
|
||||
|
||||
onDestroy(() => {
|
||||
isMounted = false;
|
||||
clearPlaybackRefreshTimer();
|
||||
clearVttRefetchTimer();
|
||||
stopJobPoller();
|
||||
lessonVideoBus.reset();
|
||||
sidePanel.closeIfScope('lesson');
|
||||
revokeBlobTrackUrl?.();
|
||||
revokeBlobTrackUrl = null;
|
||||
if (ownsVideoBus()) {
|
||||
lessonVideoBus.reset();
|
||||
sidePanel.closeIfScope('lesson');
|
||||
}
|
||||
});
|
||||
|
||||
const tracks = $derived(
|
||||
lessonVideoBus.transcript?.vttUrl
|
||||
blobTrackUrl && localTranscript
|
||||
? [
|
||||
{
|
||||
kind: 'captions' as const,
|
||||
src: lessonVideoBus.transcript.vttUrl,
|
||||
srclang: lessonVideoBus.transcript.language,
|
||||
src: blobTrackUrl,
|
||||
srclang: localTranscript.language,
|
||||
label: t.get('course.navItem.lessons.materials.tabs.video.transcript.captions_label'),
|
||||
default: false
|
||||
}
|
||||
@@ -155,30 +350,17 @@
|
||||
: []
|
||||
);
|
||||
|
||||
// Plyr can't add a <track> after construction, so when the captions URL
|
||||
// appears asynchronously we remount the player once. Include a stable key when
|
||||
// a transcript exists but VTT is not ready yet so optional controls (e.g. transcript panel) attach.
|
||||
const playerRemountKey = $derived.by(() => {
|
||||
if (!lessonVideoBus.transcript) return 'no-transcript';
|
||||
|
||||
return lessonVideoBus.transcript.vttUrl ?? 'transcript-awaiting-vtt';
|
||||
});
|
||||
|
||||
function handleFirstPlay() {
|
||||
syncVideoBus();
|
||||
lessonVideoBus.hasPlayed = true;
|
||||
|
||||
if (hasAutoOpenedTranscript) return;
|
||||
if (!lessonVideoBus.transcript?.segments?.length) return;
|
||||
|
||||
hasAutoOpenedTranscript = true;
|
||||
sidePanel.open(TRANSCRIPT_PANEL_ID);
|
||||
}
|
||||
|
||||
const transcriptPanelControl = $derived(
|
||||
lessonVideoBus.transcript
|
||||
localTranscript
|
||||
? {
|
||||
label: t.get('course.navItem.lessons.materials.tabs.video.transcript.open_side_panel'),
|
||||
onClick: () => {
|
||||
syncVideoBus();
|
||||
sidePanel.open(TRANSCRIPT_PANEL_ID);
|
||||
}
|
||||
}
|
||||
@@ -187,30 +369,39 @@
|
||||
</script>
|
||||
|
||||
<div class="w-full">
|
||||
{#key playerRemountKey}
|
||||
<MediaPlayer
|
||||
source={{
|
||||
type: video.type,
|
||||
url: video.link,
|
||||
metadata: video.metadata as { thumbnailUrl?: string; title?: string } | undefined,
|
||||
tracks
|
||||
}}
|
||||
options={{
|
||||
maxHeight: '569px',
|
||||
width: '100%',
|
||||
controls: true,
|
||||
playsinline: true,
|
||||
onTimeUpdate: (seconds) => {
|
||||
<MediaPlayer
|
||||
source={{
|
||||
type: video.type,
|
||||
url: playbackUrl,
|
||||
hls: isHls,
|
||||
metadata: video.metadata as { thumbnailUrl?: string; title?: string } | undefined,
|
||||
tracks
|
||||
}}
|
||||
options={{
|
||||
maxHeight: '569px',
|
||||
width: '100%',
|
||||
controls: true,
|
||||
playsinline: true,
|
||||
onTimeUpdate: (seconds) => {
|
||||
if (ownsVideoBus()) {
|
||||
lessonVideoBus.currentTimeSeconds = seconds;
|
||||
},
|
||||
onPlayerReady: (player) => {
|
||||
lessonVideoBus.setSeekFn((seconds) => {
|
||||
player.currentTime = seconds;
|
||||
});
|
||||
},
|
||||
onFirstPlay: handleFirstPlay,
|
||||
transcriptPanelControl
|
||||
}}
|
||||
/>
|
||||
{/key}
|
||||
}
|
||||
},
|
||||
onPlayerReady: (player) => {
|
||||
localSeekFn = (seconds) => {
|
||||
player.currentTime = seconds;
|
||||
};
|
||||
if (ownsVideoBus()) {
|
||||
lessonVideoBus.setSeekFn(localSeekFn);
|
||||
}
|
||||
},
|
||||
onFirstPlay: handleFirstPlay,
|
||||
onSourceLoaded: handleSourceLoaded,
|
||||
onBeforeHlsLoad: isHls && uploadAssetId ? () => mintHlsCookie(uploadAssetId) : undefined,
|
||||
transcriptPanelControl,
|
||||
playbackErrorLabel: $t('course.navItem.lessons.materials.tabs.video.playback_error'),
|
||||
playbackReloadLabel: $t('course.navItem.lessons.materials.tabs.video.playback_reload'),
|
||||
onPlaybackReload: handlePlaybackReload
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+2
-1
@@ -78,7 +78,7 @@
|
||||
|
||||
<!-- YouTube-like: thumbnail on top; avatar + stacked text + overflow menu -->
|
||||
<div class="group w-full max-w-full min-w-0 {isEditMode ? 'rounded-lg border' : ''}">
|
||||
<div class="ui:bg-muted relative aspect-video w-full min-w-0 overflow-hidden rounded-xl">
|
||||
<div class="ui:bg-muted ui:rounded-md relative aspect-video w-full min-w-0 overflow-hidden">
|
||||
{#if thumbnailUrl}
|
||||
<Image src={thumbnailUrl} alt={title} className="absolute inset-0 block h-full w-full object-cover" />
|
||||
{:else}
|
||||
@@ -142,6 +142,7 @@
|
||||
{video}
|
||||
{onRemove}
|
||||
onThumbnailSaved={(url) => lessonApi.updateLessonVideoThumbnail(index, url)}
|
||||
onHlsMetadataUpdated={(metadata) => lessonApi.updateLessonVideoMetadata(index, metadata)}
|
||||
menuPlacement="inline"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
+85
-14
@@ -1,21 +1,31 @@
|
||||
<script lang="ts">
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import PencilIcon from '@lucide/svelte/icons/pencil';
|
||||
import CaptionsIcon from '@lucide/svelte/icons/captions';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Textarea } from '@cio/ui/base/textarea';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { sidePanel } from '$features/side-panel';
|
||||
import { mediaApi } from '$features/media/api';
|
||||
import { lessonVideoBus } from './lesson-video-bus.svelte';
|
||||
import type { AssetTranscriptPayload } from '$features/media/utils/types';
|
||||
|
||||
type Segment = AssetTranscriptPayload['segments'][number];
|
||||
import type { TranscriptSegment } from '$features/media/utils/types';
|
||||
|
||||
const transcript = $derived(lessonVideoBus.transcript);
|
||||
const transcriptLoading = $derived(lessonVideoBus.transcriptLoading);
|
||||
const segments = $derived<Segment[]>(transcript?.segments ?? []);
|
||||
const segments = $derived<TranscriptSegment[]>(transcript?.segments ?? []);
|
||||
const language = $derived(transcript?.language ?? '');
|
||||
const assetId = $derived(lessonVideoBus.assetId);
|
||||
const currentTimeSeconds = $derived(lessonVideoBus.currentTimeSeconds);
|
||||
|
||||
function isActiveSegment(segment: Segment, index: number): boolean {
|
||||
let editing = $state(false);
|
||||
let saving = $state(false);
|
||||
let draft = $state<TranscriptSegment[]>([]);
|
||||
|
||||
const canEdit = $derived(Boolean($isOrgAdmin) && Boolean(assetId) && segments.length > 0);
|
||||
|
||||
function isActiveSegment(segment: TranscriptSegment, index: number): boolean {
|
||||
const isLast = index === segments.length - 1;
|
||||
|
||||
if (isLast) {
|
||||
@@ -40,6 +50,30 @@
|
||||
|
||||
return `${m}:${s}`;
|
||||
}
|
||||
|
||||
function startEditing() {
|
||||
draft = segments.map((segment) => ({ ...segment }));
|
||||
editing = true;
|
||||
}
|
||||
|
||||
function cancelEditing() {
|
||||
editing = false;
|
||||
draft = [];
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!assetId) return;
|
||||
|
||||
saving = true;
|
||||
const updated = await mediaApi.updateAssetTranscript(assetId, draft);
|
||||
saving = false;
|
||||
|
||||
if (!updated) return;
|
||||
|
||||
lessonVideoBus.setTranscript(updated);
|
||||
editing = false;
|
||||
draft = [];
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
@@ -57,18 +91,55 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<IconButton
|
||||
onclick={() => sidePanel.close()}
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
tooltip={$t('side_panel.close_aria')}
|
||||
>
|
||||
<XIcon size={16} />
|
||||
</IconButton>
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
{#if editing}
|
||||
<Button variant="ghost" size="sm" onclick={cancelEditing} disabled={saving}>
|
||||
{$t('course.navItem.lessons.materials.tabs.video.transcript.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onclick={save} disabled={saving}>
|
||||
{$t('course.navItem.lessons.materials.tabs.video.transcript.save')}
|
||||
</Button>
|
||||
{:else}
|
||||
{#if canEdit}
|
||||
<IconButton
|
||||
onclick={startEditing}
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
tooltip={$t('course.navItem.lessons.materials.tabs.video.transcript.edit')}
|
||||
>
|
||||
<PencilIcon size={16} />
|
||||
</IconButton>
|
||||
{/if}
|
||||
<IconButton
|
||||
onclick={() => sidePanel.close()}
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
tooltip={$t('side_panel.close_aria')}
|
||||
>
|
||||
<XIcon size={16} />
|
||||
</IconButton>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
{#if segments.length}
|
||||
{#if editing}
|
||||
{#each draft as segment, index (index)}
|
||||
<div class="mb-3 last:mb-0">
|
||||
<span class="ui:text-muted-foreground mb-1 block text-xs tabular-nums">
|
||||
{formatTimestamp(segment.start)}
|
||||
</span>
|
||||
<Textarea
|
||||
bind:value={draft[index].text}
|
||||
rows={2}
|
||||
disabled={saving}
|
||||
aria-label={$t('course.navItem.lessons.materials.tabs.video.transcript.segment_label', {
|
||||
timestamp: formatTimestamp(segment.start)
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
{:else if segments.length}
|
||||
{#each segments as segment, index (segment.start + segment.end + index)}
|
||||
{@const active = isActiveSegment(segment, index)}
|
||||
<button
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
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 { env as publicEnv } from '$env/dynamic/public';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as FileDropZone from '@cio/ui/custom/file-drop-zone';
|
||||
import { mediaApi } from '$features/media/api';
|
||||
@@ -23,6 +25,14 @@
|
||||
let { lessonId: _lessonId = '' }: Props = $props();
|
||||
|
||||
let fileSize: number | undefined = $state();
|
||||
let hlsProgress: HlsEncodeProgress | null = $state(null);
|
||||
let hlsAbortController: AbortController | null = null;
|
||||
|
||||
/**
|
||||
* HLS is the default upload path on Cloud. Self-hosted installs fall
|
||||
* back to raw-MP4 upload (no R2 + tenant-router edge dependency).
|
||||
*/
|
||||
const hlsEnabled = $derived(publicEnv.PUBLIC_IS_SELFHOSTED !== 'true');
|
||||
|
||||
let formRes: {
|
||||
url?: string;
|
||||
@@ -110,6 +120,82 @@
|
||||
fileSize = videoFile.size / (1024 * 1024);
|
||||
fileName = videoFile.name;
|
||||
|
||||
if (hlsEnabled) {
|
||||
hlsAbortController = new AbortController();
|
||||
try {
|
||||
const result = await encodeAndUploadHls({
|
||||
file: videoFile,
|
||||
title: videoFile.name,
|
||||
signal: hlsAbortController.signal,
|
||||
onProgress: (p) => {
|
||||
hlsProgress = p;
|
||||
$lessonVideoUpload.uploadProgress = p.percent;
|
||||
}
|
||||
});
|
||||
|
||||
const lessonVideoPosition = Array.isArray(lessonApi.lesson?.videos) ? lessonApi.lesson?.videos.length : 0;
|
||||
if (_lessonId) {
|
||||
await mediaApi.attachAsset(result.assetId, {
|
||||
targetType: 'lesson',
|
||||
targetId: _lessonId,
|
||||
slotType: 'lesson_video',
|
||||
position: lessonVideoPosition
|
||||
});
|
||||
}
|
||||
|
||||
const hlsManifestUrl = `/hls/${result.assetId}/master.m3u8`;
|
||||
lessonApi.updateLessonState(
|
||||
'videos',
|
||||
[
|
||||
{
|
||||
type: 'upload',
|
||||
link: hlsManifestUrl,
|
||||
assetId: result.assetId,
|
||||
fileName: videoFile.name,
|
||||
metadata: {
|
||||
fileName: videoFile.name,
|
||||
createdAt: new Date().toISOString(),
|
||||
hls: true,
|
||||
sourceWidth: result.sourceWidth,
|
||||
sourceHeight: result.sourceHeight,
|
||||
hlsRenditions: result.hlsRenditions,
|
||||
hls1080Status: result.hls1080Status,
|
||||
...(result.thumbnailUrl ? { thumbnailUrl: result.thumbnailUrl } : {})
|
||||
}
|
||||
} as {
|
||||
type: 'upload';
|
||||
link: string;
|
||||
assetId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
fileName?: string;
|
||||
}
|
||||
],
|
||||
{ append: true }
|
||||
);
|
||||
|
||||
formRes = {
|
||||
url: hlsManifestUrl,
|
||||
fileKey: result.manifestKey,
|
||||
status: 200,
|
||||
thumbnailUrl: result.thumbnailUrl
|
||||
};
|
||||
startProcessingPoll(result.assetId);
|
||||
isLoaded = true;
|
||||
return;
|
||||
} catch (err) {
|
||||
console.error('HLS encode failed', err);
|
||||
formRes = {
|
||||
type: 'INTERNAL_ERROR',
|
||||
status: 500,
|
||||
message: err instanceof Error ? err.message : t.get('hls_upload.error.encode_failed')
|
||||
};
|
||||
isLoaded = true;
|
||||
return;
|
||||
} finally {
|
||||
$lessonVideoUpload.isUploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { url: presignedUrl, fileKey } = await videoUploader.getPresignedUrl(videoFile);
|
||||
|
||||
@@ -256,6 +342,7 @@
|
||||
|
||||
function cancelUpload() {
|
||||
videoUploader.abortController?.abort();
|
||||
hlsAbortController?.abort();
|
||||
|
||||
cancelVideoUpload();
|
||||
|
||||
@@ -264,7 +351,13 @@
|
||||
$lessonVideoUpload.uploadProgress = 0;
|
||||
}
|
||||
|
||||
let helperText = $derived($lessonVideoUpload.uploadProgress + '% of ' + Math.round(fileSize || 0) + 'MB');
|
||||
let helperText = $derived.by(() => {
|
||||
const hp = hlsProgress;
|
||||
if (!hp) return $lessonVideoUpload.uploadProgress + '% of ' + Math.round(fileSize || 0) + 'MB';
|
||||
|
||||
const stageLabel = t.get(`hls_upload.stage.${hp.stage}`);
|
||||
return `${stageLabel} ${hp.percent}%`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<UpgradeBanner className="mb-3" onClick={() => ($lessonVideoUpload.isModalOpen = false)}>
|
||||
|
||||
+149
-16
@@ -4,6 +4,7 @@
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import EyeIcon from '@lucide/svelte/icons/eye';
|
||||
import ImageIcon from '@lucide/svelte/icons/image';
|
||||
import SparklesIcon from '@lucide/svelte/icons/sparkles';
|
||||
import Trash2Icon from '@lucide/svelte/icons/trash-2';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { mediaApi } from '$features/media/api';
|
||||
@@ -14,7 +15,15 @@
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { onDestroy } from 'svelte';
|
||||
import type { LessonVideo } from './video-card-utils';
|
||||
import {
|
||||
canGenerateHls1080,
|
||||
getHls1080Status,
|
||||
getVideoMetadata,
|
||||
isHlsUploadVideo,
|
||||
hasHls1080Rendition,
|
||||
type LessonVideo
|
||||
} from './video-card-utils';
|
||||
import { encodeAndUploadHls1080 } from './hls-encoder';
|
||||
import { lessonVideoBus } from './lesson-video-bus.svelte';
|
||||
import { TRANSCRIPT_PANEL_ID } from './transcript-panel-definition';
|
||||
|
||||
@@ -22,25 +31,35 @@
|
||||
video: LessonVideo;
|
||||
onRemove: () => void;
|
||||
onThumbnailSaved?: (thumbnailUrl: string) => void;
|
||||
onHlsMetadataUpdated?: (metadata: Record<string, unknown>) => void;
|
||||
/** `inline` aligns with the title row (YouTube-style). `corner` is top-right overlay on the nearest `relative` ancestor. */
|
||||
menuPlacement?: 'inline' | 'corner';
|
||||
}
|
||||
|
||||
let { video, onRemove, onThumbnailSaved, menuPlacement = 'inline' }: Props = $props();
|
||||
let { video, onRemove, onThumbnailSaved, onHlsMetadataUpdated, menuPlacement = 'inline' }: Props = $props();
|
||||
|
||||
let manageThumbsOpen = $state(false);
|
||||
let manageThumbsAsset = $state<OrganizationAsset | null>(null);
|
||||
let isLoadingAsset = $state(false);
|
||||
let dropdownOpen = $state(false);
|
||||
|
||||
let isTranscribing = $state(false);
|
||||
let hasTranscript = $state(false);
|
||||
let hasCheckedTranscript = $state(false);
|
||||
let activePoller: JobPoller<MediaJobEnvelope> | null = null;
|
||||
let isGenerating1080 = $state(false);
|
||||
let sourceFileInput: HTMLInputElement | undefined = $state();
|
||||
|
||||
const assetId = $derived((video as LessonVideo & { assetId?: string }).assetId ?? null);
|
||||
|
||||
const canGenerateTranscript = $derived(video.type === 'upload' && !!assetId);
|
||||
const canManageThumbnails = $derived(video.type === 'upload' && !!assetId);
|
||||
const hls1080Status = $derived(getHls1080Status(video));
|
||||
const showGenerate1080 = $derived(canGenerateHls1080(video) && !isGenerating1080);
|
||||
const show1080Ready = $derived(hls1080Status === 'ready');
|
||||
const show1080Unavailable = $derived(
|
||||
isHlsUploadVideo(video) && !hasHls1080Rendition(video) && !isGenerating1080 && hls1080Status === 'unavailable'
|
||||
);
|
||||
|
||||
onDestroy(() => {
|
||||
activePoller?.stop();
|
||||
@@ -54,6 +73,24 @@
|
||||
hasTranscript = !!data?.segments?.length;
|
||||
}
|
||||
|
||||
async function ensureHlsMetadataChecked() {
|
||||
if (!assetId || !isHlsUploadVideo(video)) return;
|
||||
|
||||
const metadata = getVideoMetadata(video);
|
||||
if (metadata.sourceHeight != null) return;
|
||||
|
||||
const asset = await mediaApi.refreshAsset(assetId);
|
||||
if (!asset?.metadata || typeof asset.metadata !== 'object' || Array.isArray(asset.metadata)) return;
|
||||
|
||||
const assetMetadata = asset.metadata as Record<string, unknown>;
|
||||
onHlsMetadataUpdated?.({
|
||||
...(typeof assetMetadata.sourceWidth === 'number' ? { sourceWidth: assetMetadata.sourceWidth } : {}),
|
||||
...(typeof assetMetadata.sourceHeight === 'number' ? { sourceHeight: assetMetadata.sourceHeight } : {}),
|
||||
...(Array.isArray(assetMetadata.hlsRenditions) ? { hlsRenditions: assetMetadata.hlsRenditions } : {}),
|
||||
...(typeof assetMetadata.hls1080Status === 'string' ? { hls1080Status: assetMetadata.hls1080Status } : {})
|
||||
});
|
||||
}
|
||||
|
||||
async function startTranscriptionPoll(afterAssetId: string) {
|
||||
activePoller?.stop();
|
||||
activePoller = null;
|
||||
@@ -131,6 +168,10 @@
|
||||
async function handleManageThumbnails() {
|
||||
if (!assetId || isLoadingAsset) return;
|
||||
|
||||
// Close the dropdown immediately so the user doesn't see it floating
|
||||
// behind the dialog during the asset refresh round-trip.
|
||||
dropdownOpen = false;
|
||||
|
||||
isLoadingAsset = true;
|
||||
try {
|
||||
const asset = await mediaApi.refreshAsset(assetId);
|
||||
@@ -153,18 +194,78 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleGenerate1080Click() {
|
||||
if (!assetId || isGenerating1080) return;
|
||||
|
||||
dropdownOpen = false;
|
||||
sourceFileInput?.click();
|
||||
}
|
||||
|
||||
async function handleSourceFileSelected(event: Event) {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
const selectedFile = input.files?.[0];
|
||||
input.value = '';
|
||||
|
||||
if (!selectedFile || !assetId) return;
|
||||
|
||||
isGenerating1080 = true;
|
||||
onHlsMetadataUpdated?.({ hls1080Status: 'generating' });
|
||||
|
||||
try {
|
||||
const result = await encodeAndUploadHls1080({
|
||||
file: selectedFile,
|
||||
assetId
|
||||
});
|
||||
|
||||
onHlsMetadataUpdated?.({
|
||||
hlsRenditions: result.hlsRenditions,
|
||||
hls1080Status: result.hls1080Status
|
||||
});
|
||||
snackbar.success('snackbar.lesson_video.generate_1080_completed');
|
||||
} catch (error) {
|
||||
console.error('Failed to generate 1080p rendition', error);
|
||||
onHlsMetadataUpdated?.({ hls1080Status: 'failed' });
|
||||
snackbar.error('snackbar.lesson_video.generate_1080_failed');
|
||||
} finally {
|
||||
isGenerating1080 = false;
|
||||
}
|
||||
}
|
||||
|
||||
const generateLabel = $derived(
|
||||
isTranscribing
|
||||
? t.get('course.navItem.lessons.materials.tabs.video.generate_transcript_in_progress')
|
||||
: t.get('course.navItem.lessons.materials.tabs.video.generate_transcript')
|
||||
);
|
||||
|
||||
const generate1080Label = $derived(
|
||||
isGenerating1080
|
||||
? t.get('course.navItem.lessons.materials.tabs.video.generate_1080_in_progress')
|
||||
: t.get('course.navItem.lessons.materials.tabs.video.generate_1080')
|
||||
);
|
||||
</script>
|
||||
|
||||
<input
|
||||
bind:this={sourceFileInput}
|
||||
type="file"
|
||||
accept="video/*"
|
||||
class="hidden"
|
||||
onchange={(event) => {
|
||||
void handleSourceFileSelected(event);
|
||||
}}
|
||||
/>
|
||||
|
||||
<DropdownMenu.Root
|
||||
bind:open={dropdownOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (open && canGenerateTranscript) {
|
||||
if (!open) return;
|
||||
|
||||
if (canGenerateTranscript) {
|
||||
void ensureTranscriptChecked();
|
||||
}
|
||||
|
||||
if (isHlsUploadVideo(video)) {
|
||||
void ensureHlsMetadataChecked();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Trigger
|
||||
@@ -192,7 +293,39 @@
|
||||
</span>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
{#if canGenerateTranscript}
|
||||
{#if showGenerate1080}
|
||||
<DropdownMenu.Item disabled={isGenerating1080} onclick={handleGenerate1080Click}>
|
||||
<span class="flex items-center gap-2">
|
||||
<SparklesIcon size={14} />
|
||||
{generate1080Label}
|
||||
</span>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
{#if isGenerating1080}
|
||||
<DropdownMenu.Item disabled>
|
||||
<span class="flex items-center gap-2">
|
||||
<SparklesIcon size={14} />
|
||||
{$t('course.navItem.lessons.materials.tabs.video.generate_1080_in_progress')}
|
||||
</span>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
{#if show1080Ready}
|
||||
<DropdownMenu.Item disabled>
|
||||
<span class="flex items-center gap-2">
|
||||
<SparklesIcon size={14} />
|
||||
{$t('course.navItem.lessons.materials.tabs.video.generate_1080_ready')}
|
||||
</span>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
{#if show1080Unavailable}
|
||||
<DropdownMenu.Item disabled>
|
||||
<span class="flex items-center gap-2">
|
||||
<SparklesIcon size={14} />
|
||||
{$t('course.navItem.lessons.materials.tabs.video.generate_1080_unavailable')}
|
||||
</span>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
{#if canGenerateTranscript && !hasTranscript}
|
||||
<DropdownMenu.Item
|
||||
disabled={isTranscribing}
|
||||
onclick={() => {
|
||||
@@ -204,18 +337,18 @@
|
||||
{generateLabel}
|
||||
</span>
|
||||
</DropdownMenu.Item>
|
||||
{#if hasTranscript}
|
||||
<DropdownMenu.Item
|
||||
onclick={() => {
|
||||
void handleViewTranscript();
|
||||
}}
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<EyeIcon size={14} />
|
||||
{$t('course.navItem.lessons.materials.tabs.video.view_transcript')}
|
||||
</span>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if canGenerateTranscript && hasTranscript}
|
||||
<DropdownMenu.Item
|
||||
onclick={() => {
|
||||
void handleViewTranscript();
|
||||
}}
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<EyeIcon size={14} />
|
||||
{$t('course.navItem.lessons.materials.tabs.video.view_transcript')}
|
||||
</span>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
<DropdownMenu.Item class="ui:text-red-600" onclick={onRemove}>
|
||||
<span class="flex items-center gap-2">
|
||||
|
||||
@@ -2,7 +2,9 @@ import { getYoutubeVideoId } from '@cio/ui/custom/media-player';
|
||||
|
||||
export type LessonVideo = NonNullable<import('$features/course/utils/types').Lesson['videos']>[number];
|
||||
|
||||
type VideoMetadata = {
|
||||
export type Hls1080Status = 'none' | 'generating' | 'ready' | 'failed';
|
||||
|
||||
export type VideoMetadata = {
|
||||
title?: string;
|
||||
fileName?: string;
|
||||
aspectRatio?: string;
|
||||
@@ -11,8 +13,66 @@ type VideoMetadata = {
|
||||
thumbnailUrl?: string;
|
||||
googleDriveFileId?: string;
|
||||
mimeType?: string;
|
||||
hls?: boolean;
|
||||
sourceWidth?: number;
|
||||
sourceHeight?: number;
|
||||
hlsRenditions?: string[];
|
||||
hls1080Status?: Hls1080Status;
|
||||
};
|
||||
|
||||
export function getVideoMetadata(video: LessonVideo): VideoMetadata {
|
||||
if (video.metadata && typeof video.metadata === 'object' && !Array.isArray(video.metadata)) {
|
||||
return video.metadata as VideoMetadata;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
export function isHlsUploadVideo(video: LessonVideo): boolean {
|
||||
if (video.type !== 'upload') return false;
|
||||
|
||||
const metadata = getVideoMetadata(video);
|
||||
if (metadata.hls === true) return true;
|
||||
|
||||
return typeof video.link === 'string' && video.link.includes('/hls/') && video.link.endsWith('.m3u8');
|
||||
}
|
||||
|
||||
export function hasHls1080Rendition(video: LessonVideo): boolean {
|
||||
const metadata = getVideoMetadata(video);
|
||||
return metadata.hlsRenditions?.includes('p1080') ?? false;
|
||||
}
|
||||
|
||||
export function canGenerateHls1080(video: LessonVideo): boolean {
|
||||
if (!isHlsUploadVideo(video)) return false;
|
||||
|
||||
const assetId = (video as LessonVideo & { assetId?: string }).assetId;
|
||||
if (!assetId) return false;
|
||||
|
||||
const metadata = getVideoMetadata(video);
|
||||
const sourceHeight = metadata.sourceHeight;
|
||||
if (sourceHeight != null && sourceHeight < 1080) return false;
|
||||
|
||||
if (hasHls1080Rendition(video)) return false;
|
||||
if (metadata.hls1080Status === 'generating') return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getHls1080Status(video: LessonVideo): Hls1080Status | 'unavailable' | 'unknown' {
|
||||
const metadata = getVideoMetadata(video);
|
||||
if (!isHlsUploadVideo(video)) return 'unavailable';
|
||||
|
||||
if (hasHls1080Rendition(video) || metadata.hls1080Status === 'ready') {
|
||||
return 'ready';
|
||||
}
|
||||
|
||||
const sourceHeight = metadata.sourceHeight;
|
||||
if (sourceHeight == null) return 'unknown';
|
||||
if (sourceHeight < 1080) return 'unavailable';
|
||||
|
||||
return metadata.hls1080Status ?? 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Thumbnail URL for a lesson video. YouTube uses img.youtube.com; uploads use metadata.thumbnailUrl when present.
|
||||
*/
|
||||
|
||||
+57
-16
@@ -1,9 +1,52 @@
|
||||
const JPEG_QUALITY = 0.85;
|
||||
const DEFAULT_SEEK_SECONDS = 1;
|
||||
const THUMBNAIL_TIMEOUT_MS = 15_000;
|
||||
/** Cap the captured frame to this width so the preview matches the stored thumbnail. */
|
||||
const MAX_THUMBNAIL_WIDTH = 1280;
|
||||
/** Hard ceiling for the generated thumbnail (matches the server job). */
|
||||
const THUMBNAIL_MAX_BYTES = 500 * 1024;
|
||||
/** JPEG quality steps tried (best first) until the frame fits under the size cap. */
|
||||
const THUMBNAIL_QUALITY_LADDER = [0.9, 0.8, 0.7, 0.6, 0.5];
|
||||
|
||||
export type ThumbnailResult = { blob: Blob; durationSeconds: number };
|
||||
|
||||
/**
|
||||
* Encode the canvas as JPEG, stepping quality down until the blob fits under
|
||||
* `maxBytes`. Resolves with the first blob within the cap, the smallest one
|
||||
* produced if none fit, or null on failure.
|
||||
*/
|
||||
function canvasToJpegBlobUnderCap(canvas: HTMLCanvasElement, maxBytes: number): Promise<Blob | null> {
|
||||
return new Promise((resolve) => {
|
||||
let smallest: Blob | null = null;
|
||||
let index = 0;
|
||||
|
||||
const tryNext = () => {
|
||||
if (index >= THUMBNAIL_QUALITY_LADDER.length) {
|
||||
resolve(smallest);
|
||||
return;
|
||||
}
|
||||
|
||||
const quality = THUMBNAIL_QUALITY_LADDER[index];
|
||||
index += 1;
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
if (!smallest || blob.size < smallest.size) smallest = blob;
|
||||
if (blob.size <= maxBytes) {
|
||||
resolve(blob);
|
||||
return;
|
||||
}
|
||||
}
|
||||
tryNext();
|
||||
},
|
||||
'image/jpeg',
|
||||
quality
|
||||
);
|
||||
};
|
||||
|
||||
tryNext();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a thumbnail from a video file by capturing a frame to canvas.
|
||||
* Seeks to 1s or duration/4 for short clips. Returns null on failure.
|
||||
@@ -44,13 +87,15 @@ export function generateVideoThumbnail(videoFile: File): Promise<ThumbnailResult
|
||||
|
||||
video.addEventListener('seeked', () => {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
if (canvas.width <= 0 || canvas.height <= 0) {
|
||||
if (video.videoWidth <= 0 || video.videoHeight <= 0) {
|
||||
finish(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const scale = video.videoWidth > MAX_THUMBNAIL_WIDTH ? MAX_THUMBNAIL_WIDTH / video.videoWidth : 1;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.round(video.videoWidth * scale);
|
||||
canvas.height = Math.round(video.videoHeight * scale);
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
finish(null);
|
||||
@@ -58,17 +103,13 @@ export function generateVideoThumbnail(videoFile: File): Promise<ThumbnailResult
|
||||
}
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
const durationSeconds = video.duration;
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
finish({ blob, durationSeconds: Number.isFinite(durationSeconds) ? durationSeconds : 0 });
|
||||
} else {
|
||||
finish(null);
|
||||
}
|
||||
},
|
||||
'image/jpeg',
|
||||
JPEG_QUALITY
|
||||
);
|
||||
canvasToJpegBlobUnderCap(canvas, THUMBNAIL_MAX_BYTES).then((blob) => {
|
||||
if (blob) {
|
||||
finish({ blob, durationSeconds: Number.isFinite(durationSeconds) ? durationSeconds : 0 });
|
||||
} else {
|
||||
finish(null);
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
finish(null);
|
||||
}
|
||||
|
||||
@@ -53,7 +53,15 @@
|
||||
</Button>
|
||||
|
||||
{#if videos.length}
|
||||
<Item.Group class="grid! w-full grid-cols-1 gap-x-5 gap-y-8 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<!--
|
||||
`auto-fill` + `minmax(200px, 1fr)` packs as many columns as fit while
|
||||
each card stays at least 200px wide. Unlike `auto-fit`, empty tracks
|
||||
are kept — so a single card on a wide container stays slim instead
|
||||
of stretching across the full row. As the container narrows
|
||||
(e.g. the transcript side panel opens) the grid drops columns
|
||||
naturally instead of squeezing the cards.
|
||||
-->
|
||||
<Item.Group class="grid! w-full grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-x-5 gap-y-8">
|
||||
{#each videos as video, index}
|
||||
<LessonVideoSimpleCard {video} {index} isEditMode={true} onRemove={() => requestRemoveVideo(index)} />
|
||||
{/each}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { orgs } from '$lib/utils/store/org';
|
||||
import { isOrgStudent } from '$lib/utils/store/app';
|
||||
import { isStudentExperience } from '$lib/utils/store/app';
|
||||
import CourseSidebarLogo from './course-sidebar-logo.svelte';
|
||||
import Navigation from './course-sidebar-navigation.svelte';
|
||||
import SidebarSkeleton from '$features/ui/sidebar/sidebar-skeleton.svelte';
|
||||
@@ -112,14 +112,14 @@
|
||||
{#if !isOrgLoaded}
|
||||
<SidebarSkeleton />
|
||||
{:else}
|
||||
<Sidebar.Root collapsible="icon">
|
||||
<Sidebar.Root collapsible="icon" class="ui:z-150!">
|
||||
<Sidebar.Header>
|
||||
<CourseSidebarLogo />
|
||||
</Sidebar.Header>
|
||||
|
||||
<Sidebar.Content>
|
||||
{#if isCourseReady}
|
||||
<Navigation path={currentPath} {id} isStudent={$isOrgStudent ?? undefined} />
|
||||
<Navigation path={currentPath} {id} isStudent={$isStudentExperience} />
|
||||
{:else}
|
||||
<Sidebar.Group class="pt-0!">
|
||||
<div class="flex h-8 items-center gap-2 rounded-md px-2">
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { copyPublicCoursePageUrl, viewAsStudent } from '$features/course/utils/course-preview';
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
courseSlug?: string | null;
|
||||
currentOrgDomain?: string;
|
||||
}
|
||||
|
||||
let { open = $bindable(false), courseSlug = null, currentOrgDomain = '' }: Props = $props();
|
||||
|
||||
let isNavigating = $state(false);
|
||||
|
||||
async function handleCopyLink() {
|
||||
if (!courseSlug) return;
|
||||
|
||||
await copyPublicCoursePageUrl(courseSlug, currentOrgDomain);
|
||||
}
|
||||
|
||||
async function handleGoToLms() {
|
||||
isNavigating = true;
|
||||
|
||||
// Opens the student view in a new tab; close the modal once the handoff starts.
|
||||
const ok = await viewAsStudent({ courseSlug, currentOrgDomain });
|
||||
isNavigating = false;
|
||||
|
||||
if (ok) {
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
bind:open
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen && !isNavigating) open = false;
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="w-[calc(100%-2rem)] max-w-xl! p-4">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('course.view_as_student.title')}</Dialog.Title>
|
||||
<Dialog.Description>{$t('course.view_as_student.description')}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<img
|
||||
src="https://assets.cdn.clsrio.com/cio-lms-plain.png"
|
||||
alt=""
|
||||
class="ui:border mx-auto my-4 w-full rounded-lg"
|
||||
width="2095"
|
||||
height="1744"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="secondary" onclick={handleCopyLink} disabled={!courseSlug || isNavigating}>
|
||||
{$t('course.view_as_student.copy_link')}
|
||||
</Button>
|
||||
<Button variant="default" onclick={handleGoToLms} disabled={!courseSlug} loading={isNavigating}>
|
||||
{$t('course.view_as_student.go_to_lms')}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -3,13 +3,15 @@
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as ButtonGroup from '@cio/ui/base/button-group';
|
||||
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
|
||||
import EyeIcon from '@lucide/svelte/icons/eye';
|
||||
import PencilIcon from '@lucide/svelte/icons/pencil';
|
||||
import { onDestroy, onMount, tick, untrack } from 'svelte';
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import CirclePlusIcon from '@lucide/svelte/icons/circle-plus';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
|
||||
@@ -37,7 +39,6 @@
|
||||
import ExerciseSettingsTab from '$features/course/components/exercise/exercise-settings-tab.svelte';
|
||||
import ViewMode from '$features/course/components/exercise/view-mode.svelte';
|
||||
import Submissions from '$features/course/components/exercise/submissions/submissions.svelte';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import UpdateDescription from '$features/course/components/exercise/update-description.svelte';
|
||||
import { ContentNavigationActions } from '$features/course/components/lesson';
|
||||
import {
|
||||
@@ -373,6 +374,11 @@
|
||||
const sectionId = handleAddSection();
|
||||
await scrollToExerciseElement(`exercise-section-${sectionId}`);
|
||||
}
|
||||
|
||||
function togglePreviewMode() {
|
||||
preview = !preview;
|
||||
reorderQuestions = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Page.Header isSticky={true} class="top-12! z-100! min-h-[36px]">
|
||||
@@ -401,71 +407,87 @@
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
{#if selectedTab === 'questions'}
|
||||
<div class="right-0 flex w-full items-center justify-end">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button class="mr-2" onclick={handleSave} loading={isSaving}>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.save')}
|
||||
</Button>
|
||||
<IconButton
|
||||
onclick={() => {
|
||||
preview = !preview;
|
||||
reorderQuestions = false;
|
||||
}}
|
||||
tooltip={preview
|
||||
? $t('course.navItem.lessons.exercises.all_exercises.view_mode.edit')
|
||||
: $t('course.navItem.lessons.exercises.all_exercises.preview')}
|
||||
tooltipSide="bottom"
|
||||
>
|
||||
{#if preview}
|
||||
<PencilIcon size={20} />
|
||||
{:else}
|
||||
<EyeIcon size={20} />
|
||||
{/if}
|
||||
</IconButton>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Button type="button" variant="ghost" size="icon" class="h-8 w-8">
|
||||
<CirclePlusIcon size={20} />
|
||||
<span class="sr-only">{$t('course.navItem.lessons.exercises.all_exercises.add')}</span>
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="center" class="z-201!">
|
||||
<DropdownMenu.Item onclick={addQuestionFromHeader}>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.add_question')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={addSectionFromHeader}>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.add_section')}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8">
|
||||
<EllipsisVerticalIcon class="h-5 w-5" />
|
||||
<span class="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
{#if hasSections($questionnaire.sections)}
|
||||
<DropdownMenu.Item onclick={() => (reorderQuestions = !reorderQuestions)}>
|
||||
{reorderQuestions
|
||||
? $t('course.navItem.lessons.exercises.all_exercises.reorder_done')
|
||||
: $t('course.navItem.lessons.exercises.all_exercises.reorder')}
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<ButtonGroup.Root>
|
||||
<Button variant="outline" size="sm" onclick={addQuestionFromHeader}>
|
||||
<CirclePlusIcon size={16} />
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.add_question')}
|
||||
</Button>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
{...props}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label={$t('course.navItem.lessons.exercises.all_exercises.add')}
|
||||
>
|
||||
<ChevronDownIcon size={16} />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end" class="z-201!">
|
||||
<DropdownMenu.Item onclick={addSectionFromHeader}>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.add_section')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</ButtonGroup.Root>
|
||||
|
||||
<ButtonGroup.Root>
|
||||
<Button variant="outline" size="sm" onclick={togglePreviewMode}>
|
||||
{#if preview}
|
||||
<PencilIcon size={16} />
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.edit')}
|
||||
{:else}
|
||||
<DropdownMenu.Item onclick={() => ($questionnaireOrder.open = true)}>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.reorder')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<EyeIcon size={16} />
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.preview')}
|
||||
{/if}
|
||||
<DropdownMenu.Item
|
||||
class="text-red-600 focus:text-red-600 dark:text-red-400"
|
||||
onclick={() => (shouldDeleteExercise = true)}
|
||||
>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.delete_exercise')}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Button>
|
||||
</ButtonGroup.Root>
|
||||
|
||||
<ButtonGroup.Root>
|
||||
<Button size="sm" onclick={handleSave} loading={isSaving}>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.save')}
|
||||
</Button>
|
||||
<ButtonGroup.Separator />
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
{...props}
|
||||
type="button"
|
||||
variant="default"
|
||||
size="icon-sm"
|
||||
aria-label={$t('course.navItem.lessons.exercises.all_exercises.more')}
|
||||
>
|
||||
<MoreHorizontalIcon class="h-4 w-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end" class="z-201!">
|
||||
{#if hasSections($questionnaire.sections)}
|
||||
<DropdownMenu.Item onclick={() => (reorderQuestions = !reorderQuestions)}>
|
||||
{reorderQuestions
|
||||
? $t('course.navItem.lessons.exercises.all_exercises.reorder_done')
|
||||
: $t('course.navItem.lessons.exercises.all_exercises.reorder')}
|
||||
</DropdownMenu.Item>
|
||||
{:else}
|
||||
<DropdownMenu.Item onclick={() => ($questionnaireOrder.open = true)}>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.reorder')}
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
class="text-red-600 focus:text-red-600 dark:text-red-400"
|
||||
onclick={() => (shouldDeleteExercise = true)}
|
||||
>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.delete_exercise')}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</ButtonGroup.Root>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -478,7 +500,7 @@
|
||||
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<div class="overflow-x-hidden">
|
||||
<div class="overflow-x-hidden pb-20">
|
||||
{#if $isOrgStudent}
|
||||
{#if isExerciseUnlocked}
|
||||
<ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} {mySubmissions} />
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { fade } from 'svelte/transition';
|
||||
import Save from '@lucide/svelte/icons/save';
|
||||
import Pencil from '@lucide/svelte/icons/pencil';
|
||||
import HistoryIcon from '@lucide/svelte/icons/history';
|
||||
// import HistoryIcon from '@lucide/svelte/icons/history';
|
||||
import VideoIcon from '@lucide/svelte/icons/video';
|
||||
|
||||
import MODES from '$lib/utils/constants/mode';
|
||||
@@ -381,13 +381,15 @@
|
||||
{/if}
|
||||
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
{#if mode === MODES.edit && window.innerWidth >= 1024}
|
||||
<IconButton onclick={() => (isVersionDrawerOpen = true)}>
|
||||
<HistoryIcon size={20} />
|
||||
</IconButton>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-row items-center gap-2 lg:flex">
|
||||
<!--
|
||||
{#if mode === MODES.edit && window.innerWidth >= 1024}
|
||||
<IconButton onclick={() => (isVersionDrawerOpen = true)}>
|
||||
<HistoryIcon size={20} />
|
||||
</IconButton>
|
||||
{/if}
|
||||
-->
|
||||
|
||||
{#if mode === MODES.edit}
|
||||
<span class="ui:text-muted-foreground text-sm" aria-live="polite">
|
||||
{#if lessonApi.isSaving}
|
||||
@@ -416,7 +418,9 @@
|
||||
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<div class={`overflow-x-hidden py-6 ${mode === MODES.edit ? 'lg:w-full xl:w-11/12' : 'mx-auto w-full max-w-3xl'}`}>
|
||||
<div
|
||||
class={`overflow-x-hidden py-6 pb-20 ${mode === MODES.edit ? 'lg:w-full xl:w-11/12' : 'mx-auto w-full max-w-3xl'}`}
|
||||
>
|
||||
{#if $isOrgStudent && lessonApi.lesson && !isLessonUnlocked}
|
||||
<Empty
|
||||
title={$t('course.navItem.lessons.content_locked_title')}
|
||||
|
||||
@@ -367,10 +367,10 @@
|
||||
<Field.Field>
|
||||
<div class="relative w-fit">
|
||||
<img
|
||||
style="min-width:280px; min-height:200px"
|
||||
style="min-width:320px; min-height:200px"
|
||||
alt="Course cover"
|
||||
src={$settings.logo ? $settings.logo : '/images/classroomio-course-img-template.jpg'}
|
||||
class="relative mt-2 h-[200px] w-[280px] rounded-md md:mt-0"
|
||||
class="relative mt-2 h-[200px] w-[320px] rounded-md md:mt-0"
|
||||
/>
|
||||
</div>
|
||||
</Field.Field>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { accountApi } from '$features/account/api/account.svelte';
|
||||
|
||||
interface OpenCoursePreviewOptions {
|
||||
courseId: string;
|
||||
@@ -8,6 +9,11 @@ interface OpenCoursePreviewOptions {
|
||||
currentOrgDomain?: string;
|
||||
}
|
||||
|
||||
interface ViewAsStudentOptions {
|
||||
courseSlug?: string | null;
|
||||
currentOrgDomain?: string;
|
||||
}
|
||||
|
||||
/** Absolute URL for the org-site public course page (`/course/{slug}`). Client-only. */
|
||||
export function getPublicCoursePageUrl(courseSlug: string, currentOrgDomain = ''): string {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -41,6 +47,45 @@ export function openCoursePreview({ courseId, courseSlug, currentOrgDomain = ''
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* "View as student" handoff. Mints a short-lived login-link token for the current
|
||||
* user and navigates to the org domain's `/api/auth/login-link`, which signs them in
|
||||
* there (host-only session) and redirects to the course's student view.
|
||||
*
|
||||
* One flow for cloud and self-hosted: on cloud the tenant-router worker fronts
|
||||
* `/api/auth/*`→API and `/course/*`→dashboard under the tenant host; on self-hosted the
|
||||
* dashboard's own `hooks.server.ts` proxies `/api/auth/*` to the API. Either way the
|
||||
* cookie lands on `currentOrgDomain` and the relative `redirect` resolves there.
|
||||
*/
|
||||
export async function viewAsStudent({ courseSlug, currentOrgDomain = '' }: ViewAsStudentOptions) {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!courseSlug) {
|
||||
snackbar.info('course.header.preview_missing_slug');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const token = await accountApi.createViewAsStudentToken();
|
||||
if (!token) {
|
||||
snackbar.error('snackbar.view_as_student.failed');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const origin = currentOrgDomain?.trim() || window.location.origin;
|
||||
const loginLinkUrl = new URL('/api/auth/login-link', origin);
|
||||
loginLinkUrl.searchParams.set('token', token);
|
||||
loginLinkUrl.searchParams.set('redirect', `/course/${courseSlug}`);
|
||||
|
||||
// Cross-origin handoff — open in a new tab so the teacher keeps their dashboard.
|
||||
window.open(loginLinkUrl.toString(), '_blank', 'noopener,noreferrer');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function copyPublicCoursePageUrl(courseSlug: string, currentOrgDomain = '') {
|
||||
const url = getPublicCoursePageUrl(courseSlug, currentOrgDomain);
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@ import type { CourseContentItem, CourseMembers } from '$features/course/utils/ty
|
||||
import type { Marks } from './types';
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import Papa from 'papaparse';
|
||||
import autoTable from 'jspdf-autotable';
|
||||
import jsPDF from 'jspdf';
|
||||
|
||||
/** Exercise information for display in marks table (linear, no lesson grouping) */
|
||||
export interface ExerciseInfo {
|
||||
@@ -188,13 +186,18 @@ export function generateMarksCSV(
|
||||
* @param studentMarksByExerciseId Student marks by exercise
|
||||
* @param courseTitle Course title for filename and header
|
||||
*/
|
||||
export function generateMarksPDF(
|
||||
export async function generateMarksPDF(
|
||||
students: CourseMembers,
|
||||
exercises: ExerciseInfo[],
|
||||
studentMarksByExerciseId: StudentMarksByExercise,
|
||||
courseTitle: string
|
||||
): void {
|
||||
): Promise<void> {
|
||||
try {
|
||||
const [{ default: jsPDF }, { default: autoTable }] = await Promise.all([
|
||||
import('jspdf'),
|
||||
import('jspdf-autotable')
|
||||
]);
|
||||
|
||||
const doc = new jsPDF({ orientation: 'landscape' });
|
||||
const head = [['Student', ...exercises.map((ex) => ex.title), 'Avg grade']];
|
||||
|
||||
|
||||
@@ -69,7 +69,9 @@ export function toPublicLessonView(lesson: Extract<PublicItem, { kind: 'lesson'
|
||||
title: lesson.title,
|
||||
sectionTitle: lesson.sectionTitle,
|
||||
body: lesson.body ?? '',
|
||||
video: lesson.video,
|
||||
// `lesson.video.hls` may be present on the wire from the public-course
|
||||
// API but isn't in the older inferred type; spread through verbatim.
|
||||
video: lesson.video as PublicLessonViewData['video'],
|
||||
isUnlocked: lesson.isUnlocked
|
||||
};
|
||||
}
|
||||
|
||||
@@ -301,6 +301,8 @@ export type CourseMetadata = (NonNullable<ApiCourse['metadata']> extends object
|
||||
? NonNullable<ApiCourse['metadata']>
|
||||
: Record<string, unknown>) & {
|
||||
isContentGroupingEnabled?: boolean;
|
||||
skills?: string[];
|
||||
tools?: string[];
|
||||
};
|
||||
export type CourseCertificate =
|
||||
NonNullable<ApiCourse['certificate']> extends object
|
||||
|
||||
Reference in New Issue
Block a user