DEV Community

Guray
Guray

Posted on

Dokuz sanal sunucu, üç platform, bir kota duvarı: karakter videosu hattını kurmak (Bölüm 2)

Birinci bölümde bir haber sitesinin yayın akışını ajana devrettiğimi yazmıştım. O yazıdan sonra sistemin en kırılgan yerini kurdum: sosyal medyaya konuşan sanal sunucular. Dokuz kategorinin dokuz karakteri var, her biri kendi videosuyla kendi bölümünü tanıtıyor.

Bu yazı o hattın kurulum günlüğü. İçinde çalışan kod da var, çöpe giden yedi deneme de.

Neden karakter?

Statik bir yazı linkini X'e atınca ölçüm net: kart önizlemesi görünür, kimse durmaz. Dikey videoda konuşan bir insan varsa akış duruyor. Elimde gerçek sunucu yok, o yüzden karakterleri üretiyoruz: Elif (bilim, psikoloji), Arda (oyun), Doruk (doğa ve kamp), Dr. Sinan (tıp), Defne (kitap), Süreyya (tarot), Meriç (dünya basını), Elvan (arkeoloji), Duru (güzellik).

Kural basit ve sabit: kategori → karakter eşlemesi değişmez. Aynı etiket her zaman aynı yüz ve aynı sesle geliyor. Takipçi ikinci videoda karakteri tanıyor.

Üretim hattı şöyle:

konu seçimi → yazı yayını → başlangıç karesi (t2i)
   → konuşma metni (4 kısa cümle)
   → i2v video (12 sn, ses dahil)
   → Whisper doğrulama (eşik 0,80)
   → kafa1milyon.com etiketi (ffmpeg drawtext)
   → X + Instagram + YouTube kuyruğu
Enter fullscreen mode Exit fullscreen mode

Kritik yer dördüncü satır. Onu anlatayım.

Telaffuz savaşı: modelin metni "düzeltmesi"

Video modeline Türkçe bir cümle verip "bunu oku" dediğinizde, model okumakla kalmıyor. Metni kendi kendine yeniden yazıyor.

Bir inek videosu altı kez çöpe gitti. Model "bilim insanları ile birlikte de bilim insanları" diye kelimeyi tekrarladı. Tıp videosunda "insülin" kelimesini "insülün" diye söyledi ve cümleyi kendi kendine "Tip 1 diyabette beta hücreleri..." diye temkinli bilim diline çevirdi. Bir başkasında "eureka" kelimesi "ürika" oldu.

Yedi denemeden sonra kural dosyasına şunlar girdi:

  • Konuşma metni en fazla 4 cümle, cümle başına 4-7 kelime.
  • Yabancı kökenli ve teknik kelime yok. "İnsülin" yerine "şekeri ayarlayan hücreler".
  • İddialı cümle yok. Model abartıyı düzeltmeye çalışıp metni bozuyor; cümleyi baştan dürüst kurmak gerekiyor.
  • Prompt'a "do not reword or rephrase anything, read the text exactly as written", "no stutter, no repeated words" gibi maddeler eklendi.
  • Süre 10 saniye değil 12 saniye seçiliyor. Dört cümle 10 saniyeye sıkışınca kelimeler yutuluyor.

En ilginç maddeyi en sona bıraktım. Bir kamp videosunda "İstanbul'a yakın bir yayla var" cümlesindeki "yayla" kelimesi 0,26 saniyeye sıkıştı ve "aile" gibi duyuldu. Anahtar kelime cümlenin başına gelince model üzerinden hızlı geçiyor. Kural: konunun anahtar kelimesi cümlenin ortasına, çevresinde nefes olacak şekilde konur. "Bu yayla İstanbul'a çok yakın" gibi.

Doğrulama kapısı: kulakla değil olasılıkla

"Kulağıma tuhaf geldi" ölçüt değil. Her klip üretimden sonra transkripsiyona giriyor ve kelime bazlı olasılıklara bakıyoruz:

from faster_whisper import WhisperModel

model = WhisperModel("large-v3", device="cpu", compute_type="int8")
segments, _ = model.transcribe(ses, language="tr", word_timestamps=True)

for s in segments:
    for w in s.words:
        if w.probability < 0.80:
            print(f"ZAYIF  {w.word!r}  p={w.probability:.2f}  "
                  f"{w.start:.2f}-{w.end:.2f} sn")
Enter fullscreen mode Exit fullscreen mode

Bir kelimenin olasılığı 0,80'in altındaysa klip reddediliyor ve metin yeniden yazılıyor. small ve medium modeller bu işte yanlış alarm veriyor: b/d gibi sesleri karıştırıp temiz klipleri suçluyorlar. Sadece large-v3 kullanılıyor.

Kapı bir kez de "yanlış" çalıştı: "Dr." kısaltması 0,76 döndü. Sesi tek tek dinlettiğimde model gerçekten "Doktor Sinan" diyordu, sorun kısaltmanın kendisiydi. Bu tür durumlar için tek istisna kuralı var, ama istisna gerekçesiz yazılmıyor.

Etiket: köşeyi neden değiştirmiyoruz

Her klibe küçük bir kafa1milyon.com yazısı basılıyor:

ffmpeg -y -i "$GIRDI" -vf "drawtext=fontfile=$FONT:\
text='kafa1milyon.com':\
fontcolor=white@0.78:fontsize=h/40:\
shadowcolor=black@0.45:shadowx=2:shadowy=2:\
x=w*0.035:y=h*0.030" \
  -c:v libx264 -preset medium -crf 19 -c:a copy "$CIKTI"
Enter fullscreen mode Exit fullscreen mode

Punto sabit değil, h/40. Konum sol üst ve tartışmaya kapalı: dikey formatta Reels ve Shorts arayüzünün kapatmadığı tek güvenli alan orası. Alt kısmı açıklama ve butonlar yiyor, sağ şeridi etkileşim ikonları kaplıyor.

Tıklanabilir link: platform başına ayrı duvar

Üç platform, üç ayrı problem.

X: intent URL ile metin ve link doldurulabiliyor. Videolu postta link ana posta konmuyor, ilk yanıta gidiyor.

Instagram: gönderi açıklamasındaki adres tıklanamaz, nokta. Çözüm hikâyedeki link etiketi:

from instagrapi.types import StoryLink

baglanti = StoryLink(webUri=url, x=0.5, y=0.82, width=0.7, height=0.09)
cl.video_upload_to_story(video, links=[baglanti])
Enter fullscreen mode Exit fullscreen mode

Reel paylaşıldıktan hemen sonra aynı video, link etiketiyle hikâyeye de düşüyor. Ayrıca profil biosuna koyulacak bir /link/ sayfası üretiliyor ve saatlik bir timer onu güncelliyor.

YouTube: açıklamadaki linkler tıklanabilir olsun diye kanalın telefon doğrulaması gerekiyor. Kod yazarak çözülecek bir şey değil; doğrulama tamamlanana kadar açıklamada okuru kanal profilindeki bağlantıya yönlendiriyoruz.

Üç platformda tek bir "link koy" fonksiyonu yazamamak, bu işin en öğretici tarafıydı.

Kota arkeolojisi: ücretsiz katmanı tersine mühendislik

Seslendirme tarafında ticari kotam bitti ve arşivde 76 sessiz yazı birikti. Ücretsiz TTS kotasının nasıl davrandığını ölçerek çıkarmak zorunda kaldım.

Önce 429 gövdesini okuyup gerçek metriği buldum:

generate_content_free_tier_requests
GenerateRequestsPerDayPerProjectPerModel-FreeTier    deger: 10
Enter fullscreen mode Exit fullscreen mode

Cümlenin içinde üç kelime var ve üçü de önemli: PerProject, PerModel, PerDay. Yani limit hesap başına değil, proje ve model başına. İki projeyle 20 istek.

Buradan üç hamle çıktı:

  1. Parça boyu. Modelin çıktı sınırı 16.384 token, saniyede 32 token, yani ~8,3 dakika ses. Türkçe'de bu kabaca 6.500 karakter. Parçalar 3.500 karakterde kesiliyordu; 5.800'e çıkardım. Çoğu yazı artık iki istek yerine tek istekte bitiyor. 9.400 karakteri tek istekte denemek ise sessiz başarısızlıkla dönüyor: yanıt geliyor ama içinde ses parçası olmuyor.

  2. Model havuzu. Kota model başına olduğu için model listesini taradım ve batchGenerateContent destekleyen yeni bir TTS modeli buldum. Ayrı kova, taze kota. Kısa cümlede kusursuz çalıştı, 900 karakter ve üstünde ise ne unary ne streaming uçtan yanıt döndü; beş dakika askıda kalıp zaman aşımına düştü. Preview modellerin bedeli bu. Havuzdan çıkardım ama gerekçesini koda yazdım:

# gemini-3.1-flash-tts-preview ayri bir kota kovasi ve cazip gorunuyor ama
# 14 Agu 2026'da denendi: 50 karakterlik cumlede calisiyor, 900 karakter ve
# ustunde ne unary ne streaming ucta yanit donuyor. Preview duzelirse eklenir.
MODELLER = ["gemini-2.5-flash-preview-tts"]
Enter fullscreen mode Exit fullscreen mode
  1. Toplu iş. Batch API bu modelde destekli görünüyor, çağrı ise FAILED_PRECONDITION veriyor: ücretsiz katmanda kapalı.

En faydalı bulgu tesadüfen çıktı. Akşam kotayı bitirmiş bir anahtar, gece yarısından sonra tek bir istek kabul etti, sonra yine kapandı. Demek ki günlük sayaç gece bir kerede sıfırlanmıyor, kayan pencerede eski istekler düştükçe slot boşalıyor. Bunun üzerine "günde iki tur" olan zamanlayıcıyı iki saate indirdim:

OnCalendar=*-*-* 00/2:10:00
Enter fullscreen mode Exit fullscreen mode

Kota kuruysa betik saniyeler içinde ve temiz dönüyor. Boşalan her slot aynı gün kullanılıyor.

Bir de hata kodu ayrımı ekledim. Eskiden herhangi bir başarısızlık bütün turu kesiyordu:

KOD=$?
if [ "$KOD" = "3" ]; then          # gunluk kota bitti: tur biter
  break
elif [ "$KOD" != "0" ]; then       # gecici hata: bu yaziyi atla, devam et
  continue
fi
Enter fullscreen mode Exit fullscreen mode

Paylaşımı ajandan koparmak

Bu hattın son kusuru şuydu: postları ajan atıyordu, yani oturum kapalıysa post yoktu.

Instagram tarafı API üzerinden çalıştığı için oradaki kuyruğu tamamen sisteme devrettim. Kurallar artık betiğin içinde:

PENCERE = (10, 24)     # yayin penceresi, TSI
GUNLUK_TAVAN = 10
ARA_DAKIKA = 75        # ardisik postlar arasi en az bosluk

def zaman_uygun(q, oge=None):
    simdi = datetime.datetime.now(TSI)
    basla = 9 if (oge or {}).get("kat") == "Tarot" else PENCERE[0]
    if not (basla <= simdi.hour < PENCERE[1]):
        return f"pencere disi ({simdi:%H:%M})"
    ...
Enter fullscreen mode Exit fullscreen mode

Timer yarım saatte bir ateşliyor, karar betiğe ait. Tarot 09:00 istisnası da burada yaşıyor: günlük fal geciktirilmiyor, videosu yetişmezse o günün görseliyle atılıyor.

Bir de küçük ama pahalı bir dersin kodu var: paylaşımdan önce her yazı adresi doğrulanıyor. Bir gün ezberden yazılmış bir slug 404 verdi ve postlar elle silinmek zorunda kaldı. Artık kuyruktaki adres kullanılıyor ve yine de HTTP durumu kontrol ediliyor.

Tarayıcıya bağımlılık ve bir sonraki adım

Hattın en zayıf halkası hâlâ video üretiminin tarayıcı oturumuna bağlı olması. Oturum düşünce üretim duruyor.

Şu an denediğim yol: sesi kendimiz üretip, karakteri ses güdümlü bir modelle oynatmak. Böylece telaffuz riski tamamen ortadan kalkıyor, çünkü konuşmayı video modeli üretmiyor. Sunucuda GPU yok, o yüzden hesap ücretsiz GPU sağlayan bir platformda ve tarayıcı olmadan, API ile:

kaggle kernels push -p .        # notebook + GPU + internet
kaggle kernels status <ref>
kaggle kernels output <ref> -p .
Enter fullscreen mode Exit fullscreen mode

İlk tur sessizce öldü: log'da traceback yoktu, model yükleme mesajlarından sonra çıktı bitiyordu. Traceback olmadan ölmek genelde tek bir şey demek, bellek. İkinci turda bellek kipleri sırayla deniyor ve her denemenin dönüş kodu basılıyor; -9 görürsem tanı kesinleşecek.

Sonucu bir sonraki bölümde yazacağım. Çalışırsa hat tarayıcıdan tamamen kurtulacak; çalışmazsa onu da yazacağım.


Bu seride kod kadar başarısız denemeleri de tutuyorum, çünkü altı ay sonra "bunu neden böyle yaptık" sorusunun cevabı orada duruyor.

Top comments (0)