feat(import): optional transcription job
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
0e090a0e15
commit
7c3f613feb
@@ -0,0 +1,82 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { JobContext } from "@stc/contracts";
|
||||
import { finish, managedCourse, saveManifest, stageDir, transcriptCacheDir, type Deps } from "../context";
|
||||
import { run } from "../util/exec";
|
||||
import { optStr, withStage } from "./common";
|
||||
|
||||
export interface TranscribeResult { courseId: string; skipped?: boolean; reason?: string; transcribed: number; warnings: string[] }
|
||||
interface Segment { start: number; end: number; text: string }
|
||||
|
||||
const stamp = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
|
||||
|
||||
/** Groups timed segments into paragraphs of roughly 30 seconds, each starting with a [m:ss] marker. */
|
||||
export function segmentsToMarkdown(title: string, segments: Segment[], fallbackText: string): string {
|
||||
if (segments.length === 0) return `# ${title}\n\n${fallbackText.trim()}\n`;
|
||||
const paras: string[] = [];
|
||||
let cur: string[] = [];
|
||||
let startedAt = segments[0]!.start;
|
||||
for (const s of segments) {
|
||||
if (cur.length && s.start - startedAt >= 30) {
|
||||
paras.push(`[${stamp(startedAt)}] ${cur.join(" ")}`);
|
||||
cur = []; startedAt = s.start;
|
||||
}
|
||||
cur.push(s.text.trim());
|
||||
}
|
||||
if (cur.length) paras.push(`[${stamp(startedAt)}] ${cur.join(" ")}`);
|
||||
return `# ${title}\n\n${paras.join("\n\n")}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional job: sends each video lesson's audio to a Whisper-compatible endpoint (STC_TRANSCRIBE_URL, OpenAI
|
||||
* /v1/audio/transcriptions shape) and attaches the result as the lesson's transcript. The model never runs in this process.
|
||||
*/
|
||||
export async function transcribeCourse(d: Deps, payload: unknown, jc: JobContext): Promise<TranscribeResult> {
|
||||
const p = (payload ?? {}) as Record<string, unknown>;
|
||||
const url = d.env.STC_TRANSCRIBE_URL;
|
||||
const courseId = optStr(p, "courseId") ?? "";
|
||||
if (!url) return { courseId, skipped: true, reason: "STC_TRANSCRIBE_URL is not set", transcribed: 0, warnings: [] };
|
||||
const course = await managedCourse(d, courseId);
|
||||
const only = Array.isArray(p.lessonIds) ? new Set(p.lessonIds.map(String)) : null;
|
||||
const lessons = (course.manifest.modules as any[]).flatMap((m) => m.lessons as any[]).filter((l) => l.type === "video" && !l.transcript && (!only || only.has(l.id)));
|
||||
const warnings: string[] = [];
|
||||
const stage = await stageDir(d, "transcribe");
|
||||
let done = 0;
|
||||
return withStage(stage, async () => {
|
||||
try {
|
||||
for (const [i, l] of lessons.entries()) {
|
||||
if (jc.signal.aborted) break;
|
||||
await jc.reportProgress(i / Math.max(1, lessons.length), `Transcribing ${l.title}`);
|
||||
try {
|
||||
const audio = join(stage, `${l.id}.ogg`);
|
||||
await run([d.tools.ffmpeg, "-v", "error", "-y", "-i", join(course.dir, l.file), "-vn", "-ac", "1", "-ar", "16000", "-c:a", "libopus", "-b:a", "24k", audio], { signal: jc.signal });
|
||||
const form = new FormData();
|
||||
form.set("file", Bun.file(audio), `${l.id}.ogg`);
|
||||
form.set("model", d.env.STC_TRANSCRIBE_MODEL || "whisper-1");
|
||||
form.set("response_format", "verbose_json");
|
||||
const res = await fetch(url, {
|
||||
method: "POST", body: form, signal: jc.signal,
|
||||
headers: d.env.STC_TRANSCRIBE_KEY ? { Authorization: `Bearer ${d.env.STC_TRANSCRIBE_KEY}` } : {},
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} ${(await res.text()).slice(0, 200)}`);
|
||||
const body = (await res.json()) as { text?: string; segments?: Segment[] };
|
||||
const segments = (body.segments ?? []).map((s) => ({ start: Number(s.start) || 0, end: Number(s.end) || 0, text: String(s.text ?? "") }));
|
||||
const rel = `transcripts/${l.id}.md`;
|
||||
await mkdir(join(course.dir, "transcripts"), { recursive: true });
|
||||
await writeFile(join(course.dir, rel), segmentsToMarkdown(l.title, segments, body.text ?? ""));
|
||||
await mkdir(transcriptCacheDir(d, course.id), { recursive: true });
|
||||
await writeFile(join(transcriptCacheDir(d, course.id), `${l.id}.json`), JSON.stringify({ lessonId: l.id, text: body.text ?? "", segments }));
|
||||
l.transcript = rel;
|
||||
done++;
|
||||
} catch (e) {
|
||||
warnings.push(`${l.title}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (done > 0) await saveManifest(course.dir, course.manifest); // keep what finished even if the job was cancelled
|
||||
}
|
||||
if (done > 0) await finish(d, course.id, warnings);
|
||||
await jc.reportProgress(1, `Transcribed ${done} of ${lessons.length} lessons`);
|
||||
return { courseId: course.id, transcribed: done, warnings };
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user