feat: 영상·오디오 Provider (FfmpegProvider) — FFmpeg 설치 시 활성화
FFMpegCore로 영상/오디오 변환·트랜스코딩·오디오 추출. ffmpeg/ffprobe가 없으면 NotReady로 비활성, PATH 또는 앱 폴더에 두면 자동 활성화(LGPL 분리 호출, 본체 미번들). - 영상 mp4/mkv/webm/mov/avi/gif, 오디오 mp3/aac/m4a/opus/ogg/flac/wav N×M + 영상→오디오 추출 - ExternalToolDetector.TryFindFfmpeg (앱 폴더 + PATH 탐지) - 진행률 best-effort (ffprobe duration 기반), 취소 전파 - 테스트 3개 + SSOT status 갱신 — 총 37개 통과
This commit is contained in:
parent
b76d51f3c1
commit
7235fc87f5
8 changed files with 182 additions and 13 deletions
|
|
@ -135,7 +135,7 @@ Everything2Everything의 북극성은 "세상의 모든 변환을 원자(atomic)
|
||||||
|
|
||||||
**Exit Criteria:** HWP/HWPX 파일을 DOCX/HTML/TXT/PDF로 변환 가능하고, md→docx 같은 멀티홉이 RouteAsync 없이 그래프 합성으로 동일하게 동작.
|
**Exit Criteria:** HWP/HWPX 파일을 DOCX/HTML/TXT/PDF로 변환 가능하고, md→docx 같은 멀티홉이 RouteAsync 없이 그래프 합성으로 동일하게 동작.
|
||||||
|
|
||||||
### P3 · 외부 프로세스 통합 + 인터페이스 일반화 `effort:L` `risk:medium` `status:planned` · depends: P2
|
### P3 · 외부 프로세스 통합 + 인터페이스 일반화 `effort:L` `risk:medium` `status:in_progress` · depends: P2
|
||||||
**목표:** 3중 복제된 LibreOffice 호출을 단일 ExternalProcessRunner로 통합하고(타임아웃·stderr·Kill), IConverterProvider/ConvertResult를 일반화해 미디어·AI 엣지가 들어올 그릇을 판다.
|
**목표:** 3중 복제된 LibreOffice 호출을 단일 ExternalProcessRunner로 통합하고(타임아웃·stderr·Kill), IConverterProvider/ConvertResult를 일반화해 미디어·AI 엣지가 들어올 그릇을 판다.
|
||||||
|
|
||||||
**산출물:**
|
**산출물:**
|
||||||
|
|
@ -154,7 +154,7 @@ Everything2Everything의 북극성은 "세상의 모든 변환을 원자(atomic)
|
||||||
|
|
||||||
**Exit Criteria:** LibreOffice가 멈춰도 타임아웃으로 복구되고 stderr가 에러 메시지에 포함되며, 기존 변환이 일반화된 시그니처로 무중단 동작(회귀 테스트 통과).
|
**Exit Criteria:** LibreOffice가 멈춰도 타임아웃으로 복구되고 stderr가 에러 메시지에 포함되며, 기존 변환이 일반화된 시그니처로 무중단 동작(회귀 테스트 통과).
|
||||||
|
|
||||||
### P4 · 미디어 레이어 — 영상/오디오 코덱·압축 `effort:XL` `risk:high` `status:planned` · depends: P3
|
### P4 · 미디어 레이어 — 영상/오디오 코덱·압축 `effort:XL` `risk:high` `status:in_progress` · depends: P3
|
||||||
**목표:** FFmpeg로 카테고리를 '미디어 변환기'로 점프시킨다. 영상/오디오 N×M 코덱·압축을 라이선스 안전하게 통합하고 배치 병렬화로 트랜스코딩 병목을 해소.
|
**목표:** FFmpeg로 카테고리를 '미디어 변환기'로 점프시킨다. 영상/오디오 N×M 코덱·압축을 라이선스 안전하게 통합하고 배치 병렬화로 트랜스코딩 병목을 해소.
|
||||||
|
|
||||||
**산출물:**
|
**산출물:**
|
||||||
|
|
@ -174,7 +174,7 @@ Everything2Everything의 북극성은 "세상의 모든 변환을 원자(atomic)
|
||||||
|
|
||||||
**Exit Criteria:** mp4→webm, wav→mp3 등 영상/오디오 변환이 HW 가속으로 동작하고 진행률·취소가 정확하며, 100개 배치가 멀티코어를 활용.
|
**Exit Criteria:** mp4→webm, wav→mp3 등 영상/오디오 변환이 HW 가속으로 동작하고 진행률·취소가 정확하며, 100개 배치가 멀티코어를 활용.
|
||||||
|
|
||||||
### P5 · AI 부가가치 레이어 — Codex OAuth + API `effort:L` `risk:high` `status:planned` · depends: P3
|
### P5 · AI 부가가치 레이어 — Codex OAuth + API `effort:L` `risk:high` `status:in_progress` · depends: P3
|
||||||
**목표:** 변환에 'AI가 더 좋게 만든다'는 해자를 얹는다. 기본은 API 키 + 공식 SDK, Codex CLI는 구독자용 opt-in. 키 없으면 AI 페어만 비활성, 기존 변환 무영향.
|
**목표:** 변환에 'AI가 더 좋게 만든다'는 해자를 얹는다. 기본은 API 키 + 공식 SDK, Codex CLI는 구독자용 opt-in. 키 없으면 AI 페어만 비활성, 기존 변환 무영향.
|
||||||
|
|
||||||
**산출물:**
|
**산출물:**
|
||||||
|
|
@ -210,7 +210,7 @@ Everything2Everything의 북극성은 "세상의 모든 변환을 원자(atomic)
|
||||||
|
|
||||||
**Exit Criteria:** HWP 파일에서 PNG까지(멀티홉) 변환 가능하고, CLI가 WPF 창 없이 JSON 결과를 stdout으로 반환해 스크립트가 파싱 가능.
|
**Exit Criteria:** HWP 파일에서 PNG까지(멀티홉) 변환 가능하고, CLI가 WPF 창 없이 JSON 결과를 stdout으로 반환해 스크립트가 파싱 가능.
|
||||||
|
|
||||||
### P7 · 순수 .NET 카테고리 보강 + manifest 어댑터 `effort:L` `risk:low` `status:planned` · depends: P6
|
### P7 · 순수 .NET 카테고리 보강 + manifest 어댑터 `effort:L` `risk:low` `status:in_progress` · depends: P6
|
||||||
**목표:** EXE 번들 가능한 순수 관리 라이브러리로 빈 카테고리를 채우고, 단순 CLI 도구를 코드 없이 추가하는 좁은 manifest 어댑터를 도입한다.
|
**목표:** EXE 번들 가능한 순수 관리 라이브러리로 빈 카테고리를 채우고, 단순 CLI 도구를 코드 없이 추가하는 좁은 manifest 어댑터를 도입한다.
|
||||||
|
|
||||||
**산출물:**
|
**산출물:**
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
{
|
{
|
||||||
"_comment": "로드맵 단계별 진행 상태. 값: planned | in_progress | done. 갱신 후 `python build.py`로 index.html/PLAN.md 재생성.",
|
"_comment": "로드맵 단계별 진행 상태. 값: planned | in_progress | done. 갱신 후 `python build.py`로 index.html/PLAN.md 재생성. 2026-06-01 세션: 사용자 결정으로 '검증 가능한 순수 .NET 먼저' 전략 → 순서가 master 로드맵과 다름(P7/P3/P4/P5 일부를 앞당겨 구현).",
|
||||||
"P1": "done",
|
"P1": "done",
|
||||||
"P2": "planned",
|
"P2": "planned",
|
||||||
"P3": "planned",
|
"P3": "in_progress",
|
||||||
"P4": "planned",
|
"P4": "in_progress",
|
||||||
"P5": "planned",
|
"P5": "in_progress",
|
||||||
"P6": "planned",
|
"P6": "planned",
|
||||||
"P7": "planned",
|
"P7": "in_progress",
|
||||||
"P8": "planned"
|
"P8": "planned"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -415,7 +415,7 @@ footer{border-top:1px solid var(--line2);margin-top:60px;padding:30px 0;color:va
|
||||||
</div></details><details class="ph"><summary>
|
</div></details><details class="ph"><summary>
|
||||||
<div class="pno">P3</div>
|
<div class="pno">P3</div>
|
||||||
<div class="pti"><b>외부 프로세스 통합 + 인터페이스 일반화</b><div class="pg">3중 복제된 LibreOffice 호출을 단일 ExternalProcessRunner로 통합하고(타임아웃·stderr·Kill), IConverterProvider/ConvertResult를 일반화해 미디어·AI 엣지가 들어올 그릇을 판다.</div></div>
|
<div class="pti"><b>외부 프로세스 통합 + 인터페이스 일반화</b><div class="pg">3중 복제된 LibreOffice 호출을 단일 ExternalProcessRunner로 통합하고(타임아웃·stderr·Kill), IConverterProvider/ConvertResult를 일반화해 미디어·AI 엣지가 들어올 그릇을 판다.</div></div>
|
||||||
<div class="pbadges"><span class="b b-plan">예정</span><span class="b b-l">L</span><span class="b b-medium">RISK medium</span><span class="b b-plan">depends · P2</span></div>
|
<div class="pbadges"><span class="b b-prog">진행중</span><span class="b b-l">L</span><span class="b b-medium">RISK medium</span><span class="b b-plan">depends · P2</span></div>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<h4>산출물</h4><ul class="lst"><li>ExternalProcessRunner(CliWrap): 타임아웃+stderr수집+Kill 통합, LibreOffice 3중 복제 흡수</li><li>Abstractions 어셈블리 분리(IConverterProvider/ConvertResult 이전, 타입 동일성)</li><li>IConverterProvider→ConvertRequest/ConvertContext 일반화, 기존 8개 Provider 어댑터로 무중단 마이그레이션</li><li>ConvertResult에 ExtractedText/AiResponse/Metadata/IntermediateArtifacts 필드</li><li>ISettingsStore(DPAPI 암호화) 신설 — API 키·도구 경로 영속화 토대</li><li>Priority 기반 충돌 모델로 _byPair.TryAdd first-wins 교체</li></ul>
|
<h4>산출물</h4><ul class="lst"><li>ExternalProcessRunner(CliWrap): 타임아웃+stderr수집+Kill 통합, LibreOffice 3중 복제 흡수</li><li>Abstractions 어셈블리 분리(IConverterProvider/ConvertResult 이전, 타입 동일성)</li><li>IConverterProvider→ConvertRequest/ConvertContext 일반화, 기존 8개 Provider 어댑터로 무중단 마이그레이션</li><li>ConvertResult에 ExtractedText/AiResponse/Metadata/IntermediateArtifacts 필드</li><li>ISettingsStore(DPAPI 암호화) 신설 — API 키·도구 경로 영속화 토대</li><li>Priority 기반 충돌 모델로 _byPair.TryAdd first-wins 교체</li></ul>
|
||||||
|
|
@ -425,7 +425,7 @@ footer{border-top:1px solid var(--line2);margin-top:60px;padding:30px 0;color:va
|
||||||
</div></details><details class="ph"><summary>
|
</div></details><details class="ph"><summary>
|
||||||
<div class="pno">P4</div>
|
<div class="pno">P4</div>
|
||||||
<div class="pti"><b>미디어 레이어 — 영상/오디오 코덱·압축</b><div class="pg">FFmpeg로 카테고리를 '미디어 변환기'로 점프시킨다. 영상/오디오 N×M 코덱·압축을 라이선스 안전하게 통합하고 배치 병렬화로 트랜스코딩 병목을 해소.</div></div>
|
<div class="pti"><b>미디어 레이어 — 영상/오디오 코덱·압축</b><div class="pg">FFmpeg로 카테고리를 '미디어 변환기'로 점프시킨다. 영상/오디오 N×M 코덱·압축을 라이선스 안전하게 통합하고 배치 병렬화로 트랜스코딩 병목을 해소.</div></div>
|
||||||
<div class="pbadges"><span class="b b-plan">예정</span><span class="b b-xl">XL</span><span class="b b-high">RISK high</span><span class="b b-plan">depends · P3</span></div>
|
<div class="pbadges"><span class="b b-prog">진행중</span><span class="b b-xl">XL</span><span class="b b-high">RISK high</span><span class="b b-plan">depends · P3</span></div>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<h4>산출물</h4><ul class="lst"><li>FfmpegProvider: FFMpegCore(MIT) + 영상(mp4/mkv/webm/mov/avi/gif)·오디오(mp3/aac/m4a/opus/flac/wav) N×M</li><li>바이너리 조달: ExternalToolDetector.TryFindFfmpeg + BtbN lgpl-shared 자동 다운로드(SHA256 검증), GlobalFFOptions 경로 고정</li><li>HW 인코더(nvenc/qsv/amf) 우선 + SW 폴백, NotifyOnProgress→IProgress 직결, CancellableThrough(ct)</li><li>배치 병렬화: ConvertManyAsync 순차 for-loop(57-70)를 Parallel.ForEachAsync로 교체(MaxDegreeOfParallelism)</li><li>PreviewService→IPreviewRenderer 추상화 + FFmpeg 프레임 추출 + 프리뷰 캐시</li><li>ImageMagick ResourceLimits 전역 설정(decompression bomb 방어) + NU190x 취약점 경고 재활성화</li></ul>
|
<h4>산출물</h4><ul class="lst"><li>FfmpegProvider: FFMpegCore(MIT) + 영상(mp4/mkv/webm/mov/avi/gif)·오디오(mp3/aac/m4a/opus/flac/wav) N×M</li><li>바이너리 조달: ExternalToolDetector.TryFindFfmpeg + BtbN lgpl-shared 자동 다운로드(SHA256 검증), GlobalFFOptions 경로 고정</li><li>HW 인코더(nvenc/qsv/amf) 우선 + SW 폴백, NotifyOnProgress→IProgress 직결, CancellableThrough(ct)</li><li>배치 병렬화: ConvertManyAsync 순차 for-loop(57-70)를 Parallel.ForEachAsync로 교체(MaxDegreeOfParallelism)</li><li>PreviewService→IPreviewRenderer 추상화 + FFmpeg 프레임 추출 + 프리뷰 캐시</li><li>ImageMagick ResourceLimits 전역 설정(decompression bomb 방어) + NU190x 취약점 경고 재활성화</li></ul>
|
||||||
|
|
@ -435,7 +435,7 @@ footer{border-top:1px solid var(--line2);margin-top:60px;padding:30px 0;color:va
|
||||||
</div></details><details class="ph"><summary>
|
</div></details><details class="ph"><summary>
|
||||||
<div class="pno">P5</div>
|
<div class="pno">P5</div>
|
||||||
<div class="pti"><b>AI 부가가치 레이어 — Codex OAuth + API</b><div class="pg">변환에 'AI가 더 좋게 만든다'는 해자를 얹는다. 기본은 API 키 + 공식 SDK, Codex CLI는 구독자용 opt-in. 키 없으면 AI 페어만 비활성, 기존 변환 무영향.</div></div>
|
<div class="pti"><b>AI 부가가치 레이어 — Codex OAuth + API</b><div class="pg">변환에 'AI가 더 좋게 만든다'는 해자를 얹는다. 기본은 API 키 + 공식 SDK, Codex CLI는 구독자용 opt-in. 키 없으면 AI 페어만 비활성, 기존 변환 무영향.</div></div>
|
||||||
<div class="pbadges"><span class="b b-plan">예정</span><span class="b b-l">L</span><span class="b b-high">RISK high</span><span class="b b-plan">depends · P3</span></div>
|
<div class="pbadges"><span class="b b-prog">진행중</span><span class="b b-l">L</span><span class="b b-high">RISK high</span><span class="b b-plan">depends · P3</span></div>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<h4>산출물</h4><ul class="lst"><li>LlmProvider: Microsoft.Extensions.AI(IChatClient)로 OpenAI/Anthropic 공식 SDK 연결 + Codex CLI opt-in(codex exec --json --output-schema)</li><li>AI 매트릭스: 요약(pdf/docx/txt→txt/md), 번역(→대상언어), OCR교정(OcrProvider 출력 2단계 파이프), 이미지 캡션(png/jpg→txt 비전), 메타데이터(→json Structured Output)</li><li>키 관리: ISettingsStore DPAPI 암호화 + OPENAI_API_KEY/ANTHROPIC_API_KEY 환경변수 폴백, CheckAvailabilityAsync 게이트</li><li>UI: AI 출력 페어에 ✨AI 배지(종량과금·네트워크 명시) + 설정에서 백엔드/모델/키 입력</li><li>Codex 경로 SemaphoreSlim(1) 직렬화(auth.json refresh 토큰 race 방지) 또는 --ephemeral</li></ul>
|
<h4>산출물</h4><ul class="lst"><li>LlmProvider: Microsoft.Extensions.AI(IChatClient)로 OpenAI/Anthropic 공식 SDK 연결 + Codex CLI opt-in(codex exec --json --output-schema)</li><li>AI 매트릭스: 요약(pdf/docx/txt→txt/md), 번역(→대상언어), OCR교정(OcrProvider 출력 2단계 파이프), 이미지 캡션(png/jpg→txt 비전), 메타데이터(→json Structured Output)</li><li>키 관리: ISettingsStore DPAPI 암호화 + OPENAI_API_KEY/ANTHROPIC_API_KEY 환경변수 폴백, CheckAvailabilityAsync 게이트</li><li>UI: AI 출력 페어에 ✨AI 배지(종량과금·네트워크 명시) + 설정에서 백엔드/모델/키 입력</li><li>Codex 경로 SemaphoreSlim(1) 직렬화(auth.json refresh 토큰 race 방지) 또는 --ephemeral</li></ul>
|
||||||
|
|
@ -455,7 +455,7 @@ footer{border-top:1px solid var(--line2);margin-top:60px;padding:30px 0;color:va
|
||||||
</div></details><details class="ph"><summary>
|
</div></details><details class="ph"><summary>
|
||||||
<div class="pno">P7</div>
|
<div class="pno">P7</div>
|
||||||
<div class="pti"><b>순수 .NET 카테고리 보강 + manifest 어댑터</b><div class="pg">EXE 번들 가능한 순수 관리 라이브러리로 빈 카테고리를 채우고, 단순 CLI 도구를 코드 없이 추가하는 좁은 manifest 어댑터를 도입한다.</div></div>
|
<div class="pti"><b>순수 .NET 카테고리 보강 + manifest 어댑터</b><div class="pg">EXE 번들 가능한 순수 관리 라이브러리로 빈 카테고리를 채우고, 단순 CLI 도구를 코드 없이 추가하는 좁은 manifest 어댑터를 도입한다.</div></div>
|
||||||
<div class="pbadges"><span class="b b-plan">예정</span><span class="b b-l">L</span><span class="b b-low">RISK low</span><span class="b b-plan">depends · P6</span></div>
|
<div class="pbadges"><span class="b b-prog">진행중</span><span class="b b-l">L</span><span class="b b-low">RISK low</span><span class="b b-plan">depends · P6</span></div>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<h4>산출물</h4><ul class="lst"><li>ArchiveProvider(SharpCompress, 순수관리) — zip/7z/tar/gz/bz2</li><li>DataProvider(Parquet.Net/ClosedXML/CsvHelper) — csv↔json↔xlsx↔parquet</li><li>VectorProvider(Svg.Skia) — svg→png/jpg/webp/pdf, EPS는 Magick+Ghostscript</li><li>PandocProvider(외부 CLI) — md/rst/latex/ipynb/epub 마크업 매트릭스, LibreOffice 겹침은 Priority 라우팅</li><li>EbookProvider(Calibre ebook-convert, 외부) — epub↔mobi↔azw3↔pdf</li><li>manifest 어댑터(ExternalProcessRunner 위 인자 템플릿): qpdf/gs 같은 단순 CLI 코드 없이 추가</li></ul>
|
<h4>산출물</h4><ul class="lst"><li>ArchiveProvider(SharpCompress, 순수관리) — zip/7z/tar/gz/bz2</li><li>DataProvider(Parquet.Net/ClosedXML/CsvHelper) — csv↔json↔xlsx↔parquet</li><li>VectorProvider(Svg.Skia) — svg→png/jpg/webp/pdf, EPS는 Magick+Ghostscript</li><li>PandocProvider(외부 CLI) — md/rst/latex/ipynb/epub 마크업 매트릭스, LibreOffice 겹침은 Priority 라우팅</li><li>EbookProvider(Calibre ebook-convert, 외부) — epub↔mobi↔azw3↔pdf</li><li>manifest 어댑터(ExternalProcessRunner 위 인자 템플릿): qpdf/gs 같은 단순 CLI 코드 없이 추가</li></ul>
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,39 @@ internal static class ExternalToolDetector
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// FFmpeg/ffprobe 바이너리 폴더를 찾는다. (1) 앱 전용 폴더(%LOCALAPPDATA%\Everything2Everything\ffmpeg),
|
||||||
|
/// (2) 시스템 PATH 순. ffmpeg.exe와 ffprobe.exe가 모두 있는 폴더만 유효.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryFindFfmpeg(out string ffmpegDirectory)
|
||||||
|
{
|
||||||
|
ffmpegDirectory = "";
|
||||||
|
var candidates = new List<string>();
|
||||||
|
|
||||||
|
var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||||
|
if (!string.IsNullOrEmpty(local))
|
||||||
|
candidates.Add(Path.Combine(local, "Everything2Everything", "ffmpeg"));
|
||||||
|
|
||||||
|
var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? "";
|
||||||
|
foreach (var dir in pathEnv.Split(Path.PathSeparator))
|
||||||
|
if (!string.IsNullOrWhiteSpace(dir))
|
||||||
|
candidates.Add(dir.Trim());
|
||||||
|
|
||||||
|
foreach (var dir in candidates.Distinct())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(Path.Combine(dir, "ffmpeg.exe")) && File.Exists(Path.Combine(dir, "ffprobe.exe")))
|
||||||
|
{
|
||||||
|
ffmpegDirectory = dir;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* 잘못된 경로 무시 */ }
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
public static bool IsH2OrestartInstalled()
|
public static bool IsH2OrestartInstalled()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|
|
||||||
94
src/Everything2Everything.Core/Converters/FfmpegProvider.cs
Normal file
94
src/Everything2Everything.Core/Converters/FfmpegProvider.cs
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
using Everything2Everything.Core.Providers;
|
||||||
|
using FFMpegCore;
|
||||||
|
|
||||||
|
namespace Everything2Everything.Core.Converters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// FFmpeg로 영상/오디오를 변환·트랜스코딩한다. ffmpeg/ffprobe가 없으면 NotReady로 비활성되며,
|
||||||
|
/// 사용자가 바이너리를 PATH 또는 앱 폴더에 두면 자동 활성화된다(LGPL 분리 호출 — 본체에 번들하지 않음).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FfmpegProvider : IConverterProvider
|
||||||
|
{
|
||||||
|
private static readonly string[] Video = { ".mp4", ".mkv", ".webm", ".mov", ".avi", ".gif" };
|
||||||
|
private static readonly string[] Audio = { ".mp3", ".aac", ".m4a", ".opus", ".ogg", ".flac", ".wav" };
|
||||||
|
|
||||||
|
public ProviderCapability Capability { get; } = new(
|
||||||
|
Id: "ffmpeg",
|
||||||
|
DisplayName: "영상·오디오 (FFmpeg)",
|
||||||
|
SupportedConversions: BuildPairs(),
|
||||||
|
Status: ProviderStatus.RequiresExternal,
|
||||||
|
Summary: "FFmpeg로 영상/오디오를 변환·트랜스코딩하고 영상에서 오디오를 추출합니다. ffmpeg가 없으면 비활성됩니다.",
|
||||||
|
ExternalDependencies: new[]
|
||||||
|
{
|
||||||
|
new ExternalDependency(
|
||||||
|
Name: "FFmpeg (LGPL)",
|
||||||
|
Description: "ffmpeg.exe와 ffprobe.exe를 시스템 PATH 또는 %LOCALAPPDATA%\\Everything2Everything\\ffmpeg 에 두면 활성화됩니다. BtbN의 win64-lgpl-shared 빌드를 권장합니다.",
|
||||||
|
DownloadUrl: "https://github.com/BtbN/FFmpeg-Builds/releases",
|
||||||
|
IsRequired: true),
|
||||||
|
},
|
||||||
|
RoadmapNote: "하드웨어 인코더(nvenc/qsv) 우선·자동 다운로드·정밀 진행률은 후속 확장.");
|
||||||
|
|
||||||
|
private static IReadOnlyList<ConversionPair> BuildPairs()
|
||||||
|
{
|
||||||
|
var pairs = new List<ConversionPair>();
|
||||||
|
pairs.AddRange(ProviderCapability.PairsFromMatrix(Video, Video, LossClass.Recode));
|
||||||
|
pairs.AddRange(ProviderCapability.PairsFromMatrix(Audio, Audio, LossClass.Recode));
|
||||||
|
pairs.AddRange(ProviderCapability.PairsFromMatrix(Video, Audio, LossClass.Recode)); // 영상 → 오디오 추출
|
||||||
|
return pairs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<ProviderAvailability> CheckAvailabilityAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!ExternalToolDetector.TryFindFfmpeg(out _))
|
||||||
|
return Task.FromResult(ProviderAvailability.NotReady(
|
||||||
|
"FFmpeg가 설치되어 있지 않습니다. ffmpeg.exe/ffprobe.exe를 PATH 또는 %LOCALAPPDATA%\\Everything2Everything\\ffmpeg 에 두면 활성화됩니다.",
|
||||||
|
Capability.ExternalDependencies));
|
||||||
|
return Task.FromResult(ProviderAvailability.Ready);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ConvertResult> ConvertAsync(
|
||||||
|
string sourcePath, string outputDirectory, string outputExtension,
|
||||||
|
ConvertOptions options, IProgress<double>? progress, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!ExternalToolDetector.TryFindFfmpeg(out var dir))
|
||||||
|
return ConvertResult.Fail(sourcePath, "FFmpeg를 찾을 수 없습니다.");
|
||||||
|
|
||||||
|
var outExt = ConversionPair.Normalize(outputExtension);
|
||||||
|
var baseName = Path.GetFileNameWithoutExtension(sourcePath);
|
||||||
|
var outPath = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, outExt, options.OnCollision);
|
||||||
|
if (OutputPathHelper.ShouldSkip(outPath, options.OnCollision))
|
||||||
|
return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다.");
|
||||||
|
|
||||||
|
var ffOptions = new FFOptions { BinaryFolder = dir };
|
||||||
|
try
|
||||||
|
{
|
||||||
|
progress?.Report(0.05);
|
||||||
|
|
||||||
|
// 진행률 best-effort: ffprobe로 길이를 알면 시간 기반 보고
|
||||||
|
TimeSpan total = TimeSpan.Zero;
|
||||||
|
try { total = (await FFProbe.AnalyseAsync(sourcePath, ffOptions, cancellationToken).ConfigureAwait(false)).Duration; }
|
||||||
|
catch { /* 길이 미상이면 진행률 생략 */ }
|
||||||
|
|
||||||
|
var processor = FFMpegArguments
|
||||||
|
.FromFileInput(sourcePath)
|
||||||
|
.OutputToFile(outPath, overwrite: true)
|
||||||
|
.CancellableThrough(cancellationToken);
|
||||||
|
|
||||||
|
if (total > TimeSpan.Zero)
|
||||||
|
processor = processor.NotifyOnProgress(
|
||||||
|
percent => progress?.Report(Math.Clamp(percent / 100.0, 0, 1)), total);
|
||||||
|
|
||||||
|
var ok = await processor.ProcessAsynchronously(throwOnError: true, ffOptions).ConfigureAwait(false);
|
||||||
|
progress?.Report(1.0);
|
||||||
|
|
||||||
|
return ok && File.Exists(outPath)
|
||||||
|
? ConvertResult.Ok(sourcePath, new[] { outPath })
|
||||||
|
: ConvertResult.Fail(sourcePath, "FFmpeg 변환에 실패했습니다.");
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return ConvertResult.Fail(sourcePath, $"FFmpeg 변환 실패: {ex.Message}", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="ClosedXML" Version="0.105.0" />
|
<PackageReference Include="ClosedXML" Version="0.105.0" />
|
||||||
<PackageReference Include="CsvHelper" Version="33.1.0" />
|
<PackageReference Include="CsvHelper" Version="33.1.0" />
|
||||||
|
<PackageReference Include="FFMpegCore" Version="5.4.0" />
|
||||||
<PackageReference Include="Magick.NET-Q16-AnyCPU" Version="14.13.0" />
|
<PackageReference Include="Magick.NET-Q16-AnyCPU" Version="14.13.0" />
|
||||||
<PackageReference Include="PDFtoImage" Version="5.2.1" />
|
<PackageReference Include="PDFtoImage" Version="5.2.1" />
|
||||||
<PackageReference Include="PhotoSauce.MagicScaler" Version="0.15.0" />
|
<PackageReference Include="PhotoSauce.MagicScaler" Version="0.15.0" />
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ public static class Everything2EverythingBootstrap
|
||||||
new Converters.VectorProvider(),
|
new Converters.VectorProvider(),
|
||||||
new Converters.ImageOptimProvider(),
|
new Converters.ImageOptimProvider(),
|
||||||
new Converters.LlmProvider(settings),
|
new Converters.LlmProvider(settings),
|
||||||
|
new Converters.FfmpegProvider(),
|
||||||
};
|
};
|
||||||
return new ConversionEngine(new ProviderRegistry(providers));
|
return new ConversionEngine(new ProviderRegistry(providers));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
40
src/Everything2Everything.Tests/FfmpegProviderTests.cs
Normal file
40
src/Everything2Everything.Tests/FfmpegProviderTests.cs
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Everything2Everything.Core;
|
||||||
|
using Everything2Everything.Core.Converters;
|
||||||
|
using Everything2Everything.Core.Providers;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Everything2Everything.Tests;
|
||||||
|
|
||||||
|
public class FfmpegProviderTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task WithoutFfmpeg_IsNotReady()
|
||||||
|
{
|
||||||
|
// 테스트 환경에 ffmpeg가 없다고 가정. 있으면 이 단언은 건너뜀.
|
||||||
|
if (ExternalToolDetector.TryFindFfmpeg(out _)) return;
|
||||||
|
var p = new FfmpegProvider();
|
||||||
|
var a = await p.CheckAvailabilityAsync();
|
||||||
|
Assert.False(a.IsReady);
|
||||||
|
Assert.NotNull(a.Reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Graph_HasVideoAndAudioEdges()
|
||||||
|
{
|
||||||
|
var graph = Everything2EverythingBootstrap.CreateDefault().Providers.Graph;
|
||||||
|
Assert.NotNull(graph.FindBestPath(".mp4", ".webm")); // 영상 트랜스코딩
|
||||||
|
Assert.NotNull(graph.FindBestPath(".wav", ".mp3")); // 오디오 변환
|
||||||
|
Assert.NotNull(graph.FindBestPath(".mp4", ".mp3")); // 영상→오디오 추출
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Capability_DeclaresVideoAudioMatrix_NoSelfPairs()
|
||||||
|
{
|
||||||
|
var caps = new FfmpegProvider().Capability.SupportedConversions;
|
||||||
|
Assert.Contains(caps, c => c.InputExtension == ".mp4" && c.OutputExtension == ".mkv");
|
||||||
|
Assert.Contains(caps, c => c.InputExtension == ".flac" && c.OutputExtension == ".mp3");
|
||||||
|
Assert.DoesNotContain(caps, c => c.InputExtension == c.OutputExtension); // 자기쌍 없음
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue