presentercoach / apps /web /src /lib /session.ts
nathycodes's picture
Deploy Tech Presenter Coach
ec17af3
Raw History Blame Contribute Delete
6.97 kB
import type { MetricsPoint, TranscriptSegment } from "@video/shared";
import { apiFetch } from "@/lib/api";
import type { DocumentBlock } from "@/lib/documentReader";
export type SessionPayload = {
title?: string;
mode: string;
prompt: string;
goal: string;
curriculumDay?: number;
exerciseId?: string;
storyFramework?: string;
targetKeywords?: string[];
teleprompterScript?: string;
durationMs: number;
wpmAvg: number;
fillerCount: number;
eyeContactPct: number;
transcript: TranscriptSegment[];
metrics: MetricsPoint[];
tags?: string[];
score?: Record<string, unknown>;
};
export type TranscriptionSelection = {
backend?: string;
model?: string;
};
export type TranscriptionJobResponse = {
status: string;
job_id?: string;
};
export type SessionDocumentPayload = {
file: Blob;
name: string;
mimeType: string;
blocks: DocumentBlock[];
};
export const mergeChunks = (chunks: Blob[], mimeType?: string) =>
new Blob(chunks, { type: mimeType || chunks[0]?.type || "video/webm" });
export const getVideoDurationMs = (file: Blob) =>
new Promise<number>((resolve) => {
if (typeof document === "undefined") {
resolve(0);
return;
}
const media = document.createElement(file.type.startsWith("audio/") ? "audio" : "video");
const url = URL.createObjectURL(file);
media.preload = "metadata";
media.onloadedmetadata = () => {
const duration = Number.isFinite(media.duration) ? Math.round(media.duration * 1000) : 0;
URL.revokeObjectURL(url);
resolve(duration);
};
media.onerror = () => {
URL.revokeObjectURL(url);
resolve(0);
};
media.src = url;
});
export const createSession = (payload: SessionPayload) =>
apiFetch<{ id: number }>("/sessions", {
method: "POST",
body: JSON.stringify({
title: payload.title,
mode: payload.mode,
prompt: payload.prompt,
goal: payload.goal,
curriculum_day: payload.curriculumDay,
exercise_id: payload.exerciseId,
story_framework: payload.storyFramework,
target_keywords: payload.targetKeywords || [],
teleprompter_script: payload.teleprompterScript,
duration_ms: payload.durationMs,
wpm_avg: payload.wpmAvg,
filler_count: payload.fillerCount,
eye_contact_pct: payload.eyeContactPct,
transcript_segments: payload.transcript.map((seg) => ({
start_ms: seg.startMs,
end_ms: seg.endMs,
text: seg.text,
})),
metrics: payload.metrics.map((point) => ({
t: point.t,
wpm: point.wpm,
rms: point.rms,
eye_contact: point.eyeContact,
smile: point.smile,
yaw: point.yaw,
pitch: point.pitch,
roll: point.roll,
filler_count: point.fillerCount,
pause_ms: point.pauseMs,
})),
tags: payload.tags || [],
score: payload.score,
}),
});
export const updateSessionTranscript = (sessionId: number, transcript: TranscriptSegment[]) =>
apiFetch(`/sessions/${sessionId}/transcript`, {
method: "PATCH",
body: JSON.stringify({
transcript_segments: transcript.map((seg) => ({
start_ms: seg.startMs,
end_ms: seg.endMs,
text: seg.text,
})),
}),
});
const resolveRecordingFormat = (mimeType?: string) => {
const normalized = (mimeType || "").split(";")[0].trim().toLowerCase();
if (normalized === "audio/mp4" || normalized === "audio/x-m4a") {
return { extension: "m4a", mimeType: "audio/mp4" };
}
if (normalized === "audio/wav" || normalized === "audio/x-wav") {
return { extension: "wav", mimeType: "audio/wav" };
}
if (normalized === "audio/webm") {
return { extension: "webm", mimeType: "audio/webm" };
}
if (normalized.includes("mp4")) {
return { extension: "mp4", mimeType: "video/mp4" };
}
if (normalized.includes("quicktime") || normalized.endsWith(".mov")) {
return { extension: "mov", mimeType: "video/quicktime" };
}
if (normalized.includes("webm")) {
return { extension: "webm", mimeType: "video/webm" };
}
return { extension: "webm", mimeType: normalized || "video/webm" };
};
const buildUploadQuery = (params: Record<string, string | number>) =>
new URLSearchParams(
Object.entries(params).reduce<Record<string, string>>((acc, [key, value]) => {
acc[key] = String(value);
return acc;
}, {})
).toString();
export const uploadChunks = async (sessionId: number, chunks: Blob[], mimeType?: string) => {
const format = resolveRecordingFormat(mimeType || chunks[0]?.type);
const uploadId = `${sessionId}-${Date.now()}`;
for (let i = 0; i < chunks.length; i += 1) {
const formData = new FormData();
formData.append("file", chunks[i], `chunk-${i}.${format.extension}`);
await apiFetch(
`/sessions/${sessionId}/upload?${buildUploadQuery({
chunk_index: i,
total_chunks: chunks.length,
upload_id: uploadId,
mime_type: format.mimeType,
})}`,
{
method: "POST",
body: formData,
}
);
}
};
export const uploadVideoFile = async (sessionId: number, file: File, chunkSize = 2 * 1024 * 1024) => {
const format = resolveRecordingFormat(file.type || file.name);
if (file.size <= chunkSize) {
const formData = new FormData();
formData.append("file", file, file.name || `upload.${format.extension}`);
await apiFetch(`/sessions/${sessionId}/upload`, {
method: "POST",
body: formData,
});
return;
}
const uploadId = `${sessionId}-${Date.now()}`;
const totalChunks = Math.ceil(file.size / chunkSize);
for (let index = 0; index < totalChunks; index += 1) {
const formData = new FormData();
const start = index * chunkSize;
const end = Math.min(file.size, start + chunkSize);
formData.append("file", file.slice(start, end, file.type), `chunk-${index}.${format.extension}`);
await apiFetch(
`/sessions/${sessionId}/upload?${buildUploadQuery({
chunk_index: index,
total_chunks: totalChunks,
upload_id: uploadId,
mime_type: format.mimeType,
})}`,
{
method: "POST",
body: formData,
}
);
}
};
export const triggerTranscription = (sessionId: number, selection?: TranscriptionSelection) =>
apiFetch<TranscriptionJobResponse>(`/sessions/${sessionId}/transcribe`, {
method: "POST",
body: selection ? JSON.stringify(selection) : undefined,
});
export const uploadSessionDocument = async (sessionId: number, document: SessionDocumentPayload) =>
{
const formData = new FormData();
formData.append("file", document.file, document.name);
formData.append("blocks_json", JSON.stringify(document.blocks));
return apiFetch(`/sessions/${sessionId}/document`, {
method: "POST",
body: formData,
});
};
export const deleteSession = (sessionId: number) =>
apiFetch(`/sessions/${sessionId}`, {
method: "DELETE",
});