import 'dart:io'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; /// Final export kalite/çözünürlük ayarı (export sihirbazı). class KaliteAyari { const KaliteAyari({ this.genislik = 0, this.yukseklik = 0, this.fps = 0, this.crf = 20, this.preset = 'medium', }); final int genislik; // 0 = kaynaktan final int yukseklik; // 0 = kaynaktan final int fps; // 0 = kaynaktan final int crf; // 14 (yüksek) .. 28 (düşük) final String preset; // ultrafast..veryslow /// xfade/concat sonrası ölçek+fps filtresi (boşsa uygulanmaz). String get olcekFiltresi { final p = []; if (genislik > 0 && yukseklik > 0) { p.add('scale=$genislik:$yukseklik:flags=lanczos'); } if (fps > 0) p.add('fps=$fps'); return p.join(','); } } /// ffmpeg yardımcı servisi — dalga formu / spektrum görseli üretir, video kapağı çıkarır. abstract final class FfmpegServisi { static String _ffmpeg = 'ffmpeg'; static bool _ayarlandi = false; /// Son başarısız ffmpeg çağrısının stderr özeti (UI hata gösterimi için). static String? sonHata; static String? _fontYolu; /// drawtext için sistem fontu (Windows). Bulunmazsa boş. static String get fontYolu { if (_fontYolu != null) return _fontYolu!; const adaylar = [ r'C:\Windows\Fonts\arial.ttf', r'C:\Windows\Fonts\segoeui.ttf', r'C:\Windows\Fonts\tahoma.ttf', ]; _fontYolu = adaylar.firstWhere((a) => File(a).existsSync(), orElse: () => ''); return _fontYolu!; } /// drawtext filtresine eklenecek `fontfile='...':` parçası (boşsa ''). static String get _fontDrawtext { final y = fontYolu; if (y.isEmpty) return ''; final esc = y.replaceAll(r'\', '/').replaceAll(':', r'\:'); return "fontfile='$esc':"; } /// ffmpeg'i çalıştırır; hata olursa stderr özetini [sonHata]'ya yazar. static Future _ffmpegRun(List args) async { final r = await Process.run(ffmpeg, args); if (r.exitCode != 0) { final err = (r.stderr as String).trim(); sonHata = err.split('\n').where((l) => l.trim().isNotEmpty).toList().reversed .take(4).toList().reversed.join('\n'); } return r; } /// Videonun (genişlik, yükseklik) çözünürlüğü (ffprobe). static Future<(int, int)?> videoBoyut(String yol) async { if (!File(yol).existsSync()) return null; final ffprobe = ffmpeg.replaceAll('ffmpeg.exe', 'ffprobe.exe'); try { final r = await Process.run( File(ffprobe).existsSync() ? ffprobe : 'ffprobe', [ '-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'csv=p=0:s=x', yol, ]); final parca = (r.stdout as String).trim().split('x'); if (parca.length >= 2) { final w = int.tryParse(parca[0].trim()); final h = int.tryParse(parca[1].trim()); if (w != null && h != null && w > 0 && h > 0) return (w, h); } } catch (_) {} return null; } /// PATH'te yoksa bilinen konumları dener. static String get ffmpeg { if (_ayarlandi) return _ffmpeg; const adaylar = [ r'D:\__sistem__\Masaustu\FikoYT.Yeni.Cag\video-uretim\ffmpeg\bin\ffmpeg.exe', r'D:\Comfy\App\ffmpeg-shared\bin\ffmpeg.exe', ]; for (final a in adaylar) { if (File(a).existsSync()) { _ffmpeg = a; break; } } _ayarlandi = true; return _ffmpeg; } /// Final encode kalite/çözünürlük ayarları (export sihirbazı). /// [genislik]/[yukseklik] 0 ise kaynak korunur. [crf] düşük = yüksek kalite. /// İlerleme [ilerle] callback'ine 0..1 olarak akar (toplam süre biliniyorsa). static List _kaliteArglari(KaliteAyari? k) { if (k == null) return ['-c:v', 'libx264', '-pix_fmt', 'yuv420p']; return [ '-c:v', 'libx264', '-preset', k.preset, '-crf', k.crf.toString(), '-pix_fmt', 'yuv420p', ]; } /// ffmpeg'i ilerleme akışıyla çalıştırır. -progress pipe:1 ile [ilerle] /// callback'ine 0..1 oranı bildirir ([toplamSure] saniye gerekir). static Future _calistirIlerlemeli( List args, { double toplamSure = 0, void Function(double oran)? ilerle, }) async { try { // -progress için pipe:1, -nostats; args başına ekle final tam = ['-y', '-nostats', '-progress', 'pipe:1', ...args]; final proc = await Process.start(ffmpeg, tam); proc.stdout.transform(const SystemEncoding().decoder).listen((veri) { if (toplamSure <= 0 || ilerle == null) return; for (final satir in veri.split('\n')) { final s = satir.trim(); if (s.startsWith('out_time_ms=')) { final us = int.tryParse(s.substring(12)) ?? 0; ilerle((us / 1e6 / toplamSure).clamp(0.0, 0.999)); } else if (s == 'progress=end') { ilerle(1.0); } } }); proc.stderr.drain(); return (await proc.exitCode) == 0; } catch (_) { return false; } } static Future _onbellek() async { final dir = await getApplicationSupportDirectory(); final c = Directory(p.join(dir.path, 'gorsel_onbellek')); if (!c.existsSync()) c.createSync(recursive: true); return c; } static String _anahtar(String yol, String ek) { final ad = p.basenameWithoutExtension(yol); final h = yol.hashCode.toUnsigned(32).toRadixString(16); return '${ad}_${h}_$ek.png'; } /// Ses dosyasından dalga formu PNG üretir (önbellekli). static Future dalgaFormu(String sesYolu, {String renk = '00E5FF', int genislik = 1400, int yukseklik = 180}) async { if (!File(sesYolu).existsSync()) return null; final onb = await _onbellek(); final hedef = File(p.join(onb.path, _anahtar(sesYolu, 'wave'))); if (hedef.existsSync()) return hedef.path; try { final r = await Process.run(ffmpeg, [ '-y', '-i', sesYolu, '-filter_complex', 'showwavespic=s=${genislik}x$yukseklik:colors=#$renk:split_channels=0', '-frames:v', '1', hedef.path, ]); if (r.exitCode == 0 && hedef.existsSync()) return hedef.path; } catch (_) {} return null; } /// Ses dosyasından spektrogram PNG üretir (önbellekli). static Future spektrum(String sesYolu, {int genislik = 1400, int yukseklik = 180}) async { if (!File(sesYolu).existsSync()) return null; final onb = await _onbellek(); final hedef = File(p.join(onb.path, _anahtar(sesYolu, 'spec'))); if (hedef.existsSync()) return hedef.path; try { final r = await Process.run(ffmpeg, [ '-y', '-i', sesYolu, '-lavfi', 'showspectrumpic=s=${genislik}x$yukseklik:mode=combined:color=intensity:scale=cbrt:legend=0', hedef.path, ]); if (r.exitCode == 0 && hedef.existsSync()) return hedef.path; } catch (_) {} return null; } /// Ses dosyasının süresini saniye olarak döndürür (ffprobe). static Future sesSuresi(String yol) async { if (!File(yol).existsSync()) return null; final ffprobe = ffmpeg.replaceAll('ffmpeg.exe', 'ffprobe.exe'); try { final r = await Process.run( File(ffprobe).existsSync() ? ffprobe : 'ffprobe', [ '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', yol, ]); return double.tryParse((r.stdout as String).trim()); } catch (_) { return null; } } /// Ses dosyasından [baslangic, baslangic+sure] aralığını keser → hedef (wav). static Future sesKes({ required String kaynak, required double baslangic, required double sure, required String hedef, }) async { if (!File(kaynak).existsSync()) return null; await File(hedef).parent.create(recursive: true); try { final mp3 = hedef.toLowerCase().endsWith('.mp3'); final r = await Process.run(ffmpeg, [ '-y', '-ss', baslangic.toStringAsFixed(3), '-t', sure.toStringAsFixed(3), '-i', kaynak, if (mp3) ...['-c:a', 'libmp3lame', '-q:a', '2'] else ...['-c:a', 'pcm_s16le'], hedef, ]); if (r.exitCode == 0 && File(hedef).existsSync()) return hedef; } catch (_) {} return null; } /// Sahne videolarını birleştirir + (varsa) şarkı sesini ekler → final mp4. static Future klipBirlestir({ required List videoYollari, required String hedefYol, String? sesYolu, String gecis = 'fade', }) async { final gecerli = videoYollari.where((v) => File(v).existsSync()).toList(); if (gecerli.isEmpty) return null; final hedef = File(hedefYol); await hedef.parent.create(recursive: true); // concat demuxer için liste dosyası final liste = File('${hedefYol}_liste.txt'); await liste.writeAsString( gecerli.map((v) => "file '${v.replaceAll(r'\', '/')}'").join('\n')); try { final args = ['-y', '-f', 'concat', '-safe', '0', '-i', liste.path]; if (sesYolu != null && File(sesYolu).existsSync()) { args.addAll(['-i', sesYolu]); args.addAll([ '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '192k', '-map', '0:v:0', '-map', '1:a:0', '-shortest', ]); } else { args.addAll(['-c:v', 'libx264', '-pix_fmt', 'yuv420p']); } args.add(hedefYol); final r = await _ffmpegRun(args); await liste.delete().catchError((_) => liste); if (r.exitCode == 0 && hedef.existsSync()) return hedefYol; } catch (_) {} await liste.delete().catchError((_) => liste); return null; } /// Bir videoya ffmpeg filtre zinciri (efekt) + opsiyonel trim uygular. static Future efektUygula({ required String kaynak, required String filtre, required String hedef, double kaynakBas = 0, double? sure, bool sesKoru = false, }) async { if (!File(kaynak).existsSync()) return kaynak; await File(hedef).parent.create(recursive: true); try { final args = ['-y']; if (kaynakBas > 0.01) args.addAll(['-ss', kaynakBas.toStringAsFixed(3)]); if (sure != null && sure > 0) args.addAll(['-t', sure.toStringAsFixed(3)]); args.addAll(['-i', kaynak]); if (filtre.trim().isNotEmpty) args.addAll(['-vf', filtre]); args.addAll(['-c:v', 'libx264', '-pix_fmt', 'yuv420p']); if (sesKoru) { args.addAll(['-c:a', 'copy']); } else { args.add('-an'); } args.add(hedef); final r = await _ffmpegRun(args); if (r.exitCode == 0 && File(hedef).existsSync()) return hedef; } catch (_) {} return null; } /// Dosyada ses akışı var mı (ffprobe). static Future _sesAkisiVar(String yol) async { final ffprobe = ffmpeg.replaceAll('ffmpeg.exe', 'ffprobe.exe'); try { final r = await Process.run( File(ffprobe).existsSync() ? ffprobe : 'ffprobe', [ '-v', 'error', '-select_streams', 'a', '-show_entries', 'stream=codec_type', '-of', 'csv=p=0', yol, ]); return (r.stdout as String).trim().isNotEmpty; } catch (_) { return false; } } /// KATMANLI RENDER — ana videoya overlay video/resim + yazı + ek ses bindirir. /// [videolar]: (dosya, baslangic, sure, x, y, olcekOran, opaklik) /// [yazilar]: (metin, baslangic, sure, y) /// [sesler]: (dosya, baslangic, kazanc, fadeIn, fadeOut, sure) static Future katmanliRender({ required String anaVideo, required String hedefYol, List<(String, double, double, double, double, double, double)> videolar = const [], List<(String, double, double, double)> yazilar = const [], List<(String, double, double, double, double, double)> sesler = const [], }) async { if (!File(anaVideo).existsSync()) return null; if (videolar.isEmpty && yazilar.isEmpty && sesler.isEmpty) return anaVideo; await File(hedefYol).parent.create(recursive: true); final args = ['-y', '-i', anaVideo]; // Overlay girişleri (resim ise loop) for (final (dosya, _, sure, _, _, _, _) in videolar) { final resimMi = dosya.toLowerCase().endsWith('.png') || dosya.toLowerCase().endsWith('.jpg') || dosya.toLowerCase().endsWith('.jpeg'); if (resimMi) { args.addAll(['-loop', '1', '-t', sure.toStringAsFixed(2), '-i', dosya]); } else { args.addAll(['-i', dosya]); } } for (final (dosya, _, _, _, _, _) in sesler) { args.addAll(['-i', dosya]); } final fc = StringBuffer(); var son = '0:v'; var idx = 1; // Video/resim overlay'ler for (var i = 0; i < videolar.length; i++) { final (_, bas, sure, x, y, oran, opak) = videolar[i]; final olcekli = 'ov$i'; fc.write('[$idx:v]scale=iw*$oran:-1,format=yuva420p,' 'colorchannelmixer=aa=${opak.toStringAsFixed(2)}[$olcekli];'); final cikti = 'v$i'; fc.write("[$son][$olcekli]overlay=" "x=(main_w-overlay_w)*${x.toStringAsFixed(2)}:" "y=(main_h-overlay_h)*${y.toStringAsFixed(2)}:" "enable='between(t,${bas.toStringAsFixed(2)},${(bas + sure).toStringAsFixed(2)})'" '[$cikti];'); son = cikti; idx++; } // Yazılar (drawtext) for (var i = 0; i < yazilar.length; i++) { final (metin, bas, sure, y) = yazilar[i]; final temiz = metin .replaceAll('\\', '') .replaceAll("'", '') .replaceAll(':', r'\:') .replaceAll('%', ''); final cikti = 't$i'; fc.write("[$son]drawtext=${_fontDrawtext}text='$temiz':" 'fontsize=h/12:fontcolor=white:borderw=3:bordercolor=black:' "x=(w-text_w)/2:y=h*${y.toStringAsFixed(2)}:" "enable='between(t,${bas.toStringAsFixed(2)},${(bas + sure).toStringAsFixed(2)})'" '[$cikti];'); son = cikti; } // Ek sesler (adelay + amix — ana videoda ses varsa onunla karışır) String? sesSon; if (sesler.isNotEmpty) { final anaSesVar = await _sesAkisiVar(anaVideo); final girisler = [if (anaSesVar) '0:a']; for (var i = 0; i < sesler.length; i++) { final (_, bas, kazanc, fadeIn, fadeOut, sure) = sesler[i]; final g = 'a$i'; final ms = (bas * 1000).round(); final f = StringBuffer('[${idx + i}:a]adelay=$ms|$ms,' 'volume=${kazanc.toStringAsFixed(2)}'); if (fadeIn > 0.01) { f.write(',afade=t=in:st=${bas.toStringAsFixed(2)}' ':d=${fadeIn.toStringAsFixed(2)}'); } if (fadeOut > 0.01 && sure > 0) { final st = (bas + sure - fadeOut).clamp(0, double.infinity); f.write(',afade=t=out:st=${st.toStringAsFixed(2)}' ':d=${fadeOut.toStringAsFixed(2)}'); } f.write('[$g];'); fc.write(f.toString()); girisler.add(g); } if (girisler.length == 1) { sesSon = girisler.first.replaceAll(RegExp(r'[\[\]]'), ''); } else { fc.write( '${girisler.map((g) => '[$g]').join()}amix=inputs=${girisler.length}' ':duration=first:normalize=0[aout]'); sesSon = 'aout'; } } var filtre = fc.toString(); if (filtre.endsWith(';')) filtre = filtre.substring(0, filtre.length - 1); args.addAll(['-filter_complex', filtre, '-map', '[$son]']); if (sesSon != null) { args.addAll(['-map', '[$sesSon]', '-c:a', 'aac', '-b:a', '192k']); } else { args.addAll(['-map', '0:a?', '-c:a', 'copy']); } args.addAll(['-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-shortest', hedefYol]); try { final r = await _ffmpegRun(args); if (r.exitCode == 0 && File(hedefYol).existsSync()) return hedefYol; } catch (_) {} return null; } /// Sahne videolarını PROFESYONEL GEÇİŞLERLE birleştirir (xfade) + ses. /// [gecisler]: her klibe GİRİŞ geçişi (ilk klip hariç). 'none' = sert kesme. static Future klipBirlestirGecisli({ required List videoYollari, required List gecisler, required String hedefYol, String? sesYolu, double gecisSure = 0.5, KaliteAyari? kalite, void Function(double oran)? ilerle, }) async { final gecerli = videoYollari.where((v) => File(v).existsSync()).toList(); if (gecerli.isEmpty) return null; if (gecerli.length == 1) { return klipBirlestir( videoYollari: gecerli, hedefYol: hedefYol, sesYolu: sesYolu); } await File(hedefYol).parent.create(recursive: true); // Her klibin süresini al final sureler = []; for (final v in gecerli) { sureler.add(await sesSuresi(v) ?? 3.0); } // Hedef çözünürlük: kalite verilmişse o, yoksa ilk klibin boyutu (fallback HD). int hedefW = kalite?.genislik ?? 0; int hedefH = kalite?.yukseklik ?? 0; if (hedefW <= 0 || hedefH <= 0) { final boyut = await videoBoyut(gecerli.first); hedefW = boyut?.$1 ?? 1280; hedefH = boyut?.$2 ?? 720; } final hedefFps = (kalite?.fps ?? 0) > 0 ? kalite!.fps : 30; // xfade tüm girişlerin AYNI boyut/fps/sar olmasını ister; her girişi normalize et. final fc = StringBuffer(); for (var k = 0; k < gecerli.length; k++) { fc.write('[$k:v]scale=$hedefW:$hedefH:force_original_aspect_ratio=decrease,' 'pad=$hedefW:$hedefH:(ow-iw)/2:(oh-ih)/2,setsar=1,' 'fps=$hedefFps,format=yuv420p[n$k];'); } // xfade filter zinciri (normalize edilmiş [n$k] girişleriyle) var prev = 'n0'; var acc = sureler[0]; for (var k = 1; k < gecerli.length; k++) { final gKey = (k - 1) < gecisler.length ? gecisler[k - 1] : 'fade'; final etiket = 'v$k'; if (gKey == 'none') { // sert kesme: concat fc.write('[$prev][n$k]concat=n=2:v=1:a=0[$etiket];'); acc += sureler[k]; } else { final t = gecisSure.clamp(0.1, sureler[k] - 0.05); final offset = (acc - t).clamp(0, double.infinity); fc.write( '[$prev][n$k]xfade=transition=$gKey:duration=${t.toStringAsFixed(2)}:offset=${offset.toStringAsFixed(3)}[$etiket];'); acc += sureler[k] - t; } prev = etiket; } // Final ölçek/fps (export kalitesi) — son etikete uygula final olcekF = kalite?.olcekFiltresi ?? ''; if (olcekF.isNotEmpty) { fc.write('[$prev]$olcekF[vson];'); prev = 'vson'; } var filtre = fc.toString(); if (filtre.endsWith(';')) filtre = filtre.substring(0, filtre.length - 1); final args = []; for (final v in gecerli) { args.addAll(['-i', v]); } final sesVar = sesYolu != null && File(sesYolu).existsSync(); if (sesVar) args.addAll(['-i', sesYolu]); args.addAll(['-filter_complex', filtre, '-map', '[$prev]']); if (sesVar) { args.addAll(['-map', '${gecerli.length}:a:0', '-c:a', 'aac', '-b:a', '192k', '-shortest']); } args.addAll([..._kaliteArglari(kalite), hedefYol]); final ok = await _calistirIlerlemeli(args, toplamSure: acc, ilerle: ilerle); if (ok && File(hedefYol).existsSync()) return hedefYol; return null; } /// Video SON karesini çıkarır (önbellekli) — film süreklilik zinciri + /// gerçek thumbnail için (LTX yalnız mp4 üretir, ayrı resim vermez). static Future videoSonKare(String videoYolu) async { if (!File(videoYolu).existsSync()) return null; final onb = await _onbellek(); final hedef = File(p.join(onb.path, _anahtar(videoYolu, 'sonkare'))); if (hedef.existsSync()) return hedef.path; try { final r = await _ffmpegRun([ '-y', '-sseof', '-0.2', '-i', videoYolu, '-frames:v', '1', '-q:v', '2', hedef.path, ]); if (r.exitCode == 0 && hedef.existsSync()) return hedef.path; } catch (_) {} return null; } /// Video ilk karesini kapak olarak çıkarır (önbellekli). static Future videoKapak(String videoYolu) async { if (!File(videoYolu).existsSync()) return null; final onb = await _onbellek(); final hedef = File(p.join(onb.path, _anahtar(videoYolu, 'cover'))); if (hedef.existsSync()) return hedef.path; try { final r = await Process.run(ffmpeg, [ '-y', '-i', videoYolu, '-vf', 'select=eq(n\\,0)', '-frames:v', '1', hedef.path, ]); if (r.exitCode == 0 && hedef.existsSync()) return hedef.path; } catch (_) {} return null; } }