import 'dart:io'; import 'package:path/path.dart' as p; import '../../cekirdek/log.dart'; /// ffmpeg/ffprobe orchestrator. Yollar `ffmpegYolu`/`ffprobeYolu` static field'larından /// override edilebilir (Ayarlar sayfasında). class FfmpegServis { static String ffmpegYolu = 'ffmpeg'; static String ffprobeYolu = 'ffprobe'; static Future mevcutMu() async { try { final r = await Process.run(ffmpegYolu, ['-version']); return r.exitCode == 0; } catch (_) { return false; } } static Future sesSuresi(String yol) async { final r = await Process.run(ffprobeYolu, [ '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', yol, ]); if (r.exitCode != 0) { throw Exception('ffprobe başarısız: ${r.stderr}'); } return double.parse((r.stdout as String).trim()); } /// Sesi parçalara böler. mevcut Python pipeline mantığı: kalan >= 5s ise +1 segment. /// Returns: List<({path, basla, bit, sure})>. static Future>> sesParcala({ required String girisYolu, required String hedefKlasor, required double parcaSure, required double toplamSure, }) async { int sayi = (toplamSure ~/ parcaSure).toInt(); final kalan = toplamSure - (sayi * parcaSure); if (kalan >= 5.0) sayi += 1; await Directory(hedefKlasor).create(recursive: true); final segmentler = >[]; for (int i = 0; i < sayi; i++) { final basla = i * parcaSure; final bit = ((i + 1) * parcaSure > toplamSure) ? toplamSure : (i + 1) * parcaSure; final sure = bit - basla; final cikis = p.join( hedefKlasor, 'audio_segment_${i.toString().padLeft(3, '0')}.mp3', ); final r = await Process.run(ffmpegYolu, [ '-y', '-i', girisYolu, '-ss', basla.toString(), '-t', sure.toString(), '-acodec', 'libmp3lame', '-b:a', '192k', cikis, ]); if (r.exitCode != 0) { Log.hata('Ffmpeg', 'sesParcala $i hata: ${r.stderr}'); throw Exception('ffmpeg ses parçalama segment $i başarısız'); } segmentler.add({ 'path': cikis, 'basla': basla, 'bit': bit, 'sure': sure, }); } Log.bilgi('Ffmpeg', '$sayi ses parçası oluşturuldu (${parcaSure}s)'); return segmentler; } static Future videolariBirlestir( List videolar, String cikis, ) async { final tmp = File(p.join( p.dirname(cikis), '_concat_${DateTime.now().millisecondsSinceEpoch}.txt', )); await tmp.writeAsString( videolar .map((v) => "file '${v.replaceAll(r"\", "/").replaceAll("'", "'\\''")}'") .join('\n'), ); final r = await Process.run(ffmpegYolu, [ '-y', '-f', 'concat', '-safe', '0', '-i', tmp.path, '-c', 'copy', cikis, ]); await tmp.delete(); if (r.exitCode != 0) { throw Exception('Birleştirme hatası: ${r.stderr}'); } } /// Bir videoyu [bitisSaniye] saniyesinden kes — başlangıçtan o ana kadar /// olan kısmı [cikis]'a yazar. Re-encode (frame-accurate). H264 + aac. /// [baslangicSaniye] verilirse o andan başlatılır (varsayılan 0). static Future videoKes( String girdi, String cikis, { required double bitisSaniye, double baslangicSaniye = 0, }) async { final sure = (bitisSaniye - baslangicSaniye).clamp(0.01, double.infinity); final args = [ '-y', if (baslangicSaniye > 0) ...['-ss', baslangicSaniye.toStringAsFixed(3)], '-i', girdi, '-t', sure.toStringAsFixed(3), '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '18', '-c:a', 'aac', '-b:a', '192k', '-movflags', '+faststart', cikis, ]; final r = await Process.run(ffmpegYolu, args); if (r.exitCode != 0) { throw Exception('videoKes hatası: ${r.stderr}'); } } static Future sesEkle( String videoYolu, String sesYolu, String cikis, ) async { // Ses dosyası videodan kısaysa döngüye al (loop), uzunsa video kadar kes. // -shortest yerine video süresini baz alıyoruz. final r = await Process.run(ffmpegYolu, [ '-y', '-i', videoYolu, '-stream_loop', '-1', // ses dosyasını sonsuz döngüye al '-i', sesYolu, '-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k', '-map', '0:v:0', '-map', '1:a:0', '-shortest', '-movflags', '+faststart', // moov atom başa al — player uyumluluğu cikis, ]); if (r.exitCode != 0) { throw Exception('sesEkle hatası: ${r.stderr}'); } } /// Video süresini saniye olarak döndürür. static Future videoSuresi(String yol) async { final r = await Process.run(ffprobeYolu, [ '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', yol, ]); return double.tryParse((r.stdout as String).trim()) ?? 0; } /// Video FPS'ini döndürür. static Future videoFps(String yol) async { final r = await Process.run(ffprobeYolu, [ '-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=r_frame_rate', '-of', 'default=noprint_wrappers=1:nokey=1', yol, ]); final s = (r.stdout as String).trim(); if (s.contains('/')) { final parts = s.split('/'); final num = double.tryParse(parts[0]) ?? 24; final den = double.tryParse(parts[1]) ?? 1; return den == 0 ? 24 : num / den; } return double.tryParse(s) ?? 24; } /// Düşük çözünürlüklü hızlı önizleme. /// [videoYollari]: birleştirilecek segment videoları /// [hedefYol]: çıkış mp4 /// [hedefGenislik]: 480 (varsayılan), yükseklik orana göre /// [ilerleme]: callback (yuzde 0-100, faz açıklaması) static Future hizliOnizleme({ required List videoYollari, required String hedefYol, int hedefGenislik = 480, void Function(double, String)? ilerleme, }) async { if (videoYollari.isEmpty) { throw Exception('hizliOnizleme: video listesi boş'); } final listeDosya = File('$hedefYol.txt'); final satirlar = videoYollari .map((v) => "file '${v.replaceAll(r"\", "/").replaceAll("'", "'\\''")}'") .join('\n'); await listeDosya.writeAsString(satirlar); ilerleme?.call(0, 'Toplam süre hesaplanıyor'); double toplamSure = 0; for (final v in videoYollari) { try { toplamSure += await videoSuresi(v); } catch (_) {} } ilerleme?.call(2, 'Önizleme render başlıyor'); final proc = await Process.start(ffmpegYolu, [ '-f', 'concat', '-safe', '0', '-i', listeDosya.path, '-vf', 'scale=$hedefGenislik:-2', '-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '28', '-c:a', 'aac', '-b:a', '96k', '-y', hedefYol, ]); 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 = (gecen / toplamSure * 100).clamp(0, 99).toDouble(); ilerleme?.call(yuzde, 'Render: ${gecen.toStringAsFixed(1)}s / ${toplamSure.toStringAsFixed(1)}s'); } }); proc.stdout.drain(); final exit = await proc.exitCode; try { await listeDosya.delete(); } catch (_) {} if (exit != 0) { Log.hata('Ffmpeg', 'hizliOnizleme hata: ${stderrBuf.toString()}'); throw Exception('hizliOnizleme başarısız: ${stderrBuf.toString()}'); } ilerleme?.call(100, 'Tamamlandı'); return File(hedefYol); } /// Bir videodan belirli zamanın frame'ini PNG olarak alır. static Future tekFrameAl({ required String videoYolu, required Duration zaman, required String hedefPng, }) async { final saniye = zaman.inMilliseconds / 1000.0; final h = (saniye ~/ 3600).toString().padLeft(2, '0'); final dk = ((saniye % 3600) ~/ 60).toString().padLeft(2, '0'); final sn = (saniye % 60).toStringAsFixed(3).padLeft(6, '0'); final ts = '$h:$dk:$sn'; final r = await Process.run(ffmpegYolu, [ '-ss', ts, '-i', videoYolu, '-vframes', '1', '-y', hedefPng, ]); if (r.exitCode != 0) { throw Exception('tekFrameAl hatası: ${r.stderr}'); } return File(hedefPng); } /// Color grading ön izlemesi (canlı slider için). static Future renkDuzeltmeOnizleme({ required String girdiVideo, required String hedefVideo, double parlaklik = 0.0, double kontrast = 1.0, double doygunluk = 1.0, double gama = 1.0, int hedefGenislik = 480, Duration? sureSiniri, }) async { final filtre = 'eq=brightness=$parlaklik:contrast=$kontrast:saturation=$doygunluk:gamma=$gama,scale=$hedefGenislik:-2'; final args = [ '-i', girdiVideo, if (sureSiniri != null) ...['-t', (sureSiniri.inMilliseconds / 1000.0).toString()], '-vf', filtre, '-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '28', '-c:a', 'aac', '-b:a', '96k', '-y', hedefVideo, ]; final r = await Process.run(ffmpegYolu, args); if (r.exitCode != 0) { throw Exception('renkDuzeltmeOnizleme hatası: ${r.stderr}'); } return File(hedefVideo); } /// RMS değerlerini segment aralıkları için ölçer (mevcut Python mantığı). static Future rmsOlc( String sesYolu, double basla, double bit, ) async { // amovie= filter'ında : ve \ özel karakter — Windows yolu için kaçır final guvenliYol = sesYolu.replaceAll(r'\', '/').replaceAll(':', r'\:'); final r = await Process.run(ffprobeYolu, [ '-v', 'error', '-f', 'lavfi', '-i', 'amovie=$guvenliYol:sp=$basla:ep=$bit,astats=metadata=1:reset=0', '-show_entries', 'frame_tags=lavfi.astats.Overall.RMS_level', '-of', 'default=noprint_wrappers=1:nokey=1', ]); final degerler = []; for (final line in (r.stdout as String).split('\n')) { final t = line.trim(); if (t.isEmpty || t == '-inf') continue; final v = double.tryParse(t); if (v != null) degerler.add(v); } if (degerler.isEmpty) return -60.0; return degerler.reduce((a, b) => a + b) / degerler.length; } }