import 'dart:io'; import 'package:path/path.dart' as p; import '../../cekirdek/log.dart'; import '../../modeller/cikis_ayarlari.dart'; import 'ffmpeg_servis.dart'; // ─── Geçiş modelleri ────────────────────────────────────────────────────────── /// Segment geçiş tipi. enum GecisTipi { /// Keskin kesim (varsayılan) — codec copy concat kullanır. kesim, /// Çapraz karartma / crossfade (xfade=fade). crossfade, /// Çözünme / dissolve (xfade=dissolve). dissolve, /// Wipe soldan sağa (xfade=wipeleft). wipeLeft, /// Wipe sağdan sola (xfade=wiperight). wipeRight, /// Kaydırma (xfade=slideleft). slideLeft, /// Karartma → aydınlatma (xfade=fadeblack). fadeBlack, /// Beyazlatma (xfade=fadewhite). fadeWhite, /// Yakınlaştırma (xfade=zoomin). zoomIn, } extension GecisTipiIsim on GecisTipi { String get gorselIsim => switch (this) { GecisTipi.kesim => 'Kesim', GecisTipi.crossfade => 'Crossfade', GecisTipi.dissolve => 'Dissolve', GecisTipi.wipeLeft => 'Wipe Sol→Sağ', GecisTipi.wipeRight => 'Wipe Sağ→Sol', GecisTipi.slideLeft => 'Slide', GecisTipi.fadeBlack => 'Karart & Aç', GecisTipi.fadeWhite => 'Beyazlat & Aç', GecisTipi.zoomIn => 'Zoom In', }; /// FFmpeg xfade transition ismi. null ise xfade uygulanmaz (kesim). String? get xfadeStr => switch (this) { GecisTipi.kesim => null, GecisTipi.crossfade => 'fade', GecisTipi.dissolve => 'dissolve', GecisTipi.wipeLeft => 'wipeleft', GecisTipi.wipeRight => 'wiperight', GecisTipi.slideLeft => 'slideleft', GecisTipi.fadeBlack => 'fadeblack', GecisTipi.fadeWhite => 'fadewhite', GecisTipi.zoomIn => 'zoomin', }; } /// İki segment arasındaki geçiş ayarı. class GecisAyari { /// Bu geçiş, [boundaryIdx]. segment ile [boundaryIdx+1]. segment arasına uygulanır. final int boundaryIdx; final GecisTipi tip; /// Geçiş süresi (saniye). Xfade için 0.3–1.5 sn makul aralık. final double sureSn; const GecisAyari({ required this.boundaryIdx, this.tip = GecisTipi.kesim, this.sureSn = 0.5, }); Map toJson() => { 'boundaryIdx': boundaryIdx, 'tip': tip.name, 'sureSn': sureSn, }; factory GecisAyari.fromJson(Map j) => GecisAyari( boundaryIdx: j['boundaryIdx'] as int, tip: GecisTipi.values.firstWhere( (t) => t.name == j['tip'], orElse: () => GecisTipi.kesim, ), sureSn: (j['sureSn'] as num?)?.toDouble() ?? 0.5, ); } // ─── FfmpegFiltre ───────────────────────────────────────────────────────────── /// ffmpeg filter_complex ile geçiş, loudnorm, LUT, watermark, altyazı uygular. class FfmpegFiltre { /// Tek bir video üstüne profesyonel post-processing uygular. /// Çıkış: encoded mp4. Bu yüzden re-encode yapar. static Future postUygula({ required String girisVideo, required String cikisVideo, required CikisPreset preset, required CikisAyarlari ayarlar, String? sesYolu, }) async { final filtreler = []; final args = ['-y', '-i', girisVideo]; // İkincil giriş: watermark int sonInputIdx = 0; int? wmIdx; if (ayarlar.watermark != null && File(ayarlar.watermark!.path).existsSync()) { args.addAll(['-i', ayarlar.watermark!.path]); sonInputIdx++; wmIdx = sonInputIdx; } // İkincil giriş: ses int? sesIdx; if (sesYolu != null && File(sesYolu).existsSync()) { args.addAll(['-i', sesYolu]); sonInputIdx++; sesIdx = sonInputIdx; } // Çözünürlük + oran ayarı (scale + crop) final cozunurlukYksek = _yukseklikCikar(preset.cozunurluk); final orData = preset.oran.split(':'); final orW = int.tryParse(orData[0]) ?? 16; final orH = int.tryParse(orData[1]) ?? 9; final hedefH = cozunurlukYksek; final hedefW = (cozunurlukYksek * orW / orH).round(); final w = hedefW.isOdd ? hedefW + 1 : hedefW; final h = hedefH.isOdd ? hedefH + 1 : hedefH; var akim = '[0:v]scale=$w:$h:force_original_aspect_ratio=increase,crop=$w:$h'; // Renk düzeltme if (ayarlar.renkDuzeltme != null) { final rd = ayarlar.renkDuzeltme!; final eq = 'eq=brightness=${rd.parlaklik.toStringAsFixed(2)}:contrast=${rd.kontrast.toStringAsFixed(2)}:saturation=${rd.doygunluk.toStringAsFixed(2)}'; akim = '$akim,$eq'; if (rd.lutPath != null && File(rd.lutPath!).existsSync()) { akim = '$akim,lut3d=${_kacis(rd.lutPath!)}'; } } // Altyazı (SRT) if (ayarlar.altyazi != null && ayarlar.altyazi!.srtPath != null && File(ayarlar.altyazi!.srtPath!).existsSync()) { final ay = ayarlar.altyazi!; final style = "FontName=${ay.font},FontSize=${ay.boyut},PrimaryColour=&H${_renkBgr(ay.renk)}"; akim = "$akim,subtitles=${_kacis(ay.srtPath!)}:force_style='$style'"; } final akimAd = wmIdx != null ? '[v0]' : '[vout]'; filtreler.add('$akim$akimAd'); // Watermark overlay if (wmIdx != null) { final wm = ayarlar.watermark!; final pozisyon = switch (wm.konum) { 'top-left' => '20:20', 'top-right' => 'W-w-20:20', 'bottom-left' => '20:H-h-20', 'bottom-right' => 'W-w-20:H-h-20', _ => 'W-w-20:20', }; filtreler.add( '[$wmIdx:v]format=rgba,colorchannelmixer=aa=${wm.opaklik.toStringAsFixed(2)},scale=iw*${wm.genislikOran}:-1[wm];[v0][wm]overlay=$pozisyon[vout]', ); } // Ses pipeline String sesAkim = ''; if (sesIdx != null) { sesAkim = '[$sesIdx:a]'; if (ayarlar.loudnessNormalize) { sesAkim = '${sesAkim}loudnorm=I=-14:TP=-1.5:LRA=11'; } sesAkim = '$sesAkim[aout]'; filtreler.add(sesAkim); } else if (ayarlar.loudnessNormalize) { filtreler.add('[0:a]loudnorm=I=-14:TP=-1.5:LRA=11[aout]'); } args.addAll(['-filter_complex', filtreler.join(';')]); args.addAll(['-map', '[vout]']); if (sesIdx != null || ayarlar.loudnessNormalize) { args.addAll(['-map', '[aout]']); } else { args.addAll(['-map', '0:a?']); } args.addAll([ '-c:v', preset.codec, '-crf', preset.crf.toString(), '-preset', 'medium', '-c:a', 'aac', '-b:a', '192k', '-movflags', '+faststart', cikisVideo, ]); Log.bilgi('FfmpegFiltre', 'post: $cikisVideo (${preset.cozunurluk}/${preset.oran})'); final r = await Process.run(FfmpegServis.ffmpegYolu, args); if (r.exitCode != 0) { Log.hata('FfmpegFiltre', 'Hata: ${r.stderr}'); throw Exception('Post processing başarısız'); } } /// Birden fazla videoyu [gecisler] listesindeki geçiş ayarlarıyla birleştirir. /// /// Eğer tüm geçişler [GecisTipi.kesim] ise codec-copy concat kullanılır (hızlı). /// En az bir efekt varsa filter_complex + xfade kullanılır (re-encode gerekir). /// /// [gecisler] listesinde [boundaryIdx] = i, segment i ile i+1 arasını belirtir. /// Listede bulunmayan sınırlar otomatik olarak [GecisTipi.kesim] sayılır. static Future xfadeBirlestir({ required List videolar, required List gecisler, required String cikis, void Function(double, String)? ilerleme, }) async { if (videolar.isEmpty) throw Exception('xfadeBirlestir: video listesi boş'); if (videolar.length == 1) { ilerleme?.call(50, 'Kopyalanıyor'); await File(videolar.first).copy(cikis); ilerleme?.call(100, 'Tamamlandı'); return; } // Tüm geçişler kesim mi? → hızlı concat (codec copy) final hepsKesim = gecisler.every((g) => g.tip == GecisTipi.kesim) || gecisler.isEmpty; if (hepsKesim) { // Re-encode gerekmez, basit concat yap ilerleme?.call(10, 'Concat listesi hazırlanıyor'); await FfmpegServis.videolariBirlestir(videolar, cikis); ilerleme?.call(100, 'Tamamlandı'); return; } // ── xfade re-encode yolu ────────────────────────────────────────────── // Tüm girişlerin aynı çözünürlük ve fps'te olması gerekir. // Farklıysa scale + fps filtresi eklenebilir ama şimdilik normalize etmiyoruz. // Segment üretimi zaten aynı parametreleri kullanıyor. ilerleme?.call(5, 'Süreler hesaplanıyor'); // Her videonun süresini önceden ölç final sureler = []; for (final v in videolar) { sureler.add(await FfmpegServis.videoSuresi(v)); } final args = ['-y']; for (final v in videolar) { args.addAll(['-i', v]); } ilerleme?.call(15, 'Filter graph oluşturuluyor'); final filtreler = []; String oncekiV = '[0:v]'; String oncekiA = '[0:a]'; // Geçişlerin başladığı absolute offset (saniye) // Segment 0'ın bitiş zamanı = sureler[0]. // Ama xfade overlap kadar geriye gideriz. double kumulatifSure = sureler[0]; for (int i = 1; i < videolar.length; i++) { final gec = gecisler.firstWhere( (g) => g.boundaryIdx == i - 1, orElse: () => GecisAyari(boundaryIdx: i - 1, tip: GecisTipi.kesim), ); final xfStr = gec.tip.xfadeStr; final sure = gec.sureSn.clamp(0.1, 2.0); if (xfStr == null) { // Bu sınır kesim → concat filtresiyle birleştir filtreler.add('$oncekiV[$i:v]concat=n=2:v=1:a=0[v$i]'); filtreler.add('$oncekiA[$i:a]concat=n=2:v=0:a=1[a$i]'); } else { // xfade offset = kümülatif süre - overlap final ofs = (kumulatifSure - sure).clamp(0.0, double.infinity); filtreler.add( '$oncekiV[$i:v]xfade=transition=$xfStr:duration=$sure:offset=$ofs[v$i]', ); filtreler.add( '$oncekiA[$i:a]acrossfade=d=$sure[a$i]', ); } oncekiV = '[v$i]'; oncekiA = '[a$i]'; kumulatifSure += sureler[i] - (xfStr != null ? sure : 0); } args.addAll(['-filter_complex', filtreler.join(';')]); args.addAll(['-map', oncekiV, '-map', oncekiA]); args.addAll([ '-c:v', 'libx264', '-crf', '20', '-preset', 'medium', '-c:a', 'aac', '-b:a', '192k', '-movflags', '+faststart', cikis, ]); ilerleme?.call(20, 'FFmpeg encode başlıyor'); // İlerleme izlemeli süreç final toplamSure = sureler.fold(0.0, (a, b) => a + b); final proc = await Process.start(FfmpegServis.ffmpegYolu, args); final stderrBuf = StringBuffer(); final timeReg = RegExp(r'time=(\d+):(\d+):(\d+\.?\d*)'); proc.stderr.transform(SystemEncoding().decoder).listen((satir) { stderrBuf.write(satir); final m = timeReg.firstMatch(satir); if (m != null && toplamSure > 0) { final h = double.tryParse(m.group(1)!) ?? 0; final dk = double.tryParse(m.group(2)!) ?? 0; final sn = double.tryParse(m.group(3)!) ?? 0; final gecen = h * 3600 + dk * 60 + sn; final yuzde = 20 + (gecen / toplamSure * 78).clamp(0, 78); ilerleme?.call( yuzde.toDouble(), 'Encode: ${gecen.toStringAsFixed(1)}s / ${toplamSure.toStringAsFixed(1)}s', ); } }); proc.stdout.drain(); final exitCode = await proc.exitCode; if (exitCode != 0) { Log.hata('FfmpegFiltre', 'xfade hata: ${stderrBuf.toString()}'); throw Exception('xfade birleştirme başarısız:\n${stderrBuf.toString()}'); } ilerleme?.call(100, 'Tamamlandı'); } static int _yukseklikCikar(String cozunurluk) { final m = RegExp(r'(\d+)p').firstMatch(cozunurluk); return int.tryParse(m?.group(1) ?? '1080') ?? 1080; } static String _kacis(String yol) { return yol.replaceAll(r'\', '/').replaceAll(':', r'\:'); } /// #RRGGBB → libass/SubStation BGR formatı (BBGGRR). static String _renkBgr(String hex) { final h = hex.replaceFirst('#', '').padLeft(6, '0'); final r = h.substring(0, 2); final g = h.substring(2, 4); final b = h.substring(4, 6); return '00$b$g$r'; } }