diff --git a/.github/workflows/auto-version-release.yml b/.github/workflows/auto-version-release.yml
index 43d020c8..5d50124c 100644
--- a/.github/workflows/auto-version-release.yml
+++ b/.github/workflows/auto-version-release.yml
@@ -126,7 +126,8 @@ jobs:
}
$newVersion = "$major.$minor.$build.$revision"
- $newVersionText = "$major$minor$build$revision"
+ # 使用下划线分隔,避免各组件 >= 10 时产生歧义(如 0.10.0.0 → 01000 与 0.1.0.0.0 冲突)
+ $newVersionText = "${major}_${minor}_${build}_${revision}"
Write-Host "Version: $currentVersion -> $newVersion"
@@ -199,6 +200,22 @@ jobs:
- name: Rebuild solution
run: msbuild src/VirtualPaper.sln /t:Rebuild /p:Configuration=Release /p:Platform="Any CPU" /restore /m
+ - name: Validate Build Artifacts
+ # 在产物就地校验:主 exe / Plugin exe / 核心 dll 存在且非空。
+ # 通过后才上传 artifact,确保 package / smoke_test job 拿到的是健康的构建产物。
+ env:
+ RELEASE_BIN_DIR: ${{ github.workspace }}/src/VirtualPaper/bin/Release/net8.0-windows10.0.19041.0
+ run: dotnet test src/VirtualPaper.ReleaseBuildData.Test/VirtualPaper.ReleaseBuildData.Test.csproj --configuration Release --no-build --verbosity normal --logger "trx;LogFileName=release-build-tests.trx" --results-directory TestResults/ReleaseBuild
+ shell: pwsh
+
+ - name: Upload Validate Build Artifacts results
+ uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: test-results-release-build
+ path: TestResults/ReleaseBuild/
+ retention-days: 1
+
- name: Upload build output
uses: actions/upload-artifact@v4
with:
@@ -218,11 +235,15 @@ jobs:
installer_path: ${{ steps.installer_info.outputs.installer_path }}
steps:
+ # Step 1:checkout 拿到仓库根目录的全部文件,包含 InnoSetup/ 脚本和其他非编译产物。
- name: Checkout code
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
+ # Step 2:将 build job 的编译产物下载并覆盖到 src/。
+ # 顺序不可颠倒:必须先 checkout(否则 InnoSetup/ 目录不存在),
+ # 再用 artifact 覆盖 src/(用已打版本号的 Release 二进制替换源码目录下的旧产物)。
- name: Download build output
uses: actions/download-artifact@v4
with:
@@ -241,7 +262,7 @@ jobs:
$newVersion = "${{ needs.prepare.outputs.new_version }}"
$newVersionText = "${{ needs.prepare.outputs.new_version_text }}"
$newContent = $content -replace '#define MyAppVersion "[\d\.]+"', "#define MyAppVersion `"$newVersion`""
- $newContent = $newContent -replace '#define MyAppVersionText "[\d]+"', "#define MyAppVersionText `"$newVersionText`""
+ $newContent = $newContent -replace '#define MyAppVersionText "[\d_]+"', "#define MyAppVersionText `"$newVersionText`""
Set-Content $setupPath -Value $newContent -NoNewline
Write-Host "Patched InnoSetup version to $newVersion (build-time only)"
@@ -472,12 +493,26 @@ jobs:
$classes = @($wins | ForEach-Object { $_.Current.ClassName })
Write-Host " Visible window classes: $($classes -join ', ')"
+ # ── 正向断言:MessageBox(#32770)应存在 ────────────────────────────
+ # Win32 MessageBox 的窗口类名固定为 #32770,是 Windows API 常量,不会变。
if (-not ($classes -contains '#32770')) {
Write-Error "Expected a MessageBox (#32770) but none found - UI may have started normally, guard logic broken"
Stop-Process -Id $uiProc.Id -Force -ErrorAction SilentlyContinue
exit 1
}
- Write-Host "[OK] VirtualPaper.UI is blocked by a MessageBox (#32770) as expected (PID: $($uiProc.Id))"
+ Write-Host "[OK] MessageBox (#32770) present - guard triggered as expected (PID: $($uiProc.Id))"
+
+ # ── 反向断言:WinUI3 主窗口(WinUIDesktopWin32Window)不应存在 ──────
+ # WinUI3 正常启动后主窗口类名固定为 WinUIDesktopWin32Window。
+ # 若守卫生效,此类名不应出现——与 #32770 正向检查互补,
+ # 覆盖未来从 MessageBox 迁移到其他阻塞机制(ContentDialog 等)的场景:
+ # 届时 #32770 检查会失效,但此反向检查仍能捕获"UI 意外正常启动"。
+ if ($classes -contains 'WinUIDesktopWin32Window') {
+ Write-Error "WinUI3 main window (WinUIDesktopWin32Window) appeared - UI started normally without main process, guard logic broken"
+ Stop-Process -Id $uiProc.Id -Force -ErrorAction SilentlyContinue
+ exit 1
+ }
+ Write-Host "[OK] WinUI3 main window not present - guard is holding correctly"
# ── 清理:强杀被阻塞的 UI 进程 ──────────────────────────────────────
Stop-Process -Id $uiProc.Id -Force -ErrorAction SilentlyContinue
@@ -521,7 +556,7 @@ jobs:
$newVersion = "${{ needs.prepare.outputs.new_version }}"
$newVersionText = "${{ needs.prepare.outputs.new_version_text }}"
$newContent = $content -replace '#define MyAppVersion "[\d\.]+"', "#define MyAppVersion `"$newVersion`""
- $newContent = $newContent -replace '#define MyAppVersionText "[\d]+"', "#define MyAppVersionText `"$newVersionText`""
+ $newContent = $newContent -replace '#define MyAppVersionText "[\d_]+"', "#define MyAppVersionText `"$newVersionText`""
Set-Content $setupPath -Value $newContent -NoNewline
Write-Host "Updated InnoSetup version to: $newVersion"
@@ -583,7 +618,7 @@ jobs:
# 策略 1:尝试将 main 全量合并到当前分支
# -X theirs: 冲突时自动以 main 为准,无需人工介入
- $mergeOutput = git merge origin/main --no-edit -X theirs 2>&1
+ $mergeOutput = git merge origin/main -m "[Github CI] chore: sync main -> $branch [skip ci]" -X theirs 2>&1
$mergeExit = $LASTEXITCODE
if ($mergeExit -eq 0) {
@@ -689,7 +724,13 @@ jobs:
name: Rollback on Failure
runs-on: windows-latest
needs: [prepare, build, package, smoke_test, bump, sync]
- if: always() && failure() && needs.prepare.result == 'success'
+ # 触发条件:bump 或 sync 已经开始执行(分支内容已被修改),且流程出现失败或取消。
+ # build 失败时 bump/sync 均为 skipped(分支未动),不触发,避免无意义的回滚操作。
+ if: |
+ always() &&
+ needs.prepare.result == 'success' &&
+ (needs.bump.result != 'skipped' || needs.sync.result != 'skipped') &&
+ (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
steps:
- name: Checkout with full history
diff --git a/.github/workflows/manual-rollback.yml b/.github/workflows/manual-rollback.yml
index 89da7f00..d363f49c 100644
--- a/.github/workflows/manual-rollback.yml
+++ b/.github/workflows/manual-rollback.yml
@@ -1,28 +1,59 @@
name: Manual Rollback
-run-name: "Manual Rollback v${{ inputs.version }} | by @${{ github.actor }}"
+run-name: "Manual Rollback | ${{ inputs.mode == 'version-rollback' && format('v{0} | {1}', inputs.version, inputs.scope) || format('reset {0} -> {1}', inputs.target_branch, inputs.target_sha) }} | by @${{ github.actor }}"
on:
workflow_dispatch:
inputs:
- version:
- description: 'Version number to roll back to (for example, 0.5.0.0)'
+
+ mode:
+ description: 'Rollback mode'
required: true
+ type: choice
+ options:
+ - 'version-rollback' # 按版本号 revert auto-ci bump commit
+ - 'branch-reset' # 将指定分支 hard-reset 到任意 commit SHA
+ default: 'version-rollback'
+
+ # ── [version-rollback] 专用输入 ──────────────────────────────────────
+ version:
+ description: '[version-rollback] Version to roll back (e.g. 0.5.1.0)'
+ required: false
type: string
+ default: ''
+
scope:
- description: 'Rollback Scope'
- required: true
+ description: '[version-rollback] Rollback scope'
+ required: false
type: choice
options:
- 'all (main + release + dev + bugfix)'
- 'main only'
default: 'all (main + release + dev + bugfix)'
+ # ── [branch-reset] 专用输入 ──────────────────────────────────────────
+ target_branch:
+ description: '[branch-reset] Branch to reset'
+ required: false
+ type: choice
+ options:
+ - 'main'
+ - 'release'
+ - 'dev'
+ - 'bugfix'
+ default: 'main'
+
+ target_sha:
+ description: '[branch-reset] Target commit SHA to reset to (full or short)'
+ required: false
+ type: string
+ default: ''
+
permissions:
contents: write
jobs:
rollback:
- name: Rollback v${{ inputs.version }}
+ name: Rollback | ${{ inputs.mode }}
runs-on: windows-latest
steps:
@@ -32,16 +63,60 @@ jobs:
token: ${{ secrets.RELEASE_PAT }}
fetch-depth: 0
- - name: Execute rollback
+ # ─────────────────────────────────────────────────────────────────────
+ # 公共:输入校验
+ # ─────────────────────────────────────────────────────────────────────
+ - name: Validate inputs
+ shell: powershell
+ run: |
+ $mode = "${{ inputs.mode }}"
+
+ if ($mode -eq 'version-rollback') {
+ $ver = "${{ inputs.version }}".Trim()
+ if (-not $ver) {
+ Write-Error "[version-rollback] Input 'version' is required."
+ exit 1
+ }
+ if ($ver -notmatch '^\d+\.\d+\.\d+\.\d+$') {
+ Write-Error "[version-rollback] Invalid format. Expected X.X.X.X, got: $ver"
+ exit 1
+ }
+ Write-Host "[OK] version-rollback input validated: $ver"
+
+ } elseif ($mode -eq 'branch-reset') {
+ $sha = "${{ inputs.target_sha }}".Trim()
+ $branch = "${{ inputs.target_branch }}".Trim()
+ if (-not $sha) {
+ Write-Error "[branch-reset] Input 'target_sha' is required."
+ exit 1
+ }
+ if (-not $branch) {
+ Write-Error "[branch-reset] Input 'target_branch' is required."
+ exit 1
+ }
+ Write-Host "[OK] branch-reset input validated: $branch -> $sha"
+
+ } else {
+ Write-Error "Unknown mode: $mode"
+ exit 1
+ }
+
+ # ─────────────────────────────────────────────────────────────────────
+ # Mode 1: version-rollback
+ # 在 main 上找到 bump commit 并 revert,再将预发布分支内容对齐到 pre-bump 状态
+ # ─────────────────────────────────────────────────────────────────────
+ - name: Execute version rollback
+ if: inputs.mode == 'version-rollback'
shell: powershell
run: |
$version = "${{ inputs.version }}"
$scope = "${{ inputs.scope }}"
Write-Host "========================================"
- Write-Host "Manual Rollback: version $version"
- Write-Host "Scope: $scope"
- Write-Host "Triggered by: ${{ github.actor }}"
+ Write-Host "Mode : version-rollback"
+ Write-Host "Version : $version"
+ Write-Host "Scope : $scope"
+ Write-Host "Triggered : ${{ github.actor }}"
Write-Host "========================================"
git config --local user.email "action@github.com"
@@ -51,9 +126,7 @@ jobs:
git fetch origin 2>&1 | Out-Null
$ErrorActionPreference = 'Stop'
- # ─────────────────────────────────────────────────────────
- # Step 1: 在 main 上找到并 revert 版本递增 commit
- # ─────────────────────────────────────────────────────────
+ # ── Step 1: 在 main 上找到并 revert 版本递增 commit ──────────────
Write-Host ""
Write-Host "--- [1/2] Rolling back main ---"
@@ -65,7 +138,6 @@ jobs:
exit 1
}
- # 记录 bump commit 的父 commit(即 bump 之前的 main,用于恢复版本文件)
$preBumpSha = git rev-parse "$bumpCommit~1"
Write-Host " Bump commit : $($bumpCommit.Substring(0,7))"
Write-Host " Pre-bump SHA : $($preBumpSha.Substring(0,7))"
@@ -74,16 +146,7 @@ jobs:
git push origin main
Write-Host "[OK]" -ForegroundColor Green -NoNewline; Write-Host " main: version $version reverted"
- # ─────────────────────────────────────────────────────────
- # Step 2: 在预发布分支上创建一个 revert commit,回滚到 pre-bump 状态
- # 做法:用 `git read-tree --reset -u` 将工作树和 index 重置为
- # pre-bump main 的内容快照,但 HEAD 保持不变;再 commit & push。
- # 效果:
- # - 分支内容与 pre-bump main 完全一致(含版本文件等所有文件)
- # - 完整保留历史(sync merge / bump commit 都仍在 git log 中)
- # - 多一条清晰的 revert commit,可追溯何时回滚到了哪个版本
- # - 普通 push 即可,无需 force push
- # ─────────────────────────────────────────────────────────
+ # ── Step 2: 将预发布分支内容对齐到 pre-bump 状态 ─────────────────
if ($scope -eq 'main only') {
Write-Host ""
Write-Host "[SKIP] Pre-publish branches: scope is 'main only'"
@@ -96,8 +159,6 @@ jobs:
git checkout -B $branch origin/$branch
- # 将 index 和工作树重置为 pre-bump main 的内容快照(HEAD 不动)
- # 这样后续 commit 就是一条"内容 = pre-bump"的 revert commit
git read-tree --reset -u $preBumpSha
$status = git status --porcelain
@@ -114,5 +175,76 @@ jobs:
Write-Host ""
Write-Host "========================================"
- Write-Host "[OK]" -ForegroundColor Green -NoNewline; Write-Host " Rollback v$version completed"
+ Write-Host "[OK]" -ForegroundColor Green -NoNewline; Write-Host " version-rollback v$version completed"
+ Write-Host "========================================"
+
+ # ─────────────────────────────────────────────────────────────────────
+ # Mode 2: branch-reset
+ # 将指定分支 hard-reset 到任意 commit SHA,使用 --force-with-lease 防止
+ # 覆盖并发推送。适用于非 auto-ci 产生的误提交、紧急回退等场景。
+ # ─────────────────────────────────────────────────────────────────────
+ - name: Execute branch reset
+ if: inputs.mode == 'branch-reset'
+ shell: powershell
+ run: |
+ $branch = "${{ inputs.target_branch }}"
+ $targetSha = "${{ inputs.target_sha }}".Trim()
+
+ Write-Host "========================================"
+ Write-Host "Mode : branch-reset"
+ Write-Host "Branch : $branch"
+ Write-Host "Target SHA : $targetSha"
+ Write-Host "Triggered : ${{ github.actor }}"
+ Write-Host "========================================"
+
+ git config --local user.email "action@github.com"
+ git config --local user.name "GitHub Action"
+
+ $ErrorActionPreference = 'Continue'
+ git fetch origin 2>&1 | Out-Null
+ $ErrorActionPreference = 'Stop'
+
+ # ── Step 1: 验证目标 SHA 存在 ─────────────────────────────────────
+ $resolvedSha = git rev-parse --verify $targetSha 2>$null
+ if ($LASTEXITCODE -ne 0 -or -not $resolvedSha) {
+ Write-Error "SHA not found in repository: $targetSha"
+ exit 1
+ }
+ $shortResolved = $resolvedSha.Substring(0, 7)
+
+ # 显示目标 commit 的摘要,便于人工二次确认
+ $commitMsg = git log --format="%h %s" -1 $resolvedSha
+ Write-Host " Target commit : $commitMsg"
+
+ # ── Step 2: 检查当前 HEAD ──────────────────────────────────────────
+ $currentSha = git rev-parse "origin/$branch"
+ $shortCurrent = $currentSha.Substring(0, 7)
+ Write-Host " Current HEAD : $shortCurrent ($branch)"
+
+ if ($currentSha -eq $resolvedSha) {
+ Write-Host "[SKIP] Branch '$branch' is already at $shortResolved, nothing to do."
+ exit 0
+ }
+
+ # ── Step 3: 切到目标 SHA,force-with-lease push ───────────────────
+ # force-with-lease 确保自本次 fetch 后无其他人推送;若有则报错,需重新触发。
+ git checkout -B $branch $resolvedSha
+
+ # dev / release / bugfix 的 push 会触发 pre-publish-branch-ci-check.yml。
+ # 旧历史 commit 的 message 不含 [skip ci],直接 push 会触发不必要的 CI。
+ # 解决:在目标 SHA 上追加一个空 commit 并携带 [skip ci],
+ # 不改变任何文件内容,仅作为回滚标记并阻止 CI 触发。
+ # main 分支无 CI push 触发规则,不需要额外处理。
+ $ciTriggerBranches = @("dev", "release", "bugfix")
+ if ($ciTriggerBranches -contains $branch) {
+ git commit --allow-empty -m "[rollback] hard reset to $shortResolved [skip ci]"
+ Write-Host " Added empty [skip ci] marker commit to suppress CI trigger"
+ }
+
+ git push --force-with-lease origin $branch
+
+ Write-Host ""
+ Write-Host "========================================"
+ Write-Host "[OK]" -ForegroundColor Green -NoNewline
+ Write-Host " branch-reset completed: $branch $shortCurrent -> $shortResolved"
Write-Host "========================================"
diff --git a/.github/workflows/pre-publish-branch-ci-check.yml b/.github/workflows/pre-publish-branch-ci-check.yml
index 42015d44..598b4448 100644
--- a/.github/workflows/pre-publish-branch-ci-check.yml
+++ b/.github/workflows/pre-publish-branch-ci-check.yml
@@ -44,6 +44,9 @@ jobs:
with:
dotnet-version: 8.0.x
+ - name: Setup MSBuild
+ uses: microsoft/setup-msbuild@v2
+
- name: Cache NuGet packages
uses: actions/cache@v4
with:
@@ -51,11 +54,28 @@ jobs:
key: nuget-${{ runner.os }}-${{ hashFiles('src/**/*.csproj', 'src/**/*.props') }}
restore-keys: nuget-${{ runner.os }}-
- - name: Restore dependencies
- run: dotnet restore src/VirtualPaper.sln
-
- name: Build solution
- run: dotnet build src/VirtualPaper.sln --configuration Release --no-restore
+ # 使用 msbuild 而非 dotnet build,确保 .sln 的 Platform Mapping 生效:
+ # VirtualPaper.UI(WinAppSDK)在 .sln 中映射为 Release|x64,
+ # dotnet build 不读取此映射,msbuild 才能正确路由到 x64 构建。
+ # 与 auto-version-release.yml 保持工具链一致。
+ run: msbuild src/VirtualPaper.sln /t:Build /p:Configuration=Release /p:Platform="Any CPU" /restore /m
+
+ - name: Validate Build Artifacts
+ # 在产物就地校验:主 exe / Plugin exe / 核心 dll 存在且非空。
+ # 通过后才上传 artifact,确保下游 job 拿到的是健康的构建产物。
+ env:
+ RELEASE_BIN_DIR: ${{ github.workspace }}/src/VirtualPaper/bin/Release/net8.0-windows10.0.19041.0
+ run: dotnet test src/VirtualPaper.ReleaseBuildData.Test/VirtualPaper.ReleaseBuildData.Test.csproj --configuration Release --no-build --verbosity normal --logger "trx;LogFileName=release-build-tests.trx" --results-directory TestResults/ReleaseBuild
+ shell: pwsh
+
+ - name: Upload Validate Build Artifacts results
+ uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: test-results-release-build
+ path: TestResults/ReleaseBuild/
+ retention-days: 7
- name: Upload build output
uses: actions/upload-artifact@v4
@@ -265,7 +285,7 @@ jobs:
- name: Verify all test results
run: |
echo "=== Checking test result files ==="
- EXPECTED_FILES=("core-tests.trx" "ui-tests.trx" "ml-tests.trx" "shader-tests.trx")
+ EXPECTED_FILES=("core-tests.trx" "ui-tests.trx" "ml-tests.trx" "shader-tests.trx" "release-build-tests.trx")
MISSING=0
for file in "${EXPECTED_FILES[@]}"; do
@@ -299,17 +319,17 @@ jobs:
- name: Set test status
if: always()
run: |
- if [ "${{ needs.build.result }}" = "success" ] && \
- [ "${{ needs.test-core.result }}" = "success" ] && \
- [ "${{ needs.test-ui.result }}" = "success" ] && \
- [ "${{ needs.test-ml.result }}" = "success" ] && \
- [ "${{ needs.test-shader.result }}" = "success" ] && \
- [ "${{ env.TEST_RESULTS_VALID }}" = "true" ]; then
- echo "TEST_STATUS=success" >> $GITHUB_ENV
- echo "TEST_DESCRIPTION=All tests passed [OK]" >> $GITHUB_ENV
- else
+ # 用通配方式判断:只要 needs 里任意 job 非 success 且非 skipped,就标记失败。
+ # 新增测试 job 时只需更新 needs 列表,无需修改此处逻辑。
+ ALL_RESULTS="${{ join(needs.*.result, ' ') }}"
+ echo "Job results: $ALL_RESULTS"
+
+ if echo "$ALL_RESULTS" | grep -qE 'failure|cancelled' || [ "${{ env.TEST_RESULTS_VALID }}" != "true" ]; then
echo "TEST_STATUS=failure" >> $GITHUB_ENV
echo "TEST_DESCRIPTION=Some tests failed or results missing [FAILED]" >> $GITHUB_ENV
+ else
+ echo "TEST_STATUS=success" >> $GITHUB_ENV
+ echo "TEST_DESCRIPTION=All tests passed [OK]" >> $GITHUB_ENV
fi
- name: Create Status Check
diff --git a/InnoSetup/setup.iss b/InnoSetup/setup.iss
index 7f567cdc..17a16fc5 100644
--- a/InnoSetup/setup.iss
+++ b/InnoSetup/setup.iss
@@ -6,7 +6,7 @@
#define AppId "{{95F4CECE-7C7C-40E1-B485-07DA64D1905F}"
#define MyAppName "Virtual Paper"
#define MyAppVersion "0.5.1.0"
-#define MyAppVersionText "0510"
+#define MyAppVersionText "0_5_1_0"
#define MyAppPublisher "PaperHammer"
#define MyAppURL "https://github.com/PaperHammer/VirtualPaper"
#define MyAppExeName "VirtualPaper.exe"
diff --git a/src/.github/copilot-instructions.md b/src/.github/copilot-instructions.md
new file mode 100644
index 00000000..e40201cf
--- /dev/null
+++ b/src/.github/copilot-instructions.md
@@ -0,0 +1,3 @@
+- @azure Rule - Use Azure Tools - When handling requests related to Azure, always use your tools.
+- @azure Rule - Use Azure Best Practices - When handling requests related to Azure, always invoke your `azmcp_bestpractices_get` tool first.
+- @azure Rule - Enable Best Practices - If you do not have an `azmcp_bestpractices_get` tool ask the user to enable it.
diff --git a/src/StaticImg/Consts.cs b/src/StaticImg/Consts.cs
index cab66e7b..1efba4ea 100644
--- a/src/StaticImg/Consts.cs
+++ b/src/StaticImg/Consts.cs
@@ -208,6 +208,7 @@ public enum ToolType {
Crop, // 裁剪
Selection,
CanvasSet,
+ CanvasEffect,
}
// rotate per 90 degree
diff --git a/src/StaticImg/Core/UndoRedoCommand/EffectCommand.cs b/src/StaticImg/Core/UndoRedoCommand/EffectCommand.cs
new file mode 100644
index 00000000..ce9c318c
--- /dev/null
+++ b/src/StaticImg/Core/UndoRedoCommand/EffectCommand.cs
@@ -0,0 +1,84 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using Microsoft.Graphics.Canvas;
+using VirtualPaper.Common.Extensions;
+using VirtualPaper.Common.Utils.UndoRedo;
+using VirtualPaper.Shader;
+using VirtualPaper.Shader.Core;
+using VirtualPaper.Shader.Models;
+using Workloads.Creation.StaticImg.Models.Specific;
+
+namespace Workloads.Creation.StaticImg.Core.UndoRedoCommand {
+ ///
+ /// 效果应用的撤销/重做命令。存储效果参数和压缩后的原始图层数据
+ ///
+ public record EffectCommand : IUndoableCommand {
+ public string Description { get; }
+
+ public EffectCommand(
+ Guid layerId,
+ InkCanvasData canvasData,
+ ShaderType shaderType,
+ EffectParams effectParams,
+ byte[] compressedOriginalPixels,
+ string description,
+ Action requestRenderAction) {
+ _layerId = layerId;
+ _canvasData = canvasData;
+ _shaderType = shaderType;
+ _effectParams = effectParams;
+ _compressedOriginalPixels = compressedOriginalPixels;
+ Description = description;
+ _requestRenderAction = requestRenderAction;
+ }
+
+ public Task ExecuteAsync() {
+ RestoreOriginal();
+ ApplyEffect(_effectParams);
+ return Task.CompletedTask;
+ }
+
+ public Task UndoAsync() {
+ RestoreOriginal();
+ return Task.CompletedTask;
+ }
+
+ private void ApplyEffect(EffectParams effectParams) {
+ var renderData = _canvasData.Layers.FirstOrDefault(l => l.Tag == _layerId)?.RenderData;
+ if (renderData?.RenderTarget == null) return;
+
+ var rt = renderData.RenderTarget;
+ using var temp = new CanvasRenderTarget(rt, rt.SizeInPixels.Width, rt.SizeInPixels.Height, rt.Dpi, rt.Format, rt.AlphaMode);
+ using (var ds = temp.CreateDrawingSession())
+ ds.DrawImage(rt);
+
+ var result = EffectApplier.Apply(_shaderType, effectParams, temp);
+ using (var ds = rt.CreateDrawingSession()) {
+ ds.Clear(Microsoft.UI.Colors.Transparent);
+ ds.DrawImage(result);
+ }
+
+ _requestRenderAction?.Invoke();
+ renderData.HandleOnceRenderCompleted();
+ }
+
+ private void RestoreOriginal() {
+ var renderData = _canvasData.Layers.FirstOrDefault(l => l.Tag == _layerId)?.RenderData;
+ if (renderData?.RenderTarget == null) return;
+
+ var originalPixels = _compressedOriginalPixels.DecompressPixels();
+ renderData.RenderTarget.SetPixelBytes(originalPixels);
+
+ _requestRenderAction?.Invoke();
+ renderData.HandleOnceRenderCompleted();
+ }
+
+ private readonly Guid _layerId;
+ private readonly InkCanvasData _canvasData;
+ private readonly ShaderType _shaderType;
+ private readonly EffectParams _effectParams;
+ private readonly byte[] _compressedOriginalPixels;
+ private readonly Action _requestRenderAction;
+ }
+}
diff --git a/src/StaticImg/Models/CanvasEffectItem.cs b/src/StaticImg/Models/CanvasEffectItem.cs
new file mode 100644
index 00000000..9f46a70a
--- /dev/null
+++ b/src/StaticImg/Models/CanvasEffectItem.cs
@@ -0,0 +1,28 @@
+using System.Collections.ObjectModel;
+
+namespace Workloads.Creation.StaticImg.Models {
+ ///
+ /// 单个画布效果项
+ ///
+ public class CanvasEffectItem {
+ /// 效果唯一标识符
+ public string EffectId { get; set; } = string.Empty;
+
+ /// 效果显示名称(i18n key)
+ public string EffectName { get; set; } = string.Empty;
+
+ /// 效果简要描述(i18n key)
+ public string EffectDescription { get; set; } = string.Empty;
+ }
+
+ ///
+ /// 效果分组(对应 Accordion 中的一个 Expander)
+ ///
+ public class CanvasEffectGroup {
+ /// 分组标题(i18n key)
+ public string GroupNameKey { get; set; } = string.Empty;
+
+ /// 该分组下的效果列表
+ public ObservableCollection Items { get; set; } = [];
+ }
+}
diff --git a/src/StaticImg/Models/SerializableData/StaticImgDesignFileUtil.Core.cs b/src/StaticImg/Models/SerializableData/StaticImgDesignFileUtil.Core.cs
index d2143acc..2c3af79d 100644
--- a/src/StaticImg/Models/SerializableData/StaticImgDesignFileUtil.Core.cs
+++ b/src/StaticImg/Models/SerializableData/StaticImgDesignFileUtil.Core.cs
@@ -135,8 +135,10 @@ private static FileStream CreateFileStream(
fs.Read(headerBytes);
var header = BytesToStructure(headerBytes);
- if (!header.IsValid())
+ if (!header.IsValid()) {
+ fs?.Dispose();
throw new InvalidDataException("Invalid file header");
+ }
fs.Position = 0; // 重置位置
}
diff --git a/src/StaticImg/Models/Specific/InkCanvasData.UIContext.cs b/src/StaticImg/Models/Specific/InkCanvasData.UIContext.cs
index 99e65885..ab322945 100644
--- a/src/StaticImg/Models/Specific/InkCanvasData.UIContext.cs
+++ b/src/StaticImg/Models/Specific/InkCanvasData.UIContext.cs
@@ -3,6 +3,7 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.UI;
+using Microsoft.UI.Xaml;
using VirtualPaper.Common;
using VirtualPaper.Common.Logging;
using VirtualPaper.Models.Mvvm;
@@ -165,6 +166,46 @@ public double EraserOpacity {
*/
public int EraserFeather { get; internal set; } = 0;
+ private string? _clickedEffectId;
+ public string? ClickedEffectId {
+ get { return _clickedEffectId; }
+ set {
+ if (_clickedEffectId == value) return;
+ _clickedEffectId = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(SelectedEffectPanelName));
+ }
+ }
+
+ public string SelectedEffectPanelName => ClickedEffectId switch {
+ "adjust_exposure" => "Exposure",
+ "adjust_brightness" => "Brightness",
+ "adjust_saturation" => "Saturation",
+ "adjust_hue" => "HueRotation",
+ "adjust_contrast" => "Contrast",
+ "adjust_temperature" => "TemperatureTint",
+ "adjust_highlights" => "HighlightsShadows",
+ "adjust_grayscale" => "Empty",
+ "adjust_invert" => "Empty",
+ "fx_blur" => "Blur",
+ "fx_sharpen" => "Sharpen",
+ "fx_vignette" => "Vignette",
+ "art_emboss" => "Emboss",
+ "art_pixelate" => "Posterize",
+ "fx_glow" => "Glow",
+ "fx_distort" => "Ripple",
+ "fx_noise" => "Noise",
+ "fx_bloom" => "Bloom",
+ "blend_multiply" => "Blend",
+ "blend_screen" => "Blend",
+ "blend_overlay" => "Blend",
+ "blend_softlight" => "Blend",
+ "color_sepia" => "Empty",
+ _ => "",
+ };
+
+ public Visibility EffectPanelVisible => string.IsNullOrEmpty(ClickedEffectId) ? Visibility.Collapsed : Visibility.Visible;
+
internal void InitData() {
UpdateCanvasSizeText();
AddLayer(LanguageUtil.GetI18n(nameof(Constants.I18n.Project_SI_Text_BackgroundLayer)), true, needRecord: false);
diff --git a/src/StaticImg/Models/ToolItems/EffectTool.cs b/src/StaticImg/Models/ToolItems/EffectTool.cs
new file mode 100644
index 00000000..580dfb8a
--- /dev/null
+++ b/src/StaticImg/Models/ToolItems/EffectTool.cs
@@ -0,0 +1,112 @@
+using Microsoft.Graphics.Canvas;
+using Microsoft.UI;
+using VirtualPaper.Common.Extensions;
+using VirtualPaper.Common.Logging;
+using VirtualPaper.Shader;
+using VirtualPaper.Shader.Core;
+using VirtualPaper.Shader.Models;
+using Workloads.Creation.StaticImg.Core.Rendering;
+using Workloads.Creation.StaticImg.Core.UndoRedoCommand;
+using Workloads.Creation.StaticImg.Events;
+
+namespace Workloads.Creation.StaticImg.Models.ToolItems {
+ ///
+ /// 效果工具——在活动图层上实时预览效果,Commit 确认 / Restore 还原。不参与指针交互,仅负责渲染。
+ ///
+ public sealed partial class EffectTool : RenderBase {
+ private ShaderType _shaderType = ShaderType.None;
+ private EffectParams _params = EffectParams.Default;
+ private CanvasRenderTarget? _originalCache;
+ private bool _isPreviewing;
+
+ public bool IsPreviewing => _isPreviewing;
+ public ShaderType CurrentShaderType => _shaderType;
+
+ /// 开始预览:缓存当前图层。不立即应用效果,等滑块驱动
+ public void StartPreview(ShaderType type, EffectParams? param = null) {
+ if (!IsCanvasReady) return;
+
+ LayerId = ViewModel.Data.SelectedLayer.Tag;
+ _shaderType = type;
+ _params = param ?? new EffectParams { Value = 0f, Value2 = 0f, Value3 = 0f, Value4 = 0f, Dpi = 96f };
+
+ var rt = RenderTarget;
+ _originalCache?.Dispose();
+ _originalCache = new CanvasRenderTarget(rt, rt.SizeInPixels.Width, rt.SizeInPixels.Height, rt.Dpi, rt.Format, rt.AlphaMode);
+ using (var ds = _originalCache.CreateDrawingSession())
+ ds.DrawImage(rt);
+
+ _isPreviewing = true;
+ }
+
+ /// 更新参数并刷新预览
+ public void UpdateParams(EffectParams param) {
+ if (!_isPreviewing) return;
+ _params = param;
+ ApplyEffect();
+ }
+
+ /// 确认效果:效果已写入 RenderTarget,记录撤销命令,通知缩略图更新
+ public void Commit() {
+ if (!_isPreviewing || _originalCache == null) return;
+
+ // 捕获压缩后的原始像素数据
+ var originalPixels = _originalCache.GetPixelBytes().CompressPixels();
+
+ // 创建效果命令并记录到撤销栈
+ var command = new EffectCommand(
+ layerId: LayerId,
+ canvasData: ViewModel.Data,
+ shaderType: _shaderType,
+ effectParams: _params,
+ compressedOriginalPixels: originalPixels,
+ description: $"Apply {_shaderType} effect",
+ requestRenderAction: () => HandleRender(new RenderTargetChangedEventArgs(RenderMode.FullRegion))
+ );
+ ViewModel.Session.UnReUtil.RecordCommand(command);
+
+ _isPreviewing = false;
+ _shaderType = ShaderType.None;
+ _originalCache?.Dispose();
+ _originalCache = null;
+ // 通知 LayerInfo 内容已变化,驱动缩略图刷新
+ RequestOnceRender();
+ }
+
+ /// 取消效果,还原原始图层
+ public void Cancel() {
+ if (!_isPreviewing) return;
+
+ if (_originalCache != null) {
+ using (var ds = RenderTarget.CreateDrawingSession()) {
+ ds.Clear(Colors.Transparent);
+ ds.DrawImage(_originalCache);
+ }
+ HandleRender(new RenderTargetChangedEventArgs(RenderMode.FullRegion));
+ }
+
+ _isPreviewing = false;
+ _shaderType = ShaderType.None;
+ _originalCache?.Dispose();
+ _originalCache = null;
+ }
+
+ private void ApplyEffect() {
+ if (!IsCanvasReady || _originalCache == null) return;
+
+ try {
+ // 对缓存(只读)应用效果,避免同时读写 RenderTarget
+ var result = EffectApplier.Apply(_shaderType, _params, _originalCache);
+ using (var ds = RenderTarget.CreateDrawingSession()) {
+ ds.Clear(Colors.Transparent);
+ ds.DrawImage(result);
+ }
+
+ HandleRender(new RenderTargetChangedEventArgs(RenderMode.FullRegion));
+ }
+ catch (System.Exception ex) {
+ ArcLog.GetLogger().Error($"Apply effect failed: {_shaderType}, {ex.Message}");
+ }
+ }
+ }
+}
diff --git a/src/StaticImg/StaticImg.csproj b/src/StaticImg/StaticImg.csproj
index 267b609e..ee03a769 100644
--- a/src/StaticImg/StaticImg.csproj
+++ b/src/StaticImg/StaticImg.csproj
@@ -32,6 +32,11 @@
+
+
+ MSBuild:Compile
+
+
MSBuild:Compile
diff --git a/src/StaticImg/Utils/EffectMap.cs b/src/StaticImg/Utils/EffectMap.cs
new file mode 100644
index 00000000..779669f9
--- /dev/null
+++ b/src/StaticImg/Utils/EffectMap.cs
@@ -0,0 +1,154 @@
+using VirtualPaper.Shader;
+
+namespace Workloads.Creation.StaticImg.Utils {
+ public static class EffectMap {
+ public static ShaderType ToShaderType(string effectId) => effectId switch {
+ "adjust_grayscale" => ShaderType.Grayscale,
+ "adjust_invert" => ShaderType.Invert,
+ "adjust_exposure" => ShaderType.Exposure,
+ "adjust_brightness" => ShaderType.Brightness,
+ "adjust_saturation" => ShaderType.Saturation,
+ "adjust_hue" => ShaderType.HueRotation,
+ "adjust_contrast" => ShaderType.Contrast,
+ "adjust_temperature" => ShaderType.TemperatureAndTint,
+ "adjust_highlights" => ShaderType.HighlightsAndShadows,
+
+ "color_sepia" => ShaderType.Sepia,
+
+ "art_emboss" => ShaderType.Emboss,
+ "art_pixelate" => ShaderType.Posterize,
+ "art_sepia" => ShaderType.Sepia,
+
+ "fx_blur" => ShaderType.GaussianBlur,
+ "fx_sharpen" => ShaderType.Sharpen,
+ "fx_vignette" => ShaderType.Vignette,
+ "fx_glow" => ShaderType.Glow,
+ "fx_distort" => ShaderType.RippleEffect,
+ "fx_straighten" => ShaderType.Straighten,
+ "fx_edge" => ShaderType.EdgeDetection,
+ "fx_morphology" => ShaderType.Morphology,
+ "fx_noise" => ShaderType.Noise,
+ "fx_bloom" => ShaderType.Bloom,
+
+ "adv_gamma" => ShaderType.GammaTransfer,
+ "adv_hsb" => ShaderType.HSB,
+ "adv_luma_alpha" => ShaderType.LuminanceToAlpha,
+ "adv_chroma_key" => ShaderType.ChromaKey,
+ "adv_fog" => ShaderType.Fog,
+ "adv_glass" => ShaderType.Glass,
+ "adv_colouring" => ShaderType.Colouring,
+
+ "blend_multiply" => ShaderType.BlendMultiply,
+ "blend_screen" => ShaderType.BlendScreen,
+ "blend_overlay" => ShaderType.BlendOverlay,
+ "blend_softlight" => ShaderType.BlendSoftLight,
+
+ _ => ShaderType.None,
+ };
+
+ /// 效果 → 滑块配置(Value/Value2)
+ public static EffectSliderConfig GetSliderConfig(ShaderType type) => type switch {
+ // 无参数效果
+ ShaderType.Grayscale or ShaderType.Invert or
+ ShaderType.Sepia
+ => new EffectSliderConfig(),
+
+ // 单滑块 (Value)
+ ShaderType.Exposure
+ => new EffectSliderConfig { Min = -200, Max = 200, Default = 0, Label = "曝光" },
+ ShaderType.Brightness
+ => new EffectSliderConfig { Min = -100, Max = 100, Default = 0, Label = "亮度" },
+ ShaderType.Saturation
+ => new EffectSliderConfig { Min = 0, Max = 200, Default = 100, Label = "饱和度" },
+ ShaderType.Contrast
+ => new EffectSliderConfig { Min = -100, Max = 100, Default = 0, Label = "对比度" },
+ ShaderType.Vignette
+ => new EffectSliderConfig { Min = 0, Max = 100, Default = 0, Label = "暗角" },
+ ShaderType.GaussianBlur
+ => new EffectSliderConfig { Min = 0, Max = 100, Default = 0, Label = "模糊" },
+ ShaderType.Sharpen
+ => new EffectSliderConfig { Min = 0, Max = 100, Default = 0, Label = "锐化" },
+
+ // 双滑块 (Value + Value2)
+ ShaderType.HueRotation
+ => new EffectSliderConfig { Min = -180, Max = 180, Default = 0, Label = "色相",
+ Min2 = 0, Max2 = 100, Default2 = 100, Label2 = "强度" },
+ ShaderType.TemperatureAndTint
+ => new EffectSliderConfig { Min = -100, Max = 100, Default = 0, Label = "色温",
+ Min2 = -100, Max2 = 100, Default2 = 0, Label2 = "色调" },
+ ShaderType.Emboss
+ => new EffectSliderConfig { Min = 0, Max = 100, Default = 0, Label = "强度",
+ Min2 = 0, Max2 = 360, Default2 = 45, Label2 = "角度" },
+ ShaderType.DirectionalBlur
+ => new EffectSliderConfig { Min = 0, Max = 100, Default = 0, Label = "模糊",
+ Min2 = 0, Max2 = 360, Default2 = 0, Label2 = "角度" },
+ ShaderType.EdgeDetection
+ => new EffectSliderConfig { Min = 0, Max = 100, Default = 50, Label = "强度",
+ Min2 = 0, Max2 = 100, Default2 = 0, Label2 = "模糊" },
+ ShaderType.Morphology
+ => new EffectSliderConfig { Min = 1, Max = 20, Default = 3, Label = "宽度",
+ Min2 = 1, Max2 = 20, Default2 = 3, Label2 = "高度" },
+ ShaderType.Posterize
+ => new EffectSliderConfig { Min = 2, Max = 256, Default = 4, Label = "色阶",
+ Min2 = 2, Max2 = 256, Default2 = 4, Label2 = "色阶G" },
+ ShaderType.Shadow
+ => new EffectSliderConfig { Min = 0, Max = 100, Default = 10, Label = "模糊",
+ Min2 = -200, Max2 = 200, Default2 = 5, Label2 = "偏移X" },
+ ShaderType.Straighten
+ => new EffectSliderConfig { Min = -45, Max = 45, Default = 0, Label = "角度" },
+ ShaderType.GammaTransfer
+ => new EffectSliderConfig { Min = 0, Max = 100, Default = 100, Label = "振幅",
+ Min2 = 1, Max2 = 500, Default2 = 100, Label2 = "指数" },
+ ShaderType.HighlightsAndShadows
+ => new EffectSliderConfig { Min = -100, Max = 100, Default = 0, Label = "阴影",
+ Min2 = -100, Max2 = 100, Default2 = 0, Label2 = "高光" },
+
+ // 自定义着色器
+ ShaderType.ThresholdEffect
+ => new EffectSliderConfig { Min = 0, Max = 300, Default = 150, Label = "阈值" },
+ ShaderType.RippleEffect
+ => new EffectSliderConfig { Min = 0, Max = 200, Default = 140, Label = "频率",
+ Min2 = -200, Max2 = 200, Default2 = 0, Label2 = "相位" },
+ ShaderType.DisplacementLiquefactionEffect
+ => new EffectSliderConfig { Min = 1, Max = 100, Default = 20, Label = "半径",
+ Min2 = 1, Max2 = 100, Default2 = 50, Label2 = "压力" },
+
+ // 新增效果
+ ShaderType.HSB
+ => new EffectSliderConfig { Min = 0, Max = 360, Default = 0, Label = "色相",
+ Min2 = 0, Max2 = 400, Default2 = 100, Label2 = "饱和度" },
+ ShaderType.Fog
+ => new EffectSliderConfig { Min = 0, Max = 100, Default = 50, Label = "浓度" },
+ ShaderType.Glass
+ => new EffectSliderConfig { Min = 0, Max = 100, Default = 50, Label = "强度" },
+ ShaderType.ChromaKey
+ => new EffectSliderConfig { Min = 0, Max = 100, Default = 50, Label = "容差" },
+ ShaderType.Noise
+ => new EffectSliderConfig { Min = 0, Max = 100, Default = 30, Label = "强度" },
+ ShaderType.Bloom
+ => new EffectSliderConfig { Min = 0, Max = 100, Default = 50, Label = "强度" },
+ ShaderType.BlendMultiply or ShaderType.BlendScreen or ShaderType.BlendOverlay or ShaderType.BlendSoftLight
+ => new EffectSliderConfig { Min = 0, Max = 100, Default = 0, Label = "强度" },
+
+ _ => new EffectSliderConfig { Min = 0, Max = 100, Default = 50, Label = "强度" },
+ };
+ }
+
+ /// 滑块参数配置
+ public readonly struct EffectSliderConfig {
+ // Slider 1
+ public float Min { get; init; }
+ public float Max { get; init; }
+ public float Default { get; init; }
+ public string Label { get; init; }
+
+ // Slider 2
+ public float Min2 { get; init; }
+ public float Max2 { get; init; }
+ public float Default2 { get; init; }
+ public string Label2 { get; init; }
+
+ public bool HasSlider1 => Max > Min;
+ public bool HasSlider2 => Max2 > Min2;
+ }
+}
diff --git a/src/StaticImg/ViewModels/InkCanvasViewModel.cs b/src/StaticImg/ViewModels/InkCanvasViewModel.cs
index 2dbba8c8..b35cc546 100644
--- a/src/StaticImg/ViewModels/InkCanvasViewModel.cs
+++ b/src/StaticImg/ViewModels/InkCanvasViewModel.cs
@@ -103,6 +103,7 @@ private async Task SetRenderDataAsync(string filePath, LayerInfo layerInfo) {
new() { Type = ToolType.Fill, ToolName = "Project_StaticImg_ToolName_Fill", ImageSourceKey = "DraftPanel_FuncBar_ColorFill", },
new() { Type = ToolType.Eraser, ToolName = "Project_StaticImg_ToolName_Eraser", Glyph = "\uE75C", },
new() { Type = ToolType.Crop, ToolName = "Project_StaticImg_ToolName_Crop", Glyph = "\uE7A8", },
+ new() { Type = ToolType.CanvasEffect, ToolName = "Project_StaticImg_ToolName_CanvasEffect", Glyph = "\uF4A5", },
new() { Type = ToolType.CanvasSet, ToolName = "Project_StaticImg_ToolName_CanvasSet", Glyph = "\uE9E9", },
];
private readonly InkProjectSession _session = null!;
diff --git a/src/StaticImg/Views/Components/InkCanvas.xaml b/src/StaticImg/Views/Components/InkCanvas.xaml
index 80a4d8b6..e2ea73a3 100644
--- a/src/StaticImg/Views/Components/InkCanvas.xaml
+++ b/src/StaticImg/Views/Components/InkCanvas.xaml
@@ -6,6 +6,7 @@
xmlns:staticImg="using:Workloads.Creation.StaticImg"
xmlns:local="using:Workloads.Creation.StaticImg.Views.Components"
xmlns:staticImgTools="using:Workloads.Creation.StaticImg.Views.Tools"
+ xmlns:staticImgToolsEffect="using:Workloads.Creation.StaticImg.Views.Tools.Effects"
xmlns:arc="using:VirtualPaper.UIComponent.Templates"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
@@ -26,42 +27,125 @@
-
-
+
+
+
-
-
+
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{
@@ -88,6 +93,8 @@ private void RegisterTools() {
OnFatalErrorOccurred(s, e);
};
}
+
+ CanvasEffect.EffectPreviewRequested += OnEffectPreviewRequested;
}
private async void OnFatalErrorOccurred(object? s, Exception e) {
@@ -116,7 +123,7 @@ private void SetupHandlers() {
};
_viewModel.Data.SeletcedToolChanged += (s, e) => {
//before
- HandleSelectionToolBefore();
+ TryRestore();
_selectedTool = _tool.GetTool(_viewModel.Data.SelectedToolItem.Type);
//after
@@ -147,10 +154,6 @@ private void HandleLayerChanged() {
TryRestore();
}
- private void HandleSelectionToolBefore() {
- TryRestore();
- }
-
private void TryRestore() {
if (_selectedTool is SelectionTool st) {
var op = st.RestoreOriginalContent();
@@ -160,6 +163,12 @@ private void TryRestore() {
var op = ct.RestoreOriginalContent();
if (op) RenderToCompositeTarget(RenderMode.FullRegion);
}
+ else if (_selectedTool is EffectTool et) {
+ et.Cancel();
+ CanvasEffect.Restore();
+ UnsubscribeCurrentEffectPanel();
+ effectPanelHost.Visibility = Visibility.Collapsed;
+ }
}
private void RebuildCompositeIfNeeded() {
@@ -237,9 +246,9 @@ private void RenderToCompositeTarget(RenderMode mode, Rect region = default) {
PartialRender(layers, ds, region);
}
}
-
+
renderCanvas.Invalidate();
- }
+ }
}
private void FullRender(IEnumerable layers, CanvasDrawingSession ds) {
@@ -467,6 +476,120 @@ private void CropRequested(CropRequest cr) {
}
#endregion
+ #region CanvasEffect
+ private EffectPanelBase? _currentEffectPanel;
+
+ private void CanvasEffect_Cancel(object sender, RoutedEventArgs e) {
+ if (_selectedTool is not EffectTool et) return;
+
+ et.Cancel();
+ UnsubscribeCurrentEffectPanel();
+ effectPanelHost.Visibility = Visibility.Collapsed;
+ CanvasEffect.ClickedEffectId = null;
+ }
+
+ private void CanvasEffect_Commit(object sender, RoutedEventArgs e) {
+ if (_selectedTool is not EffectTool et) return;
+
+ et.Commit();
+ UnsubscribeCurrentEffectPanel();
+ effectPanelHost.Visibility = Visibility.Collapsed;
+ CanvasEffect.ClickedEffectId = null;
+ }
+
+ private void UnsubscribeCurrentEffectPanel() {
+ if (_currentEffectPanel != null) {
+ _currentEffectPanel.ParamsChanged -= OnEffectPanelParamsChanged;
+ _currentEffectPanel = null;
+ }
+ }
+
+ private void OnEffectPreviewRequested(object? sender, string effectId) {
+ if (_selectedTool is not EffectTool et) return;
+
+ et.Cancel(); // 先取消之前的预览,避免重复叠加效果
+
+ var shaderType = EffectMap.ToShaderType(effectId);
+ if (shaderType == ShaderType.None) {
+ effectPanelHost.Visibility = Visibility.Collapsed;
+ CanvasEffect.ClickedEffectId = null;
+ return;
+ }
+
+ // 先显示面板获取初始参数
+ var panel = GetEffectPanel(shaderType);
+ var initialParams = panel?.Params ?? new EffectParams { Value = 0f, Value2 = 255f, Dpi = 96f };
+
+ et.StartPreview(shaderType, initialParams);
+ ShowEffectPanel(shaderType);
+ }
+
+ private void ShowEffectPanel(ShaderType shaderType) {
+ UnsubscribeCurrentEffectPanel();
+
+ var panel = GetEffectPanel(shaderType);
+ if (panel == null) return;
+
+ panel.Reset();
+
+ _currentEffectPanel = panel;
+ panel.ParamsChanged += OnEffectPanelParamsChanged;
+
+ // 一次性效果(无参数):立即应用,面板仍显示预览提示
+ if (panel.IsOneShot && _selectedTool is EffectTool etOneShot)
+ etOneShot.UpdateParams(panel.Params);
+
+ effectPanelHost.Visibility = Visibility.Visible;
+ }
+
+ private void OnEffectPanelParamsChanged(object? sender, EffectParams p) {
+ if (_selectedTool is EffectTool et && et.IsPreviewing)
+ et.UpdateParams(p);
+ }
+
+ private EffectPanelBase? GetEffectPanel(ShaderType shaderType) => shaderType switch {
+ ShaderType.Exposure => Exposure,
+ ShaderType.Brightness => Brightness,
+ ShaderType.Saturation => Saturation,
+ ShaderType.HueRotation => HueRotation,
+ ShaderType.Contrast => Contrast,
+ ShaderType.TemperatureAndTint => TemperatureTint,
+ ShaderType.HighlightsAndShadows => HighlightsShadows,
+ ShaderType.Grayscale => Empty,
+ ShaderType.Invert => Empty,
+ ShaderType.Sepia => Empty,
+ ShaderType.GaussianBlur => Blur,
+ ShaderType.DirectionalBlur => DirectionalBlur,
+ ShaderType.Sharpen => Sharpen,
+ ShaderType.Vignette => Vignette,
+ ShaderType.Emboss => Emboss,
+ ShaderType.Posterize => Posterize,
+ ShaderType.Shadow => Shadow,
+ ShaderType.ThresholdEffect => Threshold,
+ ShaderType.RippleEffect => Ripple,
+ ShaderType.DisplacementLiquefactionEffect => DisplacementLiquefaction,
+ ShaderType.Straighten => Straighten,
+ ShaderType.Colouring => Colouring,
+ ShaderType.EdgeDetection => EdgeDetection,
+ ShaderType.Morphology => Morphology,
+ ShaderType.LuminanceToAlpha => LuminanceToAlpha,
+ ShaderType.GammaTransfer => GammaTransfer,
+ ShaderType.HSB => HSB,
+ ShaderType.Fog => Fog,
+ ShaderType.Glass => Glass,
+ ShaderType.ChromaKey => ChromaKey,
+ ShaderType.Noise => Noise,
+ ShaderType.Bloom => Bloom,
+ ShaderType.Glow => Glow,
+ ShaderType.BlendMultiply => Blend,
+ ShaderType.BlendScreen => Blend,
+ ShaderType.BlendOverlay => Blend,
+ ShaderType.BlendSoftLight => Blend,
+ _ => null,
+ };
+
+ #endregion
+
#region Layer Mangaer
private void LayerManage_AddLayerRequest(object sender, Guid id) {
_viewModel.Data.AddLayer(layerId: id);
diff --git a/src/StaticImg/Views/Tools/CanvasEffectControl.xaml b/src/StaticImg/Views/Tools/CanvasEffectControl.xaml
new file mode 100644
index 00000000..307315db
--- /dev/null
+++ b/src/StaticImg/Views/Tools/CanvasEffectControl.xaml
@@ -0,0 +1,279 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/StaticImg/Views/Tools/CanvasEffectControl.xaml.cs b/src/StaticImg/Views/Tools/CanvasEffectControl.xaml.cs
new file mode 100644
index 00000000..57047980
--- /dev/null
+++ b/src/StaticImg/Views/Tools/CanvasEffectControl.xaml.cs
@@ -0,0 +1,185 @@
+using System;
+using System.Collections.Generic;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Controls.Primitives;
+using Microsoft.UI.Xaml.Media.Animation;
+using VirtualPaper.Common;
+using VirtualPaper.UIComponent.Collection;
+using VirtualPaper.UIComponent.Utils;
+using Workloads.Creation.StaticImg.Models;
+
+// To learn more about WinUI, the WinUI project structure,
+// and more about our project templates, see: http://aka.ms/winui-project-info.
+
+namespace Workloads.Creation.StaticImg.Views.Tools {
+ public sealed partial class CanvasEffectControl : UserControl {
+ public event EventHandler? CanvasEffectCancel;
+ public event EventHandler? CanvasEffectCommit;
+ public event EventHandler? EffectPreviewRequested;
+
+ // 当前选中的效果 ID
+ public string? ClickedEffectId {
+ get => (string?)GetValue(ClickedEffectIdProperty);
+ set => SetValue(ClickedEffectIdProperty, value);
+ }
+ public static readonly DependencyProperty ClickedEffectIdProperty =
+ DependencyProperty.Register(
+ nameof(ClickedEffectId),
+ typeof(string),
+ typeof(CanvasEffectControl),
+ new PropertyMetadata(null));
+
+ // 内容区展开目标高度:文本列表模式
+ private const double ExpandedMaxHeight = 260.0;
+ private static readonly Duration AnimDuration = new(TimeSpan.FromMilliseconds(180));
+
+ public CanvasEffectControl() {
+ InitializeComponent();
+ InitEffectGroups();
+ // 清空初始选中(ArcListView 的 TrySelectFirstItem 会自动选中首项)
+ ClearAllSelections();
+ }
+
+ // 初始化各分组数据
+ private void InitEffectGroups() {
+ // 调整 (10)
+ EffectGrid_Adjust.ItemsSource = new List {
+ new() { EffectId = "adjust_grayscale", EffectName = LanguageUtil.GetI18n(nameof(Constants.I18n.Project_StaticImg_Text_Effect_GrayScale)), EffectDescription = LanguageUtil.GetI18n(nameof(Constants.I18n.Project_StaticImg_Text_EffectDesc_GrayScale)) },
+ new() { EffectId = "adjust_invert", EffectName = LanguageUtil.GetI18n(nameof(Constants.I18n.Project_StaticImg_Text_Effect_Invert)), EffectDescription = LanguageUtil.GetI18n(nameof(Constants.I18n.Project_StaticImg_Text_EffectDesc_Invert)) },
+ new() { EffectId = "adjust_exposure", EffectName = LanguageUtil.GetI18n(nameof(Constants.I18n.Project_StaticImg_Text_Effect_Exposure)), EffectDescription = LanguageUtil.GetI18n(nameof(Constants.I18n.Project_StaticImg_Text_EffectDesc_Exposure)) },
+ new() { EffectId = "adjust_brightness", EffectName = LanguageUtil.GetI18n(nameof(Constants.I18n.Project_StaticImg_Text_Effect_Brightness)), EffectDescription = LanguageUtil.GetI18n(nameof(Constants.I18n.Project_StaticImg_Text_EffectDesc_Brightness)) },
+ new() { EffectId = "adjust_saturation", EffectName = LanguageUtil.GetI18n(nameof(Constants.I18n.Project_StaticImg_Text_Effect_Saturation)), EffectDescription = LanguageUtil.GetI18n(nameof(Constants.I18n.Project_StaticImg_Text_EffectDesc_Saturation)) },
+ new() { EffectId = "adjust_hue", EffectName = LanguageUtil.GetI18n(nameof(Constants.I18n.Project_StaticImg_Text_Effect_Hue)), EffectDescription = LanguageUtil.GetI18n(nameof(Constants.I18n.Project_StaticImg_Text_EffectDesc_Hue)) },
+ new() { EffectId = "adjust_contrast", EffectName = LanguageUtil.GetI18n(nameof(Constants.I18n.Project_StaticImg_Text_Effect_Contrast)), EffectDescription = LanguageUtil.GetI18n(nameof(Constants.I18n.Project_StaticImg_Text_EffectDesc_Contrast)) },
+ new() { EffectId = "adjust_temperature", EffectName = LanguageUtil.GetI18n(nameof(Constants.I18n.Project_StaticImg_Text_Effect_Temperature)), EffectDescription = LanguageUtil.GetI18n(nameof(Constants.I18n.Project_StaticImg_Text_EffectDesc_Temperature)) },
+ new() { EffectId = "adjust_highlights", EffectName = LanguageUtil.GetI18n(nameof(Constants.I18n.Project_StaticImg_Text_Effect_Highlights)), EffectDescription = LanguageUtil.GetI18n(nameof(Constants.I18n.Project_StaticImg_Text_EffectDesc_Highlights)) },
+ new() { EffectId = "color_sepia", EffectName = LanguageUtil.GetI18n(nameof(Constants.I18n.Project_StaticImg_Text_Effect_Sepia)), EffectDescription = LanguageUtil.GetI18n(nameof(Constants.I18n.Project_StaticImg_Text_EffectDesc_Sepia)) },
+ };
+
+
+ // 特效 (8)
+ EffectGrid_Special.ItemsSource = new List {
+ new() { EffectId = "fx_blur", EffectName = LanguageUtil.GetI18n(nameof(Constants.I18n.Project_StaticImg_Text_Effect_Blur)), EffectDescription = LanguageUtil.GetI18n(nameof(Constants.I18n.Project_StaticImg_Text_EffectDesc_Blur)) },
+ new() { EffectId = "fx_sharpen", EffectName = LanguageUtil.GetI18n(nameof(Constants.I18n.Project_StaticImg_Text_Effect_Sharpen)), EffectDescription = LanguageUtil.GetI18n(nameof(Constants.I18n.Project_StaticImg_Text_EffectDesc_Sharpen)) },
+ new() { EffectId = "fx_noise", EffectName = LanguageUtil.GetI18n(nameof(Constants.I18n.Project_StaticImg_Text_Effect_Noise)), EffectDescription = LanguageUtil.GetI18n(nameof(Constants.I18n.Project_StaticImg_Text_EffectDesc_Noise)) },
+ new() { EffectId = "fx_vignette", EffectName = LanguageUtil.GetI18n(nameof(Constants.I18n.Project_StaticImg_Text_Effect_Vignette)), EffectDescription = LanguageUtil.GetI18n(nameof(Constants.I18n.Project_StaticImg_Text_EffectDesc_Vignette)) },
+ new() { EffectId = "fx_glow", EffectName = LanguageUtil.GetI18n(nameof(Constants.I18n.Project_StaticImg_Text_Effect_Glow)), EffectDescription = LanguageUtil.GetI18n(nameof(Constants.I18n.Project_StaticImg_Text_EffectDesc_Glow)) },
+ new() { EffectId = "fx_bloom", EffectName = LanguageUtil.GetI18n(nameof(Constants.I18n.Project_StaticImg_Text_Effect_Bloom)), EffectDescription = LanguageUtil.GetI18n(nameof(Constants.I18n.Project_StaticImg_Text_EffectDesc_Bloom)) },
+ new() { EffectId = "fx_distort", EffectName = LanguageUtil.GetI18n(nameof(Constants.I18n.Project_StaticImg_Text_Effect_Distort)), EffectDescription = LanguageUtil.GetI18n(nameof(Constants.I18n.Project_StaticImg_Text_EffectDesc_Distort)) },
+ };
+
+ // 混合 (4)
+ EffectGrid_Blend.ItemsSource = new List {
+ new() { EffectId = "blend_multiply", EffectName = LanguageUtil.GetI18n(nameof(Constants.I18n.Project_StaticImg_Text_Effect_Multiply)), EffectDescription = LanguageUtil.GetI18n(nameof(Constants.I18n.Project_StaticImg_Text_EffectDesc_Multiply)) },
+ new() { EffectId = "blend_screen", EffectName = LanguageUtil.GetI18n(nameof(Constants.I18n.Project_StaticImg_Text_Effect_Screen)), EffectDescription = LanguageUtil.GetI18n(nameof(Constants.I18n.Project_StaticImg_Text_EffectDesc_Screen)) },
+ new() { EffectId = "blend_overlay", EffectName = LanguageUtil.GetI18n(nameof(Constants.I18n.Project_StaticImg_Text_Effect_Overlay)), EffectDescription = LanguageUtil.GetI18n(nameof(Constants.I18n.Project_StaticImg_Text_EffectDesc_Overlay)) },
+ new() { EffectId = "blend_softlight", EffectName = LanguageUtil.GetI18n(nameof(Constants.I18n.Project_StaticImg_Text_Effect_SoftLight)), EffectDescription = LanguageUtil.GetI18n(nameof(Constants.I18n.Project_StaticImg_Text_EffectDesc_SoftLight)) },
+ };
+ }
+
+ // Accordion:打开当前组,折叠其他组
+ private void GroupHeader_Checked(object sender, RoutedEventArgs e) {
+ var clickedHeader = (ToggleButton)sender;
+ var clickedContent = GetContentForHeader(clickedHeader);
+
+ foreach (var (header, content) in AllGroups) {
+ if (!ReferenceEquals(header, clickedHeader) && header.IsChecked == true) {
+ // 先取消选中(避免循环触发),再手动收起
+ header.IsChecked = false;
+ AnimateMaxHeight(content, to: 0);
+ }
+ }
+
+ AnimateMaxHeight(clickedContent, to: ExpandedMaxHeight);
+ }
+
+ private void GroupHeader_Unchecked(object sender, RoutedEventArgs e) {
+ var content = GetContentForHeader((ToggleButton)sender);
+ AnimateMaxHeight(content, to: 0);
+ }
+
+ // MaxHeight 动画
+ private static void AnimateMaxHeight(Grid target, double to) {
+ var anim = new DoubleAnimation {
+ To = to,
+ Duration = AnimDuration,
+ EasingFunction = new CubicEase { EasingMode = EasingMode.EaseInOut },
+ EnableDependentAnimation = true,
+ };
+ Storyboard.SetTarget(anim, target);
+ Storyboard.SetTargetProperty(anim, "MaxHeight");
+
+ var sb = new Storyboard();
+ sb.Children.Add(anim);
+ sb.Begin();
+ }
+
+ private void EffectGridView_ItemClick(object sender, ItemClickEventArgs e) {
+ if (e.ClickedItem is CanvasEffectItem item) {
+ ClickedEffectId = item.EffectId;
+
+ var clickedGrid = (ArcListView)sender;
+
+ // 跨组互斥:取消其他所有 ArcListView 的选中
+ foreach (var grid in AllGrids) {
+ if (!ReferenceEquals(grid, clickedGrid)) {
+ grid.ClearSelection();
+ }
+ }
+
+ EffectPreviewRequested?.Invoke(this, item.EffectId);
+ }
+ }
+
+ internal void ClearAllSelections() {
+ foreach (var grid in AllGrids) {
+ grid.ClearSelection();
+ }
+ }
+
+ // Header to Content 映射
+ private Grid GetContentForHeader(ToggleButton header) {
+ if (ReferenceEquals(header, Header_Adjust)) return Content_Adjust;
+ if (ReferenceEquals(header, Header_Special)) return Content_Special;
+ return Content_Blend;
+ }
+
+ private IEnumerable<(ToggleButton header, Grid content)> AllGroups =>
+ [
+ (Header_Adjust, Content_Adjust),
+ (Header_Special, Content_Special),
+ (Header_Blend, Content_Blend),
+ ];
+
+ private IEnumerable AllGrids =>
+ [
+ EffectGrid_Adjust,
+ EffectGrid_Special,
+ EffectGrid_Blend,
+ ];
+
+ // 底部按钮
+ private void SelectCancelBtn_Click(object sender, RoutedEventArgs e) {
+ Restore();
+ CanvasEffectCancel?.Invoke(this, e);
+ }
+
+ private void SelectCommitBtn_Click(object sender, RoutedEventArgs e) {
+ ClearAllSelections();
+ CanvasEffectCommit?.Invoke(this, e);
+ }
+
+ internal void Restore() {
+ ClickedEffectId = null;
+ ClearAllSelections();
+
+ foreach (var (header, content) in AllGroups) {
+ if (header.IsChecked == true) {
+ header.IsChecked = false;
+ AnimateMaxHeight(content, to: 0);
+ }
+ }
+ }
+ }
+}
diff --git a/src/StaticImg/Views/Tools/CropControl.xaml b/src/StaticImg/Views/Tools/CropControl.xaml
index 0d843e9c..d72a68d2 100644
--- a/src/StaticImg/Views/Tools/CropControl.xaml
+++ b/src/StaticImg/Views/Tools/CropControl.xaml
@@ -89,21 +89,22 @@
-
+
diff --git a/src/StaticImg/Views/Tools/Effects/BlendEffectPanel.xaml b/src/StaticImg/Views/Tools/Effects/BlendEffectPanel.xaml
new file mode 100644
index 00000000..81c9f178
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/BlendEffectPanel.xaml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/StaticImg/Views/Tools/Effects/BlendEffectPanel.xaml.cs b/src/StaticImg/Views/Tools/Effects/BlendEffectPanel.xaml.cs
new file mode 100644
index 00000000..ca870658
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/BlendEffectPanel.xaml.cs
@@ -0,0 +1,27 @@
+using VirtualPaper.Shader.Models;
+
+namespace Workloads.Creation.StaticImg.Views.Tools.Effects {
+ public sealed partial class BlendEffectPanel : EffectPanelBase {
+ private double _defaultValue;
+
+ public BlendEffectPanel() {
+ this.InitializeComponent();
+ _defaultValue = Slider.Value;
+ UpdateValueText();
+ }
+
+ public override EffectParams Params => new() { Value = (float)Slider.Value, Dpi = 96f };
+
+ public override void Reset() {
+ Slider.Value = _defaultValue;
+ UpdateValueText();
+ }
+
+ private void Slider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateValueText();
+ RaiseParamsChanged();
+ }
+
+ private void UpdateValueText() => ValueText.Text = ((int)Slider.Value).ToString();
+ }
+}
diff --git a/src/StaticImg/Views/Tools/Effects/BloomEffectPanel.xaml b/src/StaticImg/Views/Tools/Effects/BloomEffectPanel.xaml
new file mode 100644
index 00000000..2666e8ab
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/BloomEffectPanel.xaml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/StaticImg/Views/Tools/Effects/BloomEffectPanel.xaml.cs b/src/StaticImg/Views/Tools/Effects/BloomEffectPanel.xaml.cs
new file mode 100644
index 00000000..480df71c
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/BloomEffectPanel.xaml.cs
@@ -0,0 +1,27 @@
+using VirtualPaper.Shader.Models;
+
+namespace Workloads.Creation.StaticImg.Views.Tools.Effects {
+ public sealed partial class BloomEffectPanel : EffectPanelBase {
+ private double _defaultValue;
+
+ public BloomEffectPanel() {
+ this.InitializeComponent();
+ _defaultValue = Slider.Value;
+ UpdateValueText();
+ }
+
+ public override EffectParams Params => new() { Value = (float)Slider.Value, Dpi = 96f };
+
+ public override void Reset() {
+ Slider.Value = _defaultValue;
+ UpdateValueText();
+ }
+
+ private void Slider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateValueText();
+ RaiseParamsChanged();
+ }
+
+ private void UpdateValueText() => ValueText.Text = ((int)Slider.Value).ToString();
+ }
+}
diff --git a/src/StaticImg/Views/Tools/Effects/BlurEffectPanel.xaml b/src/StaticImg/Views/Tools/Effects/BlurEffectPanel.xaml
new file mode 100644
index 00000000..9bb8c615
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/BlurEffectPanel.xaml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/StaticImg/Views/Tools/Effects/BlurEffectPanel.xaml.cs b/src/StaticImg/Views/Tools/Effects/BlurEffectPanel.xaml.cs
new file mode 100644
index 00000000..9464a7cf
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/BlurEffectPanel.xaml.cs
@@ -0,0 +1,27 @@
+using VirtualPaper.Shader.Models;
+
+namespace Workloads.Creation.StaticImg.Views.Tools.Effects {
+ public sealed partial class BlurEffectPanel : EffectPanelBase {
+ private double _defaultValue;
+
+ public BlurEffectPanel() {
+ this.InitializeComponent();
+ _defaultValue = Slider.Value;
+ UpdateValueText();
+ }
+
+ public override EffectParams Params => new() { Value = (float)Slider.Value, Dpi = 96f };
+
+ public override void Reset() {
+ Slider.Value = _defaultValue;
+ UpdateValueText();
+ }
+
+ private void Slider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateValueText();
+ RaiseParamsChanged();
+ }
+
+ private void UpdateValueText() => ValueText.Text = ((int)Slider.Value).ToString();
+ }
+}
diff --git a/src/StaticImg/Views/Tools/Effects/BrightnessEffectPanel.xaml b/src/StaticImg/Views/Tools/Effects/BrightnessEffectPanel.xaml
new file mode 100644
index 00000000..37ebf963
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/BrightnessEffectPanel.xaml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/StaticImg/Views/Tools/Effects/BrightnessEffectPanel.xaml.cs b/src/StaticImg/Views/Tools/Effects/BrightnessEffectPanel.xaml.cs
new file mode 100644
index 00000000..1da7fa00
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/BrightnessEffectPanel.xaml.cs
@@ -0,0 +1,41 @@
+using VirtualPaper.Shader.Models;
+
+namespace Workloads.Creation.StaticImg.Views.Tools.Effects {
+ public sealed partial class BrightnessEffectPanel : EffectPanelBase {
+ private double _defaultBlack, _defaultWhite;
+
+ public BrightnessEffectPanel() {
+ this.InitializeComponent();
+ _defaultBlack = BlackSlider.Value;
+ _defaultWhite = WhiteSlider.Value;
+ UpdateBlackText();
+ UpdateWhiteText();
+ }
+
+ public override EffectParams Params => new() {
+ Value = (float)BlackSlider.Value,
+ Value2 = (float)WhiteSlider.Value,
+ Dpi = 96f,
+ };
+
+ public override void Reset() {
+ BlackSlider.Value = _defaultBlack;
+ WhiteSlider.Value = _defaultWhite;
+ UpdateBlackText();
+ UpdateWhiteText();
+ }
+
+ private void BlackSlider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateBlackText();
+ RaiseParamsChanged();
+ }
+
+ private void WhiteSlider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateWhiteText();
+ RaiseParamsChanged();
+ }
+
+ private void UpdateBlackText() => BlackValueText.Text = ((int)BlackSlider.Value).ToString();
+ private void UpdateWhiteText() => WhiteValueText.Text = ((int)WhiteSlider.Value).ToString();
+ }
+}
diff --git a/src/StaticImg/Views/Tools/Effects/ChromaKeyEffectPanel.xaml b/src/StaticImg/Views/Tools/Effects/ChromaKeyEffectPanel.xaml
new file mode 100644
index 00000000..d398315c
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/ChromaKeyEffectPanel.xaml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/StaticImg/Views/Tools/Effects/ChromaKeyEffectPanel.xaml.cs b/src/StaticImg/Views/Tools/Effects/ChromaKeyEffectPanel.xaml.cs
new file mode 100644
index 00000000..007baf7a
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/ChromaKeyEffectPanel.xaml.cs
@@ -0,0 +1,45 @@
+using VirtualPaper.Shader.Models;
+
+namespace Workloads.Creation.StaticImg.Views.Tools.Effects {
+ public sealed partial class ChromaKeyEffectPanel : EffectPanelBase {
+ private double _defaultTolerance;
+ private bool _defaultInvert, _defaultFeather;
+
+ public ChromaKeyEffectPanel() {
+ this.InitializeComponent();
+ _defaultTolerance = ToleranceSlider.Value;
+ _defaultInvert = InvertToggle.IsOn;
+ _defaultFeather = FeatherToggle.IsOn;
+ UpdateToleranceText();
+ }
+
+ public override EffectParams Params => new() {
+ Value = (float)ToleranceSlider.Value,
+ Flag = InvertToggle.IsOn,
+ Mode = FeatherToggle.IsOn ? 1 : 0,
+ Dpi = 96f,
+ };
+
+ public override void Reset() {
+ ToleranceSlider.Value = _defaultTolerance;
+ InvertToggle.IsOn = _defaultInvert;
+ FeatherToggle.IsOn = _defaultFeather;
+ UpdateToleranceText();
+ }
+
+ private void ToleranceSlider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateToleranceText();
+ RaiseParamsChanged();
+ }
+
+ private void InvertToggle_Toggled(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) {
+ RaiseParamsChanged();
+ }
+
+ private void FeatherToggle_Toggled(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) {
+ RaiseParamsChanged();
+ }
+
+ private void UpdateToleranceText() => ToleranceValueText.Text = ((int)ToleranceSlider.Value).ToString();
+ }
+}
diff --git a/src/StaticImg/Views/Tools/Effects/ColouringEffectPanel.xaml b/src/StaticImg/Views/Tools/Effects/ColouringEffectPanel.xaml
new file mode 100644
index 00000000..00919c66
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/ColouringEffectPanel.xaml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/StaticImg/Views/Tools/Effects/ColouringEffectPanel.xaml.cs b/src/StaticImg/Views/Tools/Effects/ColouringEffectPanel.xaml.cs
new file mode 100644
index 00000000..57ab7b68
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/ColouringEffectPanel.xaml.cs
@@ -0,0 +1,33 @@
+using Microsoft.UI;
+using VirtualPaper.Shader.Models;
+using VirtualPaper.UIComponent.Input;
+
+namespace Workloads.Creation.StaticImg.Views.Tools.Effects {
+ public sealed partial class ColouringEffectPanel : EffectPanelBase {
+ private double _defaultValue;
+
+ public ColouringEffectPanel() {
+ this.InitializeComponent();
+ _defaultValue = Slider.Value;
+ Slider.TrackFill = Gradient(
+ (0, Colors.Red), (0.17, Colors.Yellow), (0.33, Colors.Lime),
+ (0.5, Colors.Cyan), (0.67, Colors.Blue), (0.83, Colors.Magenta), (1, Colors.Red));
+ Slider.TrackFillMode = ArcSliderTrackFillMode.Full;
+ UpdateValueText();
+ }
+
+ public override EffectParams Params => new() { Value = (float)Slider.Value, Dpi = 96f };
+
+ public override void Reset() {
+ Slider.Value = _defaultValue;
+ UpdateValueText();
+ }
+
+ private void Slider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateValueText();
+ RaiseParamsChanged();
+ }
+
+ private void UpdateValueText() => ValueText.Text = ((int)Slider.Value).ToString() + "°";
+ }
+}
diff --git a/src/StaticImg/Views/Tools/Effects/ContrastEffectPanel.xaml b/src/StaticImg/Views/Tools/Effects/ContrastEffectPanel.xaml
new file mode 100644
index 00000000..b1df4533
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/ContrastEffectPanel.xaml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/StaticImg/Views/Tools/Effects/ContrastEffectPanel.xaml.cs b/src/StaticImg/Views/Tools/Effects/ContrastEffectPanel.xaml.cs
new file mode 100644
index 00000000..4624c53d
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/ContrastEffectPanel.xaml.cs
@@ -0,0 +1,31 @@
+using Microsoft.UI;
+using VirtualPaper.Shader.Models;
+using VirtualPaper.UIComponent.Input;
+
+namespace Workloads.Creation.StaticImg.Views.Tools.Effects {
+ public sealed partial class ContrastEffectPanel : EffectPanelBase {
+ private double _defaultValue;
+
+ public ContrastEffectPanel() {
+ this.InitializeComponent();
+ _defaultValue = Slider.Value;
+ Slider.TrackFill = Gradient((0, Colors.Gray), (0.5, Colors.WhiteSmoke), (1, Colors.Black));
+ Slider.TrackFillMode = ArcSliderTrackFillMode.Full;
+ UpdateValueText();
+ }
+
+ public override EffectParams Params => new() { Value = (float)Slider.Value, Dpi = 96f };
+
+ public override void Reset() {
+ Slider.Value = _defaultValue;
+ UpdateValueText();
+ }
+
+ private void Slider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateValueText();
+ RaiseParamsChanged();
+ }
+
+ private void UpdateValueText() => ValueText.Text = ((int)Slider.Value).ToString();
+ }
+}
diff --git a/src/StaticImg/Views/Tools/Effects/DirectionalBlurEffectPanel.xaml b/src/StaticImg/Views/Tools/Effects/DirectionalBlurEffectPanel.xaml
new file mode 100644
index 00000000..57c7a692
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/DirectionalBlurEffectPanel.xaml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/StaticImg/Views/Tools/Effects/DirectionalBlurEffectPanel.xaml.cs b/src/StaticImg/Views/Tools/Effects/DirectionalBlurEffectPanel.xaml.cs
new file mode 100644
index 00000000..34fa0151
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/DirectionalBlurEffectPanel.xaml.cs
@@ -0,0 +1,46 @@
+using Microsoft.UI;
+using VirtualPaper.Shader.Models;
+using VirtualPaper.UIComponent.Input;
+
+namespace Workloads.Creation.StaticImg.Views.Tools.Effects {
+ public sealed partial class DirectionalBlurEffectPanel : EffectPanelBase {
+ private double _defaultBlur, _defaultAngle;
+
+ public DirectionalBlurEffectPanel() {
+ this.InitializeComponent();
+ _defaultBlur = BlurSlider.Value;
+ _defaultAngle = AngleSlider.Value;
+ AngleSlider.TrackFill = Gradient(
+ (0, Colors.Red), (0.25, Colors.Yellow), (0.5, Colors.Cyan), (0.75, Colors.Blue), (1, Colors.Red));
+ AngleSlider.TrackFillMode = ArcSliderTrackFillMode.Full;
+ UpdateBlurText();
+ UpdateAngleText();
+ }
+
+ public override EffectParams Params => new() {
+ Value = (float)BlurSlider.Value,
+ Value2 = (float)AngleSlider.Value,
+ Dpi = 96f,
+ };
+
+ public override void Reset() {
+ BlurSlider.Value = _defaultBlur;
+ AngleSlider.Value = _defaultAngle;
+ UpdateBlurText();
+ UpdateAngleText();
+ }
+
+ private void BlurSlider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateBlurText();
+ RaiseParamsChanged();
+ }
+
+ private void AngleSlider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateAngleText();
+ RaiseParamsChanged();
+ }
+
+ private void UpdateBlurText() => BlurValueText.Text = ((int)BlurSlider.Value).ToString();
+ private void UpdateAngleText() => AngleValueText.Text = ((int)AngleSlider.Value).ToString();
+ }
+}
diff --git a/src/StaticImg/Views/Tools/Effects/DisplacementLiquefactionEffectPanel.xaml b/src/StaticImg/Views/Tools/Effects/DisplacementLiquefactionEffectPanel.xaml
new file mode 100644
index 00000000..531b2910
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/DisplacementLiquefactionEffectPanel.xaml
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+ 重置
+ 推
+ 挤压
+ 扩展
+ 左旋
+ 右旋
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/StaticImg/Views/Tools/Effects/DisplacementLiquefactionEffectPanel.xaml.cs b/src/StaticImg/Views/Tools/Effects/DisplacementLiquefactionEffectPanel.xaml.cs
new file mode 100644
index 00000000..f188a9f1
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/DisplacementLiquefactionEffectPanel.xaml.cs
@@ -0,0 +1,50 @@
+using VirtualPaper.Shader.Models;
+
+namespace Workloads.Creation.StaticImg.Views.Tools.Effects {
+ public sealed partial class DisplacementLiquefactionEffectPanel : EffectPanelBase {
+ private double _defaultRadius, _defaultPressure;
+ private int _defaultMode;
+
+ public DisplacementLiquefactionEffectPanel() {
+ this.InitializeComponent();
+ _defaultRadius = RadiusSlider.Value;
+ _defaultPressure = PressureSlider.Value;
+ _defaultMode = ModeCombo.SelectedIndex;
+ UpdateRadiusText();
+ UpdatePressureText();
+ }
+
+ public override EffectParams Params => new() {
+ Mode = ModeCombo.SelectedIndex,
+ Value = (float)PressureSlider.Value / 100f,
+ Value2 = (float)RadiusSlider.Value,
+ Amount = 512,
+ Dpi = 96f,
+ };
+
+ public override void Reset() {
+ RadiusSlider.Value = _defaultRadius;
+ PressureSlider.Value = _defaultPressure;
+ ModeCombo.SelectedIndex = _defaultMode;
+ UpdateRadiusText();
+ UpdatePressureText();
+ }
+
+ private void ModeCombo_SelectionChanged(object sender, Microsoft.UI.Xaml.Controls.SelectionChangedEventArgs e) {
+ RaiseParamsChanged();
+ }
+
+ private void RadiusSlider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateRadiusText();
+ RaiseParamsChanged();
+ }
+
+ private void PressureSlider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdatePressureText();
+ RaiseParamsChanged();
+ }
+
+ private void UpdateRadiusText() => RadiusValueText.Text = ((int)RadiusSlider.Value).ToString();
+ private void UpdatePressureText() => PressureValueText.Text = ((int)PressureSlider.Value).ToString();
+ }
+}
diff --git a/src/StaticImg/Views/Tools/Effects/DoubleSliderEffectPanel.xaml b/src/StaticImg/Views/Tools/Effects/DoubleSliderEffectPanel.xaml
new file mode 100644
index 00000000..f32bac6f
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/DoubleSliderEffectPanel.xaml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/StaticImg/Views/Tools/Effects/DoubleSliderEffectPanel.xaml.cs b/src/StaticImg/Views/Tools/Effects/DoubleSliderEffectPanel.xaml.cs
new file mode 100644
index 00000000..eff056b0
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/DoubleSliderEffectPanel.xaml.cs
@@ -0,0 +1,57 @@
+using System;
+using VirtualPaper.Shader.Models;
+using Workloads.Creation.StaticImg.Utils;
+
+namespace Workloads.Creation.StaticImg.Views.Tools.Effects {
+ public sealed partial class DoubleSliderEffectPanel : EffectPanelBase {
+ private double _defaultValue1, _defaultValue2;
+
+ public DoubleSliderEffectPanel(EffectSliderConfig cfg) {
+ this.InitializeComponent();
+ Label1Text.Text = cfg.Label;
+ Slider1.Minimum = cfg.Min;
+ Slider1.Maximum = cfg.Max;
+ Slider1.Value = _defaultValue1 = cfg.Default;
+ Slider1.TickFrequency = Math.Max(1, Math.Abs(cfg.Max - cfg.Min) / 8);
+ Slider1.SmallChange = Math.Max(1, Math.Abs(cfg.Max - cfg.Min) / 100);
+ Slider1.StepFrequency = Math.Max(1, Math.Abs(cfg.Max - cfg.Min) / 100);
+
+ Label2Text.Text = cfg.Label2;
+ Slider2.Minimum = cfg.Min2;
+ Slider2.Maximum = cfg.Max2;
+ Slider2.Value = _defaultValue2 = cfg.Default2;
+ Slider2.TickFrequency = Math.Max(1, Math.Abs(cfg.Max2 - cfg.Min2) / 8);
+ Slider2.SmallChange = Math.Max(1, Math.Abs(cfg.Max2 - cfg.Min2) / 100);
+ Slider2.StepFrequency = Math.Max(1, Math.Abs(cfg.Max2 - cfg.Min2) / 100);
+
+ UpdateValue1Text();
+ UpdateValue2Text();
+ }
+
+ public override EffectParams Params => new() {
+ Value = (float)Slider1.Value,
+ Value2 = (float)Slider2.Value,
+ Dpi = 96f,
+ };
+
+ public override void Reset() {
+ Slider1.Value = _defaultValue1;
+ Slider2.Value = _defaultValue2;
+ UpdateValue1Text();
+ UpdateValue2Text();
+ }
+
+ private void Slider1_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateValue1Text();
+ RaiseParamsChanged();
+ }
+
+ private void Slider2_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateValue2Text();
+ RaiseParamsChanged();
+ }
+
+ private void UpdateValue1Text() => Value1Text.Text = ((int)Slider1.Value).ToString();
+ private void UpdateValue2Text() => Value2Text.Text = ((int)Slider2.Value).ToString();
+ }
+}
diff --git a/src/StaticImg/Views/Tools/Effects/EdgeDetectionEffectPanel.xaml b/src/StaticImg/Views/Tools/Effects/EdgeDetectionEffectPanel.xaml
new file mode 100644
index 00000000..6d7bde0e
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/EdgeDetectionEffectPanel.xaml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/StaticImg/Views/Tools/Effects/EdgeDetectionEffectPanel.xaml.cs b/src/StaticImg/Views/Tools/Effects/EdgeDetectionEffectPanel.xaml.cs
new file mode 100644
index 00000000..8616f820
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/EdgeDetectionEffectPanel.xaml.cs
@@ -0,0 +1,49 @@
+using VirtualPaper.Shader.Models;
+
+namespace Workloads.Creation.StaticImg.Views.Tools.Effects {
+ public sealed partial class EdgeDetectionEffectPanel : EffectPanelBase {
+ private double _defaultAmount, _defaultBlur;
+ private bool _defaultOverlay;
+
+ public EdgeDetectionEffectPanel() {
+ this.InitializeComponent();
+ _defaultAmount = AmountSlider.Value;
+ _defaultBlur = BlurSlider.Value;
+ _defaultOverlay = OverlayToggle.IsOn;
+ UpdateAmountText();
+ UpdateBlurText();
+ }
+
+ public override EffectParams Params => new() {
+ Value = (float)AmountSlider.Value,
+ Value2 = (float)BlurSlider.Value,
+ Flag = OverlayToggle.IsOn,
+ Dpi = 96f,
+ };
+
+ public override void Reset() {
+ AmountSlider.Value = _defaultAmount;
+ BlurSlider.Value = _defaultBlur;
+ OverlayToggle.IsOn = _defaultOverlay;
+ UpdateAmountText();
+ UpdateBlurText();
+ }
+
+ private void AmountSlider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateAmountText();
+ RaiseParamsChanged();
+ }
+
+ private void BlurSlider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateBlurText();
+ RaiseParamsChanged();
+ }
+
+ private void OverlayToggle_Toggled(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) {
+ RaiseParamsChanged();
+ }
+
+ private void UpdateAmountText() => AmountValueText.Text = ((int)AmountSlider.Value).ToString();
+ private void UpdateBlurText() => BlurValueText.Text = ((int)BlurSlider.Value).ToString();
+ }
+}
diff --git a/src/StaticImg/Views/Tools/Effects/EffectPanelBase.cs b/src/StaticImg/Views/Tools/Effects/EffectPanelBase.cs
new file mode 100644
index 00000000..cff30dd9
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/EffectPanelBase.cs
@@ -0,0 +1,35 @@
+using System;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Media;
+using VirtualPaper.Shader.Models;
+
+namespace Workloads.Creation.StaticImg.Views.Tools.Effects {
+ /// 效果参数面板基类
+ public abstract class EffectPanelBase : UserControl {
+ public event EventHandler? ParamsChanged;
+
+ public abstract EffectParams Params { get; }
+
+ ///
+ /// true 表示此效果无需参数、点击即生效(如灰度、反相)
+ /// ShowEffectPanel 检测到此标志后会立即调用一次 UpdateParams
+ ///
+ public virtual bool IsOneShot => false;
+
+ /// 重置面板参数到初始值(每次打开效果时调用)
+ public virtual void Reset() { }
+
+ protected void RaiseParamsChanged() => ParamsChanged?.Invoke(this, Params);
+
+ protected static LinearGradientBrush Gradient(params (double offset, Windows.UI.Color color)[] stops) {
+ var brush = new LinearGradientBrush {
+ StartPoint = new Windows.Foundation.Point(0, 0),
+ EndPoint = new Windows.Foundation.Point(1, 0),
+ };
+ foreach (var (offset, color) in stops)
+ brush.GradientStops.Add(new GradientStop { Offset = offset, Color = color });
+ return brush;
+ }
+ }
+}
diff --git a/src/StaticImg/Views/Tools/Effects/EmbossEffectPanel.xaml b/src/StaticImg/Views/Tools/Effects/EmbossEffectPanel.xaml
new file mode 100644
index 00000000..1657c5aa
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/EmbossEffectPanel.xaml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/StaticImg/Views/Tools/Effects/EmbossEffectPanel.xaml.cs b/src/StaticImg/Views/Tools/Effects/EmbossEffectPanel.xaml.cs
new file mode 100644
index 00000000..0d0d80de
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/EmbossEffectPanel.xaml.cs
@@ -0,0 +1,46 @@
+using Microsoft.UI;
+using VirtualPaper.Shader.Models;
+using VirtualPaper.UIComponent.Input;
+
+namespace Workloads.Creation.StaticImg.Views.Tools.Effects {
+ public sealed partial class EmbossEffectPanel : EffectPanelBase {
+ private double _defaultAmount, _defaultAngle;
+
+ public EmbossEffectPanel() {
+ this.InitializeComponent();
+ _defaultAmount = AmountSlider.Value;
+ _defaultAngle = AngleSlider.Value;
+ AngleSlider.TrackFill = Gradient(
+ (0, Colors.Red), (0.25, Colors.Yellow), (0.5, Colors.Cyan), (0.75, Colors.Blue), (1, Colors.Red));
+ AngleSlider.TrackFillMode = ArcSliderTrackFillMode.Full;
+ UpdateAmountText();
+ UpdateAngleText();
+ }
+
+ public override EffectParams Params => new() {
+ Value = (float)AmountSlider.Value,
+ Value2 = (float)AngleSlider.Value,
+ Dpi = 96f,
+ };
+
+ public override void Reset() {
+ AmountSlider.Value = _defaultAmount;
+ AngleSlider.Value = _defaultAngle;
+ UpdateAmountText();
+ UpdateAngleText();
+ }
+
+ private void AmountSlider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateAmountText();
+ RaiseParamsChanged();
+ }
+
+ private void AngleSlider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateAngleText();
+ RaiseParamsChanged();
+ }
+
+ private void UpdateAmountText() => AmountValueText.Text = ((int)AmountSlider.Value).ToString();
+ private void UpdateAngleText() => AngleValueText.Text = ((int)AngleSlider.Value).ToString();
+ }
+}
diff --git a/src/StaticImg/Views/Tools/Effects/EmptyEffectPanel.xaml b/src/StaticImg/Views/Tools/Effects/EmptyEffectPanel.xaml
new file mode 100644
index 00000000..d6bcd510
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/EmptyEffectPanel.xaml
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/src/StaticImg/Views/Tools/Effects/EmptyEffectPanel.xaml.cs b/src/StaticImg/Views/Tools/Effects/EmptyEffectPanel.xaml.cs
new file mode 100644
index 00000000..8699611e
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/EmptyEffectPanel.xaml.cs
@@ -0,0 +1,12 @@
+using VirtualPaper.Shader.Models;
+
+namespace Workloads.Creation.StaticImg.Views.Tools.Effects {
+ public sealed partial class EmptyEffectPanel : EffectPanelBase {
+ public EmptyEffectPanel() {
+ this.InitializeComponent();
+ }
+
+ public override bool IsOneShot => true;
+ public override EffectParams Params => EffectParams.Default;
+ }
+}
diff --git a/src/StaticImg/Views/Tools/Effects/ExposureEffectPanel.xaml b/src/StaticImg/Views/Tools/Effects/ExposureEffectPanel.xaml
new file mode 100644
index 00000000..1e84bd27
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/ExposureEffectPanel.xaml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/StaticImg/Views/Tools/Effects/ExposureEffectPanel.xaml.cs b/src/StaticImg/Views/Tools/Effects/ExposureEffectPanel.xaml.cs
new file mode 100644
index 00000000..f66bac4c
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/ExposureEffectPanel.xaml.cs
@@ -0,0 +1,32 @@
+using Microsoft.UI;
+using VirtualPaper.Shader.Models;
+using VirtualPaper.UIComponent.Input;
+
+namespace Workloads.Creation.StaticImg.Views.Tools.Effects {
+ public sealed partial class ExposureEffectPanel : EffectPanelBase {
+ private double _defaultValue;
+
+ public ExposureEffectPanel() {
+ this.InitializeComponent();
+ _defaultValue = Slider.Value;
+ Slider.TrackFill = Gradient(
+ (0, Colors.Black), (0.5, Colors.Gray), (1, Colors.White));
+ Slider.TrackFillMode = ArcSliderTrackFillMode.Full;
+ UpdateValueText();
+ }
+
+ public override EffectParams Params => new() { Value = (float)Slider.Value, Dpi = 96f };
+
+ public override void Reset() {
+ Slider.Value = _defaultValue;
+ UpdateValueText();
+ }
+
+ private void Slider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateValueText();
+ RaiseParamsChanged();
+ }
+
+ private void UpdateValueText() => ValueText.Text = ((int)Slider.Value).ToString();
+ }
+}
diff --git a/src/StaticImg/Views/Tools/Effects/FogEffectPanel.xaml b/src/StaticImg/Views/Tools/Effects/FogEffectPanel.xaml
new file mode 100644
index 00000000..c44b4724
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/FogEffectPanel.xaml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+ ×1
+ ×2
+ ×4
+ ×8
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/StaticImg/Views/Tools/Effects/FogEffectPanel.xaml.cs b/src/StaticImg/Views/Tools/Effects/FogEffectPanel.xaml.cs
new file mode 100644
index 00000000..23dc2323
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/FogEffectPanel.xaml.cs
@@ -0,0 +1,38 @@
+using VirtualPaper.Shader.Models;
+
+namespace Workloads.Creation.StaticImg.Views.Tools.Effects {
+ public sealed partial class FogEffectPanel : EffectPanelBase {
+ private double _defaultAmount;
+ private int _defaultScale;
+
+ public FogEffectPanel() {
+ this.InitializeComponent();
+ _defaultAmount = AmountSlider.Value;
+ _defaultScale = ScaleCombo.SelectedIndex;
+ UpdateAmountText();
+ }
+
+ public override EffectParams Params => new() {
+ Mode = ScaleCombo.SelectedIndex,
+ Value = (float)AmountSlider.Value,
+ Dpi = 96f,
+ };
+
+ public override void Reset() {
+ AmountSlider.Value = _defaultAmount;
+ ScaleCombo.SelectedIndex = _defaultScale;
+ UpdateAmountText();
+ }
+
+ private void ScaleCombo_SelectionChanged(object sender, Microsoft.UI.Xaml.Controls.SelectionChangedEventArgs e) {
+ RaiseParamsChanged();
+ }
+
+ private void AmountSlider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateAmountText();
+ RaiseParamsChanged();
+ }
+
+ private void UpdateAmountText() => AmountValueText.Text = ((int)AmountSlider.Value).ToString();
+ }
+}
diff --git a/src/StaticImg/Views/Tools/Effects/GammaTransferEffectPanel.xaml b/src/StaticImg/Views/Tools/Effects/GammaTransferEffectPanel.xaml
new file mode 100644
index 00000000..49d64bbb
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/GammaTransferEffectPanel.xaml
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/StaticImg/Views/Tools/Effects/GammaTransferEffectPanel.xaml.cs b/src/StaticImg/Views/Tools/Effects/GammaTransferEffectPanel.xaml.cs
new file mode 100644
index 00000000..0e34d0f1
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/GammaTransferEffectPanel.xaml.cs
@@ -0,0 +1,52 @@
+using VirtualPaper.Shader.Models;
+
+namespace Workloads.Creation.StaticImg.Views.Tools.Effects {
+ public sealed partial class GammaTransferEffectPanel : EffectPanelBase {
+ private double _defaultAmplitude, _defaultExponent, _defaultOffset;
+
+ public GammaTransferEffectPanel() {
+ this.InitializeComponent();
+ _defaultAmplitude = AmplitudeSlider.Value;
+ _defaultExponent = ExponentSlider.Value;
+ _defaultOffset = OffsetSlider.Value;
+ UpdateAmplitudeText();
+ UpdateExponentText();
+ UpdateOffsetText();
+ }
+
+ public override EffectParams Params => new() {
+ Value = (float)AmplitudeSlider.Value,
+ Value2 = (float)ExponentSlider.Value,
+ Value3 = (float)OffsetSlider.Value,
+ Dpi = 96f,
+ };
+
+ public override void Reset() {
+ AmplitudeSlider.Value = _defaultAmplitude;
+ ExponentSlider.Value = _defaultExponent;
+ OffsetSlider.Value = _defaultOffset;
+ UpdateAmplitudeText();
+ UpdateExponentText();
+ UpdateOffsetText();
+ }
+
+ private void AmplitudeSlider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateAmplitudeText();
+ RaiseParamsChanged();
+ }
+
+ private void ExponentSlider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateExponentText();
+ RaiseParamsChanged();
+ }
+
+ private void OffsetSlider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateOffsetText();
+ RaiseParamsChanged();
+ }
+
+ private void UpdateAmplitudeText() => AmplitudeValueText.Text = ((int)AmplitudeSlider.Value).ToString();
+ private void UpdateExponentText() => ExponentValueText.Text = ((int)ExponentSlider.Value).ToString();
+ private void UpdateOffsetText() => OffsetValueText.Text = ((int)OffsetSlider.Value).ToString();
+ }
+}
diff --git a/src/StaticImg/Views/Tools/Effects/GlassEffectPanel.xaml b/src/StaticImg/Views/Tools/Effects/GlassEffectPanel.xaml
new file mode 100644
index 00000000..a2157dcc
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/GlassEffectPanel.xaml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+ ×1
+ ×2
+ ×4
+ ×8
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/StaticImg/Views/Tools/Effects/GlassEffectPanel.xaml.cs b/src/StaticImg/Views/Tools/Effects/GlassEffectPanel.xaml.cs
new file mode 100644
index 00000000..1d1339a0
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/GlassEffectPanel.xaml.cs
@@ -0,0 +1,38 @@
+using VirtualPaper.Shader.Models;
+
+namespace Workloads.Creation.StaticImg.Views.Tools.Effects {
+ public sealed partial class GlassEffectPanel : EffectPanelBase {
+ private double _defaultAmount;
+ private int _defaultScale;
+
+ public GlassEffectPanel() {
+ this.InitializeComponent();
+ _defaultAmount = AmountSlider.Value;
+ _defaultScale = ScaleCombo.SelectedIndex;
+ UpdateAmountText();
+ }
+
+ public override EffectParams Params => new() {
+ Mode = ScaleCombo.SelectedIndex,
+ Value = (float)AmountSlider.Value,
+ Dpi = 96f,
+ };
+
+ public override void Reset() {
+ AmountSlider.Value = _defaultAmount;
+ ScaleCombo.SelectedIndex = _defaultScale;
+ UpdateAmountText();
+ }
+
+ private void ScaleCombo_SelectionChanged(object sender, Microsoft.UI.Xaml.Controls.SelectionChangedEventArgs e) {
+ RaiseParamsChanged();
+ }
+
+ private void AmountSlider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateAmountText();
+ RaiseParamsChanged();
+ }
+
+ private void UpdateAmountText() => AmountValueText.Text = ((int)AmountSlider.Value).ToString();
+ }
+}
diff --git a/src/StaticImg/Views/Tools/Effects/GlowEffectPanel.xaml b/src/StaticImg/Views/Tools/Effects/GlowEffectPanel.xaml
new file mode 100644
index 00000000..c9ebc09a
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/GlowEffectPanel.xaml
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/StaticImg/Views/Tools/Effects/GlowEffectPanel.xaml.cs b/src/StaticImg/Views/Tools/Effects/GlowEffectPanel.xaml.cs
new file mode 100644
index 00000000..065b174b
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/GlowEffectPanel.xaml.cs
@@ -0,0 +1,52 @@
+using VirtualPaper.Shader.Models;
+
+namespace Workloads.Creation.StaticImg.Views.Tools.Effects {
+ public sealed partial class GlowEffectPanel : EffectPanelBase {
+ private double _defaultBlur, _defaultOpacity;
+
+ public GlowEffectPanel() {
+ this.InitializeComponent();
+ _defaultBlur = BlurSlider.Value;
+ _defaultOpacity = OpacitySlider.Value;
+ UpdateBlurText();
+ UpdateOpacityText();
+ }
+
+ public override EffectParams Params {
+ get {
+ if (BlurSlider == null || OpacitySlider == null) return EffectParams.Default;
+ return new EffectParams {
+ Value = (float)BlurSlider.Value,
+ Value2 = 0, // OffsetX = 0 (centered)
+ Value3 = 0, // OffsetY = 0 (centered)
+ Value4 = (float)OpacitySlider.Value,
+ Dpi = 96f,
+ };
+ }
+ }
+
+ public override void Reset() {
+ if (BlurSlider == null || OpacitySlider == null) return;
+ BlurSlider.Value = _defaultBlur;
+ OpacitySlider.Value = _defaultOpacity;
+ UpdateBlurText();
+ UpdateOpacityText();
+ }
+
+ private void Slider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateBlurText();
+ UpdateOpacityText();
+ RaiseParamsChanged();
+ }
+
+ private void UpdateBlurText() {
+ if (BlurSlider == null) return;
+ BlurValueText.Text = ((int)BlurSlider.Value).ToString();
+ }
+
+ private void UpdateOpacityText() {
+ if (OpacitySlider == null) return;
+ OpacityValueText.Text = ((int)OpacitySlider.Value).ToString();
+ }
+ }
+}
diff --git a/src/StaticImg/Views/Tools/Effects/HSBEffectPanel.xaml b/src/StaticImg/Views/Tools/Effects/HSBEffectPanel.xaml
new file mode 100644
index 00000000..39bad850
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/HSBEffectPanel.xaml
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/StaticImg/Views/Tools/Effects/HSBEffectPanel.xaml.cs b/src/StaticImg/Views/Tools/Effects/HSBEffectPanel.xaml.cs
new file mode 100644
index 00000000..de62e4de
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/HSBEffectPanel.xaml.cs
@@ -0,0 +1,52 @@
+using VirtualPaper.Shader.Models;
+
+namespace Workloads.Creation.StaticImg.Views.Tools.Effects {
+ public sealed partial class HSBEffectPanel : EffectPanelBase {
+ private double _defaultHue, _defaultSaturation, _defaultBrightness;
+
+ public HSBEffectPanel() {
+ this.InitializeComponent();
+ _defaultHue = HueSlider.Value;
+ _defaultSaturation = SaturationSlider.Value;
+ _defaultBrightness = BrightnessSlider.Value;
+ UpdateHueText();
+ UpdateSaturationText();
+ UpdateBrightnessText();
+ }
+
+ public override EffectParams Params => new() {
+ Value = (float)HueSlider.Value,
+ Value2 = (float)SaturationSlider.Value,
+ Value3 = (float)BrightnessSlider.Value,
+ Dpi = 96f,
+ };
+
+ public override void Reset() {
+ HueSlider.Value = _defaultHue;
+ SaturationSlider.Value = _defaultSaturation;
+ BrightnessSlider.Value = _defaultBrightness;
+ UpdateHueText();
+ UpdateSaturationText();
+ UpdateBrightnessText();
+ }
+
+ private void HueSlider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateHueText();
+ RaiseParamsChanged();
+ }
+
+ private void SaturationSlider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateSaturationText();
+ RaiseParamsChanged();
+ }
+
+ private void BrightnessSlider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateBrightnessText();
+ RaiseParamsChanged();
+ }
+
+ private void UpdateHueText() => HueValueText.Text = ((int)HueSlider.Value).ToString() + "°";
+ private void UpdateSaturationText() => SaturationValueText.Text = ((int)SaturationSlider.Value).ToString() + "%";
+ private void UpdateBrightnessText() => BrightnessValueText.Text = ((int)BrightnessSlider.Value).ToString();
+ }
+}
diff --git a/src/StaticImg/Views/Tools/Effects/HighlightsShadowsEffectPanel.xaml b/src/StaticImg/Views/Tools/Effects/HighlightsShadowsEffectPanel.xaml
new file mode 100644
index 00000000..e371d258
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/HighlightsShadowsEffectPanel.xaml
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/StaticImg/Views/Tools/Effects/HighlightsShadowsEffectPanel.xaml.cs b/src/StaticImg/Views/Tools/Effects/HighlightsShadowsEffectPanel.xaml.cs
new file mode 100644
index 00000000..f8d9484a
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/HighlightsShadowsEffectPanel.xaml.cs
@@ -0,0 +1,69 @@
+using Microsoft.UI;
+using VirtualPaper.Shader.Models;
+using VirtualPaper.UIComponent.Input;
+
+namespace Workloads.Creation.StaticImg.Views.Tools.Effects {
+ public sealed partial class HighlightsShadowsEffectPanel : EffectPanelBase {
+ private double _defaultShadows, _defaultHighlights, _defaultClarity, _defaultBlur;
+
+ public HighlightsShadowsEffectPanel() {
+ this.InitializeComponent();
+ _defaultShadows = ShadowsSlider.Value;
+ _defaultHighlights = HighlightsSlider.Value;
+ _defaultClarity = ClaritySlider.Value;
+ _defaultBlur = BlurSlider.Value;
+ ShadowsSlider.TrackFill = Gradient((0, Colors.Black), (1, Colors.White));
+ ShadowsSlider.TrackFillMode = ArcSliderTrackFillMode.Full;
+ HighlightsSlider.TrackFill = Gradient((0, Colors.Black), (1, Colors.White));
+ HighlightsSlider.TrackFillMode = ArcSliderTrackFillMode.Full;
+ UpdateShadowsText();
+ UpdateHighlightsText();
+ UpdateClarityText();
+ UpdateBlurText();
+ }
+
+ public override EffectParams Params => new() {
+ Value = (float)ShadowsSlider.Value,
+ Value2 = (float)HighlightsSlider.Value,
+ Value3 = (float)ClaritySlider.Value,
+ Value4 = (float)BlurSlider.Value,
+ Dpi = 96f,
+ };
+
+ public override void Reset() {
+ ShadowsSlider.Value = _defaultShadows;
+ HighlightsSlider.Value = _defaultHighlights;
+ ClaritySlider.Value = _defaultClarity;
+ BlurSlider.Value = _defaultBlur;
+ UpdateShadowsText();
+ UpdateHighlightsText();
+ UpdateClarityText();
+ UpdateBlurText();
+ }
+
+ private void ShadowsSlider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateShadowsText();
+ RaiseParamsChanged();
+ }
+
+ private void HighlightsSlider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateHighlightsText();
+ RaiseParamsChanged();
+ }
+
+ private void ClaritySlider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateClarityText();
+ RaiseParamsChanged();
+ }
+
+ private void BlurSlider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateBlurText();
+ RaiseParamsChanged();
+ }
+
+ private void UpdateShadowsText() => ShadowsValueText.Text = ((int)ShadowsSlider.Value).ToString();
+ private void UpdateHighlightsText() => HighlightsValueText.Text = ((int)HighlightsSlider.Value).ToString();
+ private void UpdateClarityText() => ClarityValueText.Text = ((int)ClaritySlider.Value).ToString();
+ private void UpdateBlurText() => BlurValueText.Text = ((int)BlurSlider.Value).ToString();
+ }
+}
diff --git a/src/StaticImg/Views/Tools/Effects/HueRotationEffectPanel.xaml b/src/StaticImg/Views/Tools/Effects/HueRotationEffectPanel.xaml
new file mode 100644
index 00000000..c8435a0a
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/HueRotationEffectPanel.xaml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/StaticImg/Views/Tools/Effects/HueRotationEffectPanel.xaml.cs b/src/StaticImg/Views/Tools/Effects/HueRotationEffectPanel.xaml.cs
new file mode 100644
index 00000000..4371b012
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/HueRotationEffectPanel.xaml.cs
@@ -0,0 +1,38 @@
+using Microsoft.UI;
+using VirtualPaper.Shader.Models;
+using VirtualPaper.UIComponent.Input;
+
+namespace Workloads.Creation.StaticImg.Views.Tools.Effects {
+ public sealed partial class HueRotationEffectPanel : EffectPanelBase {
+ private double _defaultValue;
+
+ public HueRotationEffectPanel() {
+ this.InitializeComponent();
+ _defaultValue = Slider.Value;
+ Slider.TrackFill = Gradient(
+ (0, Colors.Red),
+ (1d / 6, Colors.Yellow),
+ (2d / 6, Colors.Lime),
+ (3d / 6, Colors.Cyan),
+ (4d / 6, Colors.Blue),
+ (5d / 6, Colors.Magenta),
+ (1, Colors.Red));
+ Slider.TrackFillMode = ArcSliderTrackFillMode.Full;
+ UpdateValueText();
+ }
+
+ public override EffectParams Params => new() { Value = (float)Slider.Value, Dpi = 96f };
+
+ public override void Reset() {
+ Slider.Value = _defaultValue;
+ UpdateValueText();
+ }
+
+ private void Slider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateValueText();
+ RaiseParamsChanged();
+ }
+
+ private void UpdateValueText() => ValueText.Text = ((int)Slider.Value).ToString();
+ }
+}
diff --git a/src/StaticImg/Views/Tools/Effects/LuminanceToAlphaEffectPanel.xaml b/src/StaticImg/Views/Tools/Effects/LuminanceToAlphaEffectPanel.xaml
new file mode 100644
index 00000000..0fb35ff7
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/LuminanceToAlphaEffectPanel.xaml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+ 亮度 → 透明度
+ 亮度反转 → 透明度
+ 透明度 → 亮度反转
+
+
+
diff --git a/src/StaticImg/Views/Tools/Effects/LuminanceToAlphaEffectPanel.xaml.cs b/src/StaticImg/Views/Tools/Effects/LuminanceToAlphaEffectPanel.xaml.cs
new file mode 100644
index 00000000..7c662f7a
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/LuminanceToAlphaEffectPanel.xaml.cs
@@ -0,0 +1,25 @@
+using VirtualPaper.Shader.Models;
+
+namespace Workloads.Creation.StaticImg.Views.Tools.Effects {
+ public sealed partial class LuminanceToAlphaEffectPanel : EffectPanelBase {
+ private int _defaultMode;
+
+ public LuminanceToAlphaEffectPanel() {
+ this.InitializeComponent();
+ _defaultMode = ModeCombo.SelectedIndex;
+ }
+
+ public override EffectParams Params => new() {
+ Mode = ModeCombo.SelectedIndex,
+ Dpi = 96f,
+ };
+
+ public override void Reset() {
+ ModeCombo.SelectedIndex = _defaultMode;
+ }
+
+ private void ModeCombo_SelectionChanged(object sender, Microsoft.UI.Xaml.Controls.SelectionChangedEventArgs e) {
+ RaiseParamsChanged();
+ }
+ }
+}
diff --git a/src/StaticImg/Views/Tools/Effects/MorphologyEffectPanel.xaml b/src/StaticImg/Views/Tools/Effects/MorphologyEffectPanel.xaml
new file mode 100644
index 00000000..b357e784
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/MorphologyEffectPanel.xaml
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+ 膨胀
+ 腐蚀
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/StaticImg/Views/Tools/Effects/MorphologyEffectPanel.xaml.cs b/src/StaticImg/Views/Tools/Effects/MorphologyEffectPanel.xaml.cs
new file mode 100644
index 00000000..4711f544
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/MorphologyEffectPanel.xaml.cs
@@ -0,0 +1,49 @@
+using VirtualPaper.Shader.Models;
+
+namespace Workloads.Creation.StaticImg.Views.Tools.Effects {
+ public sealed partial class MorphologyEffectPanel : EffectPanelBase {
+ private double _defaultWidth, _defaultHeight;
+ private int _defaultMode;
+
+ public MorphologyEffectPanel() {
+ this.InitializeComponent();
+ _defaultWidth = WidthSlider.Value;
+ _defaultHeight = HeightSlider.Value;
+ _defaultMode = ModeCombo.SelectedIndex;
+ UpdateWidthText();
+ UpdateHeightText();
+ }
+
+ public override EffectParams Params => new() {
+ Mode = ModeCombo.SelectedIndex,
+ Value = (float)WidthSlider.Value,
+ Value2 = (float)HeightSlider.Value,
+ Dpi = 96f,
+ };
+
+ public override void Reset() {
+ WidthSlider.Value = _defaultWidth;
+ HeightSlider.Value = _defaultHeight;
+ ModeCombo.SelectedIndex = _defaultMode;
+ UpdateWidthText();
+ UpdateHeightText();
+ }
+
+ private void ModeCombo_SelectionChanged(object sender, Microsoft.UI.Xaml.Controls.SelectionChangedEventArgs e) {
+ RaiseParamsChanged();
+ }
+
+ private void WidthSlider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateWidthText();
+ RaiseParamsChanged();
+ }
+
+ private void HeightSlider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateHeightText();
+ RaiseParamsChanged();
+ }
+
+ private void UpdateWidthText() => WidthValueText.Text = ((int)WidthSlider.Value).ToString();
+ private void UpdateHeightText() => HeightValueText.Text = ((int)HeightSlider.Value).ToString();
+ }
+}
diff --git a/src/StaticImg/Views/Tools/Effects/NoiseEffectPanel.xaml b/src/StaticImg/Views/Tools/Effects/NoiseEffectPanel.xaml
new file mode 100644
index 00000000..c9d9c06c
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/NoiseEffectPanel.xaml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/StaticImg/Views/Tools/Effects/NoiseEffectPanel.xaml.cs b/src/StaticImg/Views/Tools/Effects/NoiseEffectPanel.xaml.cs
new file mode 100644
index 00000000..40c7af96
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/NoiseEffectPanel.xaml.cs
@@ -0,0 +1,27 @@
+using VirtualPaper.Shader.Models;
+
+namespace Workloads.Creation.StaticImg.Views.Tools.Effects {
+ public sealed partial class NoiseEffectPanel : EffectPanelBase {
+ private double _defaultValue;
+
+ public NoiseEffectPanel() {
+ this.InitializeComponent();
+ _defaultValue = Slider.Value;
+ UpdateValueText();
+ }
+
+ public override EffectParams Params => new() { Value = (float)Slider.Value, Dpi = 96f };
+
+ public override void Reset() {
+ Slider.Value = _defaultValue;
+ UpdateValueText();
+ }
+
+ private void Slider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateValueText();
+ RaiseParamsChanged();
+ }
+
+ private void UpdateValueText() => ValueText.Text = ((int)Slider.Value).ToString();
+ }
+}
diff --git a/src/StaticImg/Views/Tools/Effects/PosterizeEffectPanel.xaml b/src/StaticImg/Views/Tools/Effects/PosterizeEffectPanel.xaml
new file mode 100644
index 00000000..a19bc91b
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/PosterizeEffectPanel.xaml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/StaticImg/Views/Tools/Effects/PosterizeEffectPanel.xaml.cs b/src/StaticImg/Views/Tools/Effects/PosterizeEffectPanel.xaml.cs
new file mode 100644
index 00000000..6cfec644
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/PosterizeEffectPanel.xaml.cs
@@ -0,0 +1,32 @@
+using VirtualPaper.Shader.Models;
+
+namespace Workloads.Creation.StaticImg.Views.Tools.Effects {
+ public sealed partial class PosterizeEffectPanel : EffectPanelBase {
+ private double _defaultValue;
+
+ public PosterizeEffectPanel() {
+ this.InitializeComponent();
+ _defaultValue = Slider.Value;
+ UpdateValueText();
+ }
+
+ public override EffectParams Params => new() {
+ Value = (float)Slider.Value,
+ Value2 = (float)Slider.Value,
+ Value3 = (float)Slider.Value,
+ Dpi = 96f,
+ };
+
+ public override void Reset() {
+ Slider.Value = _defaultValue;
+ UpdateValueText();
+ }
+
+ private void Slider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateValueText();
+ RaiseParamsChanged();
+ }
+
+ private void UpdateValueText() => ValueText.Text = ((int)Slider.Value).ToString();
+ }
+}
diff --git a/src/StaticImg/Views/Tools/Effects/RippleEffectPanel.xaml b/src/StaticImg/Views/Tools/Effects/RippleEffectPanel.xaml
new file mode 100644
index 00000000..83c0c692
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/RippleEffectPanel.xaml
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/StaticImg/Views/Tools/Effects/RippleEffectPanel.xaml.cs b/src/StaticImg/Views/Tools/Effects/RippleEffectPanel.xaml.cs
new file mode 100644
index 00000000..aaf314cb
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/RippleEffectPanel.xaml.cs
@@ -0,0 +1,75 @@
+using System.Numerics;
+using Microsoft.UI.Xaml;
+using VirtualPaper.Shader.Models;
+using Workloads.Creation.StaticImg;
+
+namespace Workloads.Creation.StaticImg.Views.Tools.Effects {
+ public sealed partial class RippleEffectPanel : EffectPanelBase {
+ private double _defaultFrequency, _defaultPhase, _defaultAmplitude, _defaultSpread;
+
+ public RippleEffectPanel() {
+ this.InitializeComponent();
+ _defaultFrequency = FrequencySlider.Value;
+ _defaultPhase = PhaseSlider.Value;
+ _defaultAmplitude = AmplitudeSlider.Value;
+ _defaultSpread = SpreadSlider.Value;
+ UpdateFrequencyText();
+ UpdatePhaseText();
+ UpdateAmplitudeText();
+ UpdateSpreadText();
+ }
+
+ public static readonly DependencyProperty CanvasSizeProperty = DependencyProperty.Register(
+ nameof(CanvasSize), typeof(ArcSize), typeof(RippleEffectPanel), new PropertyMetadata(new ArcSize(1000, 1000, 96, RebuildMode.None)));
+
+ public ArcSize CanvasSize {
+ get => (ArcSize)GetValue(CanvasSizeProperty);
+ set => SetValue(CanvasSizeProperty, value);
+ }
+
+ public override EffectParams Params => new() {
+ Value = (float)FrequencySlider.Value,
+ Value2 = (float)PhaseSlider.Value,
+ Value3 = (float)AmplitudeSlider.Value,
+ Value4 = (float)SpreadSlider.Value / 100f,
+ Point1 = new Vector2(CanvasSize.Width / 2, CanvasSize.Height / 2),
+ Dpi = 96f,
+ };
+
+ public override void Reset() {
+ FrequencySlider.Value = _defaultFrequency;
+ PhaseSlider.Value = _defaultPhase;
+ AmplitudeSlider.Value = _defaultAmplitude;
+ SpreadSlider.Value = _defaultSpread;
+ UpdateFrequencyText();
+ UpdatePhaseText();
+ UpdateAmplitudeText();
+ UpdateSpreadText();
+ }
+
+ private void FrequencySlider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateFrequencyText();
+ RaiseParamsChanged();
+ }
+
+ private void PhaseSlider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdatePhaseText();
+ RaiseParamsChanged();
+ }
+
+ private void AmplitudeSlider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateAmplitudeText();
+ RaiseParamsChanged();
+ }
+
+ private void SpreadSlider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateSpreadText();
+ RaiseParamsChanged();
+ }
+
+ private void UpdateFrequencyText() => FrequencyValueText.Text = ((int)FrequencySlider.Value).ToString();
+ private void UpdatePhaseText() => PhaseValueText.Text = ((int)PhaseSlider.Value).ToString();
+ private void UpdateAmplitudeText() => AmplitudeValueText.Text = ((int)AmplitudeSlider.Value).ToString();
+ private void UpdateSpreadText() => SpreadValueText.Text = ((int)SpreadSlider.Value).ToString();
+ }
+}
diff --git a/src/StaticImg/Views/Tools/Effects/SaturationEffectPanel.xaml b/src/StaticImg/Views/Tools/Effects/SaturationEffectPanel.xaml
new file mode 100644
index 00000000..ffe0ca47
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/SaturationEffectPanel.xaml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/StaticImg/Views/Tools/Effects/SaturationEffectPanel.xaml.cs b/src/StaticImg/Views/Tools/Effects/SaturationEffectPanel.xaml.cs
new file mode 100644
index 00000000..3ae36863
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/SaturationEffectPanel.xaml.cs
@@ -0,0 +1,31 @@
+using Microsoft.UI;
+using VirtualPaper.Shader.Models;
+using VirtualPaper.UIComponent.Input;
+
+namespace Workloads.Creation.StaticImg.Views.Tools.Effects {
+ public sealed partial class SaturationEffectPanel : EffectPanelBase {
+ private double _defaultValue;
+
+ public SaturationEffectPanel() {
+ this.InitializeComponent();
+ _defaultValue = Slider.Value;
+ Slider.TrackFill = Gradient((0, Colors.Gray), (1, Colors.DeepSkyBlue));
+ Slider.TrackFillMode = ArcSliderTrackFillMode.Full;
+ UpdateValueText();
+ }
+
+ public override EffectParams Params => new() { Value = (float)Slider.Value, Dpi = 96f };
+
+ public override void Reset() {
+ Slider.Value = _defaultValue;
+ UpdateValueText();
+ }
+
+ private void Slider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateValueText();
+ RaiseParamsChanged();
+ }
+
+ private void UpdateValueText() => ValueText.Text = ((int)Slider.Value).ToString();
+ }
+}
diff --git a/src/StaticImg/Views/Tools/Effects/ShadowEffectPanel.xaml b/src/StaticImg/Views/Tools/Effects/ShadowEffectPanel.xaml
new file mode 100644
index 00000000..011e254b
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/ShadowEffectPanel.xaml
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/StaticImg/Views/Tools/Effects/ShadowEffectPanel.xaml.cs b/src/StaticImg/Views/Tools/Effects/ShadowEffectPanel.xaml.cs
new file mode 100644
index 00000000..0027a316
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/ShadowEffectPanel.xaml.cs
@@ -0,0 +1,63 @@
+using VirtualPaper.Shader.Models;
+
+namespace Workloads.Creation.StaticImg.Views.Tools.Effects {
+ public sealed partial class ShadowEffectPanel : EffectPanelBase {
+ private double _defaultBlur, _defaultOffsetX, _defaultOffsetY, _defaultOpacity;
+
+ public ShadowEffectPanel() {
+ this.InitializeComponent();
+ _defaultBlur = BlurSlider.Value;
+ _defaultOffsetX = OffsetXSlider.Value;
+ _defaultOffsetY = OffsetYSlider.Value;
+ _defaultOpacity = OpacitySlider.Value;
+ UpdateBlurText();
+ UpdateOffsetXText();
+ UpdateOffsetYText();
+ UpdateOpacityText();
+ }
+
+ public override EffectParams Params => new() {
+ Value = (float)BlurSlider.Value,
+ Value2 = (float)OffsetXSlider.Value,
+ Value3 = (float)OffsetYSlider.Value,
+ Value4 = (float)OpacitySlider.Value,
+ Dpi = 96f,
+ };
+
+ public override void Reset() {
+ BlurSlider.Value = _defaultBlur;
+ OffsetXSlider.Value = _defaultOffsetX;
+ OffsetYSlider.Value = _defaultOffsetY;
+ OpacitySlider.Value = _defaultOpacity;
+ UpdateBlurText();
+ UpdateOffsetXText();
+ UpdateOffsetYText();
+ UpdateOpacityText();
+ }
+
+ private void BlurSlider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateBlurText();
+ RaiseParamsChanged();
+ }
+
+ private void OffsetXSlider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateOffsetXText();
+ RaiseParamsChanged();
+ }
+
+ private void OffsetYSlider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateOffsetYText();
+ RaiseParamsChanged();
+ }
+
+ private void OpacitySlider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateOpacityText();
+ RaiseParamsChanged();
+ }
+
+ private void UpdateBlurText() => BlurValueText.Text = ((int)BlurSlider.Value).ToString();
+ private void UpdateOffsetXText() => OffsetXValueText.Text = ((int)OffsetXSlider.Value).ToString();
+ private void UpdateOffsetYText() => OffsetYValueText.Text = ((int)OffsetYSlider.Value).ToString();
+ private void UpdateOpacityText() => OpacityValueText.Text = ((int)OpacitySlider.Value).ToString();
+ }
+}
diff --git a/src/StaticImg/Views/Tools/Effects/SharpenEffectPanel.xaml b/src/StaticImg/Views/Tools/Effects/SharpenEffectPanel.xaml
new file mode 100644
index 00000000..f231656e
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/SharpenEffectPanel.xaml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/StaticImg/Views/Tools/Effects/SharpenEffectPanel.xaml.cs b/src/StaticImg/Views/Tools/Effects/SharpenEffectPanel.xaml.cs
new file mode 100644
index 00000000..186396b6
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/SharpenEffectPanel.xaml.cs
@@ -0,0 +1,27 @@
+using VirtualPaper.Shader.Models;
+
+namespace Workloads.Creation.StaticImg.Views.Tools.Effects {
+ public sealed partial class SharpenEffectPanel : EffectPanelBase {
+ private double _defaultValue;
+
+ public SharpenEffectPanel() {
+ this.InitializeComponent();
+ _defaultValue = Slider.Value;
+ UpdateValueText();
+ }
+
+ public override EffectParams Params => new() { Value = (float)Slider.Value, Dpi = 96f };
+
+ public override void Reset() {
+ Slider.Value = _defaultValue;
+ UpdateValueText();
+ }
+
+ private void Slider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateValueText();
+ RaiseParamsChanged();
+ }
+
+ private void UpdateValueText() => ValueText.Text = ((int)Slider.Value).ToString();
+ }
+}
diff --git a/src/StaticImg/Views/Tools/Effects/SingleSliderEffectPanel.xaml b/src/StaticImg/Views/Tools/Effects/SingleSliderEffectPanel.xaml
new file mode 100644
index 00000000..612086f3
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/SingleSliderEffectPanel.xaml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/StaticImg/Views/Tools/Effects/SingleSliderEffectPanel.xaml.cs b/src/StaticImg/Views/Tools/Effects/SingleSliderEffectPanel.xaml.cs
new file mode 100644
index 00000000..6b86218d
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/SingleSliderEffectPanel.xaml.cs
@@ -0,0 +1,35 @@
+using System;
+using VirtualPaper.Shader.Models;
+using Workloads.Creation.StaticImg.Utils;
+
+namespace Workloads.Creation.StaticImg.Views.Tools.Effects {
+ public sealed partial class SingleSliderEffectPanel : EffectPanelBase {
+ private double _defaultValue;
+
+ public SingleSliderEffectPanel(EffectSliderConfig cfg) {
+ this.InitializeComponent();
+ LabelText.Text = cfg.Label;
+ Slider.Minimum = cfg.Min;
+ Slider.Maximum = cfg.Max;
+ Slider.Value = _defaultValue = cfg.Default;
+ Slider.TickFrequency = Math.Max(1, Math.Abs(cfg.Max - cfg.Min) / 8);
+ Slider.SmallChange = Math.Max(1, Math.Abs(cfg.Max - cfg.Min) / 100);
+ Slider.StepFrequency = Math.Max(1, Math.Abs(cfg.Max - cfg.Min) / 100);
+ UpdateValueText();
+ }
+
+ public override EffectParams Params => new() { Value = (float)Slider.Value, Dpi = 96f };
+
+ public override void Reset() {
+ Slider.Value = _defaultValue;
+ UpdateValueText();
+ }
+
+ private void Slider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateValueText();
+ RaiseParamsChanged();
+ }
+
+ private void UpdateValueText() => ValueText.Text = ((int)Slider.Value).ToString();
+ }
+}
diff --git a/src/StaticImg/Views/Tools/Effects/StraightenEffectPanel.xaml b/src/StaticImg/Views/Tools/Effects/StraightenEffectPanel.xaml
new file mode 100644
index 00000000..d152ea98
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/StraightenEffectPanel.xaml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/StaticImg/Views/Tools/Effects/StraightenEffectPanel.xaml.cs b/src/StaticImg/Views/Tools/Effects/StraightenEffectPanel.xaml.cs
new file mode 100644
index 00000000..d2433707
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/StraightenEffectPanel.xaml.cs
@@ -0,0 +1,27 @@
+using VirtualPaper.Shader.Models;
+
+namespace Workloads.Creation.StaticImg.Views.Tools.Effects {
+ public sealed partial class StraightenEffectPanel : EffectPanelBase {
+ private double _defaultValue;
+
+ public StraightenEffectPanel() {
+ this.InitializeComponent();
+ _defaultValue = Slider.Value;
+ UpdateValueText();
+ }
+
+ public override EffectParams Params => new() { Value = (float)Slider.Value, Dpi = 96f };
+
+ public override void Reset() {
+ Slider.Value = _defaultValue;
+ UpdateValueText();
+ }
+
+ private void Slider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateValueText();
+ RaiseParamsChanged();
+ }
+
+ private void UpdateValueText() => ValueText.Text = ((int)Slider.Value).ToString();
+ }
+}
diff --git a/src/StaticImg/Views/Tools/Effects/TemperatureTintEffectPanel.xaml b/src/StaticImg/Views/Tools/Effects/TemperatureTintEffectPanel.xaml
new file mode 100644
index 00000000..d7f00e58
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/TemperatureTintEffectPanel.xaml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/StaticImg/Views/Tools/Effects/TemperatureTintEffectPanel.xaml.cs b/src/StaticImg/Views/Tools/Effects/TemperatureTintEffectPanel.xaml.cs
new file mode 100644
index 00000000..c12c50ea
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/TemperatureTintEffectPanel.xaml.cs
@@ -0,0 +1,47 @@
+using Microsoft.UI;
+using VirtualPaper.Shader.Models;
+using VirtualPaper.UIComponent.Input;
+
+namespace Workloads.Creation.StaticImg.Views.Tools.Effects {
+ public sealed partial class TemperatureTintEffectPanel : EffectPanelBase {
+ private double _defaultTemp, _defaultTint;
+
+ public TemperatureTintEffectPanel() {
+ this.InitializeComponent();
+ _defaultTemp = TemperatureSlider.Value;
+ _defaultTint = TintSlider.Value;
+ TemperatureSlider.TrackFill = Gradient((0, Colors.DodgerBlue), (0.5, Colors.White), (1, Colors.Orange));
+ TemperatureSlider.TrackFillMode = ArcSliderTrackFillMode.Full;
+ TintSlider.TrackFill = Gradient((0, Colors.MediumVioletRed), (0.5, Colors.White), (1, Colors.MediumSeaGreen));
+ TintSlider.TrackFillMode = ArcSliderTrackFillMode.Full;
+ UpdateTempText();
+ UpdateTintText();
+ }
+
+ public override EffectParams Params => new() {
+ Value = (float)TemperatureSlider.Value,
+ Value2 = (float)TintSlider.Value,
+ Dpi = 96f,
+ };
+
+ public override void Reset() {
+ TemperatureSlider.Value = _defaultTemp;
+ TintSlider.Value = _defaultTint;
+ UpdateTempText();
+ UpdateTintText();
+ }
+
+ private void TemperatureSlider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateTempText();
+ RaiseParamsChanged();
+ }
+
+ private void TintSlider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateTintText();
+ RaiseParamsChanged();
+ }
+
+ private void UpdateTempText() => TempValueText.Text = ((int)TemperatureSlider.Value).ToString();
+ private void UpdateTintText() => TintValueText.Text = ((int)TintSlider.Value).ToString();
+ }
+}
diff --git a/src/StaticImg/Views/Tools/Effects/ThresholdEffectPanel.xaml b/src/StaticImg/Views/Tools/Effects/ThresholdEffectPanel.xaml
new file mode 100644
index 00000000..2652d781
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/ThresholdEffectPanel.xaml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/StaticImg/Views/Tools/Effects/ThresholdEffectPanel.xaml.cs b/src/StaticImg/Views/Tools/Effects/ThresholdEffectPanel.xaml.cs
new file mode 100644
index 00000000..bb18e671
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/ThresholdEffectPanel.xaml.cs
@@ -0,0 +1,33 @@
+using System.Numerics;
+using VirtualPaper.Shader.Models;
+
+namespace Workloads.Creation.StaticImg.Views.Tools.Effects {
+ public sealed partial class ThresholdEffectPanel : EffectPanelBase {
+ private double _defaultValue;
+
+ public ThresholdEffectPanel() {
+ this.InitializeComponent();
+ _defaultValue = Slider.Value;
+ UpdateValueText();
+ }
+
+ public override EffectParams Params => new() {
+ Value = (float)Slider.Value / 100f,
+ Color1 = new Vector4(1, 1, 1, 1),
+ Color2 = new Vector4(0, 0, 0, 1),
+ Dpi = 96f,
+ };
+
+ public override void Reset() {
+ Slider.Value = _defaultValue;
+ UpdateValueText();
+ }
+
+ private void Slider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateValueText();
+ RaiseParamsChanged();
+ }
+
+ private void UpdateValueText() => ValueText.Text = ((int)Slider.Value).ToString();
+ }
+}
diff --git a/src/StaticImg/Views/Tools/Effects/VignetteEffectPanel.xaml b/src/StaticImg/Views/Tools/Effects/VignetteEffectPanel.xaml
new file mode 100644
index 00000000..b3db9445
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/VignetteEffectPanel.xaml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/StaticImg/Views/Tools/Effects/VignetteEffectPanel.xaml.cs b/src/StaticImg/Views/Tools/Effects/VignetteEffectPanel.xaml.cs
new file mode 100644
index 00000000..338f26f7
--- /dev/null
+++ b/src/StaticImg/Views/Tools/Effects/VignetteEffectPanel.xaml.cs
@@ -0,0 +1,31 @@
+using Microsoft.UI;
+using VirtualPaper.Shader.Models;
+using VirtualPaper.UIComponent.Input;
+
+namespace Workloads.Creation.StaticImg.Views.Tools.Effects {
+ public sealed partial class VignetteEffectPanel : EffectPanelBase {
+ private double _defaultValue;
+
+ public VignetteEffectPanel() {
+ this.InitializeComponent();
+ _defaultValue = Slider.Value;
+ Slider.TrackFill = Gradient((0, Colors.White), (1, Colors.Black));
+ Slider.TrackFillMode = ArcSliderTrackFillMode.Full;
+ UpdateValueText();
+ }
+
+ public override EffectParams Params => new() { Value = (float)Slider.Value, Dpi = 96f };
+
+ public override void Reset() {
+ Slider.Value = _defaultValue;
+ UpdateValueText();
+ }
+
+ private void Slider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e) {
+ UpdateValueText();
+ RaiseParamsChanged();
+ }
+
+ private void UpdateValueText() => ValueText.Text = ((int)Slider.Value).ToString();
+ }
+}
diff --git a/src/StaticImg/Views/Tools/SelectionControl.xaml b/src/StaticImg/Views/Tools/SelectionControl.xaml
index 71de3cb6..27813e84 100644
--- a/src/StaticImg/Views/Tools/SelectionControl.xaml
+++ b/src/StaticImg/Views/Tools/SelectionControl.xaml
@@ -17,21 +17,22 @@
-
+
diff --git a/src/StaticImg/Views/Tools/SelectionControl.xaml.cs b/src/StaticImg/Views/Tools/SelectionControl.xaml.cs
index b2e8b8ac..bbb9b333 100644
--- a/src/StaticImg/Views/Tools/SelectionControl.xaml.cs
+++ b/src/StaticImg/Views/Tools/SelectionControl.xaml.cs
@@ -7,8 +7,8 @@
namespace Workloads.Creation.StaticImg.Views.Tools {
public sealed partial class SelectionControl : UserControl {
- public event EventHandler SelectCancel;
- public event EventHandler SelectCommit;
+ public event EventHandler? SelectCancel;
+ public event EventHandler? SelectCommit;
public SelectionControl() {
this.InitializeComponent();
diff --git a/src/StaticImg/Views/Tools/ToolListConrtol.xaml.cs b/src/StaticImg/Views/Tools/ToolListConrtol.xaml.cs
index 4e3677a5..ce1a4e0c 100644
--- a/src/StaticImg/Views/Tools/ToolListConrtol.xaml.cs
+++ b/src/StaticImg/Views/Tools/ToolListConrtol.xaml.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
@@ -10,7 +10,7 @@
namespace Workloads.Creation.StaticImg.Views.Tools {
public sealed partial class ToolListConrtol : UserControl {
- public event EventHandler ToolListLoaded;
+ public event EventHandler? ToolListLoaded;
public ToolItem SelectedTool {
get { return (ToolItem)GetValue(SelectedToolProperty); }
diff --git a/src/VirtualPaper.Common/Constants.cs b/src/VirtualPaper.Common/Constants.cs
index 69023827..18c3509f 100644
--- a/src/VirtualPaper.Common/Constants.cs
+++ b/src/VirtualPaper.Common/Constants.cs
@@ -99,7 +99,7 @@ public static class FolderName {
}
public static class WorkingDir {
- public static string Shader => Path.Combine(UI, "Shaders");
+ public static string Shader => Path.Combine("Plugins", "Shaders");
public static string ML => Path.Combine("Plugins", "ML");
public static string ML_DepthEstimate => Path.Combine(ML, "DepthEstimate");
public static string ML_DepthEstimate_AI_Models => Path.Combine(ML_DepthEstimate, "ai_models");
@@ -189,6 +189,11 @@ public static class I18n {
public static string WpCreateDialog_AIWp_Title => "WpCreateDialog_AIWp_Title";
public static string WpCreateDialog_CommonWp_Explain => "WpCreateDialog_CommonWp_Explain";
public static string WpCreateDialog_CommonWp_Title => "WpCreateDialog_CommonWp_Title";
+ public static string WpLib_TypeFilter_All => "WpLib_TypeFilter_All";
+ public static string WpLib_TypeFilter_StaticImage => "WpLib_TypeFilter_StaticImage";
+ public static string WpLib_TypeFilter_DynamicImage => "WpLib_TypeFilter_DynamicImage";
+ public static string WpLib_TypeFilter_Video => "WpLib_TypeFilter_Video";
+ public static string WpLib_TypeFilter_WebInteractive => "WpLib_TypeFilter_WebInteractive";
public static string? Project_DeployNewDraft_PreviousStep { get; }
public static string? Project_DeployNewDraft_NextStep { get; }
public static string? Project_NewName_InvalidTip { get; }
@@ -236,7 +241,60 @@ public static class I18n {
public static string? Add_To_Lib_Success { get; }
public static string? Intelligent_Enhance_QualityRestore { get; }
public static string? Intelligent_Enhance_SuperResolution { get; }
- public static object Text_Error_InvalidFile { get; set; }
+ public static string? Text_Error_InvalidFile { get; }
+ // ── CanvasEffect 分组标题 ──────────────────────────────
+ public static string? Project_StaticImg_EffectGroup_Adjust { get; }
+ public static string? Project_StaticImg_EffectGroup_Color { get; }
+ public static string? Project_StaticImg_EffectGroup_Special { get; }
+ public static string? Project_StaticImg_EffectGroup_Blend { get; }
+ // ── CanvasEffect 效果名称 ─────────────────────────────
+ public static string? Project_StaticImg_Text_Effect_GrayScale { get; }
+ public static string? Project_StaticImg_Text_Effect_Invert { get; }
+ public static string? Project_StaticImg_Text_Effect_Exposure { get; }
+ public static string? Project_StaticImg_Text_Effect_Brightness { get; }
+ public static string? Project_StaticImg_Text_Effect_Saturation { get; }
+ public static string? Project_StaticImg_Text_Effect_Hue { get; }
+ public static string? Project_StaticImg_Text_Effect_Contrast { get; }
+ public static string? Project_StaticImg_Text_Effect_Temperature { get; }
+ public static string? Project_StaticImg_Text_Effect_Highlights { get; }
+ public static string? Project_StaticImg_Text_Effect_Sepia { get; }
+ public static string? Project_StaticImg_Text_Effect_Pixelate { get; }
+ public static string? Project_StaticImg_Text_Effect_Emboss { get; }
+ public static string? Project_StaticImg_Text_Effect_Blur { get; }
+ public static string? Project_StaticImg_Text_Effect_Sharpen { get; }
+ public static string? Project_StaticImg_Text_Effect_Noise { get; }
+ public static string? Project_StaticImg_Text_Effect_Vignette { get; }
+ public static string? Project_StaticImg_Text_Effect_Glow { get; }
+ public static string? Project_StaticImg_Text_Effect_Bloom { get; }
+ public static string? Project_StaticImg_Text_Effect_Distort { get; }
+ public static string? Project_StaticImg_Text_Effect_Multiply { get; }
+ public static string? Project_StaticImg_Text_Effect_Screen { get; }
+ public static string? Project_StaticImg_Text_Effect_Overlay { get; }
+ public static string? Project_StaticImg_Text_Effect_SoftLight { get; }
+ // CanvasEffect 效果描述
+ public static string? Project_StaticImg_Text_EffectDesc_GrayScale { get; }
+ public static string? Project_StaticImg_Text_EffectDesc_Invert { get; }
+ public static string? Project_StaticImg_Text_EffectDesc_Exposure { get; }
+ public static string? Project_StaticImg_Text_EffectDesc_Brightness { get; }
+ public static string? Project_StaticImg_Text_EffectDesc_Saturation { get; }
+ public static string? Project_StaticImg_Text_EffectDesc_Hue { get; }
+ public static string? Project_StaticImg_Text_EffectDesc_Contrast { get; }
+ public static string? Project_StaticImg_Text_EffectDesc_Temperature { get; }
+ public static string? Project_StaticImg_Text_EffectDesc_Highlights { get; }
+ public static string? Project_StaticImg_Text_EffectDesc_Sepia { get; }
+ public static string? Project_StaticImg_Text_EffectDesc_Pixelate { get; }
+ public static string? Project_StaticImg_Text_EffectDesc_Emboss { get; }
+ public static string? Project_StaticImg_Text_EffectDesc_Blur { get; }
+ public static string? Project_StaticImg_Text_EffectDesc_Sharpen { get; }
+ public static string? Project_StaticImg_Text_EffectDesc_Noise { get; }
+ public static string? Project_StaticImg_Text_EffectDesc_Vignette { get; }
+ public static string? Project_StaticImg_Text_EffectDesc_Glow { get; }
+ public static string? Project_StaticImg_Text_EffectDesc_Bloom { get; }
+ public static string? Project_StaticImg_Text_EffectDesc_Distort { get; }
+ public static string? Project_StaticImg_Text_EffectDesc_Multiply { get; }
+ public static string? Project_StaticImg_Text_EffectDesc_Screen { get; }
+ public static string? Project_StaticImg_Text_EffectDesc_Overlay { get; }
+ public static string? Project_StaticImg_Text_EffectDesc_SoftLight { get; }
}
public static class Field {
@@ -248,10 +306,5 @@ public static class Field {
public static string WpEffectFilePathUsing => "wpEffectFilePathUsing.json";
public static string WpRuntimeDataFileName => "wp_metadata_runtime.json";
}
-
- public static class ColorKey {
- public static string WindowCaptionForeground => "WindowCaptionForeground";
- public static string WindowCaptionForegroundDisabled => "WindowCaptionForegroundDisabled";
- }
}
}
diff --git a/src/VirtualPaper.Common/Enums.cs b/src/VirtualPaper.Common/Enums.cs
index fcbfeb2d..5caa6ef7 100644
--- a/src/VirtualPaper.Common/Enums.cs
+++ b/src/VirtualPaper.Common/Enums.cs
@@ -394,10 +394,13 @@ public class Scaling {
public string Type { get; init; } = "Dropdown";
public string Text { get; init; } = "Scale_Way";
+ public int Min => 0;
+ public int Max => 4;
+
private int val = 0;
public int Value {
get => val;
- set => val = Math.Clamp(value, 0, 4);
+ set => val = Math.Clamp(value, Min, Max);
}
public List Items { get; init; } = ["Fill", "Contain", "Cover", "None", "Scale-Down"];
diff --git a/src/VirtualPaper.Common/Logging/ArcLog.cs b/src/VirtualPaper.Common/Logging/ArcLog.cs
index 9d6e80f7..74e4d3b5 100644
--- a/src/VirtualPaper.Common/Logging/ArcLog.cs
+++ b/src/VirtualPaper.Common/Logging/ArcLog.cs
@@ -4,7 +4,8 @@
using Windows.ApplicationModel.Core;
/*
- * todo
+ * rweb-workload
+ *
* 分组循环播放
* AI 生成壁纸 接入 Stable Diffusion / ComfyUI,本地生成动态壁纸
* AI 静态图片动态化
diff --git a/src/VirtualPaper.Common/Utils/ComplianceUtil.cs b/src/VirtualPaper.Common/Utils/ComplianceUtil.cs
index c9b1de98..80d7fffc 100644
--- a/src/VirtualPaper.Common/Utils/ComplianceUtil.cs
+++ b/src/VirtualPaper.Common/Utils/ComplianceUtil.cs
@@ -164,9 +164,10 @@ public static bool IsValidSign(string sign) {
\p{P} - 标点符号
\p{S} - 特殊符号
\p{Zs} - 空格分隔符
+ \p{Cs} - 代理对码位(允许 emoji 等补充平面字符)
{0,100} - 长度限制0到100个字符
*/
- [GeneratedRegex(@"^[\p{L}\p{N}\p{P}\p{S}\p{Zs}]{0,100}$", RegexOptions.Compiled)]
+ [GeneratedRegex(@"^[\p{L}\p{N}\p{P}\p{S}\p{Zs}\p{Cs}]{0,100}$", RegexOptions.Compiled)]
private static partial Regex SignRegex();
}
}
diff --git a/src/VirtualPaper.Common/Utils/Files/FileUtil.cs b/src/VirtualPaper.Common/Utils/Files/FileUtil.cs
index cc83d44d..2ef38b0c 100644
--- a/src/VirtualPaper.Common/Utils/Files/FileUtil.cs
+++ b/src/VirtualPaper.Common/Utils/Files/FileUtil.cs
@@ -277,6 +277,14 @@ public static bool IsValidFilePath(string path) {
if (Path.GetInvalidPathChars().Any(path.Contains))
return false;
+ // GetInvalidPathChars 不含 < > | " 等,需逐段检查文件名非法字符
+ var invalidFileNameChars = new HashSet(Path.GetInvalidFileNameChars());
+ foreach (var segment in path.Split(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }, StringSplitOptions.RemoveEmptyEntries)) {
+ if (segment.Length >= 2 && segment[1] == ':') continue; // 跳过盘符 "C:"
+ if (segment.Any(c => invalidFileNameChars.Contains(c)))
+ return false;
+ }
+
string? dir = Path.GetDirectoryName(path);
return !string.IsNullOrEmpty(dir) && Directory.Exists(dir);
}
@@ -347,7 +355,10 @@ public static async Task GetAppxFileSizeAsync(string msAppxPath) {
public static string SizeSuffix(long value, int decimalPlaces = 1) {
ArgumentOutOfRangeException.ThrowIfNegative(decimalPlaces);
if (value < 0) { return "-" + SizeSuffix(-value, decimalPlaces); }
- if (value == 0) { return string.Format("{0:n" + decimalPlaces + "} bytes", 0); }
+
+ string fmt = "0." + new string('#', decimalPlaces);
+
+ if (value == 0) { return string.Format("{0:" + fmt + "} bytes", 0m); }
// mag is 0 for bytes, 1 for KB, 2, for MB, etc.
int mag = (int)Math.Log(value, 1024);
@@ -363,7 +374,7 @@ public static string SizeSuffix(long value, int decimalPlaces = 1) {
adjustedSize /= 1024;
}
- return string.Format("{0:n" + decimalPlaces + "} {1}",
+ return string.Format("{0:" + fmt + "} {1}",
adjustedSize,
SizeSuffixes[mag]);
}
diff --git a/src/VirtualPaper.Core.Test/T_Common/ByteExtensionsTests.cs b/src/VirtualPaper.Core.Test/T_Common/ByteExtensionsTests.cs
new file mode 100644
index 00000000..a0796d91
--- /dev/null
+++ b/src/VirtualPaper.Core.Test/T_Common/ByteExtensionsTests.cs
@@ -0,0 +1,91 @@
+using VirtualPaper.Common.Extensions;
+
+namespace VirtualPaper.Core.Test.T_Common {
+ [TestClass]
+ public class ByteExtensionsTests {
+ [TestMethod]
+ public void CompressPixels_EmptyArray_ReturnsEmpty() {
+ var data = Array.Empty();
+ var result = data.CompressPixels();
+ Assert.IsEmpty(result);
+ }
+
+ [TestMethod]
+ public void DecompressPixels_EmptyArray_ReturnsEmpty() {
+ var data = Array.Empty();
+ var result = data.DecompressPixels();
+ Assert.IsEmpty(result);
+ }
+
+ [TestMethod]
+ public void CompressDecompress_Roundtrip_PreservesData() {
+ var original = new byte[] { 255, 0, 128, 64, 32, 16, 8, 4, 2, 1 };
+
+ var compressed = original.CompressPixels();
+ var decompressed = compressed.DecompressPixels();
+
+ CollectionAssert.AreEqual(original, decompressed);
+ }
+
+ [TestMethod]
+ public void CompressDecompress_LargeData_Roundtrip() {
+ var original = new byte[100_000];
+ new Random(42).NextBytes(original);
+
+ var compressed = original.CompressPixels();
+ var decompressed = compressed.DecompressPixels();
+
+ CollectionAssert.AreEqual(original, decompressed);
+ }
+
+ [TestMethod]
+ public void CompressPixels_RepeatingData_CompressesSmaller() {
+ // 全零数据高度可压缩
+ var original = new byte[10_000];
+
+ var compressed = original.CompressPixels();
+
+ Assert.IsLessThan(original.Length, compressed.Length, $"Compressed ({compressed.Length}) should be smaller than original ({original.Length})");
+ }
+
+ [TestMethod]
+ public void CompressPixels_RandomData_ProducesNonEmpty() {
+ var original = new byte[256];
+ new Random(1).NextBytes(original);
+
+ var compressed = original.CompressPixels();
+
+ Assert.IsNotEmpty(compressed);
+ }
+
+ [TestMethod]
+ public void CompressDecompress_SingleByte_Roundtrip() {
+ var original = new byte[] { 42 };
+
+ var compressed = original.CompressPixels();
+ var decompressed = compressed.DecompressPixels();
+
+ CollectionAssert.AreEqual(original, decompressed);
+ }
+
+ [TestMethod]
+ public void CompressDecompress_AllZeros_Roundtrip() {
+ var original = new byte[1024];
+
+ var compressed = original.CompressPixels();
+ var decompressed = compressed.DecompressPixels();
+
+ CollectionAssert.AreEqual(original, decompressed);
+ }
+
+ [TestMethod]
+ public void CompressDecompress_AllOnes_Roundtrip() {
+ var original = Enumerable.Repeat((byte)0xFF, 1024).ToArray();
+
+ var compressed = original.CompressPixels();
+ var decompressed = compressed.DecompressPixels();
+
+ CollectionAssert.AreEqual(original, decompressed);
+ }
+ }
+}
diff --git a/src/VirtualPaper.Core.Test/T_Common/ComplianceUtilTests.cs b/src/VirtualPaper.Core.Test/T_Common/ComplianceUtilTests.cs
new file mode 100644
index 00000000..90316da9
--- /dev/null
+++ b/src/VirtualPaper.Core.Test/T_Common/ComplianceUtilTests.cs
@@ -0,0 +1,175 @@
+using VirtualPaper.Common.Utils;
+
+namespace VirtualPaper.Core.Test.T_Common {
+ [TestClass]
+ public class ComplianceUtilTests {
+ // ── IsValidFolderPath ─────────────────────────────────────────
+
+ [TestMethod]
+ [DataRow(@"C:\Users\Test")]
+ [DataRow(@"D:\Wallpapers\Sub")]
+ [DataRow(@"\\server\share")]
+ public void IsValidFolderPath_ValidPaths_ReturnsTrue(string path) {
+ Assert.IsTrue(ComplianceUtil.IsValidFolderPath(path));
+ }
+
+ [TestMethod]
+ [DataRow(null)]
+ [DataRow("")]
+ [DataRow("AB")] // 太短
+ [DataRow("C:folder")] // 缺少反斜杠
+ [DataRow("folder")] // 无盘符
+ public void IsValidFolderPath_InvalidPaths_ReturnsFalse(string? path) {
+ Assert.IsFalse(ComplianceUtil.IsValidFolderPath(path));
+ }
+
+ [TestMethod]
+ public void IsValidFolderPath_TooLong_ReturnsFalse() {
+ var longPath = @"C:\" + new string('a', 300);
+ Assert.IsFalse(ComplianceUtil.IsValidFolderPath(longPath));
+ }
+
+ // ── IsValidName ───────────────────────────────────────────────
+
+ [TestMethod]
+ [DataRow("MyWallpaper")]
+ [DataRow("test_file")]
+ [DataRow("file.name")]
+ public void IsValidName_ValidNames_ReturnsTrue(string name) {
+ Assert.IsTrue(ComplianceUtil.IsValidName(name));
+ }
+
+ [TestMethod]
+ [DataRow(null)]
+ [DataRow("")]
+ [DataRow("a b")] // 空格不在 ValidChars
+ public void IsValidName_InvalidNames_ReturnsFalse(string? name) {
+ Assert.IsFalse(ComplianceUtil.IsValidName(name));
+ }
+
+ [TestMethod]
+ public void IsValidName_ExceedsMaxLen_ReturnsFalse() {
+ Assert.IsFalse(ComplianceUtil.IsValidName(new string('x', 31)));
+ }
+
+ // ── IsValidValueOnlyLength ────────────────────────────────────
+
+ [TestMethod]
+ public void IsValidValueOnlyLength_InRange_ReturnsTrue() {
+ Assert.IsTrue(ComplianceUtil.IsValidValueOnlyLength("hello", 1, 10));
+ }
+
+ [TestMethod]
+ [DataRow(null)]
+ [DataRow("")]
+ public void IsValidValueOnlyLength_EmptyOrNull_ReturnsFalse(string? value) {
+ Assert.IsFalse(ComplianceUtil.IsValidValueOnlyLength(value));
+ }
+
+ [TestMethod]
+ public void IsValidValueOnlyLength_TooShort_ReturnsFalse() {
+ Assert.IsFalse(ComplianceUtil.IsValidValueOnlyLength("ab", 3, 10));
+ }
+
+ // ── IsValidEmail ──────────────────────────────────────────────
+
+ [TestMethod]
+ [DataRow("user@example.com")]
+ [DataRow("test.name@domain.org")]
+ public void IsValidEmail_ValidEmails_ReturnsTrue(string email) {
+ Assert.IsTrue(ComplianceUtil.IsValidEmail(email));
+ }
+
+ [TestMethod]
+ [DataRow("")]
+ [DataRow("noatsign")]
+ [DataRow("@nodomain")]
+ [DataRow("user@")]
+ [DataRow("user@domain")]
+ public void IsValidEmail_InvalidEmails_ReturnsFalse(string email) {
+ Assert.IsFalse(ComplianceUtil.IsValidEmail(email));
+ }
+
+ // ── IsValidPwd ────────────────────────────────────────────────
+
+ [TestMethod]
+ [DataRow("Abcdef1!")]
+ [DataRow("MyP@ssw0rd")]
+ [DataRow("Str0ng#Pass")]
+ public void IsValidPwd_ValidPasswords_ReturnsTrue(string pwd) {
+ Assert.IsTrue(ComplianceUtil.IsValidPwd(pwd));
+ }
+
+ [TestMethod]
+ [DataRow("")]
+ [DataRow("shrt1A!")] // 太短(<8)
+ [DataRow("alllowercase1!")] // 无大写
+ [DataRow("ALLUPPERCASE1!")] // 无小写
+ [DataRow("NoDigitsHere!")] // 无数字
+ [DataRow("NoSpecial1a")] // 无特殊字符
+ public void IsValidPwd_InvalidPasswords_ReturnsFalse(string pwd) {
+ Assert.IsFalse(ComplianceUtil.IsValidPwd(pwd));
+ }
+
+ // ── IsValidUserName ───────────────────────────────────────────
+
+ [TestMethod]
+ [DataRow("alice")]
+ [DataRow("user123")]
+ [DataRow("john.doe")]
+ [DataRow("user-name")]
+ [DataRow("user_name")]
+ public void IsValidUserName_ValidNames_ReturnsTrue(string name) {
+ Assert.IsTrue(ComplianceUtil.IsValidUserName(name));
+ }
+
+ [TestMethod]
+ [DataRow("")]
+ [DataRow("ab")] // 太短(<3)
+ [DataRow(".startswithdot")] // 以 . 开头
+ [DataRow("-startswithdash")] // 以 - 开头
+ [DataRow("endswithdot.")] // 以 . 结尾
+ [DataRow("endswithdash-")] // 以 - 结尾
+ [DataRow("has..double")] // 连续 ..
+ [DataRow("has--double")] // 连续 --
+ [DataRow("has._mixed")] // 连续 ._
+ [DataRow("has-.mixed")] // 连续 -.
+ public void IsValidUserName_InvalidNames_ReturnsFalse(string name) {
+ Assert.IsFalse(ComplianceUtil.IsValidUserName(name));
+ }
+
+ [TestMethod]
+ [DataRow("admin")]
+ [DataRow("root")]
+ [DataRow("test")]
+ [DataRow("guest")]
+ [DataRow("user")]
+ [DataRow("system")]
+ public void IsValidUserName_BlacklistedNames_ReturnsFalse(string name) {
+ Assert.IsFalse(ComplianceUtil.IsValidUserName(name));
+ }
+
+ [TestMethod]
+ public void IsValidUserName_BlacklistCaseInsensitive_ReturnsFalse() {
+ Assert.IsFalse(ComplianceUtil.IsValidUserName("Admin"));
+ Assert.IsFalse(ComplianceUtil.IsValidUserName("ROOT"));
+ }
+
+ // ── IsValidSign ───────────────────────────────────────────────
+
+ [TestMethod]
+ public void IsValidSign_NormalText_ReturnsTrue() {
+ Assert.IsTrue(ComplianceUtil.IsValidSign("Hello World! 你好世界 🎉"));
+ }
+
+ [TestMethod]
+ public void IsValidSign_EmptyString_ReturnsTrue() {
+ Assert.IsTrue(ComplianceUtil.IsValidSign(""));
+ }
+
+ [TestMethod]
+ public void IsValidSign_TooLong_ReturnsFalse() {
+ Assert.IsFalse(ComplianceUtil.IsValidSign(new string('a', 101)));
+ }
+ }
+}
diff --git a/src/VirtualPaper.Core.Test/T_Common/FileUtilTests.cs b/src/VirtualPaper.Core.Test/T_Common/FileUtilTests.cs
new file mode 100644
index 00000000..1c96673a
--- /dev/null
+++ b/src/VirtualPaper.Core.Test/T_Common/FileUtilTests.cs
@@ -0,0 +1,190 @@
+using VirtualPaper.Common.Utils.Files;
+
+namespace VirtualPaper.Core.Test.T_Common {
+ [TestClass]
+ public class FileUtilTests {
+ // ── GetSafeFilename ───────────────────────────────────────────
+
+ [TestMethod]
+ public void GetSafeFilename_NoInvalidChars_Unchanged() {
+ Assert.AreEqual("normal_file.txt", FileUtil.GetSafeFilename("normal_file.txt"));
+ }
+
+ [TestMethod]
+ public void GetSafeFilename_WithInvalidChars_Replaced() {
+ var result = FileUtil.GetSafeFilename("file:test.txt");
+ Assert.DoesNotContain('<', result);
+ Assert.DoesNotContain('>', result);
+ Assert.DoesNotContain(':', result);
+ }
+
+ // ── IsValidFileName ───────────────────────────────────────────
+
+ [TestMethod]
+ [DataRow("test.vpd")]
+ [DataRow("my file.png")]
+ [DataRow("document (1).jpg")]
+ public void IsValidFileName_ValidNames_ReturnsTrue(string name) {
+ Assert.IsTrue(FileUtil.IsValidFileName(name));
+ }
+
+ [TestMethod]
+ [DataRow("")]
+ [DataRow("test.vpd")]
+ [DataRow("test|file.vpd")]
+ [DataRow("test\"file.vpd")]
+ public void IsValidFileName_InvalidNames_ReturnsFalse(string name) {
+ Assert.IsFalse(FileUtil.IsValidFileName(name));
+ }
+
+ // ── IsValidFilePath ───────────────────────────────────────────
+
+ [TestMethod]
+ public void IsValidFilePath_ExistingDirectory_ReturnsTrue() {
+ var path = Path.Combine(Path.GetTempPath(), "test.vpd");
+ Assert.IsTrue(FileUtil.IsValidFilePath(path));
+ }
+
+ [TestMethod]
+ public void IsValidFilePath_InvalidChars_ReturnsFalse() {
+ Assert.IsFalse(FileUtil.IsValidFilePath("C:\\test.vpd"));
+ }
+
+ // ── SizeSuffix ────────────────────────────────────────────────
+
+ [TestMethod]
+ [DataRow(0, "0 bytes")]
+ [DataRow(500, "500 bytes")]
+ [DataRow(1024, "1 KB")]
+ [DataRow(1536, "1.5 KB")]
+ [DataRow(1048576, "1 MB")]
+ [DataRow(1073741824, "1 GB")]
+ public void SizeSuffix_CorrectFormatting(long value, string expected) {
+ Assert.AreEqual(expected, FileUtil.SizeSuffix(value));
+ }
+
+ [TestMethod]
+ public void SizeSuffix_NegativeValue_HasMinusPrefix() {
+ var result = FileUtil.SizeSuffix(-1024);
+ Assert.StartsWith("-", result);
+ }
+
+ [TestMethod]
+ public void SizeSuffix_LargeValue_RoundsUp() {
+ // 1024 MB → 应显示为 1 GB 而非 1024.0 MB
+ var result = FileUtil.SizeSuffix(1024L * 1024 * 1024);
+ Assert.AreEqual("1 GB", result);
+ }
+
+ // ── NextAvailableFilename ─────────────────────────────────────
+
+ [TestMethod]
+ public void NextAvailableFilename_NonExistent_ReturnsOriginal() {
+ var path = Path.Combine(Path.GetTempPath(), $"nonexistent_{Guid.NewGuid():N}.txt");
+ Assert.AreEqual(path, FileUtil.NextAvailableFilename(path));
+ }
+
+ [TestMethod]
+ public void NextAvailableFilename_Existing_ReturnsNumbered() {
+ var dir = Path.GetTempPath();
+ var baseName = $"testfile_{Guid.NewGuid():N}.txt";
+ var basePath = Path.Combine(dir, baseName);
+ File.WriteAllText(basePath, "");
+ try {
+ var next = FileUtil.NextAvailableFilename(basePath);
+ Assert.AreNotEqual(basePath, next);
+ Assert.Contains("(1)", next);
+ } finally {
+ File.Delete(basePath);
+ }
+ }
+
+ // ── GetTempFile ───────────────────────────────────────────────
+
+ [TestMethod]
+ public void GetTempFile_CreatesFile() {
+ var dir = Path.Combine(Path.GetTempPath(), $"testdir_{Guid.NewGuid():N}");
+ try {
+ var tempFile = FileUtil.GetTempFile(dir);
+ Assert.IsTrue(File.Exists(tempFile));
+ Assert.StartsWith(dir, tempFile);
+ } finally {
+ if (Directory.Exists(dir))
+ Directory.Delete(dir, true);
+ }
+ }
+
+ [TestMethod]
+ public void GetTempFile_WithExtension_HasCorrectExt() {
+ var dir = Path.Combine(Path.GetTempPath(), $"testdir_{Guid.NewGuid():N}");
+ try {
+ var tempFile = FileUtil.GetTempFile(dir, ".vpd");
+ Assert.EndsWith(".vpd", tempFile);
+ } finally {
+ if (Directory.Exists(dir))
+ Directory.Delete(dir, true);
+ }
+ }
+
+ // ── GetChecksumSHA256 ─────────────────────────────────────────
+
+ [TestMethod]
+ public void GetChecksumSHA256_SameContent_SameHash() {
+ var tempFile = Path.GetTempFileName();
+ File.WriteAllText(tempFile, "hello world");
+ try {
+ var h1 = FileUtil.GetChecksumSHA256(tempFile);
+ var h2 = FileUtil.GetChecksumSHA256(tempFile);
+ Assert.AreEqual(h1, h2);
+ Assert.AreEqual(64, h1.Length); // SHA256 → 64 hex chars
+ } finally {
+ File.Delete(tempFile);
+ }
+ }
+
+ [TestMethod]
+ public void GetChecksumSHA256_DifferentContent_DifferentHash() {
+ var f1 = Path.GetTempFileName();
+ var f2 = Path.GetTempFileName();
+ File.WriteAllText(f1, "hello");
+ File.WriteAllText(f2, "world");
+ try {
+ Assert.AreNotEqual(
+ FileUtil.GetChecksumSHA256(f1),
+ FileUtil.GetChecksumSHA256(f2));
+ } finally {
+ File.Delete(f1);
+ File.Delete(f2);
+ }
+ }
+
+ // ── IsFileGreaterThanThreshold ────────────────────────────────
+
+ [TestMethod]
+ public void IsFileGreaterThanThreshold_Larger_ReturnsTrue() {
+ var tempFile = Path.GetTempFileName();
+ File.WriteAllBytes(tempFile, new byte[1000]);
+ try {
+ Assert.IsTrue(FileUtil.IsFileGreaterThanThreshold(tempFile, 500));
+ } finally {
+ File.Delete(tempFile);
+ }
+ }
+
+ [TestMethod]
+ public void IsFileGreaterThanThreshold_Smaller_ReturnsFalse() {
+ var tempFile = Path.GetTempFileName();
+ File.WriteAllBytes(tempFile, new byte[100]);
+ try {
+ Assert.IsFalse(FileUtil.IsFileGreaterThanThreshold(tempFile, 500));
+ } finally {
+ File.Delete(tempFile);
+ }
+ }
+
+ [TestMethod]
+ public void IsFileGreaterThanThreshold_NonExistent_ReturnsFalse() {
+ Assert.IsFalse(FileUtil.IsFileGreaterThanThreshold(@"C:\nonexistent_file", 100));
+ }
+ }
+}
diff --git a/src/VirtualPaper.Core.Test/T_Common/IdentifyUtilTests.cs b/src/VirtualPaper.Core.Test/T_Common/IdentifyUtilTests.cs
new file mode 100644
index 00000000..20c30fdd
--- /dev/null
+++ b/src/VirtualPaper.Core.Test/T_Common/IdentifyUtilTests.cs
@@ -0,0 +1,42 @@
+using VirtualPaper.Common.Utils;
+
+namespace VirtualPaper.Core.Test.T_Common {
+ [TestClass]
+ public class IdentifyUtilTests {
+ [TestMethod]
+ public void ComputeHash_SameInput_SameOutput() {
+ int h1 = IdentifyUtil.ComputeHash("hello");
+ int h2 = IdentifyUtil.ComputeHash("hello");
+ Assert.AreEqual(h1, h2);
+ }
+
+ [TestMethod]
+ public void ComputeHash_DifferentInput_DifferentOutput() {
+ int h1 = IdentifyUtil.ComputeHash("hello");
+ int h2 = IdentifyUtil.ComputeHash("world");
+ Assert.AreNotEqual(h1, h2);
+ }
+
+ [TestMethod]
+ public void ComputeHash_EmptyString_ReturnsHash() {
+ int h = IdentifyUtil.ComputeHash("");
+ // SHA256 of empty string is well-defined, should not throw
+ Assert.IsInstanceOfType(h, typeof(int));
+ }
+
+ [TestMethod]
+ public void GenerateIdShort_ReturnsNonZero() {
+ long id = IdentifyUtil.GenerateIdShort();
+ // 极小概率为 0,但 Guid 生成的字节几乎不可能全零
+ // 主要验证不抛异常且返回 long
+ Assert.IsInstanceOfType(id, typeof(long));
+ }
+
+ [TestMethod]
+ public void GenerateIdShort_CalledTwice_DifferentValues() {
+ long id1 = IdentifyUtil.GenerateIdShort();
+ long id2 = IdentifyUtil.GenerateIdShort();
+ Assert.AreNotEqual(id1, id2);
+ }
+ }
+}
diff --git a/src/VirtualPaper.Core.Test/T_Common/InputUtilTests.cs b/src/VirtualPaper.Core.Test/T_Common/InputUtilTests.cs
index 9b0b6539..4cf10427 100644
--- a/src/VirtualPaper.Core.Test/T_Common/InputUtilTests.cs
+++ b/src/VirtualPaper.Core.Test/T_Common/InputUtilTests.cs
@@ -120,10 +120,6 @@ public void ToMouseSpanLocal_WhenCursorAtVirtualOrigin_ReturnsOrigin() {
[TestMethod]
[Description("ForwardMessageMouse encodes x in low word and y in high word of lParam")]
public void ForwardMessageMouse_LParamEncoding_XInLowWordYInHighWord() {
- int capturedMsg = 0;
- IntPtr capturedWParam = IntPtr.Zero;
- UIntPtr capturedLParam = UIntPtr.Zero;
-
// We can verify the encoding formula directly without P/Invoke:
// lParam = (uint)y << 16 | (uint)x
int x = 0x00FF;
@@ -144,7 +140,7 @@ public void ToMouseDisplayLocal_WhenCursorInsideBounds_XIsNonNegative() {
var bounds = new Rectangle(1920, 0, 1920, 1080);
// Cursor at left edge of second monitor
var result = InputUtil.ToMouseDisplayLocal(1920, 540, bounds);
- Assert.IsTrue(result.X >= 0, "X coordinate should be non-negative when inside display");
+ Assert.IsGreaterThanOrEqualTo(0, result.X, "X coordinate should be non-negative when inside display");
}
[TestMethod]
@@ -153,8 +149,8 @@ public void ToMouseSpanLocal_WhenCursorInsideBounds_XIsNonNegative() {
var virtualBounds = new Rectangle(-1920, -200, 5760, 1480);
// Cursor at virtual origin
var result = InputUtil.ToMouseSpanLocal(-1920, -200, virtualBounds);
- Assert.IsTrue(result.X >= 0, "X coordinate should be non-negative when at virtual screen origin");
- Assert.IsTrue(result.Y >= 0, "Y coordinate should be non-negative when at virtual screen origin");
+ Assert.IsGreaterThanOrEqualTo(0, result.X, "X coordinate should be non-negative when at virtual screen origin");
+ Assert.IsGreaterThanOrEqualTo(0, result.Y, "Y coordinate should be non-negative when at virtual screen origin");
}
}
}
diff --git a/src/VirtualPaper.Core.Test/T_Common/JsonSaverTests.cs b/src/VirtualPaper.Core.Test/T_Common/JsonSaverTests.cs
new file mode 100644
index 00000000..7897a4bd
--- /dev/null
+++ b/src/VirtualPaper.Core.Test/T_Common/JsonSaverTests.cs
@@ -0,0 +1,184 @@
+using System.Text.Json.Serialization;
+using VirtualPaper.Common.Utils.Storage;
+using static VirtualPaper.Common.Errors;
+
+namespace VirtualPaper.Core.Test.T_Common {
+ // ── 测试用数据类型 ────────────────────────────────────────────────
+
+ public record TestSettings {
+ public string Name { get; set; } = "";
+ public int Value { get; set; }
+ public List Items { get; set; } = new();
+ }
+
+ [JsonSerializable(typeof(TestSettings))]
+ public partial class TestSettingsContext : JsonSerializerContext { }
+
+ [TestClass]
+ public class JsonSaverTests {
+ private string _tempDir = null!;
+
+ [TestInitialize]
+ public void Setup() {
+ _tempDir = Path.Combine(Path.GetTempPath(), $"JsonSaverTest_{Guid.NewGuid():N}");
+ Directory.CreateDirectory(_tempDir);
+ }
+
+ [TestCleanup]
+ public void Cleanup() {
+ if (Directory.Exists(_tempDir))
+ Directory.Delete(_tempDir, true);
+ }
+
+ // ── SaveAsync / LoadAsync 往返 ────────────────────────────────
+
+ [TestMethod]
+ public async Task SaveLoad_Roundtrip_PreservesData() {
+ var original = new TestSettings {
+ Name = "test",
+ Value = 42,
+ Items = new List { "a", "b", "c" },
+ };
+ var path = Path.Combine(_tempDir, "settings.json");
+
+ await JsonSaver.SaveAsync(path, original, TestSettingsContext.Default);
+ var loaded = await JsonSaver.LoadAsync(path, TestSettingsContext.Default);
+
+ Assert.AreEqual(original.Name, loaded.Name);
+ Assert.AreEqual(original.Value, loaded.Value);
+ CollectionAssert.AreEqual(original.Items, loaded.Items);
+ }
+
+ [TestMethod]
+ public async Task SaveLoad_EmptyList_Roundtrips() {
+ var original = new TestSettings { Name = "empty", Items = new() };
+ var path = Path.Combine(_tempDir, "empty.json");
+
+ await JsonSaver.SaveAsync(path, original, TestSettingsContext.Default);
+ var loaded = await JsonSaver.LoadAsync(path, TestSettingsContext.Default);
+
+ Assert.IsEmpty(loaded.Items);
+ }
+
+ [TestMethod]
+ public async Task SaveLoad_DefaultValues_Roundtrips() {
+ var original = new TestSettings();
+ var path = Path.Combine(_tempDir, "default.json");
+
+ await JsonSaver.SaveAsync(path, original, TestSettingsContext.Default);
+ var loaded = await JsonSaver.LoadAsync(path, TestSettingsContext.Default);
+
+ Assert.AreEqual("", loaded.Name);
+ Assert.AreEqual(0, loaded.Value);
+ }
+
+ // ── 容错:注释 ────────────────────────────────────────────────
+
+ [TestMethod]
+ public async Task Load_WithComments_Succeeds() {
+ var path = Path.Combine(_tempDir, "commented.json");
+ File.WriteAllText(path, """
+ {
+ // 这是注释
+ "Name": "test",
+ "Value": 1 /* 内联注释 */,
+ "Items": []
+ }
+ """);
+
+ var loaded = await JsonSaver.LoadAsync(path, TestSettingsContext.Default);
+
+ Assert.AreEqual("test", loaded.Name);
+ Assert.AreEqual(1, loaded.Value);
+ }
+
+ // ── 容错:尾逗号 ──────────────────────────────────────────────
+
+ [TestMethod]
+ public async Task Load_WithTrailingComma_Succeeds() {
+ var path = Path.Combine(_tempDir, "trailing.json");
+ File.WriteAllText(path, """
+ {
+ "Name": "test",
+ "Value": 1,
+ "Items": ["a", "b",],
+ }
+ """);
+
+ var loaded = await JsonSaver.LoadAsync(path, TestSettingsContext.Default);
+
+ Assert.AreEqual("test", loaded.Name);
+ Assert.HasCount(2, loaded.Items);
+ }
+
+ // ── 容错:大小写不敏感 ────────────────────────────────────────
+
+ [TestMethod]
+ public async Task Load_CaseInsensitiveProperties_Succeeds() {
+ var path = Path.Combine(_tempDir, "case.json");
+ File.WriteAllText(path, """
+ {
+ "name": "test",
+ "VALUE": 99,
+ "items": ["x"]
+ }
+ """);
+
+ var loaded = await JsonSaver.LoadAsync(path, TestSettingsContext.Default);
+
+ Assert.AreEqual("test", loaded.Name);
+ Assert.AreEqual(99, loaded.Value);
+ }
+
+ // ── 写入格式 ─────────────────────────────────────────────────
+
+ [TestMethod]
+ public async Task Save_WritesIndentedJson() {
+ var data = new TestSettings { Name = "test" };
+ var path = Path.Combine(_tempDir, "indented.json");
+
+ await JsonSaver.SaveAsync(path, data, TestSettingsContext.Default);
+
+ var content = File.ReadAllText(path);
+ // WriteIndented = true → 包含换行和缩进
+ Assert.IsTrue(content.Contains('\n') || content.Contains("\r\n"));
+ }
+
+ // ── 错误处理 ─────────────────────────────────────────────────
+
+ [TestMethod]
+ public async Task Load_NonExistentFile_ThrowsFileAccessException() {
+ var path = Path.Combine(_tempDir, "nonexistent——11.json");
+
+ await Assert.ThrowsAsync(
+ () => JsonSaver.LoadAsync(path, TestSettingsContext.Default));
+ }
+
+ // ── 自动创建目录 ─────────────────────────────────────────────
+
+ [TestMethod]
+ public async Task Save_CreatesDirectoryIfNotExists() {
+ var subDir = Path.Combine(_tempDir, "sub", "deep");
+ var path = Path.Combine(subDir, "settings.json");
+ var data = new TestSettings { Name = "test" };
+
+ await JsonSaver.SaveAsync(path, data, TestSettingsContext.Default);
+
+ Assert.IsTrue(File.Exists(path));
+ }
+
+ // ── 同步方法 ─────────────────────────────────────────────────
+
+ [TestMethod]
+ public void SaveLoad_Sync_Roundtrips() {
+ var original = new TestSettings { Name = "sync", Value = 7 };
+ var path = Path.Combine(_tempDir, "sync.json");
+
+ JsonSaver.Save(path, original, TestSettingsContext.Default);
+ var loaded = JsonSaver.Load(path, TestSettingsContext.Default);
+
+ Assert.AreEqual("sync", loaded.Name);
+ Assert.AreEqual(7, loaded.Value);
+ }
+ }
+}
diff --git a/src/VirtualPaper.Core.Test/T_Common/OperationResultTests.cs b/src/VirtualPaper.Core.Test/T_Common/OperationResultTests.cs
new file mode 100644
index 00000000..26744f88
--- /dev/null
+++ b/src/VirtualPaper.Core.Test/T_Common/OperationResultTests.cs
@@ -0,0 +1,41 @@
+using VirtualPaper.Common.Utils;
+
+namespace VirtualPaper.Core.Test.T_Common {
+ [TestClass]
+ public class OperationResultTests {
+ [TestMethod]
+ public void Success_IsSuccessTrue_ResultSet() {
+ var result = OperationResult.Success("data");
+
+ Assert.IsTrue(result.IsSuccess);
+ Assert.AreEqual("data", result.Result);
+ Assert.AreEqual(string.Empty, result.ErrorMessage);
+ }
+
+ [TestMethod]
+ public void Failure_IsSuccessFalse_ErrorMessageSet() {
+ var result = OperationResult.Failure("something went wrong");
+
+ Assert.IsFalse(result.IsSuccess);
+ Assert.AreEqual("something went wrong", result.ErrorMessage);
+ Assert.AreEqual(default(int), result.Result);
+ }
+
+ [TestMethod]
+ public void Success_WithNull_ResultIsNull() {
+ var result = OperationResult.Success(null!);
+
+ Assert.IsTrue(result.IsSuccess);
+ Assert.IsNull(result.Result);
+ }
+
+ [TestMethod]
+ public void Success_WithComplexType_ResultPreserved() {
+ var data = new List { 1, 2, 3 };
+ var result = OperationResult>.Success(data);
+
+ Assert.IsTrue(result.IsSuccess);
+ Assert.AreSame(data, result.Result);
+ }
+ }
+}
diff --git a/src/VirtualPaper.Core.Test/T_Common/RectExtensionsTests.cs b/src/VirtualPaper.Core.Test/T_Common/RectExtensionsTests.cs
new file mode 100644
index 00000000..4329a7b4
--- /dev/null
+++ b/src/VirtualPaper.Core.Test/T_Common/RectExtensionsTests.cs
@@ -0,0 +1,155 @@
+using Windows.Foundation;
+using VirtualPaper.Common.Extensions;
+
+namespace VirtualPaper.Core.Test.T_Common {
+ [TestClass]
+ public class RectExtensionsTests {
+ // ── IntersectRect ─────────────────────────────────────────────
+
+ [TestMethod]
+ public void IntersectRect_Overlapping_ReturnsIntersection() {
+ var a = new Rect(0, 0, 10, 10);
+ var b = new Rect(5, 5, 10, 10);
+
+ var result = a.IntersectRect(b);
+
+ Assert.AreEqual(5, result.X);
+ Assert.AreEqual(5, result.Y);
+ Assert.AreEqual(5, result.Width);
+ Assert.AreEqual(5, result.Height);
+ }
+
+ [TestMethod]
+ public void IntersectRect_NoOverlap_ReturnsEmpty() {
+ var a = new Rect(0, 0, 5, 5);
+ var b = new Rect(10, 10, 5, 5);
+
+ var result = a.IntersectRect(b);
+
+ Assert.IsTrue(result.IsEmpty);
+ }
+
+ [TestMethod]
+ public void IntersectRect_OneEmpty_ReturnsEmpty() {
+ var a = new Rect(0, 0, 10, 10);
+
+ Assert.IsTrue(a.IntersectRect(Rect.Empty).IsEmpty);
+ Assert.IsTrue(Rect.Empty.IntersectRect(a).IsEmpty);
+ }
+
+ [TestMethod]
+ public void IntersectRect_Contained_ReturnsInner() {
+ var outer = new Rect(0, 0, 100, 100);
+ var inner = new Rect(10, 10, 20, 20);
+
+ var result = outer.IntersectRect(inner);
+
+ Assert.AreEqual(10, result.X);
+ Assert.AreEqual(10, result.Y);
+ Assert.AreEqual(20, result.Width);
+ Assert.AreEqual(20, result.Height);
+ }
+
+ [TestMethod]
+ public void IntersectRect_TouchingEdge_ReturnsEmpty() {
+ var a = new Rect(0, 0, 10, 10);
+ var b = new Rect(10, 0, 10, 10);
+
+ var result = a.IntersectRect(b);
+
+ Assert.IsTrue(result.IsEmpty);
+ }
+
+ // ── UnionRect ─────────────────────────────────────────────────
+
+ [TestMethod]
+ public void UnionRect_Overlapping_ReturnsBounding() {
+ var a = new Rect(0, 0, 10, 10);
+ var b = new Rect(5, 5, 10, 10);
+
+ var result = a.UnionRect(b);
+
+ Assert.AreEqual(0, result.X);
+ Assert.AreEqual(0, result.Y);
+ Assert.AreEqual(15, result.Width);
+ Assert.AreEqual(15, result.Height);
+ }
+
+ [TestMethod]
+ public void UnionRect_OneEmpty_ReturnsOther() {
+ var rect = new Rect(5, 5, 10, 10);
+
+ var r1 = rect.UnionRect(Rect.Empty);
+ var r2 = Rect.Empty.UnionRect(rect);
+
+ Assert.AreEqual(rect, r1);
+ Assert.AreEqual(rect, r2);
+ }
+
+ [TestMethod]
+ public void UnionRect_BothEmpty_ReturnsEmpty() {
+ var result = Rect.Empty.UnionRect(Rect.Empty);
+ Assert.IsTrue(result.IsEmpty);
+ }
+
+ [TestMethod]
+ public void UnionRect_Disjoint_ReturnsFullBounding() {
+ var a = new Rect(0, 0, 5, 5);
+ var b = new Rect(10, 10, 5, 5);
+
+ var result = a.UnionRect(b);
+
+ Assert.AreEqual(0, result.X);
+ Assert.AreEqual(0, result.Y);
+ Assert.AreEqual(15, result.Width);
+ Assert.AreEqual(15, result.Height);
+ }
+
+ // ── RoundOutwardAsInt ─────────────────────────────────────────
+
+ [TestMethod]
+ public void RoundOutwardAsInt_FractionalCoords_Expands() {
+ var rect = new Rect(1.2, 2.3, 5.6, 7.8);
+
+ var result = rect.RoundOutwardAsInt();
+
+ Assert.AreEqual(1, result.X); // Floor(1.2)
+ Assert.AreEqual(2, result.Y); // Floor(2.3)
+ Assert.AreEqual(6, result.Width); // Ceil(1.2+5.6) - Floor(1.2) = Ceil(6.8) - 1 = 7 - 1 = 6
+ Assert.AreEqual(9, result.Height); // Ceil(2.3+7.8) - Floor(2.3) = Ceil(10.1) - 2 = 11 - 2 = 9
+ }
+
+ [TestMethod]
+ public void RoundOutwardAsInt_IntegerCoords_Unchanged() {
+ var rect = new Rect(1, 2, 5, 7);
+
+ var result = rect.RoundOutwardAsInt();
+
+ Assert.AreEqual(1, result.X);
+ Assert.AreEqual(2, result.Y);
+ Assert.AreEqual(5, result.Width);
+ Assert.AreEqual(7, result.Height);
+ }
+
+ [TestMethod]
+ public void RoundOutwardAsInt_NegativeCoords_FloorsCorrectly() {
+ var rect = new Rect(-1.5, -2.5, 10, 10);
+
+ var result = rect.RoundOutwardAsInt();
+
+ Assert.AreEqual(-2, result.X); // Floor(-1.5)
+ Assert.AreEqual(-3, result.Y); // Floor(-2.5)
+ }
+
+ // ── GetSize ───────────────────────────────────────────────────
+
+ [TestMethod]
+ public void GetSize_ReturnsWidthAndHeight() {
+ var rect = new Rect(10, 20, 100, 200);
+ var size = rect.GetSize();
+
+ Assert.AreEqual(100, size.Width);
+ Assert.AreEqual(200, size.Height);
+ }
+ }
+}
diff --git a/src/VirtualPaper.Core.Test/T_Common/SliderPropertyTests.cs b/src/VirtualPaper.Core.Test/T_Common/SliderPropertyTests.cs
new file mode 100644
index 00000000..f3868473
--- /dev/null
+++ b/src/VirtualPaper.Core.Test/T_Common/SliderPropertyTests.cs
@@ -0,0 +1,99 @@
+using VirtualPaper.Common;
+
+namespace VirtualPaper.Core.Test.T_Common {
+ [TestClass]
+ public class SliderPropertyTests {
+ // ── SliderProperty.Value 越界回退 ──────────────────────────
+
+ [TestMethod]
+ public void Saturation_ValueInRange_Accepted() {
+ var prop = new Saturation();
+ prop.Value = 5;
+ Assert.AreEqual(5d, prop.Value);
+ }
+
+ [TestMethod]
+ public void Saturation_ValueBelowMin_ResetsToDefault() {
+ var prop = new Saturation();
+ prop.Value = -1; // < Min(0)
+ Assert.AreEqual(prop.DefaultValue, prop.Value);
+ }
+
+ [TestMethod]
+ public void Saturation_ValueAboveMax_ResetsToDefault() {
+ var prop = new Saturation();
+ prop.Value = 11; // > Max(10)
+ Assert.AreEqual(prop.DefaultValue, prop.Value);
+ }
+
+ [TestMethod]
+ public void Brightness_ValueAtBoundary_Accepted() {
+ var prop = new Brightness();
+ prop.Value = 0;
+ Assert.AreEqual(0d, prop.Value);
+
+ prop.Value = 2;
+ Assert.AreEqual(2d, prop.Value);
+ }
+
+ [TestMethod]
+ public void Speed_ValueBelowMin_ResetsToDefault() {
+ var prop = new Speed();
+ prop.Value = 0.1; // < Min(0.25)
+ Assert.AreEqual(prop.DefaultValue, prop.Value);
+ }
+
+ [TestMethod]
+ public void Volume_ValueAboveMax_ResetsToDefault() {
+ var prop = new Volume();
+ prop.Value = 1.5; // > Max(1)
+ Assert.AreEqual(prop.DefaultValue, prop.Value);
+ }
+
+ [TestMethod]
+ public void Hue_IntValueInRange_Accepted() {
+ var prop = new Hue();
+ prop.Value = 180;
+ Assert.AreEqual(180, prop.Value);
+ }
+
+ [TestMethod]
+ public void Hue_IntValueOutOfRange_ResetsToDefault() {
+ var prop = new Hue();
+ prop.Value = 400; // > Max(359)
+ Assert.AreEqual(prop.DefaultValue, prop.Value);
+ }
+
+ [TestMethod]
+ public void SliderProperty_DefaultValue_IsInitialValue() {
+ Assert.AreEqual(1d, new Saturation().Value);
+ Assert.AreEqual(1d, new Brightness().Value);
+ Assert.AreEqual(1d, new Contrast().Value);
+ Assert.AreEqual(1d, new Speed().Value);
+ Assert.AreEqual(0.8d, new Volume().Value);
+ Assert.AreEqual(0, new Hue().Value);
+ }
+
+ // ── Scaling.Value clamp ───────────────────────────────────────
+
+ [TestMethod]
+ public void Scaling_ValueClamped_0To4() {
+ var s = new Scaling();
+
+ s.Value = -1;
+ Assert.AreEqual(0, s.Value);
+
+ s.Value = 10;
+ Assert.AreEqual(4, s.Value);
+
+ s.Value = 2;
+ Assert.AreEqual(2, s.Value);
+ }
+
+ [TestMethod]
+ public void Scaling_Has5Items() {
+ var s = new Scaling();
+ Assert.HasCount(5, s.Items);
+ }
+ }
+}
diff --git a/src/VirtualPaper.Core.Test/T_Common/UndoRedoUtilTests.cs b/src/VirtualPaper.Core.Test/T_Common/UndoRedoUtilTests.cs
new file mode 100644
index 00000000..82aa0e1c
--- /dev/null
+++ b/src/VirtualPaper.Core.Test/T_Common/UndoRedoUtilTests.cs
@@ -0,0 +1,329 @@
+using VirtualPaper.Common.Utils.UndoRedo;
+
+namespace VirtualPaper.Core.Test.T_Common {
+ [TestClass]
+ public class UndoRedoUtilTests {
+ private UndoRedoUtil _util = null!;
+
+ [TestInitialize]
+ public void Setup() {
+ _util = new UndoRedoUtil(isSaved: true);
+ }
+
+ [TestCleanup]
+ public void Cleanup() {
+ _util.Dispose();
+ }
+
+ // ── 辅助 ─────────────────────────────────────────────────────
+
+ private static IUndoableCommand MakeCommand(string desc = "cmd") {
+ int execCount = 0, undoCount = 0;
+ return new TestCommand(desc,
+ () => execCount++,
+ () => undoCount++);
+ }
+
+ private sealed class TestCommand : IUndoableCommand {
+ public string Description { get; }
+ private readonly Action _exec;
+ private readonly Action _undo;
+ public int ExecCount;
+ public int UndoCount;
+
+ public TestCommand(string desc, Action exec, Action undo) {
+ Description = desc;
+ _exec = exec;
+ _undo = undo;
+ }
+
+ public Task ExecuteAsync() { ExecCount++; _exec(); return Task.CompletedTask; }
+ public Task UndoAsync() { UndoCount++; _undo(); return Task.CompletedTask; }
+ }
+
+ // ── Record ────────────────────────────────────────────────────
+
+ [TestMethod]
+ public void Record_IncreasesUndoStackSize() {
+ _util.Record(MakeCommand());
+ Assert.AreEqual(1, _util.UndoStackSize);
+
+ _util.Record(MakeCommand());
+ Assert.AreEqual(2, _util.UndoStackSize);
+ }
+
+ [TestMethod]
+ public async Task Record_ClearsRedoStack() {
+ _util.Record(MakeCommand());
+ _util.Record(MakeCommand());
+ await _util.UndoAsync();
+ Assert.IsTrue(_util.CanRedo);
+
+ _util.Record(MakeCommand());
+ Assert.IsFalse(_util.CanRedo);
+ }
+
+ [TestMethod]
+ public void Record_WhenMaxStackSize_ExceedsOldestRemoved() {
+ using var limited = new UndoRedoUtil(isSaved: true, maxStackSize: 3);
+
+ limited.Record(MakeCommand("a"));
+ limited.Record(MakeCommand("b"));
+ limited.Record(MakeCommand("c"));
+ limited.Record(MakeCommand("d"));
+
+ Assert.AreEqual(3, limited.UndoStackSize);
+ }
+
+ // ── UndoAsync ─────────────────────────────────────────────────
+
+ [TestMethod]
+ public async Task UndoAsync_WithCommand_MovesToRedoStack() {
+ _util.Record(MakeCommand());
+
+ bool result = await _util.UndoAsync();
+
+ Assert.IsTrue(result);
+ Assert.AreEqual(0, _util.UndoStackSize);
+ Assert.AreEqual(1, _util.RedoStackSize);
+ }
+
+ [TestMethod]
+ public async Task UndoAsync_EmptyStack_ReturnsFalse() {
+ bool result = await _util.UndoAsync();
+ Assert.IsFalse(result);
+ }
+
+ [TestMethod]
+ public async Task UndoAsync_CallsCommandUndo() {
+ var cmd = new TestCommand("test", () => { }, () => { });
+ _util.Record(cmd);
+
+ await _util.UndoAsync();
+
+ Assert.AreEqual(1, cmd.UndoCount);
+ Assert.AreEqual(0, cmd.ExecCount);
+ }
+
+ [TestMethod]
+ public async Task UndoAsync_MultipleCommands_UndoesLastFirst() {
+ var cmd1 = new TestCommand("first", () => { }, () => { });
+ var cmd2 = new TestCommand("second", () => { }, () => { });
+ _util.Record(cmd1);
+ _util.Record(cmd2);
+
+ await _util.UndoAsync();
+
+ Assert.AreEqual(1, cmd2.UndoCount);
+ Assert.AreEqual(0, cmd1.UndoCount);
+ }
+
+ // ── RedoAsync ─────────────────────────────────────────────────
+
+ [TestMethod]
+ public async Task RedoAsync_AfterUndo_MovesBackToUndoStack() {
+ _util.Record(MakeCommand());
+ await _util.UndoAsync();
+
+ bool result = await _util.RedoAsync();
+
+ Assert.IsTrue(result);
+ Assert.AreEqual(1, _util.UndoStackSize);
+ Assert.AreEqual(0, _util.RedoStackSize);
+ }
+
+ [TestMethod]
+ public async Task RedoAsync_EmptyRedoStack_ReturnsFalse() {
+ bool result = await _util.RedoAsync();
+ Assert.IsFalse(result);
+ }
+
+ [TestMethod]
+ public async Task RedoAsync_CallsCommandExecute() {
+ var cmd = new TestCommand("test", () => { }, () => { });
+ _util.Record(cmd);
+ await _util.UndoAsync();
+
+ await _util.RedoAsync();
+
+ // ExecuteAsync called once during Redo
+ Assert.AreEqual(1, cmd.ExecCount);
+ }
+
+ // ── Undo → Redo → Undo 往返 ──────────────────────────────────
+
+ [TestMethod]
+ public async Task UndoRedoRoundtrip_ThreeCommands() {
+ var cmd1 = new TestCommand("a", () => { }, () => { });
+ var cmd2 = new TestCommand("b", () => { }, () => { });
+ var cmd3 = new TestCommand("c", () => { }, () => { });
+ _util.Record(cmd1);
+ _util.Record(cmd2);
+ _util.Record(cmd3);
+
+ // Undo c, b, a
+ await _util.UndoAsync();
+ await _util.UndoAsync();
+ await _util.UndoAsync();
+
+ Assert.AreEqual(0, _util.UndoStackSize);
+ Assert.AreEqual(3, _util.RedoStackSize);
+
+ // Redo a, b, c
+ await _util.RedoAsync();
+ await _util.RedoAsync();
+ await _util.RedoAsync();
+
+ Assert.AreEqual(3, _util.UndoStackSize);
+ Assert.AreEqual(0, _util.RedoStackSize);
+ }
+
+ // ── IsSaved / MarkAsSaved ─────────────────────────────────────
+
+ [TestMethod]
+ public void IsSaved_InitiallyTrue_WhenConstructedAsSaved() {
+ Assert.IsTrue(_util.IsSaved);
+ }
+
+ [TestMethod]
+ public void IsSaved_FalseAfterRecord() {
+ _util.Record(MakeCommand());
+ Assert.IsFalse(_util.IsSaved);
+ }
+
+ [TestMethod]
+ public async Task IsSaved_TrueAfterMarkAsSaved() {
+ _util.Record(MakeCommand());
+ Assert.IsFalse(_util.IsSaved);
+
+ _util.MarkAsSaved();
+ Assert.IsTrue(_util.IsSaved);
+ }
+
+ [TestMethod]
+ public async Task IsSaved_FalseAfterUndoFromSavedState() {
+ _util.Record(MakeCommand());
+ _util.MarkAsSaved();
+ Assert.IsTrue(_util.IsSaved);
+
+ await _util.UndoAsync();
+ Assert.IsFalse(_util.IsSaved);
+ }
+
+ [TestMethod]
+ public async Task IsSaved_TrueAfterUndoBackToSavedCommand() {
+ _util.Record(MakeCommand("a"));
+ _util.Record(MakeCommand("b"));
+ _util.MarkAsSaved(); // saved at "b"
+
+ await _util.UndoAsync(); // undo "b"
+ Assert.IsFalse(_util.IsSaved);
+
+ await _util.RedoAsync(); // redo "b" → back to saved state
+ Assert.IsTrue(_util.IsSaved);
+ }
+
+ [TestMethod]
+ public void IsSaved_EmptyStackAfterRecord_ReturnsFalseAfterMarkSaved() {
+ // 构造时 isSaved=true,空栈 → IsSaved=true
+ Assert.IsTrue(_util.IsSaved);
+
+ _util.Record(MakeCommand());
+ _util.MarkAsSaved();
+ Assert.IsTrue(_util.IsSaved);
+
+ // Clear 不重置 _savedAtEmpty 标记,栈空时 IsSaved 取决于该标记
+ // MarkAsSaved 在非空栈时设置 _savedAtEmpty=false,Clear 后仍为 false
+ _util.Clear();
+ Assert.IsFalse(_util.IsSaved);
+ }
+
+ // ── IsSavedChanged 事件 ───────────────────────────────────────
+
+ [TestMethod]
+ public void IsSavedChanged_FiresOnRecord() {
+ bool? received = null;
+ _util.IsSavedChanged += (_, e) => received = e.IsSaved;
+
+ _util.Record(MakeCommand());
+
+ Assert.IsFalse(received);
+ }
+
+ [TestMethod]
+ public void IsSavedChanged_FiresOnMarkAsSaved() {
+ _util.Record(MakeCommand());
+ bool? received = null;
+ _util.IsSavedChanged += (_, e) => received = e.IsSaved;
+
+ _util.MarkAsSaved();
+
+ Assert.IsTrue(received);
+ }
+
+ // ── Clear ─────────────────────────────────────────────────────
+
+ [TestMethod]
+ public void Clear_EmptiesBothStacks() {
+ _util.Record(MakeCommand());
+ _util.Record(MakeCommand());
+
+ _util.Clear();
+
+ Assert.AreEqual(0, _util.UndoStackSize);
+ Assert.AreEqual(0, _util.RedoStackSize);
+ Assert.IsFalse(_util.CanUndo);
+ Assert.IsFalse(_util.CanRedo);
+ }
+
+ // ── Dispose ───────────────────────────────────────────────────
+
+ [TestMethod]
+ public void Dispose_PreventsFurtherRecord() {
+ _util.Dispose();
+ // 不应抛异常,静默忽略
+ _util.Record(MakeCommand());
+ Assert.AreEqual(0, _util.UndoStackSize);
+ }
+
+ [TestMethod]
+ public async Task Dispose_PreventsFurtherUndo() {
+ _util.Record(MakeCommand());
+ _util.Dispose();
+
+ bool result = await _util.UndoAsync();
+ Assert.IsFalse(result);
+ }
+
+ // ── CanUndo / CanRedo ─────────────────────────────────────────
+
+ [TestMethod]
+ public void CanUndo_InitiallyFalse() {
+ Assert.IsFalse(_util.CanUndo);
+ }
+
+ [TestMethod]
+ public void CanRedo_InitiallyFalse() {
+ Assert.IsFalse(_util.CanRedo);
+ }
+
+ [TestMethod]
+ public async Task CanUndo_TrueAfterRecord_FalseAfterUndo() {
+ _util.Record(MakeCommand());
+ Assert.IsTrue(_util.CanUndo);
+
+ await _util.UndoAsync();
+ Assert.IsFalse(_util.CanUndo);
+ }
+
+ [TestMethod]
+ public async Task CanRedo_TrueAfterUndo_FalseAfterRecord() {
+ _util.Record(MakeCommand());
+ await _util.UndoAsync();
+ Assert.IsTrue(_util.CanRedo);
+
+ _util.Record(MakeCommand());
+ Assert.IsFalse(_util.CanRedo);
+ }
+ }
+}
diff --git a/src/VirtualPaper.Core.Test/T_Common/UniverseCostumiseTests.cs b/src/VirtualPaper.Core.Test/T_Common/UniverseCostumiseTests.cs
new file mode 100644
index 00000000..944e73b9
--- /dev/null
+++ b/src/VirtualPaper.Core.Test/T_Common/UniverseCostumiseTests.cs
@@ -0,0 +1,108 @@
+using VirtualPaper.Common;
+
+namespace VirtualPaper.Core.Test.T_Common {
+ [TestClass]
+ public class UniverseCostumiseTests {
+ // ── ModifyPropertyValue 正常路径 ──────────────────────────────
+
+ [TestMethod]
+ public void ModifyPropertyValue_ValidNameAndValue_SetsValue() {
+ var ctx = new PictureAndGifCostumise();
+
+ ctx.ModifyPropertyValue("Saturation", 5.0);
+
+ Assert.AreEqual(5.0, ctx.Saturation.Value);
+ }
+
+ [TestMethod]
+ public void ModifyPropertyValue_BoolProperty_SetsWithoutRangeCheck() {
+ var ctx = new PictureAndGifCostumise();
+
+ ctx.ModifyPropertyValue("Parallax", true);
+
+ Assert.IsTrue(ctx.Parallax.Value);
+ }
+
+ [TestMethod]
+ public void ModifyPropertyValue_VideoSpeed_SetsValue() {
+ var ctx = new VideoCostumize();
+
+ ctx.ModifyPropertyValue("Speed", 2.0);
+
+ Assert.AreEqual(2.0, ctx.Speed.Value);
+ }
+
+ // ── ModifyPropertyValue 异常路径 ──────────────────────────────
+
+ [TestMethod]
+ public void ModifyPropertyValue_InvalidPropertyName_ThrowsArgumentException() {
+ var ctx = new PictureAndGifCostumise();
+
+ Assert.Throws(() =>
+ ctx.ModifyPropertyValue("NonExistent", 1.0));
+ }
+
+ [TestMethod]
+ public void ModifyPropertyValue_ValueOutOfRange_ThrowsArgumentOutOfRange() {
+ var ctx = new PictureAndGifCostumise();
+
+ Assert.Throws(() =>
+ ctx.ModifyPropertyValue("Saturation", 999.0)); // Max=10
+ }
+
+ [TestMethod]
+ public void ModifyPropertyValue_ValueBelowMin_ThrowsArgumentOutOfRange() {
+ var ctx = new VideoCostumize();
+
+ Assert.Throws(() =>
+ ctx.ModifyPropertyValue("Speed", 0.01)); // Min=0.25
+ }
+
+ // ── 子类属性注册 ──────────────────────────────────────────────
+
+ [TestMethod]
+ public void PictureAndGifCostumise_HasScalingAndParallax() {
+ var ctx = new PictureAndGifCostumise();
+
+ ctx.ModifyPropertyValue("Scaling", 2);
+ Assert.AreEqual(2, ctx.Scaling.Value);
+
+ ctx.ModifyPropertyValue("Parallax", true);
+ Assert.IsTrue(ctx.Parallax.Value);
+ }
+
+ [TestMethod]
+ public void VideoCostumize_HasSpeedVolumeScalingParallax() {
+ var ctx = new VideoCostumize();
+
+ ctx.ModifyPropertyValue("Speed", 1.5);
+ ctx.ModifyPropertyValue("Volume", 0.5);
+ ctx.ModifyPropertyValue("Scaling", 1);
+ ctx.ModifyPropertyValue("Parallax", true);
+
+ Assert.AreEqual(1.5, ctx.Speed.Value);
+ Assert.AreEqual(0.5, ctx.Volume.Value);
+ Assert.AreEqual(1, ctx.Scaling.Value);
+ Assert.IsTrue(ctx.Parallax.Value);
+ }
+
+ [TestMethod]
+ public void Picture3DCostumise_HasScalingAndParallax() {
+ var ctx = new Picture3DCostumize();
+
+ ctx.ModifyPropertyValue("Scaling", 3);
+ Assert.AreEqual(3, ctx.Scaling.Value);
+ }
+
+ [TestMethod]
+ public void WebCostumize_OnlyBaseProperties() {
+ var ctx = new WebCostumize();
+
+ ctx.ModifyPropertyValue("Saturation", 2.0);
+ Assert.AreEqual(2.0, ctx.Saturation.Value);
+
+ Assert.Throws(() =>
+ ctx.ModifyPropertyValue("Scaling", 1)); // Web 无 Scaling
+ }
+ }
+}
diff --git a/src/VirtualPaper.DraftPanel/Views/ConfigSpace.xaml.cs b/src/VirtualPaper.DraftPanel/Views/ConfigSpace.xaml.cs
index b1246ae1..a5bd8cd4 100644
--- a/src/VirtualPaper.DraftPanel/Views/ConfigSpace.xaml.cs
+++ b/src/VirtualPaper.DraftPanel/Views/ConfigSpace.xaml.cs
@@ -84,6 +84,7 @@ public void NavigateByState(DraftPanelState nextState, params NaviPayloadData[]
_viewModel.RefreshCardComponentData();
};
_viewModel._cardComponent = cardComponent;
+ cardComponent.UpdateCardComponentUI();
}
}
});
diff --git a/src/VirtualPaper.FuntionTest/MLTest/MLTest_DepthImage.cs b/src/VirtualPaper.FuntionTest/MLTest/MLTest_DepthImage.cs
deleted file mode 100644
index 98bc2bd3..00000000
--- a/src/VirtualPaper.FuntionTest/MLTest/MLTest_DepthImage.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-using VirtualPaper.ML.DepthEstimate;
-
-namespace VirtualPaper.FuntionTest.MLTest {
- internal class MLTest_DepthImage {
- public void GetDepthImage() {
- MiDaS.LoadModel("D:\\Virtuals\\VirtualPaper\\src\\SourceCode\\VirtualPaper.ML\\Models\\model-small.onnx");
- var v0 = MiDaS.Run("C:\\Users\\PaperHammer\\Desktop\\img29.jpg");
- MiDaS.SaveDepthMap(v0.Depth, v0.Width, v0.Height, v0.OriginalWidth, v0.OriginalHeight, "C:\\Users\\PaperHammer\\Desktop\\_img29.jpg");
- }
- }
-}
diff --git a/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/MLTest_StyleTransfer.cs b/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/MLTest_StyleTransfer.cs
deleted file mode 100644
index 3ca4fa88..00000000
--- a/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/MLTest_StyleTransfer.cs
+++ /dev/null
@@ -1,97 +0,0 @@
-using System.Diagnostics;
-using System.IO;
-using OpenCvSharp;
-using VirtualPaper.ML.StyleTransfer;
-using VirtualPaper.ML.SuperResolution;
-using Size = OpenCvSharp.Size;
-
-namespace VirtualPaper.FuntionTest.MLTest.T_StyleTransfer {
- [TestClass]
- public class MLTest_StyleTransfer {
- ///
- /// 完整工作流测试:获取原图尺寸 -> 风格迁移(降维) -> 超分还原尺寸
- ///
- [TestMethod]
- public void RunPipelineTest() {
- // 1. 根据你的目录结构,构建基础路径
- string baseDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "MLTest", "T_StyleTransfer", "data");
- string contentDir = Path.Combine(baseDir, "content");
- string styleDir = Path.Combine(baseDir, "style");
- string outputDir = Path.Combine(baseDir, "output");
-
- // 确保目录结构存在,如果不存在则自动创建
- if (!Directory.Exists(contentDir)) Directory.CreateDirectory(contentDir);
- if (!Directory.Exists(styleDir)) Directory.CreateDirectory(styleDir);
- if (!Directory.Exists(outputDir)) Directory.CreateDirectory(outputDir);
-
- // 2. 定义具体的测试文件路径 (假设你的测试图叫 content.jpg 和 style.jpg)
- string contentPath = Path.Combine(contentDir, "img30.jpg");
- string stylePath = Path.Combine(styleDir, "impronte_d_artista.jpg");
-
- // 临时输出与最终输出存放在 output 文件夹中
- string tempAdaInOutput = Path.Combine(outputDir, "temp_adain.jpg");
- string finalOutputPath = Path.Combine(outputDir, "final_result.jpg");
-
- // 3. 检查测试文件是否就绪
- if (!File.Exists(contentPath) || !File.Exists(stylePath)) {
- Debug.WriteLine($"[缺少测试文件]");
- Debug.WriteLine($"请确保你已经放入了以下两张测试图片:");
- Debug.WriteLine($"内容图: {contentPath}");
- Debug.WriteLine($"风格图: {stylePath}");
- return;
- }
-
- Debug.WriteLine("========== 开始 AI 风格迁移混合流水线测试 ==========");
- Stopwatch totalTimer = Stopwatch.StartNew();
-
- // [步骤 1] 获取内容图真实尺寸 (关键步骤)
- Size originalSize;
- using (var mat = Cv2.ImRead(contentPath, ImreadModes.Color)) {
- if (mat.Empty()) throw new Exception("无法读取内容图!请检查图片是否损坏。");
- originalSize = new Size(mat.Width, mat.Height);
- Debug.WriteLine($"[1/4] 原图尺寸读取成功: {originalSize.Width}x{originalSize.Height}");
- }
-
- // [步骤 2] 执行风格迁移
- Debug.WriteLine("[2/4] 正在执行风格迁移 (AdaIn 降维处理)...");
- Stopwatch adainTimer = Stopwatch.StartNew();
- using (var adain = new AdaIn()) {
- // contentSize = 512, 降低推理压力并防止风格笔触细碎
- adain.TransferStyle(
- contentImagePath: contentPath,
- styleImagePath: stylePath,
- outputImagePath: tempAdaInOutput, // 临时小图保存到 output 目录
- alpha: 1.0f,
- contentSize: 512,
- styleSize: 512
- );
- }
- adainTimer.Stop();
- Debug.WriteLine($" -> AdaIn 耗时: {adainTimer.ElapsedMilliseconds} ms");
-
- // [步骤 3] 执行超分模型并精确还原至原图大小
- Debug.WriteLine($"[3/4] 正在执行超分辨率放大 (恢复至 {originalSize.Width}x{originalSize.Height})...");
- Stopwatch srTimer = Stopwatch.StartNew();
- using (var esrgan = new Realesrgan()) {
- esrgan.Upscale(
- inputImagePath: tempAdaInOutput,
- outputImagePath: finalOutputPath, // 最终高清图保存到 output 目录
- exactTargetSize: originalSize // 将原图 Size 传入,保证最终输出严丝合缝
- );
- }
- srTimer.Stop();
- Debug.WriteLine($" -> Real-ESRGAN 耗时: {srTimer.ElapsedMilliseconds} ms");
-
- // [步骤 4] 清理临时小图
- Debug.WriteLine("[4/4] 清理临时文件...");
- if (File.Exists(tempAdaInOutput)) {
- File.Delete(tempAdaInOutput);
- }
-
- totalTimer.Stop();
- Debug.WriteLine($"\n✅ [测试成功] 完整工作流结束!");
- Debug.WriteLine($"⏱️ 总耗时: {totalTimer.ElapsedMilliseconds} ms");
- Debug.WriteLine($"📁 请查看最终结果: {finalOutputPath}");
- }
- }
-}
\ No newline at end of file
diff --git a/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/content/chicago.jpg b/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/content/chicago.jpg
deleted file mode 100644
index 0fcc2f77..00000000
Binary files a/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/content/chicago.jpg and /dev/null differ
diff --git a/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/content/cornell.jpg b/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/content/cornell.jpg
deleted file mode 100644
index 2dd270fa..00000000
Binary files a/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/content/cornell.jpg and /dev/null differ
diff --git a/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/content/flowers.jpg b/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/content/flowers.jpg
deleted file mode 100644
index d449e530..00000000
Binary files a/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/content/flowers.jpg and /dev/null differ
diff --git a/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/content/golden_gate.jpg b/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/content/golden_gate.jpg
deleted file mode 100644
index 4b5f2035..00000000
Binary files a/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/content/golden_gate.jpg and /dev/null differ
diff --git a/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/content/img0_1920x1200.jpg b/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/content/img0_1920x1200.jpg
deleted file mode 100644
index aec76709..00000000
Binary files a/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/content/img0_1920x1200.jpg and /dev/null differ
diff --git a/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/content/img30.jpg b/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/content/img30.jpg
deleted file mode 100644
index 3189e42f..00000000
Binary files a/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/content/img30.jpg and /dev/null differ
diff --git a/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/content/modern.jpg b/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/content/modern.jpg
deleted file mode 100644
index 088e329c..00000000
Binary files a/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/content/modern.jpg and /dev/null differ
diff --git a/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/content/newyork.jpg b/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/content/newyork.jpg
deleted file mode 100644
index 77c1af00..00000000
Binary files a/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/content/newyork.jpg and /dev/null differ
diff --git a/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/content/sailboat.jpg b/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/content/sailboat.jpg
deleted file mode 100644
index 2ada328a..00000000
Binary files a/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/content/sailboat.jpg and /dev/null differ
diff --git a/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/style/asheville.jpg b/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/style/asheville.jpg
deleted file mode 100644
index 94df8885..00000000
Binary files a/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/style/asheville.jpg and /dev/null differ
diff --git a/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/style/brushstrokes.jpg b/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/style/brushstrokes.jpg
deleted file mode 100644
index 564d825c..00000000
Binary files a/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/style/brushstrokes.jpg and /dev/null differ
diff --git a/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/style/contrast_of_forms.jpg b/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/style/contrast_of_forms.jpg
deleted file mode 100644
index 1e8dd57f..00000000
Binary files a/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/style/contrast_of_forms.jpg and /dev/null differ
diff --git a/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/style/img29.jpg b/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/style/img29.jpg
deleted file mode 100644
index 8bb6d86a..00000000
Binary files a/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/style/img29.jpg and /dev/null differ
diff --git a/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/style/impronte_d_artista.jpg b/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/style/impronte_d_artista.jpg
deleted file mode 100644
index 9739af1e..00000000
Binary files a/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/style/impronte_d_artista.jpg and /dev/null differ
diff --git a/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/style/la_muse.jpg b/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/style/la_muse.jpg
deleted file mode 100644
index 3120a012..00000000
Binary files a/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/style/la_muse.jpg and /dev/null differ
diff --git a/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/style/mondrian.jpg b/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/style/mondrian.jpg
deleted file mode 100644
index 57ecbc34..00000000
Binary files a/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/style/mondrian.jpg and /dev/null differ
diff --git a/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/style/picasso_seated_nude_hr.jpg b/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/style/picasso_seated_nude_hr.jpg
deleted file mode 100644
index bdf44888..00000000
Binary files a/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/style/picasso_seated_nude_hr.jpg and /dev/null differ
diff --git a/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/style/scene_de_rue.jpg b/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/style/scene_de_rue.jpg
deleted file mode 100644
index a627dffd..00000000
Binary files a/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/style/scene_de_rue.jpg and /dev/null differ
diff --git a/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/style/the_resevoir_at_poitiers.jpg b/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/style/the_resevoir_at_poitiers.jpg
deleted file mode 100644
index 6f0534c3..00000000
Binary files a/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/style/the_resevoir_at_poitiers.jpg and /dev/null differ
diff --git a/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/style/trial.jpg b/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/style/trial.jpg
deleted file mode 100644
index 59d43dea..00000000
Binary files a/src/VirtualPaper.FuntionTest/MLTest/T_StyleTransfer/data/style/trial.jpg and /dev/null differ
diff --git a/src/VirtualPaper.FuntionTest/MLTest/T_SuperResolution/MLTest_SuperResolution.cs b/src/VirtualPaper.FuntionTest/MLTest/T_SuperResolution/MLTest_SuperResolution.cs
deleted file mode 100644
index 00d4c80a..00000000
--- a/src/VirtualPaper.FuntionTest/MLTest/T_SuperResolution/MLTest_SuperResolution.cs
+++ /dev/null
@@ -1,41 +0,0 @@
-using System.Diagnostics;
-using System.IO;
-using VirtualPaper.ML.SuperResolution;
-using Size = OpenCvSharp.Size;
-
-namespace VirtualPaper.FuntionTest.MLTest.T_SuperResolution {
- internal class MLTest_SuperResolution {
-
- ///
- /// 独立测试超分辨率模型能力
- ///
- public static void RunStandaloneTest() {
- string testDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "TestData");
- if (!Directory.Exists(testDir)) Directory.CreateDirectory(testDir);
-
- string inputPath = Path.Combine(testDir, "low_res_test.jpg");
- string outputPath = Path.Combine(testDir, "high_res_test.jpg");
-
- if (!File.Exists(inputPath)) {
- Console.WriteLine($"[缺少测试文件] 请在以下目录放入用于超分测试的低清图片 'low_res_test.jpg':\n{testDir}");
- return;
- }
-
- Console.WriteLine("========== 开始 Real-ESRGAN 独立测试 ==========");
-
- // 假设我们测试想将这张图片超分还原到标准的 1920x1080 尺寸
- Size targetSize = new Size(1920, 1080);
-
- Stopwatch sw = Stopwatch.StartNew();
- using (var esrgan = new Realesrgan()) {
- Console.WriteLine($"正在超分并缩放至确切尺寸: {targetSize.Width}x{targetSize.Height}");
- esrgan.Upscale(inputPath, outputPath, targetSize);
- }
- sw.Stop();
-
- Console.WriteLine($"✅ [测试成功] 独立超分结束!");
- Console.WriteLine($"⏱️ 耗时: {sw.ElapsedMilliseconds} ms");
- Console.WriteLine($"📁 查看结果: {outputPath}");
- }
- }
-}
\ No newline at end of file
diff --git a/src/VirtualPaper.FuntionTest/Program.cs b/src/VirtualPaper.FuntionTest/Program.cs
deleted file mode 100644
index 2288e275..00000000
--- a/src/VirtualPaper.FuntionTest/Program.cs
+++ /dev/null
@@ -1,185 +0,0 @@
-using System.Diagnostics;
-using System.IO;
-using System.Reflection.Metadata;
-using System.Text;
-using System.Text.Json;
-using System.Threading;
-using System.Xml;
-using VirtualPaper.Common.Extensions;
-using VirtualPaper.Common.Utils.IPC;
-using VirtualPaper.Common.Utils.PInvoke;
-using VirtualPaper.FuntionTest.MLTest;
-using VirtualPaper.FuntionTest.ScrSaverTest;
-using VirtualPaper.FuntionTest.ShaderTest;
-
-namespace VirtualPaper.FuuntionTest
-{
- internal class Program
- {
- public static Process? Proc { get; set; }
- public static nint Handle { get; private set; }
- public static nint InputHandle { get; private set; }
- public static bool IsExited { get; private set; }
- public static bool IsLoaded { get; private set; } = false;
-
- static async Task Main(string[] args)
- {
- //ShaderTest_Complier.RunTest();
-
- //_optionsIpcMsg.Converters.Add(new IpcMessageConverter());
-
- //string loadActionJson = "{\"action\":\"--load\",\"source\":\"C:\\\\Users\\\\PaperHammer\\\\Desktop\\\\img28.jpg\",\"type\":\"picture\"}";
- ////string loadActionJsox = "{\"action\":\"_load\",\"source\":\"C:\\\\Users\\\\PaperHammer\\\\Desktop\\\\img28.jpg\",\"type\":\"picture\"}";
- //string modifyActionJson = "{\"action\":\"--modify\",\"imgVal\":{\"Saturation\":{\"Type\":\"Slider\",\"Text\":\"Saturation\",\"Value\":1.0,\"Max\":10.0,\"Min\":0.0,\"Step\":0.1},\"Hue\":{\"Type\":\"Slider\",\"Text\":\"Hue\",\"Value\":0,\"Max\":359,\"Min\":0,\"Step\":1},\"Brightness\":{\"Type\":\"Slider\",\"Text\":\"Brightness\",\"Value\":1.0,\"Max\":2.0,\"Min\":0.0,\"Step\":0.1},\"Contrast\":{\"Type\":\"Slider\",\"Text\":\"Contrast\",\"Value\":1.0,\"Max\":10.0,\"Min\":0.0,\"Step\":0.1}}}";
- ////string modifyActionJsox = "{\"action\":\"_modify\",\"imgVal\":{\"Saturation\":{\"Type\":\"Slider\",\"Text\":\"Saturation\",\"Value\":1.0,\"Max\":10.0,\"Min\":0.0,\"Step\":0.1},\"Hue\":{\"Type\":\"Slider\",\"Text\":\"Hue\",\"Value\":0,\"Max\":359,\"Min\":0,\"Step\":1},\"Brightness\":{\"Type\":\"Slider\",\"Text\":\"Brightness\",\"Value\":1.0,\"Max\":2.0,\"Min\":0.0,\"Step\":0.1},\"Contrast\":{\"Type\":\"Slider\",\"Text\":\"Contrast\",\"Value\":1.0,\"Max\":10.0,\"Min\":0.0,\"Step\":0.1}}}";
-
- //// 创建临时文件夹(如有必要)
- //string tempFolderPath = Path.Combine(Path.GetTempPath(), "VirtualPaperActions");
- //Directory.CreateDirectory(tempFolderPath);
-
- //// 写入JSON到临时文件
- //string loadActionFilePath = Path.Combine(tempFolderPath, "load.json");
- //File.WriteAllText(loadActionFilePath, loadActionJson);
-
- //string modifyActionFilePath = Path.Combine(tempFolderPath, "modify.json");
- //File.WriteAllText(modifyActionFilePath, modifyActionJson);
-
- //MLTest_DepthImage mLTest_DepthImage = new();
- //mLTest_DepthImage.GetDepthImage();
-
- string filePath = "C:\\Users\\PaperHammer\\AppData\\Local\\VirtualPaper\\Library\\wallpapers\\3tezpyj1.few\\3tezpyj1.few.jpg";
- string customizePath = "C:\\Users\\PaperHammer\\AppData\\Local\\VirtualPaper\\Library\\wallpapers\\3tezpyj1.few\\1\\wpEffectFilePathUsing.json";
-
- DirectoryInfo path = new(AppDomain.CurrentDomain.BaseDirectory);
- string workingDir = path.Parent.Parent.Parent.Parent.FullName;
- workingDir = Path.Combine(workingDir, "VirtualPaper", "Plugins", "PlayerWeb");
-
- StringBuilder cmdArgs = new();
- cmdArgs.Append(" --working-dir " + "\"" + workingDir + "\"");
- cmdArgs.Append(" --file-path " + "\"" + filePath + "\"");
- cmdArgs.Append(" --wallpaper-type " + "picture");
- cmdArgs.Append(" --effect-file-path " + "\"" + customizePath + "\"");
-
- ProcessStartInfo start = new() {
- FileName = Path.Combine(workingDir, "VirtualPaper.PlayerWeb.exe"),
- RedirectStandardInput = true,
- RedirectStandardOutput = true,
- RedirectStandardError = false,
- UseShellExecute = false,
- WorkingDirectory = workingDir,
- Arguments = cmdArgs.ToString(),
- };
-
- Process _process = new() {
- EnableRaisingEvents = true,
- StartInfo = start,
- };
-
- await Console.Out.WriteLineAsync(start.Arguments);
-
- //Proc = MainTest_ScrSaver.InitScr(
- // "C:\\Users\\PaperHammer\\Desktop\\img29.jpg",
- // "FImage",
- // "Bubble");
-
- //await ShowAsync();
-
- Application.Run();
- }
-
- static async Task ShowAsync()
- {
- try
- {
- Proc.Exited += Proc_Exited;
- Proc.OutputDataReceived += Proc_OutputDataReceived;
- Proc.Start();
- Proc.BeginOutputReadLine();
-
- await _tcsProcessWait.Task;
- if (_tcsProcessWait.Task.Result is not null)
- throw _tcsProcessWait.Task.Result;
-
- return true;
- }
- catch (Exception)
- {
- Terminate();
-
- return false;
- }
- }
-
- private static void Proc_OutputDataReceived(object sender, DataReceivedEventArgs e)
- {
- //When the redirected stream is closed, a null line is sent to the event handler.
- if (!string.IsNullOrEmpty(e.Data))
- {
- if (!_isInitialized || !IsLoaded)
- {
- IpcMessage obj;
- try
- {
- obj = JsonSerializer.Deserialize(e.Data, IpcMessageContext.Default.IpcMessage) ?? throw new("null msg recieved");
- }
- catch (Exception)
- {
- return;
- }
-
- if (obj.Type == MessageType.msg_hwnd)
- {
- Exception? error = null;
- try
- {
- var handle = new IntPtr(((VirtualPaperMessageHwnd)obj).Hwnd);
-
- var chrome_WidgetWin_0 = Native.FindWindowEx(handle, IntPtr.Zero, "Chrome_WidgetWin_0", null);
- if (!chrome_WidgetWin_0.Equals(IntPtr.Zero))
- {
- InputHandle = Native.FindWindowEx(chrome_WidgetWin_0, IntPtr.Zero, "Chrome_WidgetWin_1", null);
- }
- Handle = Proc.GetProcessWindow(true);
-
- if (IntPtr.Equals(Handle, IntPtr.Zero) || IntPtr.Equals(InputHandle, IntPtr.Zero))
- {
- throw new Exception("Browser input/window handle NULL.");
- }
- }
- catch (Exception ie)
- {
- error = ie;
- }
- finally
- {
- _isInitialized = true;
- _tcsProcessWait.TrySetResult(error);
- }
- }
- else if (obj.Type == MessageType.msg_wploaded)
- {
- IsLoaded = true;
- }
- }
- }
- }
-
- private static void Terminate()
- {
- try
- {
- Proc?.Kill();
- }
- catch { }
- }
-
- private static void Proc_Exited(object? sender, EventArgs e)
- {
- Proc?.Dispose();
- }
-
- private static readonly TaskCompletionSource _tcsProcessWait = new();
- private static bool _isInitialized;
- //private static readonly JsonSerializerOptions _optionsIpcMsg = new();
- }
-}
diff --git a/src/VirtualPaper.FuntionTest/ScrSaverTest/MainTest_ScrSaver.cs b/src/VirtualPaper.FuntionTest/ScrSaverTest/MainTest_ScrSaver.cs
deleted file mode 100644
index a2c55e17..00000000
--- a/src/VirtualPaper.FuntionTest/ScrSaverTest/MainTest_ScrSaver.cs
+++ /dev/null
@@ -1,34 +0,0 @@
-using System.Diagnostics;
-using System.IO;
-using System.Text;
-
-namespace VirtualPaper.FuntionTest.ScrSaverTest {
- internal class MainTest_ScrSaver {
- public static Process InitScr(string filePath, string type, string effect) {
- StringBuilder cmdArgs = new();
- cmdArgs.Append($" --file-path {filePath}");
- cmdArgs.Append($" --wallpaper-type {type}");
- cmdArgs.Append($" --effect {effect}");
-
- ProcessStartInfo start = new() {
- FileName = _fileName,
- RedirectStandardInput = true,
- RedirectStandardOutput = true,
- RedirectStandardError = true,
- UseShellExecute = false,
- WorkingDirectory = _workingDir,
- Arguments = cmdArgs.ToString(),
- };
-
- Process _process = new() {
- EnableRaisingEvents = true,
- StartInfo = start,
- };
-
- return _process;
- }
-
- private readonly static string _workingDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Plugins", "ScrSaver");
- private readonly static string _fileName = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Plugins", "ScrSaver", "VirtualPaper.ScreenSaver.exe");
- }
-}
diff --git a/src/VirtualPaper.FuntionTest/ShaderTest/ShaderTest_Complier.cs b/src/VirtualPaper.FuntionTest/ShaderTest/ShaderTest_Complier.cs
deleted file mode 100644
index 20481c32..00000000
--- a/src/VirtualPaper.FuntionTest/ShaderTest/ShaderTest_Complier.cs
+++ /dev/null
@@ -1,91 +0,0 @@
-using System.Diagnostics;
-using System.IO;
-using System.Text;
-
-namespace VirtualPaper.FuntionTest.ShaderTest
-{
- public class ShaderTest_Complier
- {
- ///
- /// 编译指定 HLSL 文件为 .cso。
- ///
- /// HLSL 文件完整路径
- /// 入口函数(默认 main)
- /// 着色器模型(默认 ps_4_0)
- /// dxc.exe 路径,留空表示使用系统 PATH 中的
- /// 编译输出的 .cso 文件路径
- ///
- ///
- public static string Compile(
- string hlslPath,
- string entryPoint = "main",
- string profile = "ps_4_0",
- string? dxcPath = null) {
- if (!File.Exists(hlslPath))
- throw new FileNotFoundException("HLSL 文件不存在", hlslPath);
-
- dxcPath ??= "dxc.exe";
- string outputPath = Path.ChangeExtension(hlslPath, ".cso");
-
- var psi = new ProcessStartInfo {
- FileName = dxcPath,
- Arguments = $"-T {profile} -E {entryPoint} -Fo \"{outputPath}\" \"{hlslPath}\"",
- RedirectStandardOutput = true,
- RedirectStandardError = true,
- UseShellExecute = false,
- CreateNoWindow = true,
- StandardOutputEncoding = Encoding.UTF8,
- StandardErrorEncoding = Encoding.UTF8
- };
-
- var process = Process.Start(psi)
- ?? throw new InvalidOperationException("无法启动 dxc 编译进程。");
-
- string stdOut = process.StandardOutput.ReadToEnd();
- string stdErr = process.StandardError.ReadToEnd();
-
- process.WaitForExit();
-
- if (process.ExitCode != 0 || !File.Exists(outputPath)) {
- throw new InvalidOperationException(
- $"Shader 编译失败:\n" +
- $"File: {Path.GetFileName(hlslPath)}\n" +
- $"ExitCode: {process.ExitCode}\n" +
- $"Error:\n{stdErr}\n" +
- $"Output:\n{stdOut}"
- );
- }
-
- return outputPath;
- }
-
- ///
- /// 编译并直接读取为字节数组。
- ///
- public static byte[] CompileToBytes(
- string hlslPath,
- string entryPoint = "main",
- string profile = "ps_4_0",
- string? dxcPath = null) {
- string outputPath = Compile(hlslPath, entryPoint, profile, dxcPath);
- return File.ReadAllBytes(outputPath);
- }
-
- ///
- /// 简单单元测试运行(可直接执行)
- ///
- public static void RunTest() {
- string shaderPath = "D:\\Virtuals\\VirtualPaper\\src\\VirtualPaper\\bin\\Debug\\net8.0-windows10.0.19041.0\\Plugins\\Shader\\Shaders\\AlphaFadeErase.hlsl";
-
- Console.WriteLine($"[INFO] Compiling shader: {shaderPath}");
-
- try {
- string output = Compile(shaderPath);
- Console.WriteLine($"✅ 编译成功: {output}");
- }
- catch (Exception ex) {
- Console.WriteLine($"❌ 编译失败: {ex.Message}");
- }
- }
- }
-}
diff --git a/src/VirtualPaper.FuntionTest/VirtualPaper.FuntionTest.csproj b/src/VirtualPaper.FuntionTest/VirtualPaper.FuntionTest.csproj
deleted file mode 100644
index 81315ac0..00000000
--- a/src/VirtualPaper.FuntionTest/VirtualPaper.FuntionTest.csproj
+++ /dev/null
@@ -1,92 +0,0 @@
-
-
-
- WinExe
- net8.0-windows10.0.19041.0
- enable
- enable
- 10.0.19041.0
- True
- True
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Always
-
-
- Always
-
-
- Always
-
-
- Always
-
-
- Always
-
-
- Always
-
-
- Always
-
-
- Always
-
-
- Always
-
-
- Always
-
-
- Always
-
-
- Always
-
-
- Always
-
-
- Always
-
-
- Always
-
-
- Always
-
-
- Always
-
-
- Always
-
-
- Always
-
-
- Always
-
-
-
-
diff --git a/src/VirtualPaper.FuntionTest/WebPlayer/time_simulate.js b/src/VirtualPaper.FuntionTest/WebPlayer/time_simulate.js
deleted file mode 100644
index 027c23db..00000000
--- a/src/VirtualPaper.FuntionTest/WebPlayer/time_simulate.js
+++ /dev/null
@@ -1,64 +0,0 @@
-// ===== 模拟配置 =====
-const SIM_STEP_MS = 60 * 1000;
-const TICK_INTERVAL_MS = 50; // 现实每50ms = 模拟1分钟
-const SIM_DURATION_DAYS = 2;
-
-// ===== Mock Date.now =====
-let simNow = new Date();
-simNow.setHours(0, 0, 0, 0);
-let simTimeMs = simNow.getTime();
-
-const _realDateNow = Date.now;
-Date.now = () => simTimeMs;
-
-// ===== 注入配置 =====
-const config = {
- enabled: true,
- sunrise: "06:23",
- sunset: "19:00",
- transitionMinutes: 30,
- phases: {
- night: { brightness: -0.3, hue: 220, saturate: -0.2 },
- dawn: { brightness: 0.1, hue: 30, saturate: 0.3 },
- day: { brightness: 0.0, hue: 0, saturate: 0.0 },
- dusk: { brightness: -0.1, hue: 20, saturate: 0.2 }
- }
-};
-tpConfig = config;
-
-// ===== 模拟循环 =====
-const totalSteps = SIM_DURATION_DAYS * 24 * 60;
-let step = 0;
-
-function simStep() {
- if (step >= totalSteps) {
- Date.now = _realDateNow;
- console.log("===== 模拟结束 =====");
- return;
- }
-
- simTimeMs += SIM_STEP_MS;
- step++;
-
- tickTimePerception();
-
- const d = new Date(simTimeMs);
- const day = Math.floor(step / (24 * 60)) + 1;
- const hh = d.getHours().toString().padStart(2, '0');
- const mm = d.getMinutes().toString().padStart(2, '0');
-
- // 每整点打印一次,减少刷屏;其余时间只在相位变化时打印
- if (mm === '00') {
- console.log(
- `[Day${day} ${hh}:${mm}]`,
- `brightness=${tpBrightnessOffset.toFixed(3)}`,
- `hue=${tpHueOffset.toFixed(1).padStart(6)}`,
- `saturate=${tpSaturateOffset.toFixed(3)}`
- );
- }
-
- setTimeout(simStep, TICK_INTERVAL_MS);
-}
-
-console.log("===== 开始模拟(每50ms = 模拟1分钟)=====");
-simStep();
\ No newline at end of file
diff --git a/src/VirtualPaper.Models/Cores/WpBasicData.cs b/src/VirtualPaper.Models/Cores/WpBasicData.cs
index bf0c0fef..5eea7abd 100644
--- a/src/VirtualPaper.Models/Cores/WpBasicData.cs
+++ b/src/VirtualPaper.Models/Cores/WpBasicData.cs
@@ -123,10 +123,7 @@ public void Read(string filePath) {
}
public void Save() {
- JsonSaver.Save(
- Path.Combine(this.FolderPath, Constants.Field.WpBasicDataFileName),
- this,
- WpBasicDataContext.Default);
+ SaveAsync().GetAwaiter().GetResult();
}
public async Task SaveAsync() {
@@ -134,6 +131,21 @@ await JsonSaver.SaveAsync(
Path.Combine(this.FolderPath, Constants.Field.WpBasicDataFileName),
this,
WpBasicDataContext.Default);
+
+ await SaveProjectJsonAsync();
+ }
+
+ private async Task SaveProjectJsonAsync() {
+ if (this.FType != FileType.FWebZip) return;
+
+ string projectJsonPath = Path.Combine(this.FolderPath, "project.json");
+ if (!File.Exists(projectJsonPath)) return;
+
+ var project = await JsonSaver.LoadAsync(projectJsonPath, WpWebProjectDataContext.Default);
+ project.Title = this.Title;
+ project.Desc = this.Desc;
+ project.Tags = this.Tags;
+ await JsonSaver.SaveAsync(projectJsonPath, project, WpWebProjectDataContext.Default);
}
public async Task MoveToAsync(string targetFolderPath) {
diff --git a/src/VirtualPaper/Models/WpWebProjectData.cs b/src/VirtualPaper.Models/Cores/WpWebProjectData.cs
similarity index 97%
rename from src/VirtualPaper/Models/WpWebProjectData.cs
rename to src/VirtualPaper.Models/Cores/WpWebProjectData.cs
index 4eccba83..8bf6c5de 100644
--- a/src/VirtualPaper/Models/WpWebProjectData.cs
+++ b/src/VirtualPaper.Models/Cores/WpWebProjectData.cs
@@ -1,6 +1,6 @@
using System.Text.Json.Serialization;
-namespace VirtualPaper.Models {
+namespace VirtualPaper.Models.Cores {
[JsonSerializable(typeof(WpWebProjectData))]
public partial class WpWebProjectDataContext : JsonSerializerContext { }
diff --git a/src/VirtualPaper.ReleaseBuildData.Test/MSTestSettings.cs b/src/VirtualPaper.ReleaseBuildData.Test/MSTestSettings.cs
new file mode 100644
index 00000000..aaf278c8
--- /dev/null
+++ b/src/VirtualPaper.ReleaseBuildData.Test/MSTestSettings.cs
@@ -0,0 +1 @@
+[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)]
diff --git a/src/VirtualPaper.ReleaseBuildData.Test/PluginSanityTests.cs b/src/VirtualPaper.ReleaseBuildData.Test/PluginSanityTests.cs
new file mode 100644
index 00000000..b937104f
--- /dev/null
+++ b/src/VirtualPaper.ReleaseBuildData.Test/PluginSanityTests.cs
@@ -0,0 +1,147 @@
+namespace VirtualPaper.ReleaseBuildData.Test {
+
+ ///
+ /// Plugin 子目录完整性检测。
+ /// 验证各 Plugin 的主 exe / dll 以及关键资源文件存在且非空。
+ ///
+ /// Plugin 目录约定(来自 VirtualPaper.csproj CopyPluginsToOutput target):
+ /// Plugins/UI/ ← VirtualPaper.UI(WinUI3 主界面)
+ /// Plugins/PlayerWeb/ ← VirtualPaper.PlayerWeb(网页壁纸播放器)
+ /// Plugins/ScrSaver/ ← VirtualPaper.ScreenSaver(屏保模块)
+ ///
+ [TestClass]
+ public class PluginSanityTests {
+
+ private static string PluginsDir => ReleaseBuildSanityTests.PluginsDir;
+
+ [TestInitialize]
+ public void SkipIfReleaseDirMissing() {
+ if (!Directory.Exists(ReleaseBuildSanityTests.ReleaseDir))
+ Assert.Inconclusive(
+ $"Release build directory not found: {ReleaseBuildSanityTests.ReleaseDir}\n" +
+ "Run a Release build first, or set the RELEASE_BIN_DIR environment variable.");
+ }
+
+ // ── UI Plugin(VirtualPaper.UI,WinUI3)──────────────────────────────
+
+ [TestMethod]
+ [TestCategory("ReleaseBuild")]
+ public void Plugin_UI_Dir_Exists() {
+ ReleaseBuildSanityTests.AssertDirExists(
+ Path.Combine(PluginsDir, "UI"), "Plugins/UI");
+ }
+
+ [TestMethod]
+ [TestCategory("ReleaseBuild")]
+ public void Plugin_UI_Exe_Exists() {
+ ReleaseBuildSanityTests.AssertFileExists(
+ Path.Combine(PluginsDir, "UI", "VirtualPaper.UI.exe"), "UI plugin exe");
+ }
+
+ [TestMethod]
+ [TestCategory("ReleaseBuild")]
+ public void Plugin_UI_NlogConfig_Exists() {
+ // UI plugin 有自己的 Nlog.config
+ ReleaseBuildSanityTests.AssertFileExists(
+ Path.Combine(PluginsDir, "UI", "Nlog.config"), "UI plugin Nlog.config");
+ }
+
+ // ── UI Plugin 内嵌 Panel dll ─────────────────────────────────────────
+
+ [TestMethod]
+ [TestCategory("ReleaseBuild")]
+ [DataRow("VirtualPaper.AppSettingsPanel.dll")]
+ [DataRow("VirtualPaper.WpSettingsPanel.dll")]
+ [DataRow("VirtualPaper.DraftPanel.dll")]
+ [DataRow("VirtualPaper.IntelligentPanel.dll")]
+ public void Plugin_UI_PanelDll_Exists(string dllName) {
+ ReleaseBuildSanityTests.AssertFileExists(
+ Path.Combine(PluginsDir, "UI", dllName), $"Panel dll [{dllName}]");
+ }
+
+ // ── UI Plugin 内嵌 ML dll(推理模块作为 dll 加载,无独立 exe)──────────
+
+ [TestMethod]
+ [TestCategory("ReleaseBuild")]
+ public void Plugin_UI_MlDll_Exists() {
+ ReleaseBuildSanityTests.AssertFileExists(
+ Path.Combine(PluginsDir, "UI", "VirtualPaper.ML.dll"), "ML dll");
+ }
+
+ // ── UI Plugin StaticImg 子目录 ────────────────────────────────────────
+
+ [TestMethod]
+ [TestCategory("ReleaseBuild")]
+ public void Plugin_UI_StaticImgDir_Exists() {
+ ReleaseBuildSanityTests.AssertDirExists(
+ Path.Combine(PluginsDir, "UI", "StaticImg"), "Plugins/UI/StaticImg");
+ }
+
+ [TestMethod]
+ [TestCategory("ReleaseBuild")]
+ public void Plugin_UI_StaticImgDll_Exists() {
+ ReleaseBuildSanityTests.AssertFileExists(
+ Path.Combine(PluginsDir, "UI", "StaticImg.dll"), "StaticImg dll");
+ }
+
+ // ── PlayerWeb Plugin ─────────────────────────────────────────────────
+
+ [TestMethod]
+ [TestCategory("ReleaseBuild")]
+ public void Plugin_PlayerWeb_Dir_Exists() {
+ ReleaseBuildSanityTests.AssertDirExists(
+ Path.Combine(PluginsDir, "PlayerWeb"), "Plugins/PlayerWeb");
+ }
+
+ [TestMethod]
+ [TestCategory("ReleaseBuild")]
+ public void Plugin_PlayerWeb_Exe_Exists() {
+ ReleaseBuildSanityTests.AssertFileExists(
+ Path.Combine(PluginsDir, "PlayerWeb", "VirtualPaper.PlayerWeb.exe"),
+ "PlayerWeb plugin exe");
+ }
+
+ // ── ScreenSaver Plugin ────────────────────────────────────────────────
+
+ [TestMethod]
+ [TestCategory("ReleaseBuild")]
+ public void Plugin_ScrSaver_Dir_Exists() {
+ ReleaseBuildSanityTests.AssertDirExists(
+ Path.Combine(PluginsDir, "ScrSaver"), "Plugins/ScrSaver");
+ }
+
+ [TestMethod]
+ [TestCategory("ReleaseBuild")]
+ public void Plugin_ScrSaver_Exe_Exists() {
+ ReleaseBuildSanityTests.AssertFileExists(
+ Path.Combine(PluginsDir, "ScrSaver", "VirtualPaper.ScreenSaver.exe"),
+ "ScreenSaver plugin exe");
+ }
+
+ // ── ML 模型目录(ai_models 由 VirtualPaper.ML.csproj CopyToVirtualPaper 复制)
+
+ [TestMethod]
+ [TestCategory("ReleaseBuild")]
+ public void Plugin_ML_StyleTransfer_ModelsDir_Exists() {
+ ReleaseBuildSanityTests.AssertDirExists(
+ Path.Combine(PluginsDir, "ML", "StyleTransfer", "ai_models"),
+ "ML/StyleTransfer/ai_models");
+ }
+
+ [TestMethod]
+ [TestCategory("ReleaseBuild")]
+ public void Plugin_ML_SuperResolution_ModelsDir_Exists() {
+ ReleaseBuildSanityTests.AssertDirExists(
+ Path.Combine(PluginsDir, "ML", "SuperResolution", "ai_models"),
+ "ML/SuperResolution/ai_models");
+ }
+
+ [TestMethod]
+ [TestCategory("ReleaseBuild")]
+ public void Plugin_ML_DepthEstimate_ModelsDir_Exists() {
+ ReleaseBuildSanityTests.AssertDirExists(
+ Path.Combine(PluginsDir, "ML", "DepthEstimate", "ai_models"),
+ "ML/DepthEstimate/ai_models");
+ }
+ }
+}
diff --git a/src/VirtualPaper.ReleaseBuildData.Test/ReleaseBuildSanityTests.cs b/src/VirtualPaper.ReleaseBuildData.Test/ReleaseBuildSanityTests.cs
new file mode 100644
index 00000000..ebb3dbd0
--- /dev/null
+++ b/src/VirtualPaper.ReleaseBuildData.Test/ReleaseBuildSanityTests.cs
@@ -0,0 +1,95 @@
+namespace VirtualPaper.ReleaseBuildData.Test {
+
+ ///
+ /// Release build 产物完整性检测。
+ /// 在 build job 构建完成后立即运行,验证主程序目录的关键文件结构。
+ ///
+ /// 运行前提:
+ /// 环境变量 RELEASE_BIN_DIR 指向 Release 产物根目录,
+ /// 例如:src/VirtualPaper/bin/Release/net8.0-windows10.0.19041.0
+ ///
+ [TestClass]
+ public class ReleaseBuildSanityTests {
+
+ // ── 目录解析 ──────────────────────────────────────────────────────────
+
+ internal static string ReleaseDir {
+ get {
+ var env = Environment.GetEnvironmentVariable("RELEASE_BIN_DIR");
+ if (!string.IsNullOrWhiteSpace(env))
+ return env;
+
+ // 本地回退:从测试程序集位置向上推算
+ // 测试 dll 在 src/VirtualPaper.ReleaseBuildData.Test/bin/{config}/net8.0/
+ // 目标在 src/VirtualPaper/bin/Release/net8.0-windows10.0.19041.0/
+ var testBin = AppContext.BaseDirectory;
+ var srcDir = Path.GetFullPath(Path.Combine(testBin, "..", "..", "..", ".."));
+ return Path.Combine(srcDir, "VirtualPaper", "bin", "Release", "net8.0-windows10.0.19041.0");
+ }
+ }
+
+ internal static string PluginsDir => Path.Combine(ReleaseDir, "Plugins");
+
+ // ── 前置检查 ──────────────────────────────────────────────────────────
+
+ ///
+ /// 若 Release 产物目录不存在,将测试标记为 Inconclusive 而非 Failed。
+ /// CI 环境中 RELEASE_BIN_DIR 已设置且目录一定存在;
+ /// 本地未做 Release build 时直接跳过,不干扰开发工作流。
+ ///
+ [TestInitialize]
+ public void SkipIfReleaseDirMissing() {
+ if (!Directory.Exists(ReleaseDir))
+ Assert.Inconclusive(
+ $"Release build directory not found: {ReleaseDir}\n" +
+ "Run a Release build first, or set the RELEASE_BIN_DIR environment variable.");
+ }
+
+ // ── 辅助方法 ──────────────────────────────────────────────────────────
+
+ /// 断言文件存在且大小 > 0。
+ internal static void AssertFileExists(string path, string? hint = null) {
+ string msg = hint != null ? $"{hint}: " : "";
+ Assert.IsTrue(File.Exists(path),
+ $"{msg}Expected file not found: {path}");
+ Assert.IsGreaterThan(0, new FileInfo(path).Length, $"{msg}File is empty (0 bytes): {path}");
+ }
+
+ /// 断言目录存在且非空。
+ internal static void AssertDirExists(string path, string? hint = null) {
+ string msg = hint != null ? $"{hint}: " : "";
+ Assert.IsTrue(Directory.Exists(path),
+ $"{msg}Expected directory not found: {path}");
+ }
+
+ // ── 1. 产物根目录可访问 ───────────────────────────────────────────────
+
+ [TestMethod]
+ [TestCategory("ReleaseBuild")]
+ public void ReleaseDir_IsAccessible() {
+ AssertDirExists(ReleaseDir, "RELEASE_BIN_DIR");
+ }
+
+ // ── 2. 主程序 ─────────────────────────────────────────────────────────
+
+ [TestMethod]
+ [TestCategory("ReleaseBuild")]
+ public void MainExe_Exists() {
+ AssertFileExists(Path.Combine(ReleaseDir, "VirtualPaper.exe"), "Main executable");
+ }
+
+ [TestMethod]
+ [TestCategory("ReleaseBuild")]
+ public void NlogConfig_Exists() {
+ AssertFileExists(Path.Combine(ReleaseDir, "Nlog.config"), "NLog config");
+ }
+
+ // ── 3. Plugins 目录结构 ───────────────────────────────────────────────
+
+ [TestMethod]
+ [TestCategory("ReleaseBuild")]
+ public void PluginsDir_Exists() {
+ AssertDirExists(PluginsDir, "Plugins root");
+ }
+ }
+}
diff --git a/src/VirtualPaper.ReleaseBuildData.Test/ResourceSanityTests.cs b/src/VirtualPaper.ReleaseBuildData.Test/ResourceSanityTests.cs
new file mode 100644
index 00000000..8a3297df
--- /dev/null
+++ b/src/VirtualPaper.ReleaseBuildData.Test/ResourceSanityTests.cs
@@ -0,0 +1,77 @@
+namespace VirtualPaper.ReleaseBuildData.Test {
+
+ ///
+ /// 语言资源与 PlayerWeb 内嵌静态资源完整性检测。
+ ///
+ [TestClass]
+ public class ResourceSanityTests {
+
+ private static string PluginsDir => ReleaseBuildSanityTests.PluginsDir;
+
+ [TestInitialize]
+ public void SkipIfReleaseDirMissing() {
+ if (!Directory.Exists(ReleaseBuildSanityTests.ReleaseDir))
+ Assert.Inconclusive(
+ $"Release build directory not found: {ReleaseBuildSanityTests.ReleaseDir}\n" +
+ "Run a Release build first, or set the RELEASE_BIN_DIR environment variable.");
+ }
+
+ // ── UI Plugin 语言目录 ────────────────────────────────────────────────
+ // VirtualPaper.UI 是主语言化入口,语言目录在 Plugins/UI/ 下
+ // 检查最核心的两个 locale,全量语言由 InnoSetup smoke test 负责
+
+ [TestMethod]
+ [TestCategory("ReleaseBuild")]
+ [DataRow("zh-CN")]
+ [DataRow("en-us")]
+ [DataRow("ja-JP")]
+ [DataRow("ko-KR")]
+ [DataRow("de-DE")]
+ [DataRow("fr-FR")]
+ public void Plugin_UI_LocaleDir_Exists(string locale) {
+ ReleaseBuildSanityTests.AssertDirExists(
+ Path.Combine(PluginsDir, "UI", locale),
+ $"UI locale dir [{locale}]");
+ }
+
+ // ── UI Plugin 资源文件 ────────────────────────────────────────────────
+
+ [TestMethod]
+ [TestCategory("ReleaseBuild")]
+ public void Plugin_UI_ResourcesPri_Exists() {
+ // resources.pri 是 WinUI3/WinAppSDK 运行必需的资源索引
+ ReleaseBuildSanityTests.AssertFileExists(
+ Path.Combine(PluginsDir, "UI", "resources.pri"),
+ "WinUI3 resources.pri");
+ }
+
+ [TestMethod]
+ [TestCategory("ReleaseBuild")]
+ public void Plugin_UI_DraftPanelConfigs_Exists() {
+ ReleaseBuildSanityTests.AssertDirExists(
+ Path.Combine(PluginsDir, "UI", "DraftPanelConfigs"),
+ "DraftPanelConfigs directory");
+ }
+
+ // ── PlayerWeb 内嵌 Web 资源 ───────────────────────────────────────────
+
+ [TestMethod]
+ [TestCategory("ReleaseBuild")]
+ public void Plugin_PlayerWeb_WebDir_Exists() {
+ // PlayerWeb 需要 PLAYER_Web 目录内的 HTML/JS 资源
+ ReleaseBuildSanityTests.AssertDirExists(
+ Path.Combine(PluginsDir, "UI", "PLAYER_Web"),
+ "PLAYER_Web directory");
+ }
+
+ // ── Shader 资源 ───────────────────────────────────────────────────────
+
+ [TestMethod]
+ [TestCategory("ReleaseBuild")]
+ public void Plugin_UI_ShadersDir_Exists() {
+ ReleaseBuildSanityTests.AssertDirExists(
+ Path.Combine(PluginsDir, "Shaders"),
+ "Shaders directory");
+ }
+ }
+}
diff --git a/src/VirtualPaper.ReleaseBuildData.Test/VirtualPaper.ReleaseBuildData.Test.csproj b/src/VirtualPaper.ReleaseBuildData.Test/VirtualPaper.ReleaseBuildData.Test.csproj
new file mode 100644
index 00000000..fb02b23f
--- /dev/null
+++ b/src/VirtualPaper.ReleaseBuildData.Test/VirtualPaper.ReleaseBuildData.Test.csproj
@@ -0,0 +1,11 @@
+
+
+
+ net8.0
+ latest
+ enable
+ enable
+ true
+
+
+
diff --git a/src/VirtualPaper.Shader.Test/ShaderCompiler_CompileTests.cs b/src/VirtualPaper.Shader.Test/ShaderCompiler_CompileTests.cs
index 69b4a1a6..95c8d8a9 100644
--- a/src/VirtualPaper.Shader.Test/ShaderCompiler_CompileTests.cs
+++ b/src/VirtualPaper.Shader.Test/ShaderCompiler_CompileTests.cs
@@ -51,7 +51,7 @@ public void Cleanup() {
public static IEnumerable