auto-save 2026-05-12 15:57 (~5)

This commit is contained in:
2026-05-12 15:57:18 +08:00
parent 2e45ad9d16
commit 064083ef0a
5 changed files with 166 additions and 33 deletions

View File

@@ -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
} }
] ]
} }

View File

@@ -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)

View File

@@ -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>
)} )}

View File

@@ -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>
) )

View File

@@ -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}`)