Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 39 additions & 43 deletions src/components/ai-edition/Modals.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,51 +17,28 @@ import {
type ReactNode,
type PointerEvent as ReactPointerEvent,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { toFileUrl } from "@/components/video-editor/projectPersistence";
import type { CropRegion } from "@/components/video-editor/types";
import { useScopedT } from "@/contexts/I18nContext";
import { useI18n, useScopedT } from "@/contexts/I18nContext";
import { toAxcutTranscriptDsl } from "@/lib/ai-edition/document/transcribe";
import type { AxcutClip, AxcutTranscript } from "@/lib/ai-edition/schema";
import {
type AxcutClip,
type AxcutTranscript,
type TranscriptLanguageCode,
transcriptLanguageSchema,
} from "@/lib/ai-edition/schema";
import { formatSec, formatSeconds } from "@/lib/ai-edition/timeline/format";
import {
languageLabel,
sortedLanguageOptions,
} from "@/lib/ai-edition/transcription/languageLabels";
import styles from "./NewEditorShell.module.css";
import type { VideoSource } from "./VirtualPreview";

// ponytail: keep the UI's language list literal in one place. Mirrors
// `transcriptLanguageSchema` in schema/index.ts; if the schema gains a
// language, add it here too.
const REGEN_LANGUAGES = [
"auto",
"en",
"fr",
"de",
"es",
"it",
"pt",
"nl",
"ja",
"ko",
"zh",
] as const;

type TranscriptLanguage = (typeof REGEN_LANGUAGES)[number];

const LANGUAGE_LABELS: Record<TranscriptLanguage, string> = {
auto: "Auto",
en: "EN",
fr: "FR",
de: "DE",
es: "ES",
it: "IT",
pt: "PT",
nl: "NL",
ja: "JA",
ko: "KO",
zh: "ZH",
};

interface BaseModalProps {
open: boolean;
onClose: () => void;
Expand Down Expand Up @@ -1539,6 +1516,17 @@ export function InsertSourceModal({
);
}

/**
* `AxcutTranscript.language` is `z.string().min(1)`, not validated against
* the known code list, so a stored transcript can hold a value no
* `<option>` matches — falls back to "auto" rather than letting the select
* go visibly out of sync with the code a regenerate would actually submit.
*/
function supportedTranscriptLanguage(language: string | undefined): TranscriptLanguageCode {
const parsed = transcriptLanguageSchema.safeParse(language);
return parsed.success ? parsed.data : "auto";
}

export interface SourceTranscriptModalProps extends BaseModalProps {
assetLabel: string;
assetPath: string;
Expand All @@ -1548,7 +1536,7 @@ export interface SourceTranscriptModalProps extends BaseModalProps {
isFailed: boolean;
/** Why the last run produced nothing — "no audio track", or the engine's own message. */
failureMessage?: string;
onRegenerate: (language: TranscriptLanguage) => void;
onRegenerate: (language: TranscriptLanguageCode) => void;
}

export function SourceTranscriptModal({
Expand All @@ -1569,17 +1557,23 @@ export function SourceTranscriptModal({
const [isPlaying, setIsPlaying] = useState(false);
const [playTime, setPlayTime] = useState(0);
const [duration, setDuration] = useState<number | null>(null);
const [regenLang, setRegenLang] = useState<TranscriptLanguage>(
(transcript?.language as TranscriptLanguage) ?? "auto",
const { locale } = useI18n();
const [regenLang, setRegenLang] = useState<TranscriptLanguageCode>(
supportedTranscriptLanguage(transcript?.language),
);

// ponytail: sync the language picker to whatever the stored transcript was
// generated with. Avoids surprising the user with a different selection on
// every open after a regenerate.
useEffect(() => {
if (open) setRegenLang((transcript?.language as TranscriptLanguage) ?? "auto");
if (open) setRegenLang(supportedTranscriptLanguage(transcript?.language));
}, [open, transcript?.language]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const regenLanguageOptions = useMemo(
() => sortedLanguageOptions(locale, t("mediaStage.auto")),
[locale, t],
);

useEffect(() => {
if (!open) {
setIsPlaying(false);
Expand Down Expand Up @@ -1875,7 +1869,9 @@ export function SourceTranscriptModal({
border: "1px solid color-mix(in srgb, var(--success) 22%, transparent)",
}}
>
{t("mediaStage.detectedLanguage", { language: detectedLanguage })}
{t("mediaStage.detectedLanguage", {
language: languageLabel(detectedLanguage, locale),
})}
</span>
) : null}
</div>
Expand All @@ -1900,7 +1896,7 @@ export function SourceTranscriptModal({
aria-label={t("mediaStage.regenerateAs")}
value={regenLang}
disabled={isTranscribing}
onChange={(e) => setRegenLang(e.target.value as TranscriptLanguage)}
onChange={(e) => setRegenLang(e.target.value as TranscriptLanguageCode)}
style={{
width: "100%",
padding: "10px 12px",
Expand All @@ -1911,9 +1907,9 @@ export function SourceTranscriptModal({
font: "500 13px var(--font-body)",
}}
>
{REGEN_LANGUAGES.map((code) => (
{regenLanguageOptions.map(({ code, label }) => (
<option key={code} value={code}>
{code === "auto" ? t("mediaStage.auto") : LANGUAGE_LABELS[code]}
{label}
</option>
))}
</select>
Expand Down
30 changes: 21 additions & 9 deletions src/components/ai-edition/v4/MediaStage.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { ArrowDown, Film, Plus, RotateCw, Search, X } from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { useScopedT } from "@/contexts/I18nContext";
import type { AxcutAsset } from "@/lib/ai-edition/schema";
import { useI18n, useScopedT } from "@/contexts/I18nContext";
import type { AxcutAsset, TranscriptLanguageCode } from "@/lib/ai-edition/schema";
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
import {
useAssetTranscriptions,
useTranscriptionStore,
} from "@/lib/ai-edition/store/transcriptionStore";
import { formatSeconds } from "@/lib/ai-edition/timeline/format";
import {
languageLabel,
sortedLanguageOptions,
} from "@/lib/ai-edition/transcription/languageLabels";
import type {
AssetTranscriptionStatus,
AssetTranscriptionView,
Expand Down Expand Up @@ -49,6 +53,7 @@ export function MediaStage({
onAddToTimeline: (assetId: string) => Promise<void>;
}) {
const t = useScopedT("editor");
const { locale } = useI18n();
const projectId = useProjectStore((s) => s.projectId);
const document = useProjectStore((s) => s.document);
const addAsset = useProjectStore((s) => s.addAsset);
Expand All @@ -62,7 +67,11 @@ export function MediaStage({
const [busy, setBusy] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [lang, setLang] = useState("auto");
const [lang, setLang] = useState<TranscriptLanguageCode>("auto");
const regenLanguageOptions = useMemo(
() => sortedLanguageOptions(locale, t("mediaStage.auto")),
[locale, t],
);

const assets = document?.assets ?? [];
const filtered = useMemo(
Expand Down Expand Up @@ -335,7 +344,9 @@ export function MediaStage({
fontWeight: 600,
}}
>
{t("mediaStage.detectedLanguage", { language: transcript.language })}
{t("mediaStage.detectedLanguage", {
language: languageLabel(transcript.language, locale),
})}
</span>
) : null}
</div>
Expand Down Expand Up @@ -372,7 +383,7 @@ export function MediaStage({
<div style={{ display: "flex", gap: 8 }}>
<select
value={lang}
onChange={(e) => setLang(e.target.value)}
onChange={(e) => setLang(e.target.value as TranscriptLanguageCode)}
style={{
flex: 1,
minWidth: 0,
Expand All @@ -387,10 +398,11 @@ export function MediaStage({
outline: "none",
}}
>
<option value="auto">{t("mediaStage.auto")}</option>
<option value="en">English</option>
<option value="fr">Français</option>
<option value="es">Español</option>
{regenLanguageOptions.map(({ code, label }) => (
<option key={code} value={code}>
{label}
</option>
))}
</select>
<button
type="button"
Expand Down
122 changes: 115 additions & 7 deletions src/lib/ai-edition/schema/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -811,19 +811,124 @@ export const chatInputSchema = z.object({
message: z.string().trim().min(1),
});

export const transcriptLanguageSchema = z.enum([
// Every code whisper.cpp's multilingual model can resolve, plus "auto" for
// detection. Codes and order mirror whisper.cpp's own `g_lang` table
// (`src/whisper.cpp`, verified against the tag `nix/whisper-stt.nix` pins —
// v1.9.1) — `wparams.language` in
// electron/native/whisper-stt/src/main.cpp forwards the code verbatim, so a
// value outside this list fails to resolve a language id there. "auto" is
// always index 0 — code that needs "every real language, no sentinel" relies
// on that (see `WhisperLanguageCode` below) rather than filtering it out.
// Consumed by both "Regenerate as" pickers: `MediaStage.tsx` (the one
// actually mounted by `NewEditorShell`) and `Modals.tsx`'s
// `SourceTranscriptModal` (currently unreachable — `LeftPanel` is only ever
// mounted with `active="chat"` — but kept correct rather than deleted, since
// nothing marks it dead code and a test or a future rewire could reach it).
export const TRANSCRIPT_LANGUAGE_CODES = [
"auto",
"en",
"fr",
"zh",
"de",
"es",
"it",
"ru",
"ko",
"fr",
"ja",
"pt",
"tr",
"pl",
"ca",
"nl",
"ja",
"ko",
"zh",
]);
"ar",
"sv",
"it",
"id",
"hi",
"fi",
"vi",
"he",
"uk",
"el",
"ms",
"cs",
"ro",
"da",
"hu",
"ta",
"no",
"th",
"ur",
"hr",
"bg",
"lt",
"la",
"mi",
"ml",
"cy",
"sk",
"te",
"fa",
"lv",
"bn",
"sr",
"az",
"sl",
"kn",
"et",
"mk",
"br",
"eu",
"is",
"hy",
"ne",
"mn",
"bs",
"kk",
"sq",
"sw",
"gl",
"mr",
"pa",
"si",
"km",
"sn",
"yo",
"so",
"af",
"oc",
"ka",
"be",
"tg",
"sd",
"gu",
"am",
"yi",
"lo",
"uz",
"fo",
"ht",
"ps",
"tk",
"nn",
"mt",
"sa",
"lb",
"my",
"bo",
"tl",
"mg",
"as",
"tt",
"haw",
"ln",
"ha",
"ba",
"jw",
"su",
"yue",
] as const;

export const transcriptLanguageSchema = z.enum(TRANSCRIPT_LANGUAGE_CODES);

export type AxcutWord = z.infer<typeof wordSchema>;
export type AxcutTranscriptSegment = z.infer<typeof transcriptSegmentSchema>;
Expand All @@ -845,6 +950,9 @@ export type AxcutDocumentInput = z.input<typeof documentSchema>;
export type CreateProjectInput = z.infer<typeof createProjectInputSchema>;
export type AddAssetInput = z.infer<typeof addAssetInputSchema>;
export type ChatInput = z.infer<typeof chatInputSchema>;
export type TranscriptLanguageCode = z.infer<typeof transcriptLanguageSchema>;
/** A real whisper.cpp language code — `TranscriptLanguageCode` minus the "auto" detection sentinel. */
export type WhisperLanguageCode = Exclude<TranscriptLanguageCode, "auto">;

export function createEmptyDocument(
input: CreateProjectInput & { projectId: string; createdAt?: string },
Expand Down
Loading
Loading