auto-save 2026-05-12 15:57 (~5)
This commit is contained in:
@@ -20,6 +20,13 @@
|
|||||||
"message": "auto-save 2026-05-12 15:41 (+1, ~3)",
|
"message": "auto-save 2026-05-12 15:41 (+1, ~3)",
|
||||||
"hash": "bbd41fa",
|
"hash": "bbd41fa",
|
||||||
"files_changed": 4
|
"files_changed": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-05-12T15:51:42+08:00",
|
||||||
|
"type": "commit",
|
||||||
|
"message": "auto-save 2026-05-12 15:47 (+2, ~3)",
|
||||||
|
"hash": "2e45ad9",
|
||||||
|
"files_changed": 96
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
83
api/main.py
83
api/main.py
@@ -11,7 +11,7 @@ from pathlib import Path
|
|||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from fastapi import BackgroundTasks, FastAPI, HTTPException
|
from fastapi import BackgroundTasks, FastAPI, File, HTTPException, UploadFile
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
@@ -106,7 +106,9 @@ app.add_middleware(
|
|||||||
def run(cmd: list[str], cwd: Path | None = None) -> str:
|
def run(cmd: list[str], cwd: Path | None = None) -> str:
|
||||||
res = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
|
res = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
|
||||||
if res.returncode != 0:
|
if res.returncode != 0:
|
||||||
raise RuntimeError(f"cmd failed: {' '.join(cmd[:3])}... · {res.stderr[:500]}")
|
# ffmpeg 把 banner 写 stderr,挑最后几行(真错误一般在末尾)
|
||||||
|
tail = "\n".join(res.stderr.splitlines()[-12:]) or res.stderr[-800:]
|
||||||
|
raise RuntimeError(f"cmd failed: {' '.join(cmd[:3])}... · {tail}")
|
||||||
return res.stdout
|
return res.stdout
|
||||||
|
|
||||||
|
|
||||||
@@ -122,18 +124,21 @@ async def pipeline_download_split_frames(job_id: str) -> None:
|
|||||||
job = JOBS[job_id]
|
job = JOBS[job_id]
|
||||||
d = job_dir(job_id)
|
d = job_dir(job_id)
|
||||||
try:
|
try:
|
||||||
# ---- 1. yt-dlp 下载
|
|
||||||
update(job, status="downloading", message="yt-dlp 下载中…", progress=5)
|
|
||||||
mp4 = d / "source.mp4"
|
mp4 = d / "source.mp4"
|
||||||
run([
|
# ---- 1. yt-dlp 下载(上传模式 mp4 已存在 → 跳过)
|
||||||
"yt-dlp", "-f", "best[ext=mp4]/best",
|
if mp4.exists():
|
||||||
"-o", str(mp4),
|
update(job, status="downloading", message="本地上传,跳过下载", progress=15)
|
||||||
"--no-warnings", "--no-playlist",
|
else:
|
||||||
"--retries", "3",
|
update(job, status="downloading", message="yt-dlp 下载中…", progress=5)
|
||||||
job.url,
|
run([
|
||||||
])
|
"yt-dlp", "-f", "best[ext=mp4]/best",
|
||||||
if not mp4.exists():
|
"-o", str(mp4),
|
||||||
raise RuntimeError("下载完成但找不到 source.mp4")
|
"--no-warnings", "--no-playlist",
|
||||||
|
"--retries", "3",
|
||||||
|
job.url,
|
||||||
|
])
|
||||||
|
if not mp4.exists():
|
||||||
|
raise RuntimeError("下载完成但找不到 source.mp4")
|
||||||
|
|
||||||
# 元数据
|
# 元数据
|
||||||
meta = ffprobe_meta(mp4)
|
meta = ffprobe_meta(mp4)
|
||||||
@@ -165,14 +170,20 @@ async def pipeline_download_split_frames(job_id: str) -> None:
|
|||||||
shutil.rmtree(frames_dir)
|
shutil.rmtree(frames_dir)
|
||||||
frames_dir.mkdir(parents=True)
|
frames_dir.mkdir(parents=True)
|
||||||
|
|
||||||
# 先用场景切换检测
|
# 先用场景切换检测(失败时不阻塞,走均匀采样兜底)
|
||||||
run([
|
try:
|
||||||
"ffmpeg", "-y", "-i", str(mp4),
|
run([
|
||||||
"-vf", "select='gt(scene,0.4)',showinfo",
|
"ffmpeg", "-y", "-i", str(mp4),
|
||||||
"-vsync", "vfr",
|
"-vf", "select='gt(scene,0.4)'",
|
||||||
"-frames:v", "30",
|
"-fps_mode", "vfr",
|
||||||
str(frames_dir / "scene_%03d.jpg"),
|
"-frames:v", "30",
|
||||||
])
|
"-pix_fmt", "yuvj420p", # mjpeg encoder 要 JPEG full-range
|
||||||
|
"-q:v", "3",
|
||||||
|
str(frames_dir / "scene_%03d.jpg"),
|
||||||
|
])
|
||||||
|
except Exception:
|
||||||
|
# 场景切换检测在某些纯合成 / 静态视频上会失败,让它静默走兜底
|
||||||
|
pass
|
||||||
scene_frames = sorted(frames_dir.glob("scene_*.jpg"))
|
scene_frames = sorted(frames_dir.glob("scene_*.jpg"))
|
||||||
|
|
||||||
# 均匀采样兜底 / 补足
|
# 均匀采样兜底 / 补足
|
||||||
@@ -184,7 +195,9 @@ async def pipeline_download_split_frames(job_id: str) -> None:
|
|||||||
out = frames_dir / f"sample_{i:03d}.jpg"
|
out = frames_dir / f"sample_{i:03d}.jpg"
|
||||||
run([
|
run([
|
||||||
"ffmpeg", "-y", "-ss", str(t), "-i", str(mp4),
|
"ffmpeg", "-y", "-ss", str(t), "-i", str(mp4),
|
||||||
"-frames:v", "1", "-q:v", "3", str(out),
|
"-frames:v", "1",
|
||||||
|
"-pix_fmt", "yuvj420p",
|
||||||
|
"-q:v", "3", str(out),
|
||||||
])
|
])
|
||||||
|
|
||||||
# 统一排序、按时间戳读取、限制 10 张
|
# 统一排序、按时间戳读取、限制 10 张
|
||||||
@@ -299,6 +312,32 @@ async def create_job(req: CreateJobReq, bg: BackgroundTasks) -> Job:
|
|||||||
return job
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/jobs/upload", response_model=Job)
|
||||||
|
async def create_job_from_upload(bg: BackgroundTasks, file: UploadFile = File(...)) -> Job:
|
||||||
|
if not file.filename:
|
||||||
|
raise HTTPException(400, "file required")
|
||||||
|
# 简化:只验后缀,不嗅探 magic bytes
|
||||||
|
ext = Path(file.filename).suffix.lower()
|
||||||
|
if ext not in {".mp4", ".mov", ".webm", ".mkv", ".m4v"}:
|
||||||
|
raise HTTPException(400, f"unsupported video format: {ext}")
|
||||||
|
|
||||||
|
job_id = uuid.uuid4().hex[:12]
|
||||||
|
d = job_dir(job_id)
|
||||||
|
mp4 = d / "source.mp4"
|
||||||
|
# 直接落盘(流式写入,避免全量进内存)
|
||||||
|
with mp4.open("wb") as f:
|
||||||
|
while chunk := await file.read(1024 * 1024):
|
||||||
|
f.write(chunk)
|
||||||
|
if not mp4.exists() or mp4.stat().st_size == 0:
|
||||||
|
raise HTTPException(500, "upload failed")
|
||||||
|
|
||||||
|
job = Job(id=job_id, url=f"upload://{file.filename}")
|
||||||
|
JOBS[job_id] = job
|
||||||
|
save_state(job)
|
||||||
|
bg.add_task(pipeline_download_split_frames, job_id)
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
@app.get("/jobs/{job_id}", response_model=Job)
|
@app.get("/jobs/{job_id}", response_model=Job)
|
||||||
def get_job(job_id: str) -> Job:
|
def get_job(job_id: str) -> Job:
|
||||||
job = JOBS.get(job_id)
|
job = JOBS.get(job_id)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { UrlInput } from "@/components/url-input"
|
|||||||
import { JobStatusBar } from "@/components/job-status"
|
import { JobStatusBar } from "@/components/job-status"
|
||||||
import { KeyframeGallery } from "@/components/keyframe-gallery"
|
import { KeyframeGallery } from "@/components/keyframe-gallery"
|
||||||
import { TranscriptPanel } from "@/components/transcript-panel"
|
import { TranscriptPanel } from "@/components/transcript-panel"
|
||||||
import { createJob, getJob, triggerTranscribe, videoUrl, type Job } from "@/lib/api"
|
import { createJob, getJob, triggerTranscribe, uploadJob, videoUrl, type Job } from "@/lib/api"
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [job, setJob] = useState<Job | null>(null)
|
const [job, setJob] = useState<Job | null>(null)
|
||||||
@@ -30,6 +30,22 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const handleUpload = useCallback(async (file: File) => {
|
||||||
|
setSubmitting(true)
|
||||||
|
setSelected(new Set())
|
||||||
|
transcribeTriggeredRef.current = null
|
||||||
|
try {
|
||||||
|
toast.info(`正在上传 ${file.name} (${(file.size / 1024 / 1024).toFixed(1)} MB)`)
|
||||||
|
const created = await uploadJob(file)
|
||||||
|
setJob(created)
|
||||||
|
toast.success(`已上传,任务 ${created.id.slice(0, 8)}`)
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("上传失败:" + (e instanceof Error ? e.message : String(e)))
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
// 轮询 job 状态
|
// 轮询 job 状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!job) return
|
if (!job) return
|
||||||
@@ -93,7 +109,11 @@ export default function Home() {
|
|||||||
|
|
||||||
{/* URL 输入 */}
|
{/* URL 输入 */}
|
||||||
<section>
|
<section>
|
||||||
<UrlInput loading={submitting || (job !== null && job.status !== "transcribed" && job.status !== "failed")} onSubmit={handleSubmit} />
|
<UrlInput
|
||||||
|
loading={submitting || (job !== null && job.status !== "transcribed" && job.status !== "failed")}
|
||||||
|
onSubmitUrl={handleSubmit}
|
||||||
|
onUploadFile={handleUpload}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{job && (
|
{job && (
|
||||||
@@ -146,7 +166,7 @@ export default function Home() {
|
|||||||
|
|
||||||
{!job && (
|
{!job && (
|
||||||
<section className="text-center py-16">
|
<section className="text-center py-16">
|
||||||
<div className="font-serif text-2xl text-white/30">↑ 粘贴一条 TikTok 链接开始</div>
|
<div className="font-serif text-2xl text-white/30">↑ 粘贴 TikTok 链接,或拖入 / 上传本地视频</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,41 +1,94 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { useState } from "react"
|
import { useRef, useState } from "react"
|
||||||
import { Link2, Loader2 } from "lucide-react"
|
import { Link2, Loader2, Upload } from "lucide-react"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
onSubmit: (url: string) => void
|
onSubmitUrl: (url: string) => void
|
||||||
|
onUploadFile: (file: File) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UrlInput({ loading, onSubmit }: Props) {
|
export function UrlInput({ loading, onSubmitUrl, onUploadFile }: Props) {
|
||||||
const [url, setUrl] = useState("")
|
const [url, setUrl] = useState("")
|
||||||
|
const [dragOver, setDragOver] = useState(false)
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const pickFile = () => fileRef.current?.click()
|
||||||
|
|
||||||
|
const handleFiles = (files: FileList | null) => {
|
||||||
|
if (!files || files.length === 0) return
|
||||||
|
const f = files[0]
|
||||||
|
if (!f.type.startsWith("video/") && !/\.(mp4|mov|webm|mkv|m4v)$/i.test(f.name)) {
|
||||||
|
// sonner is in page; keep simple here
|
||||||
|
console.warn("not a video file:", f.name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onUploadFile(f)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const trimmed = url.trim()
|
const trimmed = url.trim()
|
||||||
if (trimmed) onSubmit(trimmed)
|
if (trimmed) onSubmitUrl(trimmed)
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!loading) setDragOver(true)
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragOver(false)
|
||||||
|
if (loading) return
|
||||||
|
handleFiles(e.dataTransfer.files)
|
||||||
}}
|
}}
|
||||||
className="relative w-full"
|
className="relative w-full"
|
||||||
>
|
>
|
||||||
<div className="glass-card flex items-center gap-3 px-5 py-4">
|
<div
|
||||||
|
className={`glass-card flex items-center gap-3 px-5 py-4 transition ${
|
||||||
|
dragOver ? "ring-2 ring-white/40 bg-white/[0.08]" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<Link2 className="h-5 w-5 shrink-0 text-white/40" />
|
<Link2 className="h-5 w-5 shrink-0 text-white/40" />
|
||||||
<input
|
<input
|
||||||
value={url}
|
value={url}
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
placeholder="粘贴 TikTok 链接,例如 https://www.tiktok.com/@user/video/..."
|
placeholder={dragOver ? "松开以上传视频" : "粘贴 TikTok 链接,或拖入 / 上传本地视频"}
|
||||||
className="w-full bg-transparent text-white placeholder:text-white/30 outline-none text-[15px]"
|
className="w-full bg-transparent text-white placeholder:text-white/30 outline-none text-[15px]"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
|
{/* 上传按钮 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={pickFile}
|
||||||
|
disabled={loading}
|
||||||
|
className="shrink-0 inline-flex items-center gap-1.5 rounded-md border border-white/15 bg-white/5 hover:bg-white/10 px-3 py-2 text-xs text-white/80 disabled:opacity-40 disabled:cursor-not-allowed transition"
|
||||||
|
title="上传本地视频"
|
||||||
|
>
|
||||||
|
<Upload className="h-3.5 w-3.5" />
|
||||||
|
上传
|
||||||
|
</button>
|
||||||
|
{/* 提交按钮 */}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading || !url.trim()}
|
disabled={loading || !url.trim()}
|
||||||
className="shrink-0 rounded-md bg-white text-black px-4 py-2 text-sm font-medium hover:bg-white/90 disabled:opacity-40 disabled:cursor-not-allowed transition flex items-center gap-2"
|
className="shrink-0 rounded-md bg-white text-black px-4 py-2 text-sm font-medium hover:bg-white/90 disabled:opacity-40 disabled:cursor-not-allowed transition inline-flex items-center gap-2"
|
||||||
>
|
>
|
||||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||||
{loading ? "处理中" : "提交"}
|
{loading ? "处理中" : "提交"}
|
||||||
</button>
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="video/mp4,video/quicktime,video/webm,video/x-matroska,.mp4,.mov,.webm,.mkv,.m4v"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => handleFiles(e.target.files)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-[11px] text-white/30 px-1">
|
||||||
|
支持:TikTok 链接 · 拖入视频文件 · 点击上传 · 格式 mp4 / mov / webm / mkv / m4v
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -48,6 +48,20 @@ export async function createJob(tkUrl: string): Promise<Job> {
|
|||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function uploadJob(file: File): Promise<Job> {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append("file", file)
|
||||||
|
const res = await fetch(`${API_BASE}/jobs/upload`, {
|
||||||
|
method: "POST",
|
||||||
|
body: fd,
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "")
|
||||||
|
throw new Error(`uploadJob ${res.status} ${text.slice(0, 200)}`)
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
export async function getJob(id: string): Promise<Job> {
|
export async function getJob(id: string): Promise<Job> {
|
||||||
const res = await fetch(`${API_BASE}/jobs/${id}`)
|
const res = await fetch(`${API_BASE}/jobs/${id}`)
|
||||||
if (!res.ok) throw new Error(`getJob ${res.status}`)
|
if (!res.ok) throw new Error(`getJob ${res.status}`)
|
||||||
|
|||||||
Reference in New Issue
Block a user