diff --git a/components/app-header/load-srt.tsx b/components/app-header/load-srt.tsx
index ee50fe8..411ff31 100644
--- a/components/app-header/load-srt.tsx
+++ b/components/app-header/load-srt.tsx
@@ -12,6 +12,7 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
+import TranscriptImportDialog from "@/components/transcript-import-dialog";
import {
Dialog,
DialogClose,
@@ -39,6 +40,7 @@ import { getReadableTextColor, getTrackHandleColor } from "@/lib/track-colors";
import {
IconBadgeCc,
IconFile,
+ IconFileText,
IconPencilPlus,
IconPlus,
IconTrash,
@@ -251,6 +253,16 @@ export default function LoadSrt() {
);
})}
+
+
+
+
+
{t("labels.or")}
+
+
+
);
}
diff --git a/components/subtitle/track-tabs.tsx b/components/subtitle/track-tabs.tsx
index e3284a3..580f73c 100644
--- a/components/subtitle/track-tabs.tsx
+++ b/components/subtitle/track-tabs.tsx
@@ -5,11 +5,13 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import TranscriptImportDialog from "@/components/transcript-import-dialog";
import { getTrackColor, getTrackHandleColor } from "@/lib/track-colors";
import type { SubtitleTrack } from "@/types/subtitle";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
import type { Dispatch, RefObject, SetStateAction } from "react";
+import { IconFileText } from "@tabler/icons-react";
interface TrackTabsProps {
tracks: SubtitleTrack[];
@@ -76,6 +78,16 @@ export default function TrackTabs({
>
{t("labels.startFromScratch")}
+ {t("labels.or")}
+
+
+
);
}
diff --git a/components/transcript-import-dialog.tsx b/components/transcript-import-dialog.tsx
new file mode 100644
index 0000000..e911326
--- /dev/null
+++ b/components/transcript-import-dialog.tsx
@@ -0,0 +1,375 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ useSubtitleActionsContext,
+ useSubtitleState,
+} from "@/context/subtitle-context";
+import {
+ segmentTranscriptToSubtitles,
+ TRANSCRIPT_SEGMENTATION_DEFAULTS,
+ type TranscriptSegmentationMode,
+ type TranscriptSegmentationOptions,
+} from "@/lib/transcript-segmentation";
+import { IconFileText, IconUpload } from "@tabler/icons-react";
+import { useTranslations } from "next-intl";
+import {
+ useId,
+ useMemo,
+ useState,
+ type ChangeEvent,
+ type ReactNode,
+} from "react";
+
+interface TranscriptImportDialogProps {
+ children: ReactNode;
+}
+
+const TRACK_LIMIT = 4;
+
+const parseNumberInput = (value: string, fallback: number): number => {
+ const parsed = Number.parseFloat(value);
+ return Number.isFinite(parsed) ? parsed : fallback;
+};
+
+const getTxtTrackName = (fileName: string, fallback: string): string => {
+ const trimmed = fileName.trim();
+ if (!trimmed) return fallback;
+ return trimmed.replace(/\.txt$/i, "") || fallback;
+};
+
+export default function TranscriptImportDialog({
+ children,
+}: TranscriptImportDialogProps) {
+ const t = useTranslations();
+ const [open, setOpen] = useState(false);
+ const [transcriptText, setTranscriptText] = useState("");
+ const [trackName, setTrackName] = useState("");
+ const [mode, setMode] = useState(
+ TRANSCRIPT_SEGMENTATION_DEFAULTS.mode,
+ );
+ const [startTimeSeconds, setStartTimeSeconds] = useState(
+ String(TRANSCRIPT_SEGMENTATION_DEFAULTS.startTimeSeconds),
+ );
+ const [cueDurationSeconds, setCueDurationSeconds] = useState(
+ String(TRANSCRIPT_SEGMENTATION_DEFAULTS.cueDurationSeconds),
+ );
+ const [gapSeconds, setGapSeconds] = useState(
+ String(TRANSCRIPT_SEGMENTATION_DEFAULTS.gapSeconds),
+ );
+ const [maxCharactersPerCue, setMaxCharactersPerCue] = useState(
+ String(TRANSCRIPT_SEGMENTATION_DEFAULTS.maxCharactersPerCue),
+ );
+ const fileInputId = useId();
+ const { loadSubtitlesIntoTrack, renameTrack, setInitialSubtitles } =
+ useSubtitleActionsContext();
+ const { tracks, activeTrackId } = useSubtitleState();
+
+ const defaultTrackName = t("transcriptImport.defaultTrackName");
+ const effectiveTrackName = trackName.trim() || defaultTrackName;
+ const activeTrack = activeTrackId
+ ? tracks.find((track) => track.id === activeTrackId)
+ : null;
+ const activeTrackIsEmpty = Boolean(
+ activeTrack && activeTrack.subtitles.length === 0,
+ );
+ const cannotCreateTrack = tracks.length >= TRACK_LIMIT && !activeTrackIsEmpty;
+ const destinationText =
+ activeTrack && activeTrackIsEmpty
+ ? t("transcriptImport.destinationReplace", { track: activeTrack.name })
+ : t("transcriptImport.destinationNewTrack");
+
+ const segmentationOptions = useMemo(
+ () => ({
+ mode,
+ startTimeSeconds: parseNumberInput(
+ startTimeSeconds,
+ TRANSCRIPT_SEGMENTATION_DEFAULTS.startTimeSeconds,
+ ),
+ cueDurationSeconds: parseNumberInput(
+ cueDurationSeconds,
+ TRANSCRIPT_SEGMENTATION_DEFAULTS.cueDurationSeconds,
+ ),
+ gapSeconds: parseNumberInput(
+ gapSeconds,
+ TRANSCRIPT_SEGMENTATION_DEFAULTS.gapSeconds,
+ ),
+ maxCharactersPerCue: parseNumberInput(
+ maxCharactersPerCue,
+ TRANSCRIPT_SEGMENTATION_DEFAULTS.maxCharactersPerCue,
+ ),
+ }),
+ [
+ mode,
+ startTimeSeconds,
+ cueDurationSeconds,
+ gapSeconds,
+ maxCharactersPerCue,
+ ],
+ );
+
+ const previewSubtitles = useMemo(
+ () => segmentTranscriptToSubtitles(transcriptText, segmentationOptions),
+ [transcriptText, segmentationOptions],
+ );
+
+ const importDisabled = cannotCreateTrack || previewSubtitles.length === 0;
+
+ const handleFileSelect = async (event: ChangeEvent) => {
+ const file = event.target.files?.[0];
+ if (!file) return;
+
+ const text = await file.text();
+ setTranscriptText(text);
+ setTrackName(getTxtTrackName(file.name, defaultTrackName));
+ event.currentTarget.value = "";
+ };
+
+ const handleImport = () => {
+ if (importDisabled) return;
+
+ if (activeTrackId && activeTrackIsEmpty) {
+ loadSubtitlesIntoTrack(activeTrackId, previewSubtitles);
+ renameTrack(activeTrackId, effectiveTrackName);
+ } else {
+ setInitialSubtitles(previewSubtitles, effectiveTrackName);
+ }
+
+ setOpen(false);
+ };
+
+ return (
+
+ );
+}
diff --git a/lib/transcript-segmentation.ts b/lib/transcript-segmentation.ts
new file mode 100644
index 0000000..1efab4e
--- /dev/null
+++ b/lib/transcript-segmentation.ts
@@ -0,0 +1,136 @@
+import type { Subtitle } from "@/types/subtitle";
+import { secondsToTime } from "@/lib/utils";
+import { v4 as uuidv4 } from "uuid";
+
+export type TranscriptSegmentationMode =
+ | "lines"
+ | "sentences"
+ | "maxCharacters";
+
+export interface TranscriptSegmentationOptions {
+ mode: TranscriptSegmentationMode;
+ startTimeSeconds: number;
+ cueDurationSeconds: number;
+ gapSeconds: number;
+ maxCharactersPerCue?: number;
+}
+
+export const TRANSCRIPT_SEGMENTATION_DEFAULTS = {
+ mode: "lines" as const,
+ startTimeSeconds: 0,
+ cueDurationSeconds: 3,
+ gapSeconds: 0.2,
+ maxCharactersPerCue: 80,
+};
+
+const normalizeInlineWhitespace = (text: string): string =>
+ text.replace(/\s+/g, " ").trim();
+
+const getPositiveNumber = (value: number, fallback: number): number =>
+ Number.isFinite(value) && value > 0 ? value : fallback;
+
+const getNonNegativeNumber = (value: number, fallback: number): number =>
+ Number.isFinite(value) && value >= 0 ? value : fallback;
+
+const splitByLines = (text: string): string[] =>
+ text
+ .replace(/\r\n?/g, "\n")
+ .split("\n")
+ .map((line) => line.trim())
+ .filter(Boolean);
+
+const splitBySentences = (text: string): string[] => {
+ const normalized = normalizeInlineWhitespace(text);
+ if (!normalized) return [];
+ return (
+ normalized
+ .match(/[^.!?]+(?:[.!?]+|$)/g)
+ ?.map((sentence) => sentence.trim())
+ .filter(Boolean) ?? []
+ );
+};
+
+const splitByMaxCharacters = (
+ text: string,
+ maxCharactersPerCue: number,
+): string[] => {
+ const normalized = normalizeInlineWhitespace(text);
+ if (!normalized) return [];
+
+ const chunks: string[] = [];
+ let current = "";
+
+ for (const word of normalized.split(" ")) {
+ if (!current) {
+ current = word;
+ continue;
+ }
+
+ const candidate = `${current} ${word}`;
+ if (candidate.length <= maxCharactersPerCue) {
+ current = candidate;
+ continue;
+ }
+
+ chunks.push(current);
+ current = word;
+ }
+
+ if (current) {
+ chunks.push(current);
+ }
+
+ return chunks;
+};
+
+const splitTranscript = (
+ text: string,
+ options: TranscriptSegmentationOptions,
+): string[] => {
+ if (options.mode === "sentences") {
+ return splitBySentences(text);
+ }
+
+ if (options.mode === "maxCharacters") {
+ const maxCharactersPerCue = Math.floor(
+ getPositiveNumber(
+ options.maxCharactersPerCue ??
+ TRANSCRIPT_SEGMENTATION_DEFAULTS.maxCharactersPerCue,
+ TRANSCRIPT_SEGMENTATION_DEFAULTS.maxCharactersPerCue,
+ ),
+ );
+ return splitByMaxCharacters(text, maxCharactersPerCue);
+ }
+
+ return splitByLines(text);
+};
+
+export const segmentTranscriptToSubtitles = (
+ text: string,
+ options: TranscriptSegmentationOptions,
+): Subtitle[] => {
+ const cueDurationSeconds = getPositiveNumber(
+ options.cueDurationSeconds,
+ TRANSCRIPT_SEGMENTATION_DEFAULTS.cueDurationSeconds,
+ );
+ const gapSeconds = getNonNegativeNumber(
+ options.gapSeconds,
+ TRANSCRIPT_SEGMENTATION_DEFAULTS.gapSeconds,
+ );
+ const startTimeSeconds = getNonNegativeNumber(
+ options.startTimeSeconds,
+ TRANSCRIPT_SEGMENTATION_DEFAULTS.startTimeSeconds,
+ );
+
+ return splitTranscript(text, options).map((cueText, index) => {
+ const startSeconds =
+ startTimeSeconds + index * (cueDurationSeconds + gapSeconds);
+ return {
+ uuid: uuidv4(),
+ id: index + 1,
+ startTime: secondsToTime(startSeconds),
+ endTime: secondsToTime(startSeconds + cueDurationSeconds),
+ text: cueText,
+ };
+ });
+};
diff --git a/messages/de.json b/messages/de.json
index 816c7ed..4381893 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -24,7 +24,8 @@
"downloadAsSrt": ".srt",
"downloadAsVtt": ".vtt",
"downloadAsTxt": ".txt",
- "downloadAsCsv": ".csv"
+ "downloadAsCsv": ".csv",
+ "importTranscript": "Transkript importieren"
},
"controls": {
"play": "Abspielen",
@@ -36,7 +37,8 @@
"labels": {
"loadSrtFile": "Eine .srt/.vtt-Datei laden",
"or": "oder",
- "startFromScratch": "Von vorne beginnen"
+ "startFromScratch": "Von vorne beginnen",
+ "importTranscript": "Transkript importieren"
},
"videoPlayer": {
"loadFile": "Video- / Audiodatei laden",
@@ -100,6 +102,38 @@
"increaseOneSecond": "Versatz um 1 Sekunde erhöhen"
}
},
+ "transcriptImport": {
+ "title": "Transkript importieren",
+ "description": "Fügen Sie Transkripttext ein oder laden Sie eine .txt-Datei, und prüfen Sie die erzeugten Untertitel vor dem Import.",
+ "defaultTrackName": "Transkript",
+ "fileLabel": "Textdatei",
+ "chooseFile": ".txt-Datei wählen",
+ "fileHint": "Transkriptdateien bleiben in Ihrem Browser.",
+ "pasteLabel": "Transkripttext",
+ "pastePlaceholder": "Transkripttext hier einfügen...",
+ "trackNameLabel": "Spurname",
+ "modeLabel": "Aufteilen nach",
+ "modeLines": "Zeilen",
+ "modeSentences": "Sätzen",
+ "modeMaxCharacters": "Max. Zeichen",
+ "durationLabel": "Untertiteldauer",
+ "gapLabel": "Abstand",
+ "startOffsetLabel": "Startversatz",
+ "maxCharactersLabel": "Max. Zeichen",
+ "destinationReplace": "Der Import ersetzt die leere Spur „{track}“.",
+ "destinationNewTrack": "Der Import erstellt eine neue Untertitelspur.",
+ "trackLimitReached": "Sie haben bereits 4 Spuren. Löschen Sie eine Spur oder wechseln Sie vor dem Import zu einer leeren Spur.",
+ "previewTitle": "Vorschau",
+ "cueCount": "{count, plural, =0 {Keine Untertitel} =1 {# Untertitel} other {# Untertitel}}",
+ "emptyPreview": "Fügen Sie Text ein oder wählen Sie eine .txt-Datei, um erzeugte Untertitel zu sehen.",
+ "importButton": "Untertitel importieren",
+ "table": {
+ "id": "ID",
+ "start": "Start",
+ "end": "Ende",
+ "text": "Text"
+ }
+ },
"instructions": {
"afterLoading": "Nach dem Laden der Untertitel und der Mediendatei:",
"editText": "Klicken Sie auf den Untertiteltext oder die Zeitstempel, um Ihre Untertitel zu bearbeiten.",
diff --git a/messages/en.json b/messages/en.json
index 95f907d..1e2bd5f 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -24,7 +24,8 @@
"downloadAsSrt": ".srt",
"downloadAsVtt": ".vtt",
"downloadAsTxt": ".txt",
- "downloadAsCsv": ".csv"
+ "downloadAsCsv": ".csv",
+ "importTranscript": "Import transcript"
},
"controls": {
"play": "Play",
@@ -36,7 +37,8 @@
"labels": {
"loadSrtFile": "Load a .srt/.vtt file",
"or": "or",
- "startFromScratch": "Start from scratch"
+ "startFromScratch": "Start from scratch",
+ "importTranscript": "Import transcript"
},
"videoPlayer": {
"loadFile": "Load a video / audio file",
@@ -100,6 +102,38 @@
"increaseOneSecond": "Increase offset by 1 second"
}
},
+ "transcriptImport": {
+ "title": "Import transcript",
+ "description": "Paste transcript text or load a .txt file, then preview draft subtitle cues before importing.",
+ "defaultTrackName": "Transcript",
+ "fileLabel": "Text file",
+ "chooseFile": "Choose .txt file",
+ "fileHint": "Transcript files stay in your browser.",
+ "pasteLabel": "Transcript text",
+ "pastePlaceholder": "Paste transcript text here...",
+ "trackNameLabel": "Track name",
+ "modeLabel": "Split by",
+ "modeLines": "Lines",
+ "modeSentences": "Sentences",
+ "modeMaxCharacters": "Max characters",
+ "durationLabel": "Cue duration",
+ "gapLabel": "Gap",
+ "startOffsetLabel": "Start offset",
+ "maxCharactersLabel": "Max characters",
+ "destinationReplace": "Import will replace the empty track “{track}”.",
+ "destinationNewTrack": "Import will create a new subtitle track.",
+ "trackLimitReached": "You already have 4 tracks. Delete a track or switch to an empty track before importing.",
+ "previewTitle": "Preview",
+ "cueCount": "{count, plural, =0 {No cues} =1 {# cue} other {# cues}}",
+ "emptyPreview": "Paste text or choose a .txt file to preview generated cues.",
+ "importButton": "Import cues",
+ "table": {
+ "id": "ID",
+ "start": "Start",
+ "end": "End",
+ "text": "Text"
+ }
+ },
"instructions": {
"afterLoading": "After loading the subtitles and media file:",
"editText": "Click the subtitle text or timestamps to edit your captions.",
diff --git a/messages/yue.json b/messages/yue.json
index bcccf60..9f54364 100644
--- a/messages/yue.json
+++ b/messages/yue.json
@@ -24,7 +24,8 @@
"downloadAsSrt": ".srt",
"downloadAsVtt": ".vtt",
"downloadAsTxt": ".txt",
- "downloadAsCsv": ".csv"
+ "downloadAsCsv": ".csv",
+ "importTranscript": "匯入逐字稿"
},
"controls": {
"play": "播放",
@@ -36,7 +37,8 @@
"labels": {
"loadSrtFile": "載入 .srt/.vtt 檔案",
"or": "或",
- "startFromScratch": "由頭開始"
+ "startFromScratch": "由頭開始",
+ "importTranscript": "匯入逐字稿"
},
"videoPlayer": {
"loadFile": "載入影片 / 音訊檔案",
@@ -100,6 +102,38 @@
"increaseOneSecond": "遲 1 秒"
}
},
+ "transcriptImport": {
+ "title": "匯入逐字稿",
+ "description": "貼上逐字稿文字,或者載入 .txt 檔案,匯入前可以預覽產生嘅字幕。",
+ "defaultTrackName": "逐字稿",
+ "fileLabel": "文字檔案",
+ "chooseFile": "揀 .txt 檔案",
+ "fileHint": "逐字稿檔案只會留喺你嘅瀏覽器。",
+ "pasteLabel": "逐字稿文字",
+ "pastePlaceholder": "喺度貼上逐字稿文字...",
+ "trackNameLabel": "字幕軌名",
+ "modeLabel": "分段方式",
+ "modeLines": "按行",
+ "modeSentences": "按句",
+ "modeMaxCharacters": "按最多字數",
+ "durationLabel": "字幕時長",
+ "gapLabel": "間隔",
+ "startOffsetLabel": "起始偏移",
+ "maxCharactersLabel": "最多字數",
+ "destinationReplace": "匯入會取代空白字幕軌「{track}」。",
+ "destinationNewTrack": "匯入會建立一條新字幕軌。",
+ "trackLimitReached": "你已經有 4 條字幕軌。匯入前請先刪除一條,或者切換到空白字幕軌。",
+ "previewTitle": "預覽",
+ "cueCount": "{count} 條字幕",
+ "emptyPreview": "貼上文字或者揀 .txt 檔案,就可以預覽產生嘅字幕。",
+ "importButton": "匯入字幕",
+ "table": {
+ "id": "ID",
+ "start": "起點",
+ "end": "終點",
+ "text": "文字"
+ }
+ },
"instructions": {
"afterLoading": "載入字幕同媒體檔案後:",
"editText": "撳字幕文字或時間點嚟編輯字幕。",
diff --git a/tests/transcript-segmentation.test.ts b/tests/transcript-segmentation.test.ts
new file mode 100644
index 0000000..621f98b
--- /dev/null
+++ b/tests/transcript-segmentation.test.ts
@@ -0,0 +1,95 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import {
+ segmentTranscriptToSubtitles,
+ type TranscriptSegmentationOptions,
+} from "../lib/transcript-segmentation";
+
+const baseOptions: TranscriptSegmentationOptions = {
+ mode: "lines",
+ startTimeSeconds: 0,
+ cueDurationSeconds: 3,
+ gapSeconds: 0.2,
+};
+
+test("line mode ignores blank lines and creates default sequential timings", () => {
+ const subtitles = segmentTranscriptToSubtitles(
+ " First line \n\nSecond line\r\n \nThird line ",
+ baseOptions,
+ );
+
+ assert.deepEqual(
+ subtitles.map((subtitle) => subtitle.text),
+ ["First line", "Second line", "Third line"],
+ );
+ assert.deepEqual(
+ subtitles.map((subtitle) => [
+ subtitle.id,
+ subtitle.startTime,
+ subtitle.endTime,
+ ]),
+ [
+ [1, "00:00:00,000", "00:00:03,000"],
+ [2, "00:00:03,200", "00:00:06,200"],
+ [3, "00:00:06,400", "00:00:09,400"],
+ ],
+ );
+ assert.ok(subtitles.every((subtitle) => subtitle.uuid.length > 0));
+});
+
+test("sentence mode splits pasted paragraphs and normalizes whitespace", () => {
+ const subtitles = segmentTranscriptToSubtitles(
+ "Hello world. How are you?\n\nFine!",
+ { ...baseOptions, mode: "sentences" },
+ );
+
+ assert.deepEqual(
+ subtitles.map((subtitle) => subtitle.text),
+ ["Hello world.", "How are you?", "Fine!"],
+ );
+});
+
+test("max character mode chunks at word boundaries", () => {
+ const subtitles = segmentTranscriptToSubtitles(
+ "One two three four five six seven",
+ { ...baseOptions, mode: "maxCharacters", maxCharactersPerCue: 13 },
+ );
+
+ assert.deepEqual(
+ subtitles.map((subtitle) => subtitle.text),
+ ["One two three", "four five six", "seven"],
+ );
+});
+
+test("max character mode keeps a single long word intact", () => {
+ const subtitles = segmentTranscriptToSubtitles(
+ "supercalifragilistic short words",
+ { ...baseOptions, mode: "maxCharacters", maxCharactersPerCue: 8 },
+ );
+
+ assert.deepEqual(
+ subtitles.map((subtitle) => subtitle.text),
+ ["supercalifragilistic", "short", "words"],
+ );
+});
+
+test("timing options control start offset, duration, and gap", () => {
+ const subtitles = segmentTranscriptToSubtitles("A\nB", {
+ ...baseOptions,
+ startTimeSeconds: 10,
+ cueDurationSeconds: 2.5,
+ gapSeconds: 0.5,
+ });
+
+ assert.deepEqual(
+ subtitles.map((subtitle) => [subtitle.startTime, subtitle.endTime]),
+ [
+ ["00:00:10,000", "00:00:12,500"],
+ ["00:00:13,000", "00:00:15,500"],
+ ],
+ );
+});
+
+test("empty input returns no cues", () => {
+ assert.deepEqual(segmentTranscriptToSubtitles(" \n\t ", baseOptions), []);
+});