Initial commit

This commit is contained in:
admin
2026-05-26 22:46:00 +02:00
commit 7e2c2ff89c
256 changed files with 51523 additions and 0 deletions
+346
View File
@@ -0,0 +1,346 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Captions, Loader2, Save, FolderOpen, CheckCircle2, AlertCircle, RefreshCw, Trash2, Sparkles } from "lucide-react";
import type { WhisperJavSettings as WhisperJavSettingsT } from "@/lib/db/appSettings";
import { setWhisperJavSettings } from "@/app/actions/settings";
import { useSettings } from "./SettingsProvider";
import { useSettingsPanel } from "./SettingsPanelProvider";
import { cn } from "@/lib/utils";
type Quality = WhisperJavSettingsT["quality"];
type SourceLang = WhisperJavSettingsT["sourceLanguage"];
type OutputMode = WhisperJavSettingsT["outputMode"];
type Sensitivity = WhisperJavSettingsT["sensitivity"];
type Location = WhisperJavSettingsT["outputLocation"];
interface VerifyResult {
ok: boolean;
version?: string;
resolvedPath?: string;
error?: string;
}
export function WhisperJavSettings() {
const { settings } = useSettings();
const w = settings.whisperjav;
const router = useRouter();
const { close: closeSettings } = useSettingsPanel();
const [, start] = useTransition();
const [draft, setDraft] = useState<WhisperJavSettingsT>(w);
const [saving, setSaving] = useState(false);
const [verifying, setVerifying] = useState(false);
const [verify, setVerify] = useState<VerifyResult | null>(null);
const [autodetected, setAutodetected] = useState(false);
useEffect(() => { setDraft(w); }, [w]);
// First-open autodetect — only when cliPath is empty AND no draft change.
useEffect(() => {
if (autodetected) return;
if (draft.cliPath) return;
setAutodetected(true);
void (async () => {
try {
const r = await fetch("/api/whisperjav-verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ autodetect: true }),
});
const data = (await r.json()) as VerifyResult;
if (data.ok && data.resolvedPath) {
setDraft((cur) => (cur.cliPath ? cur : { ...cur, cliPath: data.resolvedPath! }));
setVerify(data);
}
} catch { /* no autodetect available */ }
})();
}, [autodetected, draft.cliPath]);
const dirty = JSON.stringify(draft) !== JSON.stringify(w);
async function browse() {
try {
const r = await fetch("/api/pick-file", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ purpose: "whisperjav" }),
});
const data = await r.json();
if (data.path) setDraft((cur) => ({ ...cur, cliPath: data.path }));
} catch { /* ignore */ }
}
async function runVerify() {
setVerifying(true);
setVerify(null);
try {
const r = await fetch("/api/whisperjav-verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: draft.cliPath }),
});
const data = (await r.json()) as VerifyResult;
setVerify(data);
} catch (e) {
setVerify({ ok: false, error: (e as Error).message });
} finally {
setVerifying(false);
}
}
async function save() {
setSaving(true);
try {
await setWhisperJavSettings(draft);
start(() => router.refresh());
} finally {
setSaving(false);
}
}
return (
<div className="py-2 space-y-5">
<div>
<div className="text-sm font-medium mb-1 flex items-center gap-1.5">
<Captions className="w-3.5 h-3.5 text-[var(--color-cyan)]" />
WhisperJAV Subtitle Generator
</div>
<div className="text-xs text-[var(--color-fg-muted)] mb-2 max-w-2xl">
Local AI subtitle generation. Configure the CLI path, then use{" "}
<span className="font-mono">Generate Subtitles</span> from the player&apos;s subtitle dropdown.
First run may download models (~12 GB).
</div>
</div>
{/* CLI path */}
<div>
<div className="text-sm font-medium mb-1">CLI Path</div>
<div className="flex items-center gap-2">
<input
type="text"
value={draft.cliPath}
onChange={(e) => setDraft({ ...draft, cliPath: e.target.value })}
placeholder="whisperjav (or absolute path)"
className="flex-1 glass rounded-lg px-3 py-1.5 text-sm font-mono outline-none focus:border-[var(--color-cyan)]"
/>
<button
type="button"
onClick={browse}
title="Browse for whisperjav.exe"
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover"
>
<FolderOpen className="w-4 h-4" /> Browse
</button>
<button
type="button"
onClick={runVerify}
disabled={verifying || !draft.cliPath.trim()}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover disabled:opacity-40"
>
{verifying ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
Verify
</button>
</div>
{verify && (
<div className={cn(
"mt-2 text-xs rounded-lg px-3 py-2 border",
verify.ok
? "text-[var(--color-mint)] bg-[var(--color-mint)]/5 border-[var(--color-mint)]/25"
: "text-red-300 bg-red-500/5 border-red-500/25",
)}>
<div className="flex items-start gap-2">
{verify.ok
? <CheckCircle2 className="w-4 h-4 mt-0.5 shrink-0" />
: <AlertCircle className="w-4 h-4 mt-0.5 shrink-0" />}
<div className="min-w-0">
{verify.ok
? <>Detected: <strong>WhisperJAV {verify.version}</strong> at <span className="font-mono break-all">{verify.resolvedPath}</span></>
: <>Verify failed: {verify.error}</>}
</div>
</div>
</div>
)}
</div>
{/* Quality */}
<RadioGroup<Quality>
label="Quality"
value={draft.quality}
onChange={(v) => setDraft({ ...draft, quality: v })}
options={[
{ value: "fast", title: "Fast", desc: "Quickest pass; lower fidelity." },
{ value: "balanced", title: "Balanced", desc: "Default. Good speed/quality tradeoff." },
{ value: "qwen", title: "Best (Qwen)", desc: "Qwen3-ASR with JAV-tuned post-processing. Slow + heavy on VRAM." },
]}
/>
{/* Source language + Output mode */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<RadioGroup<SourceLang>
label="Source Language"
value={draft.sourceLanguage}
onChange={(v) => setDraft({ ...draft, sourceLanguage: v })}
options={[
{ value: "japanese", title: "Japanese", desc: "" },
{ value: "korean", title: "Korean", desc: "" },
{ value: "chinese", title: "Chinese", desc: "" },
{ value: "english", title: "English", desc: "" },
]}
/>
<RadioGroup<OutputMode>
label="Output Mode"
value={draft.outputMode}
onChange={(v) => setDraft({ ...draft, outputMode: v })}
options={[
{ value: "native", title: "Native", desc: "Transcribe in source language." },
{ value: "direct-to-english", title: "Direct To English", desc: "Whisper-translate to English." },
]}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<RadioGroup<Sensitivity>
label="Sensitivity"
value={draft.sensitivity}
onChange={(v) => setDraft({ ...draft, sensitivity: v })}
options={[
{ value: "conservative", title: "Conservative", desc: "" },
{ value: "balanced", title: "Balanced", desc: "" },
{ value: "aggressive", title: "Aggressive", desc: "" },
]}
/>
<RadioGroup<Location>
label="Output Location"
value={draft.outputLocation}
onChange={(v) => setDraft({ ...draft, outputLocation: v })}
options={[
{ value: "beside-video", title: "Beside Video", desc: "Falls back to data folder if read-only." },
{ value: "data-folder", title: "Data Folder", desc: "data/generated-subtitles/<code>/" },
]}
/>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={draft.noSignature}
onChange={(e) => setDraft({ ...draft, noSignature: e.target.checked })}
/>
Disable WhisperJAV signature cue at end of subtitles
</label>
<div>
<div className="text-sm font-medium mb-1">Job History Retention</div>
<div className="text-xs text-[var(--color-fg-muted)] mb-2 max-w-2xl">
Days to keep failed and cancelled job temp folders for debugging.
Successful job folders are deleted immediately.{" "}
<span className="font-mono">0</span> = keep forever.
</div>
<div className="flex items-center gap-2">
<input
type="number"
min={0}
value={draft.retentionDays}
onChange={(e) => setDraft({ ...draft, retentionDays: Math.max(0, Number(e.target.value) || 0) })}
className="w-24 glass rounded-lg px-3 py-1.5 text-sm font-mono outline-none focus:border-[var(--color-cyan)]"
/>
<span className="text-xs text-[var(--color-fg-dim)]">days</span>
</div>
</div>
<div className="flex items-center gap-2 pt-3 border-t border-[var(--color-glass-border)] flex-wrap">
<button
type="button"
onClick={save}
disabled={!dirty || saving}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg bg-[var(--color-cyan)] text-black font-medium disabled:opacity-40 disabled:cursor-not-allowed"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save
</button>
<Link
href="/subtitles/batch"
onClick={closeSettings}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover"
>
<Sparkles className="w-4 h-4 text-[var(--color-cyan)]" />
Open Batch Generator
</Link>
<ClearHistoryButton />
</div>
</div>
);
}
function ClearHistoryButton() {
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<string | null>(null);
async function clearAll() {
if (!window.confirm("Clear all WhisperJAV job history? Temp folders for past jobs will be deleted.")) return;
setBusy(true);
setResult(null);
try {
const r = await fetch("/api/whisperjav-jobs", { method: "DELETE" });
const data = (await r.json()) as { rows?: number; dirs?: number };
setResult(`Removed ${data.rows ?? 0} row(s), ${data.dirs ?? 0} folder(s).`);
} catch (e) {
setResult(`Failed: ${(e as Error).message}`);
} finally {
setBusy(false);
}
}
return (
<>
<button
type="button"
onClick={clearAll}
disabled={busy}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg border border-red-500/30 text-red-300 hover:bg-red-500/10 disabled:opacity-40"
>
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
Clear Job History
</button>
{result && <span className="text-xs text-[var(--color-fg-dim)]">{result}</span>}
</>
);
}
function RadioGroup<T extends string>({ label, value, onChange, options }: {
label: string;
value: T;
onChange: (v: T) => void;
options: Array<{ value: T; title: string; desc: string }>;
}) {
return (
<div>
<div className="text-sm font-medium mb-1">{label}</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{options.map((opt) => {
const active = value === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() => onChange(opt.value)}
className={cn(
"text-left rounded-lg border px-3 py-2 transition-colors cursor-pointer",
active
? "border-[var(--color-cyan)] bg-[var(--color-cyan)]/10 shadow-[var(--shadow-glow-cyan)]"
: "border-[var(--color-glass-border)] bg-[var(--color-glass)]/30 hover:border-[var(--color-glass-border-strong)]",
)}
>
<div className={cn(
"text-sm font-medium",
active ? "text-[var(--color-cyan)]" : "text-[var(--color-fg)]",
)}>{opt.title}</div>
{opt.desc && (
<div className="text-[11px] text-[var(--color-fg-muted)] mt-0.5 leading-snug">{opt.desc}</div>
)}
</button>
);
})}
</div>
</div>
);
}