Codemaru 1.0.0.0
QR 코드, 모바일 랜딩 페이지, vCard 연락처 저장과 명함 디자인을 한 화면에서 제공하는 디지털 명함 서비스입니다.
로딩중...
검색중...
일치하는것 없음
FfmpegHlsService.cs
이 파일의 문서화 페이지로 가기
1
11
12using System.Diagnostics;
13using System.IO;
14using System.Text.RegularExpressions;
15using Microsoft.Extensions.Hosting;
16using Microsoft.Extensions.Logging;
17using Microsoft.Extensions.Options;
18using Microsoft.AspNetCore.Hosting; // IWebHostEnvironment
19using Codemaru.Models;
20
21namespace Codemaru.Services
22{
31 public sealed class FfmpegHlsService : BackgroundService
32 {
41 private readonly ILogger<FfmpegHlsService> _log;
50 private readonly FfmpegOptions _opt;
59 private readonly IReadOnlyList<StreamConfig> _streams;
68 private readonly IWebHostEnvironment _env;
69
70 // 스트림명 → ffmpeg 프로세스
79 private readonly Dictionary<string, Process> _procs = new();
80
81 // 스트림명 → 최근 m3u8 시퀀스/타임스탬프
90 private readonly Dictionary<string, long> _lastSeq = new();
99 private readonly Dictionary<string, DateTime> _lastWrite = new();
100
109 private static readonly Regex MediaSeqRegex = new(@"#EXT-X-MEDIA-SEQUENCE:(\d+)", RegexOptions.Compiled);
110
152 ILogger<FfmpegHlsService> log,
153 IOptions<FfmpegOptions> opt,
154 IOptions<List<StreamConfig>> streams,
155 IWebHostEnvironment env)
156 {
157 _log = log;
158 _opt = opt.Value;
159 _streams = streams.Value;
160 _env = env;
161 }
162
187 protected override async Task ExecuteAsync(CancellationToken ct)
188 {
189 Directory.CreateDirectory(GetOutputRoot());
190
191 foreach (var s in _streams)
192 {
193 TryStartOne(s);
195 }
196
197 // 주기적으로 프로세스 상태와 m3u8 갱신을 점검
198 while (!ct.IsCancellationRequested)
199 {
200 foreach (var s in _streams)
201 {
202 // 1) ffmpeg 죽었으면 재시작
203 if (!_procs.TryGetValue(s.Name, out var p) || p.HasExited)
204 {
205 _log.LogWarning("ffmpeg 재시작(프로세스 종료 감지): {Name}", s.Name);
206 TryStartOne(s);
208 continue;
209 }
210
211 // 2) m3u8 갱신 감시(워치독)
212 var (m3u8Path, _) = GetOutputPaths(s);
213 if (File.Exists(m3u8Path))
214 {
215 var (seq, write) = ReadSeqAndTime(m3u8Path);
216 var lastSeq = _lastSeq[s.Name];
217 var lastWrite = _lastWrite[s.Name];
218
219 // 시퀀스 증가 또는 수정 시간이 갱신되면 정상
220 if (seq > lastSeq || write > lastWrite)
221 {
222 _lastSeq[s.Name] = Math.Max(seq, lastSeq);
223 _lastWrite[s.Name] = write > lastWrite ? write : lastWrite;
224 }
225 else
226 {
227 // 일정 시간 이상 변화가 없으면 재시작
228 var idleSec = (DateTime.UtcNow - lastWrite.ToUniversalTime()).TotalSeconds;
229 var idleLimit = Math.Max(_opt.WatchdogIdleSeconds, _opt.HlsSegmentSeconds * _opt.HlsListSize);
230 if (idleSec >= idleLimit)
231 {
232 _log.LogWarning("m3u8 갱신 정지 감지({Idle}s >= {Limit}s): {Name} → ffmpeg 재시작",
233 idleSec, idleLimit, s.Name);
234 SafeRestart(s);
236 continue;
237 }
238 }
239 }
240 else
241 {
242 // 파일이 없으면 ffmpeg가 아직 시작 중이거나 실패→ 잠시 후 다시 확인
243 _log.LogDebug("m3u8 미존재: {Name} ({Path})", s.Name, m3u8Path);
244 }
245 }
246
247 await Task.Delay(TimeSpan.FromSeconds(_opt.WatchdogIntervalSeconds), ct);
248 }
249 }
250
275 public override Task StopAsync(CancellationToken cancellationToken)
276 {
277 foreach (var p in _procs.Values)
278 {
279 try
280 {
281 if (!p.HasExited)
282 {
283 p.Kill(entireProcessTree: true);
284 p.WaitForExit(2000);
285 }
286 }
287 catch { /* ignore */ }
288 }
289 _procs.Clear();
290 return Task.CompletedTask;
291 }
292
309 private string GetOutputRoot()
310 {
311 // appsettings에 상대경로("wwwroot\\hls")인 경우 ContentRoot 기준으로 절대경로화
312 return Path.IsPathRooted(_opt.OutputRoot)
313 ? _opt.OutputRoot
314 : Path.Combine(_env.ContentRootPath, _opt.OutputRoot);
315 }
316
341 private (string m3u8Path, string segFmt) GetOutputPaths(StreamConfig s)
342 {
343 var outRoot = GetOutputRoot();
344 var outDir = Path.Combine(outRoot, s.Name);
345 Directory.CreateDirectory(outDir);
346
347 // 가독성 및 캐싱 안전성 위해 seg_%05d.ts 권장
348 var m3u8Path = Path.Combine(outDir, "index.m3u8");
349 var segFmt = Path.Combine(outDir, "seg_%05d.ts");
350 return (m3u8Path, segFmt);
351 }
352
369 private void SafeRestart(StreamConfig s)
370 {
371 if (_procs.TryGetValue(s.Name, out var old))
372 {
373 try
374 {
375 if (!old.HasExited)
376 {
377 old.Kill(entireProcessTree: true);
378 old.WaitForExit(2000);
379 }
380 }
381 catch { /* ignore */ }
382 }
383 TryStartOne(s);
384 }
385
402 private void TryStartOne(StreamConfig s)
403 {
404 var (m3u8Path, segFmt) = GetOutputPaths(s);
405
406 // 입력 옵션: TCP 강제(안정), 타임스탬프 보정
407 var inputOpts = "-rtsp_transport tcp -rtsp_flags prefer_tcp -fflags +genpts -use_wallclock_as_timestamps 1";
408
409 // 인코딩/복사 분기
410 var v = (s.VideoCodec ?? _opt.VideoCodec).ToLowerInvariant();
411 var a = (s.AudioCodec ?? _opt.AudioCodec).ToLowerInvariant();
412
413 // 비디오 파트
414 // - 트랜스코딩: GOP 고정 (hls_time 2초라면 gop≈50@25fps / 60@30fps 등으로 맞추세요)
415 var videoPart =
416 v == "copy"
417 ? "-c:v copy -bsf:v h264_mp4toannexb"
418 : "-c:v libx264 -preset veryfast -tune zerolatency -profile:v high -level 4.1 -pix_fmt yuv420p " +
419 "-g 50 -keyint_min 50 -sc_threshold 0 -force_key_frames \"expr:gte(t,n_forced*2)\"";
420
421 // 오디오 파트
422 var audioPart =
423 a == "an" ? "-an"
424 : $"-c:a {a} -b:a {_opt.AudioBitrate} -ar {_opt.AudioRate} -ac {_opt.AudioChannels}";
425
426 // HLS 파트(독립 세그먼트 + 프로그램 시간 + 삭제)
427 var hlsPart = string.Join(' ', new[]
428 {
429 "-f hls",
430 $"-hls_time {_opt.HlsSegmentSeconds}",
431 $"-hls_list_size {_opt.HlsListSize}",
432 "-hls_flags delete_segments+program_date_time+independent_segments",
433 $"-hls_segment_filename \"{segFmt}\"",
434 $"\"{m3u8Path}\""
435 });
436
437 // 전체 인자 조합
438 var args = string.Join(' ', new[]
439 {
440 inputOpts,
441 $"-i \"{s.RtspUrl}\"",
442 videoPart,
443 audioPart,
444 hlsPart
445 });
446
447 var psi = new ProcessStartInfo
448 {
449 FileName = _opt.Path,
450 Arguments = args,
451 UseShellExecute = false,
452 RedirectStandardError = true,
453 RedirectStandardOutput = true,
454 CreateNoWindow = true
455 };
456
457 var proc = new Process { StartInfo = psi, EnableRaisingEvents = true };
458 proc.ErrorDataReceived += (_, e) =>
459 {
460 if (!string.IsNullOrEmpty(e.Data))
461 _log.LogInformation("[{name}] {line}", s.Name, e.Data);
462 };
463 proc.OutputDataReceived += (_, e) =>
464 {
465 if (!string.IsNullOrEmpty(e.Data))
466 _log.LogDebug("[{name}] {line}", s.Name, e.Data);
467 };
468 proc.Exited += (_, __) =>
469 {
470 _log.LogWarning("ffmpeg 종료 감지: {Name} (ExitCode={Code})", s.Name, proc.ExitCode);
471 };
472
473 if (proc.Start())
474 {
475 proc.BeginErrorReadLine();
476 proc.BeginOutputReadLine();
477 _procs[s.Name] = proc;
478 _log.LogInformation("ffmpeg 시작: {Name} → {Out}", s.Name, Path.GetDirectoryName(m3u8Path));
479 }
480 else
481 {
482 _log.LogError("ffmpeg 시작 실패: {Name}", s.Name);
483 }
484 }
485
503 {
504 _lastSeq[s.Name] = -1;
505 _lastWrite[s.Name] = DateTime.MinValue;
506 }
507
532 private (long seq, DateTime lastWrite) ReadSeqAndTime(string m3u8Path)
533 {
534 try
535 {
536 var text = File.ReadAllText(m3u8Path);
537 var m = MediaSeqRegex.Match(text);
538 var seq = m.Success ? long.Parse(m.Groups[1].Value) : -1;
539 var info = new FileInfo(m3u8Path);
540 return (seq, info.LastWriteTimeUtc);
541 }
542 catch (Exception ex)
543 {
544 _log.LogDebug("m3u8 읽기 실패: {Path} - {Msg}", m3u8Path, ex.Message);
545 return (-1, DateTime.MinValue);
546 }
547 }
548 }
549}
override async Task ExecuteAsync(CancellationToken ct)
override Task StopAsync(CancellationToken cancellationToken)
readonly IWebHostEnvironment _env
readonly Dictionary< string, DateTime > _lastWrite
FfmpegHlsService(ILogger< FfmpegHlsService > log, IOptions< FfmpegOptions > opt, IOptions< List< StreamConfig > > streams, IWebHostEnvironment env)
long DateTime lastWrite ReadSeqAndTime(string m3u8Path)
readonly Dictionary< string, Process > _procs
readonly Dictionary< string, long > _lastSeq
readonly IReadOnlyList< StreamConfig > _streams
string string segFmt GetOutputPaths(StreamConfig s)
readonly ILogger< FfmpegHlsService > _log