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)",
|
||||
"hash": "bbd41fa",
|
||||
"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 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.responses import FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -106,7 +106,9 @@ app.add_middleware(
|
||||
def run(cmd: list[str], cwd: Path | None = None) -> str:
|
||||
res = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
|
||||
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
|
||||
|
||||
|
||||
@@ -122,18 +124,21 @@ async def pipeline_download_split_frames(job_id: str) -> None:
|
||||
job = JOBS[job_id]
|
||||
d = job_dir(job_id)
|
||||
try:
|
||||
# ---- 1. yt-dlp 下载
|
||||
update(job, status="downloading", message="yt-dlp 下载中…", progress=5)
|
||||
mp4 = d / "source.mp4"
|
||||
run([
|
||||
"yt-dlp", "-f", "best[ext=mp4]/best",
|
||||
"-o", str(mp4),
|
||||
"--no-warnings", "--no-playlist",
|
||||
"--retries", "3",
|
||||
job.url,
|
||||
])
|
||||
if not mp4.exists():
|
||||
raise RuntimeError("下载完成但找不到 source.mp4")
|
||||
# ---- 1. yt-dlp 下载(上传模式 mp4 已存在 → 跳过)
|
||||
if mp4.exists():
|
||||
update(job, status="downloading", message="本地上传,跳过下载", progress=15)
|
||||
else:
|
||||
update(job, status="downloading", message="yt-dlp 下载中…", progress=5)
|
||||
run([
|
||||
"yt-dlp", "-f", "best[ext=mp4]/best",
|
||||
"-o", str(mp4),
|
||||
"--no-warnings", "--no-playlist",
|
||||
"--retries", "3",
|
||||
job.url,
|
||||
])
|
||||
if not mp4.exists():
|
||||
raise RuntimeError("下载完成但找不到 source.mp4")
|
||||
|
||||
# 元数据
|
||||
meta = ffprobe_meta(mp4)
|
||||
@@ -165,14 +170,20 @@ async def pipeline_download_split_frames(job_id: str) -> None:
|
||||
shutil.rmtree(frames_dir)
|
||||
frames_dir.mkdir(parents=True)
|
||||
|
||||
# 先用场景切换检测
|
||||
run([
|
||||
"ffmpeg", "-y", "-i", str(mp4),
|
||||
"-vf", "select='gt(scene,0.4)',showinfo",
|
||||
"-vsync", "vfr",
|
||||
"-frames:v", "30",
|
||||
str(frames_dir / "scene_%03d.jpg"),
|
||||
])
|
||||
# 先用场景切换检测(失败时不阻塞,走均匀采样兜底)
|
||||
try:
|
||||
run([
|
||||
"ffmpeg", "-y", "-i", str(mp4),
|
||||
"-vf", "select='gt(scene,0.4)'",
|
||||
"-fps_mode", "vfr",
|
||||
"-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"))
|
||||
|
||||
# 均匀采样兜底 / 补足
|
||||
@@ -184,7 +195,9 @@ async def pipeline_download_split_frames(job_id: str) -> None:
|
||||
out = frames_dir / f"sample_{i:03d}.jpg"
|
||||
run([
|
||||
"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 张
|
||||
@@ -299,6 +312,32 @@ async def create_job(req: CreateJobReq, bg: BackgroundTasks) -> 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)
|
||||
def get_job(job_id: str) -> Job:
|
||||
job = JOBS.get(job_id)
|
||||
|
||||
@@ -5,7 +5,7 @@ import { UrlInput } from "@/components/url-input"
|
||||
import { JobStatusBar } from "@/components/job-status"
|
||||
import { KeyframeGallery } from "@/components/keyframe-gallery"
|
||||
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() {
|
||||
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 状态
|
||||
useEffect(() => {
|
||||
if (!job) return
|
||||
@@ -93,7 +109,11 @@ export default function Home() {
|
||||
|
||||
{/* URL 输入 */}
|
||||
<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>
|
||||
|
||||
{job && (
|
||||
@@ -146,7 +166,7 @@ export default function Home() {
|
||||
|
||||
{!job && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,41 +1,94 @@
|
||||
"use client"
|
||||
import { useState } from "react"
|
||||
import { Link2, Loader2 } from "lucide-react"
|
||||
import { useRef, useState } from "react"
|
||||
import { Link2, Loader2, Upload } from "lucide-react"
|
||||
|
||||
interface Props {
|
||||
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 [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 (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
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"
|
||||
>
|
||||
<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" />
|
||||
<input
|
||||
value={url}
|
||||
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]"
|
||||
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
|
||||
type="submit"
|
||||
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 ? "处理中" : "提交"}
|
||||
</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>
|
||||
</form>
|
||||
)
|
||||
|
||||
@@ -48,6 +48,20 @@ export async function createJob(tkUrl: string): Promise<Job> {
|
||||
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> {
|
||||
const res = await fetch(`${API_BASE}/jobs/${id}`)
|
||||
if (!res.ok) throw new Error(`getJob ${res.status}`)
|
||||
|
||||
Reference in New Issue
Block a user