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:
2026-09-01 15:26:22 +03:00
co-authored by Claude Sonnet 5
parent 5fef7460cf
commit f00fc73b0b
17 changed files with 519 additions and 58 deletions
@@ -1,7 +1,6 @@
import { createReadStream, statSync } from "node:fs";
import { Readable } from "node:stream";
import type { NextRequest } from "next/server";
import { prisma } from "@streamclipper/db";
import { streamVideoFile } from "../../../../../lib/videoStream";
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
@@ -9,40 +8,5 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
const segment = await prisma.rawSegment.findUnique({ where: { id } });
if (!segment) return new Response("Not found", { status: 404 });
let size: number;
try {
size = statSync(segment.filePath).size;
} catch {
return new Response("File not found on disk", { status: 404 });
}
const headers: Record<string, string> = {
"Content-Type": "video/mp4",
"Accept-Ranges": "bytes",
};
if (req.nextUrl.searchParams.get("download") === "1") {
headers["Content-Disposition"] = `attachment; filename="${id}.mp4"`;
}
const range = req.headers.get("range");
const match = range ? /bytes=(\d+)-(\d*)/.exec(range) : null;
if (match) {
const start = Number(match[1]);
const end = match[2] ? Number(match[2]) : size - 1;
const stream = createReadStream(segment.filePath, { start, end });
return new Response(Readable.toWeb(stream) as ReadableStream, {
status: 206,
headers: {
...headers,
"Content-Range": `bytes ${start}-${end}/${size}`,
"Content-Length": String(end - start + 1),
},
});
}
const stream = createReadStream(segment.filePath);
return new Response(Readable.toWeb(stream) as ReadableStream, {
headers: { ...headers, "Content-Length": String(size) },
});
return streamVideoFile(req, segment.filePath, `${id}.mp4`);
}
@@ -0,0 +1,12 @@
import type { NextRequest } from "next/server";
import { prisma } from "@streamclipper/db";
import { streamVideoFile } from "../../../../../lib/videoStream";
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const short = await prisma.shortVideo.findUnique({ where: { id } });
if (!short?.filePath) return new Response("Not found", { status: 404 });
return streamVideoFile(req, short.filePath, `${id}.mp4`);
}
+25 -1
View File
@@ -13,6 +13,8 @@ const STATUS_BADGE: Record<string, string> = {
DISCARDED: "err",
PENDING_STT: "warn",
TRANSCRIBED: "ok",
RENDERING: "warn",
READY: "ok",
FAILED: "err",
};
@@ -34,7 +36,7 @@ export default async function ChannelDetailPage({ params }: { params: Promise<{
include: {
segments: {
orderBy: { createdAt: "desc" },
include: { candidates: { orderBy: { createdAt: "desc" } } },
include: { candidates: { orderBy: { createdAt: "desc" }, include: { short: true } } },
},
},
},
@@ -132,6 +134,28 @@ export default async function ChannelDetailPage({ params }: { params: Promise<{
{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>
+25 -1
View File
@@ -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>
+42
View File
@@ -0,0 +1,42 @@
import { createReadStream, statSync } from "node:fs";
import { Readable } from "node:stream";
import type { NextRequest } from "next/server";
export function streamVideoFile(req: NextRequest, filePath: string, downloadName: string): Response {
let size: number;
try {
size = statSync(filePath).size;
} catch {
return new Response("File not found on disk", { status: 404 });
}
const headers: Record<string, string> = {
"Content-Type": "video/mp4",
"Accept-Ranges": "bytes",
};
if (req.nextUrl.searchParams.get("download") === "1") {
headers["Content-Disposition"] = `attachment; filename="${downloadName}"`;
}
const range = req.headers.get("range");
const match = range ? /bytes=(\d+)-(\d*)/.exec(range) : null;
if (match) {
const start = Number(match[1]);
const end = match[2] ? Number(match[2]) : size - 1;
const stream = createReadStream(filePath, { start, end });
return new Response(Readable.toWeb(stream) as ReadableStream, {
status: 206,
headers: {
...headers,
"Content-Range": `bytes ${start}-${end}/${size}`,
"Content-Length": String(end - start + 1),
},
});
}
const stream = createReadStream(filePath);
return new Response(Readable.toWeb(stream) as ReadableStream, {
headers: { ...headers, "Content-Length": String(size) },
});
}