Files
screenclipper/apps/frontend/app/segments/page.tsx
T
ayrisdevandClaude Sonnet 5 f00fc73b0b 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>
2026-09-01 15:26:22 +03:00

110 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { prisma } from "@streamclipper/db";
import { deleteRawSegment, deleteCandidateSegment } from "../actions";
import { DeleteButton } from "../components/DeleteButton";
export const dynamic = "force-dynamic";
const STATUS_BADGE: Record<string, string> = {
PENDING: "muted",
PROCESSED: "ok",
DISCARDED: "err",
PENDING_STT: "warn",
TRANSCRIBED: "ok",
RENDERING: "warn",
READY: "ok",
FAILED: "err",
};
function transcriptPreview(transcriptJson: unknown): string | null {
if (!transcriptJson || typeof transcriptJson !== "object") return null;
const text = (transcriptJson as { text?: string }).text;
return text ? text.slice(0, 240) : null;
}
export default async function SegmentsPage() {
const segments = await prisma.rawSegment.findMany({
orderBy: { createdAt: "desc" },
take: 50,
include: { candidates: { orderBy: { createdAt: "desc" }, include: { short: true } } },
});
return (
<>
<h1>Segment & Aday Klip Kütüphanesi</h1>
{segments.length === 0 ? (
<p className="empty">Henüz işlenmiş segment yok.</p>
) : (
segments.map((segment) => (
<div className="card" key={segment.id}>
<div className="card-head">
<span className="mono">{segment.filePath}</span>
<span className={`badge ${STATUS_BADGE[segment.status] ?? "muted"}`}>{segment.status}</span>
</div>
<div className="mono">
{segment.duration}sn · {new Date(segment.startedAt).toLocaleString("tr-TR")}
</div>
<video controls preload="metadata" style={{ width: "100%", maxWidth: "480px", marginTop: "0.5rem", borderRadius: "6px" }}>
<source src={`/api/segments/${segment.id}/video`} type="video/mp4" />
</video>
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", marginTop: "0.5rem" }}>
<a className="badge muted" href={`/api/segments/${segment.id}/video?download=1`}>İndir</a>
<form action={deleteRawSegment.bind(null, segment.id)}>
<DeleteButton confirmText="Bu segmenti ve içindeki tüm aday klipleri silmek istediğine emin misin? Geri alınamaz." />
</form>
</div>
{segment.candidates.length === 0 ? (
<p className="empty" style={{ marginTop: "0.5rem" }}>
Bu segmentte aday klip bulunamadı.
</p>
) : (
segment.candidates.map((c) => (
<div key={c.id} style={{ marginTop: "0.75rem", paddingLeft: "0.75rem", borderLeft: "2px solid var(--border)" }}>
<div className="card-head">
<span className="mono">
{c.startSec}s {c.endSec}s
{c.audioPeakScore != null && ` · peak ${c.audioPeakScore.toFixed(1)}dB`}
{c.chatVelocityScore != null && " · chat spike"}
</span>
<span className={`badge ${STATUS_BADGE[c.status] ?? "muted"}`}>{c.status}</span>
</div>
{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>
</div>
))
)}
</div>
))
)}
</>
);
}