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. /// /// Farklı çözünürlükteki segmentleri ilk videonun boyutuna normalize eder. /// 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). 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? → codec-copy concat (hızlı, re-encode yok) final hepsKesim = gecisler.isEmpty || gecisler.every((g) => g.tip == GecisTipi.kesim); if (hepsKesim) { ilerleme?.call(10, 'Concat listesi hazırlanıyor'); await FfmpegServis.videolariBirlestir(videolar, cikis); ilerleme?.call(100, 'Tamamlandı'); return; } // ── xfade re-encode yolu ────────────────────────────────────────────── ilerleme?.call(5, 'Süreler hesaplanıyor'); final sureler = []; for (final v in videolar) { sureler.add(await FfmpegServis.videoSuresi(v)); } // Hedef çözünürlük: ilk videonun W×H (diğerleri scale+pad ile uyarlanır) final hedefRes = await _videoResim(videolar.first); final W = hedefRes.$1; final H = hedefRes.$2; final args = ['-y']; for (final v in videolar) { args.addAll(['-i', v]); } ilerleme?.call(15, 'Filter graph oluşturuluyor'); final filtreler = []; // 1. Aşama: her girişi normalize et → [sv0]..[svN], [sa0]..[saN] for (int i = 0; i < videolar.length; i++) { // Video: scale + pad → sabit W×H, aspect ratio korunur filtreler.add( '[$i:v]scale=$W:$H:force_original_aspect_ratio=decrease,' 'pad=$W:$H:(ow-iw)/2:(oh-ih)/2,setsar=1[sv$i]', ); // Ses: stereo normalize (acrossfade mono+stereo karışımında hata verir) filtreler.add('[$i:a]aformat=sample_fmts=fltp:channel_layouts=stereo[sa$i]'); } // 2. Aşama: xfade/acrossfade zinciri String oncekiV = '[sv0]'; String oncekiA = '[sa0]'; double kumul = 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 gSure = gec.sureSn.clamp(0.1, 2.0); if (xfStr == null) { // Bu sınır kesim → video concat + ses concat filtreler.add('${oncekiV}[sv$i]concat=n=2:v=1:a=0[v$i]'); filtreler.add('${oncekiA}[sa$i]concat=n=2:v=0:a=1[a$i]'); kumul += sureler[i]; } else { final ofs = (kumul - gSure).clamp(0.0, double.infinity); filtreler.add( '${oncekiV}[sv$i]xfade=transition=$xfStr:duration=$gSure:offset=$ofs[v$i]', ); filtreler.add('${oncekiA}[sa$i]acrossfade=d=$gSure[a$i]'); kumul += sureler[i] - gSure; } oncekiV = '[v$i]'; oncekiA = '[a$i]'; } args.addAll(['-filter_complex', filtreler.join(';')]); args.addAll(['-map', oncekiV, '-map', oncekiA]); args.addAll([ '-c:v', 'libx264', '-crf', '20', '-preset', 'fast', '-pix_fmt', 'yuv420p', // player uyumluluğu için '-c:a', 'aac', '-b:a', '192k', '-movflags', '+faststart', cikis, ]); ilerleme?.call(20, 'FFmpeg encode başlıyor'); // Tam komut + filter_complex'i log dosyasına yaz final debugLogDosya = File( p.join(p.dirname(cikis), 'xfade_debug.log'), ); await debugLogDosya.writeAsString( 'CMD: ${FfmpegServis.ffmpegYolu} ${args.join(" ")}\n\n' 'FILTRELER:\n${filtreler.join("\n")}\n', mode: FileMode.write, ); 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((s) { stderrBuf.write(s); final m = timeReg.firstMatch(s); 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; // Stderr'i de log dosyasına ekle await debugLogDosya.writeAsString( '\nFFMPEG STDERR:\n${stderrBuf.toString()}', mode: FileMode.append, ); if (exitCode != 0) { Log.hata('FfmpegFiltre', 'xfade hata:\n${stderrBuf.toString()}'); throw Exception('xfade birleştirme başarısız:\n${stderrBuf.toString()}'); } ilerleme?.call(100, 'Tamamlandı'); } /// Video dosyasından (genişlik, yükseklik) döndürür. static Future<(int, int)> _videoResim(String yol) async { final r = await Process.run(FfmpegServis.ffprobeYolu, [ '-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'csv=p=0', yol, ]); final parts = (r.stdout as String).trim().split(','); final w = int.tryParse(parts.isNotEmpty ? parts[0] : '') ?? 1280; final h = int.tryParse(parts.length > 1 ? parts[1] : '') ?? 720; return (w, h); } 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'; } }