Skip to content

Commit d56c3b4

Browse files
authored
feat: 拆分引入本地视频上传支持
合并维护者拆分分支,保留 PR #68 中低风险且边界清楚的改动:本地视频上传、OpenAI Base URL 规范化、Whisper 缓存校验失败重试、ffmpeg/ffprobe 路径配置,以及对应测试。
2 parents 5af1cc0 + cdb7b25 commit d56c3b4

23 files changed

Lines changed: 581 additions & 29 deletions

.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ CORS_ALLOW_ORIGIN_REGEX=
77
# Device: cuda, cuda:0, cpu, or auto
88
DEVICE=cuda
99

10+
# Optional explicit media binary paths when they are not on PATH.
11+
# FFMPEG_PATH=C:\path\to\ffmpeg.exe
12+
# FFPROBE_PATH=C:\path\to\ffprobe.exe
13+
14+
# Maximum local video upload size in bytes. Default: 4 GiB.
15+
LOCAL_UPLOAD_MAX_BYTES=4294967296
16+
1017
# OpenAI-compatible translator
1118
OPENAI_BASE_URL=https://api.openai.com/v1
1219
OPENAI_API_KEY=

apps/web/src/app/page.tsx

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22

33
import Link from "next/link"
44
import { useRouter } from "next/navigation"
5-
import { FormEvent, useEffect, useState } from "react"
6-
import { ChevronRight, Play } from "lucide-react"
5+
import { ChangeEvent, FormEvent, useEffect, useRef, useState } from "react"
6+
import { ChevronRight, Play, Upload } from "lucide-react"
77

88
import {
99
TaskSummary,
10+
LocalDirection,
1011
createTask,
1112
listTasks,
13+
uploadLocalTask,
1214
} from "@/lib/api"
1315
import { useI18n } from "@/lib/i18n"
1416
import { statusBadgeClass } from "@/lib/status"
@@ -24,6 +26,13 @@ import {
2426
import { Input } from "@/components/ui/input"
2527
import { Label } from "@/components/ui/label"
2628
import { ScrollArea } from "@/components/ui/scroll-area"
29+
import {
30+
Select,
31+
SelectContent,
32+
SelectItem,
33+
SelectTrigger,
34+
SelectValue,
35+
} from "@/components/ui/select"
2736

2837
function isActive(status: string) {
2938
return status === "queued" || status === "running"
@@ -47,8 +56,11 @@ function activeCount(tasks: TaskSummary[]) {
4756
export default function Home() {
4857
const router = useRouter()
4958
const { activeTasksText, stageLabel, statusLabel, t } = useI18n()
59+
const fileInputRef = useRef<HTMLInputElement>(null)
5060
const [youtubeUrl, setYoutubeUrl] = useState("")
5161
const [bilibiliUrl, setBilibiliUrl] = useState("")
62+
const [localFile, setLocalFile] = useState<File | null>(null)
63+
const [localDirection, setLocalDirection] = useState<LocalDirection>("en-zh")
5264
const [tasks, setTasks] = useState<TaskSummary[]>([])
5365
const [error, setError] = useState("")
5466
const [submitting, setSubmitting] = useState(false)
@@ -78,16 +90,27 @@ export default function Home() {
7890
}
7991
}, [t.home.loadError])
8092

93+
function selectLocalFile(event: ChangeEvent<HTMLInputElement>) {
94+
setError("")
95+
setLocalFile(event.target.files?.[0] || null)
96+
}
97+
8198
async function submitTask(event: FormEvent<HTMLFormElement>) {
8299
event.preventDefault()
83100
setError("")
84101
const submittedUrl = youtubeUrl.trim() || bilibiliUrl.trim()
85-
if (!submittedUrl) return
102+
if (!submittedUrl && !localFile) return
86103
setSubmitting(true)
87104
try {
88-
const created = await createTask(submittedUrl)
105+
const created = localFile
106+
? await uploadLocalTask(localFile, localDirection)
107+
: await createTask(submittedUrl)
89108
setYoutubeUrl("")
90109
setBilibiliUrl("")
110+
setLocalFile(null)
111+
if (fileInputRef.current) {
112+
fileInputRef.current.value = ""
113+
}
91114
refreshTasks().catch(() => undefined)
92115
router.push(`/tasks/${created.id}`)
93116
} catch (err) {
@@ -98,7 +121,9 @@ export default function Home() {
98121
}
99122

100123
const queued = activeCount(tasks)
101-
const canSubmit = Boolean((youtubeUrl.trim() || bilibiliUrl.trim()) && !submitting)
124+
const hasUrl = Boolean(youtubeUrl.trim() || bilibiliUrl.trim())
125+
const hasLocalFile = Boolean(localFile)
126+
const canSubmit = Boolean((hasUrl || hasLocalFile) && !submitting)
102127

103128
return (
104129
<main className="min-h-screen bg-[linear-gradient(135deg,#fff5f5_0%,#f2fbff_48%,#fff4fa_100%)] text-foreground">
@@ -118,7 +143,7 @@ export default function Home() {
118143
value={youtubeUrl}
119144
onChange={(event) => setYoutubeUrl(event.target.value)}
120145
placeholder="https://www.youtube.com/watch?v=..."
121-
disabled={Boolean(bilibiliUrl.trim())}
146+
disabled={Boolean(bilibiliUrl.trim()) || hasLocalFile}
122147
/>
123148
</div>
124149
<div className="space-y-2">
@@ -128,9 +153,38 @@ export default function Home() {
128153
value={bilibiliUrl}
129154
onChange={(event) => setBilibiliUrl(event.target.value)}
130155
placeholder="https://www.bilibili.com/video/BV..."
131-
disabled={Boolean(youtubeUrl.trim())}
156+
disabled={Boolean(youtubeUrl.trim()) || hasLocalFile}
132157
/>
133158
</div>
159+
<div className="grid gap-3 sm:grid-cols-[1fr_180px]">
160+
<div className="space-y-2">
161+
<Label htmlFor="local-video">{t.home.localVideoLabel}</Label>
162+
<Input
163+
ref={fileInputRef}
164+
id="local-video"
165+
type="file"
166+
accept="video/*,.mp4,.mov,.m4v,.mkv,.webm,.avi,.flv,.wmv"
167+
onChange={selectLocalFile}
168+
disabled={hasUrl}
169+
/>
170+
</div>
171+
<div className="space-y-2">
172+
<Label htmlFor="local-direction">{t.home.localDirectionLabel}</Label>
173+
<Select
174+
value={localDirection}
175+
onValueChange={(value) => setLocalDirection(value as LocalDirection)}
176+
disabled={hasUrl}
177+
>
178+
<SelectTrigger id="local-direction" className="h-10">
179+
<SelectValue />
180+
</SelectTrigger>
181+
<SelectContent>
182+
<SelectItem value="en-zh">{t.home.localEnZh}</SelectItem>
183+
<SelectItem value="zh-en">{t.home.localZhEn}</SelectItem>
184+
</SelectContent>
185+
</Select>
186+
</div>
187+
</div>
134188
<div className="flex items-center justify-between gap-3">
135189
{queued > 0 ? (
136190
<p className="text-xs text-muted-foreground">
@@ -140,7 +194,7 @@ export default function Home() {
140194
<span />
141195
)}
142196
<Button type="submit" disabled={!canSubmit}>
143-
<Play className="size-4" />
197+
{hasLocalFile ? <Upload className="size-4" /> : <Play className="size-4" />}
144198
{submitting ? t.home.submitting : t.home.createTask}
145199
</Button>
146200
</div>

apps/web/src/lib/api.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ export type YtdlpSettings = {
6060
proxy_port: string
6161
}
6262

63+
export type LocalDirection = "en-zh" | "zh-en"
64+
6365
async function request<T>(path: string, options?: RequestInit): Promise<T> {
6466
const response = await fetch(`${API_BASE}${path}`, {
6567
...options,
@@ -131,6 +133,23 @@ export function createTask(url: string) {
131133
})
132134
}
133135

136+
export async function uploadLocalTask(file: File, direction: LocalDirection) {
137+
const form = new FormData()
138+
form.append("direction", direction)
139+
form.append("file", file)
140+
141+
const response = await fetch(`${API_BASE}/api/tasks/upload`, {
142+
method: "POST",
143+
body: form,
144+
cache: "no-store",
145+
})
146+
if (!response.ok) {
147+
const body = await response.json().catch(() => ({}))
148+
throw new Error(body.detail || `Request failed: ${response.status}`)
149+
}
150+
return response.json() as Promise<Task>
151+
}
152+
134153
export function getCookieInfo() {
135154
return request<CookieInfo>("/api/cookies/youtube")
136155
}

apps/web/src/lib/i18n.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,14 @@ const messages: Record<UiLanguage, Messages> = {
4141
createTitle: "Create new task",
4242
youtubeLabel: "YouTube URL (English -> Chinese)",
4343
bilibiliLabel: "Bilibili URL (Chinese -> English)",
44+
localVideoLabel: "Local video file",
45+
localDirectionLabel: "Translation direction",
46+
localEnZh: "English -> Chinese",
47+
localZhEn: "Chinese -> English",
4448
submitting: "Submitting",
4549
createTask: "Create task",
4650
taskHistory: "Task history",
47-
empty: "No tasks yet. Submit a YouTube or Bilibili URL above to start.",
51+
empty: "No tasks yet. Submit a URL or upload a local video above to start.",
4852
loadError: "Failed to load tasks",
4953
createError: "Failed to create task",
5054
},
@@ -147,10 +151,14 @@ const messages: Record<UiLanguage, Messages> = {
147151
createTitle: "新建任务",
148152
youtubeLabel: "YouTube 链接(英文 -> 中文)",
149153
bilibiliLabel: "Bilibili 链接(中文 -> 英文)",
154+
localVideoLabel: "本地视频文件",
155+
localDirectionLabel: "翻译方向",
156+
localEnZh: "英文 -> 中文",
157+
localZhEn: "中文 -> 英文",
150158
submitting: "提交中",
151159
createTask: "创建任务",
152160
taskHistory: "任务历史",
153-
empty: "暂无任务。输入 YouTube 或 Bilibili 链接后即可开始。",
161+
empty: "暂无任务。输入链接或上传本地视频后即可开始。",
154162
loadError: "加载任务失败",
155163
createError: "创建任务失败",
156164
},

backend/app/adapters/ffmpeg.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import subprocess
66
from pathlib import Path
77

8+
from ..config import ffmpeg_binary, ffprobe_binary
9+
810
SUBTITLE_PUNCTUATION = {",", ",", ";", ";", ":", ":", "。", "?", "?", "!", "!", "、"}
911
SUBTITLE_PROTECTED_PAIRS = {"《": "》", "(": ")", "【": "】", "「": "」", "『": "』"}
1012
SUBTITLE_CLOSING_QUOTES = {'"', "'", "」", "』", "》", ")", "】", "\u201d", "\u2019", "]"}
@@ -187,7 +189,7 @@ def write_srt(translation_file: Path, session: Path) -> Path:
187189
def probe_video_size(video_file: Path) -> tuple[int, int] | None:
188190
result = subprocess.run(
189191
[
190-
"ffprobe",
192+
ffprobe_binary(),
191193
"-v",
192194
"error",
193195
"-select_streams",
@@ -251,7 +253,7 @@ def merge_video(video_file: Path, dubbing_file: Path, bgm_file: Path, timings_fi
251253
mixed_audio = tmp_dir / "audio_mixed.m4a"
252254
subprocess.run(
253255
[
254-
"ffmpeg",
256+
ffmpeg_binary(),
255257
"-y",
256258
"-i",
257259
str(dubbing_file),
@@ -269,7 +271,7 @@ def merge_video(video_file: Path, dubbing_file: Path, bgm_file: Path, timings_fi
269271
)
270272
subprocess.run(
271273
[
272-
"ffmpeg",
274+
ffmpeg_binary(),
273275
"-y",
274276
"-i",
275277
str(video_file),
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import shutil
5+
import subprocess
6+
from pathlib import Path
7+
from urllib.parse import parse_qs, urlparse
8+
9+
from ..config import ffmpeg_binary
10+
from ..sanitize import sanitize_text
11+
from ..sources import SourceConfig
12+
from ..youtube import local_upload_task_id
13+
14+
15+
def upload_dir(workfolder: Path, task_id: str) -> Path:
16+
return workfolder / "_uploads" / task_id
17+
18+
19+
def remove_upload(workfolder: Path, task_id: str) -> None:
20+
target = upload_dir(workfolder, task_id)
21+
if target.exists():
22+
shutil.rmtree(target)
23+
24+
25+
def _uploaded_video_file(workfolder: Path, task_id: str) -> Path:
26+
root = upload_dir(workfolder, task_id)
27+
if not root.exists():
28+
raise FileNotFoundError(f"Local upload is missing for task {task_id}.")
29+
30+
files = sorted(path for path in root.iterdir() if path.is_file())
31+
if not files:
32+
raise FileNotFoundError(f"Local upload is missing for task {task_id}.")
33+
if len(files) > 1:
34+
raise RuntimeError(f"Local upload has multiple files for task {task_id}.")
35+
return files[0]
36+
37+
38+
def _title_from_url(url: str, source_file: Path) -> str:
39+
query = parse_qs(urlparse(url.strip()).query)
40+
filename = (query.get("filename") or [""])[0].strip()
41+
if filename:
42+
return Path(filename).stem or source_file.stem
43+
return source_file.stem
44+
45+
46+
def _session_path(workfolder: Path, task_id: str, title: str) -> Path:
47+
safe_title = sanitize_text(title) or "local-video"
48+
return workfolder / "local" / f"{safe_title}__{task_id}"
49+
50+
51+
def _transcode_to_mp4(source_file: Path, video_file: Path) -> None:
52+
subprocess.run(
53+
[
54+
ffmpeg_binary(),
55+
"-y",
56+
"-i",
57+
str(source_file),
58+
"-map",
59+
"0:v:0",
60+
"-map",
61+
"0:a:0?",
62+
"-c:v",
63+
"libx264",
64+
"-preset",
65+
"fast",
66+
"-crf",
67+
"23",
68+
"-c:a",
69+
"aac",
70+
"-movflags",
71+
"+faststart",
72+
str(video_file),
73+
],
74+
check=True,
75+
)
76+
77+
78+
def import_local_video(url: str, workfolder: Path, source: SourceConfig) -> tuple[Path, dict]:
79+
task_id = local_upload_task_id(url)
80+
if not task_id:
81+
raise ValueError("Invalid local upload URL.")
82+
83+
source_file = _uploaded_video_file(workfolder, task_id)
84+
title = _title_from_url(url, source_file)
85+
session = _session_path(workfolder, task_id, title)
86+
media_dir = session / "media"
87+
metadata_dir = session / "metadata"
88+
media_dir.mkdir(parents=True, exist_ok=True)
89+
metadata_dir.mkdir(parents=True, exist_ok=True)
90+
91+
video_file = media_dir / "video_source.mp4"
92+
info = {
93+
"id": task_id,
94+
"title": title,
95+
"source": "local",
96+
"webpage_url": url,
97+
"original_path": str(source_file),
98+
"asr_language": source.asr_language,
99+
"target_language": source.target_language,
100+
}
101+
metadata_file = metadata_dir / "local_info.json"
102+
metadata_file.write_text(json.dumps(info, ensure_ascii=False, indent=2), encoding="utf-8")
103+
104+
if video_file.exists() and video_file.stat().st_size > 0:
105+
return session, info
106+
107+
_transcode_to_mp4(source_file, video_file)
108+
if not video_file.exists() or video_file.stat().st_size == 0:
109+
raise RuntimeError("ffmpeg finished without producing media/video_source.mp4")
110+
return session, info
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from __future__ import annotations
2+
3+
4+
def normalize_openai_base_url(base_url: str) -> str:
5+
url = base_url.strip().rstrip("/")
6+
lowered = url.lower()
7+
for suffix in ("/chat/completions", "/completions"):
8+
if lowered.endswith(suffix):
9+
url = url[: -len(suffix)].rstrip("/")
10+
lowered = url.lower()
11+
return url or "https://api.openai.com/v1"

0 commit comments

Comments
 (0)