From 1dcbd9a0b6ab97f33a8dfc375aa2d1901c6d8e9b Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Wed, 28 Oct 2026 00:04:05 +0900 Subject: [PATCH] Read Dahua DVR .dav recordings through FFmpeg (#594) .dav is a read-only video format, read by FFmpeg's dhav demuxer (found by the DHAV chunks, no -f needed) and converted to every video and audio target. A reporter's Dahua recording (H.264 1080p + AAC) probes as dhav and converts to MP4. Settings schema 49 teaches existing video donuts .dav. The engine test builds a DHAV recording from FFmpeg-made H.264 and A-law sound, since FFmpeg has no DHAV muxer; no sample file is committed. Co-Authored-By: Claude Opus 5.5 --- README.ar.md | 2 +- README.es.md | 2 +- README.fr.md | 2 +- README.hi.md | 2 +- README.ja.md | 2 +- README.ko.md | 2 +- README.md | 2 +- README.pl.md | 2 +- README.pt-BR.md | 2 +- README.pt-PT.md | 2 +- README.ru.md | 2 +- README.th.md | 2 +- README.zh-CN.md | 2 +- README.zh-TW.md | 2 +- docs/ENGINES.md | 5 + src/Filee.Core/Formats/FormatRegistry.cs | 3 + src/Filee.Core/Settings/AppSettings.cs | 2 +- src/Filee.Core/Settings/UserDataStore.cs | 4 + tests/Filee.Core.Tests/DavFormatTests.cs | 34 +++++++ tests/Filee.Engines.Tests/MediaTests.cs | 112 +++++++++++++++++++++++ 20 files changed, 173 insertions(+), 15 deletions(-) create mode 100644 tests/Filee.Core.Tests/DavFormatTests.cs diff --git a/README.ar.md b/README.ar.md index d4ddf7c2e..8c000f8e9 100644 --- a/README.ar.md +++ b/README.ar.md @@ -58,7 +58,7 @@ تظهر الصيغ حيث أنت، ولا يظهر منها إلا ما يناسب الملفات التي تسحبها. - 266 صيغة دون برامج أخرى
+ 267 صيغة دون برامج أخرى
الصور وPDF وWord وExcel وPowerPoint وHWP والكتب الإلكترونية والأرشيفات والخطوط وCAD والنماذج ثلاثية الأبعاد والبيانات الجغرافية، كلها تعمل دون Office أو Hancom Office أو LibreOffice. diff --git a/README.es.md b/README.es.md index fb39aa7f6..13358a122 100644 --- a/README.es.md +++ b/README.es.md @@ -56,7 +56,7 @@ Los formatos aparecen donde ya estás, y solo los que tienen sentido para los archivos que arrastras. - 266 formatos, sin otros programas
+ 267 formatos, sin otros programas
Imágenes, PDF, Word, Excel, PowerPoint, HWP, libros electrónicos, archivos comprimidos, tipografías, CAD, modelos 3D y datos geoespaciales funcionan sin Office, Hancom Office ni LibreOffice. diff --git a/README.fr.md b/README.fr.md index f82ce86f0..fc04c878d 100644 --- a/README.fr.md +++ b/README.fr.md @@ -56,7 +56,7 @@ Les formats apparaissent là où vous êtes déjà, et seulement ceux qui ont un sens pour les fichiers que vous faites glisser. - 266 formats, sans autre logiciel
+ 267 formats, sans autre logiciel
Images, PDF, Word, Excel, PowerPoint, HWP, livres numériques, archives, polices, CAO, modèles 3D et données géospatiales fonctionnent sans Office, Hancom Office ni LibreOffice. diff --git a/README.hi.md b/README.hi.md index e05973088..29373cf82 100644 --- a/README.hi.md +++ b/README.hi.md @@ -56,7 +56,7 @@ फ़ॉर्मैट वहीं दिखते हैं जहाँ आप पहले से हैं, और सिर्फ़ वही जो आपकी खींची जा रही फ़ाइलों के लिए सही हों। - 266 फ़ॉर्मैट, कोई और सॉफ़्टवेयर नहीं
+ 267 फ़ॉर्मैट, कोई और सॉफ़्टवेयर नहीं
इमेज, PDF, Word, Excel, PowerPoint, HWP, ई-बुक, आर्काइव, फ़ॉन्ट, CAD, 3D मॉडल और भू-स्थानिक डेटा Office, Hancom Office या LibreOffice के बिना काम करते हैं। diff --git a/README.ja.md b/README.ja.md index ea211204a..0af9d0981 100644 --- a/README.ja.md +++ b/README.ja.md @@ -56,7 +56,7 @@ いま作業している場所に、ドラッグ中のファイルに合う形式だけが表示されます。 - 266形式、ほかのソフトは不要
+ 267形式、ほかのソフトは不要
画像、PDF、Word、Excel、PowerPoint、HWP、電子書籍、アーカイブ、フォント、CAD、3Dモデル、地理空間データを、Office、Hancom Office、LibreOfficeなしで変換できます。 diff --git a/README.ko.md b/README.ko.md index 01740b605..3bb2a685d 100644 --- a/README.ko.md +++ b/README.ko.md @@ -57,7 +57,7 @@ 끌고 있는 파일에 맞는 형식만 지금 있는 자리에 나타나요. - 266가지 형식, 다른 프로그램 없이
+ 267가지 형식, 다른 프로그램 없이
이미지, PDF, 워드, 엑셀, 파워포인트, HWP, 전자책, 압축 파일, 글꼴, CAD, 3D 모델, 지리 정보 데이터를 오피스·한컴오피스·LibreOffice 없이 변환해요. diff --git a/README.md b/README.md index 29a6c995e..44f912cde 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ The formats appear where you already are, only the ones that make sense for the files you drag. - 266 formats, no other software
+ 267 formats, no other software
Images, PDF, Word, Excel, PowerPoint, HWP, e-books, archives, fonts, CAD, 3D models and geospatial data work without Office, Hancom Office or LibreOffice. diff --git a/README.pl.md b/README.pl.md index 35bfc3a11..e2d213928 100644 --- a/README.pl.md +++ b/README.pl.md @@ -56,7 +56,7 @@ Formaty pojawiają się tam, gdzie już jesteś, i tylko te, które pasują do przeciąganych plików. - 266 formatów bez dodatkowych programów
+ 267 formatów bez dodatkowych programów
Obrazy, PDF, Word, Excel, PowerPoint, HWP, e-booki, archiwa, czcionki, CAD, modele 3D i dane geoprzestrzenne działają bez pakietu Office, Hancom Office i LibreOffice. diff --git a/README.pt-BR.md b/README.pt-BR.md index 91fc978ae..d5761d023 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -56,7 +56,7 @@ Os formatos aparecem onde você já está, só os que fazem sentido para os arquivos que você arrasta. - 266 formatos, sem outro programa
+ 267 formatos, sem outro programa
Imagens, PDF, Word, Excel, PowerPoint, HWP, e-books, arquivos compactados, fontes, CAD, modelos 3D e dados geoespaciais funcionam sem Office, Hancom Office ou LibreOffice. diff --git a/README.pt-PT.md b/README.pt-PT.md index 23d027e4f..8993a13fb 100644 --- a/README.pt-PT.md +++ b/README.pt-PT.md @@ -56,7 +56,7 @@ Os formatos aparecem onde estás, apenas os que fazem sentido para os ficheiros que arrastas. - 266 formatos, sem outro software
+ 267 formatos, sem outro software
Imagens, PDF, Word, Excel, PowerPoint, HWP, livros digitais, arquivos, tipos de letra, CAD, modelos 3D e dados geoespaciais funcionam sem Office, Hancom Office ou LibreOffice. diff --git a/README.ru.md b/README.ru.md index 0f62d18d3..c7ca7777b 100644 --- a/README.ru.md +++ b/README.ru.md @@ -56,7 +56,7 @@ Форматы появляются там, где вы уже работаете, и только те, что подходят перетаскиваемым файлам. - 266 форматов без лишних программ
+ 267 форматов без лишних программ
Изображения, PDF, Word, Excel, PowerPoint, HWP, электронные книги, архивы, шрифты, чертежи CAD, 3D-модели и геоданные работают без Office, Hancom Office и LibreOffice. diff --git a/README.th.md b/README.th.md index 2c4c456f9..fcd73a938 100644 --- a/README.th.md +++ b/README.th.md @@ -56,7 +56,7 @@ ฟอร์แมตที่เหมาะสมจะแสดงขึ้นตรงจุดที่คุณทำงานอยู่ โดยแสดงเฉพาะฟอร์แมตที่ใช้ได้กับไฟล์ที่คุณลากเท่านั้น - 266 ฟอร์แมต โดยไม่ต้องลงโปรแกรมอื่นเพิ่ม
+ 267 ฟอร์แมต โดยไม่ต้องลงโปรแกรมอื่นเพิ่ม
รูปภาพ, PDF, Word, Excel, PowerPoint, HWP, อีบุ๊ก, ไฟล์บีบอัด, ฟอนต์, CAD, โมเดล 3D และข้อมูลเชิงพื้นที่ ทำงานได้โดยไม่ต้องมี Microsoft Office, Hancom Office หรือ LibreOffice diff --git a/README.zh-CN.md b/README.zh-CN.md index 149de6a97..2608a02d3 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -56,7 +56,7 @@ 只显示适合当前拖动文件的格式,就在你所在的位置。 - 266 种格式,无需其他软件
+ 267 种格式,无需其他软件
图片、PDF、Word、Excel、PowerPoint、HWP、电子书、压缩包、字体、CAD、3D 模型和地理信息数据,无需 Office、韩文办公软件或 LibreOffice。 diff --git a/README.zh-TW.md b/README.zh-TW.md index bda85f7d4..1c459297b 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -56,7 +56,7 @@ 只顯示適合當前拖曳檔案的格式,就在你所在的位置。 - 266 種格式,無需其他軟體
+ 267 種格式,無需其他軟體
圖片、PDF、Word、Excel、PowerPoint、HWP、電子書、壓縮檔、字型、CAD、3D 模型和地理資訊資料,無需 Office、韓文文書軟體或 LibreOffice。 diff --git a/docs/ENGINES.md b/docs/ENGINES.md index 42fdc3a09..19297c013 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -1463,6 +1463,11 @@ A self-contained `.m4s` (read only, #354), a fragmented MP4 that starts with `ft and goes to every video and audio target. A DASH media segment, whose top-level boxes reach a `moof` before any `moov`, plays only with a separate init segment; Filee refuses it before FFmpeg runs, with its own message (`error.m4s_needs_init`), instead of FFmpeg's demuxer error. +Dahua DVR / NVR recordings (`.dav`, read only, #594) are read by FFmpeg's `dhav` demuxer, which finds them by the +`DHAV` chunks they are made of, and go to every video and audio target. Their H.264 / H.265 video is copied where +the target and preset allow it (`CanCopyVideo`), like any other container's; G.711 sound is encoded for targets that +can't hold it. The chunks carry the camera's wall-clock time, so FFmpeg reports the recording date as the start time; +the output starts at zero. A Video CD's `MPEGAV/AVSEQ01.DAT` (MPEG-1 in a RIFF/CDXA wrapper) counts as MPEG: `.dat` is an extension of that format, and FFmpeg reads what the file holds, so a `.dat` with no video in it fails instead of making a file. diff --git a/src/Filee.Core/Formats/FormatRegistry.cs b/src/Filee.Core/Formats/FormatRegistry.cs index 93cb0a8f6..4e5cc37c4 100644 --- a/src/Filee.Core/Formats/FormatRegistry.cs +++ b/src/Filee.Core/Formats/FormatRegistry.cs @@ -229,6 +229,9 @@ public static class FormatRegistry // Fragmented MP4 (#354): an .m4s with its own moov (ftyp + moov, then styp / sidx / moof / mdat) is read by FFmpeg; // a DASH media segment that needs a separate init segment is refused (DashSegment). new("m4s", "M4S", FormatCategory.Video, ["m4s"], Writable: false), + // Dahua and similar DVR / NVR recordings (#594): FFmpeg's dhav demuxer reads them (H.264 / H.265 video, often + // with G.711 or AAC sound). + new("dav", "DAV", FormatCategory.Video, ["dav"], Writable: false), // Raw H.265 / H.264 streams with no container, as security cameras and VMS software export them (issue #301). new("h265", "H.265", FormatCategory.Video, ["h265", "265", "hevc"], Writable: false), new("h264", "H.264", FormatCategory.Video, ["h264", "264"], Writable: false), diff --git a/src/Filee.Core/Settings/AppSettings.cs b/src/Filee.Core/Settings/AppSettings.cs index bede3d9c9..b5f953c47 100644 --- a/src/Filee.Core/Settings/AppSettings.cs +++ b/src/Filee.Core/Settings/AppSettings.cs @@ -123,7 +123,7 @@ public static bool IsFlipKeyAllowed(string key, IEnumerable gest public sealed class AppSettings { /// Current schema version. Increase when the format changes and add a migration. - public const int CurrentSchemaVersion = 48; + public const int CurrentSchemaVersion = 49; public int SchemaVersion { get; set; } = CurrentSchemaVersion; diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs index f9c47a370..88ff480f9 100644 --- a/src/Filee.Core/Settings/UserDataStore.cs +++ b/src/Filee.Core/Settings/UserDataStore.cs @@ -889,6 +889,10 @@ public static bool ApplyToLibrary(int from, List presets, List p.Id == "video"); + video.Extensions.Remove("dav"); + + Assert.True(SettingsMigrations.ApplyToLibrary(48, presets, profiles)); + + Assert.Contains("dav", video.Extensions); + Assert.False(SettingsMigrations.ApplyToLibrary(48, presets, profiles)); + } +} diff --git a/tests/Filee.Engines.Tests/MediaTests.cs b/tests/Filee.Engines.Tests/MediaTests.cs index e23213518..30a558bcd 100644 --- a/tests/Filee.Engines.Tests/MediaTests.cs +++ b/tests/Filee.Engines.Tests/MediaTests.cs @@ -2,6 +2,7 @@ // when Filee's FFmpeg is installed (`pwsh build/fetch-engines.ps1 -Only ffmpeg`; CI does not install it). Inputs are // generated by FFmpeg itself (test pattern + sine tone) and every output is checked with ffprobe. +using System.Buffers.Binary; using System.Diagnostics; using System.Globalization; using System.Text.Json; @@ -44,6 +45,117 @@ public void M4s_pieces_are_read_but_never_written() Assert.Null(Planner().Plan("mp4", "m4s")); } + [Fact] + public void Dav_recordings_are_read_but_never_written() + { + // #594: Dahua DVR recordings, read by FFmpeg's dhav demuxer; never a target. + var route = Planner().Plan("dav", "mp4"); + Assert.NotNull(route); + Assert.Equal("ffmpeg", Assert.Single(route.Steps).Converter.Id); + Assert.Null(Planner().Plan("mp4", "dav")); + } + + [Fact] + public async Task A_dahua_dav_recording_converts_to_mp4() + { + // FFmpeg has no DHAV muxer, so the recording is put together here the way Dahua cameras write it: H.264 frames + // and G.711 A-law sound in DHAV chunks. + SkipUnlessInstalled(); + var folder = fx.NewFolder(); + var h264 = await MakeClipAsync(folder, "camera.h264", size: "160x120", audio: false, + extra: ["-c:v", "libx264", "-g", "25", "-bsf:v", "h264_metadata=aud=insert"]); + var alaw = Path.Combine(folder, "camera.alaw"); + await RunAsync(Tools!.Value.Ffmpeg, ["-hide_banner", "-nostdin", "-v", "error", "-y", "-f", "lavfi", + "-i", "sine=frequency=440:sample_rate=8000:duration=2", "-ac", "1", "-f", "alaw", alaw]); + var dav = Path.Combine(folder, "ch01_main.dav"); + await File.WriteAllBytesAsync(dav, Dhav.Recording(await File.ReadAllBytesAsync(h264, TestContext.Current.CancellationToken), + await File.ReadAllBytesAsync(alaw, TestContext.Current.CancellationToken), 160, 120), TestContext.Current.CancellationToken); + + var probe = await ProcessRunner.RunAsync(Tools!.Value.Ffprobe, ["-v", "error", "-show_entries", "format=format_name", "-of", "csv=p=0", dav], + TimeSpan.FromMinutes(1), CancellationToken.None); + Assert.Equal("dhav", probe.StandardOutput.Trim()); + + var probed = await ProbeAsync(await ConvertAsync(dav, "mp4")); + var video = probed.Video; + Assert.NotNull(video); + Assert.Equal(("h264", 160, 120, 50), (video.Codec, video.Width, video.Height, video.Frames)); + Assert.Equal("aac", probed.Audio?.Codec); + Assert.InRange(probed.Duration, 1.7, 2.4); + } + + /// Dahua's DHAV container, as far as FFmpeg's dhav demuxer reads it. + private static class Dhav + { + /// + /// One video chunk per H.264 access unit (each starting with an access unit delimiter) at 25 fps, each followed + /// by 40 ms of 8 kHz mono A-law sound. + /// + public static byte[] Recording(byte[] annexB, byte[] alaw, int width, int height) + { + var units = AccessUnits(annexB); + var output = new MemoryStream(); + for (var i = 0; i < units.Count; i++) + { + var ms = i * 40; + // 0x80: size in units of 8 pixels; 0x81: codec 8 (H.264) at 25 fps. + byte[] video = [0x80, 0, (byte)(width / 8), (byte)(height / 8), 0x81, 0, 0x08, 25]; + Chunk(output, IsKeyFrame(units[i]) ? (byte)0xFD : (byte)0xFC, i + 1, ms, video, units[i]); + var sound = alaw.AsSpan().Slice(Math.Min(i * 320, alaw.Length), Math.Clamp(alaw.Length - i * 320, 0, 320)); + // 0x83: one channel, codec 0x0E (A-law), sample rate 0 (8 kHz). + if (!sound.IsEmpty) + Chunk(output, 0xF0, i + 1, ms, [0x83, 1, 0x0E, 0], sound); + } + return output.ToArray(); + } + + private static void Chunk(MemoryStream output, byte type, int number, int ms, ReadOnlySpan extension, ReadOnlySpan payload) + { + var length = 24 + extension.Length + payload.Length + 8; + var seconds = ms / 1000; + // Wall-clock time of the chunk: 2026-10-05 15:mm:ss. + var date = (uint)(seconds % 60 | seconds / 60 % 60 << 6 | 15 << 12 | 5 << 17 | 10 << 22 | (2026 - 2000) << 26); + Span header = stackalloc byte[24]; + "DHAV"u8.CopyTo(header); + header[4] = type; + BinaryPrimitives.WriteInt32LittleEndian(header[8..], number); + BinaryPrimitives.WriteInt32LittleEndian(header[12..], length); + BinaryPrimitives.WriteUInt32LittleEndian(header[16..], date); + BinaryPrimitives.WriteUInt16LittleEndian(header[20..], (ushort)ms); + header[22] = (byte)extension.Length; + output.Write(header); + output.Write(extension); + output.Write(payload); + output.Write("dhav"u8); + Span trailer = stackalloc byte[4]; + BinaryPrimitives.WriteInt32LittleEndian(trailer, length); + output.Write(trailer); + } + + private static List AccessUnits(byte[] annexB) + { + byte[] delimiter = [0, 0, 0, 1, 9]; + var starts = new List(); + for (var at = annexB.AsSpan().IndexOf(delimiter); at >= 0;) + { + starts.Add(at); + var next = annexB.AsSpan(at + 1).IndexOf(delimiter); + at = next < 0 ? -1 : at + 1 + next; + } + return starts.Select((start, i) => annexB[start..(i + 1 < starts.Count ? starts[i + 1] : annexB.Length)]).ToList(); + } + + /// An access unit with an IDR slice (NAL type 5). + private static bool IsKeyFrame(byte[] unit) + { + for (var i = 0; i + 3 < unit.Length; i++) + { + if (unit[i] == 0 && unit[i + 1] == 0 && unit[i + 2] == 1 && (unit[i + 3] & 0x1F) == 5) + return true; + } + return false; + } + } + [Fact] public async Task A_self_contained_m4s_converts_and_a_bare_dash_segment_is_refused() {