From d471aaca56e30d5074a588932b66e7eabc5434ef Mon Sep 17 00:00:00 2001 From: Nick Kovalsky Date: Sun, 12 Apr 2026 15:28:37 +0300 Subject: [PATCH 1/3] net10 --- .github/workflows/android-release.yml | 143 ++++++++++ .github/workflows/dotnet-windows.yml | 28 +- .github/workflows/ios-release.yml | 197 +++++++++++++ README.md | 2 + dev/CameraApp-Refs.sln | 39 ++- docs/github-actions-cicd.md | 120 ++++++++ global.json | 5 + .../Resources/Raw/Shaders/Camera/cartoon.sksl | 270 ++++++++++++++++++ .../Resources/Raw/Shaders/Camera/geisha.sksl | 206 +++++++++++++ .../Resources/Raw/Shaders/Camera/print.sksl | 210 ++++++++++++++ src/app/ShadersCamera.csproj | 45 ++- src/app/ViewModels/CameraViewModel.cs | 14 +- .../{CameraWithEffects.cs => AppCamera.cs} | 131 +++++---- src/app/Views/MainPageCameraFluent.Ui.cs | 68 ++--- src/app/Views/MainPageCameraFluent.cs | 94 +++--- 15 files changed, 1372 insertions(+), 200 deletions(-) create mode 100644 .github/workflows/android-release.yml create mode 100644 .github/workflows/ios-release.yml create mode 100644 docs/github-actions-cicd.md create mode 100644 global.json create mode 100644 src/app/Resources/Raw/Shaders/Camera/cartoon.sksl create mode 100644 src/app/Resources/Raw/Shaders/Camera/geisha.sksl create mode 100644 src/app/Resources/Raw/Shaders/Camera/print.sksl rename src/app/Views/Controls/{CameraWithEffects.cs => AppCamera.cs} (64%) diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml new file mode 100644 index 0000000..5b8cae9 --- /dev/null +++ b/.github/workflows/android-release.yml @@ -0,0 +1,143 @@ +name: Android Release Build + +on: + workflow_dispatch: + inputs: + package_format: + description: Android package output to build + required: true + type: choice + default: both + options: + - both + - aab + - apk + +jobs: + build-android: + runs-on: windows-latest + + env: + PROJECT_PATH: src/app/ShadersCamera.csproj + TARGET_FRAMEWORK: net10.0-android + OUTPUT_DIR: artifacts/android + MANIFEST_PATH: src/app/Platforms/Android/AndroidManifest.xml + KEYSTORE_FILE_NAME: release.keystore + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup .NET from global.json + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Install MAUI workload + shell: pwsh + run: dotnet workload install maui + + - name: Decode Android keystore + shell: pwsh + run: | + $keystorePath = Join-Path $pwd $env:KEYSTORE_FILE_NAME + [IO.File]::WriteAllBytes( + $keystorePath, + [Convert]::FromBase64String("${{ secrets.ANDROID_KEYSTORE }}") + ) + "KEYSTORE_PATH=$keystorePath" >> $env:GITHUB_ENV + + - name: Set version variables + shell: pwsh + run: | + [xml]$project = Get-Content $env:PROJECT_PATH + [xml]$manifest = Get-Content $env:MANIFEST_PATH + $androidNs = "http://schemas.android.com/apk/res/android" + + $projectNode = $project.Project.PropertyGroup | Where-Object { $_.ApplicationDisplayVersion } | Select-Object -First 1 + $displayBase = if ($projectNode -and $projectNode.ApplicationDisplayVersion) { "$($projectNode.ApplicationDisplayVersion)" } else { "1.0" } + + $manifestVersionCode = [int]$manifest.manifest.GetAttribute("versionCode", $androidNs) + $runNumber = [int]"${{ github.run_number }}" + $versionCode = $manifestVersionCode + $runNumber + $versionDisplay = "$displayBase.$runNumber" + + "VERSION_DISPLAY=$versionDisplay" >> $env:GITHUB_ENV + "VERSION_CODE=$versionCode" >> $env:GITHUB_ENV + + - name: Resolve package formats + shell: pwsh + run: | + $packageFormat = "${{ inputs.package_format }}" + "PACKAGE_FORMAT=$packageFormat" >> $env:GITHUB_ENV + + - name: Restore NuGet packages + shell: pwsh + run: dotnet restore "$env:PROJECT_PATH" + + - name: Publish signed Android packages + shell: pwsh + run: | + function Publish-AndroidPackage([string]$packageFormat) { + $publishArgs = @( + 'publish' + $env:PROJECT_PATH + '-f' + $env:TARGET_FRAMEWORK + '-c' + 'Release' + '-p:AndroidKeyStore=true' + "-p:AndroidPackageFormats=$packageFormat" + "-p:AndroidVersionCode=$env:VERSION_CODE" + "-p:ApplicationVersion=$env:VERSION_CODE" + "-p:ApplicationDisplayVersion=$env:VERSION_DISPLAY" + "-p:AndroidSigningKeyAlias=${{ secrets.ANDROID_KEY_ALIAS }}" + "-p:AndroidSigningKeyPass=${{ secrets.ANDROID_KEY_PASSWORD }}" + "-p:AndroidSigningStorePass=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" + "-p:AndroidSigningKeyStore=$env:KEYSTORE_PATH" + ) + + & dotnet @publishArgs + if ($LASTEXITCODE -ne 0) { + throw "dotnet publish failed for package format '$packageFormat'." + } + } + + if ($env:PACKAGE_FORMAT -eq 'both') { + Publish-AndroidPackage 'aab' + Publish-AndroidPackage 'apk' + } + else { + Publish-AndroidPackage $env:PACKAGE_FORMAT + } + + - name: Collect Android artifacts + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path "$env:OUTPUT_DIR" | Out-Null + $files = Get-ChildItem -Path (Split-Path $env:PROJECT_PATH -Parent) -Recurse -File | + Where-Object { + $_.Extension -in '.aab', '.apk' -and + $_.BaseName -like '*-Signed' + } + + if (-not $files) { + throw "No signed Android artifacts were produced." + } + + foreach ($file in $files) { + Copy-Item $file.FullName -Destination "$env:OUTPUT_DIR" -Force + } + + - name: Upload Android artifacts + uses: actions/upload-artifact@v4 + with: + name: android-${{ inputs.package_format }}-release + path: artifacts/android + if-no-files-found: error \ No newline at end of file diff --git a/.github/workflows/dotnet-windows.yml b/.github/workflows/dotnet-windows.yml index 3153274..9f39338 100644 --- a/.github/workflows/dotnet-windows.yml +++ b/.github/workflows/dotnet-windows.yml @@ -5,23 +5,27 @@ on: branches: [ "main" ] pull_request: branches: [ "main" ] + workflow_dispatch: jobs: build: - runs-on: windows-latest steps: - - uses: actions/checkout@v3 - - name: Setup .NET - uses: actions/setup-dotnet@v2 - with: - dotnet-version: 9.0.x - - - name: Install MAUI workload - run: dotnet workload install maui - - - name: Build - run: dotnet build src/app/ShadersCamera.csproj -c Release -f:net9.0-windows10.0.19041.0 + - uses: actions/checkout@v4 + + - name: Setup .NET from global.json + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Install MAUI workload + run: dotnet workload install maui + + - name: Restore + run: dotnet restore src/app/ShadersCamera.csproj + + - name: Build + run: dotnet build src/app/ShadersCamera.csproj -c Release -f:net10.0-windows10.0.19041.0 --no-restore diff --git a/.github/workflows/ios-release.yml b/.github/workflows/ios-release.yml new file mode 100644 index 0000000..1cb56e8 --- /dev/null +++ b/.github/workflows/ios-release.yml @@ -0,0 +1,197 @@ +name: iOS IPA Build + +on: + workflow_dispatch: + +jobs: + validate-signing: + runs-on: macos-15 + + env: + PROJECT_PATH: src/app/ShadersCamera.csproj + CERTIFICATE_PATH: ${{ github.workspace }}/ios-signing.p12 + PROFILE_PATH: ${{ github.workspace }}/ios-signing.mobileprovision + PROFILE_PLIST_PATH: ${{ github.workspace }}/ios-signing-profile.plist + KEYCHAIN_PATH: ${{ github.workspace }}/ios-signing.keychain-db + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Decode signing assets + shell: bash + run: | + echo "${{ secrets.IOS_P12_BASE64 }}" | base64 --decode > "$CERTIFICATE_PATH" + echo "${{ secrets.IOS_MOBILEPROVISION_BASE64 }}" | base64 --decode > "$PROFILE_PATH" + + - name: Validate signing materials + shell: bash + run: | + KEYCHAIN_PASSWORD="$(uuidgen)" + + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security import "$CERTIFICATE_PATH" -P "${{ secrets.IOS_P12_PASSWORD }}" -f pkcs12 -k "$KEYCHAIN_PATH" -T /usr/bin/codesign -T /usr/bin/security + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + if ! security find-identity -v -p codesigning "$KEYCHAIN_PATH" | grep -F "${{ secrets.IOS_CODESIGN_KEY }}" >/dev/null; then + echo "Configured IOS_CODESIGN_KEY was not found in the imported certificate." + exit 1 + fi + + security cms -D -i "$PROFILE_PATH" > "$PROFILE_PLIST_PATH" + + PROFILE_NAME="$(/usr/libexec/PlistBuddy -c 'Print :Name' "$PROFILE_PLIST_PATH")" + PROFILE_APP_ID="$(/usr/libexec/PlistBuddy -c 'Print :Entitlements:application-identifier' "$PROFILE_PLIST_PATH")" + APP_BUNDLE_ID="$(python3 -c 'import os, xml.etree.ElementTree as ET; root = ET.parse(os.environ["PROJECT_PATH"]).getroot(); print(next((node.text.strip() for group in root.findall("PropertyGroup") for node in [group.find("ApplicationId")] if node is not None and node.text), ""))')" + + PROFILE_BUNDLE_PATTERN="${PROFILE_APP_ID#*.}" + + if [[ "$PROFILE_BUNDLE_PATTERN" == '*' ]]; then + : + elif [[ "$PROFILE_BUNDLE_PATTERN" == *'.*' ]]; then + PROFILE_PREFIX="${PROFILE_BUNDLE_PATTERN%.*}" + if [[ "$APP_BUNDLE_ID" != "$PROFILE_PREFIX".* ]]; then + echo "Provisioning profile wildcard '$PROFILE_APP_ID' does not match bundle id '$APP_BUNDLE_ID'." + exit 1 + fi + elif [[ "$PROFILE_BUNDLE_PATTERN" != "$APP_BUNDLE_ID" ]]; then + echo "Provisioning profile app identifier '$PROFILE_APP_ID' does not match bundle id '$APP_BUNDLE_ID'." + exit 1 + fi + + echo "Validated signing identity and provisioning profile: $PROFILE_NAME" + + - name: Cleanup validation assets + if: always() + shell: bash + run: | + security delete-keychain "$KEYCHAIN_PATH" || true + rm -f "$CERTIFICATE_PATH" "$PROFILE_PATH" "$PROFILE_PLIST_PATH" + + build-ios: + needs: validate-signing + runs-on: macos-15 + + env: + PROJECT_PATH: src/app/ShadersCamera.csproj + TARGET_FRAMEWORK: net10.0-ios + OUTPUT_DIR: artifacts/ios + CERTIFICATE_PATH: ${{ github.workspace }}/ios-signing.p12 + PROFILE_PATH: ${{ github.workspace }}/ios-signing.mobileprovision + PROFILE_PLIST_PATH: ${{ github.workspace }}/ios-signing-profile.plist + KEYCHAIN_PATH: ${{ github.workspace }}/ios-signing.keychain-db + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup .NET from global.json + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Select Xcode 16.4 + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '16.4' + + - name: Show Xcode version + shell: bash + run: xcodebuild -version + + - name: Install MAUI workload + shell: bash + run: dotnet workload install maui + + - name: Decode signing assets + shell: bash + run: | + echo "${{ secrets.IOS_P12_BASE64 }}" | base64 --decode > "$CERTIFICATE_PATH" + echo "${{ secrets.IOS_MOBILEPROVISION_BASE64 }}" | base64 --decode > "$PROFILE_PATH" + + - name: Import certificate and provisioning profile + shell: bash + run: | + KEYCHAIN_PASSWORD="$(uuidgen)" + echo "KEYCHAIN_PASSWORD=$KEYCHAIN_PASSWORD" >> "$GITHUB_ENV" + + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security import "$CERTIFICATE_PATH" -P "${{ secrets.IOS_P12_PASSWORD }}" -f pkcs12 -k "$KEYCHAIN_PATH" -T /usr/bin/codesign -T /usr/bin/security + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security default-keychain -d user -s "$KEYCHAIN_PATH" + security list-keychain -d user -s "$KEYCHAIN_PATH" login.keychain-db + + mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles" + + security cms -D -i "$PROFILE_PATH" > "$PROFILE_PLIST_PATH" + + PROFILE_UUID="$(/usr/libexec/PlistBuddy -c 'Print :UUID' "$PROFILE_PLIST_PATH")" + PROFILE_NAME="$(/usr/libexec/PlistBuddy -c 'Print :Name' "$PROFILE_PLIST_PATH")" + + cp "$PROFILE_PATH" "$HOME/Library/MobileDevice/Provisioning Profiles/$PROFILE_UUID.mobileprovision" + + echo "IOS_PROFILE_UUID=$PROFILE_UUID" >> "$GITHUB_ENV" + echo "IOS_PROFILE_NAME=$PROFILE_NAME" >> "$GITHUB_ENV" + + - name: Set version variables + shell: bash + run: | + DISPLAY_BASE="$(python3 -c 'import os, xml.etree.ElementTree as ET; root = ET.parse(os.environ["PROJECT_PATH"]).getroot(); print(next((node.text.strip() for group in root.findall("PropertyGroup") for node in [group.find("ApplicationDisplayVersion")] if node is not None and node.text), "1.0"))')" + + BUILD_NUMBER="${{ github.run_number }}" + echo "VERSION_DISPLAY=$DISPLAY_BASE.$BUILD_NUMBER" >> "$GITHUB_ENV" + echo "VERSION_BUILD=$BUILD_NUMBER" >> "$GITHUB_ENV" + + - name: Restore NuGet packages + shell: bash + run: | + dotnet restore "$PROJECT_PATH" \ + -p:RuntimeIdentifier=ios-arm64 + + - name: Publish signed IPA + shell: bash + run: | + dotnet publish "$PROJECT_PATH" \ + -f "$TARGET_FRAMEWORK" \ + -c Release \ + -p:RuntimeIdentifier=ios-arm64 \ + -p:ArchiveOnBuild=true \ + -p:BuildIpa=true \ + -p:ProvisioningType=manual \ + -p:CodesignKey="${{ secrets.IOS_CODESIGN_KEY }}" \ + -p:CodesignProvision="$IOS_PROFILE_NAME" \ + -p:ApplicationDisplayVersion="$VERSION_DISPLAY" \ + -p:ApplicationVersion="$VERSION_BUILD" + + - name: Collect IPA artifact + shell: bash + run: | + mkdir -p "$OUTPUT_DIR" + PROJECT_DIR="$(dirname "$PROJECT_PATH")" + IPA_PATH="$(find "$PROJECT_DIR/bin/Release/net10.0-ios" -type f -name "*.ipa" | head -n 1)" + + if [[ -z "$IPA_PATH" ]]; then + echo "No .ipa file was produced." + exit 1 + fi + + cp "$IPA_PATH" "$OUTPUT_DIR/" + + - name: Upload iOS IPA artifact + uses: actions/upload-artifact@v4 + with: + name: ios-ipa-release + path: artifacts/ios + if-no-files-found: error + + - name: Cleanup signing assets + if: always() + shell: bash + run: | + security delete-keychain "$KEYCHAIN_PATH" || true + rm -f "$CERTIFICATE_PATH" "$PROFILE_PATH" "$PROFILE_PLIST_PATH" + rm -f "$HOME/Library/MobileDevice/Provisioning Profiles/$IOS_PROFILE_UUID.mobileprovision" || true \ No newline at end of file diff --git a/README.md b/README.md index 05144d8..19e40fb 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,8 @@ style="margin-top: 16px;" /> Contributing to repository is very welcome. Many other nifty shaders could be added, the current UI is also not something fixed. +CI/CD documentation is in `docs/github-actions-cicd.md`. + ### Credits * **App Screenshots** - created with [Hotpot](https://hotpot.ai/) diff --git a/dev/CameraApp-Refs.sln b/dev/CameraApp-Refs.sln index cec0e71..4f34d07 100644 --- a/dev/CameraApp-Refs.sln +++ b/dev/CameraApp-Refs.sln @@ -5,8 +5,6 @@ VisualStudioVersion = 18.0.11201.2 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Refs", "Refs", "{5B1CDC4F-5ED6-4662-8EC6-3DE3FF0B05BE}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DrawnUi.Maui.Camera", "..\..\DrawnUi.Maui\src\Maui\Addons\DrawnUi.Maui.Camera\DrawnUi.Maui.Camera.csproj", "{DD2D491D-7046-41D2-A00E-FE65CBADE85E}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{089100B1-113F-4E66-888A-E83F3999EAFD}" ProjectSection(SolutionItems) = preProject ..\.gitignore = ..\.gitignore @@ -15,13 +13,13 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution ..\..\ShadersCam.targets = ..\..\ShadersCam.targets EndProjectSection EndProject -Project("{D954291E-2A0B-460D-934E-DC6B0785DB48}") = "DrawnUi.Shared", "..\..\DrawnUi.Maui\src\Shared\DrawnUi.Shared.shproj", "{83974207-9636-48DD-BDB3-98EDECBB1107}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ShadersCamera", "..\src\app\ShadersCamera.csproj", "{9F71E169-6D24-B132-9826-8A6DA9FFFBC8}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DrawnUi.Maui", "..\..\DrawnUi.Maui\src\Maui\DrawnUi\DrawnUi.Maui.csproj", "{93E119B1-4378-87DF-2DD2-A818D1E6C2A2}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DrawnUi.Maui.Camera", "..\..\DrawnUi.Maui.Camera\src\Lib\DrawnUi.Maui.Camera.csproj", "{B908CF8F-86DA-57A3-E68C-E59E36316522}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ShadersCamera", "..\src\app\ShadersCamera.csproj", "{9F71E169-6D24-B132-9826-8A6DA9FFFBC8}" +Project("{D954291E-2A0B-460D-934E-DC6B0785DB48}") = "DrawnUi.Shared", "..\..\DrawnUi\src\Shared\DrawnUi.Shared.shproj", "{83974207-9636-48DD-BDB3-98EDECBB1107}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CameraTests", "..\..\DrawnUi.Maui\src\Maui\Samples\Camera\CameraTests.csproj", "{2C274321-F41E-090D-C929-79400D88D71E}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DrawnUi.Maui", "..\..\DrawnUi\src\Maui\DrawnUi\DrawnUi.Maui.csproj", "{315B4382-048D-46A6-E86A-B0684C70741B}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -29,39 +27,34 @@ Global Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {DD2D491D-7046-41D2-A00E-FE65CBADE85E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {DD2D491D-7046-41D2-A00E-FE65CBADE85E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {DD2D491D-7046-41D2-A00E-FE65CBADE85E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {DD2D491D-7046-41D2-A00E-FE65CBADE85E}.Release|Any CPU.Build.0 = Release|Any CPU - {93E119B1-4378-87DF-2DD2-A818D1E6C2A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {93E119B1-4378-87DF-2DD2-A818D1E6C2A2}.Debug|Any CPU.Build.0 = Debug|Any CPU - {93E119B1-4378-87DF-2DD2-A818D1E6C2A2}.Release|Any CPU.ActiveCfg = Release|Any CPU - {93E119B1-4378-87DF-2DD2-A818D1E6C2A2}.Release|Any CPU.Build.0 = Release|Any CPU {9F71E169-6D24-B132-9826-8A6DA9FFFBC8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9F71E169-6D24-B132-9826-8A6DA9FFFBC8}.Debug|Any CPU.Build.0 = Debug|Any CPU {9F71E169-6D24-B132-9826-8A6DA9FFFBC8}.Debug|Any CPU.Deploy.0 = Debug|Any CPU {9F71E169-6D24-B132-9826-8A6DA9FFFBC8}.Release|Any CPU.ActiveCfg = Release|Any CPU {9F71E169-6D24-B132-9826-8A6DA9FFFBC8}.Release|Any CPU.Build.0 = Release|Any CPU {9F71E169-6D24-B132-9826-8A6DA9FFFBC8}.Release|Any CPU.Deploy.0 = Release|Any CPU - {2C274321-F41E-090D-C929-79400D88D71E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2C274321-F41E-090D-C929-79400D88D71E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2C274321-F41E-090D-C929-79400D88D71E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2C274321-F41E-090D-C929-79400D88D71E}.Release|Any CPU.Build.0 = Release|Any CPU + {B908CF8F-86DA-57A3-E68C-E59E36316522}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B908CF8F-86DA-57A3-E68C-E59E36316522}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B908CF8F-86DA-57A3-E68C-E59E36316522}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B908CF8F-86DA-57A3-E68C-E59E36316522}.Release|Any CPU.Build.0 = Release|Any CPU + {315B4382-048D-46A6-E86A-B0684C70741B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {315B4382-048D-46A6-E86A-B0684C70741B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {315B4382-048D-46A6-E86A-B0684C70741B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {315B4382-048D-46A6-E86A-B0684C70741B}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution - {DD2D491D-7046-41D2-A00E-FE65CBADE85E} = {5B1CDC4F-5ED6-4662-8EC6-3DE3FF0B05BE} + {B908CF8F-86DA-57A3-E68C-E59E36316522} = {5B1CDC4F-5ED6-4662-8EC6-3DE3FF0B05BE} {83974207-9636-48DD-BDB3-98EDECBB1107} = {5B1CDC4F-5ED6-4662-8EC6-3DE3FF0B05BE} - {93E119B1-4378-87DF-2DD2-A818D1E6C2A2} = {5B1CDC4F-5ED6-4662-8EC6-3DE3FF0B05BE} - {2C274321-F41E-090D-C929-79400D88D71E} = {5B1CDC4F-5ED6-4662-8EC6-3DE3FF0B05BE} + {315B4382-048D-46A6-E86A-B0684C70741B} = {5B1CDC4F-5ED6-4662-8EC6-3DE3FF0B05BE} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {329E3D0C-A3F7-4A3E-B61C-6B2D1BD7F708} EndGlobalSection GlobalSection(SharedMSBuildProjectFiles) = preSolution - ..\..\DrawnUi.Maui\src\Shared\Shared.projitems*{83974207-9636-48dd-bdb3-98edecbb1107}*SharedItemsImports = 13 - ..\..\DrawnUi.Maui\src\Shared\Shared.projitems*{93e119b1-4378-87df-2dd2-a818d1e6c2a2}*SharedItemsImports = 5 + ..\..\DrawnUi\src\Shared\Shared.projitems*{315b4382-048d-46a6-e86a-b0684c70741b}*SharedItemsImports = 5 + ..\..\DrawnUi\src\Shared\Shared.projitems*{83974207-9636-48dd-bdb3-98edecbb1107}*SharedItemsImports = 13 EndGlobalSection EndGlobal diff --git a/docs/github-actions-cicd.md b/docs/github-actions-cicd.md new file mode 100644 index 0000000..eea1929 --- /dev/null +++ b/docs/github-actions-cicd.md @@ -0,0 +1,120 @@ +# GitHub Actions CI/CD + +This repository includes GitHub Actions workflows for MAUI build/release automation: + +* `.github/workflows/dotnet-windows.yml` + * Triggers: push and pull request on `main`, plus manual dispatch + * Purpose: restore and build Windows target (`net10.0-windows10.0.19041.0`) + +* `.github/workflows/android-release.yml` + * Trigger: manual (`workflow_dispatch`) + * Input: `package_format` (`both`, `aab`, `apk`) + * Purpose: signed Android release publish and artifact upload + * Artifacts: signed outputs only (`*-Signed.aab`, `*-Signed.apk`) + +* `.github/workflows/ios-release.yml` + * Trigger: manual (`workflow_dispatch`) + * Purpose: validate iOS signing, build signed IPA, and upload artifact + * Artifacts: built `.ipa` + +All repository secrets (single list): + +```dos +ANDROID_KEYSTORE= +ANDROID_KEY_ALIAS= +ANDROID_KEY_PASSWORD= +ANDROID_KEYSTORE_PASSWORD= + +IOS_CODESIGN_KEY= +IOS_P12_BASE64= +IOS_P12_PASSWORD= +IOS_MOBILEPROVISION_BASE64= + +APPSTORE_USERNAME= +APPSTORE_APP_PASSWORD= +APPSTORE_PROVIDER_PUBLIC_ID= +``` + +Secret meaning and how to create values: + +* `IOS_CODESIGN_KEY` + * What it is: the exact certificate identity name used for code signing. + * Example value: `Apple Distribution: Your Company Name (TEAMID1234)` + * How to find it on macOS: + * Run: `security find-identity -v -p codesigning` + * Copy the full identity string exactly. + +* `IOS_P12_BASE64` + * What it is: base64 of the exported signing certificate `.p12` file. + * How to create `.p12`: + * Open Keychain Access -> My Certificates. + * Export your Apple Distribution certificate as `.p12`. + * Set an export password (this becomes `IOS_P12_PASSWORD`). + * How to produce base64 (macOS): +```xml +base64 -i ios_dist.p12 | pbcopy +``` + * How to produce base64 (Windows PowerShell): +```xml +$b64 = [Convert]::ToBase64String( + [IO.File]::ReadAllBytes("C:\path\ios_dist.p12") +) +$b64 | Set-Clipboard +Write-Output $b64 +``` + +* `IOS_P12_PASSWORD` + * What it is: the password you entered when exporting the `.p12` certificate. + * Important: this is not your Apple ID password. + +* `IOS_MOBILEPROVISION_BASE64` + * What it is: base64 of the provisioning profile `.mobileprovision` used for this app id. + * How to get profile: + * Apple Developer Portal -> Profiles -> create/download App Store profile for `com.appomobi.drawnui.shaderscam`. + * How to produce base64 (macOS): +```xml +base64 -i ShadersCam.mobileprovision | pbcopy +``` + * How to produce base64 (Windows PowerShell): +```xml +$b64 = [Convert]::ToBase64String( + [IO.File]::ReadAllBytes("C:\path\ShadersCam.mobileprovision") +) +$b64 | Set-Clipboard +Write-Output $b64 +``` + +App Store upload secrets: + +* `APPSTORE_USERNAME` + * What it is: Apple ID email used for App Store Connect uploads. + * Example: `your.name@company.com` + +* `APPSTORE_APP_PASSWORD` + * What it is: Apple app-specific password (not your Apple ID login password). + * Where to create it: + * Go to https://account.apple.com + * Sign in with the Apple ID from `APPSTORE_USERNAME` + * Open Sign-In and Security -> App-Specific Passwords + * Create a new app-specific password and copy it immediately + * Store that generated value in this secret. + +* `APPSTORE_PROVIDER_PUBLIC_ID` (optional) + * What it is: provider public id used only if the Apple ID belongs to multiple providers/teams. + * Leave empty unless upload fails with provider ambiguity. + +Recommended combined flow in one workflow file: + +* Validate signing assets +* Build signed IPA +* Upload IPA to App Store Connect + +Minimal upload example in workflow step: + +* `xcrun altool --upload-app --type ios --file --username "$APPSTORE_USERNAME" --password "$APPSTORE_APP_PASSWORD"` + +Notes: + +* SDK is pinned via `global.json`. +* Android version/build numbers are derived from manifest/project values plus GitHub run number. +* iOS profile name is parsed from the provisioning profile during the workflow. \ No newline at end of file diff --git a/global.json b/global.json new file mode 100644 index 0000000..9e2ef80 --- /dev/null +++ b/global.json @@ -0,0 +1,5 @@ +{ + "sdk": { + "version": "10.0.104" + } +} \ No newline at end of file diff --git a/src/app/Resources/Raw/Shaders/Camera/cartoon.sksl b/src/app/Resources/Raw/Shaders/Camera/cartoon.sksl new file mode 100644 index 0000000..5299dee --- /dev/null +++ b/src/app/Resources/Raw/Shaders/Camera/cartoon.sksl @@ -0,0 +1,270 @@ +uniform float4 iMouse; +uniform float iTime; +uniform float2 iResolution; +uniform float2 iImageResolution; +uniform shader iImage1; +uniform float2 iOffset; +uniform float2 iOrigin; + +// Debug switches +const int DEBUG_MODE = 2; + +// Sketch mixing parameters +const float SKETCH_THRESHOLD = 0.94; +const int BLEND_MODE = 3; + +// Sketch parameters +const float iLineWidth = 4.5; +const float eraseNoise = 1.045; +const float contrast = 9.0; + +// === KAWAII COLOR PARAMETERS === +const float COLOR_LEVELS = 12.0; +const float iColorAlpha = 0.82; +const float iHueShift = 0.02; +const float iSaturation = 2.25; +const float iLightness = 1.28; +const float iShadows = -0.08; +const float iHighlights = 0.42; + +const float SMOOTH_RADIUS = 5.0; + +// Color dodge +half3 colorDodge(in half3 src, in half3 dst) { + return step(0.0, dst) * mix(min(half3(1.0), dst / (1.0 - src)), half3(1.0), step(1.0, src)); +} + +// Grayscale +float greyScale(in half3 col) { + return dot(col, half3(0.3, 0.59, 0.11)); +} + +// Gaussian blur +half3 gaussianBlur(float2 inputCoord, float scaleFactor) { + half3 result = half3(0.0); + float totalWeight = 0.0; + + float radius = SMOOTH_RADIUS * scaleFactor; + + for (int i = -2; i <= 2; i++) { + for (int j = -2; j <= 2; j++) { + float2 sampleCoord = inputCoord + float2(float(i), float(j)) * radius; + half3 sampleColor = iImage1.eval(sampleCoord).rgb; + + float distance = float(i*i + j*j); + float weight = exp(-distance * 0.15); + + result += sampleColor * weight; + totalWeight += weight; + } + } + + return result / totalWeight; +} + +// Posterize +half3 smoothPosterize(float2 inputCoord, float scaleFactor) { + half3 blurred = gaussianBlur(inputCoord, scaleFactor); + return floor(blurred * COLOR_LEVELS + 0.5) / COLOR_LEVELS; +} + +// RGB to HSL +half3 rgbToHsl(half3 rgb) { + float maxVal = max(max(rgb.r, rgb.g), rgb.b); + float minVal = min(min(rgb.r, rgb.g), rgb.b); + float delta = maxVal - minVal; + + float l = (maxVal + minVal) * 0.5; + + if (delta == 0.0) { + return half3(0.0, 0.0, l); + } + + float s = l > 0.5 ? delta / (2.0 - maxVal - minVal) : delta / (maxVal + minVal); + + float h; + if (maxVal == rgb.r) { + h = (rgb.g - rgb.b) / delta + (rgb.g < rgb.b ? 6.0 : 0.0); + } else if (maxVal == rgb.g) { + h = (rgb.b - rgb.r) / delta + 2.0; + } else { + h = (rgb.r - rgb.g) / delta + 4.0; + } + h /= 6.0; + + return half3(h, s, l); +} + +// HSL to RGB +half3 hslToRgb(half3 hsl) { + float h = hsl.x; + float s = hsl.y; + float l = hsl.z; + + if (s == 0.0) { + return half3(l, l, l); + } + + float q = l < 0.5 ? l * (1.0 + s) : l + s - l * s; + float p = 2.0 * l - q; + + float tr = h + 1.0/3.0; + float tg = h; + float tb = h - 1.0/3.0; + + if (tr < 0.0) tr += 1.0; + if (tr > 1.0) tr -= 1.0; + if (tb < 0.0) tb += 1.0; + if (tb > 1.0) tb -= 1.0; + + float r, g, b; + + if (tr < 1.0/6.0) r = p + (q - p) * 6.0 * tr; + else if (tr < 1.0/2.0) r = q; + else if (tr < 2.0/3.0) r = p + (q - p) * (2.0/3.0 - tr) * 6.0; + else r = p; + + if (tg < 1.0/6.0) g = p + (q - p) * 6.0 * tg; + else if (tg < 1.0/2.0) g = q; + else if (tg < 2.0/3.0) g = p + (q - p) * (2.0/3.0 - tg) * 6.0; + else g = p; + + if (tb < 1.0/6.0) b = p + (q - p) * 6.0 * tb; + else if (tb < 1.0/2.0) b = q; + else if (tb < 2.0/3.0) b = p + (q - p) * (2.0/3.0 - tb) * 6.0; + else b = p; + + return half3(r, g, b); +} + +// HSL adjustment +half3 adjustHSL(half3 rgb, float hueShift, float satMult, float lightMult) { + half3 hsl = rgbToHsl(rgb); + hsl.x = fract(hsl.x + hueShift); + hsl.y = clamp(hsl.y * satMult, 0.0, 1.0); + hsl.z = clamp(hsl.z * lightMult, 0.0, 1.0); + return hslToRgb(hsl); +} + +// Shadows & Highlights (softened for kawaii) +half3 adjustShadowsHighlights(half3 rgb, float shadowsAdj, float highlightsAdj) { + float luminance = dot(rgb, half3(0.299, 0.587, 0.114)); + float shadowMask = 1.0 - smoothstep(0.0, 0.55, luminance); + float highlightMask = smoothstep(0.45, 1.0, luminance); + half3 adjustedRgb = rgb + half3(shadowsAdj * shadowMask + highlightsAdj * highlightMask); + return clamp(adjustedRgb, 0.0, 1.0); +} + +// Blend functions +half3 blendMultiply(half3 base, half3 blend) { + return base * blend; +} + +half3 blendDarken(half3 base, half3 blend) { + return min(base, blend); +} + +half3 blendColorBurn(half3 base, half3 blend) { + return half3(1.0) - (half3(1.0) - base) / blend; +} + +half3 blendOverlay(half3 base, half3 blend) { + return mix( + 2.0 * base * blend, + half3(1.0) - 2.0 * (half3(1.0) - base) * (half3(1.0) - blend), + step(0.5, base) + ); +} + +// Line blending +half3 applyLineBlending(half3 colors, float sketchIntensity) { + if (sketchIntensity >= SKETCH_THRESHOLD) { + return colors; + } + + float lineAlpha = 1.0 - (sketchIntensity / SKETCH_THRESHOLD); + lineAlpha = clamp(lineAlpha, 0.0, 1.0); + + if (BLEND_MODE == 3) { + half3 lineCol = max(half3(sketchIntensity / SKETCH_THRESHOLD), half3(0.001)); + return blendColorBurn(colors, lineCol); + } else if (BLEND_MODE == 0) { + return mix(colors, half3(0.0), lineAlpha); + } else if (BLEND_MODE == 1) { + return blendMultiply(colors, half3(sketchIntensity / SKETCH_THRESHOLD)); + } else if (BLEND_MODE == 2) { + return blendDarken(colors, half3(sketchIntensity / SKETCH_THRESHOLD)); + } else if (BLEND_MODE == 4) { + return blendOverlay(colors, half3(sketchIntensity / SKETCH_THRESHOLD)); + } + + return colors; +} + +// Kawaii color processing +half3 processCartoonColors(float2 inputCoord, float scaleFactor) { + half3 col = iImage1.eval(inputCoord).rgb; + + half3 quantizedColor = smoothPosterize(inputCoord, scaleFactor); + + half3 adjustedCartoonColor = adjustHSL(quantizedColor, iHueShift, iSaturation, iLightness); + adjustedCartoonColor = adjustShadowsHighlights(adjustedCartoonColor, iShadows, iHighlights); + + half3 adjustedOriginalColor = adjustHSL(col, iHueShift, iSaturation, iLightness); + adjustedOriginalColor = adjustShadowsHighlights(adjustedOriginalColor, iShadows, iHighlights); + + return mix(adjustedOriginalColor, adjustedCartoonColor, iColorAlpha); +} + +// Original sketch lines (unchanged) +float processSketchLines(float2 inputCoord, float scaleFactor) { + half3 col = iImage1.eval(inputCoord).rgb; + + half3 blurred = half3(0.0); + float totalWeight = 0.0; + + for (int i = -2; i <= 2; i++) { + for (int j = -2; j <= 2; j++) { + float weight = exp(-float(i*i + j*j) * 0.2); + float2 sampleCoord = inputCoord + float2(float(i), float(j)) * 1.5 * scaleFactor * iLineWidth; + blurred += iImage1.eval(sampleCoord).rgb * weight; + totalWeight += weight; + } + } + blurred = blurred / totalWeight; + + float2 texelSize = (1.0 / iImageResolution.xy) * scaleFactor * iLineWidth; + float gradX = greyScale(iImage1.eval(inputCoord + float2(texelSize.x, 0.0)).rgb) - + greyScale(iImage1.eval(inputCoord - float2(texelSize.x, 0.0)).rgb); + float gradY = greyScale(iImage1.eval(inputCoord + float2(0.0, texelSize.y)).rgb) - + greyScale(iImage1.eval(inputCoord - float2(0.0, texelSize.y)).rgb); + + half3 inv = half3(1.0) - blurred; + half3 lighten = colorDodge(col, inv) * eraseNoise; + half3 res = half3(greyScale(lighten)); + res = half3(pow(res.x, contrast)); + + return res.x; +} + +half4 main(float2 fragCoord) { + float REFERENCE_SIZE = 1000.0; + + float2 renderingScale = iImageResolution.xy / iResolution.xy; + float2 inputCoord = (fragCoord - iOffset) * renderingScale; + + float imageSize = min(iImageResolution.x, iImageResolution.y); + float scaleFactor = imageSize / REFERENCE_SIZE; + + half3 cartoonColors = processCartoonColors(inputCoord, scaleFactor); + float sketchIntensity = processSketchLines(inputCoord, scaleFactor); + + if (DEBUG_MODE == 0) { + return half4(half3(sketchIntensity), 1.0); + } else if (DEBUG_MODE == 1) { + return half4(cartoonColors, 1.0); + } else { + half3 finalColor = applyLineBlending(cartoonColors, sketchIntensity); + return half4(clamp(finalColor, 0.0, 1.0), 1.0); + } +} \ No newline at end of file diff --git a/src/app/Resources/Raw/Shaders/Camera/geisha.sksl b/src/app/Resources/Raw/Shaders/Camera/geisha.sksl new file mode 100644 index 0000000..327d7cf --- /dev/null +++ b/src/app/Resources/Raw/Shaders/Camera/geisha.sksl @@ -0,0 +1,206 @@ +uniform float4 iMouse; +uniform float iTime; +uniform float2 iResolution; +uniform float2 iImageResolution; +uniform shader iImage1; +uniform float2 iOffset; + +const int DEBUG_MODE = 2; + +const float SKETCH_THRESHOLD = 0.93; +const int BLEND_MODE = 3; + +const float iLineWidth = 5.0; +const float eraseNoise = 1.05; +const float contrast = 8.0; + +// Fast Kawaii Parameters +const float COLOR_LEVELS = 22.0; +const float iColorAlpha = 0.68; +const float iHueShift = 0.01; +const float iSaturation = 1.90; +const float iLightness = 1.24; +const float iShadows = -0.05; +const float iHighlights = 0.36; + +const float SMOOTH_RADIUS = 1.8; + +// ======================== +// Fast helpers +float greyScale(half3 col) { + return dot(col, half3(0.299, 0.587, 0.114)); +} + +half3 colorDodge(half3 src, half3 dst) { + return step(0.0, dst) * mix(min(half3(1.0), dst / (1.0 - src)), half3(1.0), step(1.0, src)); +} + +// Cheap 3x3 smoothing (big performance win) +half3 fastSmooth(float2 coord, float2 texel) { + half3 sum = iImage1.eval(coord).rgb * 4.0; + sum += iImage1.eval(coord + float2(-texel.x, 0.0)).rgb; + sum += iImage1.eval(coord + float2( texel.x, 0.0)).rgb; + sum += iImage1.eval(coord + float2(0.0, -texel.y)).rgb; + sum += iImage1.eval(coord + float2(0.0, texel.y)).rgb; + return sum / 8.0; +} + +// Fast posterize +half3 fastPosterize(float2 inputCoord, float2 texel) { + half3 smoothed = fastSmooth(inputCoord, texel * SMOOTH_RADIUS); + return floor(smoothed * COLOR_LEVELS + 0.5) / COLOR_LEVELS; +} + +// RGB to HSL +half3 rgbToHsl(half3 rgb) { + float maxVal = max(max(rgb.r, rgb.g), rgb.b); + float minVal = min(min(rgb.r, rgb.g), rgb.b); + float delta = maxVal - minVal; + + float l = (maxVal + minVal) * 0.5; + + if (delta == 0.0) { + return half3(0.0, 0.0, l); + } + + float s = l > 0.5 ? delta / (2.0 - maxVal - minVal) : delta / (maxVal + minVal); + + float h; + if (maxVal == rgb.r) { + h = (rgb.g - rgb.b) / delta + (rgb.g < rgb.b ? 6.0 : 0.0); + } else if (maxVal == rgb.g) { + h = (rgb.b - rgb.r) / delta + 2.0; + } else { + h = (rgb.r - rgb.g) / delta + 4.0; + } + h /= 6.0; + + return half3(h, s, l); +} + +// HSL to RGB +half3 hslToRgb(half3 hsl) { + float h = hsl.x; + float s = hsl.y; + float l = hsl.z; + + if (s == 0.0) { + return half3(l, l, l); + } + + float q = l < 0.5 ? l * (1.0 + s) : l + s - l * s; + float p = 2.0 * l - q; + + float tr = h + 1.0/3.0; + float tg = h; + float tb = h - 1.0/3.0; + + if (tr < 0.0) tr += 1.0; if (tr > 1.0) tr -= 1.0; + if (tb < 0.0) tb += 1.0; if (tb > 1.0) tb -= 1.0; + + float r, g, b; + + if (tr < 1.0/6.0) r = p + (q - p) * 6.0 * tr; + else if (tr < 1.0/2.0) r = q; + else if (tr < 2.0/3.0) r = p + (q - p) * (2.0/3.0 - tr) * 6.0; + else r = p; + + if (tg < 1.0/6.0) g = p + (q - p) * 6.0 * tg; + else if (tg < 1.0/2.0) g = q; + else if (tg < 2.0/3.0) g = p + (q - p) * (2.0/3.0 - tg) * 6.0; + else g = p; + + if (tb < 1.0/6.0) b = p + (q - p) * 6.0 * tb; + else if (tb < 1.0/2.0) b = q; + else if (tb < 2.0/3.0) b = p + (q - p) * (2.0/3.0 - tb) * 6.0; + else b = p; + + return half3(r, g, b); +} + +// HSL Adjustment +half3 adjustHSL(half3 rgb, float hueShift, float satMult, float lightMult) { + half3 hsl = rgbToHsl(rgb); + hsl.x = fract(hsl.x + hueShift); + hsl.y = clamp(hsl.y * satMult, 0.0, 1.0); + hsl.z = clamp(hsl.z * lightMult, 0.0, 1.0); + return hslToRgb(hsl); +} + +// Shadows & Highlights +half3 adjustShadowsHighlights(half3 rgb, float shadowsAdj, float highlightsAdj) { + float lum = greyScale(rgb); + float shadowMask = 1.0 - smoothstep(0.0, 0.65, lum); + float highlightMask = smoothstep(0.35, 1.0, lum); + return clamp(rgb + half3(shadowsAdj * shadowMask + highlightsAdj * highlightMask), 0.0, 1.0); +} + +// Blend +half3 blendColorBurn(half3 base, half3 blend) { + return half3(1.0) - (half3(1.0) - base) / blend; +} + +// Fast sketch lines +float fastSketchLines(float2 inputCoord, float scaleFactor) { + half3 col = iImage1.eval(inputCoord).rgb; + + // Cheap blur + half3 blurred = col * 0.6; + float2 offset = 1.4 * scaleFactor * iLineWidth / iImageResolution.xy; + blurred += iImage1.eval(inputCoord + float2(-offset.x, 0.0)).rgb * 0.1; + blurred += iImage1.eval(inputCoord + float2( offset.x, 0.0)).rgb * 0.1; + blurred += iImage1.eval(inputCoord + float2(0.0, -offset.y)).rgb * 0.1; + blurred += iImage1.eval(inputCoord + float2(0.0, offset.y)).rgb * 0.1; + + // Simple fast edge + float2 texel = 1.8 * scaleFactor * iLineWidth / iImageResolution.xy; + float edge = abs(greyScale(iImage1.eval(inputCoord + float2(texel.x, 0.0)).rgb) - + greyScale(iImage1.eval(inputCoord - float2(texel.x, 0.0)).rgb)) + + abs(greyScale(iImage1.eval(inputCoord + float2(0.0, texel.y)).rgb) - + greyScale(iImage1.eval(inputCoord - float2(0.0, texel.y)).rgb)); + + half3 inv = half3(1.0) - blurred; + float sketch = pow(greyScale(colorDodge(col, inv) * eraseNoise), contrast); + + return clamp(sketch * (1.0 + edge * 1.2), 0.0, 1.0); +} + +// Fast color processing +half3 processKawaiiColors(float2 inputCoord, float2 texel) { + half3 col = iImage1.eval(inputCoord).rgb; + + half3 quantized = fastPosterize(inputCoord, texel); + + half3 cartoon = adjustHSL(quantized, iHueShift, iSaturation, iLightness); + cartoon = adjustShadowsHighlights(cartoon, iShadows, iHighlights); + + half3 originalAdj = adjustHSL(col, iHueShift, iSaturation, iLightness); + originalAdj = adjustShadowsHighlights(originalAdj, iShadows, iHighlights); + + return mix(originalAdj, cartoon, iColorAlpha); +} + +// Line blending +half3 applyLineBlending(half3 colors, float sketchIntensity) { + if (sketchIntensity >= SKETCH_THRESHOLD) return colors; + + float alpha = clamp(1.0 - (sketchIntensity / SKETCH_THRESHOLD), 0.0, 1.0) * 1.6; + return blendColorBurn(colors, max(half3(sketchIntensity / SKETCH_THRESHOLD), half3(0.001))); +} + +half4 main(float2 fragCoord) { + float2 renderingScale = iImageResolution.xy / iResolution.xy; + float2 inputCoord = (fragCoord - iOffset) * renderingScale; + + float2 texel = 1.0 / iImageResolution.xy; + float scaleFactor = min(iImageResolution.x, iImageResolution.y) / 1000.0; + + half3 colors = processKawaiiColors(inputCoord, texel); + float lines = fastSketchLines(inputCoord, scaleFactor); + + if (DEBUG_MODE == 0) return half4(half3(lines), 1.0); + if (DEBUG_MODE == 1) return half4(colors, 1.0); + + half3 final = applyLineBlending(colors, lines); + return half4(clamp(final, 0.0, 1.0), 1.0); +} \ No newline at end of file diff --git a/src/app/Resources/Raw/Shaders/Camera/print.sksl b/src/app/Resources/Raw/Shaders/Camera/print.sksl new file mode 100644 index 0000000..9d7cfa5 --- /dev/null +++ b/src/app/Resources/Raw/Shaders/Camera/print.sksl @@ -0,0 +1,210 @@ +uniform float4 iMouse; +uniform float iTime; +uniform float2 iResolution; +uniform float2 iImageResolution; +uniform shader iImage1; +uniform float2 iOffset; + +const int DEBUG_MODE = 2; + +const float SKETCH_THRESHOLD = 0.94; +const int BLEND_MODE = 3; + +const float iLineWidth = 5.2; +const float eraseNoise = 1.05; +const float contrast = 8.5; + +// KAWAII COLOR PARAMETERS +const float COLOR_LEVELS = 20.0; +const float iColorAlpha = 0.78; +const float iHueShift = 0.015; +const float iSaturation = 1.95; +const float iLightness = 1.22; +const float iShadows = -0.06; +const float iHighlights = 0.35; + +const float SMOOTH_RADIUS = 2.6; // Increased a bit for softer look + +// Helpers +half3 colorDodge(in half3 src, in half3 dst) { + return step(0.0, dst) * mix(min(half3(1.0), dst / (1.0 - src)), half3(1.0), step(1.0, src)); +} + +float greyScale(in half3 col) { + return dot(col, half3(0.3, 0.59, 0.11)); +} + +// Softer 3x3 blur for colors (better quality than previous fast version) +half3 softColorBlur(float2 inputCoord, float scaleFactor) { + half3 result = half3(0.0); + float totalWeight = 0.0; + float radius = SMOOTH_RADIUS * scaleFactor; + + for (int i = -1; i <= 1; i++) { + for (int j = -1; j <= 1; j++) { + float weight = exp(-float(i*i + j*j) * 0.5); // Gaussian-like weight + float2 sampleCoord = inputCoord + float2(float(i), float(j)) * radius; + result += iImage1.eval(sampleCoord).rgb * weight; + totalWeight += weight; + } + } + return result / totalWeight; +} + +// Posterize with softer blur +half3 smoothPosterize(float2 inputCoord, float scaleFactor) { + half3 blurred = softColorBlur(inputCoord, scaleFactor); + return floor(blurred * COLOR_LEVELS + 0.5) / COLOR_LEVELS; +} + +// Your original HSL functions (unchanged) +half3 rgbToHsl(half3 rgb) { + float maxVal = max(max(rgb.r, rgb.g), rgb.b); + float minVal = min(min(rgb.r, rgb.g), rgb.b); + float delta = maxVal - minVal; + float l = (maxVal + minVal) * 0.5; + + if (delta == 0.0) return half3(0.0, 0.0, l); + + float s = l > 0.5 ? delta / (2.0 - maxVal - minVal) : delta / (maxVal + minVal); + + float h; + if (maxVal == rgb.r) h = (rgb.g - rgb.b) / delta + (rgb.g < rgb.b ? 6.0 : 0.0); + else if (maxVal == rgb.g) h = (rgb.b - rgb.r) / delta + 2.0; + else h = (rgb.r - rgb.g) / delta + 4.0; + h /= 6.0; + + return half3(h, s, l); +} + +half3 hslToRgb(half3 hsl) { + float h = hsl.x; float s = hsl.y; float l = hsl.z; + if (s == 0.0) return half3(l, l, l); + + float q = l < 0.5 ? l * (1.0 + s) : l + s - l * s; + float p = 2.0 * l - q; + + float tr = h + 1.0/3.0; + float tg = h; + float tb = h - 1.0/3.0; + + if (tr < 0.0) tr += 1.0; if (tr > 1.0) tr -= 1.0; + if (tb < 0.0) tb += 1.0; if (tb > 1.0) tb -= 1.0; + + float r, g, b; + if (tr < 1.0/6.0) r = p + (q - p) * 6.0 * tr; + else if (tr < 1.0/2.0) r = q; + else if (tr < 2.0/3.0) r = p + (q - p) * (2.0/3.0 - tr) * 6.0; + else r = p; + + if (tg < 1.0/6.0) g = p + (q - p) * 6.0 * tg; + else if (tg < 1.0/2.0) g = q; + else if (tg < 2.0/3.0) g = p + (q - p) * (2.0/3.0 - tg) * 6.0; + else g = p; + + if (tb < 1.0/6.0) b = p + (q - p) * 6.0 * tb; + else if (tb < 1.0/2.0) b = q; + else if (tb < 2.0/3.0) b = p + (q - p) * (2.0/3.0 - tb) * 6.0; + else b = p; + + return half3(r, g, b); +} + +half3 adjustHSL(half3 rgb, float hueShift, float satMult, float lightMult) { + half3 hsl = rgbToHsl(rgb); + hsl.x = fract(hsl.x + hueShift); + hsl.y = clamp(hsl.y * satMult, 0.0, 1.0); + hsl.z = clamp(hsl.z * lightMult, 0.0, 1.0); + return hslToRgb(hsl); +} + +half3 adjustShadowsHighlights(half3 rgb, float shadowsAdj, float highlightsAdj) { + float luminance = dot(rgb, half3(0.299, 0.587, 0.114)); + float shadowMask = 1.0 - smoothstep(0.0, 0.6, luminance); + float highlightMask = smoothstep(0.4, 1.0, luminance); + return clamp(rgb + half3(shadowsAdj * shadowMask + highlightsAdj * highlightMask), 0.0, 1.0); +} + +half3 blendColorBurn(half3 base, half3 blend) { + return half3(1.0) - (half3(1.0) - base) / blend; +} + +// Color processing +half3 processCartoonColors(float2 inputCoord, float scaleFactor) { + half3 col = iImage1.eval(inputCoord).rgb; + half3 quantizedColor = smoothPosterize(inputCoord, scaleFactor); + + half3 adjustedCartoonColor = adjustHSL(quantizedColor, iHueShift, iSaturation, iLightness); + adjustedCartoonColor = adjustShadowsHighlights(adjustedCartoonColor, iShadows, iHighlights); + + half3 adjustedOriginalColor = adjustHSL(col, iHueShift, iSaturation, iLightness); + adjustedOriginalColor = adjustShadowsHighlights(adjustedOriginalColor, iShadows, iHighlights); + + return mix(adjustedOriginalColor, adjustedCartoonColor, iColorAlpha); +} + +// Keep your original sketch lines (best for line quality) +float processSketchLines(float2 inputCoord, float scaleFactor) { + half3 col = iImage1.eval(inputCoord).rgb; + + half3 blurred = half3(0.0); + float totalWeight = 0.0; + + for (int i = -2; i <= 2; i++) { + for (int j = -2; j <= 2; j++) { + float weight = exp(-float(i*i + j*j) * 0.2); + float2 sampleCoord = inputCoord + float2(float(i), float(j)) * 1.5 * scaleFactor * iLineWidth; + blurred += iImage1.eval(sampleCoord).rgb * weight; + totalWeight += weight; + } + } + blurred = blurred / totalWeight; + + float2 texelSize = (1.0 / iImageResolution.xy) * scaleFactor * iLineWidth; + float gradX = greyScale(iImage1.eval(inputCoord + float2(texelSize.x, 0.0)).rgb) - + greyScale(iImage1.eval(inputCoord - float2(texelSize.x, 0.0)).rgb); + float gradY = greyScale(iImage1.eval(inputCoord + float2(0.0, texelSize.y)).rgb) - + greyScale(iImage1.eval(inputCoord - float2(0.0, texelSize.y)).rgb); + + half3 inv = half3(1.0) - blurred; + half3 lighten = colorDodge(col, inv) * eraseNoise; + half3 res = half3(greyScale(lighten)); + res = half3(pow(res.x, contrast)); + + return res.x; +} + +// Line blending +half3 applyLineBlending(half3 colors, float sketchIntensity) { + if (sketchIntensity >= SKETCH_THRESHOLD) { + return colors; + } + + float lineAlpha = 1.0 - (sketchIntensity / SKETCH_THRESHOLD); + lineAlpha = clamp(lineAlpha, 0.0, 1.0); + + if (BLEND_MODE == 3) { + half3 lineCol = max(half3(sketchIntensity / SKETCH_THRESHOLD), half3(0.001)); + return blendColorBurn(colors, lineCol); + } + return mix(colors, half3(0.0), lineAlpha); +} + +half4 main(float2 fragCoord) { + float REFERENCE_SIZE = 1000.0; + + float2 renderingScale = iImageResolution.xy / iResolution.xy; + float2 inputCoord = (fragCoord - iOffset) * renderingScale; + + float imageSize = min(iImageResolution.x, iImageResolution.y); + float scaleFactor = imageSize / REFERENCE_SIZE; + + half3 cartoonColors = processCartoonColors(inputCoord, scaleFactor); + float sketchIntensity = processSketchLines(inputCoord, scaleFactor); + + if (DEBUG_MODE == 0) return half4(half3(sketchIntensity), 1.0); + if (DEBUG_MODE == 1) return half4(cartoonColors, 1.0); + + half3 finalColor = applyLineBlending(cartoonColors, sketchIntensity); + return half4(clamp(finalColor, 0.0, 1.0), 1.0); +} \ No newline at end of file diff --git a/src/app/ShadersCamera.csproj b/src/app/ShadersCamera.csproj index e0d1d8e..4d8932d 100644 --- a/src/app/ShadersCamera.csproj +++ b/src/app/ShadersCamera.csproj @@ -2,11 +2,11 @@ - - + + - net9.0-android;net9.0-ios;net9.0-maccatalyst - $(TargetFrameworks);net9.0-windows10.0.19041.0 + net10.0-android;net10.0-ios;net10.0-maccatalyst + $(TargetFrameworks);net10.0-windows10.0.19041.0 Exe ShadersCamera @@ -20,8 +20,22 @@ com.appomobi.drawnui.shaderscam 340e07b0-ebc2-4fde-9ac9-074d5c3269b4 + false + + + + + + + + + + + + + None 15.0 @@ -32,9 +46,6 @@ $(MSBuildProjectName) - - - True @@ -55,20 +66,12 @@ iPhone Developer - - - - manual - Apple Distribution: NIKOLAY KOVALSKIY (F5H2D34D9G) - ShadersCam AppStore - - @@ -96,15 +99,11 @@ - - - - - + + + @@ -155,7 +154,7 @@ - + True diff --git a/src/app/ViewModels/CameraViewModel.cs b/src/app/ViewModels/CameraViewModel.cs index 0a69b9a..a80b07e 100644 --- a/src/app/ViewModels/CameraViewModel.cs +++ b/src/app/ViewModels/CameraViewModel.cs @@ -62,10 +62,13 @@ public CameraViewModel() new ShaderItem { Title = "Sketch", Filename = "Shaders/Camera/sketch.sksl" }, new ShaderItem { Title = "Paint", Filename = "Shaders/Camera/sketchcolors.sksl" }, + new ShaderItem { Title = "Print", Filename = "Shaders/Camera/print.sksl" }, + new ShaderItem { Title = "Geisha", Filename = "Shaders/Camera/geisha.sksl" }, -#if !ANDROID + #region Low FPS on Android new ShaderItem { Title = "Poster", Filename = "Shaders/Camera/sketchcomics4.sksl" }, -#endif + new ShaderItem { Title = "Cartoon", Filename = "Shaders/Camera/cartoon.sksl" }, + #endregion new ShaderItem { Title = "Mars", Filename = "Shaders/Camera/hell.sksl" }, new ShaderItem { Title = "Invert", Filename = "Shaders/Camera/invert.sksl" }, @@ -316,7 +319,7 @@ public void AttachCamera(SkiaCamera camera) Camera.CaptureSuccess += OnCaptureSuccess; Camera.StateChanged += OnCameraStateChanged; Camera.NewPreviewSet += OnNewPreviewSet; - Camera.IsRecordingVideoChanged += OnIsRecordingVideoChanged; + Camera.IsRecordingChanged += OnIsRecordingVideoChanged; Camera.RecordingProgress += OnVideoRecordingProgress; } } @@ -328,7 +331,7 @@ public override void OnDisposing() Camera.CaptureSuccess -= OnCaptureSuccess; Camera.StateChanged -= OnCameraStateChanged; Camera.NewPreviewSet -= OnNewPreviewSet; - Camera.IsRecordingVideoChanged -= OnIsRecordingVideoChanged; + Camera.IsRecordingChanged -= OnIsRecordingVideoChanged; Camera.RecordingProgress -= OnVideoRecordingProgress; Camera = null; } @@ -378,8 +381,6 @@ private void OnCameraStateChanged(object sender, HardwareState state) private async void OnCaptureSuccess(object sender, CapturedImage captured) { - captured.SolveExifOrientation(); - var imageWithOverlay = await Camera.RenderCapturedPhotoAsync(captured, null, image => { if (SelectedShader != null) @@ -387,7 +388,6 @@ private async void OnCaptureSuccess(object sender, CapturedImage captured) var shaderEffect = new SkiaShaderEffect() { ShaderSource = SelectedShader.Filename, - TileMode = SKShaderTileMode.Mirror }; image.VisualEffects.Add(shaderEffect); } diff --git a/src/app/Views/Controls/CameraWithEffects.cs b/src/app/Views/Controls/AppCamera.cs similarity index 64% rename from src/app/Views/Controls/CameraWithEffects.cs rename to src/app/Views/Controls/AppCamera.cs index 2e0a3a8..98cb8f7 100644 --- a/src/app/Views/Controls/CameraWithEffects.cs +++ b/src/app/Views/Controls/AppCamera.cs @@ -1,17 +1,34 @@ +using AppoMobi.Specials; using DrawnUi.Camera; using DrawnUi.Infrastructure; using ShadersCamera.Models; using System.Windows.Input; -using AppoMobi.Specials; +using static Microsoft.Maui.Controls.PlatformConfiguration.iOSSpecific.VisualElement; namespace ShadersCamera.Views.Controls { - public class CameraWithEffects : SkiaCamera + public class AppCamera : SkiaCamera { - public CameraWithEffects() + public AppCamera() { //NeedPermissionsSet = NeedPermissions.Camera | NeedPermissions.Gallery; + UseRealtimeVideoProcessing = true; +#if DEBUG + VideoDiagnosticsOn = true; +#endif + } + + protected override void RenderPreviewForProcessing(SKCanvas canvas, SKImage frame) + { + var shader = GetEffectShader(); + if (shader == null) + { + base.RenderPreviewForProcessing(canvas, frame); + return; + } + + shader.DrawImage(canvas, frame, 0, 0); } protected override void OnDisplayReady() @@ -23,13 +40,7 @@ protected override void OnDisplayReady() //do not block startup by this InitializeEffects(); }); - } - protected override void Paint(DrawingContext ctx) - { - base.Paint(ctx); - - FrameAquired = false; } /// @@ -60,20 +71,19 @@ static void InitializeAvailableShaders() if (_shaders == null) { _shaders = Files.ListAssets(path); - } } - private SkiaShaderEffect _shader; + //private SkiaShaderEffect _shader; private SkiaShaderEffect _shaderGlobal; public void ChangeShaderCode(string code) { - if (Display == null || _shader==null) + if (_effectShader == null) { return; } - _shader.ShaderCode = code; + _effectShader.CompileFromCode(code, null, false, RaiseShaderError); } public void SetEffect(SkiaImageEffect effect) @@ -87,59 +97,80 @@ public void SetEffect(SkiaImageEffect effect) SetCustomShader(ShaderSource); } - - protected virtual void SetCustomShader(ShaderItem shader) + private SkiaShader GetEffectShader() { - if (Display == null) + var effect = VideoEffect; + if (effect == null) { - return; + ReleaseEffectShader(); + return null; } - //just having fun, add ripples to preview -/* - if (_shaderGlobal == null) + if (_effectShader != null && _loadedEffect == effect) { - _shaderGlobal = new MultiRippleWithTouchEffect() - { - SecondarySource="Images/logo.png" - }; - VisualEffects.Add(_shaderGlobal); + return _effectShader; } -*/ - // Remove existing shader if any - if (_shader != null && VisualEffects.Contains(_shader)) + ReleaseEffectShader(); + + var filename = effect.Filename;// ShaderEffectHelper.GetFilename(effect); + if (string.IsNullOrWhiteSpace(filename)) { - _shader.OnCompilationError -= OnShaderError; - VisualEffects.Remove(_shader); + return null; } - if (Effect == SkiaImageEffect.Custom && shader != null) - { + _effectShader = SkiaShader.FromResource(filename, true, RaiseShaderError); + _loadedEffect = effect; - // Create new shader with the specified filename - _shader = new ClippedShaderEffect(Display) - { - ShaderSource = shader.Filename, - //FilterMode = SKFilterMode.Linear <== it's default - }; + return _effectShader; + } - // Add the new shader - if (_shader != null && !VisualEffects.Contains(_shader)) - { - _shader.OnCompilationError += OnShaderError; - VisualEffects.Add(_shader); - } - } + private void ReleaseEffectShader() + { + _effectShader?.Dispose(); + _effectShader = null; + _loadedEffect = null; + } + + private SkiaShader _effectShader; + private ShaderItem _loadedEffect; + + public static readonly BindableProperty VideoEffectProperty = BindableProperty.Create( + nameof(VideoEffect), + typeof(ShaderItem), + typeof(AppCamera), + null); + + public ShaderItem VideoEffect + { + get => (ShaderItem)GetValue(VideoEffectProperty); + set => SetValue(VideoEffectProperty, value); + } + + public override void OnWillDisposeWithChildren() + { + ReleaseEffectShader(); + + base.OnWillDisposeWithChildren(); + } + + protected virtual void SetCustomShader(ShaderItem shader) + { + VideoEffect = shader; } private ShaderEditorPage _editor; - private void OnShaderError(object sender, string error) + private void RaiseShaderError(string error) { _editor?.ReportCompilationError(error); } + private void OnShaderError(object sender, string error) + { + RaiseShaderError(error); + } + public ICommand CommandEditShader { get @@ -147,9 +178,9 @@ public ICommand CommandEditShader return new Command(async (context) => { //just change currently running shader code, no matter what exactly we longpressed - if (_shader != null) + if (_effectShader != null) { - var code = _shader.LoadedCode; + var code = _effectShader.Code; MainThread.BeginInvokeOnMainThread(() => { _editor = new ShaderEditorPage(code, CallBackSetSelectedShaderCode); @@ -192,7 +223,7 @@ public static void OpenPageInNewWindow(ContentPage page, private static void NeedChangeShader(BindableObject bindable, object oldValue, object newValue) { - if (bindable is CameraWithEffects control) + if (bindable is AppCamera control) { control.SetCustomShader(control.ShaderSource); } @@ -200,7 +231,7 @@ private static void NeedChangeShader(BindableObject bindable, object oldValue, o public static readonly BindableProperty ShaderSourceProperty = BindableProperty.Create(nameof(ShaderSource), typeof(ShaderItem), - typeof(CameraWithEffects), + typeof(AppCamera), null, propertyChanged: NeedChangeShader); public ShaderItem ShaderSource diff --git a/src/app/Views/MainPageCameraFluent.Ui.cs b/src/app/Views/MainPageCameraFluent.Ui.cs index 5eb7168..2a60fed 100644 --- a/src/app/Views/MainPageCameraFluent.Ui.cs +++ b/src/app/Views/MainPageCameraFluent.Ui.cs @@ -14,7 +14,7 @@ namespace ShadersCamera.Views public partial class MainCameraPageFluent : BasePageReloadable, IPageWIthCamera { Canvas Canvas; - CameraWithEffects CameraControl; + AppCamera _appCameraControl; //static for Hot Preview public static SkiaViewSwitcher? ViewsContainer; @@ -58,29 +58,22 @@ public override void Build() CreateMainLayout() } }.Assign(out ViewsContainer), -#if xDEBUG - new SkiaLabelFps() - { - Margin = new(0, 0, 4, 24), - VerticalOptions = LayoutOptions.End, - HorizontalOptions = LayoutOptions.End, - Rotation = -45, - FontSize = 11, - BackgroundColor = Colors.DarkRed, - TextColor = Colors.White, - ZIndex = 110, - } +#if DEBUG + CreateDebugFps() #endif } }.Fill() }; this.Content = - new Grid() +#if IOS + new Grid() //use grid for safe insets on iOS due to MAUI specifics { Children = { Canvas } }; - +#else + Canvas; +#endif Subscribe(true); } @@ -92,12 +85,12 @@ SkiaLayout CreateMainLayout() VerticalOptions = LayoutOptions.Fill, Children = { + CreateCameraControl(), CreateCameraLayer(), new SkiaDrawer() { - AutoCache = false, - UseCache = SkiaCacheType.Operations, + UseCache = SkiaCacheType.GPU, Margin = new Thickness(0, 0, 0, 100), HeaderSize = 40, Direction = DrawerDirection.FromLeft, @@ -148,6 +141,7 @@ SkiaLayout CreateMainLayout() Content = new SkiaLayoutWithSelector() { Type = LayoutType.Row, + UseCache = SkiaCacheType.Operations, VerticalOptions = LayoutOptions.Center, Spacing = 8, RecyclingTemplate = RecyclingTemplate.Disabled, @@ -190,9 +184,6 @@ SkiaLayout CreateMainLayout() } } .Assign(out ShaderDrawer), -#if DEBUG - CreateDebugFps() -#endif } }; } @@ -205,8 +196,7 @@ SkiaLayer CreateCameraLayer() VerticalOptions = LayoutOptions.Fill, Children = { - CreateCameraControl(), - CreateRecordingBadge(), + //CreateRecordingBadge(), CreateControlsLayer(), CreateZoomHotspot() } @@ -266,9 +256,9 @@ SkiaShape CreateRecordingBadge() }); } - CameraWithEffects CreateCameraControl() + AppCamera CreateCameraControl() { - return new CameraWithEffects() + return new AppCamera() { BackgroundColor = Colors.Black, PhotoQuality = CaptureQuality.Medium, @@ -281,8 +271,8 @@ CameraWithEffects CreateCameraControl() ConstantUpdate = false, Tag = "Camera" } - .Assign(out CameraControl) - .ObserveBindingContext((me, vm, prop) => + .Assign(out _appCameraControl) + .ObserveBindingContext((me, vm, prop) => { bool attached = prop == nameof(BindingContext); if (attached || prop == nameof(vm.SelectedShader)) @@ -296,7 +286,7 @@ SkiaLayer CreateControlsLayer() { return new SkiaLayer() { - UseCache = SkiaCacheType.Operations, + //UseCache = SkiaCacheType.Operations, VerticalOptions = LayoutOptions.Fill, Children = { @@ -355,6 +345,7 @@ SkiaShape CreateControlsPanel() { return new SkiaShape() { + UseCache = SkiaCacheType.GPU, HorizontalOptions = LayoutOptions.Center, VerticalOptions = LayoutOptions.End, Margin = new Thickness(0, 0, 0, 24), @@ -465,12 +456,12 @@ SkiaShape CreateSettingsButton() } .OnTapped(me => { - if (SelectedFormat == null || CameraControl.PermissionsError) + if (SelectedFormat == null || _appCameraControl.PermissionsError) { //camera error try { - CameraControl.IsOn = true; + _appCameraControl.IsOn = true; } catch (Exception e) { @@ -479,7 +470,7 @@ SkiaShape CreateSettingsButton() Tasks.StartDelayed(TimeSpan.FromMilliseconds(500), () => { - if (CameraControl.PermissionsError) + if (_appCameraControl.PermissionsError) { MainThread.BeginInvokeOnMainThread(async () => { @@ -547,13 +538,13 @@ SkiaShape CreateEffectsButton() } .OnTapped(me => { - var current = CameraControl.Effect; - var currentIndex = CameraControl.AvailableEffects.IndexOf(current); + var current = _appCameraControl.Effect; + var currentIndex = _appCameraControl.AvailableEffects.IndexOf(current); // Move to next effect, wrap around to beginning if at end - var nextIndex = (currentIndex + 1) % CameraControl.AvailableEffects.Count; + var nextIndex = (currentIndex + 1) % _appCameraControl.AvailableEffects.Count; - CameraControl.SetEffect(CameraControl.AvailableEffects[nextIndex]); + _appCameraControl.SetEffect(_appCameraControl.AvailableEffects[nextIndex]); }); } @@ -824,9 +815,9 @@ DataTemplate CreateShaderItemTemplate() }) .OnLongPressing(me => { - if (me.BindingContext is ShaderItem item && CameraControl?.CommandEditShader != null) + if (me.BindingContext is ShaderItem item && _appCameraControl?.CommandEditShader != null) { - CameraControl.CommandEditShader.Execute(item); + _appCameraControl.CommandEditShader.Execute(item); } }); }); @@ -873,6 +864,7 @@ SkiaLabelFps CreateDebugFps() return new SkiaLabelFps() { Margin = new Thickness(0, 0, 4, 24), + UseCache = SkiaCacheType.GPU, BackgroundColor = Colors.Black, ForceRefresh = false, HorizontalOptions = LayoutOptions.End, @@ -896,7 +888,7 @@ void SyncUi() ApplyAspect(); // CaptureFlashMode - var currentMode = CameraControl.CaptureFlashMode; + var currentMode = _appCameraControl.CaptureFlashMode; switch (currentMode) { case CaptureFlashMode.Off: @@ -911,7 +903,7 @@ void SyncUi() } //FlashMode - var torch = CameraControl.FlashMode; + var torch = _appCameraControl.FlashMode; switch (torch) { case FlashMode.On: diff --git a/src/app/Views/MainPageCameraFluent.cs b/src/app/Views/MainPageCameraFluent.cs index e4acdd2..1c91050 100644 --- a/src/app/Views/MainPageCameraFluent.cs +++ b/src/app/Views/MainPageCameraFluent.cs @@ -39,10 +39,10 @@ void Subscribe(bool subscribe) { Canvas.ViewDisposing += CanvasWillDispose; Canvas.WillFirstTimeDraw += WillFirstTimeDraw; - if (CameraControl != null) + if (_appCameraControl != null) { - CameraControl.CaptureFlashMode = (CaptureFlashMode)UserSettings.Current.Flash; - CameraControl.PropertyChanged += OnContextPropertyChanged; + _appCameraControl.CaptureFlashMode = (CaptureFlashMode)UserSettings.Current.Flash; + _appCameraControl.PropertyChanged += OnContextPropertyChanged; } } else @@ -53,27 +53,27 @@ void Subscribe(bool subscribe) Canvas.WillFirstTimeDraw -= WillFirstTimeDraw; } - if (CameraControl != null) + if (_appCameraControl != null) { - CameraControl.PropertyChanged -= OnContextPropertyChanged; + _appCameraControl.PropertyChanged -= OnContextPropertyChanged; } } } void AttachCamera() { - if (BindingContext is CameraViewModel vm && CameraControl != null) + if (BindingContext is CameraViewModel vm && _appCameraControl != null) { - vm.AttachCamera(CameraControl); + vm.AttachCamera(_appCameraControl); - CameraControl.NewPreviewSet += OnPreviewSet; - CameraControl.StateChanged += OnCameraStateChanged; + _appCameraControl.NewPreviewSet += OnPreviewSet; + _appCameraControl.StateChanged += OnAppCameraStateChanged; SyncUi(); try { - CameraControl.IsOn = true; + _appCameraControl.IsOn = true; } catch (Exception e) { @@ -95,20 +95,20 @@ void AttachCamera() /// private bool StartupSuccessChecked; - private void OnCameraStateChanged(object sender, HardwareState state) + private void OnAppCameraStateChanged(object sender, HardwareState state) { if (state == HardwareState.On) { Debug.WriteLine($"[CameraApp] State in ON!"); - if (UserSettings.Current.Formats.TryGetValue(CameraControl.CameraDevice.Id, out var format)) + if (UserSettings.Current.Formats.TryGetValue(_appCameraControl.CameraDevice.Id, out var format)) { - CameraControl.PhotoFormatIndex = format; - CameraControl.PhotoQuality = CaptureQuality.Manual; + _appCameraControl.PhotoFormatIndex = format; + _appCameraControl.PhotoQuality = CaptureQuality.Manual; } else { - CameraControl.PhotoQuality = CaptureQuality.Medium; + _appCameraControl.PhotoQuality = CaptureQuality.Medium; } } } @@ -194,7 +194,7 @@ private void OnPreviewSet(object sender, LoadedImageSource source) }; if (dispose != null) { - CameraControl.DisposeObject(dispose); + _appCameraControl.DisposeObject(dispose); } //for AI/ML use this: @@ -228,9 +228,9 @@ public LoadedImageSource SmallPreview private void TappedSwitchCamera() { - if (CameraControl.IsOn) + if (_appCameraControl.IsOn) { - CameraControl.Facing = CameraControl.Facing == CameraPosition.Selfie + _appCameraControl.Facing = _appCameraControl.Facing == CameraPosition.Selfie ? CameraPosition.Default : CameraPosition.Selfie; } @@ -238,29 +238,29 @@ private void TappedSwitchCamera() private void TappedTurnCamera() { - if (CameraControl.State == HardwareState.On) + if (_appCameraControl.State == HardwareState.On) { - CameraControl.IsOn = false; + _appCameraControl.IsOn = false; } else { - CameraControl.IsOn = true; + _appCameraControl.IsOn = true; } } private async void TappedTakePicture(object sender, SkiaGesturesParameters skiaGesturesParameters) { - if (CameraControl.State == HardwareState.On && !CameraControl.IsBusy) + if (_appCameraControl.State == HardwareState.On && !_appCameraControl.IsBusy) { - CameraControl.FlashScreen(Color.Parse("#EEFFFFFF")); - await CameraControl.TakePicture().ConfigureAwait(false); + _appCameraControl.FlashScreen(Color.Parse("#EEFFFFFF")); + await _appCameraControl.TakePicture().ConfigureAwait(false); } } private void TappedResume() { - CameraControl.IsOn = true; + _appCameraControl.IsOn = true; } float step = 0.2f; @@ -268,17 +268,17 @@ private void TappedResume() private void Tapped_ZoomOut(object sender, SkiaGesturesParameters skiaGesturesParameters) { - CameraControl.Zoom -= step; + _appCameraControl.Zoom -= step; } private void Tapped_ZoomIn(object sender, SkiaGesturesParameters skiaGesturesParameters) { - CameraControl.Zoom += step; + _appCameraControl.Zoom += step; } private void OnZoomed(object sender, ZoomEventArgs e) { - CameraControl.Zoom = e.Value; + _appCameraControl.Zoom = e.Value; } private void TappedFlash() @@ -287,11 +287,11 @@ private void TappedFlash() if (_flashOn) { - CameraControl.FlashMode = FlashMode.On; + _appCameraControl.FlashMode = FlashMode.On; } else { - CameraControl.FlashMode = FlashMode.Off; + _appCameraControl.FlashMode = FlashMode.Off; } SyncUi(); @@ -310,8 +310,8 @@ private void WillFirstTimeDraw(object sender, SkiaDrawingContext e) private void CanvasWillDispose(object sender, EventArgs e) { - CameraControl.NewPreviewSet -= OnPreviewSet; - CameraControl.StateChanged -= OnCameraStateChanged; + _appCameraControl.NewPreviewSet -= OnPreviewSet; + _appCameraControl.StateChanged -= OnAppCameraStateChanged; } private void TappedDrawerHeader() @@ -332,7 +332,7 @@ private void OnContextPropertyChanged(object sender, PropertyChangedEventArgs e) if (_vm.IsRecording) return; - ButtonCapture.BackgroundColor = CameraControl.IsBusy + ButtonCapture.BackgroundColor = _appCameraControl.IsBusy ? Colors.DarkRed : Color.Parse("#CECECE"); } @@ -343,18 +343,18 @@ private void OnContextPropertyChanged(object sender, PropertyChangedEventArgs e) public CaptureFormat SelectedFormat { - get { return CameraControl.CurrentStillCaptureFormat; } + get { return _appCameraControl.CurrentStillCaptureFormat; } } public void SelectFormat(Action changed) { - if (CameraControl.IsOn) + if (_appCameraControl.IsOn) { MainThread.BeginInvokeOnMainThread(async () => { try { - var formats = await CameraControl.GetAvailableCaptureFormatsAsync(); + var formats = await _appCameraControl.GetAvailableCaptureFormatsAsync(); if (!formats.Any()) { @@ -379,15 +379,15 @@ public void SelectFormat(Action changed) if (selectedIndex >= 0) { // Set manual capture mode with selected format - CameraControl.PhotoFormatIndex = selectedIndex; - CameraControl.PhotoQuality = CaptureQuality.Manual; + _appCameraControl.PhotoFormatIndex = selectedIndex; + _appCameraControl.PhotoQuality = CaptureQuality.Manual; OnPropertyChanged(nameof(SelectedFormat)); changed?.Invoke(result); Debug.WriteLine( - $"[CameraApp] Format selection: {selectedIndex} for {CameraControl.CameraDevice.Id}"); + $"[CameraApp] Format selection: {selectedIndex} for {_appCameraControl.CameraDevice.Id}"); - UserSettings.Current.Formats[CameraControl.CameraDevice.Id] = selectedIndex; + UserSettings.Current.Formats[_appCameraControl.CameraDevice.Id] = selectedIndex; } } } @@ -413,24 +413,24 @@ public void SetAspect(bool fullScreen) void ApplyAspect() { - if (CameraControl == null) + if (_appCameraControl == null) { return; } if (IsFullScreen) { - CameraControl.Aspect = TransformAspect.AspectCover; + _appCameraControl.Aspect = TransformAspect.AspectCover; } else { - CameraControl.Aspect = TransformAspect.AspectFitFill; + _appCameraControl.Aspect = TransformAspect.AspectFitFill; } - CameraControl.MirrorPreviewX = IsMirrored; + _appCameraControl.MirrorPreviewX = IsMirrored; UserSettings.Current.Mirror = IsMirrored; - UserSettings.Current.Fill = CameraControl.Aspect == TransformAspect.AspectCover; + UserSettings.Current.Fill = _appCameraControl.Aspect == TransformAspect.AspectCover; } public bool IsFullScreen { get; set; } @@ -449,7 +449,7 @@ private void OnFlashClicked(object sender, object e) { try { - var currentMode = CameraControl.CaptureFlashMode; + var currentMode = _appCameraControl.CaptureFlashMode; var nextMode = currentMode switch { CaptureFlashMode.Off => CaptureFlashMode.Auto, @@ -458,7 +458,7 @@ private void OnFlashClicked(object sender, object e) _ => CaptureFlashMode.Auto }; - CameraControl.CaptureFlashMode = nextMode; + _appCameraControl.CaptureFlashMode = nextMode; SyncUi(); From 13b7b0efff5142aab126cf0caf9000939ff77656 Mon Sep 17 00:00:00 2001 From: Nick Kovalsky Date: Sun, 12 Apr 2026 16:37:16 +0300 Subject: [PATCH 2/3] Save geolocation to EXIF and optimize UI --- README.md | 8 +- src/app/Platforms/Android/AndroidManifest.xml | 4 +- src/app/ShadersCamera.csproj | 9 +- src/app/ViewModels/CameraViewModel.cs | 4 +- src/app/Views/Controls/AppCamera.cs | 7 +- src/app/Views/MainPageCameraFluent.Ui.cs | 116 +++++-------- src/app/Views/MainPageCameraFluent.cs | 153 +++++++----------- 7 files changed, 118 insertions(+), 183 deletions(-) diff --git a/README.md b/README.md index 19e40fb..58462f8 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,10 @@ Read [the blog article](https://taublast.github.io/posts/FiltersCamera/) 👈 ### Latest Changes -* Fixed camera album creation/permission on iOS 26+ -* Use latest camera nuget with better performance and bug fixes -* Smooth filters menu +* Save geolocation to EXIF if permissions granted +* New draw-style shaders Print, Geisha +* Enabled shaders for Android due to performance gain: Poster, Cartoon +* Now uses .NET 10 and updated nugets ### Install @@ -68,7 +69,6 @@ style="margin-top: 16px;" /> * Create presets (BW, For Kids etc..) * Crop manual/presets * Combine with lens shaders -* Save geolocation to EXIF * Shaders editor for mobile version * ML Z-axis detection and apply smart bokeh diff --git a/src/app/Platforms/Android/AndroidManifest.xml b/src/app/Platforms/Android/AndroidManifest.xml index 9ac1420..3c8ae5e 100644 --- a/src/app/Platforms/Android/AndroidManifest.xml +++ b/src/app/Platforms/Android/AndroidManifest.xml @@ -1,5 +1,5 @@  - + @@ -11,5 +11,7 @@ + + \ No newline at end of file diff --git a/src/app/ShadersCamera.csproj b/src/app/ShadersCamera.csproj index 4d8932d..3ec84c0 100644 --- a/src/app/ShadersCamera.csproj +++ b/src/app/ShadersCamera.csproj @@ -94,14 +94,9 @@ - - - - - - - + + diff --git a/src/app/ViewModels/CameraViewModel.cs b/src/app/ViewModels/CameraViewModel.cs index a80b07e..54c1de0 100644 --- a/src/app/ViewModels/CameraViewModel.cs +++ b/src/app/ViewModels/CameraViewModel.cs @@ -381,7 +381,7 @@ private void OnCameraStateChanged(object sender, HardwareState state) private async void OnCaptureSuccess(object sender, CapturedImage captured) { - var imageWithOverlay = await Camera.RenderCapturedPhotoAsync(captured, null, image => + var imageWithEffect = await Camera.RenderCapturedPhotoAsync(captured, null, image => { if (SelectedShader != null) { @@ -394,7 +394,7 @@ private async void OnCaptureSuccess(object sender, CapturedImage captured) }, true); captured.Image.Dispose(); - captured.Image = imageWithOverlay; + captured.Image = imageWithEffect; captured.Meta.Vendor = MauiProgram.ExifCameraVendor; captured.Meta.Model = MauiProgram.ExifCameraModel; diff --git a/src/app/Views/Controls/AppCamera.cs b/src/app/Views/Controls/AppCamera.cs index 98cb8f7..6e85a46 100644 --- a/src/app/Views/Controls/AppCamera.cs +++ b/src/app/Views/Controls/AppCamera.cs @@ -12,8 +12,13 @@ public class AppCamera : SkiaCamera public AppCamera() { - //NeedPermissionsSet = NeedPermissions.Camera | NeedPermissions.Gallery; + NeedPermissionsSet = NeedPermissions.Camera | NeedPermissions.Gallery; //we don't add location because it will be requested by OS when we + UseRealtimeVideoProcessing = true; + + //GPS metadata + this.InjectGpsLocation = true; + #if DEBUG VideoDiagnosticsOn = true; #endif diff --git a/src/app/Views/MainPageCameraFluent.Ui.cs b/src/app/Views/MainPageCameraFluent.Ui.cs index 2a60fed..29e448e 100644 --- a/src/app/Views/MainPageCameraFluent.Ui.cs +++ b/src/app/Views/MainPageCameraFluent.Ui.cs @@ -14,7 +14,7 @@ namespace ShadersCamera.Views public partial class MainCameraPageFluent : BasePageReloadable, IPageWIthCamera { Canvas Canvas; - AppCamera _appCameraControl; + AppCamera CameraControl; //static for Hot Preview public static SkiaViewSwitcher? ViewsContainer; @@ -66,19 +66,18 @@ public override void Build() }; this.Content = -#if IOS - new Grid() //use grid for safe insets on iOS due to MAUI specifics + new Grid() //for safe insets due to MAUI specifics { Children = { Canvas } }; -#else - Canvas; -#endif + Subscribe(true); } SkiaLayout CreateMainLayout() { + var headerSize = 38; + return new SkiaLayout() { HorizontalOptions = LayoutOptions.Fill, @@ -86,13 +85,16 @@ SkiaLayout CreateMainLayout() Children = { CreateCameraControl(), - CreateCameraLayer(), + + CreateControlsPanel(), + + CreateGestureCatcher(), new SkiaDrawer() { UseCache = SkiaCacheType.GPU, Margin = new Thickness(0, 0, 0, 100), - HeaderSize = 40, + HeaderSize = headerSize, Direction = DrawerDirection.FromLeft, VerticalOptions = LayoutOptions.End, HorizontalOptions = LayoutOptions.Fill, @@ -136,7 +138,7 @@ SkiaLayout CreateMainLayout() Footer = new SkiaLayout() { VerticalOptions = LayoutOptions.Fill, - WidthRequest = 8 + 41 //drawer header + WidthRequest = 9 + headerSize //drawer header }, Content = new SkiaLayoutWithSelector() { @@ -185,23 +187,17 @@ SkiaLayout CreateMainLayout() } .Assign(out ShaderDrawer), } - }; - } - - SkiaLayer CreateCameraLayer() - { - return new SkiaLayer() + }.OnTapped(me => { - HorizontalOptions = LayoutOptions.Fill, - VerticalOptions = LayoutOptions.Fill, - Children = - { - //CreateRecordingBadge(), - CreateControlsLayer(), - CreateZoomHotspot() - } - } - .OnTapped(me => { TriggerUpdateSmallPreview = true; }); + if (!CameraControl.IsOn) + { + CameraControl.IsOn = true; + } + else + { + TriggerUpdateSmallPreview = true; + } + }); ; } SkiaShape CreateRecordingBadge() @@ -271,7 +267,7 @@ AppCamera CreateCameraControl() ConstantUpdate = false, Tag = "Camera" } - .Assign(out _appCameraControl) + .Assign(out CameraControl) .ObserveBindingContext((me, vm, prop) => { bool attached = prop == nameof(BindingContext); @@ -282,21 +278,7 @@ AppCamera CreateCameraControl() }); } - SkiaLayer CreateControlsLayer() - { - return new SkiaLayer() - { - //UseCache = SkiaCacheType.Operations, - VerticalOptions = LayoutOptions.Fill, - Children = - { - //CreateCaptureModeLabel(), //todo in next version for video - CreateControlsPanel(), - CreateResumeHotspot() - } - }; - } - + SkiaShape CreateCaptureModeLabel() { return new SkiaShape() @@ -456,12 +438,12 @@ SkiaShape CreateSettingsButton() } .OnTapped(me => { - if (SelectedFormat == null || _appCameraControl.PermissionsError) + if (SelectedFormat == null || CameraControl.PermissionsError) { //camera error try { - _appCameraControl.IsOn = true; + CameraControl.IsOn = true; } catch (Exception e) { @@ -470,7 +452,7 @@ SkiaShape CreateSettingsButton() Tasks.StartDelayed(TimeSpan.FromMilliseconds(500), () => { - if (_appCameraControl.PermissionsError) + if (CameraControl.PermissionsError) { MainThread.BeginInvokeOnMainThread(async () => { @@ -538,13 +520,13 @@ SkiaShape CreateEffectsButton() } .OnTapped(me => { - var current = _appCameraControl.Effect; - var currentIndex = _appCameraControl.AvailableEffects.IndexOf(current); + var current = CameraControl.Effect; + var currentIndex = CameraControl.AvailableEffects.IndexOf(current); // Move to next effect, wrap around to beginning if at end - var nextIndex = (currentIndex + 1) % _appCameraControl.AvailableEffects.Count; + var nextIndex = (currentIndex + 1) % CameraControl.AvailableEffects.Count; - _appCameraControl.SetEffect(_appCameraControl.AvailableEffects[nextIndex]); + CameraControl.SetEffect(CameraControl.AvailableEffects[nextIndex]); }); } @@ -691,35 +673,17 @@ SkiaShape CreateCaptureButton() }); } - SkiaHotspot CreateResumeHotspot() - { - return new SkiaHotspot() - { - HorizontalOptions = LayoutOptions.Center, - LockRatio = 1, - VerticalOptions = LayoutOptions.Center, - WidthRequest = 290, - ZIndex = 110 - } - .OnTapped(me => TappedResume()) - .ObserveBindingContext((me, vm, prop) => - { - bool attached = prop == nameof(BindingContext); - if (attached || prop == nameof(vm.ShowResume)) - { - me.IsVisible = vm.ShowResume; - } - }); - } - - SkiaHotspotZoom CreateZoomHotspot() + SkiaHotspotZoom CreateGestureCatcher() { return new SkiaHotspotZoom() { ZoomMax = 3, ZoomMin = 1 } - .Initialize(hotspot => { hotspot.Zoomed += OnZoomed; }); + .Initialize(hotspot => + { + hotspot.Zoomed += OnZoomed; + }); } DataTemplate CreateShaderItemTemplate() @@ -815,9 +779,9 @@ DataTemplate CreateShaderItemTemplate() }) .OnLongPressing(me => { - if (me.BindingContext is ShaderItem item && _appCameraControl?.CommandEditShader != null) + if (me.BindingContext is ShaderItem item && CameraControl?.CommandEditShader != null) { - _appCameraControl.CommandEditShader.Execute(item); + CameraControl.CommandEditShader.Execute(item); } }); }); @@ -888,7 +852,7 @@ void SyncUi() ApplyAspect(); // CaptureFlashMode - var currentMode = _appCameraControl.CaptureFlashMode; + var currentMode = CameraControl.CaptureFlashMode; switch (currentMode) { case CaptureFlashMode.Off: @@ -903,7 +867,7 @@ void SyncUi() } //FlashMode - var torch = _appCameraControl.FlashMode; + var torch = CameraControl.FlashMode; switch (torch) { case FlashMode.On: @@ -918,8 +882,6 @@ void SyncUi() public void OpenHelp() { - return; - MainThread.BeginInvokeOnMainThread(() => { var popup = new HelpPopup(); diff --git a/src/app/Views/MainPageCameraFluent.cs b/src/app/Views/MainPageCameraFluent.cs index 1c91050..b4a1cbf 100644 --- a/src/app/Views/MainPageCameraFluent.cs +++ b/src/app/Views/MainPageCameraFluent.cs @@ -33,16 +33,26 @@ public MainCameraPageFluent() } } + void RefreshGpsLocationIfNeeded() + { + if (CameraControl.InjectGpsLocation) + { + MainThread.BeginInvokeOnMainThread(() => + { + _ = CameraControl.RefreshGpsLocation(); + }); + } + } + void Subscribe(bool subscribe) { if (subscribe) { Canvas.ViewDisposing += CanvasWillDispose; Canvas.WillFirstTimeDraw += WillFirstTimeDraw; - if (_appCameraControl != null) + if (CameraControl != null) { - _appCameraControl.CaptureFlashMode = (CaptureFlashMode)UserSettings.Current.Flash; - _appCameraControl.PropertyChanged += OnContextPropertyChanged; + CameraControl.CaptureFlashMode = (CaptureFlashMode)UserSettings.Current.Flash; } } else @@ -52,28 +62,23 @@ void Subscribe(bool subscribe) Canvas.ViewDisposing -= CanvasWillDispose; Canvas.WillFirstTimeDraw -= WillFirstTimeDraw; } - - if (_appCameraControl != null) - { - _appCameraControl.PropertyChanged -= OnContextPropertyChanged; - } } } void AttachCamera() { - if (BindingContext is CameraViewModel vm && _appCameraControl != null) + if (BindingContext is CameraViewModel vm && CameraControl != null) { - vm.AttachCamera(_appCameraControl); + vm.AttachCamera(CameraControl); - _appCameraControl.NewPreviewSet += OnPreviewSet; - _appCameraControl.StateChanged += OnAppCameraStateChanged; + CameraControl.NewPreviewSet += OnPreviewSet; + CameraControl.StateChanged += OnCameraStateChanged; SyncUi(); try { - _appCameraControl.IsOn = true; + CameraControl.IsOn = true; } catch (Exception e) { @@ -95,20 +100,34 @@ void AttachCamera() /// private bool StartupSuccessChecked; - private void OnAppCameraStateChanged(object sender, HardwareState state) + private void OnCameraStateChanged(object sender, HardwareState state) { if (state == HardwareState.On) { Debug.WriteLine($"[CameraApp] State in ON!"); - if (UserSettings.Current.Formats.TryGetValue(_appCameraControl.CameraDevice.Id, out var format)) + if (UserSettings.Current.Formats.TryGetValue(CameraControl.CameraDevice.Id, out var format)) { - _appCameraControl.PhotoFormatIndex = format; - _appCameraControl.PhotoQuality = CaptureQuality.Manual; + CameraControl.PhotoFormatIndex = format; + CameraControl.PhotoQuality = CaptureQuality.Manual; } else { - _appCameraControl.PhotoQuality = CaptureQuality.Medium; + CameraControl.PhotoQuality = CaptureQuality.Medium; + } + + if (CameraControl.Display != null) + { + CameraControl.Display.Blur = 0; + } + + RefreshGpsLocationIfNeeded(); + } + else + { + if (CameraControl.Display != null) + { + CameraControl.Display.Blur = 10; } } } @@ -194,7 +213,7 @@ private void OnPreviewSet(object sender, LoadedImageSource source) }; if (dispose != null) { - _appCameraControl.DisposeObject(dispose); + CameraControl.DisposeObject(dispose); } //for AI/ML use this: @@ -228,9 +247,9 @@ public LoadedImageSource SmallPreview private void TappedSwitchCamera() { - if (_appCameraControl.IsOn) + if (CameraControl.IsOn) { - _appCameraControl.Facing = _appCameraControl.Facing == CameraPosition.Selfie + CameraControl.Facing = CameraControl.Facing == CameraPosition.Selfie ? CameraPosition.Default : CameraPosition.Selfie; } @@ -238,47 +257,22 @@ private void TappedSwitchCamera() private void TappedTurnCamera() { - if (_appCameraControl.State == HardwareState.On) + if (CameraControl.State == HardwareState.On) { - _appCameraControl.IsOn = false; + CameraControl.IsOn = false; } else { - _appCameraControl.IsOn = true; + CameraControl.IsOn = true; } } - - private async void TappedTakePicture(object sender, SkiaGesturesParameters skiaGesturesParameters) - { - if (_appCameraControl.State == HardwareState.On && !_appCameraControl.IsBusy) - { - _appCameraControl.FlashScreen(Color.Parse("#EEFFFFFF")); - await _appCameraControl.TakePicture().ConfigureAwait(false); - } - } - - private void TappedResume() - { - _appCameraControl.IsOn = true; - } - float step = 0.2f; private bool _flashOn; - private void Tapped_ZoomOut(object sender, SkiaGesturesParameters skiaGesturesParameters) - { - _appCameraControl.Zoom -= step; - } - - private void Tapped_ZoomIn(object sender, SkiaGesturesParameters skiaGesturesParameters) - { - _appCameraControl.Zoom += step; - } - private void OnZoomed(object sender, ZoomEventArgs e) { - _appCameraControl.Zoom = e.Value; + CameraControl.Zoom = e.Value; } private void TappedFlash() @@ -287,21 +281,16 @@ private void TappedFlash() if (_flashOn) { - _appCameraControl.FlashMode = FlashMode.On; + CameraControl.FlashMode = FlashMode.On; } else { - _appCameraControl.FlashMode = FlashMode.Off; + CameraControl.FlashMode = FlashMode.Off; } SyncUi(); } - private void TappedBackground(object sender, ControlTappedEventArgs e) - { - TriggerUpdateSmallPreview = true; - } - private void WillFirstTimeDraw(object sender, SkiaDrawingContext e) { @@ -310,8 +299,8 @@ private void WillFirstTimeDraw(object sender, SkiaDrawingContext e) private void CanvasWillDispose(object sender, EventArgs e) { - _appCameraControl.NewPreviewSet -= OnPreviewSet; - _appCameraControl.StateChanged -= OnAppCameraStateChanged; + CameraControl.NewPreviewSet -= OnPreviewSet; + CameraControl.StateChanged -= OnCameraStateChanged; } private void TappedDrawerHeader() @@ -320,41 +309,23 @@ private void TappedDrawerHeader() } - /// - /// Observing SkiaCamera props - /// - /// - /// - private void OnContextPropertyChanged(object sender, PropertyChangedEventArgs e) - { - if (e.PropertyName == nameof(SkiaCamera.IsBusy)) - { - if (_vm.IsRecording) - return; - - ButtonCapture.BackgroundColor = _appCameraControl.IsBusy - ? Colors.DarkRed - : Color.Parse("#CECECE"); - } - } - #region SELECT FORMAT public CaptureFormat SelectedFormat { - get { return _appCameraControl.CurrentStillCaptureFormat; } + get { return CameraControl.CurrentStillCaptureFormat; } } public void SelectFormat(Action changed) { - if (_appCameraControl.IsOn) + if (CameraControl.IsOn) { MainThread.BeginInvokeOnMainThread(async () => { try { - var formats = await _appCameraControl.GetAvailableCaptureFormatsAsync(); + var formats = await CameraControl.GetAvailableCaptureFormatsAsync(); if (!formats.Any()) { @@ -379,15 +350,15 @@ public void SelectFormat(Action changed) if (selectedIndex >= 0) { // Set manual capture mode with selected format - _appCameraControl.PhotoFormatIndex = selectedIndex; - _appCameraControl.PhotoQuality = CaptureQuality.Manual; + CameraControl.PhotoFormatIndex = selectedIndex; + CameraControl.PhotoQuality = CaptureQuality.Manual; OnPropertyChanged(nameof(SelectedFormat)); changed?.Invoke(result); Debug.WriteLine( - $"[CameraApp] Format selection: {selectedIndex} for {_appCameraControl.CameraDevice.Id}"); + $"[CameraApp] Format selection: {selectedIndex} for {CameraControl.CameraDevice.Id}"); - UserSettings.Current.Formats[_appCameraControl.CameraDevice.Id] = selectedIndex; + UserSettings.Current.Formats[CameraControl.CameraDevice.Id] = selectedIndex; } } } @@ -413,24 +384,24 @@ public void SetAspect(bool fullScreen) void ApplyAspect() { - if (_appCameraControl == null) + if (CameraControl == null) { return; } if (IsFullScreen) { - _appCameraControl.Aspect = TransformAspect.AspectCover; + CameraControl.Aspect = TransformAspect.AspectCover; } else { - _appCameraControl.Aspect = TransformAspect.AspectFitFill; + CameraControl.Aspect = TransformAspect.AspectFitFill; } - _appCameraControl.MirrorPreviewX = IsMirrored; + CameraControl.MirrorPreviewX = IsMirrored; UserSettings.Current.Mirror = IsMirrored; - UserSettings.Current.Fill = _appCameraControl.Aspect == TransformAspect.AspectCover; + UserSettings.Current.Fill = CameraControl.Aspect == TransformAspect.AspectCover; } public bool IsFullScreen { get; set; } @@ -449,7 +420,7 @@ private void OnFlashClicked(object sender, object e) { try { - var currentMode = _appCameraControl.CaptureFlashMode; + var currentMode = CameraControl.CaptureFlashMode; var nextMode = currentMode switch { CaptureFlashMode.Off => CaptureFlashMode.Auto, @@ -458,7 +429,7 @@ private void OnFlashClicked(object sender, object e) _ => CaptureFlashMode.Auto }; - _appCameraControl.CaptureFlashMode = nextMode; + CameraControl.CaptureFlashMode = nextMode; SyncUi(); From e0f1592800cb4caf5d810d5951b7341ff4f02404 Mon Sep 17 00:00:00 2001 From: Nick Kovalsky Date: Sun, 12 Apr 2026 16:42:58 +0300 Subject: [PATCH 3/3] Save filter name to EXIF Software --- README.md | 6 +++--- src/app/ViewModels/CameraViewModel.cs | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 58462f8..13eb370 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,10 @@ Read [the blog article](https://taublast.github.io/posts/FiltersCamera/) 👈 ### Latest Changes -* Save geolocation to EXIF if permissions granted -* New draw-style shaders Print, Geisha +* New draw-style shaders: Print, Geisha * Enabled shaders for Android due to performance gain: Poster, Cartoon +* Save geolocation to EXIF if permissions granted +* Save filter name to EXIF Software * Now uses .NET 10 and updated nugets ### Install @@ -59,7 +60,6 @@ style="margin-top: 16px;" /> * Rotate saved photo on iOS if taken while rotated even if rotation turned off for app * Rotate previews in menu when phone is rotated to landscape -* Save filter name to EXIF (what field, Software (0x0131)?) * Add selection indicator for previews, scroll to selected at startup * Pass rendering scale as uniform for all shaders for full consistency between preview and large capture * Localization and change language in settings diff --git a/src/app/ViewModels/CameraViewModel.cs b/src/app/ViewModels/CameraViewModel.cs index 54c1de0..bdf4994 100644 --- a/src/app/ViewModels/CameraViewModel.cs +++ b/src/app/ViewModels/CameraViewModel.cs @@ -398,6 +398,10 @@ private async void OnCaptureSuccess(object sender, CapturedImage captured) captured.Meta.Vendor = MauiProgram.ExifCameraVendor; captured.Meta.Model = MauiProgram.ExifCameraModel; + if (SelectedShader != null) + { + captured.Meta.Software = SelectedShader.Title; + } await Camera.SaveToGalleryAsync(captured);