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:
@@ -50,3 +50,8 @@ PROXY_URL=
|
||||
# (DB row + file) — PRD's auto-purge lifecycle. Only segments already fully
|
||||
# analyzed (no candidate still waiting on STT) are eligible.
|
||||
RAW_SEGMENT_TTL_HOURS=24
|
||||
|
||||
# Video render (yüz takibi + 9:16 crop + altyazı) her segment/stt-scoring
|
||||
# işinden çok daha ağır — düşük tutulur ki aynı anda birden fazla render
|
||||
# worker container'ını boğmasın.
|
||||
VIDEO_RENDER_CONCURRENCY=2
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
# StreamClipper AI — Faz 1
|
||||
# StreamClipper AI — Faz 1 + Faz 2 (render)
|
||||
|
||||
`prd.md` içindeki tam kapsamlı otonom pipeline'ın ilk fazı: ingestion (yt-dlp
|
||||
headless capture + 15dk segmentleme), sinyal analizi (ses peak + chat
|
||||
velocity), Whisper STT (OpenAI API, kelime zaman damgalı), BullMQ/Redis/
|
||||
Postgres altyapısı ve durum/transkript gösteren bir Next.js panel. LLM
|
||||
virality skorlama, render/crop/altyazı ve multi-platform dağıtım bu fazda
|
||||
**yok** — bkz. `prd.md` Modül 3 (skorlama kısmı), 4 ve 5.
|
||||
`prd.md` içindeki tam kapsamlı otonom pipeline'ın Faz 1+2'si: ingestion
|
||||
(yt-dlp headless capture + 15dk segmentleme), sinyal analizi (ses peak +
|
||||
chat velocity), Whisper STT (OpenAI API, kelime zaman damgalı), BullMQ/
|
||||
Redis/Postgres altyapısı, auth korumalı bir Next.js panel — ve Faz 2 olarak
|
||||
transkribe edilen her aday klibi otomatik yüz-takipli 9:16'ya crop'layıp
|
||||
dinamik altyazı basan bir render motoru (`apps/worker/worker/video_render.py`).
|
||||
LLM virality skorlama, çoklu-konuşmacı/split-screen şablonu ve
|
||||
multi-platform dağıtım hâlâ **yok** — bkz. `prd.md` Modül 3 (skorlama
|
||||
kısmı) ve Modül 5.
|
||||
|
||||
## Klasör Yapısı
|
||||
|
||||
@@ -77,8 +80,8 @@ Panel: http://localhost:3000 · API daemon health: http://localhost:4001/health
|
||||
Panelin tüm sayfaları giriş gerektirir (middleware ile korunuyor). İlk açılışta
|
||||
henüz hiç kullanıcı yoksa `/login` otomatik olarak "İlk Yönetici Hesabını
|
||||
Oluştur" formunu gösterir — kullanıcı adı/şifre girip ilk admin hesabını
|
||||
oradan oluşturursun. Sonraki kullanıcılar için ayrı bir "kullanıcı ekle"
|
||||
arayüzü henüz yok (Faz 2).
|
||||
oradan oluşturursun. `/users` sayfasından ek kullanıcı ekleyip silebilirsin
|
||||
(kendi hesabını veya son kalan tek kullanıcıyı silmek engellenir).
|
||||
|
||||
`SESSION_SECRET` production'da zorunlu (`openssl rand -base64 32` ile
|
||||
üretilebilir) — local dev'de boş bırakılırsa güvensiz bir varsayılan
|
||||
@@ -94,15 +97,21 @@ kullanılır, sadece test için.
|
||||
kuyruğundan alıp ses peak + (varsa) chat velocity analiziyle
|
||||
`candidate_segments` üretir.
|
||||
4. Her aday, `stt-scoring` kuyruğunda OpenAI Whisper API ile transkribe edilir;
|
||||
sonuç `candidate_segments.transcript_json` alanına yazılır ve Telegram'a
|
||||
bildirim gider.
|
||||
5. Panelde (`/` ve `/segments`) kanal durumu ve üretilen transkriptleri
|
||||
kontrol et.
|
||||
sonuç `candidate_segments.transcript_json` alanına yazılır, Telegram'a
|
||||
bildirim gider ve `video-render` kuyruğuna otomatik iş eklenir.
|
||||
5. `video-render` (Python, `video_render.py`) yüz tespiti + yumuşatılmış
|
||||
dinamik crop ile 9:16'ya çevirir, transkriptten `.ass` altyazı üretip
|
||||
yakar, `/shared-media/shorts/<candidate_id>.mp4`'e yazar;
|
||||
`short_videos.status` `READY`/`FAILED` olur, Telegram'a bildirim gider.
|
||||
6. Panelde (`/`, `/segments`, kanal detay sayfaları) kanal durumu, ham
|
||||
segment/transkript ve render edilen 9:16 kısa videoları izle/indir.
|
||||
|
||||
## Bilinen Sınırlamalar (bu faz kapsamı dışı)
|
||||
|
||||
- LLM virality skorlama, metadata üretimi, telif/görüntü filtresi yok.
|
||||
- 9:16 crop/altyazı render motoru yok.
|
||||
- LLM virality skorlama, metadata üretimi, telif/görüntü filtresi yok —
|
||||
transkribe edilen her aday otomatik render edilir, eşik/filtre yok.
|
||||
- Çoklu konuşmacı / split-screen şablonu yok — sadece tekil konuşmacı
|
||||
(yüz-merkezli dinamik crop).
|
||||
- YouTube/Instagram/TikTok'a otomatik yayınlama yok (bu API'ler ayrı onay
|
||||
süreçleri gerektiriyor — `prd.md` tartışmasına bkz.).
|
||||
- Aday pencere çıkarımı her zaman tek bir 15dk'lık segment dosyasıyla sınırlı;
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) },
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ffmpeg libsndfile1 \
|
||||
ffmpeg libsndfile1 libgl1 libglib2.0-0 curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
@@ -9,5 +9,11 @@ COPY apps/worker/requirements.txt ./apps/worker/requirements.txt
|
||||
RUN pip install --no-cache-dir -r apps/worker/requirements.txt
|
||||
COPY apps/worker ./apps/worker
|
||||
|
||||
# MediaPipe's Tasks API needs the face detector model asset on disk — not
|
||||
# bundled with the pip package, downloaded once at build time instead of on
|
||||
# every worker startup.
|
||||
RUN mkdir -p /app/models && curl -L -o /app/models/blaze_face_short_range.tflite \
|
||||
https://storage.googleapis.com/mediapipe-models/face_detector/blaze_face_short_range/float16/latest/blaze_face_short_range.tflite
|
||||
|
||||
WORKDIR /app/apps/worker
|
||||
CMD ["python", "-m", "worker.main"]
|
||||
|
||||
@@ -6,3 +6,5 @@ openai>=1.50.0
|
||||
httpx>=0.27.0
|
||||
chat-downloader>=0.2.7
|
||||
python-dotenv>=1.0.0
|
||||
opencv-python-headless>=4.10.0
|
||||
mediapipe>=0.10.14
|
||||
|
||||
@@ -93,3 +93,61 @@ async def update_candidate_transcript(candidate_id: str, transcript: dict, statu
|
||||
async def get_active_sessions() -> list[asyncpg.Record]:
|
||||
pool = await get_pool()
|
||||
return await pool.fetch("SELECT id, live_video_id FROM stream_sessions WHERE ended_at IS NULL")
|
||||
|
||||
|
||||
async def get_candidate_for_render(candidate_id: str) -> asyncpg.Record | None:
|
||||
pool = await get_pool()
|
||||
return await pool.fetchrow(
|
||||
"""
|
||||
SELECT cs.id, cs.start_sec, cs.end_sec, cs.transcript_json, rs.file_path, c.name AS channel_name
|
||||
FROM candidate_segments cs
|
||||
JOIN raw_segments rs ON rs.id = cs.raw_segment_id
|
||||
JOIN stream_sessions ss ON ss.id = rs.session_id
|
||||
JOIN channels c ON c.id = ss.channel_id
|
||||
WHERE cs.id = $1
|
||||
""",
|
||||
candidate_id,
|
||||
)
|
||||
|
||||
|
||||
async def upsert_short_video(candidate_id: str, status: str) -> str:
|
||||
pool = await get_pool()
|
||||
existing = await pool.fetchrow(
|
||||
"SELECT id FROM short_videos WHERE candidate_id = $1", candidate_id
|
||||
)
|
||||
if existing:
|
||||
await pool.execute(
|
||||
"UPDATE short_videos SET status = $2, error_message = NULL, updated_at = now() WHERE id = $1",
|
||||
existing["id"],
|
||||
status,
|
||||
)
|
||||
return existing["id"]
|
||||
|
||||
short_id = str(uuid.uuid4())
|
||||
await pool.execute(
|
||||
"""
|
||||
INSERT INTO short_videos (id, candidate_id, status, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, now(), now())
|
||||
""",
|
||||
short_id,
|
||||
candidate_id,
|
||||
status,
|
||||
)
|
||||
return short_id
|
||||
|
||||
|
||||
async def update_short_video(
|
||||
short_id: str, status: str, file_path: str | None = None, error_message: str | None = None
|
||||
) -> None:
|
||||
pool = await get_pool()
|
||||
await pool.execute(
|
||||
"""
|
||||
UPDATE short_videos
|
||||
SET status = $2, file_path = COALESCE($3, file_path), error_message = $4, updated_at = now()
|
||||
WHERE id = $1
|
||||
""",
|
||||
short_id,
|
||||
status,
|
||||
file_path,
|
||||
error_message,
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ from . import db
|
||||
from .chat_capture import start_chat_capture
|
||||
from .signal_detection import start_signal_detection_worker
|
||||
from .stt_scoring import start_stt_scoring_worker
|
||||
from .video_render import start_video_render_worker
|
||||
|
||||
SESSION_WATCH_INTERVAL_SEC = 10
|
||||
|
||||
@@ -22,13 +23,15 @@ async def watch_sessions_for_chat_capture():
|
||||
async def main():
|
||||
signal_worker = start_signal_detection_worker()
|
||||
stt_worker = start_stt_scoring_worker()
|
||||
print("[main] worker started: signal-detection + stt-scoring queues")
|
||||
render_worker = start_video_render_worker()
|
||||
print("[main] worker started: signal-detection + stt-scoring + video-render queues")
|
||||
|
||||
try:
|
||||
await watch_sessions_for_chat_capture()
|
||||
finally:
|
||||
await signal_worker.close()
|
||||
await stt_worker.close()
|
||||
await render_worker.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -21,3 +21,8 @@ SHARED_MEDIA_ROOT = os.environ.get("SHARED_MEDIA_ROOT", "./shared-media")
|
||||
# producing segments/candidates around the same time would otherwise process
|
||||
# strictly one at a time and build up a backlog.
|
||||
WORKER_CONCURRENCY = int(os.environ.get("WORKER_CONCURRENCY", "5"))
|
||||
|
||||
# Video rendering (face detection across sampled frames + two ffmpeg encodes)
|
||||
# is far heavier per job than signal-detection/stt-scoring — kept separate
|
||||
# and lower by default so several renders at once don't starve the container.
|
||||
VIDEO_RENDER_CONCURRENCY = int(os.environ.get("VIDEO_RENDER_CONCURRENCY", "2"))
|
||||
|
||||
@@ -3,7 +3,7 @@ import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from bullmq import Worker
|
||||
from bullmq import Queue, Worker
|
||||
from openai import OpenAI
|
||||
|
||||
from . import db
|
||||
@@ -11,6 +11,7 @@ from .queues import QUEUE_NAMES, REDIS_URL, WORKER_CONCURRENCY
|
||||
from .telegram import send_telegram_message
|
||||
|
||||
_openai_client: OpenAI | None = None
|
||||
video_render_queue = Queue(QUEUE_NAMES["VIDEO_RENDER"], {"connection": REDIS_URL})
|
||||
|
||||
|
||||
def _client() -> OpenAI:
|
||||
@@ -67,6 +68,8 @@ async def process_stt_scoring(job, job_token=None):
|
||||
f"({candidate['start_sec']}-{candidate['end_sec']}s):\n_{preview}_"
|
||||
)
|
||||
|
||||
await video_render_queue.add("render-short", {"candidateSegmentId": candidate_id})
|
||||
|
||||
print(f"[stt-scoring] candidate {candidate_id} transcribed")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
"""
|
||||
9:16 crop (yüz takipli) + dinamik altyazı render motoru.
|
||||
|
||||
İki geçişli işleme: (1) OpenCV ile örneklenmiş karelerde MediaPipe yüz
|
||||
tespiti yapıp bir merkez-yol haritası çıkarır, yumuşatır; (2) OpenCV ile
|
||||
kareleri tekrar okuyup o an için hesaplanan crop penceresini uygulayıp
|
||||
ffmpeg'e pipe'lar — aynı ffmpeg çağrısı orijinal klibin sesini de
|
||||
map'leyip .ass altyazısını yakar. Hiç yüz bulunamazsa sabit merkez crop'a
|
||||
düşer (hata değil, sadece daha az "akıllı" bir sonuç).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from bullmq import Worker
|
||||
from mediapipe.tasks.python import BaseOptions
|
||||
from mediapipe.tasks.python.vision import FaceDetector, FaceDetectorOptions
|
||||
import mediapipe as mp
|
||||
|
||||
from . import db
|
||||
from .queues import QUEUE_NAMES, REDIS_URL, SHARED_MEDIA_ROOT, VIDEO_RENDER_CONCURRENCY
|
||||
from .telegram import send_telegram_message
|
||||
|
||||
FACE_MODEL_PATH = os.environ.get(
|
||||
"FACE_DETECTOR_MODEL_PATH", "/app/models/blaze_face_short_range.tflite"
|
||||
)
|
||||
SAMPLE_INTERVAL_SEC = 0.5
|
||||
OUTPUT_WIDTH = 1080
|
||||
OUTPUT_HEIGHT = 1920
|
||||
|
||||
_detector: FaceDetector | None = None
|
||||
|
||||
|
||||
def _get_detector() -> FaceDetector:
|
||||
global _detector
|
||||
if _detector is None:
|
||||
options = FaceDetectorOptions(base_options=BaseOptions(model_asset_path=FACE_MODEL_PATH))
|
||||
_detector = FaceDetector.create_from_options(options)
|
||||
return _detector
|
||||
|
||||
|
||||
def _detect_face_centers(video_path: str) -> tuple[list[tuple[float, float]], int, int, float]:
|
||||
"""Örneklenmiş karelerde en büyük yüzün merkezini (x_frac, y_frac) döner."""
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
|
||||
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
frame_interval = max(1, int(fps * SAMPLE_INTERVAL_SEC))
|
||||
|
||||
detector = _get_detector()
|
||||
points: list[tuple[float, float]] = [] # (time_sec, x_frac)
|
||||
frame_idx = 0
|
||||
|
||||
while True:
|
||||
ok, frame = cap.read()
|
||||
if not ok:
|
||||
break
|
||||
if frame_idx % frame_interval == 0:
|
||||
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb)
|
||||
result = detector.detect(mp_image)
|
||||
if result.detections:
|
||||
best = max(
|
||||
result.detections,
|
||||
key=lambda d: d.bounding_box.width * d.bounding_box.height,
|
||||
)
|
||||
bbox = best.bounding_box
|
||||
cx = (bbox.origin_x + bbox.width / 2) / width
|
||||
points.append((frame_idx / fps, cx))
|
||||
frame_idx += 1
|
||||
|
||||
cap.release()
|
||||
return points, width, height, fps
|
||||
|
||||
|
||||
def _smooth_x_path(points: list[tuple[float, float]], total_frames: int, fps: float) -> np.ndarray | None:
|
||||
"""Kare başına yumuşatılmış crop merkezi (x_frac). Hiç yüz yoksa None (sabit merkez crop kullanılır)."""
|
||||
if not points or total_frames <= 0:
|
||||
return None
|
||||
|
||||
times = np.array([p[0] for p in points])
|
||||
xs = np.array([p[1] for p in points])
|
||||
|
||||
frame_times = np.arange(total_frames) / fps
|
||||
interp_x = np.interp(frame_times, times, xs)
|
||||
|
||||
window = min(max(1, int(fps)), len(interp_x))
|
||||
kernel = np.ones(window) / window
|
||||
return np.convolve(interp_x, kernel, mode="same")
|
||||
|
||||
|
||||
def _seconds_to_ass_time(t: float) -> str:
|
||||
t = max(0.0, t)
|
||||
h = int(t // 3600)
|
||||
m = int((t % 3600) // 60)
|
||||
s = t % 60
|
||||
return f"{h:d}:{m:02d}:{s:05.2f}"
|
||||
|
||||
|
||||
def _build_ass_subtitles(transcript: dict, path: str, words_per_caption: int = 3) -> None:
|
||||
words = transcript.get("words") or []
|
||||
header = (
|
||||
"[Script Info]\n"
|
||||
"ScriptType: v4.00+\n"
|
||||
f"PlayResX: {OUTPUT_WIDTH}\n"
|
||||
f"PlayResY: {OUTPUT_HEIGHT}\n\n"
|
||||
"[V4+ Styles]\n"
|
||||
"Format: Name, Fontname, Fontsize, PrimaryColour, OutlineColour, BackColour, Bold, "
|
||||
"BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV\n"
|
||||
"Style: Default,Arial,64,&H00FFFFFF,&H00000000,&H80000000,1,1,3,2,2,60,60,300\n\n"
|
||||
"[Events]\n"
|
||||
"Format: Layer, Start, End, Style, Text\n"
|
||||
)
|
||||
|
||||
lines = [header]
|
||||
for i in range(0, len(words), words_per_caption):
|
||||
chunk = words[i : i + words_per_caption]
|
||||
if not chunk:
|
||||
continue
|
||||
start = chunk[0].get("start", 0.0)
|
||||
end = chunk[-1].get("end", start)
|
||||
text = " ".join(str(w.get("word", "")).strip() for w in chunk).strip()
|
||||
if not text:
|
||||
continue
|
||||
lines.append(f"Dialogue: 0,{_seconds_to_ass_time(start)},{_seconds_to_ass_time(end)},Default,{text}\n")
|
||||
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.writelines(lines)
|
||||
|
||||
|
||||
def _render_final(
|
||||
clip_path: str,
|
||||
smoothed_x: np.ndarray | None,
|
||||
width: int,
|
||||
height: int,
|
||||
fps: float,
|
||||
ass_path: str,
|
||||
out_path: str,
|
||||
) -> None:
|
||||
crop_w = min(width, max(2, int(height * OUTPUT_WIDTH / OUTPUT_HEIGHT) // 2 * 2))
|
||||
|
||||
ffmpeg = subprocess.Popen(
|
||||
[
|
||||
"ffmpeg", "-y",
|
||||
"-f", "rawvideo", "-pix_fmt", "bgr24",
|
||||
"-s", f"{OUTPUT_WIDTH}x{OUTPUT_HEIGHT}",
|
||||
"-r", str(fps),
|
||||
"-i", "-",
|
||||
"-i", clip_path,
|
||||
"-vf", f"ass={ass_path}",
|
||||
"-map", "0:v:0", "-map", "1:a:0?",
|
||||
"-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p",
|
||||
"-c:a", "aac",
|
||||
"-shortest",
|
||||
out_path,
|
||||
],
|
||||
stdin=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
cap = cv2.VideoCapture(clip_path)
|
||||
frame_idx = 0
|
||||
try:
|
||||
while True:
|
||||
ok, frame = cap.read()
|
||||
if not ok:
|
||||
break
|
||||
|
||||
cx_frac = 0.5
|
||||
if smoothed_x is not None and frame_idx < len(smoothed_x):
|
||||
cx_frac = float(smoothed_x[frame_idx])
|
||||
|
||||
cx_px = int(cx_frac * width)
|
||||
x0 = max(0, min(width - crop_w, cx_px - crop_w // 2))
|
||||
cropped = frame[0:height, x0 : x0 + crop_w]
|
||||
resized = cv2.resize(cropped, (OUTPUT_WIDTH, OUTPUT_HEIGHT))
|
||||
|
||||
assert ffmpeg.stdin is not None
|
||||
ffmpeg.stdin.write(resized.tobytes())
|
||||
frame_idx += 1
|
||||
finally:
|
||||
cap.release()
|
||||
if ffmpeg.stdin:
|
||||
ffmpeg.stdin.close()
|
||||
ffmpeg.wait()
|
||||
|
||||
|
||||
def _render_sync(file_path: str, start_sec: int, end_sec: int, transcript: dict, out_path: str) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
clip_path = os.path.join(tmp_dir, "clip.mp4")
|
||||
subprocess.run(
|
||||
[
|
||||
"ffmpeg", "-y",
|
||||
"-ss", str(start_sec),
|
||||
"-to", str(end_sec),
|
||||
"-i", file_path,
|
||||
"-c", "copy",
|
||||
clip_path,
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
points, width, height, fps = _detect_face_centers(clip_path)
|
||||
total_frames = int(cv2.VideoCapture(clip_path).get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
smoothed = _smooth_x_path(points, total_frames, fps)
|
||||
|
||||
ass_path = os.path.join(tmp_dir, "subs.ass")
|
||||
_build_ass_subtitles(transcript, ass_path)
|
||||
|
||||
_render_final(clip_path, smoothed, width, height, fps, ass_path, out_path)
|
||||
|
||||
|
||||
async def process_video_render(job, job_token=None):
|
||||
candidate_id = job.data["candidateSegmentId"]
|
||||
candidate = await db.get_candidate_for_render(candidate_id)
|
||||
if candidate is None:
|
||||
print(f"[video-render] candidate {candidate_id} not found, skipping")
|
||||
return
|
||||
|
||||
short_id = await db.upsert_short_video(candidate_id, status="RENDERING")
|
||||
|
||||
out_dir = Path(SHARED_MEDIA_ROOT) / "shorts"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_path = str(out_dir / f"{candidate_id}.mp4")
|
||||
|
||||
try:
|
||||
transcript = candidate["transcript_json"] or {}
|
||||
if isinstance(transcript, str):
|
||||
transcript = json.loads(transcript)
|
||||
|
||||
await asyncio.to_thread(
|
||||
_render_sync,
|
||||
candidate["file_path"],
|
||||
candidate["start_sec"],
|
||||
candidate["end_sec"],
|
||||
transcript,
|
||||
out_path,
|
||||
)
|
||||
|
||||
await db.update_short_video(short_id, status="READY", file_path=out_path)
|
||||
await send_telegram_message(f"🎬 *{candidate['channel_name']}* için 9:16 kısa video hazır!")
|
||||
print(f"[video-render] short {short_id} ready: {out_path}")
|
||||
except Exception as exc:
|
||||
print(f"[video-render] failed for candidate {candidate_id}: {exc}")
|
||||
await db.update_short_video(short_id, status="FAILED", error_message=str(exc)[:500])
|
||||
|
||||
|
||||
def start_video_render_worker() -> Worker:
|
||||
return Worker(
|
||||
QUEUE_NAMES["VIDEO_RENDER"],
|
||||
process_video_render,
|
||||
{"connection": REDIS_URL, "concurrency": VIDEO_RENDER_CONCURRENCY},
|
||||
)
|
||||
@@ -65,6 +65,7 @@ services:
|
||||
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
|
||||
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
|
||||
WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-5}
|
||||
VIDEO_RENDER_CONCURRENCY: ${VIDEO_RENDER_CONCURRENCY:-2}
|
||||
volumes:
|
||||
- shared-media:/shared-media
|
||||
depends_on:
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ShortVideoStatus" AS ENUM ('PENDING', 'RENDERING', 'READY', 'FAILED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "short_videos" (
|
||||
"id" TEXT NOT NULL,
|
||||
"candidate_id" TEXT NOT NULL,
|
||||
"file_path" TEXT,
|
||||
"status" "ShortVideoStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"error_message" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "short_videos_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "short_videos_candidate_id_key" ON "short_videos"("candidate_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "short_videos" ADD CONSTRAINT "short_videos_candidate_id_fkey" FOREIGN KEY ("candidate_id") REFERENCES "candidate_segments"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -19,6 +19,13 @@ enum CandidateSegmentStatus {
|
||||
FAILED
|
||||
}
|
||||
|
||||
enum ShortVideoStatus {
|
||||
PENDING
|
||||
RENDERING
|
||||
READY
|
||||
FAILED
|
||||
}
|
||||
|
||||
model Channel {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
@@ -74,9 +81,24 @@ model CandidateSegment {
|
||||
status CandidateSegmentStatus @default(PENDING_STT)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
short ShortVideo?
|
||||
|
||||
@@map("candidate_segments")
|
||||
}
|
||||
|
||||
model ShortVideo {
|
||||
id String @id @default(cuid())
|
||||
candidateId String @unique @map("candidate_id")
|
||||
candidate CandidateSegment @relation(fields: [candidateId], references: [id])
|
||||
filePath String? @map("file_path")
|
||||
status ShortVideoStatus @default(PENDING)
|
||||
errorMessage String? @map("error_message")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("short_videos")
|
||||
}
|
||||
|
||||
model AppSetting {
|
||||
key String @id
|
||||
value String
|
||||
|
||||
Reference in New Issue
Block a user