feat: CCTV 재생 기능 추가

prototype
HyungJune Kim 10 months ago
parent d0254a590e
commit 55e22ddcc3

@ -0,0 +1,228 @@
using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Drawing;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using FFmpeg.AutoGen;
using SmartAquaViewer.Helper.FFHelper;
namespace SmartAquaViewer.Classes
{
public static class RtspImageBehavior
{
public static readonly DependencyProperty SourceUrlProperty =
DependencyProperty.RegisterAttached(
"SourceUrl", typeof(string), typeof(RtspImageBehavior),
new PropertyMetadata(null, OnSourceUrlChanged));
public static void SetSourceUrl(DependencyObject obj, string value) => obj.SetValue(SourceUrlProperty, value);
public static string GetSourceUrl(DependencyObject obj) => (string)obj.GetValue(SourceUrlProperty);
private static readonly DependencyProperty PlayerProperty =
DependencyProperty.RegisterAttached(
"Player", typeof(ImageRtspAdapter), typeof(RtspImageBehavior), new PropertyMetadata(null));
private static void OnSourceUrlChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not System.Windows.Controls.Image img) return;
// 1) 기존 플레이어 정리
if (img.GetValue(PlayerProperty) is ImageRtspAdapter old)
{
old.Dispose();
img.ClearValue(PlayerProperty);
img.Source = null;
img.Visibility = Visibility.Collapsed;
}
// 2) 신규 URL 연결
var url = e.NewValue as string;
if (string.IsNullOrWhiteSpace(url)) return;
var player = new ImageRtspAdapter(img, url);
img.SetValue(PlayerProperty, player);
player.Start();
img.Unloaded -= OnUnloaded;
img.Unloaded += OnUnloaded;
}
private static void OnUnloaded(object sender, RoutedEventArgs e)
{
if (sender is System.Windows.Controls.Image img && img.GetValue(PlayerProperty) is ImageRtspAdapter p)
{
p.Dispose();
img.ClearValue(PlayerProperty);
}
if (sender is System.Windows.Controls.Image i) i.Unloaded -= OnUnloaded;
}
}
public sealed class ImageRtspAdapter : IDisposable
{
private readonly System.Windows.Controls.Image _img;
private readonly string _url;
private Thread? _videoThread;
private Thread? _renderThread;
private CancellationTokenSource? _videoCts;
private CancellationTokenSource? _renderCts;
private readonly Queue<AVFrame> _frameQueue = new(); // lock 불필요
private volatile bool _disposed;
// 큐 최대 길이(지연 방지용): 최신 프레임 위주로 보여주기
private const int MaxQueue = 2;
private object _lockObject = new();
public ImageRtspAdapter(System.Windows.Controls.Image img, string url)
{
_img = img;
_url = url;
}
public void Start()
{
_videoCts = new CancellationTokenSource();
_renderCts = new CancellationTokenSource();
_videoThread = new Thread(OpenMedia) { IsBackground = true, Name = "RTSP-Decode" };
_renderThread = new Thread(RenderLoop) { IsBackground = true, Name = "RTSP-Render" };
_videoThread.Start();
_renderThread.Start();
}
private unsafe void OpenMedia()
{
try
{
using (var sd = new StreamDecoder(_url))
using (var vfc = new VideoFrameConverter(sd.FrameSize, sd.PixelFormat, sd.FrameSize, AVPixelFormat.AV_PIX_FMT_BGR24))
{
while (!_videoCts!.IsCancellationRequested)
{
if (!sd.TryDecodeNextFrame(out var frame))
{
// 디코드 실패: 너무 바쁘지 않게 살짝 쉼
Thread.Sleep(2);
continue;
}
try
{
AVFrame convertedFrame = vfc.Convert(frame);
EnqueueFrame(convertedFrame); // 큐 삽입 (길이 제한 적용)
}
catch (Exception ex)
{
Debug.WriteLine("Decode/Enqueue error: " + ex.Message);
}
}
}
}
catch (Exception ex)
{
Debug.WriteLine("OpenMedia() : " + ex);
}
}
private void EnqueueFrame(AVFrame avf)
{
_frameQueue.Enqueue(avf);
}
private unsafe void RenderLoop()
{
try
{
while (!_renderCts!.IsCancellationRequested)
{
if (_frameQueue.Count > 0)
{
AVFrame convertedFrame;
lock (_lockObject)
{
if (_frameQueue.Count > 0)
{
convertedFrame = _frameQueue.Dequeue();
}
else
{
continue;
}
}
try
{
Bitmap bitmap = new Bitmap(convertedFrame.width, convertedFrame.height, convertedFrame.linesize[0], System.Drawing.Imaging.PixelFormat.Format24bppRgb, (IntPtr)convertedFrame.data[0]);
var src = CreateBitmapSource(bitmap);
src.Freeze();
_img.Dispatcher.BeginInvoke(new Action(() =>
{
if (_disposed) { return; }
_img.Source = src;
if (_img.Visibility != Visibility.Visible)
_img.Visibility = Visibility.Visible;
}), DispatcherPriority.Render);
}
catch (Exception ex)
{
Debug.WriteLine("RenderLoop() : " + ex.Message);
}
}
}
}
catch (OperationCanceledException) { /* 정상 종료 */ }
finally
{
//ClearQueue();
}
}
private static BitmapSource CreateBitmapSource(Bitmap bitmap)
{
// GDI 핸들 사용 X. LockBits → BitmapSource.Create 경로만 사용.
var rect = new System.Drawing.Rectangle(0, 0, bitmap.Width, bitmap.Height);
var data = bitmap.LockBits(rect, System.Drawing.Imaging.ImageLockMode.ReadOnly, bitmap.PixelFormat);
try
{
// Format24bppRgb ↔ PixelFormats.Bgr24 매칭
return BitmapSource.Create(
data.Width, data.Height, 96, 96,
PixelFormats.Bgr24, null,
data.Scan0, data.Stride * data.Height, data.Stride);
}
finally
{
bitmap.UnlockBits(data);
}
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
try { _videoCts?.Cancel(); } catch { }
try { _renderCts?.Cancel(); } catch { }
try { if (_videoThread?.IsAlive == true) _videoThread.Join(300); } catch { }
try { if (_renderThread?.IsAlive == true) _renderThread.Join(300); } catch { }
_frameQueue.Clear();
_videoCts?.Dispose();
_renderCts?.Dispose();
}
}
}

@ -7,6 +7,7 @@ using System.Text;
using System.Threading.Tasks;
using FFmpeg.AutoGen;
using System.Windows;
using System.IO;
namespace SmartAquaViewer.Helper.FFHelper
{
@ -29,8 +30,34 @@ namespace SmartAquaViewer.Helper.FFHelper
public bool _isContextNotInitialized = false;
public double _fps;
private static bool _ffInited;
private static void EnsureFFmpegLoaded()
{
if (_ffInited) return;
// 실행폴더\ffmpeg 강제 지정 (RootPath는 어떤 ffmpeg.* 호출보다 먼저!)
var root = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "ffmpeg");
ffmpeg.RootPath = root;
// 네트워크 초기화 (중복 호출 무해)
ffmpeg.avformat_network_init();
// 여기서 한번 강제로 심볼 로딩 검증
var ver = ffmpeg.av_version_info();
_ffInited = true;
}
private static void SetLogLevelSafe(int level)
{
try { ffmpeg.av_log_set_level(level); }
catch (NotSupportedException) { /* 일부 조합에서만 실패 → 그냥 무시 */ }
}
public StreamDecoder(string url)
{
//Application.Current.Dispatcher.Invoke(() =>
//{
// ProgressWindow.GetInstance().Show();
@ -45,7 +72,8 @@ namespace SmartAquaViewer.Helper.FFHelper
try
{
ffmpeg.av_log_set_level(ffmpeg.AV_LOG_DEBUG);
EnsureFFmpegLoaded(); // ★ 가장 첫 줄
SetLogLevelSafe(ffmpeg.AV_LOG_ERROR);
_pFormatContext = ffmpeg.avformat_alloc_context();
if (_pFormatContext == null)

@ -40,7 +40,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="FFmpeg.AutoGen" Version="7.1.1" />
<PackageReference Include="FFmpeg.AutoGen" Version="4.4.0" />
<PackageReference Include="MaterialDesignColors" Version="5.2.1" />
<PackageReference Include="MaterialDesignThemes" Version="5.2.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.8" />

@ -4,6 +4,7 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:SmartAquaViewer.View"
xmlns:classes="clr-namespace:SmartAquaViewer.Classes"
mc:Ignorable="d"
d:DesignHeight="450" d:DesignWidth="800">
<Grid Background="Transparent">
@ -15,7 +16,39 @@
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border/>
<Border>
<Grid Background="Transparent">
<Grid.RowDefinitions>
<RowDefinition Height="24"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Border Grid.RowSpan="2" BorderThickness="1" x:Name="bdrNoSignalContainer" Visibility="Collapsed">
<Border.BorderBrush>
<SolidColorBrush Color="#394861" Opacity="0.4"></SolidColorBrush>
</Border.BorderBrush>
<Border.Background>
<SolidColorBrush Color="#22262D" Opacity="0.4"></SolidColorBrush>
</Border.Background>
<Grid VerticalAlignment="Center" HorizontalAlignment="Center" Background="Transparent">
<Grid.RowDefinitions>
<RowDefinition></RowDefinition>
<RowDefinition></RowDefinition>
</Grid.RowDefinitions>
<Label Name="lblVideoStatus" Content="연결 없음" Grid.Row="0" FontSize="17" Foreground="#414961" HorizontalContentAlignment="Center"></Label>
</Grid>
</Border>
<Image x:Name="imgPlayer" Grid.Row="0" Visibility="Collapsed" Grid.RowSpan="2" Stretch="Fill" Margin="0"
classes:RtspImageBehavior.SourceUrl="{Binding RtspUrl}"/>
<TextBlock Text="{Binding DeviceId}" Margin="20 5 5 0" x:Name="lblCCTVId"
FontFamily="{StaticResource SCDream6}" FontSize="15" Foreground="White"/>
<Grid Grid.Row="1">
<StackPanel VerticalAlignment="Bottom" Orientation="Vertical">
<TextBlock Text="{Binding DeviceName}" HorizontalAlignment="Right" Margin="20, 5, 5, 0" x:Name="lblCCTVName"
FontFamily="{StaticResource SCDream6}" FontSize="15" Foreground="White"/>
</StackPanel>
</Grid>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>

@ -68,28 +68,42 @@ namespace SmartAquaViewer.ViewModel
{
DeviceId = "000003",
DeviceName = "CCTV 3",
RtspUrl = "rtsp://210.217.121.58:8554/CAM-211",
RtspUrl = "rtsp://210.217.121.58:8554/CAM-01",
Status = "Active"
},
new()
{
DeviceId = "000004",
DeviceName = "CCTV 4",
RtspUrl = "rtsp://210.217.121.58:8554/CAM-212",
RtspUrl = "rtsp://210.217.121.58:8554/CAM-02",
Status = "Active"
},
new()
{
DeviceId = "000005",
DeviceName = "CCTV 5",
RtspUrl = "rtsp://210.217.121.58:8554/CAM-211",
RtspUrl = "rtsp://210.217.121.58:8554/CAM-03",
Status = "Active"
},
new()
{
DeviceId = "000006",
DeviceName = "CCTV 6",
RtspUrl = "rtsp://210.217.121.58:8554/CAM-212",
RtspUrl = "rtsp://210.217.121.58:8554/CAM-04",
Status = "Active"
},
new()
{
DeviceId = "000007",
DeviceName = "CCTV 7",
RtspUrl = "rtsp://210.217.121.58:8554/CAM-05",
Status = "Active"
},
new()
{
DeviceId = "000008",
DeviceName = "CCTV 8",
RtspUrl = "rtsp://210.217.121.58:8554/CAM-06",
Status = "Active"
}
};

@ -340,7 +340,7 @@ namespace SmartAquaViewer.ViewModel
}
// 3) 팔레트
var colors = OxyPalettes.HueDistinct(tankIds.Count).Colors;
var colors = OxyPalettes.Hot(tankIds.Count).Colors;
for (int k = 0; k < tankIds.Count; k++)
{
@ -441,7 +441,7 @@ namespace SmartAquaViewer.ViewModel
if (tankIds.Count == 0) { Model.InvalidatePlot(true); return; }
// 색상/마커
var colors = OxyPalettes.HueDistinct(tankIds.Count).Colors;
var colors = OxyPalettes.Hot(tankIds.Count).Colors;
var markerCycle = new[]
{
MarkerType.Circle, MarkerType.Square, MarkerType.Triangle, MarkerType.Diamond,

@ -7,6 +7,7 @@ using System.Runtime.CompilerServices;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Input;
using FFmpeg.AutoGen;
using Newtonsoft.Json;
using SmartAquaViewer.Controls;
using SmartAquaViewer.DataAnalysis;
@ -54,6 +55,8 @@ namespace SmartAquaViewer.ViewModel
SelectedViewModel = new MonitoringViewModel(); // Default view
ffmpeg.RootPath = $"{Environment.CurrentDirectory}\\ffmpeg";
//더미데이터 생성 및 파일로 저장
//for (int i = 1; i <= 10; i++)
//{

Loading…
Cancel
Save