Skip to content

Commit c6dbbff

Browse files
committed
Play SMIL/CSS-animated SVG via existing WebView2 host (#242)
NeedsFallback now treats animate/set/CSS animation and script as complex, same as foreignObject/filter/mask. Present/EnsureReady failure demotes to static D2D with an OSD instead of a failed load.
1 parent c1ea641 commit c6dbbff

8 files changed

Lines changed: 155 additions & 8 deletions

File tree

‎CMakeLists.txt‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,8 @@ add_executable(QuickViewTests
382382
tests/MiniTiffTests.cpp
383383
tests/Transform2DTests.cpp
384384
tests/SettingsSliderMathTests.cpp
385+
tests/SvgNeedsFallbackTests.cpp
386+
QuickView/OffscreenWebView2.cpp
385387
QuickView/LosslessTransform.cpp
386388
QuickView/ImageLoaderSimd.cpp
387389
QuickView/MiniTiff.cpp

‎QuickView/AppStrings.cpp‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ namespace AppStrings {
1414
const wchar_t *CurrentLocale = L"en-us";
1515
const wchar_t *OSD_NoImage = nullptr;
1616
const wchar_t *OSD_FolderEmpty = nullptr;
17+
const wchar_t *OSD_SvgStaticFallback = nullptr;
1718
const wchar_t *OSD_Lossless = nullptr;
1819
const wchar_t *OSD_ReencodedLossless = nullptr;
1920
const wchar_t *OSD_EdgeAdapted = nullptr;
@@ -597,6 +598,7 @@ const wchar_t *Settings_Text_License = L"Licensed under GNU GPL v3.0";
597598
struct LanguageTable {
598599
const wchar_t *OSD_NoImage;
599600
const wchar_t *OSD_FolderEmpty;
601+
const wchar_t *OSD_SvgStaticFallback;
600602
const wchar_t *OSD_Lossless;
601603
const wchar_t *OSD_ReencodedLossless;
602604
const wchar_t *OSD_EdgeAdapted;
@@ -1167,6 +1169,7 @@ struct LanguageTable {
11671169
static const LanguageTable Table_EN = {
11681170
L"No image loaded", // OSD_NoImage
11691171
L"No images in this folder", // OSD_FolderEmpty
1172+
L"Animated SVG shown as a still image", // OSD_SvgStaticFallback
11701173
L"Lossless", // OSD_Lossless
11711174
L"Re-encoded (Lossless)", // OSD_ReencodedLossless
11721175
L"Cropped", // OSD_EdgeAdapted
@@ -1737,6 +1740,7 @@ static const LanguageTable Table_EN = {
17371740
static const LanguageTable Table_CN = {
17381741
L"没有加载图片", // OSD_NoImage
17391742
L"文件夹中没有图片", // OSD_FolderEmpty
1743+
L"动态 SVG 已显示为静态图像", // OSD_SvgStaticFallback
17401744
L"无损", // OSD_Lossless
17411745
L"重编码 (无损)", // OSD_ReencodedLossless
17421746
L"边缘优化", // OSD_EdgeAdapted
@@ -2307,6 +2311,7 @@ static const LanguageTable Table_CN = {
23072311
static const LanguageTable Table_TW = {
23082312
L"沒有載入圖片", // OSD_NoImage
23092313
L"資料夾中沒有圖片", // OSD_FolderEmpty
2314+
L"動態 SVG 已顯示為靜態圖像", // OSD_SvgStaticFallback
23102315
L"無損", // OSD_Lossless
23112316
L"重新編碼 (無損)", // OSD_ReencodedLossless
23122317
L"剪裁", // OSD_EdgeAdapted
@@ -2877,6 +2882,7 @@ static const LanguageTable Table_TW = {
28772882
static const LanguageTable Table_JA = {
28782883
L"画像が読み込まれていません", // OSD_NoImage
28792884
L"このフォルダーに画像がありません", // OSD_FolderEmpty
2885+
L"アニメーション SVG を静止画として表示しています", // OSD_SvgStaticFallback
28802886
L"ロスレス", // OSD_Lossless
28812887
L"再エンコード (ロスレス)", // OSD_ReencodedLossless
28822888
L"クロップ済み", // OSD_EdgeAdapted
@@ -3447,6 +3453,7 @@ static const LanguageTable Table_JA = {
34473453
static const LanguageTable Table_RU = {
34483454
L"Изображение не загружено", // OSD_NoImage
34493455
L"В этой папке нет изображений", // OSD_FolderEmpty
3456+
L"Анимированный SVG показан как статичный кадр", // OSD_SvgStaticFallback
34503457
L"Без потерь", // OSD_Lossless
34513458
L"Перекодировано (без потерь)", // OSD_ReencodedLossless
34523459
L"Оптимизация краёв", // OSD_EdgeAdapted
@@ -4017,6 +4024,7 @@ static const LanguageTable Table_RU = {
40174024
static const LanguageTable Table_DE = {
40184025
L"Kein Bild geladen", // OSD_NoImage
40194026
L"Keine Bilder in diesem Ordner", // OSD_FolderEmpty
4027+
L"Animiertes SVG als Standbild angezeigt", // OSD_SvgStaticFallback
40204028
L"Verlustfrei", // OSD_Lossless
40214029
L"Neu kodiert (verlustfrei)", // OSD_ReencodedLossless
40224030
L"Kantenoptimiert", // OSD_EdgeAdapted
@@ -4587,6 +4595,7 @@ static const LanguageTable Table_DE = {
45874595
static const LanguageTable Table_ES = {
45884596
L"No hay imagen cargada", // OSD_NoImage
45894597
L"No hay imágenes en esta carpeta", // OSD_FolderEmpty
4598+
L"SVG animado mostrado como imagen fija", // OSD_SvgStaticFallback
45904599
L"Sin pérdida", // OSD_Lossless
45914600
L"Recodificado (sin pérdida)", // OSD_ReencodedLossless
45924601
L"Bordes optimizados", // OSD_EdgeAdapted
@@ -5157,6 +5166,7 @@ static const LanguageTable Table_ES = {
51575166
static const LanguageTable Table_FR = {
51585167
L"No image loaded", // OSD_NoImage
51595168
L"Aucune image dans ce dossier", // OSD_FolderEmpty
5169+
L"SVG animé affiché comme image fixe", // OSD_SvgStaticFallback
51605170
L"Lossless", // OSD_Lossless
51615171
L"Re-encoded (Lossless)", // OSD_ReencodedLossless
51625172
L"Cropped", // OSD_EdgeAdapted
@@ -5727,6 +5737,7 @@ static const LanguageTable Table_FR = {
57275737
void Apply(const LanguageTable& t) {
57285738
OSD_NoImage = t.OSD_NoImage;
57295739
OSD_FolderEmpty = t.OSD_FolderEmpty;
5740+
OSD_SvgStaticFallback = t.OSD_SvgStaticFallback;
57305741
OSD_Lossless = t.OSD_Lossless;
57315742
OSD_ReencodedLossless = t.OSD_ReencodedLossless;
57325743
OSD_EdgeAdapted = t.OSD_EdgeAdapted;

‎QuickView/AppStrings.h‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ namespace AppStrings {
2929
// OSD Messages
3030
extern const wchar_t* OSD_NoImage;
3131
extern const wchar_t* OSD_FolderEmpty;
32+
extern const wchar_t* OSD_SvgStaticFallback;
3233
extern const wchar_t* OSD_Lossless;
3334
extern const wchar_t* OSD_ReencodedLossless;
3435
extern const wchar_t* OSD_EdgeAdapted;

‎QuickView/ImageEngine.cpp‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ static constexpr const char* CURRENT_MODULE = "ImageEngine";
66
#include "HeavyLanePool.h" // [N+1] Include pool implementation
77
#include "EditState.h" // [v9.9] Access g_runtime.ForceRawDecode for dispatch decisions
88
#include "TileManager.h" // [Infinity Engine]
9+
#include "OffscreenWebView2.h"
910

1011
#include <algorithm>
1112
#include <psapi.h>
@@ -331,6 +332,17 @@ void ImageEngine::DispatchImageLoad(const std::wstring& path, ImageID imageId, u
331332
cachedFrame.reset();
332333
isHit = false;
333334
}
335+
336+
// Same-process upgrade: old SVG_XML entries that now classify as animated/complex.
337+
if (cachedFrame && cachedFrame->format == QuickView::PixelFormat::SVG_XML &&
338+
cachedFrame->svg && !cachedFrame->svg->xmlData.empty()) {
339+
const auto& xml = cachedFrame->svg->xmlData;
340+
if (QuickView::OffscreenWebView2::NeedsFallback(
341+
std::string_view(reinterpret_cast<const char*>(xml.data()), xml.size()))) {
342+
cachedFrame->format = QuickView::PixelFormat::SVG_WEBVIEW;
343+
cachedFrame->formatDetails = L"Complex SVG";
344+
}
345+
}
334346

335347
// [v9.0] Smart RAW Quality Check
336348
// RAW files require strict quality matching (Preview vs Full) for A/B comparison

‎QuickView/ImageLoader.cpp‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13866,7 +13866,7 @@ HRESULT CImageLoader::LoadToFrame(
1386613866
if (svgH <= 0)
1386713867
svgH = 512;
1386813868

13869-
// 3. Check for WebView2 DComp Composition (complex SVG with <foreignObject> or <filter>)
13869+
// 3. Check for WebView2 DComp Composition (filters, SMIL/CSS animation, script)
1387013870
if (QuickView::OffscreenWebView2::NeedsFallback(svgContent)) {
1387113871
// Tag as SVG_WEBVIEW -- UI thread will route to WebContentHost DComp visual
1387213872
outFrame->format = PixelFormat::SVG_WEBVIEW;

‎QuickView/OffscreenWebView2.cpp‎

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,63 @@
88

99
namespace QuickView {
1010

11-
bool OffscreenWebView2::NeedsFallback(std::string_view svgContent) {
12-
// D2D native SVG lacks these features. Pure scan — no allocations.
13-
auto has = [&](std::string_view tag) {
14-
return svgContent.find(tag) != std::string_view::npos;
11+
static bool ContainsI(std::string_view hay, std::string_view needle) {
12+
if (needle.empty() || hay.size() < needle.size()) return false;
13+
auto fold = [](unsigned char c) -> unsigned char {
14+
return (c >= 'A' && c <= 'Z') ? static_cast<unsigned char>(c + 32) : c;
15+
};
16+
const size_t n = needle.size();
17+
for (size_t i = 0; i + n <= hay.size(); ++i) {
18+
bool ok = true;
19+
for (size_t j = 0; j < n; ++j) {
20+
if (fold(static_cast<unsigned char>(hay[i + j])) !=
21+
fold(static_cast<unsigned char>(needle[j]))) {
22+
ok = false;
23+
break;
24+
}
25+
}
26+
if (ok) return true;
27+
}
28+
return false;
29+
}
30+
31+
static bool HasSetTag(std::string_view svg) {
32+
// "<set" plus a tag delimiter — avoid matching "settings" text.
33+
auto fold = [](unsigned char c) -> unsigned char {
34+
return (c >= 'A' && c <= 'Z') ? static_cast<unsigned char>(c + 32) : c;
1535
};
16-
return has("<foreignObject") || has("<foreignobject") ||
17-
has("<filter") || has("<Filter") ||
18-
has("<mask") || has("<Mask");
36+
constexpr size_t k = 4;
37+
for (size_t i = 0; i + k <= svg.size(); ++i) {
38+
if (fold(static_cast<unsigned char>(svg[i])) != '<' ||
39+
fold(static_cast<unsigned char>(svg[i + 1])) != 's' ||
40+
fold(static_cast<unsigned char>(svg[i + 2])) != 'e' ||
41+
fold(static_cast<unsigned char>(svg[i + 3])) != 't') {
42+
continue;
43+
}
44+
if (i + k >= svg.size()) return false;
45+
const unsigned char next = static_cast<unsigned char>(svg[i + k]);
46+
if (next == ' ' || next == '\t' || next == '\n' || next == '\r' ||
47+
next == '>' || next == '/') {
48+
return true;
49+
}
50+
}
51+
return false;
52+
}
53+
54+
bool OffscreenWebView2::NeedsFallback(std::string_view svgContent) {
55+
// D2D native SVG lacks filters/masks/foreignObject and has no SMIL/CSS/JS.
56+
return ContainsI(svgContent, "<foreignObject") ||
57+
ContainsI(svgContent, "<filter") ||
58+
ContainsI(svgContent, "<mask") ||
59+
ContainsI(svgContent, "<animate") ||
60+
HasSetTag(svgContent) ||
61+
ContainsI(svgContent, "begin=") ||
62+
ContainsI(svgContent, "repeatCount=") ||
63+
ContainsI(svgContent, "repeatDur=") ||
64+
ContainsI(svgContent, "@keyframes") ||
65+
ContainsI(svgContent, "animation:") ||
66+
ContainsI(svgContent, "animation-name") ||
67+
ContainsI(svgContent, "<script");
1968
}
2069

2170
} // namespace QuickView

‎QuickView/main.cpp‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13586,6 +13586,43 @@ void ProcessEngineEvents(HWND hwnd) {
1358613586
resourceReady = true;
1358713587
}
1358813588
}
13589+
if (!resourceReady && evt.rawFrame->svg && !evt.rawFrame->svg->xmlData.empty()) {
13590+
// Present/EnsureReady failed: demote this file to static D2D. Do not latch the host.
13591+
auto& res = GetPaneContext(PaneSlot::Primary).resource;
13592+
res.Reset();
13593+
res.isSvg = true;
13594+
res.isWebView = false;
13595+
res.svgW = evt.rawFrame->svg->viewBoxW;
13596+
res.svgH = evt.rawFrame->svg->viewBoxH;
13597+
ComPtr<ID2D1DeviceContext> ctxBase = g_renderEngine ? g_renderEngine->GetDeviceContext() : nullptr;
13598+
ComPtr<ID2D1DeviceContext5> ctx5;
13599+
const auto& xml = evt.rawFrame->svg->xmlData;
13600+
HRESULT hrDoc = E_FAIL;
13601+
if (ctxBase && SUCCEEDED(ctxBase.As(&ctx5))) {
13602+
ComPtr<IStream> stream;
13603+
HGLOBAL hMem = GlobalAlloc(GMEM_MOVEABLE, xml.size());
13604+
if (hMem) {
13605+
void* pMem = GlobalLock(hMem);
13606+
if (pMem) {
13607+
memcpy(pMem, xml.data(), xml.size());
13608+
GlobalUnlock(hMem);
13609+
CreateStreamOnHGlobal(hMem, TRUE, &stream);
13610+
} else {
13611+
GlobalFree(hMem);
13612+
}
13613+
}
13614+
if (stream) {
13615+
D2D1_SIZE_F vpSize = { res.svgW, res.svgH };
13616+
if (vpSize.width <= 0.0f) vpSize.width = 100.0f;
13617+
if (vpSize.height <= 0.0f) vpSize.height = 100.0f;
13618+
hrDoc = ctx5->CreateSvgDocument(stream.Get(), vpSize, &res.svgDoc);
13619+
}
13620+
}
13621+
if (SUCCEEDED(hrDoc)) {
13622+
resourceReady = true;
13623+
g_osd.Show(hwnd, AppStrings::OSD_SvgStaticFallback, false);
13624+
}
13625+
}
1358913626
} else {
1359013627
// === Bitmap Path ===
1359113628
QuickView::DisplayColorState uploadState = {};

‎tests/SvgNeedsFallbackTests.cpp‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
#include "pch.h"
2+
#include "gtest/gtest.h"
3+
#include "OffscreenWebView2.h"
4+
5+
using QuickView::OffscreenWebView2;
6+
7+
TEST(SvgNeedsFallback, StaticCircleStaysOnD2D) {
8+
constexpr std::string_view svg =
9+
"<svg xmlns='http://www.w3.org/2000/svg'><circle cx='10' cy='10' r='5'/></svg>";
10+
EXPECT_FALSE(OffscreenWebView2::NeedsFallback(svg));
11+
}
12+
13+
TEST(SvgNeedsFallback, AnimateTransformRoutesToWebView) {
14+
EXPECT_TRUE(OffscreenWebView2::NeedsFallback(
15+
"<svg><animateTransform attributeName='transform' type='rotate'/></svg>"));
16+
}
17+
18+
TEST(SvgNeedsFallback, AnimateAndSetAndCssAndScript) {
19+
EXPECT_TRUE(OffscreenWebView2::NeedsFallback("<svg><animate attributeName='r'/></svg>"));
20+
EXPECT_TRUE(OffscreenWebView2::NeedsFallback("<svg><set attributeName='opacity' to='0'/></svg>"));
21+
EXPECT_TRUE(OffscreenWebView2::NeedsFallback("<svg><style>@keyframes spin { to { transform: rotate(360deg); } }</style></svg>"));
22+
EXPECT_TRUE(OffscreenWebView2::NeedsFallback("<svg><style>.x { animation: spin 1s; }</style></svg>"));
23+
EXPECT_TRUE(OffscreenWebView2::NeedsFallback("<svg><script>document.querySelector('circle')</script></svg>"));
24+
}
25+
26+
TEST(SvgNeedsFallback, ExistingComplexTagsStillRoute) {
27+
EXPECT_TRUE(OffscreenWebView2::NeedsFallback("<svg><foreignObject></foreignObject></svg>"));
28+
EXPECT_TRUE(OffscreenWebView2::NeedsFallback("<svg><FILTER></FILTER></svg>"));
29+
EXPECT_TRUE(OffscreenWebView2::NeedsFallback("<svg><Mask></Mask></svg>"));
30+
}
31+
32+
TEST(SvgNeedsFallback, SettingsWordIsNotSetTag) {
33+
EXPECT_FALSE(OffscreenWebView2::NeedsFallback(
34+
"<svg><text>settings</text></svg>"));
35+
}

0 commit comments

Comments
 (0)