feat: 9:16 crop + dinamik altyazı render motoru (Faz 2, Modül 4)
Her aday klip transkribe olduktan hemen sonra otomatik olarak video-render kuyruğuna giriyor. Yeni Python worker (video_render.py): - Klibi ffmpeg ile keser - OpenCV + MediaPipe Face Detector (Tasks API — yeni mediapipe sürümlerinde eski solutions API'si kalktı, .tflite model dosyası Docker build'de indiriliyor) ile örneklenmiş karelerde yüz merkezi tespit eder, yumuşatır (moving average); hiç yüz bulunamazsa sabit merkez crop'a düşer, hata saymaz - Kırpılmış kareleri OpenCV'den ffmpeg'e pipe'layıp tek geçişte orijinal sesi map'ler ve transcript_json'dan ürettiği .ass altyazıyı yakar - Sonucu /shared-media/shorts/<candidate_id>.mp4'e yazar Yeni ShortVideo modeli (PENDING/RENDERING/READY/FAILED). Panelde (segments + kanal detay sayfaları) render durumu + READY olunca video önizleme/indirme. VIDEO_RENDER_CONCURRENCY ayrı ve düşük (varsayılan 2) — bu iş diğerlerinden çok daha ağır. Kapsam: sadece tekil konuşmacı şablonu (çoklu konuşmacı/split-screen sonraki bir iterasyona bırakıldı — aktif-konuşmacı tespiti gerektiriyor). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,8 @@ const STATUS_BADGE: Record<string, string> = {
|
||||
DISCARDED: "err",
|
||||
PENDING_STT: "warn",
|
||||
TRANSCRIBED: "ok",
|
||||
RENDERING: "warn",
|
||||
READY: "ok",
|
||||
FAILED: "err",
|
||||
};
|
||||
|
||||
@@ -23,7 +25,7 @@ export default async function SegmentsPage() {
|
||||
const segments = await prisma.rawSegment.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 50,
|
||||
include: { candidates: { orderBy: { createdAt: "desc" } } },
|
||||
include: { candidates: { orderBy: { createdAt: "desc" }, include: { short: true } } },
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -71,6 +73,28 @@ export default async function SegmentsPage() {
|
||||
{transcriptPreview(c.transcriptJson) && (
|
||||
<p className="transcript-preview">{transcriptPreview(c.transcriptJson)}</p>
|
||||
)}
|
||||
|
||||
{c.short && (
|
||||
<div style={{ marginTop: "0.5rem" }}>
|
||||
<span className={`badge ${STATUS_BADGE[c.short.status] ?? "muted"}`}>
|
||||
9:16: {c.short.status}
|
||||
</span>
|
||||
{c.short.status === "READY" && (
|
||||
<>
|
||||
<video controls preload="metadata" style={{ width: "220px", marginTop: "0.4rem", borderRadius: "6px", display: "block" }}>
|
||||
<source src={`/api/shorts/${c.short.id}/video`} type="video/mp4" />
|
||||
</video>
|
||||
<a className="badge muted" style={{ marginTop: "0.3rem", display: "inline-block" }} href={`/api/shorts/${c.short.id}/video?download=1`}>
|
||||
İndir
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{c.short.status === "FAILED" && c.short.errorMessage && (
|
||||
<p className="mono" style={{ fontSize: "0.75rem", marginTop: "0.3rem" }}>{c.short.errorMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form action={deleteCandidateSegment.bind(null, c.id)} style={{ marginTop: "0.35rem" }}>
|
||||
<DeleteButton confirmText="Bu aday klibi silmek istediğine emin misin?" />
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user