diff --git a/.github/testshards/internal-ui.tsv b/.github/testshards/internal-ui.tsv index 3e479854..18911d4d 100644 --- a/.github/testshards/internal-ui.tsv +++ b/.github/testshards/internal-ui.tsv @@ -5,8 +5,8 @@ # baseline-run: 34627413846 # baseline-attempts: 1 # weights: single CI attempt; projected test sums, not measured shard runtimes -# ui-1: 236 entries, 470.520s projected weight sum -# ui-2: 248 entries, 470.530s projected weight sum +# ui-1: 238 entries, 470.520s projected weight sum +# ui-2: 253 entries, 470.530s projected weight sum # ui-3: 202 entries, 504.870s projected weight sum # rebalance: 27 whole-test moves; slowest projected sum within 5% of mean # evidence: finished_refactorings/2026-09-11-ui-shard-rebalance.md @@ -117,6 +117,8 @@ TestGlobalFavoriteShortcutOpensStoredFilesThroughViewer ui-1 TestGridBrowseDuringAnalysis ui-1 TestGridHighlight_TitleKeepsTheModePrefixes ui-1 TestGridHighlight_VariantsHoverUpdatesTitleAndHidesMergePrefix ui-1 +TestHEICOwnerStopsWithViewer ui-1 +TestHEICSourceConsumers ui-1 TestHandleDropBlockedWhileCopyPendingShowsToast ui-1 TestHandleDrop_ClosesOpenGrid ui-1 TestHandleDrop_HidesRestoreLinkEvenWithoutUsingIt ui-1 @@ -331,6 +333,11 @@ TestE2E_LaunchWithSavedSessionShowsRestoreLink ui-2 TestE2E_SuccessfulDropShowsImage ui-2 TestExifLink_OpensExifWindow ui-2 TestExifNavigationCancelsMetadataBeforeTheNextImageLoads ui-2 +TestExperimentalHEICActiveAnalysis ui-2 +TestExperimentalHEICAdmission ui-2 +TestExperimentalHEICPreviewConsumers ui-2 +TestExperimentalHEICRestartOnly ui-2 +TestExperimentalHEICStartup ui-2 TestExportAs_CancelWritesNothing ui-2 TestExportAs_ReportsAFailedWrite ui-2 TestExportAs_RotationCorrectsDimensionTagsButNotFilenameOrToast ui-2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f24c2424..7bdbc103 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,9 @@ jobs: - name: Check formatting run: make fmt-check + - name: Verify isolated HEIC development artifact + run: make heic-check-provenance heic-check-imports + - name: Check search review controls run: | node --version @@ -152,6 +155,135 @@ jobs: if-no-files-found: error retention-days: 14 + heic-linux: + name: Linux ${{ matrix.arch }} HEIC native guards (attempt ${{ github.run_attempt }}) + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-latest + arch: amd64 + - runner: ubuntu-24.04-arm + arch: arm64 + runs-on: ${{ matrix.runner }} + steps: + - name: Check out code + uses: actions/checkout@v7 + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + - name: Install Linux application fixture dependencies + run: | + sudo apt-get update + sudo apt-get install -y gcc libgl1-mesa-dev xorg-dev libwayland-dev libxkbcommon-dev + - name: Qualify HEIC native isolation and address-space ceiling + run: go run ./scripts/nativeguards -suite heic-linux -capture "${{ runner.temp }}/native-guards-heic-linux.json" + - name: Upload native guard events + if: always() + uses: actions/upload-artifact@v7 + with: + name: native-guards-Linux-${{ matrix.arch }}-${{ github.run_id }}-attempt-${{ github.run_attempt }} + path: ${{ runner.temp }}/native-guards-heic-linux.json + if-no-files-found: error + retention-days: 14 + + heic-windows: + name: Windows ${{ matrix.arch }} HEIC sandbox and pipe guards (attempt ${{ github.run_attempt }}) + env: + # The shipped Windows helper is pure Go; ARM runners lack a native C compiler. + CGO_ENABLED: '0' + strategy: + fail-fast: false + matrix: + include: + - runner: windows-latest + arch: amd64 + - runner: windows-11-arm + arch: arm64 + runs-on: ${{ matrix.runner }} + steps: + - name: Check out code + uses: actions/checkout@v7 + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + - name: Qualify HEIC as a disposable standard user + shell: pwsh + run: ./packaging/heic/qualify-windows.ps1 -Scenario standalone -EvidenceDirectory "${{ runner.temp }}/heic-standard-user" + - name: Upload native guard events + if: always() + uses: actions/upload-artifact@v7 + with: + name: native-guards-Windows-HEIC-${{ matrix.arch }}-${{ github.run_id }}-attempt-${{ github.run_attempt }} + path: ${{ runner.temp }}/heic-standard-user/ + if-no-files-found: error + retention-days: 14 + + heic-msix-build: + name: Build complete HEIC test-MSIX inputs + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + - name: Build Store-managed applications for both native targets + run: make package-windows-store + - uses: actions/upload-artifact@v7 + with: + name: heic-qualification-store-executables + path: bin/picfetch-microsoft-store-*.exe + if-no-files-found: error + retention-days: 14 + + heic-msix: + name: Installed MSIX ${{ matrix.arch }} HEIC activation (attempt ${{ github.run_attempt }}) + needs: heic-msix-build + env: + CGO_ENABLED: '0' + strategy: + fail-fast: false + matrix: + include: + - runner: windows-latest + arch: amd64 + runtime: x64 + - runner: windows-11-arm + arch: arm64 + runtime: arm64 + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + - uses: actions/download-artifact@v8 + with: + name: heic-qualification-store-executables + path: dist/executables + - name: Fetch the build-time pinned runtime package + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + Invoke-WebRequest -Uri 'https://github.com/microsoft/onnxruntime/releases/download/v1.29.0/onnxruntime-win-${{ matrix.runtime }}-1.29.0.zip' -OutFile dist/runtime.zip + - name: Install and activate the disposable test-MSIX as a standard user + shell: pwsh + run: ./packaging/heic/qualify-windows.ps1 -Scenario msix -Executable dist/executables/picfetch-microsoft-store-${{ matrix.arch }}.exe -RuntimeArchive dist/runtime.zip -EvidenceDirectory "${{ runner.temp }}/heic-installed-msix" + - name: Upload installed activation and provisioning evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: native-guards-Windows-MSIX-${{ matrix.arch }}-${{ github.run_id }}-attempt-${{ github.run_attempt }} + path: ${{ runner.temp }}/heic-installed-msix/ + if-no-files-found: error + retention-days: 14 + windows-test: name: Windows tests (attempt ${{ github.run_attempt }}) runs-on: windows-latest @@ -206,6 +338,9 @@ jobs: - name: Check Explorer runtime selection and worker exit run: go test ./internal/ort ./internal/similarity ./scripts/explorereval -count=1 + - name: Qualify signed HEIC App Sandbox helper + run: go run ./scripts/nativeguards -suite heic-macos -capture "${{ runner.temp }}/native-guards-heic-macos.json" + - name: Qualify Intel Explorer download and offline inference if: matrix.arch == 'amd64' run: make explorer-install-test diff --git a/.github/workflows/microsoft-store.yml b/.github/workflows/microsoft-store.yml index ceb654ef..2a0b6651 100644 --- a/.github/workflows/microsoft-store.yml +++ b/.github/workflows/microsoft-store.yml @@ -69,6 +69,10 @@ jobs: if ($LASTEXITCODE -ne 0) { throw 'Staging the x64 package or pinned runtime failed.' } go run -tags no_emoji,nodynamic ./scripts/msixstage -arch arm64 -exe dist/executables/picfetch-microsoft-store-arm64.exe -runtime-archive dist/onnxruntime-win-arm64.zip -out dist/stage-arm64 if ($LASTEXITCODE -ne 0) { throw 'Staging the ARM64 package failed.' } + go run ./scripts/heicpackage -os windows -arch amd64 -out dist/stage-x64 + if ($LASTEXITCODE -ne 0) { throw 'Staging the x64 HEIC helper failed.' } + go run ./scripts/heicpackage -os windows -arch arm64 -out dist/stage-arm64 + if ($LASTEXITCODE -ne 0) { throw 'Staging the ARM64 HEIC helper failed.' } - name: Ensure Desktop C++ framework for local certification shell: pwsh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 730a743f..ece5393f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -83,12 +83,12 @@ jobs: # name already carries the arch, and that is what users unpacked # before Windows gained a second architecture. for arch in amd64 arm64; do - cp "picfetch-windows-$arch.exe" "picfetch.exe" - zip "picfetch-windows-$arch.zip" "picfetch.exe" ../LICENSE ../THIRD-PARTY-NOTICES.md ../PRIVACY.md -j - rm "picfetch.exe" + cp "picfetch-windows-$arch.exe" "heic-windows-$arch/picfetch.exe" + cp ../LICENSE ../THIRD-PARTY-NOTICES.md ../PRIVACY.md "heic-windows-$arch/" + (cd "heic-windows-$arch" && zip -r "../picfetch-windows-$arch.zip" picfetch.exe heic LICENSE THIRD-PARTY-NOTICES.md PRIVACY.md) done for arch in amd64 arm64; do - tar -czf "picfetch-linux-$arch.tar.gz" "picfetch-linux-$arch" -C .. LICENSE THIRD-PARTY-NOTICES.md PRIVACY.md + tar -czf "picfetch-linux-$arch.tar.gz" "picfetch-linux-$arch" -C "heic-linux-$arch" heic -C "$GITHUB_WORKSPACE" LICENSE THIRD-PARTY-NOTICES.md PRIVACY.md done - name: Upload unsigned Windows artifacts for signing @@ -119,6 +119,7 @@ jobs: permissions: contents: read steps: + # No checkout, repository programs or build toolchain may run with the signer. - name: Download unsigned Windows artifacts uses: actions/download-artifact@v8 with: @@ -178,12 +179,38 @@ jobs: if ($LASTEXITCODE -ne 0) { throw "Signing failed for $($archive.Name)." } - & $signtool.FullName verify /pa /all /v /tw $executable - if ($LASTEXITCODE -ne 0) { - throw "Signature verification failed for $($archive.Name)." + $helper = Join-Path $unpacked 'heic/picfetch-heic-worker.exe' + if (-not (Test-Path -LiteralPath $helper -PathType Leaf)) { throw 'The HEIC helper is missing.' } + & $signtool.FullName sign /fd sha256 /tr http://time.certum.pl /td sha256 /sha1 $env:CERTUM_CERT_THUMBPRINT /v $helper + if ($LASTEXITCODE -ne 0) { throw 'HEIC helper signing failed.' } + $arch = if ($archive.BaseName.EndsWith('-amd64')) { 'amd64' } else { 'arm64' } + + # Only fixed data operations run after authentication. Never execute + # repository code here, even to refresh a post-signing digest. + $manifestPath = Join-Path $unpacked 'heic/manifest.json' + if ((Get-Item -LiteralPath $manifestPath -ErrorAction Stop).Length -gt 4096) { + throw 'The HEIC manifest is too large.' + } + $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json -AsHashtable + if ($manifest.version -ne 1 -or $manifest.goos -cne 'windows' -or $manifest.goarch -cne $arch -or + $manifest.guestSHA256 -cnotmatch '^[0-9a-f]{64}$' -or $manifest.guestSHA256 -ceq ('0' * 64)) { + throw 'The HEIC manifest target or guest digest is invalid.' + } + $finalManifest = [ordered]@{ + version = 1 + goos = 'windows' + goarch = $arch + executableSHA256 = (Get-FileHash -LiteralPath $helper -Algorithm SHA256).Hash.ToLowerInvariant() + guestSHA256 = $manifest.guestSHA256 + } + $finalManifest | ConvertTo-Json | Set-Content -LiteralPath $manifestPath -Encoding utf8NoBOM + + foreach ($signedFile in @($executable, $helper)) { + & $signtool.FullName verify /pa /all /v /tw $signedFile + if ($LASTEXITCODE -ne 0) { throw "Final signature verification failed: $signedFile" } } - $packageFiles = @($executable) + $packageFiles = @($executable, (Join-Path $unpacked 'heic')) foreach ($name in @('LICENSE', 'THIRD-PARTY-NOTICES.md', 'PRIVACY.md')) { $notice = Join-Path $unpacked $name if (-not (Test-Path -LiteralPath $notice -PathType Leaf)) { diff --git a/.gitignore b/.gitignore index b780eae0..f8c4ed17 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ bin/ coverage/ .DS_Store fyne-cross/ +/fyne_metadata_init.go internal/ui/testdata/failed/ .idea .vscode diff --git a/AGENTS.md b/AGENTS.md index 8bd9c5cb..46a549ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,6 +110,8 @@ precedence over its default handoff procedure. - Use `internal/uitest` for synthetic image formats, temp URIs, approximate comparisons, and OS seam stubs. UI tests should build through `newTestUI`/`newTestViewer`, which mirror production startup. - `CanvasObject.Visible()` is the object's own hidden flag, not a statement about the tree it is in: a widget that was built and then left out of its container still reports `true`. A test that asserts only `Visible()` therefore passes on a widget that never reaches the screen. When the fact under test is "this is *in* the surface", walk the container from its root — see `infoview`'s `inCard` in `card_test.go`. `Visible()` alone is enough only for a widget already known to be in the tree whose show/hide is what moves. - Keep platform-specific behavior in existing build-tag pairs and preserve no-cgo AVIF decoding through `gen2brain` WASM; Fyne itself still requires a C/OpenGL toolchain. +- **HEIC source changes:** Read [the maintained-source record](third_party/h265/PICFETCH.md) before changing the decoder or its build. Preserve local hardening until equivalent upstream checks are verified. HEIC parsing belongs only in the separate WASI guest; keep the native import guard and fail-closed application boundary. `make test-h265` covers ordinary WASI fixtures, not the historical full decoder suite. +- **Security-boundary changes:** Reconcile [THREAT-MODEL.md](THREAT-MODEL.md) when parser ownership, worker permissions, memory limits, sensitive persistence or update authority changes; distinguish implemented controls from platform qualification. ## Build and Verification @@ -118,7 +120,7 @@ precedence over its default handoff procedure. - Direct Go commands that import imaging require `-tags no_emoji,nodynamic`; Make targets supply them. The build guard rejects missing `nodynamic` or enabled `wasm2go` before decoder initialization. - `make package-mac` runs `go run -tags no_emoji,nodynamic ./scripts/plistdoctypes` to derive the packaged app's `CFBundleTypeExtensions` from `imaging.SupportedExtensions()`; it no longer depends on `python3`. - Match CI before handoff with `make verify`: formatting/TUF checks, vet and build run from the repository root, and the race suite runs through the same Linux/amd64 Docker path as `make test`. -- **HEIC/HEIF support is disabled** pending decoder distribution and security qualification. See [the removal record](finished_refactorings/2026-09-14-remove-heic-decoder.md) before considering restoration. +- **HEIC/HEIF is default-off and restart-only** through Experimental Settings. Before changing activation or packaging, read [the activation record](docs/heic/experimental-opt-in.md) for complete-package requirements and unverified native/release gates. Retain every sandbox, identity and resource check. - Run focused tests while iterating, e.g. `go test -tags no_emoji,nodynamic -run TestE2E -v ./internal/ui/...`; the complete suite remains the final check. - **GoLand inspections before feature completion:** Inspect every changed code file with GoLand, review all findings (including weak warnings), and fix confirmed issues before finalizing a feature. Document and mitigate confirmed false positives with narrowly scoped suppressions or exclusions. Re-run inspections after fixes or mitigations, and report unavailable or incomplete inspection results as unverified. - Golden screenshots are under `internal/ui/testdata/`. Regenerate only with `make golden` (Docker linux/amd64), inspect `internal/ui/testdata/failed/*.png`, and never commit failed renders. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a4891f13..ccd5a422 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -4,6 +4,8 @@ Navigation map for AI agents. PicFetch is a Fyne desktop image viewer: one binary, split into `internal/...` packages. Start here to find a file. Standing rules (data flow, concurrency, conventions, build) live in `AGENTS.md` — do not duplicate them here. +`THREAT-MODEL.md` maps application assets, trust boundaries and residual risks; +HEIC execution and distribution evidence lives in `docs/heic/qualification.md`. ## Package map @@ -47,6 +49,12 @@ the raw events outside the repository; the generated assignment lives at | `docker-race.sh` | Public Make runner: unique host artifact directory, attached Docker lifecycle, raw streams and console retention, exit/memory/OOM diagnostics before cleanup. | | `main_test.go` | Command-boundary fixtures for event streams and capture, deterministic planning, every manifest rejection, build-selected runnable forms, parallel-call refusal, exact filter generation, package partitioning, and Make contract expansion. | +### `scripts/qodanaconfig` + +Validates the root Qodana YAML mapping before Make's exact test-path inventory +check. `main_test.go` covers malformed mappings, commented parents and duplicate +keys; text path matching alone cannot establish YAML validity. + ### `internal/similarity` Local content analysis shared by the viewer and its reproducible experiment. @@ -56,6 +64,10 @@ dispatch. Each request captures the caller's encoded-file size limit; the worker installs it before source reads. `analyze.go` accounts for every input, captures source versions, reuses canonical full oriented decoding, makes previews, and publishes a map on manual request, optionally every 30 sources, and at completion. +`heic.go` attaches the optional application-owned HEIC client's explicit pipes, +initializes one worker Remote/`imaging.Reader`, and joins connection cleanup. +Analysis and retained search derive hashes/facts/previews from `imaging.Source`; +workers receive no helper executable selection and cannot launch their own. `facts.go` captures versioned oriented dimensions, normalized extension and optional EXIF camera/calendar-date facts from already-read sources; old cached representations backfill these facts without inference. `tags.go` exposes catalogue identity for @@ -483,6 +495,7 @@ The concurrency invariant: see `AGENTS.md` § Concurrency and Fyne. | `run.go` | `Run`: restore startup viewer, start runtime (`favstore.DefaultDir`, position polling), register shutdown and CLI drop, enter the Fyne loop. Shutdown retires title/menu updates, cancels feature work and flushes preferences without rebuilding retired native menus. Store-managed builds skip GitHub update startup and staged-binary apply. Explicit trial startup reserves new evidence, isolates Favorites/presets/updates, disables update activity, auto-opens Explorer after the ordinary scan, and joins its signal watcher and workers before finalizing evidence. | | `build.go` | `buildViewer` composes widgets and `registerFeatures` modules and snapshots `distribution.StoreManaged` onto the viewer. Overlay tail: copy selection, similarity map, grid, comparison (including its pointer shield), delete confirm, export prompt, toast. Desktop canvases also receive the chained comparison key-down hook for exact physical `Ctrl+L`; ordinary typed-key and shortcut wiring remains separate. | | `startup.go` | `loadStartupState` / `restoreStartupGeometry` / `buildStartupViewer` — the one load→build→restore path shared by `Run` and tests. | +| `images.go` | One optional app-family HEIC owner and immutable foreground/background `imaging.Reader` values injected before GUI and analysis work. Shutdown Stop/Wait owns complete helper/pipe retirement. Startup loads preferences first and creates one owner only for a valid, opted-in installed helper package; unavailable status is separate from immutable admission. | | `components.go` | Dropzone, scan, sort, and info-overlay constructors. Toast stays in `toast.go`. | | `trane.go` | Welcome-screen Trane: hosts `widgets.Gaze` with a compact 17-cell atlas. Owns pointer/window-layout coordinates, scaled dead zone, immutable decode cache and magenta-spill correction within five source pixels of transparency. Hide/MouseOut forget pointer position and circle progress. A hover-only surface preserves input across the restore link; ten circles request `Help.ShowFinis`. No timers or background workers. `scripts/appassets` retains the used pixels from `assets/trane/codex-pet/spritesheet.webp`. | | `explorer.go` | Root adapter for Explorer: captures duplicate-prepared sources, composes setup acknowledgment with preferences, maps frozen cohort identities to collection indexes, and coordinates map/Grid/image transitions. The feature owns workflow, dialogs, workers and delivery. `explorerInput` retains collection/launch/window policy only. | @@ -546,7 +559,7 @@ The concurrency invariant: see `AGENTS.md` § Concurrency and Fyne. | `internal/ui/exifwin/` | EXIF panel (E): `metadata.go` owns cancellable source reads, generation-checked tag/GPS/action presentation and separate `MetadataDone` completion. `stripwork.go` owns cancellable removal, busy admission and a committed `WriteResult` in the Host notification. GPS map (`tiles.go`, `tilework.go`, `startWarm`): four shared workers, a 64-job queue, 256 expiring failure entries and a 16 MiB encoded-byte cache. Navigation/close cancels old reads and tile sessions; collapse cancels tiles. `uiqueue.go` owns result delivery and Settle waits/drains removal, metadata, warm and tile workers repeatedly. Shutdown Stop is terminal. Geometry via `widgets.Singleton`. | 4-method `Host`. | | `internal/ui/help/` | Manual, About, What's New (`whatsnew.go`), Help menu; embeds `manual.md` / `manual_de.md`. Secret search phrase calls the viewer’s registered `SetOnSpiral` callback; `finis` in manual search opens the cursor-following companion (`finis.go`, embedded `finis.webp`), hosting `widgets.Gaze` with centered portrait geometry and its own hover surface. `ShowFinis` also serves welcome Trane; ten independent circles reveal the localized, wrapped bubble in `finis_clue.go`, whose click opens an empty focused manual search. | `New(app, title, art)` plus optional event callbacks. | | `internal/ui/spiral/` | Full-screen shader easter egg. `tunnel.go` owns serial preview admission and three texture slots; `playback.go` advances bounded GIF frames on the existing UI clock with independent flight origins; `flow.go` owns cycles, batch variation and route selection; `flight.go` owns safe route geometry used for admission/retirement; `shader.go` renders depth, feather and translucent composition. `uiqueue.go` marshals preview/frame callbacks with session checks. H toggles local help; F1 invokes the viewer's manual callback. The shared centre stays within a resized viewport. | Viewer supplies a frozen URI value to `Show` / `ShowForGesture`; `Close` cancels on UI and test `Settle` joins/drains off UI. Process shutdown does not join uninterruptible preview source reads. | -| `internal/ui/settingswin/` | Settings: General/Appearance/Updates/Limits, update dialogs, snapshot seed, live apply, Singleton geometry. `Show(State, storeManaged)` replaces the GitHub update controls with Store-owned-update copy when applicable. | `Show(State, bool)` + Host (`ApplySettings`, `CheckForUpdatesNow`, `PerformUpdate`). | +| `internal/ui/settingswin/` | Settings: General/Appearance/Updates/Limits, optional Cache, then Experimental; General always opens first. Snapshot seed, live apply, restart-only HEIC intent, unavailable explanation, update dialogs and Singleton geometry. `Show(State, storeManaged)` replaces the GitHub update controls with Store-owned-update copy when applicable. | `Show(State, bool)` + Host (`ApplySettings`, `CheckForUpdatesNow`, `PerformUpdate`). | | `internal/ui/favorites/` | Favorites menu and add/overwrite/manage/remove dialogs. `New` does no disk I/O; `SetDir` from `Run`. `SetCommandsEnabled` preserves Add's file availability while disabling both static and dynamically rebuilt menu entries during comparison. | 6-method `Host`. | | `internal/ui/menus/` | The stateful File/Window/Actions menu items and their whole Checked/Disabled matrix as `Apply(State) (changed bool)`, a pure function of a value snapshot. `ComparisonActive` applies a final all-ordinary-items-disabled override while leaving Help available. Fyne-typed but viewer-free, unit-testable with no app. Menu-bar assembly, the Darwin native-bar fold, the real shortcut bindings, and every action the items run all stay in `internal/ui`. | No Host: `Apply(State)` over a value snapshot built by `menu.go`'s `menuState()`. | | `internal/ui/autoupdate/` | Shared serialized automatic/manual update worker: lazy verifier/client preparation, check/download progress events, matching-stage reuse, all-worker settle, last-check-day persistence, staged apply/relaunch intent, the What's-New cache (`whatsnew.go`), and the apply-failure cache (`applyfailure.go`) — `ApplyStagedUpdate` writes it when `update.Apply` fails, and `internal/ui` reads and clears it on the next launch. Both caches are one JSON document each in `app.Cache()`, over the `saveCacheJSON` / `loadCacheJSON` / `clearCacheJSON` helpers in `cache.go`; a failed relaunch is deliberately *not* recorded, since it happens after the new binary is installed and verified. | No Host: takes a `context.Context` and a staleness func per call (`Start` / `StartManual`), plus `Persist` and per-`Updater` verifier-factory seams — cancellation stays the viewer's own `requestLifecycle`, not promoted here. | @@ -567,6 +580,7 @@ Encode/write-back for a subset of formats lives in `save.go`; `mutations.go` ser |------|----------------| | `bytecache.go` | `ByteCache[V]`: goroutine-safe LRU by estimated bytes. `Add` admits foreground images even over budget; generation-bound `CacheWriter.AddIfRoom` admits display preloads only into remaining space without eviction or promotion. `AddIfFits` keeps its existing individual-size gate and may evict. `LoadedImage.DecodedBytes` shares retained pixel/vector accounting with the mosaic repeat cache. | | `loader.go` | `LoadedImage`, `NewImgCache`, `ReadAndProbe`, `CaptureDateContext` (cancellable metadata reads), `DecodeLoaded` (pixels), `DecodeRecord` (complete full-cache facts), `LoadImage`, `IsSupportedImage`, `SupportedExtensions`, `MaxEncodedBytes` / `InputTooLargeError`. | +| `source.go`, `source_kind.go` | Immutable `Reader` and `Source` share ordinary probe/decode algorithms and carry validated HEIC pixels/normalized metadata without retaining encoded HEIC bytes. Reader injection selects the shared owner; zero value refuses HEIC. Reader format queries capture this capability; package-level queries and OS associations stay unchanged. Bounded leading file-type dispatch precedes bulk reads and prevents native HEIC metadata/preview fallback. `InspectMetadata` returns normalized values, byte count and JPEG strip capability from one source read. | | `ico.go` | Explicit ICO probe/decode dispatch, independent of the desktop driver's decoder registration: validates directory/payload spans and dimensions, selects the same single image for probe/decode, delegates PNG or normalized uncompressed DIB pixels to existing decoders, and applies icon transparency. | | `raw.go` | Largest embedded JPEG from TIFF IFDs or SOI scan (CR3/RAF). | | `svg.go` | SVG detection, logical-size floor (`MinVectorWidth`/`Height` = UI `startW`/`startH`), `ClampVectorRaster` / `MaxVectorRasterPixels`. | @@ -587,6 +601,134 @@ Encode/write-back for a subset of formats lives in `save.go`; `mutations.go` ser | `mutations.go` | Live file-identity transactions shared by Save/Strip/Export and external `WithFileMutation` participants such as Trash; case aliases share admission across atomic replacements, with cancellable admission/I/O and `WriteResult` commit identity. | | `save.go` | `SaveRotated`, `Export` (+ `ExportOptions`: size limit, metadata omission and exact-path fallback encoder), `CanEncode` / `CanEncodeExt`, `StripJPEGMetadata`. `dimensionTagsInvalidated` decides whether the source's dimension tags still describe what is being written, by comparing the written bounds against the source's own frame header - so a resize, a viewer rotation and an Orientation 5-8 source all correct them; Save Changes and export share that policy, retaining tags when geometry is unchanged or the source frame cannot be read (subject to export's resize fallback). | +### `internal/heicdecode` + +Codec-free boundary for the experimental isolated HEIC/HEIF decoder. `limits.go` +defines separate finite resource contracts; it does not install OS controls. +`request.go` frames one byte-only operation. `protocol.go` validates versioned +responses, dimensions, NRGBA8/NRGBA64 layout, operation-specific payload lengths, +normalized metadata and exact EOF before publication. `metadata.go` rejects +unknown/duplicate fields and invalid bounded values. `ready.go` reports actual +startup memory controls separately from requested limits. HEIC viewing is +default-off and restart-only; full package/platform qualification remains a gate. + +### `internal/heicdecode/client`, `worker`, and `cmd/picfetch-heic-worker` + +`client` owns one admitted request, bounded waiting, executable hash validation, +parent-owned file/loopback probes, readiness, bounded pipes and response checks. +Admission precedes source reads. Stop cancels pending/active work; Wait joins +process and pipe completion. Unix group termination precedes leader reap. +Its fair lane gives waiting background work a turn after three foreground +grants. `broker.go` serves at most eight explicit pipe connections through that +same lane, sending native-ready grants before remote bulk reads and retaining +admission through result delivery. `remote.go` validates frames and joins +cancellation; a disconnected queued peer is observed through one control byte. +`probes.go` owns checked loopback listeners and a bounded UDP echo worker, closed +and joined before image input or at failed-start cleanup. +`attachment.go` supplies explicit inherited pipes and observes parent-copy, +service and remote lifetimes. GUI construction and analysis requests share one +optional owner. Preferences select that owner once at startup, before file +admission; checkbox edits never replace it. `package.go` discovers the installation +from the executable, bounds and validates the trusted installed-package manifest, fixes +helper paths per OS, checks the target and pins the post-signing executable. +`staging.go` verifies copied bytes before atomic content-addressed publication. +`installed_windows.go` owns a private user/SYSTEM cache, cross-process publication +lock, minimal AppContainer read/execute preparation, write/delete-denying leases +and safe deferred obsolete-copy cleanup. It never changes Store installation +files. Client Stop/Wait releases its leases only after admitted work joins. +Other platforms use the verified installed helper directly. Unsupported Windows +architectures refuse preparation. Native readiness and per-launch identity checks +remain mandatory, and `Unavailable` records failures without changing capability. + +`worker` embeds the fixed WASI artifact and streams bounded stdio through wazero +with finite linear memory and a deadline. Native code accepts no image paths. +Its macOS cgo boundary verifies the App Sandbox entitlement; the entry point +then verifies denied owned file reads/creation and TCP/UDP access before input. +The Linux no-cgo amd64/arm64 candidate installs synchronized default-deny +seccomp with exact Go thread flags, private anonymous mappings and runtime +stdio/scheduling operations. It verifies RLIMIT_AS/CPU/core/file/descriptor +limits and a no-access oversized-mapping refusal before readiness. Its native +qualification is a separate `heic-linux` CI suite. Windows amd64/arm64 uses +`winisolation` below and verifies its token/job before the same denial probes. +Windows additionally confirms timed-out loopback probes with a verified +AppContainer token after the parent checks the native loopback exemption list; +other platforms require permission errors. +Other builds refuse startup. +Native-memory readiness is explicitly zero on macOS; its Go memory target is +not a hard OS cap. The helper alone may +import the embedded guest runtime. `packaging/heic` holds the macOS bundle and +entitlement templates; `heicinterpreter` is the qualification runtime variant. + +`cmd/picfetch-heic-worker` is the minimal single-request entry point, without +Fyne or a native HEIC codec. `internal/similarity/heic_native_test.go` verifies +successful native pixels across inherited analysis pipes and repeated retained +search previews after normal file admission. Application activation evidence and its outstanding +gates are tracked in `docs/heic/experimental-opt-in.md`. + +### `internal/heicdecode/winisolation` + +Windows-only helper process boundary. `process_windows.go` creates a suspended +zero-capability AppContainer after refusing its explicit loopback exemption, +with three explicit inherited stdio handles, +assigns a private one-process Job Object with committed-memory/user-CPU limits +and kill-on-close, then resumes. `policy_windows.go` queries the actual token, +identity, capabilities and immediate job before worker readiness. Process Kill +and Wait serialize handle retirement. `profile_windows.go` owns the stable +AppContainer profile and explicit read/execute provisioning for a dedicated +helper file and its directory, with no inherited ACL grant. Launch does not +change ACLs. Native controls and ordinary decoder fixtures are required by the +`heic-windows` amd64/arm64 CI suite. Standalone standard-user qualification passes +on both architectures at `69fef1a`, including private helper staging and the +loopback permission query. Installed-MSIX activation remains blocked before +the test process starts and requires separate package-context evidence. + +### `scripts/heicpackage` + +Stages the minimal native helper at `client.PackagePaths`, its complete +h265/Go/wazero notices and a bounded post-signing SHA-256 manifest. It verifies +the binary target, uses pure Go for Linux/Windows, and builds/signs/verifies the +macOS App Sandbox bundle. `finalize` refreshes the manifest after Windows +Authenticode signing. Native helper tests use the same staging tool. Makefile +and release/MSIX assembly retain the helper directory; package creation alone +does not enable the saved HEIC preference. `packaging/heic/qualify-windows*.ps1` +separates disposable CI account/signing provisioning from standard-user execution. +The provisioner retains package-specific Windows activation/deployment events +as diagnostic evidence, separately from the mandatory application test result. +Native application fixtures exercise executable-derived startup, Settings, +admission, native decode and shutdown; MSIX launches its registered installed +WindowsApps executable directly and checks package identity and unchanged +installed helper permissions. + +### `scripts/heicguest` and `scripts/heicbuild` + +Development-only HEIC/WASI qualification. `heicguest` is a separate module using +the maintained `third_party/h265` copy through an explicit local replacement. +Its WASI-only entry point rejects movie containers, +decodes still pixels/config/Exif in the guest, preserves straight-alpha precision +and emits `internal/heicdecode`'s protocol. `fixturegen` writes one fixed ordinary +ten-bit gradient; `fixturephoto` writes one fixed ordinary 12-megapixel gradient. +Neither generator is an application encoder. The checked guest artifact lives +under `internal/heicdecode/worker`; it is not embedded in the viewer. + +`heicbuild` implements the Make build/provenance/import guards. It checks source, +notices and complete build-input hashes, reproduces the guest byte-for-byte and +rejects native codec and viewer-to-worker imports. Fixed generators and tests +use bounded linear memory and byte streams. The native helper qualification +compares interpreter and compiler under the same deadline. This does not +implement a native worker-family memory cap. See `docs/heic/qualification.md`, the active +restoration plan and `docs/heic/robustness-testing.md` for status and remaining +platform, compatibility and distribution gates. + +### `third_party/h265` + +The v0.2.2-based production source previously maintained at PicFetch `fc127b44`, +including local coded-work, container, NAL and transformed-config hardening. +`PICFETCH.md` records selection and upgrade rules; `PICFETCH-SOURCE.json` pins +the exact 107-file production copy. The separate WASI guest uses `noasm`; this +module is excluded from native application imports, not from source provenance. +`scripts/heicbuild/source.go` verifies its baseline and both module replacements. +The ordinary fixture target `test-h265` reaches this nested source through WASI. + ### `internal/avifpolicy` An imaging compile dependency with files only under `nodynamic && !wasm2go`. @@ -634,10 +776,11 @@ GitHub-release check, SHA-256 + immutable release attestation verify, stage, app | `update.go` | `Client`, `AssetName`, `Newer`, `Due`. | | `github.go` | Releases + release-attestation HTTP. | | `checksums.go` | `VerifyHash` (optional API digest). | -| `download.go` / `extract.go` | Fetch with optional `DownloadProgress`, hash, attest, unzip/tar, and persist `Stage` provenance plus extracted-file hashes for reuse/apply revalidation. | +| `download.go` / `extract.go` | Fetch with optional `DownloadProgress`, hash, attest, unzip/tar, and persist `Stage` provenance plus extracted-file hashes for reuse/apply revalidation. Each authenticated archive extracts into a fresh payload directory before companion hashes are captured. | +| `companions.go` / `package_install.go` | Bounded exact companion inventories tied to stage provenance. Linux/Windows swap the dedicated helper directory around the existing binary transaction and roll it back on failure. Qualified macOS stages replace the complete verified app bundle, preserving its enclosing/nested signatures. Legacy stages keep their binary/plist path and cannot install a newly introduced helper. | | `attest.go` | GitHub Fulcio Sigstore `Verifier` + in-toto release policy. | | `tufroot.go` | Offline 60-day expiry check and verified sync of `embed/tuf-repo.github.com/root.json`. | -| `apply.go` / `apply_unix.go` / `apply_windows.go` | `Apply` dispatcher with `ApplyOptions`, normal shutdown without relaunch, explicit Perform-update relaunch. Unix (`apply_unix.go`) writes `.new` beside the target, renames it into place, and rolls back through `.old` on failure. Windows (`apply_windows.go`) replaces the running executable in-process via `swapBinary` — it used to run a generated `.apply.cmd` through `cmd.exe`, which Controlled Folder Access refuses outright regardless of `cmd.exe`'s own Microsoft signature; that script is gone. | +| `apply.go` / `apply_unix.go` / `apply_windows.go` | `Apply` dispatcher with `ApplyOptions`, normal shutdown without relaunch, explicit Perform-update relaunch. Legacy/standalone Unix (`apply_unix.go`) writes `.new` beside the target, renames it into place, and rolls back through `.old` on failure. Windows (`apply_windows.go`) replaces the running executable in-process via `swapBinary` — it used to run a generated `.apply.cmd` through `cmd.exe`, which Controlled Folder Access refuses outright regardless of `cmd.exe`'s own Microsoft signature; that script is gone. | | `swap.go` | `swapBinary`: the Windows in-process replace. Renames the running executable to `.old` (the one replacement Windows allows on a running image), copies the staged binary over `dest`, SHA-256-verifies the copy against the stage, and *tries* to restore `.old` on any failure past the rename — the rename back is retried a few times and then falls back to copying the backup over `dest`, but if all of that is refused too, `dest` is left truncated or missing and the reported `Op` is `restore`. That is the one outcome PicFetch cannot recover from or even report on the next launch, since reading the record needs the executable that is broken. `.old` deliberately survives a successful swap — it is still this process's own running image — for the next launch to sweep (`await.go`). | | `applyerr.go` | `ApplyError` (`Op`/`Path`/`Err`) and `FailureReason` (`ReasonAccessDenied` / `ReasonVirusBlocked` / `ReasonSharingViolation` / `ReasonUnknown`); `ClassifyApplyError` maps a failed `Apply` to the reason the next launch reports, preferring Windows errno classification (`applyerr_windows.go`) over the portable `fs.ErrPermission` fallback (`applyerr_other.go`). | | `await.go` | `AwaitPIDEnv` (`PICFETCH_UPDATE_AWAIT_PID`) relaunch handshake. `CleanupPredecessor`, called from `main.go` before `app.NewWithID`, waits (bounded, 15s) for the process that installed this executable to exit before preferences are touched, then sweeps `.new` / `.apply.cmd` left by pre-2026-08-30 updates. `SweepBackup`, called from `internal/ui` startup once the Fyne app cache exists, removes `.old` — skipped when the last recorded apply failure has `Op == "restore"`, the one state where the backup is the user's only intact executable. | @@ -839,7 +982,7 @@ when the user opened a single file. | File | Responsibility | |------|----------------| -| `filescan.go` | `Images(ctx, uris, max, progress)` (recursive); `Siblings(ctx, file, max, progress)` (parent dir only, opened file seeded first); symlink-cycle + per-call dedupe. | +| `filescan.go` | `Images(ctx, uris, max, progress, opts...)` (recursive); `Siblings(ctx, file, max, progress, opts...)` (parent dir only, opened file seeded first); optional `WithAdmission` captures a reader capability, omitted/nil retains package defaults. Symlink-cycle + per-call dedupe. | ### `internal/launch` diff --git a/Makefile b/Makefile index bb230194..219117bc 100644 --- a/Makefile +++ b/Makefile @@ -194,7 +194,8 @@ sync-qodana-test-exclusions: ## Synchronize Qodana's duplication exclusions with fi; \ $(MAKE) --no-print-directory check-qodana-test-exclusions -check-qodana-test-exclusions: ## Fail if qodana.yaml does not exclude every *_test.go from duplication checks +check-qodana-test-exclusions: ## Validate Qodana YAML and require every *_test.go duplication exclusion + go run ./scripts/qodanaconfig @set -eu; \ listed=$$(mktemp); \ test_files=$$(mktemp); \ @@ -392,7 +393,7 @@ test-race: ## Run the guarded race partitions concurrently in one Linux/amd64 Do @bash scripts/testshards/docker-race.sh "$(CURDIR)" "$(TEST_IMAGE)" \ "$(TEST_MEMORY_GIB)" "$(TEST_CONTAINER_LABEL)" "$(TEST_LOCALE)" "$(TEST_ARTIFACTS_DIR)" -verify-build: fmt-check check-tuf-root check-qodana-test-exclusions check-tag-vectors check-app-assets check-updater-notices ## Run local verification without the test suite (format, TUF root, generated assets, notices, Qodana exclusions, vet, build) +verify-build: fmt-check check-tuf-root check-qodana-test-exclusions check-tag-vectors check-app-assets check-updater-notices heic-check-provenance heic-check-imports ## Run local verification without the test suite (format, TUF root, generated assets, notices, Qodana exclusions, vet, build) go vet -tags "$(APP_TAGS)" ./... go build -tags "$(APP_TAGS)" ./... @@ -424,8 +425,9 @@ golden: ## Regenerate the e2e golden-master screenshots via Docker (linux/amd64, tidy: ## Tidy go.mod / go.sum go mod tidy -security-govulncheck: ## Scan dependencies with the module-pinned govulncheck +security-govulncheck: ## Scan native and HEIC guest dependencies with the module-pinned govulncheck go tool govulncheck -tags "$(APP_TAGS)" ./... + $(MAKE) --no-print-directory heic-security-govulncheck security-github: ## List open GitHub Dependabot alerts for this repo (needs `gh auth login`) gh api "repos/$$(gh repo view --json nameWithOwner -q .nameWithOwner)/dependabot/alerts" \ @@ -439,7 +441,9 @@ clean: ## Remove all build artifacts package-mac: install-fyne ## Package a macOS .app bundle (native, no Docker) into bin/ "$(FYNE_BIN)" package -os darwin -icon $(ICON) -name "$(APP_NAME)" -appID $(PACKAGE_ID) -tags "$(APP_TAGS)" -release go run -tags "$(APP_TAGS)" ./scripts/plistdoctypes "$(APP_NAME).app/Contents/Info.plist" + go run ./scripts/heicpackage -os darwin -arch $$(go env GOARCH) -out "$(APP_NAME).app" cp LICENSE THIRD-PARTY-NOTICES.md PRIVACY.md "$(APP_NAME).app/Contents/Resources/" + codesign --force --sign - "$(APP_NAME).app" mkdir -p $(BIN_DIR) rm -rf "$(BIN_DIR)/$(APP_NAME).app" mv "$(APP_NAME).app" "$(BIN_DIR)/" @@ -458,6 +462,7 @@ package-windows: warm-fyne-cross-windows install-fyne-cross ## Cross-compile Win for arch in $(WIN_ARCHES); do \ "$(FYNE_CROSS_BIN)" windows -engine "$(FYNE_CROSS_ENGINE)" -image "$(FYNE_CROSS_WINDOWS_IMAGE)" -cache "$(FYNE_CROSS_CACHE)" -arch=$$arch -icon $(ICON) -name $(BIN_NAME) -app-id $(PACKAGE_ID) -tags "$(APP_TAGS)" -env GOTOOLCHAIN=auto || exit 1; \ cp fyne-cross/bin/windows-$$arch/$(BIN_NAME).exe $(BIN_DIR)/$(BIN_NAME)-windows-$$arch.exe || exit 1; \ + go run ./scripts/heicpackage -os windows -arch $$arch -out "$(BIN_DIR)/heic-windows-$$arch" || exit 1; \ done package-windows-store: warm-fyne-cross-windows install-fyne-cross ## Cross-compile Microsoft Store-managed Windows .exe files into bin/ (MSIX packaging runs on Windows in CI) @@ -479,6 +484,7 @@ package-linux: warm-fyne-cross-linux install-fyne-cross ## Cross-compile Linux b for arch in $(LINUX_ARCHES); do \ "$(FYNE_CROSS_BIN)" linux -engine "$(FYNE_CROSS_ENGINE)" -image "$(FYNE_CROSS_LINUX_IMAGE)" -cache "$(FYNE_CROSS_CACHE)" -arch=$$arch -icon $(ICON) -name $(BIN_NAME) -app-id $(PACKAGE_ID) -tags "$(APP_TAGS)" -env GOTOOLCHAIN=auto || exit 1; \ cp fyne-cross/bin/linux-$$arch/* $(BIN_DIR)/$(BIN_NAME)-linux-$$arch || exit 1; \ + go run ./scripts/heicpackage -os linux -arch $$arch -out "$(BIN_DIR)/heic-linux-$$arch" || exit 1; \ done package-linux-debug: warm-fyne-cross-linux install-fyne-cross ## Cross-compile unstripped Linux binaries for diagnosing startup failures, one per arch in LINUX_ARCHES @@ -592,3 +598,49 @@ release: ## Full release: verify, bump version, commit, tag, push (PART=major|mi help: ## Show this help @grep -hE '^[a-zA-Z_-]+:.*##' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*##"}; {printf " %-16s %s\n", $$1, $$2}' + +.PHONY: heic-build heic-check-provenance heic-check-imports heic-check-guest heic-fixture +heic-build: ## Rebuild the development-only HEIC WASI guest and provenance record + go run ./scripts/heicbuild build + +heic-check-provenance: ## Verify pinned source/notices and reproduce the HEIC guest byte-for-byte + go run ./scripts/heicbuild check + +heic-check-imports: ## Reject native HEIC codec imports and unreviewed guest dependencies + go run ./scripts/heicbuild imports + +heic-check-guest: ## Test the guest and owned runtime/transport boundaries + go test -tags "$(APP_TAGS)" ./internal/heicdecode/... ./scripts/heicbuild + +heic-fixture: ## Reproduce the fixed 16x16 ten-bit fixture inside WASI + go run ./scripts/heicbuild fixture + +.PHONY: heic-photo-fixture heic-native-macos heic-native-linux heic-native-windows +heic-photo-fixture: ## Reproduce the fixed 12-megapixel qualification gradient inside WASI + go run ./scripts/heicbuild photo-fixture + +heic-native-macos: ## Qualify signed sandboxed helpers and bounded runtime choice on native macOS + mkdir -p .scratch/heic-qualification + go run ./scripts/nativeguards -suite heic-macos -capture .scratch/heic-qualification/native-macos.json + +heic-native-linux: ## Qualify the no-cgo HEIC helper's syscall and native address-space limits on Linux + mkdir -p .scratch/heic-qualification + go run ./scripts/nativeguards -suite heic-linux -capture .scratch/heic-qualification/native-linux.json + +heic-native-windows: ## Qualify the HEIC AppContainer helper, job limits and inherited pipes on Windows + mkdir -p .scratch/heic-qualification + go run ./scripts/nativeguards -suite heic-windows -capture .scratch/heic-qualification/native-windows.json + pwsh -NoProfile -File packaging/heic/qualify-windows-child.ps1 -RequireInstalledMSIX -Configuration "$(HEIC_MSIX_CONFIGURATION)" + +.PHONY: heic-security-govulncheck +heic-security-govulncheck: ## Scan the separate WASI guest module (including its fixed fixture generator) + @scanner="$$(go tool -n govulncheck)"; \ + cd scripts/heicguest && GOOS=wasip1 GOARCH=wasm CGO_ENABLED=0 GOWORK=off "$$scanner" -tags=noasm ./... + +.PHONY: generate-heic-wasm check-heic-wasm test-h265 +generate-heic-wasm: heic-build ## Rebuild the guest from the maintained h265 source + +check-heic-wasm: heic-check-provenance heic-check-imports ## Verify maintained source and WASI-only decoder provenance + +test-h265: check-heic-wasm ## Exercise the maintained nested decoder through ordinary WASI fixtures + go test -tags "$(APP_TAGS)" -run '^TestWASIGuestOrdinaryFixtures$$' -count=1 ./scripts/heicbuild diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 28276c11..01056883 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -9,6 +9,14 @@ reconciles the pinned production updater sources across all six desktop targets; `make check-updater-notices` verifies that inventory and its exact source texts. Test-only dependencies are excluded because they do not ship. +The isolated HEIC helper contains the maintained PicFetch h265/HEIC source based +on gen2brain/h265 v0.2.2 (MIT), a Go WASI runtime (BSD-3-Clause and patent grant), +and wazero v1.12.0 (Apache-2.0). Their complete applicable source notices ship in +`heic/notices/`, under `Contents/Resources/` in the macOS app. The helper's manifest +records its post-signing executable and guest hashes. HEIC viewing remains +disabled until platform qualification is complete. These software notices make +no claim about HEVC patent rights. + The historical `github.com/hashicorp/golang-lru/v2` notices remain below, including MPL-2.0 and the separate Go-derived BSD notice for `simplelru/list.go`. The updater inventory also records MPL-2.0 matcher provenance in diff --git a/THREAT-MODEL.md b/THREAT-MODEL.md new file mode 100644 index 00000000..00cfa677 --- /dev/null +++ b/THREAT-MODEL.md @@ -0,0 +1,383 @@ +# PicFetch threat model + +**Updated:** 2026-09-16 + +**Source baseline:** PR #28 at commit +52ed2dfb2500d8a6b8b0895e0cba353c934915e0, with the history-reconciliation changes +in this PR. This adapts the historical model from 73cb3c9 and its fc127b44 +implementation baseline; it does not import the old worker implementation. + +This model records assets, trust boundaries, implemented controls and residual +risks. It is a static source/documentation assessment, not an exhaustive audit +or verification of every installed binary. Risk categories are not confirmed +vulnerability findings. [ARCHITECTURE.md](ARCHITECTURE.md) is the package map; +[HEIC qualification](docs/heic/qualification.md) records runtime evidence and +[history reconciliation](docs/heic/history-reconciliation.md) records carryover. + +**HEIC viewing is default-off and requires a restart after an explicit +Experimental Settings opt-in.** Startup validates the executable-derived helper +package before constructing an immutable shared capability. Missing/corrupt +packages or failed isolation remain unavailable while ordinary viewing works. +Windows private staging and standalone standard-user activation pass native +x64/ARM64 qualification at 69fef1a. Installed-MSIX activation remains blocked +before the test process starts; standalone helper results do not qualify it. +Distribution clearance, production signing and broad color/camera qualification +remain release gates. See the [activation record](docs/heic/experimental-opt-in.md). +The accepted macOS design lacks a hard total native-memory cap; memory pressure +and application/system crashes remain possible. Capability isolation and finite +WASM/input/output/job/time limits remain mandatory. + +## 1. Application and assets + +PicFetch is a Go/Fyne desktop image viewer. Files arrive through command-line +paths, drag-and-drop, native pickers and macOS Open With events. Opening a file +can also discover sibling images; folder opening can recursively scan a tree. +Production supports common raster formats, SVG, AVIF and embedded JPEG +previews from camera RAW containers. HEIC/HEIF is an experimental opt-in. PicFetch +also handles EXIF metadata, save/export, clipboard, Trash, wallpaper, favorites, +mosaics and session paths. + +Optional features add network and native-code dependencies: Similarity Explorer +installs model/runtime assets and analyzes locally in a subprocess; EXIF maps +fetch tiles; standalone builds offer GitHub updates. Microsoft Store builds use +their separate distribution policy. PicFetch has no account system, listening +application server or privileged daemon. Desktop input and optional outbound +traffic, rather than web sessions or server-side roles, define this model. + +Assets to protect include: + +- Images, GPS and other metadata, paths, previews and similarity representations. +- Original files and user-selected destinations during writes and deletion. +- Application integrity, installed native libraries and release provenance. +- Desktop and host availability, including memory, CPU and storage. +- Release, signing and Store credentials and the authority to distribute updates. + +## 2. Attacker capabilities and trust assumptions + +**Untrusted content:** an attacker can supply image/container bytes, filenames, +metadata, dimensions, animation data and SVG structure through a download, +message, removable device or shared folder. Opening or previewing a file does +not make its internals trusted. A writable shared tree can contain symlinks and +can change while PicFetch reads or mutates it. Network services and their +responses are inputs too, subject to HTTPS and feature-specific integrity checks. + +**Local configuration:** selected paths, preferences, environment variables, +`PATH`, proxy/CA configuration and asset overrides are normally controlled by +the operator. Installed OS helpers and their output are part of the local trust +base. These assumptions can change when launch configuration or directories are +shared with a less-trusted party. App-local state should be protected by suitable +OS directory permissions; PicFetch does not encrypt it as a separate boundary. + +**Process authority:** normal operation assumes PicFetch is not elevated. Each +process has the authority actually granted by its OS account, access controls, +entitlements, sandbox and privacy permissions. Two processes with the same user +ID need not have identical access: macOS TCC/App Sandbox and Windows application +write controls can matter. A same-user prerequisite does not by itself dismiss +a defect that lets PicFetch act with authority the attacker lacks. + +**User intent:** opening content authorizes reading for viewing, not arbitrary +file mutation or disclosure. Save, export, metadata removal, Trash and wallpaper +actions must respect the user's selected operation and target. Confirmation is +an interaction boundary, not proof that all affected paths are safe. + +**Supply chain:** the OS, Go/runtime implementations, shipped dependencies, +checksum pins and update trust roots are trusted components. Maintainers control +source, build tags, embedded assets and workflows. Repository permissions, +release accounts, signing services and protected environments are separate +operational boundaries. Integrity verification against a trusted pin establishes +which bytes were obtained; it does not prove those bytes are free of defects. + +## 3. Content parsing and resource use + +Most image work runs inside the GUI process. Decoder code executes with its +host process's effective privileges. A memory-safety defect that enables +arbitrary code execution could expose that authority; an ordinary panic or +out-of-memory failure primarily affects availability and does not itself prove +code execution or arbitrary filesystem access. Go memory safety, third-party +native code, runtime trust, resource limits and OS confinement are distinct +parts of the assessment. + +The canonical [loader](internal/imaging/loader.go) defaults to a **512 MiB +encoded-file limit**, configurable in settings, and checks probed raster +dimensions against **200 million pixels**. [GIF handling](internal/imaging/gif.go) +bounds animation storage and frame count before full animation decode. +[SVG handling](internal/imaging/vector.go) and +[raster sizing](internal/imaging/svg.go) include encoded-source, XML-depth, +expansion-work and rasterization limits, plus recovery around rasterizer panics. +ICO admission validates the selected +directory entry and embedded dimensions. AVIF build guards require its WASM +implementation. These are scoped controls, not a claim +that all parser paths recover panics or all intermediate allocations are bounded. + +Large accepted images, concurrent operations and decoder/runtime overhead can +still exhaust resources. A header check is not a bound on every operation needed +to read that header; many in-process decoder calls cannot be interrupted in +place. Image-cache budgets are not process-wide memory quotas. No universal CPU +or total-memory bound is established here. Codec/runtime selection must also be +checked for the actual build; this model does not claim every format runs in WASM. + +### Experimental HEIC helper boundary + +The [image reader](internal/imaging/source.go) performs bounded leading-brand +recognition and routes HEIC to an explicitly injected shared client. The default +reader refuses it. HEIC configuration, pixels and Exif parsing run in a fixed +WASI guest inside a dedicated, disposable [helper](cmd/picfetch-heic-worker/main.go). +The guest is built from the exact maintained h265 production source; image bytes +are data, not executable modules. No native codec is imported into the viewer. +HEIC refusal does not trigger parent metadata parsing or RAW/JPEG fallback. + +The [runtime](internal/heicdecode/worker/runtime.go) supplies bounded standard +streams without guest filesystem mounts, environment, clock, randomness or +sockets. It caps WASM linear memory at **1 GiB**. Growth can temporarily retain +old and replacement backing buffers; the linear-memory ceiling does not cover +that overlap, the native Go/wazero process or parent/cached pixels. Eager +reservation was rejected after it broke native Linux qualification. The native +Go memory target remains soft. + +[Limits](internal/heicdecode/limits.go) permit at most **64 MiB encoded input**, +**64 million pixels**, **256,000,000 output bytes**, **64 KiB normalized metadata**, +**4096 diagnostic bytes**, and **60 seconds** per request after admission. +Readers apply the current user file-size limit to each new read, bounded by the +shared owner's fixed 64 MiB ceiling. Raising the live preference cannot raise +that hard HEIC ceiling or replace the owner. +The output cap admits at most 32 million NRGBA64 pixels. One shared lane covers +the app and its analysis descendants; bounded queues prioritize interactive work +without indefinitely starving background requests. Bulk reads wait for admission +and helper readiness. Cancellation retires the process and pipes before releasing +the lane. Validated pixels and metadata cross back through the codec-free +[protocol](internal/heicdecode/protocol.go); helper identity is pinned to the +trusted installed package. Hashes are not an independent package signature. + +| Platform | Candidate native helper boundary | +| --- | --- | +| Linux x64/ARM64 | Thread-synchronized default-deny seccomp, resource limits and a 2 GiB address-space ceiling before input. This is not a physical-RAM/cgroup limit. | +| Windows x64/ARM64 | Suspended zero-capability AppContainer setup, explicit inherited handles, private Job Object committed-memory/CPU/process limits and kill-on-close. Standalone standard-user native guards pass on both architectures; installed-MSIX and production distribution remain unqualified. | +| macOS Intel/Apple Silicon | Separately entitled App Sandbox helper and Hardened Runtime with verified owned file/network denial; helper-only executable-memory entitlement for wazero. No guaranteed hard total native-memory ceiling. | + +The saved `experimentalHEIC` value describes user intent, separately from the +session's configured reader and its latest availability failure. Scanning, +restored sessions and Favorites use that same reader predicate. A failed helper +never selects a different decoder or replaces the service. Existing package +format queries and OS associations remain unchanged; sequence admission and +HEIC encoding stay unsupported. + +Windows copies the verified installed helper into a dedicated user/SYSTEM-only +cache. A cross-process file lock serializes publication and cleanup. Copies are +bounded and hashed again before atomic publication; only the AppContainer's +read/execute grant is prepared, on the copied executable and its containing +directory. Reparse points and hard-linked executable entries are refused before +permission changes. Client-held file/directory handles deny replacement/deletion +until Stop/Wait has joined work. Each launch still verifies the pinned hash and +native readiness. A damaged inactive copy is rebuilt; obsolete live copies are +retained until a later safe cleanup. Cache/staging failure refuses activation. +The installed manifest remains part of the enclosing authenticated package; +a content hash does not establish publisher authenticity. Store source files +and their ACLs are never staging targets, and Store update authority is unchanged. + +Windows helper startup supplies only `GOMAXPROCS` and the OS-reported +`SystemRoot`/`LOCALAPPDATA`; it does not inherit the parent's environment or +search path. Windows redirects the profile directory for the AppContainer. +Profile creation uses a bounded per-user/session cross-process mutex. These +startup controls pass standalone standard-user x64/ARM64 CI at `69fef1a`, including +the launch-time loopback permission query; they grant no additional AppContainer +capabilities or filesystem rights. Hosted runner results do not qualify every +Windows installation. The installed-MSIX test process cannot start in the hosted +alternate-user session, so its package-context behavior remains unverified. + +Windows can drop blocked loopback traffic instead of returning a permission +error. Both parent-owned listeners are positively checked before launch, and +UDP has a bounded one-byte echo worker. A helper timeout qualifies only with a +live request and a verified zero-capability AppContainer identity. Before +creating each helper, the parent must confirm through the native loopback +exemption API that this identity is not exempt; the AppContainer itself cannot +perform this privileged query. Successful +communication, an absent listener or an API +failure refuses readiness. The listeners close before image input; their worker +is joined on every path. Linux/macOS still require explicit permission refusal. + +Actual platform controls are verified before helper readiness; unsupported or +failed setup refuses input. OS controls and WASI restrictions protect different +boundaries. A decoder failure, runtime compromise and OS sandbox escape are +separate events. The fixed module, Go, wazero and kernel are trusted components; +containment is not proof of absence of defects. See the qualification record for +native execution evidence, executable-memory tradeoff and incomplete package +checks. Insufficient native headroom may refuse a job; raising limits or +dropping isolation is not a fallback. + +Container transforms run in the guest. ICC/wide-gamut/HDR presentation is not +implemented: some such files can decode into untagged pixels rather than being +rejected. Successful decode and 16-bit transport do not establish color fidelity. +These HEIC controls do not isolate other codecs, rendering, or native ONNX work. + +## 4. Filesystem discovery and mutation + +[Folder scanning](internal/filescan/filescan.go) resolves symlinks for cycle +detection and deduplication, supports cancellation and defaults to **200,000 +results**. It does not confine discovery beneath the originally selected folder: +a symlink can lead to other accessible files. A selected tree is not a stable +snapshot, especially on shared storage. + +[Save/export/metadata writes](internal/imaging/save.go) use temporary files, +file synchronization and rename replacement, coordinated by +[in-process path transactions](internal/imaging/mutations.go). Save-through-link +intentionally follows the target; export resolves parent links and refuses an +existing symlink leaf. These controls reduce partial writes and conflicts among +PicFetch operations; they do not lock out another process changing the filesystem. +[Deletion](internal/ui/deletion/deletion.go) uses an OS Trash operation after a +confirmation that defaults to Cancel. Recoverability depends on the OS/storage. + +OS integration adapters handle paths through platform-specific argument, URI or +data transport. Their correctness and the trustworthiness of installed helpers +remain part of the boundary; a filename is not authorization for a command. +Assess any unintended access or mutation by its actual target, user consent and +authority crossed, including shared-folder and application-specific permissions. + +## 5. Similarity analysis, assets and caches + +[Similarity Explorer](internal/similarity/client.go) sends source paths and +requests over local pipes to its own analysis subprocess. It uses native ONNX +Runtime, checks source versions and verifies pinned assets before loading. +Native inference requires cgo; the no-cgo implementation reports it unavailable. +HEIC requests from analysis use explicit inherited pipes to the same +application-owned HEIC admission lane; analysis workers cannot choose a helper +executable or start an independent lane. The analysis process itself retains +separate privileges and is not a general sandbox for other image codecs. + +| Platform | Analysis-worker network control | +| --- | --- | +| Linux x64/ARM64 | Worker installs a thread-synchronized seccomp network-denial policy; setup failure stops startup. | +| macOS | Worker launches under a `sandbox-exec` network-denial profile. | +| Windows | Ordinary subprocess; no OS network isolation is configured. | + +[Runtime checks](internal/similarity/offline.go) require actual TCP/UDP denial +before analysis on macOS/Linux; merely being disconnected does not satisfy them. +These network policies do not establish comprehensive filesystem confinement. +The worker retains filesystem access subject to its effective OS permissions; +Windows also permits networking. The [encoder](internal/similarity/encoder.go) +requests the telemetry opt-out before library loading and requires the runtime +API opt-out to succeed before session creation. This is not proof that every +dependency or build can never communicate. + +[Asset installation](internal/similarity/assets_install.go) requires a user +download action, uses pinned sources, checks expected sizes and SHA-256, restricts +HTTPS redirects to listed provider domains, and extracts named runtime members +into staging before publication. Analysis itself does not start downloads. +Store builds use verified bundled runtime DLLs and download model data; model +cache overrides cannot replace the bundled runtime. These checks still trust +the selected upstream model/runtime and the shipped pins. + +Worker events cross back into the parent and are part of its input-validation +boundary. [Cache records](internal/similarity/cache_payload.go) have size, +version, vector and preview checks, but cached paths, image facts, previews and +representations remain sensitive local data. Hash-based filenames do not +anonymize those contents. Filesystem protection and user-selected persistence +matter independently of cache integrity checks. + +## 6. Network and privacy + +Normal viewing and similarity analysis do not intentionally upload images or +representations. Optional downloads, update checks and maps do generate outbound +traffic and disclose ordinary connection information. This local-processing +design is not a guarantee against disclosure following a compromised component. + +The [EXIF map](internal/ui/exifwin/exifwin.go) starts collapsed in a fresh window. +Expansion requests tiles for the image's location; while expanded it follows +navigation to subsequent GPS images. The configured +[tile origin](internal/ui/exifwin/tiles.go) is OpenStreetMap. Requests include +tile coordinates and a PicFetch user agent, not the source image, but the tile +area can reveal sensitive location information alongside the client's network +address. A configured origin alone is not a guarantee about redirect recipients. +The fetcher limits concurrency, response bytes and cache storage and uses +timeouts; these limits are not a total decode-memory guarantee. + +Favorites, sessions, thumbnails, presets and analysis caches are unencrypted +local state. Their exposure depends on directory permissions, backups, storage +sharing and the authority of other processes. Metadata removal/omission is +privacy-sensitive: failure to honor the selected export or removal behavior can +expose location or identity when a user shares the result. GPS disclosure can +have serious consequences even when only one image is affected. + +## 7. Updates and release supply chain + +Standalone builds support manual update checks and optional automatic checks, +which default off. Disabling checks does not discard an already completed stage +scheduled for installation at shutdown. [Store builds](internal/distribution/distribution_store.go) +disable the GitHub update channel. + +[Fresh downloads](internal/update/download.go) have a **200 MiB archive-download +cap** and require successful Sigstore release-attestation verification before +extraction, independently of an optional GitHub API digest. The +[verification policy](internal/update/attest.go) binds the archive digest, asset +name, repository, tag and package URL to the expected GitHub release-service +identity, using TUF trust and a signed timestamp. This authenticates the release +artifact; it does not establish that its source was reviewed or its behavior is +safe. [Extraction](internal/update/extract.go) rejects nonlocal paths and archive +links. The download cap is not an aggregate expanded-size quota. + +Persisted staging stores provenance fields and the full extracted companion-file +inventory locally. Reuse/apply checks helper and manifest identity, rejects +missing/changed/unexpected companion files and validates the platform; +it relies on trusted local staging state rather than cryptographically sealed +metadata. Any claimed abuse must establish the attacker's existing permissions, +PicFetch's destination authority and the boundary actually crossed. + +The candidate updater installs Linux/Windows helper files with executable +replacement and rollback, and replaces the entire signed macOS app bundle. +The currently released updater cannot perform the first helper-bearing upgrade: +it drops the helper and deletes staging, invalidating the enclosing macOS +signature. The first transition needs a complete reinstall or a separately +qualified bridge. New updater tests do not fix an already installed old updater. + +The [release workflow](.github/workflows/release.yml) configures CI gates, +Windows signing/verification and release publication dependencies. +The protected Windows signing job has no repository checkout/toolchain and +executes no repository programs. Fixed PowerShell data operations refresh the +post-signing helper digest; both executable signatures are checked afterward. +[Signing documentation](docs/release-signing.md) describes required environment +protection and credential handling. Configuration does not prove that live +environment approvals are enabled, a particular CI run passed, or a downloaded +artifact is correctly signed. macOS packaging verifies ad-hoc bundle/helper +signatures; that does not establish Developer ID/notarization or App Store +qualification. Manual-download and Store distribution have their own +provenance/installation paths; the in-app updater's +checks must not be attributed to every installation method. + +Compromise of trusted source, dependencies, workflows, signing accounts or +release services can affect many installations. Signatures and checksums do +not make a malicious authorized release safe. Operational release qualification +must be tied to the actual tag, build and distributed artifacts. + +## 8. Severity guidance + +These are project triage starting points, not automatic or universal ratings. +Use demonstrated impact, exploitability, user interaction, persistence, +recoverability, data sensitivity and the real permission boundary. Distinguish +a confirmed defect from an untested hypothesis. + +| Starting point | Example impact to assess | +| --- | --- | +| Critical | Reliable code execution through ordinary content opening, or a compromised update/release path distributing attacker code broadly. Scope and prerequisites still matter. | +| High | Substantial unauthorized file access, destructive mutation, sensitive disclosure or code installation across a meaningful OS/application permission boundary. | +| Medium | Repeatable application resource exhaustion or crash with limited, recoverable impact; limited integrity/privacy failures when the actual consequences justify this level. | +| Low | Recoverable local-state errors or non-sensitive diagnostic/thumbnail defects that add little authority or impact beyond what the attacker already possesses. | + +A same-user prerequisite lowers concern only when the attacker already has +equivalent relevant authority. Conversely, a nominally local issue can cross +TCC, sandbox, write-control or release boundaries. GPS exposure may warrant +high severity depending on context; availability failures can be more serious +when persistent, destructive or capable of destabilizing the host. Neither a +single-process crash nor user interaction determines the rating by itself. + +## 9. Maintenance and reporting + +Update the baseline and review this model when decoder/runtime choices, worker +permissions, network behavior, file mutation, persistence or distribution change. +Record implementation and qualification separately; planned safeguards and +successful source checks are not substitutes for deployment evidence. + +Use the repository's existing [security policy](.github/SECURITY.md) to report +suspected vulnerabilities privately. Its preferred route is +[GitHub Security Advisories](https://github.com/frathe/picfetch/security/advisories/new). +Include the affected version/build, platform and impact; do not post sensitive +reports or private sample images in a public issue. This model adds no separate +disclosure policy or response-time commitment. diff --git a/cmd/picfetch-heic-worker/main.go b/cmd/picfetch-heic-worker/main.go new file mode 100644 index 00000000..43c81d41 --- /dev/null +++ b/cmd/picfetch-heic-worker/main.go @@ -0,0 +1,13 @@ +// Command picfetch-heic-worker is the minimal disposable HEIC helper. It has no +// GUI, image-format registration, native HEIC codec, or user-facing CLI mode. +package main + +import ( + "os" + + "github.com/frathe/picfetch/internal/heicdecode/worker" +) + +func main() { + os.Exit(worker.Main(os.Args[1:])) +} diff --git a/docs/heic/compatibility-2026-09-16.md b/docs/heic/compatibility-2026-09-16.md new file mode 100644 index 00000000..fbd8dbfc --- /dev/null +++ b/docs/heic/compatibility-2026-09-16.md @@ -0,0 +1,118 @@ +# Ordinary HEIC compatibility checks, 2026-09-16 + +## Scope and method + +These checks use ordinary existing licensed fixtures and the current real, +ad-hoc-signed App Sandbox helper on macOS 27.0 (26A428), Apple Silicon. The +helper uses the maintained WASI artifact and unchanged production candidate +limits: one admitted job, 64 MiB input, 64M pixels, 256 MB output, 1 GiB WASM +memory and a 60-second deadline. Source bytes enter after native readiness. +There is no native decoder fallback in PicFetch. + +Independent reference work runs outside PicFetch, only on these ordinary +fixtures: macOS ImageIO applies orientation and draws into an eight-bit sRGB +bitmap; ExifTool 13.55 reports metadata. PNG comparisons use premultiplied RGBA, +with errors expressed on a 0–255 scale. The eight-bit reference cannot establish +sixteen-bit precision, HDR correctness or broad colorimetric fidelity. Local +tools/results are retained under `.scratch/heic-qualification/`; the reference +tool is `/private/tmp/picfetch-heic-reference.swift`. + +## Existing fixture results + +| Fixture | Real helper result | Independent check and limit | +| --- | --- | --- | +| `internal/imaging/testdata/test_exif.heic` | 480×640 NRGBA, 1.204 s; orientation 6, TestCam/Model123, f/5.6, ISO 800 | ImageIO and ExifTool agree on metadata and upright dimensions. RGB mean absolute error 0.893/0.517/0.586; maximum 3/2/2. This is a synthetic metadata fixture, not a camera photo. | +| `scripts/heicbuild/testdata/basic.heic` | 320×240 NRGBA, 1.023 s | Same dimensions and recognizable test pattern as ImageIO. Six unobstructed bottom-row color bars differ by at most two levels per channel. Full-image RGB mean absolute error 3.515/1.863/2.339; maxima 134/74/117 occur outside those flat samples. This is not pixel-equivalence evidence; texture/edge discrepancies are not diagnosed here. | +| `scripts/heicbuild/testdata/alpha.heic` | 64×64 NRGBA, 0.911 s | Alpha samples match the independent reference exactly. Premultiplied RGB mean absolute error 0.535/0.326/0.469. | +| `scripts/heicbuild/testdata/tenbit.heic` | 16×16 NRGBA64, 0.917 s | The owned full-range grayscale ramp matches its known generated values within one ten-bit quantization step; equal RGB channels and opaque alpha survive. ImageIO expands/clips this fixture's range differently, so its eight-bit output is not accepted as the numeric oracle. | + +Fixture origin and retained notices are recorded in +[`scripts/heicbuild/testdata/README.md`](../../scripts/heicbuild/testdata/README.md) +and [`internal/imaging/testdata/README.md`](../../internal/imaging/testdata/README.md). +The synthetic EXIF fixture SHA-256 is +`49d881a7a87d91cdf79d4599a3f7ecf331fa9d940743954856dc1c66fdd2eae3`. + +The native guard now checks Decode, DecodeConfig and DecodeExif against those +known synthetic metadata and single-container-rotation expectations. Its +metadata subtest passes in 3.850 s including package/test setup. The strengthened +ordinary ten-bit subtest passes in 1.893 s. GoLand reports no findings in the +changed test file. These assertions establish additional positive behavior; +they do not claim a fixed production defect or new camera support. +The complete native sandbox-helper test passes in 5.906 s with both additions; +`make verify-build` also passes afterward. + +## One real camera photograph + +The selected subject is a bee on a flower, Imazen sample 1427, photographed by +contributor `lilith` with a Samsung S23 Ultra. The publisher's pinned +[per-photo manifest](https://raw.githubusercontent.com/imazen/imazen-26/ce09d338b3f68f9bdc8d222f40017ea71ca6bfa4/1400-lilith-nature/MANIFEST.tsv) +records `PD-own`; its +[license statement](https://raw.githubusercontent.com/imazen/imazen-26/ce09d338b3f68f9bdc8d222f40017ea71ca6bfa4/README.md) +defines that as the photographer's own work released to the public domain, +while describing the inventory as best-effort. No private photo library was +accessed. The sample is retained only in local ignored qualification output. + +The [immutable test manifest](https://raw.githubusercontent.com/imazen/imazen-26/ce09d338b3f68f9bdc8d222f40017ea71ca6bfa4/manifests/test.tsv) +pins the [ordinary original](https://codec-corpus.r2.imazen.org/imazen-26-unprocessed/1400-lilith-nature/1427_nature_bee-on-pink-flower_monument-inochi-kyoto_s23u_iso64-f4p9_20231107-111746_4000x3000.heic): + +- Actual file size: 2,872,198 bytes. +- SHA-256: `b18db35022b1d1d0617b166eef381464cf0d07dbd5f34afeb11d01820ad08f40`. +- The complete size and digest were verified before any decoder read. Stalled + long transfers were completed with sequential bounded 64 KiB range requests; + no incomplete image was decoded. +- Real sandboxed helper: successful NRGBA decode in **20.414 seconds**, upright + **3000×4000**, within the unchanged 60-second/1-GiB-WASM/one-job limits. +- Metadata agrees with independent ImageIO/ExifTool: orientation 6, `samsung`, + `Galaxy S23 Ultra`, original date `2023:11:07 11:17:46`, f/4.9, ISO 64, + exposure 1/60 s and focal length 27.2 mm. + +The public image contains an eight-bit P3 profile with sRGB transfer. Against +the orientation-aware, color-managed ImageIO sRGB output, RGB mean absolute +errors are **7.529 / 8.283 / 7.644**, RMSE **9.017 / 9.524 / 9.465**, and maxima +**29 / 30 / 70** on a 0–255 scale. Alpha agrees exactly. Visual inspection +confirms the same complete upright scene; these pixel differences do not +establish color fidelity. Profile omission is a known implementation limit; +the experiment does not attribute every pixel difference solely to that cause. + +A publisher-generated upright SDR PNG was also identified through a pinned +[LFS pointer](https://raw.githubusercontent.com/imazen/imazen-26/0264d4d8a7e283c046ba2d1febb6e1fc547d511e/png-v3/1400-lilith-nature/1427_nature_bee-on-pink-flower_monument-inochi-kyoto_s23u_iso64-f4p9_20231107-111746_3000x4000.sdr.png): +17,752,719 bytes, SHA-256 +`dd7b9491fe6bf115820b28dc945f58090866de872f365eddcb50e5751825129b`. +It was not downloaded or used as a pixel oracle; the actual reference above is +local ImageIO. That distinction avoids claiming an unperformed comparison. + +This establishes normal decode, metadata and orientation for **one** Samsung +file on Apple Silicon. It does not qualify every Samsung image, other camera +families, iPhone files, HDR, or faithful wide-gamut display. + +## Color/HDR support boundary + +The maintained decoder's documented contract applies YUV matrix/range conversion +but leaves primaries and transfer functions in the source color space. ICC data +is only exposed by DecodeColor. PicFetch's guest calls Decode, and its protocol +carries no ICC/CICP profile or HDR metadata. See +[`third_party/h265/heic/heic.go`](../../third_party/h265/heic/heic.go) and +[`scripts/heicguest/main.go`](../../scripts/heicguest/main.go). + +ICC-managed/wide-gamut display, PQ/HLG transfer handling, HDR tone mapping and +gain-map composition are therefore not implemented. The guest does not reject +files merely because they use those color classes; it can return untagged +source-space pixels. Successful decode or sixteen-bit transport must not be +described as faithful HDR/wide-gamut display. This work does not add a color +engine or change that behavior. Production HEIC remains disabled. + +## What remains unverified + +- Broad camera/device compatibility and other valid crop/mirror/rotation + combinations. The decoder supports still-container transforms; the checked + cases cover only the ordinary inputs listed here. +- Exact SDR color equivalence, wide-gamut color management and HDR presentation. + No suitable HDR reference was selected; the missing presentation behavior is + established independently by the current code contract and is not cured by + adding a fixture. +- Native Intel/macOS, Linux and Windows camera results, distribution execution, + and native worker memory measurements. Apple Silicon runs and cross-builds + do not substitute for those CI/package gates. + +The bounded compatibility continuation is complete. No new decoder, color +engine, production activation, commit, push or signing change was made. diff --git a/docs/heic/experimental-opt-in.md b/docs/heic/experimental-opt-in.md new file mode 100644 index 00000000..89a29682 --- /dev/null +++ b/docs/heic/experimental-opt-in.md @@ -0,0 +1,148 @@ +# Experimental HEIC activation + +Status: implementation in progress, 2026-09-16. The +[active plan](../../plans/2026-09-16-experimental-heic-opt-in.md) owns current +verification; [qualification](qualification.md) preserves earlier helper/source +evidence. This record is not release approval. + +## Settings and installation + +Experimental is the last Settings tab, including when Cache is present. General +opens by default. “Experimental HEIC support” persists as `experimentalHEIC`, +false when absent, and takes effect only at the next startup. Colors may be +inaccurate; HDR display remains unsupported. Still `.heic` and `.heif` are the +only added formats; sequence rejection and the absence of HEIC encoding remain. + +Startup reads preferences before constructing the shared viewer/preview/analysis +owner. The saved checkbox, immutable session capability and latest unavailable +status are separate. Missing or invalid packages retain the preference and show +an explanation in Experimental. Ordinary image formats continue to work. No +alternative decoder, runtime download, elevation prompt or relaxed sandbox is +available as a fallback. Package-level format queries and OS associations remain +unchanged. Saved Explorer HEIC rules remain editable when HEIC is disabled. +The live file-size setting applies to each new read through that reader. The +shared owner's fixed 64 MiB HEIC input ceiling still bounds every request, even +when the user chooses a higher limit for ordinary formats. + +Development builds need the complete helper package. `go run .` or copying only +the main executable does not supply it. On macOS use `make package-mac`: the +running binary must be in `PicFetch.app/Contents/MacOS`, with the independently +signed helper in the fixed nested bundle. Linux and Windows require the `heic` +directory next to the main executable, with the architecture-matched helper, +manifest and notices produced by `scripts/heicpackage`. Preserve that directory +when assembling an installed package; packaging alone leaves the preference off. +Windows uses a verified private copy under the application's cache and never +changes immutable Store files or their permissions. Client shutdown releases +copy leases only after work joins; active obsolete copies defer cleanup. + +The first transition from the released updater still requires a complete +reinstall or a separately qualified bridge. That updater cannot preserve the +new companion/helper package, and on macOS can invalidate the enclosing +signature. This feature does not qualify or implement that bridge. + +## Native evidence commands + +- `make heic-native-macos`: native Intel or Apple Silicon, independently signed + helper and enclosing test app, shared application startup and native decode. +- `make heic-native-linux`: native amd64 or arm64; retains no-cgo helper seccomp + and finite resource tests, then requires application startup and admission. +- `make heic-native-windows HEIC_MSIX_CONFIGURATION=`: run as + a standard user; executes helper/cache/application guards, then installs and + activates the disposable test-MSIX described by that configuration. + +Each native suite also requires `TestNativeHEICAnalysisPixels`: successfully +decoded pixels reach an analysis subprocess and two preview queries in one +retained search subprocess through the shared owner, with cancellation/join +checks. This test uses the owned HEIC fixture and does not run model inference. + +The application fixture is `TestNativePackagedHEICActivation` under `heicnative`. +It relocates the application test executable to the fixed package layout and +runs the shared startup/viewer harness. It exercises the real checkbox across +separate viewer lifetimes, HEIC/HEIF admission, native helper decoding, ordinary +viewing, cancellation and shutdown. Fyne's test driver supplies the surface; +this is application-constructor/package-boundary evidence, not a production GUI +smoke test. Its resumed coverage adds mixed-directory navigation, a foreground +source held after native readiness across a saved Settings edit and collection +replacement, and ordinary viewing after missing/invalid/wrong-target package +inputs. macOS also exercises an owned helper signed without App Sandbox: native +readiness refuses it before bulk source reads, and ordinary viewing recovers. +All damaged-package fixtures are disposable standalone copies; installed MSIX +files remain immutable. These additions pass locally on macOS arm64 with race +detection; the current plan records subsequent cross-platform CI results. +The installed-MSIX entry is `TestNativeInstalledHEICActivation`, +compiled with `heicnative,microsoftstore` alongside the actual Store executable. +The fixture launches the declared test executable directly from its installed +WindowsApps path. Windows can resolve package identity during this ordinary +process launch, as described by a [Microsoft Terminal maintainer](https://github.com/microsoft/terminal/discussions/20060). +It requires actual package identity, Store-managed behavior, a nonadministrator +token and unchanged installed helper bytes/ACLs. + +CI's `packaging/heic/qualify-windows.ps1` provisions a disposable local standard +account on a GitHub-hosted runner. For MSIX it adds a separate test application, +uses a disposable signing certificate, and records setup separately from +application execution. The child replaces inherited runner profile variables +with the loaded standard user's native environment via +[CreateEnvironmentBlock](https://learn.microsoft.com/en-us/windows/win32/api/userenv/nf-userenv-createenvironmentblock). +`qualify-windows-child.ps1` performs the unelevated work +and removes its test package. The parent removes its account, owned workspace +and certificate/trust entry. Neither script is called by PicFetch. CI runs both +architectures for standalone and installed-MSIX scenarios. Runtime or permission +query failures fail the gate; there is no successful skip or elevated substitute. +At `69fef1a`, standalone standard-user qualification passes on both architectures. +Installed-MSIX activation remains blocked: IApplicationActivationManager returned +0x80070520, and ordinary installed-executable launch returns Access denied with +either the installed directory or the owned writable workspace as its working +directory. Both packages install successfully; their test process never starts. +Microsoft requires an [interactive user for packaged application execution](https://learn.microsoft.com/en-us/windows/msix/desktop/desktop-to-uwp-debug). +Qualification now needs native x64 and ARM64 environments with an interactive +standard-user session. Actual package identity and every existing guard must +pass there before this gate is complete. The hosted alternate-user fixture +does not establish installed Store helper activation, staging or sandbox behavior. +The same-desktop-owner experiment at `98d3895` failed on both architectures: +Windows refused to remove the protected account from Administrators +(STATUS_SPECIAL_ACCOUNT, 0xC0000124). Explicit `LogonUserW` / +`CreateProcessWithTokenW` experiments at `e8ea7d4` and `dea62b9` also failed before +producing a child transcript, including with console creation disabled. All +unsuccessful account/token-launch changes were removed. The original fixture +now retains bounded package-specific Windows AppModel/deployment events in +`activation-events.json`; these diagnostics cannot substitute for the installed +test's completion record. No hosted configuration tried so far establishes +successful standard-user MSIX activation. A known-working native standard-user +desktop on each architecture remains the next qualification environment. +For a local MSIX run, the configuration supplies `Scenario: "msix"`, `Repository`, +`Go`, `Work`, `Evidence`, `Arch`, `Commit`, the signed `Package`, its `PackageName` +and architecture-matched `Dependency`. Provision only disposable test state. + +The Windows cache uses Win32 sharing restrictions to retain leased executables +and serialize publishers; copied bytes are checked before publication and again +before launch. The underlying semantics are documented in +[CreateFile](https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilew). +Native execution remains necessary to qualify ACLs, concurrent publication and +Store identity; cross-compilation is not that evidence. + +## Current verification and remaining gates + +At `c1b6890`, native Linux, macOS and standalone standard-user Windows pass on +both architectures on first attempt, including the expanded application +failure/navigation cases. All four Linux race partitions, ordinary Windows, +validation and Store input construction pass. Installed-MSIX remains the only +failing scenario in CI 35120395881: both packages install, but the test process +cannot start (Access denied). Standard-user desktop-session prerequisites and +Windows combined application-level ACL/query/concurrent-lifetime cases remain +open. The old intermittent ARM64 analysis failure did not recur; no root-cause +fix is claimed. + +Fresh Codex code/security reviews report no findings for the implementation +commit. Final Qodana SARIF is empty; CodeQL contains only its two previously +assessed dismissed false positives. GitHub's separate dynamic AI scanner still +fails before analysis because its service rejects the configured model. That +scan remains unverified. Production GUI smoke tests remain distinct from the +test-driver fixture. See the plan and PR #28 for checkpoint and subsequent +documentation-only review/CI evidence. + +All prior sandbox, integrity and resource controls remain. macOS still has the +previously accepted absence of a guaranteed total native-memory cap; WASM, +input/output and deadline limits remain mandatory. Decoder source and dependency +versions are unchanged. Distribution/licensing clearance (including patent and +source traceability questions), production signing and broader camera/color +qualification remain release gates. Successful decoding is not color fidelity. diff --git a/docs/heic/history-reconciliation.md b/docs/heic/history-reconciliation.md new file mode 100644 index 00000000..a1461bb5 --- /dev/null +++ b/docs/heic/history-reconciliation.md @@ -0,0 +1,156 @@ +# HEIC branch history reconciliation + +Date: 2026-09-16. This record accounts for the two commits on +`feature/heic-hardening` against PR #28's published `52ed2df` baseline and the +reconciliation change that contains this document. Neither historical commit +is an ancestor of the PR. Their source remains accessible on the original +branch; no history is rewritten. + +## Scope and acceptance + +Ronin authorized restoring useful omissions into the same PR. The lead owns +code, documentation, review and fixes. One read-only scout checked ordinary +fixture provenance; the CI/review agent remains paused. This extends the +existing Deep plan rather than replacing the new helper architecture. + +| Acceptance | Verification | +| --- | --- | +| Preserved decoder hardening | Compare all 107 `PICFETCH-SOURCE.json` entries byte for byte against `fc127b44`; `make check-heic-wasm`. | +| Qodana YAML rejected before path checks | `go test ./scripts/qodanaconfig`; actual Make gate rejects malformed YAML in an isolated temporary checkout. | +| Evaluate historical eager memory allocation without breaking isolation | Trial runtime/native guards, then restore the prior allocation strategy if current native constraints reject it; keep all memory and sandbox limits unchanged. | +| Useful ordinary compatibility coverage restored | `make test-h265` through the fixed WASI artifact; exact fixture hashes/provenance in testdata README. | +| Current app-wide threat model | Check relative links and review every changed assertion against the current source/qualification record. | +| Reviewable publication | Focused tests, build/provenance/import/Qodana/shard checks, GoLand inspections, signed commit and matching GitHub PR head. | + +## `fc127b44` — Harden and isolate HEIC decoding + +All **107 production/source-license files** in the maintained-source manifest +are byte-identical to the historical commit. These include the decoder's local +input/work/allocation checks. The following table accounts for every remaining +non-vendored path changed by that commit, grouped by responsibility. + +| Historical paths | Current disposition | +| --- | --- | +| `.gitignore` | Restore `/fyne_metadata_init.go`; generated Fyne metadata remains local. | +| `AGENTS.md` | Restore source-maintenance and WASI-only ownership guidance, using current commands and honest test scope; omit the obsolete RSS-test command. | +| `ARCHITECTURE.md` | Current helper/client/guest map supersedes the historical module map; add the restored YAML-validator package and threat-model pointer. | +| `Makefile` | Current guest/build/native/package targets replace old targets. Restore YAML syntax validation. Host and guest advisory scans already remain present. | +| `THIRD-PARTY-NOTICES.md` | Current h265/Go/wazero notices and packaging inventory replace historical inline notices. | +| `docs/find-more-like-this/dependency-qualification.md` | Current source/license/runtime limitations live in `qualification.md` and `third_party/h265/PICFETCH.md`; old qualification is historical evidence, not current release approval. | +| `go.mod`, `go.sum` | Current root/guest replacements select the exact maintained h265 source; removed Rust decoder remains absent. | +| `internal/heicdecode/client.go`, `command_unix.go`, `command_windows.go`, `worker.go` | Replaced by dedicated `cmd/picfetch-heic-worker`, `client/`, `worker/` and `winisolation/`, with shared app-family admission and native restrictions. Do not restore self-execution/ordinary-privilege launch. | +| `internal/heicdecode/register.go`, `internal/imaging/heic.go` | Replaced by explicit injected `imaging.Reader`/`Source`; production activation remains disabled instead of globally registering HEIC. | +| `internal/heicdecode/runtime.go` | Replaced by `worker/runtime.go`. Historical eager maximum-capacity allocation was trialed, then withdrawn after native Linux failures; the existing linear/native ceilings remain unchanged. | +| `internal/heicdecode/wire/wire.go` | Current codec-free request/response/readiness/metadata protocol replaces it; retains bounded NRGBA8/NRGBA64 transport. | +| `internal/heicdecode/decoder.json`, `decoder.wasm`, `scripts/heicwasm/main.go`, `scripts/heicwasmbuild/main.go` | Superseded by separate `scripts/heicguest`, `scripts/heicbuild`, its exact manifest and helper-only embedded artifact. | +| `internal/heicdecode/client_test.go`, `runtime_test.go`, `testdata/capabilities/main.go` | Current owned process/IPC/memory/cancellation/capability tests cover the new boundary. Current ceiling/cancellation tests remain; the trial stable-backing regression was removed with the incompatible eager-allocation option. Old test binaries and protocol are not current tests. | +| `internal/imaging/exif.go`, `loader.go`, `internal/similarity/analyze.go`, `facts.go`, `search_worker.go`, `internal/ui/exifwin/metadata.go`, `filework.go` | Current injected reader and shared analysis pipes propagate context, validated pixels and metadata; byte-only helpers refuse HEIC/native preview fallback. | +| `internal/imaging/heic_test.go` | Current source, native-helper and guest tests cover admission, upright metadata/pixels, cancellation, alpha and explicit ten-bit output. Extend ordinary container/chroma coverage below; no claim of full historical test equivalence. | +| `internal/imaging/heic_leak_test.go` | Legacy in-process/RSS comparison does not qualify the new process-family budget. Native memory/cleanup guards and platform qualification replace that claim. | +| `internal/imaging/save.go` | HEIC encoding remains unavailable; current ordinary-image save behavior does not require the old decoder comment. | +| `plans/2026-09-14-heic-hardening.md`, `plans/2026-09-14-heic-isolation.md`, `todos.md` | Historical evidence remains in Git; current restoration plan, qualification and todos own incomplete work. | +| `qodana.yaml` | Current exact exclusions cover present files, including restored validator tests; old exclusions for absent paths are obsolete. | +| `scripts/qodanaconfig/main.go` | Restore actual YAML parsing before Make's text inventory; reuse the existing pinned YAML dependency. | + +The historical vendored `PICFETCH.md` is replaced by the current provenance, +upgrade and qualification record. An additional manifest pins the unchanged +production copy. + +### Historical test-tree disposition + +The old vendored tree also contained 21 test files, 90 fixture/corpus files and +one upstream workflow absent at `52ed2df`. Copying that entire tree would restore +native decoder/assembly and encoder testing plus historical reproducer/fuzz +inputs, rather than qualify PicFetch's current WASI-only path. + +Restore four unchanged ordinary positive fixtures (`chroma422`, `chroma444`, +`lossless`, `thumb`) and their configuration/decode agreement checks through +the current bounded guest. Strengthen the existing alpha fixture to check its +left/middle/right ramp. Keep existing ordinary metadata, orientation, ten-bit, +input-admission and owned containment tests. This is useful additional coverage, +**not** the complete historical decoder suite or a security certification. +The remaining historical tests/fixtures remain available in the old commit; +none is represented as having passed or being covered by these eight fixtures. + +## `73cb3c9` — added threat model + +Restore a current root `THREAT-MODEL.md`, retaining its application assets, +attacker/operator/user-intent assumptions, filesystem and cache/privacy risks, +release authority, severity guidance and private-reporting policy. + +Replace the historical HEIC section: the new helper has per-platform OS +restrictions, 64 MiB input, a 60-second ceiling, shared GUI/analysis admission, +new paths and explicit disabled production status. Update package/updater +boundaries and document the first-upgrade limitation. The old document remains +historical evidence; it is not copied as an assertion of current behavior. + +## Verification evidence + +- Before changes, the actual Make exclusion-check recipe accepted malformed + `exclude: [` YAML (exit 0) in an isolated temporary repository. +- In the allocation trial, both owned memory-growth regression cases first + failed because growth replaced the backing buffer, then passed with eager + reservation while preserving ceiling refusal. This was not sufficient native + qualification; the option and its pointer-stability test were later removed. +- The YAML validator's five cases pass. The actual Make gate now rejects the + same malformed YAML and accepts valid configuration. Exact Qodana exclusions + include the new test file. +- Focused race runs pass for `internal/heicdecode/worker`, `scripts/heicbuild` + and `scripts/qodanaconfig` (14.833 s, 17.929 s and 1.184 s). The fixture run + exercises all eight ordinary inputs. Owned memory/cancellation regressions + also pass with `heicinterpreter` (0.416 s). +- `make generate-heic-wasm` refreshes the input manifest without changing the + guest artifact. `make verify-build` passes formatting, TUF, Qodana, generated + assets/notices, source and artifact reproducibility, native import guards, + vet and build. The initial restricted-cache failure was rerun with access to + the existing Go cache; no source or policy was weakened. +- `make heic-native-macos` passes all four required Apple Silicon guards during + the preallocation trial: sandbox/readiness/decode/cancellation, both runtime + engines, signed bundle install/rollback, and the legacy upgrade limitation. + The interpreter's expected deadline is a successful containment check, not + a claim that it decoded the 12MP image. +- GoLand inspections, including weak warnings, are clear for all five changed + Go files. The runtime test was re-inspected after its owned module ceiling + was corrected. All local threat-model links resolve; the lead checked + changed behavior claims against source and qualification evidence. +- The final read-only inventory confirms 107/107 source/license hashes and + 4/4 recovered fixture bytes match `fc127b44`, with no mismatches. +- Local canonical shard validation remains unverified: the Linux-only direct + target was mistakenly invoked on macOS, then the documented Docker target + could not start because the daemon was unavailable. No UI test or shard + assignment changed. Native Linux CI remains the canonical gate. + +Publication uses the existing PR branch and configured commit signer, without +merging or rewriting either historical branch. The final task response records +the resulting commit and remote-head verification. Production activation and +the separately paused CI/GitHub review loop are outside this reconciliation's +completion claim. At baseline `52ed2df`, native Linux/macOS guards and all UI +race shards passed, while Windows HEIC guards and the non-UI Linux race job +failed. Fresh CI must verify the reconciliation and its native Linux correction. + +### Native Linux regression caught before handoff + +Signed trial commit `30f5b10` reached PR #28 and triggered native CI run +[35064245412](https://github.com/frathe/picfetch/actions/runs/35064245412). +Both Linux architectures failed `TestNativeSandboxHelper` on the ordinary +ten-bit and 12MP fixtures; they passed at `52ed2df`. The amd64 event artifact +records a closed response pipe and `HEIC helper exceeded diagnostic limit` +for both fixtures. The native policy guard itself passed. Bounded diagnostics +intentionally discard content, so no exact inner fatal message is claimed. + +The only production runtime change was `WithMemoryCapacityFromMax(true)`. +Eager allocation competes with host/runtime mappings under the 2 GiB Linux +address-space ceiling, but the precise allocation-versus-policy mechanism is +not established by the captured error. The useful-omissions scope does not +justify a new allocator or a larger security budget. Restore `runtime.go` and +`runtime_test.go` exactly to `52ed2df`, retaining the existing ceiling tests, +and require the unchanged native Linux guards to pass again. All sandbox, +WASM/native memory, diagnostic and deadline limits remain intact. The other +restored omissions remain included. Current threat/qualification records +explicitly retain transient backing-buffer overlap as a limitation. + +After withdrawing the option, focused worker race tests pass (14.419 s), as do +interpreter memory/cancellation tests (0.303 s). Both restored runtime files +have clear GoLand inspections and exactly match `52ed2df`. The subsequent PR +CI run is the required native Linux confirmation; its status is reported in the +PR description and final handoff rather than inferred from local Mac results. diff --git a/docs/heic/macos-rlimit-probe.c.txt b/docs/heic/macos-rlimit-probe.c.txt new file mode 100644 index 00000000..32cc66ba --- /dev/null +++ b/docs/heic/macos-rlimit-probe.c.txt @@ -0,0 +1,17 @@ +#include +#include +#include +#include +#include +int main(void) { + struct mach_task_basic_info info = {0}; + mach_msg_type_number_t count = MACH_TASK_BASIC_INFO_COUNT; + kern_return_t kr = task_info(mach_task_self(), MACH_TASK_BASIC_INFO, (task_info_t)&info, &count); + if (kr != KERN_SUCCESS) return 2; + printf("virtual_bytes=%llu resident_bytes=%llu\n", (unsigned long long)info.virtual_size, (unsigned long long)info.resident_size); + struct rlimit limit = {2ULL * 1024 * 1024 * 1024, 2ULL * 1024 * 1024 * 1024}; + int result = setrlimit(RLIMIT_AS, &limit); + int saved_errno = errno; + printf("setrlimit_2GiB=%d errno=%d (%s)\n", result, saved_errno, result ? strerror(saved_errno) : "success"); + return 0; +} diff --git a/docs/heic/notices/Go-LICENSE b/docs/heic/notices/Go-LICENSE new file mode 100644 index 00000000..2a7cf70d --- /dev/null +++ b/docs/heic/notices/Go-LICENSE @@ -0,0 +1,27 @@ +Copyright 2009 The Go Authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google LLC nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/heic/notices/Go-PATENTS b/docs/heic/notices/Go-PATENTS new file mode 100644 index 00000000..73309904 --- /dev/null +++ b/docs/heic/notices/Go-PATENTS @@ -0,0 +1,22 @@ +Additional IP Rights Grant (Patents) + +"This implementation" means the copyrightable works distributed by +Google as part of the Go project. + +Google hereby grants to You a perpetual, worldwide, non-exclusive, +no-charge, royalty-free, irrevocable (except as stated in this section) +patent license to make, have made, use, offer to sell, sell, import, +transfer and otherwise run, modify and propagate the contents of this +implementation of Go, where such license applies only to those patent +claims, both currently owned or controlled by Google and acquired in +the future, licensable by Google that are necessarily infringed by this +implementation of Go. This grant does not include claims that would be +infringed only as a consequence of further modification of this +implementation. If you or your agent or exclusive licensee institute or +order or agree to the institution of patent litigation against any +entity (including a cross-claim or counterclaim in a lawsuit) alleging +that this implementation of Go or any code incorporated within this +implementation of Go constitutes direct or contributory patent +infringement, or inducement of patent infringement, then any patent +rights granted to you under this License for this implementation of Go +shall terminate as of the date such litigation is filed. diff --git a/docs/heic/notices/h265-LICENSE b/docs/heic/notices/h265-LICENSE new file mode 100644 index 00000000..91d5b917 --- /dev/null +++ b/docs/heic/notices/h265-LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2025 roticv +Copyright (c) 2026 Karpeles Lab Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/heic/notices/wazero-LICENSE b/docs/heic/notices/wazero-LICENSE new file mode 100644 index 00000000..e21d6995 --- /dev/null +++ b/docs/heic/notices/wazero-LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2020-2023 wazero authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/docs/heic/notices/wazero-NOTICE b/docs/heic/notices/wazero-NOTICE new file mode 100644 index 00000000..2f5ea8eb --- /dev/null +++ b/docs/heic/notices/wazero-NOTICE @@ -0,0 +1,2 @@ +wazero +Copyright 2020-2023 wazero authors diff --git a/docs/heic/qualification.md b/docs/heic/qualification.md new file mode 100644 index 00000000..32c2523d --- /dev/null +++ b/docs/heic/qualification.md @@ -0,0 +1,474 @@ +# HEIC restoration qualification, 2026-09-15 + +## Current disposition + +The September 16 [experimental activation record](experimental-opt-in.md) +supersedes the earlier disabled-constructor status. The implementation now has +a default-off, restart-only Settings preference and executable-derived package +activation. Existing helper/source evidence below remains historical evidence; +it does not qualify the new Windows cache, standard-user or installed-MSIX +paths. Native macOS arm64 application-constructor coverage passes locally; +remaining platform/package runs, complete CI and fresh reviews are pending. +Distribution clearance, production signing, the first-upgrade transition and +broader camera/color qualification remain release gates. No release or merge +is authorized by this record. + +The subsequent source-retention instruction is implemented by restoring the +exact maintained production copy in `third_party/h265`. The earlier unmodified +v0.2.3 guest is superseded: it did not contain every local hardening change. +The new source/artifact is undergoing native and compatibility qualification; +preserving those checks is not itself permission to activate HEIC. + +## Exact source and distribution inventory + +| Component | Reviewed input | Distribution record | +| --- | --- | --- | +| h265 HEIC/HEVC code | Maintained PicFetch production copy from `fc127b44e1c99447b8d150256563c6d4f99b8ad3`, based on upstream v0.2.2 `665fd95984177afef4a7efca7d50638e4b695c7a`. Exact 107-file baseline in `third_party/h265/PICFETCH-SOURCE.json`; its SHA-256 is `583c5ab3b021675acfac0bc7344a7ff7acdc8f0ae1ab94e804d7b931e3618b9b`. | Local hardening retained unchanged; MIT text and both copyright notices in the maintained LICENSE and `notices/h265-LICENSE`. | +| Guest compiler/runtime | Go 1.27.1, `wasip1/wasm`, no CGo, no assembly | BSD license and patent grant in `notices/Go-LICENSE` and `notices/Go-PATENTS`. The selected guest graph has no vendored third-party standard-library imports. | +| PicFetch adapter/protocol | This branch's `scripts/heicguest` and `internal/heicdecode`; exact input hashes in `scripts/heicguest/decoder.json` | PicFetch MIT; adapter and maintained decoder are separately inventoried. | +| WASI host | Existing wazero v1.12.0; fixed guest, helper and qualification tools | Apache-2.0 license and NOTICE in `notices/wazero-LICENSE` and `notices/wazero-NOTICE`. No version upgrade. | +| Native helper / OS library | `cmd/picfetch-heic-worker`, codec-free boundary, Go/wazero; macOS uses system Security/CoreFoundation frameworks via cgo | Separate minimal executable, no Fyne or native HEIC codec. Final shipped closure and notice placement remain packaging gates. | + +The [pinned upstream README](https://github.com/gen2brain/h265/blob/b2d46ba787d8f0a2025bd106443ab1b1c7cd010f/README.md) +describes a pure-Go implementation without module dependencies and names +rust_h265 and oxideav-h265 as source projects. Its LICENSE credits roticv (2025) +and Karpeles Lab Inc. (2026). The reviewed module contains that license, but does +not identify exact translated-source revisions or a file-by-file lineage map. +This records the distributed notice and the remaining traceability gap; it does +not invent upstream provenance or claim the whole translation is qualified. +The prior removed `github.com/gen2brain/heic` Rust payload is unrelated to this +new module and remains excluded. + +The upstream README explicitly separates its software copyright license from +HEVC patent rights. This work claims no patent clearance, purchases no license, +and does not change PicFetch's MIT license. Existing project-wide dependency +qualification obligations also remain open. + +### Source reconciliation with the earlier v0.2.3 candidate + +The previous module is pinned at `665fd95984177afef4a7efca7d50638e4b695c7a`. +Static source comparison found six production files changed: `heic/exif.go`, +`heic/heic.go`, `heic/sequence.go`, `hevc/deblock.go`, `hevc/decoder.go`, and +`hevc/headers.go`. Upstream changes include a missing-metadata guard, propagating +coded-frame limits into the HEVC decoder, bounded/delayed sequence-table work, +transform-size validation; the slice/deblocking differences are comments. Upstream test +changes were inventoried but not executed or copied. + +The maintained v0.2.2-based source already contains the missing-metadata, +coded-frame and 32-sample transform checks. It additionally retains aggregate +coded-work accounting, metadata/extent limits, streaming bounded NAL traversal, +finite sequence retention and transformed configuration. Its stricter sequence +implementation is preserved in full; no complete equivalence to upstream's +different table representation is claimed. All 107 production/source-notice +files are byte-identical to the clean historical subtree, including unused +native assembly. The WASI guest selects `noasm`; native application imports +remain prohibited. The historical full test corpus is not imported. Selected +ordinary fixtures and a current application-wide [threat model](../../THREAT-MODEL.md) +are restored by the [history reconciliation](history-reconciliation.md). + +The exact upstream base module sum is +`h1:rnpfo8I4PFhohbij8ySYZlJFn07TTwV+/uWBrKsOEas=` and its ZIP SHA-256 is +`57a197c95e25b481abcd6d20773ebfb17d859e6d864017304aff6ec0e0f19757`. +Root and guest replacements select the maintained directory. Build guards +refuse missing/redirected replacements, changed baseline bytes and uninventoried +source files before reproducing the guest. + +Two API facts affect this adapter: + +- The still API can fall back to decoding an image sequence. PicFetch's guest + therefore refuses every top-level `moov` box before calling upstream. This is + also conservative for a still image accompanied by movie data. +- Decoding returns straight-alpha NRGBA8/NRGBA64; treating those bytes as + premultiplied RGBA changes translucent pixels. Protocol v2 identifies the + layout and preserves the full sixteen-bit samples. The earlier v1 boundary + was never shipped and has no compatibility promise. + +## Reproduction and guards + +From the repository root, with Go 1.27.1: + +```sh +make heic-build +make heic-check-provenance +make heic-check-imports +make heic-check-guest +``` + +`heic-build` checks the reviewed source revision, module and archive hashes, +verifies the module cache and notices, then builds with `GOOS=wasip1`, +`GOARCH=wasm`, `CGO_ENABLED=0`, `GOWORK=off`, `GOTOOLCHAIN=local`, empty +`GOEXPERIMENT`/`GOFLAGS`/`GOWASM`, `-mod=readonly -tags=noasm -trimpath +-buildvcs=false -ldflags='-s -w -buildid='`. It writes the artifact and its +input/hash manifest. `heic-check-provenance` refuses input inventory or hash +changes and independently rebuilds to a temporary file for byte comparison. +The manifest is a review record, not a signature or protection from a committer +who changes both sources and recorded hashes. + +The import guard rejects direct native codec imports across build tags, checks +the selected native imaging/analysis graph, and permits only the exact h265, +PicFetch adapter and standard-library guest closure. The guest's entry point is +WASI-only in a separate Go module. The artifact is not embedded in the viewer. +Source/notice changes require an intentional rebuild and review. Guard tests +include mismatched source content, missing artifacts and forbidden imports. + +`make heic-fixture` reproduces one ordinary 16x16 ten-bit gradient inside WASI. +Fixture provenance and scope are in `scripts/heicbuild/testdata/README.md`. +Upstream `basic.heic` and `main10.heic` are byte-identical in the pinned archive; +only the generated explicit-depth gradient is used as ten-bit evidence. + +## Resource and compatibility limits + +The WASI runtime enforces a linear-memory ceiling, but guest growth can +temporarily retain old and replacement backing buffers. Eager maximum-capacity +allocation was trialed during history reconciliation and passed local runtime +and native Apple Silicon tests. It broke both native Linux architectures on +ordinary fixtures, so it was withdrawn without raising limits or relaxing +seccomp. Neither allocation strategy establishes a whole-helper RSS guarantee. +See [the reconciliation evidence](history-reconciliation.md). + +The candidate production maxima remain 64 MiB input, 64M pixels, 256,000,000 +output bytes, 64 KiB normalized metadata, 4096-byte diagnostics, 60 seconds, +1 GiB WASM linear memory, and a requested 2 GiB native worker budget. +The output cap allows at most 32M NRGBA64 pixels. The protocol accepts only +positive finite limits; resource fields are contracts, not OS enforcement. +Guest decoding sets upstream `Threads: 1`. The helper sets Go's thread ceiling +to 32 and GOMAXPROCS to 1; this is not an OS process-family thread quota. Its +1.5 GiB Go memory target is soft. Readiness explicitly reports zero hard native +memory bytes on macOS under Ronin's accepted availability tradeoff. + +The development fixture tests use 1 MiB input, 1M pixels, 8 MiB output, 128 MiB +WASM linear memory, bounded stderr/stdout, and a deadline. They provide no +untrusted files, filesystem preopens, environment, real clock or sockets to the +guest. The host test process is not claimed to have the production OS cap. +These are positive ABI checks with ordinary images, not memory-exhaustion tests. + +Configuration currently decodes pixels to report the same transformed dimensions +as the displayed image. Optimizing this requires a qualified transformed-header +API. Container transforms are applied in the guest; Exif orientation is returned +as metadata. The imaging adapter keeps these validated pixels without applying +Exif orientation again. Color-profile/HDR handling, representative camera +compatibility and memory measurements remain unqualified. + +The color limitation is stronger than missing test evidence: the maintained +decoder applies the YUV matrix and range, but deliberately leaves primaries and +transfer in the source color space (`third_party/h265/heic/heic.go`, Color +contract). Its ICC profile is reported only by `DecodeColor`; the guest calls +`Decode`, and the response protocol carries neither ICC nor CICP descriptions. +Consequently, ICC-managed, wide-gamut, PQ/HLG and gain-map HDR display are not +implemented. These files are **not deliberately rejected by color class**; +some can decode into untagged pixels without faithful display semantics. +Sixteen-bit sample transport is not an HDR or color-management claim. No color +engine redesign or new HDR feature is part of this qualification work. +Additional real-helper metadata/orientation, alpha and known-ramp checks are +recorded in the [ordinary compatibility report](compatibility-2026-09-16.md). + +## OS enforcement status + +| Platform | Documented candidate | Implemented/runtime verified here | Admission | +| --- | --- | --- | --- | +| Linux x64/ARM64 | All-thread seccomp, hard RLIMIT_AS virtual-address-space ceiling, CPU/core/file/descriptor limits before input; bounded WASI and parent lifetime | Both native helper suites pass in hosted CI. Packaged application qualification remains open. No RSS or cgroup limit is claimed. | Disabled | +| Windows x64/ARM64 | AppContainer/restricted capabilities plus Job Object aggregate commit-memory/process/CPU/kill-on-close limits, explicit inherited pipes and suspended setup | Both native policy/transport/ordinary-decoding suites pass at `ee5cc67`. Standard-user, Authenticode/MSIX and distribution execution remain separate qualification gates. | Disabled | +| macOS Intel/Apple Silicon | Independently entitled App Sandbox helper bundle, hardened runtime, bounded WASI, parent-owned pipes/deadline/group termination | Both architectures have passed native ordinary decode, read/create/TCP/UDP denial and cancellation in CI. Packaged application qualification remains open. Hard total native cap absent by explicit approval. | Disabled pending qualification | + +Linux's [cgroup v2 documentation](https://docs.kernel.org/admin-guide/cgroup-v2.html) +describes charged memory and explicitly permits temporary `memory.max` overshoot. +Swap is separately controlled, and unprivileged use depends on actual delegation. +Neither cgroup existence nor a CLI flag proves the worker is governed by it. + +Windows [Job Object extended limits](https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-jobobject_extended_limit_information) +control aggregate committed memory, not an exact physical-RAM measurement. +[AppContainer](https://learn.microsoft.com/en-us/windows/win32/secauthz/implementing-an-appcontainer) +is a separate privilege boundary. Both must be established before input and +verified in the direct executable and MSIX distribution. + +Apple's [published XNU resource implementation](https://github.com/apple-oss-distributions/xnu/blob/xnu-12377.121.6/bsd/kern/kern_resource.c) +sets an address-space map limit for `RLIMIT_AS`, rejecting one below existing +mappings. It is incorrect to say this API is universally ignored. This source +is evidence about address-space accounting, not a whole-helper resident-memory +limit or supported-version matrix. [Sandboxed XPC services](https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingXPCServices.html) +provide privilege/crash separation, not a configurable hard RAM ceiling by +themselves. Private or privileged jetsam controls are not an established shipping +solution for an ordinary app. + +### Local macOS feasibility observation + +On macOS 27.0 (26A428), arm64, a disposable C executable queried +`MACH_TASK_BASIC_INFO`, then attempted `setrlimit(RLIMIT_AS, {2 GiB, 2 GiB})`. +It did not allocate a large buffer, test an image, or change the parent process. +Result: + +```text +virtual_bytes=500512735232 resident_bytes=1835008 +setrlimit_2GiB=-1 errno=22 (Invalid argument) +``` + +The probe source is retained in `macos-rlimit-probe.c.txt`. Reproduce with: + +```sh +clang -x c -Wall -Wextra -Werror docs/heic/macos-rlimit-probe.c.txt -o /tmp/picfetch-heic-rlimit-probe +/tmp/picfetch-heic-rlimit-probe +``` + +This eliminates this specific 2 GiB address-space setting on this host; it does +not prove all macOS resource strategies impossible. The later approval below +supersedes that hard-native-memory gate, without removing sandbox requirements. + +## Subsequent explicit macOS decision + +Ronin subsequently approved the practical macOS tradeoff: activation may proceed +once sandboxing and finite decoder memory are verified, even though the native +helper lacks a guaranteed hard total-memory cap and can cause memory pressure or +crashes. Capability restrictions remain mandatory. This supersedes the original +whole-worker-cap gate for macOS; the proposed 2 GiB number was an engineering +starting point. Application/analysis injection of the shared client is now +implemented, while production construction still supplies no HEIC owner. +Bounded host allocations, byte-only validated IPC, finite jobs/time, +cancellation/cleanup, shared admission and platform sandboxing remain required. + +## Native macOS helper evidence, 2026-09-16 + +The selected public route is an independently entitled helper `.app` bundle. +`SecTaskCopyValueForEntitlement` verifies App Sandbox at startup; owned external +read/create and owned loopback TCP/UDP probes must all return EPERM/EACCES before +the helper sends readiness or reads image input. A plain ad-hoc signed executable +aborted before main on this host; the signed bundle starts and strict codesign +verification passes. No deprecated `sandbox_init`/`sandbox-exec` route is used. +Apple recommends App Sandbox for computation helpers and describes separate +container rights in its [secure-helper guide](https://developer.apple.com/library/archive/documentation/Security/Conceptual/SecureCodingGuide/DesigningSecureHelpers/DesigningSecureHelpers.html) +and [current sandbox guide](https://developer.apple.com/documentation/security/protecting-user-data-with-app-sandbox). + +App Sandbox is not zero native filesystem authority: the helper can access its +own container and some system resources. It also does not prove zero possible +native descendants. The guest gets no filesystem preopens, environment, socket +descriptors or native execution bridge. Generic WASI imports are not granted +capabilities. The trusted native host compiles only the fixed embedded module; +image bytes enter solely through bounded stdin. Parent code uses an absolute +hash-pinned executable, explicit stdio, a minimal environment, and no user image +paths in helper arguments. Trusted probe paths contain no image data. + +The parent owns one admitted operation, at most 64 waiters, the 60-second +deadline, every pipe and process wait. It kills the Unix group before reaping +its leader (preventing PID reuse before a late kill) and joins pipe work before +releasing admission. An owned peer and descendant pass cancellation with an +observable connection close; deliberately killing only the leader makes that +guard fail. The shared Client now serves bounded analysis connections through +explicit inherited pipes. GUI and analysis construction inject that same owner; +production construction still supplies no owner until qualification completes. + +### Executable-memory decision + +Pinned wazero v1.12.0 maps anonymous code pages read/write and then changes them +to read/execute; it does not use MAP_JIT. See +[allocation](https://github.com/tetratelabs/wazero/blob/v1.12.0/internal/platform/mmap_other.go) +and [protection](https://github.com/tetratelabs/wazero/blob/v1.12.0/internal/platform/mmap_unix.go). +Apple's [allow-jit entitlement](https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.security.cs.allow-jit) +specifically concerns MAP_JIT. The working compiler bundle therefore needs the +broader [unsigned-executable-memory exception](https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.security.cs.allow-unsigned-executable-memory). +No dynamic-library validation, DYLD-environment, executable-page-protection, +network or user-file exception is added. + +Actual controls on macOS 27.0 (26A428), arm64, Go 1.27.1: + +| Hardened, App Sandbox helper | Ordinary 320x240 image | Owned 4032x3024 lossless gradient | +| --- | --- | --- | +| Interpreter, no executable-memory exception | 1.5206 s, decoded | Parent deadline at 30.0093 s; terminated and joined | +| Compiler, required unsigned-executable-memory exception | 1.6174 s, decoded | 7.4040 s, correct dimensions | + +These are single cold runs including startup and compilation, not a general +performance guarantee. The initial comparison used the raw fixture; its checked +gzip wrapper now saves repository space without changing HEIC bytes. A separate +real ten-bit decode plus read/create/TCP/UDP denial and cancellation test passed +in 1.94 s with the hardened compiler bundle. Hardened Runtime without the +required exception refused the real decode, while its cancellation check passed. + +Keep the compiler in the disposable helper: the interpreter failed the existing +deadline on an ordinary image of a common camera size. The broader entitlement +increases native executable-memory authority and therefore the trusted runtime +surface; it does not grant file or network access. Images are data, never modules +to compile. App Sandbox, WASI validation, fixed code, bounded IPC and process +termination remain separate protections, not a guarantee against every unknown +native/runtime/kernel defect. Developer ID signing/notarization, Intel execution, +own-container identity across updates and final packaged launch remain to verify. + +Reproduce with `make heic-native-macos`; the native suite refuses skipped guards. +It uses only owned peers/probes and ordinary fixtures. Pure boundary tests also +verify initial/growing WASM memory limits, an owned loop's deadline, bounded +diagnostics, changed-helper refusal, blocked-writer timeout, crash, and Stop/Wait. +An owned WASI capability control confirms no filesystem preopen and detects a +deliberately installed temporary directory. The canonical native target passed +in 46.377 s after adding missing-sandbox refusal and descendant cleanup. Its +gzip-wrapped 12-megapixel fixture took 7.1366 s with the compiler; the interpreter +reached the unchanged deadline at 30.0108 s. Raw HEIC bytes are unchanged. +Full app functionality and broad camera/color/orientation qualification remain +open. The separate macOS research informed this record; native results above +come from this implementation task, not from documentation alone. + +### Finite lifetime tuning after native Intel evidence + +Signed worker `c4a89d1` passed native Intel App Sandbox, small-image decoding, +read/create/TCP/UDP denial and cancellation. Its 12-megapixel compiler comparison +reached the 30-second ceiling (30.060 s); the interpreter also timed out. This +was a controlled deadline rejection, not successful large-image qualification. +The same x86 transport/compiler path with the maintained source completed the +fixture in 5.967 s under Rosetta after using the installed ARM clang toolchain. +Rosetta is diagnostic evidence only and does not replace native Intel timing. + +The lifetime ceiling is now proposed as a finite 60 seconds, with an explicit +rejection test above that value. The initial 30 seconds was an engineering +choice; native Intel ordinary-camera usefulness requires measuring beyond it. +The pending native Intel run must show its actual completion time before that +platform's common-camera performance is qualified. Longer occupation of the +single lane is the availability cost; file/network restrictions, WASM memory, +input/output/metadata/diagnostic sizes, thread settings and job count do not +change. Earlier 30-second measurements above remain historical observations. + +The prior Linux race failure was compilation of the fixed real guest under +instrumentation: 37.08 seconds exceeded 30. The same focused ordinary-worker +test took 0.89 seconds without race instrumentation on this host; all owned +memory, capability, cancellation and stream tests passed in CI. The new finite +ceiling retains that real compiler test and all native guards; none is skipped. + +The maintained-source candidate passes `make verify-build test-h265`, the +focused boundary/guest race suite, and native Apple Silicon qualification. +With the 60-second ceiling, its compiler decoded the 12MP fixture in 6.713 s; +the interpreter reached 60.012 s and was terminated/joined. The helper-only +executable-memory entitlement remains justified by this observed runtime +difference. Native Intel timing at the new ceiling is still pending CI. + +## Linux native-helper candidate + +The no-cgo amd64/arm64 helper now has a default-deny seccomp policy synchronized +across threads. It allows explicit Go scheduling, private futexes, stdio/poll, +signal and anonymous-memory operations. Exact architecture-specific CLONE_THREAD +flags permit runtime threads; process/exec, file opens, network, io_uring, +namespace and resource-policy changes remain denied. Wazero's private RW +mapping followed by RX protection is allowed; RWX and file-backed mappings are +not. Optional huge-page allocation and mapping-name decoration may be refused. +The compiled runtime and kernel remain trusted. This reduces native authority; +seccomp by itself is not a complete security guarantee. See the +[kernel seccomp documentation](https://docs.kernel.org/userspace-api/seccomp_filter.html). + +Before input, the helper verifies finite hard/soft limits for virtual address +space (2 GiB), CPU time (rounded request seconds), core files (zero), file size +(zero), and descriptors (32). It refuses an already oversized starting address +space and requires ENOMEM from an oversized PROT_NONE reservation after policy +installation. The reservation touches no RAM. RLIMIT_AS constrains virtual +mappings, not physical RSS; the Go thread ceiling remains runtime enforcement, +not a per-family kernel PID quota. See [Linux resource-limit semantics](https://man7.org/linux/man-pages/man2/getrlimit.2.html). + +Pure Go is part of this candidate's contract: cgo Linux builds and unsupported +architectures refuse startup. The `heic-linux` native suite records actual +thread synchronization/inheritance, address-limit negative control, owned +runtime cancellation, file/network denial and ordinary ten-bit/12MP decoding. +CI selects native amd64 and arm64 hosts using documented +[GitHub runner labels](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). +Cross-build and portable BPF results are available locally; native execution +remains pending publication and CI. No production HEIC activation is included. + + +## Shared source and Windows integration checkpoint + +The GUI owns one optional HEIC Client shared by foreground/background Readers +and both analysis subprocess types. Remote connections inherit explicit pipes, +wait for owner admission/native readiness before bulk reads, and retain the lane +through validated output delivery. The imaging Source retains ordinary encoded +data or validated HEIC pixels/metadata, so HEIC probing and decoding do not run +twice. Container transforms remain applied once. Display/preloads, comparison, +grid, EXIF, capture sorting, Favorite/Spiral/mosaic previews and similarity use +the injected readers. Production still supplies no owner and advertises no HEIC +formats while qualification is incomplete. Focused race results are in the plan. + +The Windows candidate creates a zero-capability AppContainer process suspended, +with three explicit inherited stdio handles and a child-process restriction. +Before resuming it, the parent assigns a private Job Object with active-process +limit one, requested process/job committed-memory ceilings, finite user CPU time +and kill-on-close. The worker independently checks its AppContainer SID, empty +capability list and immediate job, then performs owned file/TCP/UDP denial +probes before readiness. [Microsoft documents creation attributes](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-updateprocthreadattribute) +and [immediate-job queries](https://learn.microsoft.com/en-us/windows/win32/api/jobapi2/nf-jobapi2-queryinformationjobobject). + +Dedicated helper provisioning grants that AppContainer read/execute on only the +helper and its immediate directory; the grant does not propagate to children. +Launch does not mutate ACLs. The profile can access its own storage and permitted +system resources; these controls do not establish zero native filesystem access. +The stable profile and immutable MSIX installation/staging path still need native +distribution qualification. + +The `heic-windows` CI matrix requires owned token/job and unsandboxed-refusal +controls, inherited-pipe cancellation, analysis attachment cleanup, and ordinary +ten-bit/12-megapixel decode. The memory control uses a 64 MiB job and a fixed +65 MiB commitment attempt, touches no pages, and frees any unexpected allocation +immediately. It is a bounded OS-policy control, not an image decoder stress test. +Cross-build/vet/IDE results establish compilation only. The Windows execution +evidence below comes from native hosted runners. + +## Native Windows qualification checkpoint (2026-09-16) + +At `ee5cc67`, [CI run 35084746959](https://github.com/frathe/picfetch/actions/runs/35084746959) +passes both Windows HEIC suites, including token/job checks, fixed bounded +commitment refusal, child-process denial, unsandboxed refusal, inherited-pipe +cancellation and ordinary helper decoding. The native runner rejects skipped +required guards. `windows-latest` runs amd64; `windows-11-arm` runs ARM64. + +| Ordinary fixture | Windows amd64 | Windows ARM64 | Verified result | +| --- | --- | --- | --- | +| `tenbit.heic` | 4.316 s | 4.104 s | 16 x 16, NRGBA64 retained | +| `photo-gradient.heic.gz` | 17.396 s | 14.740 s | 4032 x 3024 | + +The launcher checks the native loopback exemption list on every launch and +refuses an exempt helper SID or query failure. This privileged read cannot run +inside AppContainer. The helper verifies its exact zero-capability token and +job before file/network probes. Parent-owned TCP/UDP listeners are positively +checked first; UDP uses a bounded one-byte echo worker. Windows' packet-drop +semantics may produce a timeout, accepted only with a live request, the verified +token and the launcher's nonexemption gate. A successful connection/echo refuses +readiness. Listeners close and their worker joins before image bytes are read. +Linux/macOS continue to require explicit permission errors. + +The ordinary Windows suite also runs the release workflow's fixed manifest +transformation on inert helper bytes, verifying both targets and invalid-input +rejection without signing credentials. The protected signing job has no +repository checkout/toolchain execution and verifies both executables after +manifest finalization. Actual Authenticode/MSIX, final distribution execution +and nonadministrator Windows launches remain unverified; these hosted results +do not activate HEIC support or qualify the first released-updater transition. + + +## Package updates and first-upgrade limitation + +Authenticated archives now extract into a fresh payload directory. Stage +provenance records every companion-file digest and revalidates the exact file +set after persistence and before install. Linux/Windows prepare the dedicated +helper directory before replacing it, then roll it back if the existing binary +transaction fails. macOS installs the complete verified app bundle, including +its enclosing signature resources, nested helper, manifest, notices and sealed +resources. The viewer joins HEIC process/pipe retirement before applying updates. +Removing companion provenance from a helper-bearing stage is rejected; only +legacy stages without helper content may omit that proof. + +Owned native macOS bundles with different helper signatures, manifests and +sealed resources pass strict deep codesign verification after successful +installation and after an injected final-rename failure restores the original +bundle. Neighboring owned user-data bytes remain unchanged. Reverting to the +binary/plist-only path deliberately makes this guard fail; the restored +implementation passes. These are signed fixture/package transaction checks, +not a claim of Windows or production distribution execution. + +The final local `make heic-native-macos` run on 2026-09-16 passes all four +mandatory helper/runtime/update/legacy-transition guards. The compiler's 12MP +decode takes 6.772 s; the interpreter reaches 60.015 s and is terminated/joined. +The signed install/rollback guard passes in 1.310 s, and the expected legacy +transition limitation is observed in 1.220 s. The raw local event stream is +`.scratch/heic-qualification/native-macos.json`. Full updater race regressions +pass in 4.091 s; native guard registration regressions pass in 1.344 s. + +**The currently released updater cannot perform the first helper-bearing +upgrade completely.** Its binary/plist replacement omits the helper and then +deletes the staged archive payload. The new app cannot recover the helper from +that cache. An owned native reproduction also leaves the enclosing macOS bundle +failing strict signature verification with an invalid resource-directory error. +The first helper-bearing version therefore requires complete package +reinstallation, or a separately designed and qualified bridge before release. +New updater code only governs subsequent upgrades. Missing/unqualified helper +availability remains closed; no release, repair download, or external user +notification is authorized or performed by this work. diff --git a/docs/heic/robustness-testing.md b/docs/heic/robustness-testing.md new file mode 100644 index 00000000..228693ab --- /dev/null +++ b/docs/heic/robustness-testing.md @@ -0,0 +1,74 @@ +# HEIC robustness testing boundary + +This project restores HEIC only behind a disposable, resource-bounded WASI +helper. Qualification is defensive validation of PicFetch's own boundary, not +third-party vulnerability research. + +## Allowed inputs and exercises + +- Ordinary, licensed photographs may exercise configuration, still decode, + metadata, orientation, alpha, supported bit depths and application routes. +- PicFetch-owned fake helpers and guests may return bounded invalid protocol + messages, exit, hang, request bounded over-allocation, or attempt benign file + and network access that the sandbox must deny. +- PicFetch-owned property tests may generate only length-bounded protocol values. +- Cancellation, timeout, busy admission, foreground priority without starvation, + process-family cleanup, missing isolation and recovery on the next request are + required observations. + +Do not generate malicious images, use historical exploit corpora, conduct +third-party vulnerability discovery, or pass rejected work to another model. +An ordinary invalid fixture found during normal use may become a regression only +after its provenance and safe handling are reviewed. + +## Budgets and observables + +The current finite ceiling for an admitted operation is 60 seconds. Native +Intel ordinary-camera evidence motivated tuning the initial 30-second proposal; +see the qualification record for completed and pending measurements. The separate maxima +are 1 GiB WASM linear memory, a requested 2 GiB native-process budget, +64 MiB encoded HEIC input (further reduced by a positive user limit), 64 million +pixels, checked NRGBA8/NRGBA64 output, 64 KiB metadata, 4096-byte diagnostics, +one guest decode lane and one live helper per application instance. Native +helper thread/process ceilings require separate OS enforcement. Ronin approved +macOS's absent hard native-memory cap once sandbox/WASM bounds are verified; +readiness reports this absence, and a soft Go memory target is not a hard cap. Measurements +on representative photographs must account for parent input, pipe buffers, +guest backing, wazero/Go overhead, output staging and application caches. Limits +may be lowered from evidence; they must remain finite. + +Each lifecycle test observes its own completion rather than inferring completion +from a cleared counter. A cancellation, timeout, crash, startup failure, excess +output or memory termination must stop admission, close IPC, join the helper and +descendants, release the lane and allow the next valid request. File/network +probes observe both denial and helper cleanup. Platform tests also observe the +kernel primitive rather than trusting a command-line flag. + +## Incomplete coverage + +These bounded checks are not fuzzing, a proof of decoder correctness, a security +certification or HEVC patent clearance. No fuzz coverage is claimed unless an +actual command and duration are recorded in the active plan. Compile-only +Windows or macOS results do not qualify runtime support. HEIC remains unavailable +on a platform until its packaged application passes that platform's isolation, +memory, cleanup and ordinary-photo tests. + +## Local development evidence + +The development guest is a separate WASI-only module. Its positive fixture ABI +tests use three distinct ordinary images (RGB, alpha, and an explicitly ten-bit +gradient) plus the upstream byte-identical `main10.heic` alias. Parent protocol +checks use bounded owned messages. No upstream negative test corpus, historical +reproducer or fuzz campaign was run. Owned helper peers now exercise crash, +blocked-writer timeout, excess diagnostics, descendant cleanup and Stop/Wait. +Owned tiny WASM modules exercise memory-page admission/growth, loop termination, +diagnostic bounds and absent filesystem preopens. A deliberately granted +temporary directory is detected; leader-only termination fails the descendant +guard. Both controls are restored to their required production behavior. +The real signed macOS helper checks owned file read/create and loopback TCP/UDP +denial before ordinary image input, then verifies decode and cancellation. A +fixed ordinary 12-megapixel gradient compares runtimes within the existing +deadline; it is not an image-parser attack or a memory-exhaustion campaign. +Source/artifact guards are negatively exercised using +owned source text and missing files. Full status and remaining gates are in +[qualification.md](qualification.md). diff --git a/docs/release-signing.md b/docs/release-signing.md index 0170881b..3de0918b 100644 --- a/docs/release-signing.md +++ b/docs/release-signing.md @@ -42,9 +42,11 @@ the action installs it. 1. A v* tag runs the normal reusable CI test gate. 2. The Linux cross-build produces the two unsigned Windows ZIP artifacts. 3. The sign-windows job waits for the protected release-signing environment, - downloads those artifacts, and authenticates SimplySign. -4. SignTool signs each picfetch.exe with SHA-256 and Certum's RFC-3161 - timestamp service, then verifies the embedded signature. + downloads those artifacts, and authenticates SimplySign. It has no source + checkout or build toolchain and never executes repository programs. +4. SignTool signs each picfetch.exe and its HEIC helper with SHA-256 and Certum's + RFC-3161 timestamp service. Fixed PowerShell hash/JSON commands refresh the + helper manifest, then both executable signatures are verified before packing. 5. The job uploads new signed Windows ZIP artifacts. 6. The final release job publishes macOS, Linux, and only the signed Windows ZIPs. It does not download the unsigned Windows artifacts. diff --git a/go.mod b/go.mod index fd97d6f7..0d698d3e 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/sigstore/sigstore-go v1.3.0 github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef github.com/stretchr/testify v1.12.1 + github.com/tetratelabs/wazero v1.12.0 github.com/theupdateframework/go-tuf/v2 v2.4.2 github.com/yalue/onnxruntime_go v1.36.0 golang.org/x/image v0.46.0 @@ -26,6 +27,9 @@ require ( // isolated behind internal/ort; all other platforms retain the current binding. replace github.com/frathe/picfetch/internal/ortlegacy => github.com/yalue/onnxruntime_go v1.25.0 +// Retain the maintained decoder source; only the separate WASI module imports it. +replace github.com/gen2brain/h265 => ./third_party/h265 + require ( fyne.io/systray v1.12.2 // indirect github.com/BurntSushi/toml v1.6.0 // indirect @@ -94,7 +98,6 @@ require ( github.com/sigstore/rekor-tiles/v2 v2.3.0 // indirect github.com/sigstore/timestamp-authority/v2 v2.1.3 // indirect github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect - github.com/tetratelabs/wazero v1.12.0 // indirect github.com/transparency-dev/formats v0.1.1 // indirect github.com/transparency-dev/merkle v0.0.2 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect diff --git a/internal/explorerpresets/presets.go b/internal/explorerpresets/presets.go index 0340d83f..c66cb4ae 100644 --- a/internal/explorerpresets/presets.go +++ b/internal/explorerpresets/presets.go @@ -61,7 +61,8 @@ func (r Rule) Validate() error { } } if r.Format != "" { - valid := false + // Saved rules survive changes in the session's experimental capability. + valid := r.Format == "heic" || r.Format == "heif" for _, ext := range imaging.SupportedExtensions() { ext = strings.TrimPrefix(ext, ".") if ext == "jpeg" { diff --git a/internal/favthumbs/sync.go b/internal/favthumbs/sync.go index c3e5b1fe..397f83b4 100644 --- a/internal/favthumbs/sync.go +++ b/internal/favthumbs/sync.go @@ -83,6 +83,11 @@ func (p *Preview) RGBA64At(x, y int) color.RGBA64 { // sink may be nil, which reads as "nothing is cached, and storing is a // no-op": the pass still fills the on-disk cache for a later opener. func Sync(ctx context.Context, favDir string, files []fyne.URI, sink Sink) error { + return SyncWithReader(ctx, favDir, files, sink, imaging.Reader{}) +} + +// SyncWithReader shares the caller's canonical source reader across the bounded workers. +func SyncWithReader(ctx context.Context, favDir string, files []fyne.URI, sink Sink, reader imaging.Reader) error { // The app's merge mode loads one path at two indices whenever the same // file arrives from two dropped folders. Two workers on that path would // duplicate a full decode and then race each other to write a single @@ -148,7 +153,7 @@ loop: return } - if err := syncFile(ctx, favDir, u, sink); err != nil { + if err := syncFile(ctx, favDir, u, sink, reader); err != nil { fail(err) } }) @@ -178,7 +183,7 @@ loop: // syncFile brings one file's preview up to date, taking the cheapest of the // three routes that applies. It is the body of a worker goroutine, so it // touches nothing shared beyond sink, which the caller owns and guards. -func syncFile(ctx context.Context, favDir string, u fyne.URI, sink Sink) error { +func syncFile(ctx context.Context, favDir string, u fyne.URI, sink Sink, reader imaging.Reader) error { if err := ctx.Err(); err != nil { return err } @@ -216,7 +221,7 @@ func syncFile(ctx context.Context, favDir string, u fyne.URI, sink Sink) error { return nil } - thumb, err = imaging.LoadThumbnailContext(ctx, u) + thumb, _, err = reader.Thumbnail(ctx, u, imaging.ThumbnailSize) if err != nil { return err } diff --git a/internal/filescan/filescan.go b/internal/filescan/filescan.go index 43329e60..ae724faa 100644 --- a/internal/filescan/filescan.go +++ b/internal/filescan/filescan.go @@ -35,6 +35,31 @@ import ( // cap) pass that instead. const DefaultMax = 200_000 +type options struct{ admit func(fyne.URI) bool } + +// Option configures one scan without changing the package's default admission. +type Option func(*options) + +// WithAdmission uses a session's immutable format capability. Nil retains the +// ordinary package-level policy. +func WithAdmission(admit func(fyne.URI) bool) Option { + return func(o *options) { + if admit != nil { + o.admit = admit + } + } +} + +func admission(opts []Option) func(fyne.URI) bool { + o := options{admit: imaging.IsSupportedImage} + for _, option := range opts { + if option != nil { + option(&o) + } + } + return o.admit +} + // realPathOf resolves u's filesystem path through any symlinks, falling // back to the URI's own path if that fails (a broken symlink, or a // filesystem race between the scan and something else touching the same @@ -65,7 +90,8 @@ func realPathOf(u fyne.URI) string { // discards a superseded scan's result anyway (as internal/ui's handleDrop // does) only needs the walk to stop touching the filesystem promptly, not // to finish correctly. -func Images(ctx context.Context, uris []fyne.URI, max int, progress func(n int)) (images []fyne.URI, truncated bool) { +func Images(ctx context.Context, uris []fyne.URI, max int, progress func(n int), opts ...Option) (images []fyne.URI, truncated bool) { + admit := admission(opts) if max < 1 { max = 1 } @@ -122,7 +148,7 @@ func Images(ctx context.Context, uris []fyne.URI, max int, progress func(n int)) return } - if !imaging.IsSupportedImage(u) { + if !admit(u) { return } @@ -175,7 +201,8 @@ func Images(ctx context.Context, uris []fyne.URI, max int, progress func(n int)) // when it is a supported image, otherwise empty. ctx is checked before any // work and before each child; an already-cancelled context returns nil, // false rather than a partial directory. -func Siblings(ctx context.Context, file fyne.URI, max int, progress func(n int)) (images []fyne.URI, truncated bool) { +func Siblings(ctx context.Context, file fyne.URI, max int, progress func(n int), opts ...Option) (images []fyne.URI, truncated bool) { + admit := admission(opts) if max < 1 { max = 1 } @@ -193,7 +220,7 @@ func Siblings(ctx context.Context, file fyne.URI, max int, progress func(n int)) if canList, err := storage.CanList(u); err == nil && canList { return } - if !imaging.IsSupportedImage(u) { + if !admit(u) { return } pathOf := realPathOf(u) @@ -214,7 +241,7 @@ func Siblings(ctx context.Context, file fyne.URI, max int, progress func(n int)) } } - if imaging.IsSupportedImage(file) { + if admit(file) { add(file) } if truncated { diff --git a/internal/filescan/filescan_test.go b/internal/filescan/filescan_test.go index d7dbdb05..c31a352b 100644 --- a/internal/filescan/filescan_test.go +++ b/internal/filescan/filescan_test.go @@ -50,6 +50,44 @@ func TestImages_EmptyInput(t *testing.T) { } } +func TestExperimentalHEICAdmission(t *testing.T) { + dir := t.TempDir() + for _, name := range []string{"photo.heic", "other.heif", "ordinary.png", "ignore.txt"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte("owned scan fixture"), 0600); err != nil { + t.Fatal(err) + } + } + file := storage.NewFileURI(filepath.Join(dir, "photo.heic")) + admit := WithAdmission(func(u fyne.URI) bool { + return u.Extension() == ".heic" || u.Extension() == ".heif" || u.Extension() == ".png" + }) + for _, active := range []bool{false, true} { + option := WithAdmission(nil) + want := 1 + if active { + option, want = admit, 3 + } + images, truncated := Images(context.Background(), []fyne.URI{storage.NewFileURI(dir), file}, DefaultMax, nil, option) + if len(images) != want || truncated { + t.Fatalf("recursive active=%v: %v, truncated=%v", active, images, truncated) + } + images, truncated = Siblings(context.Background(), file, DefaultMax, nil, option) + if len(images) != want || truncated || (active && images[0].String() != file.String()) { + t.Fatalf("siblings active=%v: %v, truncated=%v", active, images, truncated) + } + } + images, truncated := Images(context.Background(), []fyne.URI{file, file}, 1, nil, admit) + if len(images) != 1 || !truncated { + t.Fatal("custom admission lost the scan cap") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + images, truncated = Siblings(ctx, file, DefaultMax, nil, admit) + if len(images) != 0 || truncated { + t.Fatal("cancelled custom scan admitted a source") + } +} + func TestImages_FiltersUnsupportedFiles(t *testing.T) { jpegURI := uitest.TempJPEGURI(t, "keep.jpg", 4, 4, color.White) pngPath := uitest.WriteTempFile(t, "keep.png", uitest.EncodePNG(t, 4, 4, color.White)) diff --git a/internal/filesort/filesort.go b/internal/filesort/filesort.go index b6929d90..9d428bb4 100644 --- a/internal/filesort/filesort.go +++ b/internal/filesort/filesort.go @@ -93,6 +93,11 @@ func DisplayName(m Mode) string { // stop touching the filesystem promptly once ctx is done - it doesn't need // to return a fully correct partial order, since nothing ever looks at one. func Order(ctx context.Context, m Mode, raw []fyne.URI) []fyne.URI { + return OrderWithReader(ctx, m, raw, imaging.Reader{}) +} + +// OrderWithReader uses the caller's shared reader for capture-date extraction. +func OrderWithReader(ctx context.Context, m Mode, raw []fyne.URI, reader imaging.Reader) []fyne.URI { ordered := append([]fyne.URI(nil), raw...) switch m { @@ -100,7 +105,7 @@ func Order(ctx context.Context, m Mode, raw []fyne.URI) []fyne.URI { // Already in raw order - nothing to do. case ByCaptureDate: sortByInt64Key(ctx, ordered, func(u fyne.URI) (int64, error) { - date, err := captureOrModTime(ctx, u) + date, err := captureOrModTime(ctx, u, reader) return date.UnixNano(), err }) case ByModTime: @@ -199,8 +204,8 @@ func fileSizeOf(u fyne.URI) int64 { // capture-date sort mode still produces a sensible, total order instead of // clumping every such file at the same zero-time position. Cancellation stops // the sort instead of falling back to another filesystem operation. -func captureOrModTime(ctx context.Context, u fyne.URI) (time.Time, error) { - date, ok, err := imaging.CaptureDateContext(ctx, u) +func captureOrModTime(ctx context.Context, u fyne.URI, reader imaging.Reader) (time.Time, error) { + date, ok, err := reader.CaptureDate(ctx, u) if ctx.Err() != nil { return time.Time{}, ctx.Err() } diff --git a/internal/heicdecode/client/admission.go b/internal/heicdecode/client/admission.go new file mode 100644 index 00000000..0fe0b891 --- /dev/null +++ b/internal/heicdecode/client/admission.go @@ -0,0 +1,116 @@ +package client + +import ( + "context" + "sync" +) + +// Priority separates an interactive image from speculative/background work. +type Priority uint8 + +const ( + Foreground Priority = iota + Background +) + +// Callers enforce the 64-waiter limit before entering. The queue does not start +// workers: a caller retains its grant until helper and transport cleanup ends. +type admissionQueue struct { + mu sync.Mutex + busy bool + foregroundRun int + waiting []*admission +} + +type admission struct { + ready chan struct{} + ctx context.Context + once sync.Once + queue *admissionQueue + priority Priority + granted bool +} + +func (q *admissionQueue) enter(ctx context.Context, priority Priority) *admission { + a := &admission{ready: make(chan struct{}), ctx: ctx, queue: q, priority: priority} + q.mu.Lock() + q.waiting = append(q.waiting, a) + if !q.busy { + q.promote() + } + q.mu.Unlock() + return a +} + +func (a *admission) wait() error { + select { + case <-a.ready: + return a.ctx.Err() + case <-a.ctx.Done(): + return a.ctx.Err() + } +} + +func (a *admission) release() { + a.once.Do(func() { + q := a.queue + q.mu.Lock() + defer q.mu.Unlock() + if a.granted { + q.promote() + return + } + for i, waiting := range q.waiting { + if waiting == a { + copy(q.waiting[i:], q.waiting[i+1:]) + q.waiting[len(q.waiting)-1] = nil + q.waiting = q.waiting[:len(q.waiting)-1] + return + } + } + }) +} + +// Called under mu when the previous grant has fully retired. Preserve FIFO +// within each priority, with a background grant after at most three foreground +// grants. Cancelled requests never consume a grant or a fairness turn. +func (q *admissionQueue) promote() { + active := q.waiting[:0] + for _, a := range q.waiting { + if a.ctx.Err() == nil { + active = append(active, a) + } + } + clear(q.waiting[len(active):]) + q.waiting = active + foreground, background := -1, -1 + for i, a := range q.waiting { + if a.priority == Foreground && foreground < 0 { + foreground = i + } + if a.priority == Background && background < 0 { + background = i + } + } + index := foreground + if foreground < 0 || (background >= 0 && q.foregroundRun >= 3) { + index = background + } + if index < 0 { + q.busy = false + q.foregroundRun = 0 + return + } + a := q.waiting[index] + copy(q.waiting[index:], q.waiting[index+1:]) + q.waiting[len(q.waiting)-1] = nil + q.waiting = q.waiting[:len(q.waiting)-1] + if a.priority == Foreground { + q.foregroundRun = min(q.foregroundRun+1, 3) + } else { + q.foregroundRun = 0 + } + q.busy = true + a.granted = true + close(a.ready) +} diff --git a/internal/heicdecode/client/admission_test.go b/internal/heicdecode/client/admission_test.go new file mode 100644 index 00000000..da18bb75 --- /dev/null +++ b/internal/heicdecode/client/admission_test.go @@ -0,0 +1,81 @@ +package client + +import ( + "context" + "errors" + "testing" +) + +func TestAdmissionPrefersForegroundWithoutStarvingBackground(t *testing.T) { + var queue admissionQueue + first := queue.enter(context.Background(), Foreground) + if err := first.wait(); err != nil { + t.Fatal(err) + } + background := queue.enter(context.Background(), Background) + foreground := []*admission{ + queue.enter(context.Background(), Foreground), + queue.enter(context.Background(), Foreground), + queue.enter(context.Background(), Foreground), + } + assertPending := func(request *admission) { + t.Helper() + select { + case <-request.ready: + t.Fatal("request admitted before its turn") + default: + } + } + assertPending(background) + first.release() + for _, request := range foreground[:2] { + select { + case <-request.ready: + default: + t.Fatal("foreground was not preferred") + } + assertPending(background) + request.release() + } + // At most three consecutive foreground grants, including the first one. + select { + case <-background.ready: + default: + t.Fatal("background starved") + } + assertPending(foreground[2]) + background.release() + if err := foreground[2].wait(); err != nil { + t.Fatal(err) + } + foreground[2].release() +} + +func TestAdmissionCancellationAndIdempotentRelease(t *testing.T) { + var queue admissionQueue + first := queue.enter(context.Background(), Background) + ctx, cancel := context.WithCancel(context.Background()) + cancelled := queue.enter(ctx, Foreground) + next := queue.enter(context.Background(), Background) + cancel() + if err := cancelled.wait(); !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled wait: %v", err) + } + cancelled.release() + first.release() + if err := next.wait(); err != nil { + t.Fatal(err) + } + later := queue.enter(context.Background(), Foreground) + first.release() + select { + case <-later.ready: + t.Fatal("duplicate release admitted overlapping work") + default: + } + next.release() + if err := later.wait(); err != nil { + t.Fatal(err) + } + later.release() +} diff --git a/internal/heicdecode/client/attachment.go b/internal/heicdecode/client/attachment.go new file mode 100644 index 00000000..258a0923 --- /dev/null +++ b/internal/heicdecode/client/attachment.go @@ -0,0 +1,110 @@ +package client + +import ( + "context" + "os" + "os/exec" + "sync" + + "github.com/frathe/picfetch/internal/heicdecode" +) + +// PipeConfig names only handles explicitly inherited by an analysis process. +// Limits must match its application's owner; no executable path is forwarded. +type PipeConfig struct { + Input, Output uint64 + Limits heicdecode.Limits +} + +// Attachment owns the parent's service and its temporary copies of child pipe +// ends. Call Started immediately after exec.Start; Stop/Wait on every return. +type Attachment struct { + Config PipeConfig + children []*os.File + once sync.Once + cancel context.CancelFunc + done chan struct{} +} + +func (c *Client) Attach(ctx context.Context, cmd *exec.Cmd) (*Attachment, error) { + if cmd == nil || cmd.Process != nil { + return nil, ErrUnavailable + } + finish, err := c.beginService() + if err != nil { + return nil, err + } + transferred := false + var files []*os.File + defer func() { + if !transferred { + for _, file := range files { + _ = file.Close() + } + finish() + } + }() + requestRead, requestWrite, err := os.Pipe() + if err != nil { + return nil, err + } + files = append(files, requestRead, requestWrite) + responseRead, responseWrite, err := os.Pipe() + if err != nil { + return nil, err + } + files = append(files, responseRead, responseWrite) + readID, writeID, copies, err := inheritBrokerPipes(cmd, responseRead, requestWrite) + if err != nil { + return nil, err + } + ctx, cancel := context.WithCancel(ctx) + attachment := &Attachment{ + Config: PipeConfig{Input: readID, Output: writeID, Limits: c.config.Limits}, + children: append([]*os.File{responseRead, requestWrite}, copies...), cancel: cancel, done: make(chan struct{}), + } + transferred = true + go func() { + defer finish() + defer close(attachment.done) + _ = c.serve(ctx, requestRead, responseWrite) + }() + return attachment, nil +} + +func (a *Attachment) Started() { + a.once.Do(func() { + for _, file := range a.children { + _ = file.Close() + } + }) +} +func (a *Attachment) Stop() { a.cancel(); a.Started() } +func (a *Attachment) Wait() { <-a.done } + +// OpenRemote consumes the two inherited handles. They must be pipes; on UNIX, +// nonblocking mode is installed before NewFile so Close can interrupt reads. +func OpenRemote(config PipeConfig) (*Remote, error) { + if err := config.Limits.Validate(); err != nil { + return nil, err + } + if config.Input <= 2 || config.Output <= 2 || config.Input == config.Output { + return nil, ErrUnavailable + } + input, err := openBrokerPipe(config.Input, true) + if err != nil { + return nil, err + } + output, err := openBrokerPipe(config.Output, false) + if err != nil { + _ = input.Close() + return nil, err + } + remote, err := NewRemote(input, output, config.Limits) + if err != nil { + _ = input.Close() + _ = output.Close() + return nil, err + } + return remote, nil +} diff --git a/internal/heicdecode/client/attachment_other.go b/internal/heicdecode/client/attachment_other.go new file mode 100644 index 00000000..eb41e181 --- /dev/null +++ b/internal/heicdecode/client/attachment_other.go @@ -0,0 +1,13 @@ +//go:build !darwin && !linux && !windows + +package client + +import ( + "os" + "os/exec" +) + +func inheritBrokerPipes(_ *exec.Cmd, _, _ *os.File) (uint64, uint64, []*os.File, error) { + return 0, 0, nil, ErrUnavailable +} +func openBrokerPipe(_ uint64, _ bool) (*os.File, error) { return nil, ErrUnavailable } diff --git a/internal/heicdecode/client/attachment_test.go b/internal/heicdecode/client/attachment_test.go new file mode 100644 index 00000000..5cb0bc90 --- /dev/null +++ b/internal/heicdecode/client/attachment_test.go @@ -0,0 +1,63 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "os" + "os/exec" + "testing" + "time" +) + +func TestInheritedRemoteUsesOwner(t *testing.T) { + runInheritedRemote(t, "success", "--owned-heic-remote", "owned remote decode complete\n") +} + +func TestInheritedRemoteCancellationJoins(t *testing.T) { + runInheritedRemote(t, "hang", "--owned-heic-remote-cancel", "owned remote cancellation complete\n") +} + +func runInheritedRemote(t *testing.T, peer, mode, want string) { + t.Helper() + owner := ownedPeer(t, peer, 5*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, os.Args[0]) + link, err := owner.Attach(ctx, cmd) + if err != nil { + t.Fatal(err) + } + defer func() { link.Stop(); link.Wait() }() + config, err := json.Marshal(link.Config) + if err != nil { + t.Fatal(err) + } + cmd.Args = append(cmd.Args, mode, string(config)) + var output, diagnostic bytes.Buffer + cmd.Stdout, cmd.Stderr = &output, &diagnostic + if err = cmd.Start(); err != nil { + t.Fatal(err) + } + link.Started() + if err = cmd.Wait(); err != nil { + t.Fatalf("inherited remote: %v (%s)", err, diagnostic.String()) + } + if output.String() != want { + t.Fatalf("unexpected child output: %q", output.String()) + } +} + +func TestAttachmentStopWithoutProcessStartJoinsOwner(t *testing.T) { + owner := ownedPeer(t, "success", 5*time.Second) + link, err := owner.Attach(context.Background(), exec.Command(os.Args[0])) + if err != nil { + t.Fatal(err) + } + link.Stop() + link.Wait() + link.Started() + link.Stop() + owner.Stop() + owner.Wait() +} diff --git a/internal/heicdecode/client/attachment_unix.go b/internal/heicdecode/client/attachment_unix.go new file mode 100644 index 00000000..a115cb64 --- /dev/null +++ b/internal/heicdecode/client/attachment_unix.go @@ -0,0 +1,46 @@ +//go:build darwin || linux + +package client + +import ( + "os" + "os/exec" + + "golang.org/x/sys/unix" +) + +func inheritBrokerPipes(cmd *exec.Cmd, input, output *os.File) (uint64, uint64, []*os.File, error) { + readID := uint64(3 + len(cmd.ExtraFiles)) + cmd.ExtraFiles = append(cmd.ExtraFiles, input, output) + return readID, readID + 1, nil, nil +} + +func openBrokerPipe(id uint64, read bool) (*os.File, error) { + if id > uint64(^uint(0)>>1) { + return nil, ErrUnavailable + } + fd := int(id) + var stat unix.Stat_t + if err := unix.Fstat(fd, &stat); err != nil { + return nil, err + } + if stat.Mode&unix.S_IFMT != unix.S_IFIFO { + return nil, ErrUnavailable + } + flags, err := unix.FcntlInt(uintptr(fd), unix.F_GETFL, 0) + if err != nil { + return nil, err + } + want := unix.O_WRONLY + if read { + want = unix.O_RDONLY + } + if flags&unix.O_ACCMODE != want { + return nil, ErrUnavailable + } + if err = unix.SetNonblock(fd, true); err != nil { + return nil, err + } + unix.CloseOnExec(fd) + return os.NewFile(uintptr(fd), "HEIC broker pipe"), nil +} diff --git a/internal/heicdecode/client/attachment_windows.go b/internal/heicdecode/client/attachment_windows.go new file mode 100644 index 00000000..3508edeb --- /dev/null +++ b/internal/heicdecode/client/attachment_windows.go @@ -0,0 +1,52 @@ +package client + +import ( + "os" + "os/exec" + "syscall" + + "golang.org/x/sys/windows" +) + +func inheritBrokerPipes(cmd *exec.Cmd, input, output *os.File) (uint64, uint64, []*os.File, error) { + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + if cmd.SysProcAttr.NoInheritHandles || cmd.SysProcAttr.ParentProcess != 0 { + return 0, 0, nil, ErrUnavailable + } + var copies []*os.File + for _, file := range []*os.File{input, output} { + var handle windows.Handle + if err := windows.DuplicateHandle(windows.CurrentProcess(), windows.Handle(file.Fd()), windows.CurrentProcess(), &handle, 0, true, windows.DUPLICATE_SAME_ACCESS); err != nil { + for _, copy := range copies { + _ = copy.Close() + } + return 0, 0, nil, err + } + // Wrap before any I/O starts: NewFile's synchronous-mode query must + // not race an outstanding operation on this handle. + copies = append(copies, os.NewFile(uintptr(handle), "HEIC inherited pipe")) + } + readID, writeID := copies[0].Fd(), copies[1].Fd() + cmd.SysProcAttr.AdditionalInheritedHandles = append(cmd.SysProcAttr.AdditionalInheritedHandles, syscall.Handle(readID), syscall.Handle(writeID)) + return uint64(readID), uint64(writeID), copies, nil +} + +func openBrokerPipe(id uint64, _ bool) (*os.File, error) { + if id > uint64(^uintptr(0)) { + return nil, ErrUnavailable + } + handle := windows.Handle(id) + typeID, err := windows.GetFileType(handle) + if err != nil { + return nil, err + } + if typeID != windows.FILE_TYPE_PIPE { + return nil, ErrUnavailable + } + if err = windows.SetHandleInformation(handle, windows.HANDLE_FLAG_INHERIT, 0); err != nil { + return nil, err + } + return os.NewFile(uintptr(handle), "HEIC broker pipe"), nil +} diff --git a/internal/heicdecode/client/broker.go b/internal/heicdecode/client/broker.go new file mode 100644 index 00000000..0666b3d9 --- /dev/null +++ b/internal/heicdecode/client/broker.go @@ -0,0 +1,229 @@ +package client + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "io" + + "github.com/frathe/picfetch/internal/heicdecode" +) + +const ( + brokerIntent uint16 = 1 + iota + brokerGrant + brokerInput + brokerResult + maxServices = 8 +) + +type brokerHeader struct { + kind uint16 + operation heicdecode.Operation + size uint64 +} + +// Serve shares this Client's admission lane with one analysis connection. +// Its context belongs to the analysis process's lifetime. Stop closes active +// and idle connections; Wait joins their complete service work. No source path +// or native executable selection crosses this byte-only protocol. +func (c *Client) Serve(ctx context.Context, input io.ReadCloser, output io.WriteCloser) error { + finish, err := c.beginService() + if err != nil { + _ = input.Close() + _ = output.Close() + return err + } + defer finish() + return c.serve(ctx, input, output) +} + +func (c *Client) beginService() (func(), error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.closed { + return nil, context.Canceled + } + if c.services >= maxServices { + return nil, ErrBusy + } + c.services++ + c.work.Add(1) + return func() { c.mu.Lock(); c.services--; c.mu.Unlock(); c.work.Done() }, nil +} + +func (c *Client) serve(ctx context.Context, input io.ReadCloser, output io.WriteCloser) error { + closeIO := func() { _ = input.Close(); _ = output.Close() } + defer closeIO() + ctx, cancel := context.WithCancel(ctx) + stopParent := watchCancellation(c.ctx, cancel) + stopIO := watchCancellation(ctx, closeIO) + defer func() { cancel(); stopIO(); stopParent() }() + for { + header, err := readBrokerHeader(input) + if err != nil { + return err + } + if header.kind != brokerIntent || header.size != 0 { + return heicdecode.ErrInvalidRequest + } + err = c.serveRequest(ctx, header.operation, input, output, closeIO) + if err != nil { + return err + } + } +} + +// Read one control byte while queued so a disconnected analysis peer can retire +// immediately. No encoded allocation or bulk read occurs before native-ready +// admission. Every exit joins this reader, including pre-admission refusal. +func (c *Client) serveRequest(ctx context.Context, op heicdecode.Operation, input io.Reader, output io.Writer, closeIO func()) (resultErr error) { + ctx, cancel := context.WithCancel(ctx) + stopIO := watchCancellation(ctx, closeIO) + var first [1]byte + var peekErr error + peekDone := make(chan struct{}) + go func() { + defer close(peekDone) + _, peekErr = io.ReadFull(input, first[:]) + if peekErr != nil { + cancel() + } + }() + received := false + defer func() { + if resultErr != nil || !received { + closeIO() + } + <-peekDone + stopIO() + cancel() + }() + resultErr = c.withAdmission(ctx, Background, func(ctx context.Context) error { + // Include source transfer and downstream output in the admitted deadline. + stopAdmittedIO := watchCancellation(ctx, closeIO) + defer stopAdmittedIO() + var response heicdecode.Response + var inputErr error + decodeErr := c.verifyExecutable(ctx) + if decodeErr == nil { + response, decodeErr = c.run(ctx, op, func(ctx context.Context, maxBytes int64) (data []byte, resultErr error) { + defer func() { inputErr = resultErr }() + if err := ctx.Err(); err != nil { + return nil, err + } + if err := writeBrokerHeader(output, brokerHeader{kind: brokerGrant, operation: op, size: uint64(maxBytes)}); err != nil { + return nil, err + } + <-peekDone + if peekErr != nil { + return nil, peekErr + } + reader := io.MultiReader(bytes.NewReader(first[:]), input) + data, resultErr = readBrokerInput(reader, op, c.config.Limits) + received = resultErr == nil + return data, resultErr + }) + } + if ctx.Err() == nil { + c.recordAvailability(decodeErr) + } + if inputErr != nil { + return inputErr + } + if err := ctx.Err(); err != nil { + return err + } + return writeBrokerResult(output, op, response, decodeErr, c.config.Limits) + }) + if resultErr == nil && !received { + return io.EOF + } + return resultErr +} + +func readBrokerInput(input io.Reader, op heicdecode.Operation, limits heicdecode.Limits) ([]byte, error) { + encoded, err := readBrokerHeader(input) + if err != nil { + return nil, err + } + if encoded.kind != brokerInput || encoded.operation != op || encoded.size <= 24 || encoded.size > uint64(limits.MaxInputBytes)+24 { + return nil, heicdecode.ErrInvalidRequest + } + frame := &io.LimitedReader{R: input, N: int64(encoded.size)} + request, err := heicdecode.ReadRequest(frame, limits) + if err != nil { + return nil, err + } + if frame.N != 0 || request.Operation != op || request.MaxPixels != limits.MaxPixels || request.MaxOutputBytes != limits.MaxOutputBytes { + return nil, heicdecode.ErrInvalidRequest + } + return request.Input, nil +} + +func readBrokerHeader(r io.Reader) (brokerHeader, error) { + var data [16]byte + if _, err := io.ReadFull(r, data[:]); err != nil { + return brokerHeader{}, err + } + h := brokerHeader{kind: binary.LittleEndian.Uint16(data[4:]), operation: heicdecode.Operation(binary.LittleEndian.Uint16(data[6:])), size: binary.LittleEndian.Uint64(data[8:])} + if string(data[:4]) != "PHB1" || h.kind < brokerIntent || h.kind > brokerResult || h.operation < heicdecode.Decode || h.operation > heicdecode.DecodeExif { + return brokerHeader{}, heicdecode.ErrInvalidRequest + } + return h, nil +} + +func writeBrokerHeader(w io.Writer, h brokerHeader) error { + if h.kind < brokerIntent || h.kind > brokerResult || h.operation < heicdecode.Decode || h.operation > heicdecode.DecodeExif { + return heicdecode.ErrInvalidRequest + } + var data [16]byte + copy(data[:4], "PHB1") + binary.LittleEndian.PutUint16(data[4:], h.kind) + binary.LittleEndian.PutUint16(data[6:], uint16(h.operation)) + binary.LittleEndian.PutUint64(data[8:], h.size) + n, err := w.Write(data[:]) + if err == nil && n != len(data) { + return io.ErrShortWrite + } + return err +} + +func readBrokerResult(r io.Reader, size uint64, op heicdecode.Operation, limits heicdecode.Limits) (heicdecode.Response, error) { + if size < 40 || size > uint64(limits.MaxOutputBytes)+uint64(limits.MaxMetadataBytes)+uint64(limits.MaxDiagnosticBytes)+40 { + return heicdecode.Response{}, heicdecode.ErrInvalidResponse + } + frame := &io.LimitedReader{R: r, N: int64(size)} + result, err := heicdecode.ReadResponse(frame, op, limits) + if frame.N != 0 { + return heicdecode.Response{}, heicdecode.ErrInvalidResponse + } + return result, err +} + +func writeBrokerResult(w io.Writer, op heicdecode.Operation, result heicdecode.Response, decodeErr error, limits heicdecode.Limits) error { + encode := func(w io.Writer) error { + if decodeErr == nil { + return heicdecode.WriteResponse(w, op, result, limits) + } + if failure, ok := errors.AsType[*heicdecode.Failure](decodeErr); ok { + return heicdecode.WriteFailure(w, failure.Status, failure.Diagnostic, limits) + } + return heicdecode.WriteFailure(w, heicdecode.StatusUnavailable, "helper request failed", limits) + } + // Validation/counting touches only lengths and bounded metadata. Pixel + // slices are streamed on the second pass, never copied into an envelope. + var count byteCounter + if err := encode(&count); err != nil { + return err + } + if err := writeBrokerHeader(w, brokerHeader{kind: brokerResult, operation: op, size: uint64(count)}); err != nil { + return err + } + return encode(w) +} + +type byteCounter int64 + +func (c *byteCounter) Write(p []byte) (int, error) { *c += byteCounter(len(p)); return len(p), nil } diff --git a/internal/heicdecode/client/client.go b/internal/heicdecode/client/client.go new file mode 100644 index 00000000..e27a58b7 --- /dev/null +++ b/internal/heicdecode/client/client.go @@ -0,0 +1,185 @@ +// Package client owns bounded HEIC helper admission, transport and process +// lifetime. The app-family service must share one Client across its consumers. +package client + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sync" + + "github.com/frathe/picfetch/internal/heicdecode" +) + +var ( + ErrUnavailable = errors.New("isolated HEIC helper unavailable") + ErrBusy = errors.New("HEIC request queue is full") + ErrDiagnosticLimit = errors.New("HEIC helper exceeded diagnostic limit") +) + +// Config is immutable after New. SHA256 pins the packaged, already signed +// helper executable. An absent helper or pin never selects an ambient program. +type Config struct { + Executable string + SHA256 [32]byte + Limits heicdecode.Limits +} + +// Input reads a source only after admission and native sandbox readiness. It +// must honor ctx and maxBytes, and may not return a larger encoded allocation. +type Input func(ctx context.Context, maxBytes int64) ([]byte, error) + +// Client admits one job and at most 64 pending callers. Stop is nonblocking; +// Wait joins active process/pipe work and cancelled waiting callers off the UI. +type Client struct { + config Config + ctx context.Context + cancel context.CancelFunc + lane admissionQueue + mu sync.Mutex + closed bool + active int + services int + work sync.WaitGroup + unavailable bool + release func() + releaseOnce sync.Once +} + +func New(config Config) (*Client, error) { + if err := config.Limits.Validate(); err != nil { + return nil, err + } + if !filepath.IsAbs(config.Executable) || config.SHA256 == [32]byte{} { + return nil, ErrUnavailable + } + ctx, cancel := context.WithCancel(context.Background()) + return &Client{config: config, ctx: ctx, cancel: cancel}, nil +} + +func (c *Client) Stop() { + c.mu.Lock() + c.closed = true + c.cancel() + c.mu.Unlock() +} + +func (c *Client) Wait() { + c.work.Wait() + c.mu.Lock() + closed := c.closed + c.mu.Unlock() + if closed && c.release != nil { + c.releaseOnce.Do(c.release) + } +} + +// Unavailable reports the latest helper identity/readiness failure, separately +// from immutable reader capability and the user's saved preference. +func (c *Client) Unavailable() bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.unavailable +} + +func (c *Client) recordAvailability(err error) { + if err == nil || errors.Is(err, ErrUnavailable) { + c.mu.Lock() + c.unavailable = err != nil + c.mu.Unlock() + } +} + +// Do retains admission until source work, the helper, all pipes, and output +// validation have finished. A cancelled queued call never invokes input. +func (c *Client) Do(ctx context.Context, op heicdecode.Operation, input Input) (heicdecode.Response, error) { + return c.DoWithPriority(ctx, Foreground, op, input) +} + +// DoWithPriority preserves FIFO within each class and gives waiting background +// work a turn after at most three foreground grants. The timeout begins after +// admission; queued callers retain their own cancellation/deadline. +func (c *Client) DoWithPriority(ctx context.Context, priority Priority, op heicdecode.Operation, input Input) (heicdecode.Response, error) { + var result heicdecode.Response + err := c.withAdmission(ctx, priority, func(ctx context.Context) error { + if err := c.verifyExecutable(ctx); err != nil { + return err + } + var err error + result, err = c.run(ctx, op, input) + return err + }) + c.recordAvailability(err) + if err != nil { + return heicdecode.Response{}, err + } + return result, nil +} + +// The pipe service also holds this grant through downstream response delivery, +// so a stalled analysis consumer cannot accumulate decoded outputs off-lane. +func (c *Client) withAdmission(ctx context.Context, priority Priority, operation func(context.Context) error) error { + if priority > Background { + return heicdecode.ErrInvalidRequest + } + c.mu.Lock() + if c.closed { + c.mu.Unlock() + return context.Canceled + } + if c.active >= 65 { + c.mu.Unlock() + return ErrBusy + } + c.active++ + c.work.Add(1) + c.mu.Unlock() + defer func() { + c.mu.Lock() + c.active-- + c.mu.Unlock() + c.work.Done() + }() + ctx, cancel := context.WithCancel(ctx) + stop := context.AfterFunc(c.ctx, cancel) + defer func() { stop(); cancel() }() + admitted := c.lane.enter(ctx, priority) + defer admitted.release() + if err := admitted.wait(); err != nil { + return err + } + ctx, deadline := context.WithTimeout(ctx, c.config.Limits.Timeout) + defer deadline() + if err := ctx.Err(); err != nil { + return err + } + err := operation(ctx) + if ctx.Err() != nil { + return ctx.Err() + } + return err +} + +func (c *Client) verifyExecutable(ctx context.Context) error { + file, err := os.Open(c.config.Executable) + if err != nil { + return fmt.Errorf("%w: missing executable", ErrUnavailable) + } + defer func() { _ = file.Close() }() + info, err := file.Stat() + if err != nil || !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > 64*1024*1024 { + return fmt.Errorf("%w: executable size or type", ErrUnavailable) + } + hash := sha256.New() + if _, err = io.Copy(hash, io.LimitReader(file, 64*1024*1024+1)); err != nil { + return fmt.Errorf("%w: executable read", ErrUnavailable) + } + if string(hash.Sum(nil)) != string(c.config.SHA256[:]) { + return fmt.Errorf("%w: executable identity", ErrUnavailable) + } + return ctx.Err() +} diff --git a/internal/heicdecode/client/client_test.go b/internal/heicdecode/client/client_test.go new file mode 100644 index 00000000..99460cfd --- /dev/null +++ b/internal/heicdecode/client/client_test.go @@ -0,0 +1,341 @@ +package client + +import ( + "context" + "crypto/sha256" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "image" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/frathe/picfetch/internal/heicdecode" +) + +func TestRejectsChangedHelperBeforeReadingSource(t *testing.T) { + path := filepath.Join(t.TempDir(), "helper") + if err := os.WriteFile(path, []byte("changed helper"), 0700); err != nil { + t.Fatal(err) + } + client, err := New(Config{Executable: path, SHA256: sha256.Sum256([]byte("expected helper")), Limits: heicdecode.DefaultLimits(0)}) + if err != nil { + t.Fatal(err) + } + defer client.Stop() + read := false + _, err = client.Do(context.Background(), heicdecode.Decode, func(_ context.Context, _ int64) ([]byte, error) { + read = true + return nil, nil + }) + if !errors.Is(err, ErrUnavailable) || read { + t.Fatalf("changed helper: err=%v, source read=%v", err, read) + } + client.Stop() + client.Wait() +} + +// TestMain also supplies an owned protocol peer. It is never a decoder or OS +// sandbox test: these modes exercise only the parent's process/pipe boundary. +func TestMain(m *testing.M) { + if len(os.Args) == 3 && (os.Args[1] == "--owned-heic-remote" || os.Args[1] == "--owned-heic-remote-cancel") { + var config PipeConfig + if err := json.Unmarshal([]byte(os.Args[2]), &config); err != nil { + os.Exit(2) + } + remote, err := OpenRemote(config) + if err != nil { + os.Exit(2) + } + ctx := context.Background() + if os.Args[1] == "--owned-heic-remote-cancel" { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, 500*time.Millisecond) + defer cancel() + } + result, err := remote.Do(ctx, heicdecode.Decode, func(_ context.Context, _ int64) ([]byte, error) { return []byte("owned inherited input"), nil }) + remote.Stop() + remote.Wait() + if os.Args[1] == "--owned-heic-remote-cancel" { + if !errors.Is(err, context.DeadlineExceeded) { + os.Exit(2) + } + _, _ = fmt.Fprintln(os.Stdout, "owned remote cancellation complete") + os.Exit(0) + } + if err != nil || result.Image == nil || result.Image.Bounds() != image.Rect(0, 0, 1, 1) { + os.Exit(2) + } + _, _ = fmt.Fprintln(os.Stdout, "owned remote decode complete") + os.Exit(0) + } + if len(os.Args) == 3 && os.Args[1] == "--owned-heic-descendant" { + connection, err := net.DialTimeout("tcp4", os.Args[2], time.Second) + if err != nil { + os.Exit(2) + } + if err = binary.Write(connection, binary.LittleEndian, uint32(os.Getpid())); err != nil { + os.Exit(2) + } + _, _ = io.Copy(io.Discard, connection) + _ = connection.Close() + os.Exit(0) + } + if len(os.Args) == 7 && os.Args[1] == "--heic-worker-v2" { + var limits heicdecode.Limits + if err := json.Unmarshal([]byte(os.Args[2]), &limits); err != nil { + os.Exit(2) + } + mode := strings.TrimSuffix(filepath.Base(os.Args[0]), ".exe") + if mode == "startup" { + _, _ = fmt.Fprintln(os.Stderr, "owned startup refusal") + os.Exit(3) + } + if mode == "startup-overflow" { + _, _ = io.CopyN(os.Stderr, zeroReader{}, int64(limits.MaxDiagnosticBytes)+1) + os.Exit(3) + } + if err := heicdecode.WriteReady(os.Stdout, heicdecode.Ready{WASMMemoryBytes: limits.WASMMemoryBytes, NativeMemoryBytes: limits.OSProcessBytes}); err != nil { + os.Exit(2) + } + switch mode { + case "success": + request, err := heicdecode.ReadRequest(os.Stdin, limits) + if err != nil { + os.Exit(2) + } + result := heicdecode.Response{Image: image.NewNRGBA64(image.Rect(0, 0, 1, 1))} + if err = heicdecode.WriteResponse(os.Stdout, request.Operation, result, limits); err != nil { + os.Exit(2) + } + os.Exit(0) + case "hang": + // Remain alive with all pipes open until the parent terminates us. + for { + time.Sleep(time.Hour) + } + case "crash": + _, _ = fmt.Fprintln(os.Stderr, "owned post-readiness diagnostic") + os.Exit(7) + case "diagnostics": + _, _ = io.CopyN(os.Stderr, zeroReader{}, int64(limits.MaxDiagnosticBytes)+1) + os.Exit(0) + case "family": + request, err := heicdecode.ReadRequest(os.Stdin, limits) + if err != nil { + os.Exit(2) + } + // This peer launches only another copy of its own test executable. + // The source contains the test's owned loopback listener address. + descendant := exec.Command(os.Args[0], "--owned-heic-descendant", string(request.Input)) + descendant.Stdout, descendant.Stderr = os.Stdout, os.Stderr + if err = descendant.Run(); err != nil { + os.Exit(2) + } + os.Exit(0) + default: + os.Exit(2) + } + } + os.Exit(m.Run()) +} + +type zeroReader struct{} + +func (zeroReader) Read(p []byte) (int, error) { clear(p); return len(p), nil } + +func ownedPeer(t *testing.T, mode string, timeout time.Duration) *Client { + t.Helper() + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + name := mode + if runtime.GOOS == "windows" { + name += ".exe" + } + path := filepath.Join(t.TempDir(), name) + // Its basename selects an owned protocol behavior above. Unix uses a + // hard link; Windows prepares an independent AppContainer-readable copy. + if err = installOwnedPeer(executable, path); err != nil { + t.Fatal(err) + } + file, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + hash := sha256.New() + _, copyErr := io.Copy(hash, file) + _ = file.Close() + if copyErr != nil { + t.Fatal(copyErr) + } + var digest [32]byte + copy(digest[:], hash.Sum(nil)) + limits := heicdecode.DefaultLimits(1024 * 1024) + limits.Timeout = timeout + client, err := New(Config{Executable: path, SHA256: digest, Limits: limits}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { client.Stop(); client.Wait() }) + return client +} + +func TestOwnedPeerTimeoutJoinsBlockedWriter(t *testing.T) { + client := ownedPeer(t, "hang", 500*time.Millisecond) + _, err := client.Do(context.Background(), heicdecode.Decode, func(_ context.Context, _ int64) ([]byte, error) { + // Larger than a pipe buffer; the peer deliberately never reads stdin. + return make([]byte, 1024*1024), nil + }) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("blocked peer termination: %v", err) + } + client.Stop() + client.Wait() +} + +func TestOwnedPeerCrashAndDiagnosticsFailClosed(t *testing.T) { + for _, mode := range []string{"crash", "diagnostics"} { + t.Run(mode, func(t *testing.T) { + client := ownedPeer(t, mode, 5*time.Second) + response, err := client.Do(context.Background(), heicdecode.Decode, func(_ context.Context, _ int64) ([]byte, error) { + return []byte("owned protocol test input"), nil + }) + if err == nil || response.Image != nil { + t.Fatalf("invalid peer response accepted: %+v, %v", response, err) + } + want := heicdecode.ErrInvalidResponse + if mode == "diagnostics" { + want = ErrDiagnosticLimit + } + if !errors.Is(err, want) { + t.Fatalf("peer did not exercise expected boundary: %v, want %v", err, want) + } + if strings.Contains(err.Error(), "owned post-readiness diagnostic") { + t.Fatal("post-readiness helper diagnostics reached the caller") + } + }) + } +} + +func TestOwnedPeerStartupDiagnostics(t *testing.T) { + for _, mode := range []string{"startup", "startup-overflow"} { + t.Run(mode, func(t *testing.T) { + client := ownedPeer(t, mode, 5*time.Second) + called := false + _, err := client.Do(context.Background(), heicdecode.Decode, func(_ context.Context, _ int64) ([]byte, error) { + called = true + return nil, nil + }) + if called || !errors.Is(err, ErrUnavailable) { + t.Fatalf("startup refusal: input called=%v, err=%v", called, err) + } + if mode == "startup" { + if !strings.Contains(err.Error(), `"owned startup refusal\n"`) { + t.Fatalf("bounded startup diagnostic missing: %v", err) + } + } else if !errors.Is(err, ErrDiagnosticLimit) || strings.Contains(err.Error(), "startup diagnostic") { + t.Fatalf("oversized startup diagnostic not bounded: %v", err) + } + }) + } +} + +func TestOwnedNativeProbesEchoAndJoin(t *testing.T) { + probes, err := openNativeProbes(context.Background()) + if err != nil { + t.Fatal(err) + } + defer probes.close() + connection, err := net.DialTimeout("udp4", probes.udp.LocalAddr().String(), time.Second) + if err != nil { + t.Fatal(err) + } + defer func() { _ = connection.Close() }() + _ = connection.SetDeadline(time.Now().Add(time.Second)) + if _, err = connection.Write([]byte{0}); err != nil { + t.Fatal(err) + } + var reply [1]byte + if _, err = io.ReadFull(connection, reply[:]); err != nil || reply[0] != 0 { + t.Fatalf("owned probe echo: %v, %v", reply, err) + } + probes.close() + select { + case <-probes.udpDone: + default: + t.Fatal("probe close did not join its worker") + } + if _, _, err = probes.udp.ReadFrom(reply[:]); !errors.Is(err, net.ErrClosed) { + t.Fatalf("probe listener remained open: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if unexpected, err := openNativeProbes(ctx); !errors.Is(err, context.Canceled) || unexpected != nil { + if unexpected != nil { + unexpected.close() + } + t.Fatalf("cancelled probe setup: %v", err) + } +} + +func TestStopCancelsAdmittedAndWaitingSources(t *testing.T) { + client := ownedPeer(t, "hang", 5*time.Second) + entered := make(chan struct{}) + done := make(chan error, 2) + go func() { + _, err := client.Do(context.Background(), heicdecode.Decode, func(ctx context.Context, _ int64) ([]byte, error) { + close(entered) + <-ctx.Done() + return nil, ctx.Err() + }) + done <- err + }() + select { + case <-entered: + case err := <-done: + t.Fatalf("owned peer readiness: %v", err) + case <-time.After(5 * time.Second): + t.Fatal("owned peer never reached source admission") + } + go func() { + _, err := client.Do(context.Background(), heicdecode.Decode, func(_ context.Context, _ int64) ([]byte, error) { + return nil, fmt.Errorf("waiting source was admitted") + }) + done <- err + }() + client.Stop() + client.Wait() + for range 2 { + if err := <-done; !errors.Is(err, context.Canceled) { + t.Fatalf("stop result: %v", err) + } + } +} + +func TestUnavailableProbeStorageRefusesSource(t *testing.T) { + owner := ownedPeer(t, "success", time.Second) + blocked := filepath.Join(t.TempDir(), "not-a-directory") + if err := os.WriteFile(blocked, []byte("owned refusal"), 0600); err != nil { + t.Fatal(err) + } + for _, key := range []string{"TMPDIR", "TEMP", "TMP"} { + t.Setenv(key, blocked) + } + _, err := owner.Do(context.Background(), heicdecode.Decode, func(_ context.Context, _ int64) ([]byte, error) { + t.Fatal("failed sandbox preparation read image input") + return nil, nil + }) + if !errors.Is(err, ErrUnavailable) || !owner.Unavailable() { + t.Fatalf("failed sandbox preparation did not report unavailable: %v", err) + } +} diff --git a/internal/heicdecode/client/installed_other.go b/internal/heicdecode/client/installed_other.go new file mode 100644 index 00000000..8c8073e4 --- /dev/null +++ b/internal/heicdecode/client/installed_other.go @@ -0,0 +1,12 @@ +//go:build !windows + +package client + +import "context" + +func prepareInstalled(ctx context.Context, helper string, _ [32]byte, _ string) (string, func(), error) { + if err := ctx.Err(); err != nil { + return "", nil, err + } + return helper, func() {}, nil +} diff --git a/internal/heicdecode/client/installed_unsupported_windows.go b/internal/heicdecode/client/installed_unsupported_windows.go new file mode 100644 index 00000000..eb01e1a4 --- /dev/null +++ b/internal/heicdecode/client/installed_unsupported_windows.go @@ -0,0 +1,9 @@ +//go:build windows && !amd64 && !arm64 + +package client + +import "context" + +func prepareInstalled(_ context.Context, _ string, _ [32]byte, _ string) (string, func(), error) { + return "", nil, ErrUnavailable +} diff --git a/internal/heicdecode/client/installed_windows.go b/internal/heicdecode/client/installed_windows.go new file mode 100644 index 00000000..af85e88b --- /dev/null +++ b/internal/heicdecode/client/installed_windows.go @@ -0,0 +1,216 @@ +//go:build windows && (amd64 || arm64) + +package client + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + "unsafe" + + "golang.org/x/sys/windows" + + "github.com/frathe/picfetch/internal/heicdecode/winisolation" +) + +func prepareInstalled(ctx context.Context, source string, digest [32]byte, root string) (string, func(), error) { + if err := ctx.Err(); err != nil { + return "", nil, err + } + if err := privateCacheDirectory(root); err != nil { + return "", nil, err + } + rootHandle, err := pinCachePath(root, true) + if err != nil { + return "", nil, err + } + handles := []windows.Handle{rootHandle} + var once sync.Once + release := func() { + once.Do(func() { + for i := len(handles) - 1; i >= 0; i-- { + _ = windows.CloseHandle(handles[i]) + } + }) + } + success := false + defer func() { + if !success { + release() + } + }() + locked, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + lock, err := lockCache(locked, filepath.Join(root, "publication.lock")) + if err != nil { + return "", nil, err + } + defer func() { _ = windows.CloseHandle(lock) }() + path, err := stageHelper(ctx, source, digest, root, func(path string) error { + // Reject reparse points and hard links before changing a copy's permissions. + file, err := pinCachePath(path, false) + if err != nil { + return err + } + defer func() { _ = windows.CloseHandle(file) }() + return winisolation.PrepareExecutable(path) + }) + if err != nil { + return "", nil, err + } + for _, entry := range []struct { + path string + directory bool + }{{filepath.Dir(path), true}, {path, false}} { + handle, pinErr := pinCachePath(entry.path, entry.directory) + if pinErr != nil { + return "", nil, pinErr + } + handles = append(handles, handle) + } + // Verify the published bytes while write/delete-denying leases are held. + if err = verifyStaged(ctx, path, digest); err != nil { + return "", nil, err + } + cleanObsoleteHelpers(root, filepath.Dir(path)) + success = true + return path, release, nil +} + +// Only this user and SYSTEM receive inherited access to the private cache. +// The helper receives its separate, noninherited read/execute grant later. +func privateCacheDirectory(root string) error { + if !filepath.IsAbs(root) || filepath.Dir(root) == root { + return ErrUnavailable + } + if err := os.MkdirAll(filepath.Dir(root), 0700); err != nil { + return err + } + user, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + return err + } + sid := user.User.Sid.String() + descriptor, err := windows.SecurityDescriptorFromString("O:" + sid + "D:P(A;OICI;FA;;;" + sid + ")(A;OICI;FA;;;SY)") + if err != nil { + return err + } + name, err := windows.UTF16PtrFromString(root) + if err != nil { + return err + } + attributes := windows.SecurityAttributes{Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), SecurityDescriptor: descriptor} + if err = windows.CreateDirectory(name, &attributes); err != nil && !errors.Is(err, windows.ERROR_ALREADY_EXISTS) { + return err + } + handle, err := pinCachePath(root, true) + if err != nil { + return err + } + defer func() { _ = windows.CloseHandle(handle) }() + existing, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION) + if err != nil { + return err + } + owner, _, err := existing.Owner() + if err != nil { + return err + } + if owner == nil || !windows.EqualSid(owner, user.User.Sid) { + return fmt.Errorf("%w: private cache owner", ErrUnavailable) + } + acl, _, err := descriptor.DACL() + if err != nil { + return err + } + return windows.SetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, nil, nil, acl, nil) +} + +func pinCachePath(path string, directory bool) (windows.Handle, error) { + name, err := windows.UTF16PtrFromString(path) + if err != nil { + return 0, err + } + flags := uint32(windows.FILE_FLAG_OPEN_REPARSE_POINT) + access, share := uint32(windows.GENERIC_READ), uint32(windows.FILE_SHARE_READ) + if directory { + flags |= windows.FILE_FLAG_BACKUP_SEMANTICS + access = windows.FILE_READ_ATTRIBUTES | windows.READ_CONTROL | windows.WRITE_DAC + share |= windows.FILE_SHARE_WRITE + } + handle, err := windows.CreateFile(name, access, share, nil, windows.OPEN_EXISTING, flags, 0) + if err != nil { + return 0, err + } + var info windows.ByHandleFileInformation + err = windows.GetFileInformationByHandle(handle, &info) + if err == nil && (info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 || (info.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY != 0) != directory || (!directory && info.NumberOfLinks != 1)) { + err = fmt.Errorf("%w: private cache entry identity", ErrUnavailable) + } + if err != nil { + _ = windows.CloseHandle(handle) + return 0, err + } + return handle, nil +} + +func lockCache(ctx context.Context, path string) (windows.Handle, error) { + name, err := windows.UTF16PtrFromString(path) + if err != nil { + return 0, err + } + for { + if err = ctx.Err(); err != nil { + return 0, err + } + handle, openErr := windows.CreateFile(name, windows.GENERIC_READ|windows.GENERIC_WRITE, 0, nil, windows.OPEN_ALWAYS, windows.FILE_FLAG_OPEN_REPARSE_POINT, 0) + if openErr == nil { + var info windows.ByHandleFileInformation + if err = windows.GetFileInformationByHandle(handle, &info); err == nil && (info.FileAttributes&(windows.FILE_ATTRIBUTE_REPARSE_POINT|windows.FILE_ATTRIBUTE_DIRECTORY) != 0 || info.NumberOfLinks != 1) { + err = ErrUnavailable + } + if err != nil { + _ = windows.CloseHandle(handle) + return 0, err + } + return handle, nil + } + if !errors.Is(openErr, windows.ERROR_SHARING_VIOLATION) { + return 0, openErr + } + timer := time.NewTimer(25 * time.Millisecond) + select { + case <-ctx.Done(): + timer.Stop() + return 0, ctx.Err() + case <-timer.C: + } + } +} + +func cleanObsoleteHelpers(root, current string) { + entries, err := os.ReadDir(root) + if err != nil { + return + } + for _, entry := range entries { + path := filepath.Join(root, entry.Name()) + if path == current || !entry.IsDir() || entry.Type()&os.ModeSymlink != 0 { + continue + } + arch, digest, ok := strings.Cut(entry.Name(), "-") + decoded, err := hex.DecodeString(digest) + if !ok || (arch != "amd64" && arch != "arm64") || err != nil || len(decoded) != 32 || hex.EncodeToString(decoded) != digest { + continue + } + // An active client holds a write/delete-denying file lease. A failed removal + // is safe to defer until a later startup; never recursively force cleanup. + _ = removeStaged(path) + } +} diff --git a/internal/heicdecode/client/installed_windows_test.go b/internal/heicdecode/client/installed_windows_test.go new file mode 100644 index 00000000..202723d4 --- /dev/null +++ b/internal/heicdecode/client/installed_windows_test.go @@ -0,0 +1,255 @@ +//go:build windows && (amd64 || arm64) + +package client + +import ( + "bufio" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/frathe/picfetch/internal/heicdecode" +) + +func TestWindowsInstalledHelperCache(t *testing.T) { + source := filepath.Join(t.TempDir(), "owned.exe") + data := []byte("owned fixture, never executed") + if err := os.WriteFile(source, data, 0700); err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(data) + root := filepath.Join(t.TempDir(), "private-cache") + staged, release, err := prepareInstalled(context.Background(), source, digest, root) + if err != nil { + t.Fatal(err) + } + defer release() + if staged == source || !strings.Contains(staged, hex.EncodeToString(digest[:])) { + t.Fatal("installed source was not privately staged") + } + first, err := os.Stat(staged) + if err != nil { + t.Fatal(err) + } + reused, release2, err := prepareInstalled(context.Background(), source, digest, root) + if err != nil { + t.Fatal(err) + } + defer release2() + info, err := os.Stat(reused) + if err != nil || !os.SameFile(first, info) { + t.Fatal("valid active helper copy was replaced") + } + if err = os.WriteFile(staged, []byte("damaged"), 0700); err == nil { + t.Fatal("active executable was writable") + } + if err = os.Remove(staged); err == nil { + t.Fatal("active executable was deletable") + } + release() + release2() + if err = os.WriteFile(staged, []byte("damaged"), 0700); err != nil { + t.Fatal(err) + } + repaired, release3, err := prepareInstalled(context.Background(), source, digest, root) + if err != nil { + t.Fatal(err) + } + defer release3() + got, err := os.ReadFile(repaired) + if err != nil || string(got) != string(data) { + t.Fatal("damaged cache was not repaired") + } + newer := []byte("owned newer helper") + if err = os.WriteFile(source, newer, 0700); err != nil { + t.Fatal(err) + } + current, release4, err := prepareInstalled(context.Background(), source, sha256.Sum256(newer), root) + if err != nil { + t.Fatal(err) + } + defer release4() + if _, err = os.Stat(repaired); err != nil { + t.Fatal("removed obsolete helper while leased") + } + release3() + again, release5, err := prepareInstalled(context.Background(), source, sha256.Sum256(newer), root) + if err != nil { + t.Fatal(err) + } + defer release5() + if again != current { + t.Fatal("current helper path changed") + } + if _, err = os.Stat(repaired); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("unused obsolete copy survived cleanup: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, _, err = prepareInstalled(ctx, source, sha256.Sum256(newer), root); !errors.Is(err, context.Canceled) { + t.Fatalf("canceled stage: %v", err) + } +} + +func TestWindowsCacheLeaseWaitsForShutdown(t *testing.T) { + source := filepath.Join(t.TempDir(), "owned.exe") + data := []byte("owned helper") + if err := os.WriteFile(source, data, 0700); err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(data) + path, release, err := prepareInstalled(context.Background(), source, digest, filepath.Join(t.TempDir(), "cache")) + if err != nil { + t.Fatal(err) + } + owner, err := New(Config{Executable: path, SHA256: digest, Limits: heicdecode.DefaultLimits(0)}) + if err != nil { + release() + t.Fatal(err) + } + owner.release = release + defer func() { owner.Stop(); owner.Wait() }() + owner.Wait() + if err = os.Remove(path); err == nil { + t.Fatal("nonterminal Wait released the helper lease") + } + owner.Stop() + owner.Wait() + owner.Wait() + if err = os.Remove(path); err != nil { + t.Fatalf("shutdown retained the helper lease: %v", err) + } +} + +func TestWindowsConcurrentHelperStaging(t *testing.T) { + source := filepath.Join(t.TempDir(), "owned.exe") + data := []byte("owned simultaneous publication") + if err := os.WriteFile(source, data, 0700); err != nil { + t.Fatal(err) + } + root := filepath.Join(t.TempDir(), "cache") + config, err := json.Marshal([]string{source, root}) + if err != nil { + t.Fatal(err) + } + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + type peer struct { + command *exec.Cmd + input io.WriteCloser + output *bufio.Reader + } + var peers []peer + for range 3 { + cmd := exec.CommandContext(ctx, executable, "-test.run=^TestWindowsHelperCachePeer$") + cmd.Env = append(os.Environ(), "PICFETCH_HEIC_CACHE_TEST_PEER="+string(config)) + input, pipeErr := cmd.StdinPipe() + if pipeErr != nil { + t.Fatal(pipeErr) + } + output, pipeErr := cmd.StdoutPipe() + if pipeErr != nil { + t.Fatal(pipeErr) + } + cmd.Stderr = os.Stderr + if err = cmd.Start(); err != nil { + t.Fatal(err) + } + peers = append(peers, peer{cmd, input, bufio.NewReader(output)}) + } + defer func() { + for _, peer := range peers { + _ = peer.input.Close() + _ = peer.command.Wait() + } + }() + var selected string + for _, peer := range peers { + line, readErr := peer.output.ReadString('\n') + if readErr != nil || !strings.HasPrefix(line, "ready ") { + t.Fatalf("cache peer: %q, %v", line, readErr) + } + path := strings.TrimSpace(strings.TrimPrefix(line, "ready ")) + if selected != "" && selected != path { + t.Fatal("concurrent publishers selected different copies") + } + selected = path + } + if got, readErr := os.ReadFile(selected); readErr != nil || string(got) != string(data) { + t.Fatal("concurrent publication exposed incomplete bytes") + } + for _, peer := range peers { + if err = peer.input.Close(); err != nil { + t.Fatal(err) + } + if err = peer.command.Wait(); err != nil { + t.Fatal(err) + } + } + peers = nil +} + +func TestWindowsHelperCachePeer(t *testing.T) { + value := os.Getenv("PICFETCH_HEIC_CACHE_TEST_PEER") + if value == "" { + return + } + var args []string + if err := json.Unmarshal([]byte(value), &args); err != nil || len(args) != 2 { + t.Fatal("invalid owned peer configuration") + } + data, err := os.ReadFile(args[0]) + if err != nil { + t.Fatal(err) + } + path, release, err := prepareInstalled(context.Background(), args[0], sha256.Sum256(data), args[1]) + if err != nil { + t.Fatal(err) + } + defer release() + fmt.Println("ready " + path) + _, _ = io.Copy(io.Discard, os.Stdin) +} + +func TestWindowsHelperCacheRejectsHardLinksAndNonDirectories(t *testing.T) { + source := filepath.Join(t.TempDir(), "owned.exe") + data := []byte("owned helper") + if err := os.WriteFile(source, data, 0700); err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(data) + root := filepath.Join(t.TempDir(), "cache") + path, release, err := prepareInstalled(context.Background(), source, digest, root) + if err != nil { + t.Fatal(err) + } + release() + if err = os.Remove(path); err != nil { + t.Fatal(err) + } + if err = os.Link(source, path); err != nil { + t.Fatal(err) + } + if _, stop, err := prepareInstalled(context.Background(), source, digest, root); err == nil { + stop() + t.Fatal("cache hard link could change installed source permissions") + } + if _, stop, err := prepareInstalled(context.Background(), source, digest, source); err == nil { + stop() + t.Fatal("file accepted as private cache directory") + } +} diff --git a/internal/heicdecode/client/native_darwin_test.go b/internal/heicdecode/client/native_darwin_test.go new file mode 100644 index 00000000..a87bfd98 --- /dev/null +++ b/internal/heicdecode/client/native_darwin_test.go @@ -0,0 +1,258 @@ +//go:build heicnative && darwin && cgo + +package client + +import ( + "compress/gzip" + "context" + "crypto/sha256" + "errors" + "image" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/frathe/picfetch/internal/heicdecode" +) + +// Run on macOS outside a surrounding sandbox after building with heicnative. +// It creates an ad-hoc signed helper bundle and uses only owned probe files, +// owned loopback listeners and ordinary licensed image fixtures. +func TestNativeMacSandboxHelper(t *testing.T) { + root, err := filepath.Abs(filepath.Join("..", "..", "..")) + if err != nil { + t.Fatal(err) + } + config := buildNativeMacHelper(t, root, "compiler") + config.Limits.MaxInputBytes = 1024 * 1024 + config.Limits.WASMMemoryBytes = 128 * 1024 * 1024 + config.Limits.MaxPixels = 1024 * 1024 + config.Limits.MaxOutputBytes = 8 * 1024 * 1024 + client, err := New(config) + if err != nil { + t.Fatal(err) + } + defer func() { client.Stop(); client.Wait() }() + fixture := filepath.Join(root, "scripts", "heicbuild", "testdata", "tenbit.heic") + t.Run("missing sandbox refuses source", func(t *testing.T) { + unprotected := buildNativeMacHelper(t, root, "unprotected-control") + other, newErr := New(unprotected) + if newErr != nil { + t.Fatal(newErr) + } + defer func() { other.Stop(); other.Wait() }() + read := false + _, decodeErr := other.Do(context.Background(), heicdecode.Decode, func(_ context.Context, _ int64) ([]byte, error) { + read = true + return nil, nil + }) + if !errors.Is(decodeErr, ErrUnavailable) || read { + t.Fatalf("missing sandbox: error=%v, read=%v", decodeErr, read) + } + }) + t.Run("ordinary image after native readiness", func(t *testing.T) { + result, decodeErr := client.Do(context.Background(), heicdecode.Decode, func(_ context.Context, _ int64) ([]byte, error) { + return os.ReadFile(fixture) + }) + if decodeErr != nil { + t.Fatal(decodeErr) + } + img, ok := result.Image.(*image.NRGBA64) + if !ok || img.Bounds().Dx() != 16 || img.Bounds().Dy() != 16 { + t.Fatalf("ordinary ten-bit result: %T", result.Image) + } + // The owned lossless full-range source is a known grayscale ramp. + // Allow one ten-bit quantization step in the sixteen-bit output. + for y := 0; y < 16; y++ { + for x := 0; x < 16; x++ { + pixel := img.NRGBA64At(x, y) + expected := (x + y) * 2000 + if pixel.R != pixel.G || pixel.G != pixel.B || pixel.A != 65535 || int(pixel.R) < expected-65 || int(pixel.R) > expected+65 { + t.Fatalf("ten-bit ramp at %d,%d: %+v, expected gray %d", x, y, pixel, expected) + } + } + } + }) + t.Run("metadata and container rotation", func(t *testing.T) { + // This ordinary synthetic fixture carries both Exif orientation 6 and + // a container rotation. Independent ImageIO output is 480x640; applying + // the Exif orientation to already transformed pixels would rotate twice. + path := filepath.Join(root, "internal", "imaging", "testdata", "test_exif.heic") + for _, operation := range []heicdecode.Operation{heicdecode.Decode, heicdecode.DecodeConfig, heicdecode.DecodeExif} { + result, decodeErr := client.Do(context.Background(), operation, func(_ context.Context, _ int64) ([]byte, error) { + return os.ReadFile(path) + }) + if decodeErr != nil { + t.Fatal(decodeErr) + } + metadata := result.Metadata + if metadata == nil || metadata.Orientation != 6 || metadata.Make != "TestCam" || metadata.Model != "Model123" || metadata.FNumber != 5.6 || metadata.ISOSpeed != 800 { + t.Fatalf("ordinary fixture metadata: %+v", metadata) + } + if operation != heicdecode.DecodeExif && (result.Config.Width != 480 || result.Config.Height != 640) { + t.Fatalf("transformed configuration: %+v", result.Config) + } + if operation == heicdecode.Decode && result.Image.Bounds() != image.Rect(0, 0, 480, 640) { + t.Fatalf("transformed pixels: %v", result.Image.Bounds()) + } + } + }) + t.Run("cancellation joins admitted and queued work", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + entered := make(chan struct{}) + done := make(chan error, 1) + go func() { + _, decodeErr := client.Do(ctx, heicdecode.Decode, func(ctx context.Context, _ int64) ([]byte, error) { + close(entered) + <-ctx.Done() + return nil, ctx.Err() + }) + done <- decodeErr + }() + select { + case <-entered: + case err = <-done: + t.Fatalf("failed before source admission: %v", err) + case <-time.After(30 * time.Second): + t.Fatal("source was never admitted") + } + cancelled, cancelQueued := context.WithCancel(context.Background()) + cancelQueued() + _, queuedErr := client.Do(cancelled, heicdecode.Decode, func(_ context.Context, _ int64) ([]byte, error) { + t.Error("cancelled queued source was read") + return nil, nil + }) + if !errors.Is(queuedErr, context.Canceled) { + t.Fatalf("queued cancellation: %v", queuedErr) + } + cancel() + if decodeErr := <-done; !errors.Is(decodeErr, context.Canceled) { + t.Fatalf("admitted cancellation: %v", decodeErr) + } + client.Stop() + client.Wait() + }) +} + +func buildNativeMacHelper(t *testing.T, root, engine string) Config { + t.Helper() + if engine == "compiler" { + bundle := filepath.Join(t.TempDir(), "PicFetch.app") + command := exec.Command("go", "run", "./scripts/heicpackage", "-os", "darwin", "-arch", runtime.GOARCH, "-out", bundle) + command.Dir = root + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("stage signed helper package: %v: %s", err, output) + } + executable, digest, err := LoadPackage(bundle, "darwin", runtime.GOARCH) + if err != nil { + t.Fatal(err) + } + return Config{Executable: executable, SHA256: digest, Limits: heicdecode.DefaultLimits(0)} + } + + var err error + tags := "no_emoji,nodynamic" + entitlements := filepath.Join(root, "packaging", "heic", "macos.entitlements.plist") + if engine == "interpreter" { + tags += ",heicinterpreter" + entitlements = filepath.Join(t.TempDir(), "interpreter.plist") + const minimal = `com.apple.security.app-sandbox` + if err = os.WriteFile(entitlements, []byte(minimal), 0600); err != nil { + t.Fatal(err) + } + } + if engine == "unprotected-control" { + entitlements = filepath.Join(t.TempDir(), "no-sandbox.plist") + const empty = `` + if err = os.WriteFile(entitlements, []byte(empty), 0600); err != nil { + t.Fatal(err) + } + } + bundle := filepath.Join(t.TempDir(), "HEICWorker.app") + executable := filepath.Join(bundle, "Contents", "MacOS", "picfetch-heic-worker") + if err = os.MkdirAll(filepath.Dir(executable), 0700); err != nil { + t.Fatal(err) + } + build := exec.Command("go", "build", "-tags", tags, "-o", executable, "./cmd/picfetch-heic-worker") + build.Dir = root + if out, buildErr := build.CombinedOutput(); buildErr != nil { + t.Fatalf("build helper: %v: %s", buildErr, out) + } + plist, err := os.ReadFile(filepath.Join(root, "packaging", "heic", "macos.Info.plist")) + if err != nil { + t.Fatal(err) + } + if err = os.WriteFile(filepath.Join(bundle, "Contents", "Info.plist"), plist, 0600); err != nil { + t.Fatal(err) + } + sign := exec.Command("/usr/bin/codesign", "--force", "--sign", "-", "--options", "runtime", "--entitlements", entitlements, bundle) + if out, signErr := sign.CombinedOutput(); signErr != nil { + t.Fatalf("sign helper: %v: %s", signErr, out) + } + verify := exec.Command("/usr/bin/codesign", "--verify", "--strict", bundle) + if out, verifyErr := verify.CombinedOutput(); verifyErr != nil { + t.Fatalf("verify helper: %v: %s", verifyErr, out) + } + contents, err := os.ReadFile(executable) + if err != nil { + t.Fatal(err) + } + limits := heicdecode.DefaultLimits(0) + config := Config{Executable: executable, SHA256: sha256.Sum256(contents), Limits: limits} + return config +} + +// This comparison records an ordinary owned image within the production +// deadline. Interpreter deadline refusal is an observed result, not a skipped +// sandbox gate or permission to extend resource limits. +func TestNativeMacRuntimeChoice(t *testing.T) { + root, err := filepath.Abs(filepath.Join("..", "..", "..")) + if err != nil { + t.Fatal(err) + } + for _, engine := range []string{"interpreter", "compiler"} { + t.Run(engine, func(t *testing.T) { + config := buildNativeMacHelper(t, root, engine) + client, err := New(config) + if err != nil { + t.Fatal(err) + } + defer func() { client.Stop(); client.Wait() }() + for _, name := range []string{"basic.heic", "photo-gradient.heic.gz"} { + start := time.Now() + result, err := client.Do(context.Background(), heicdecode.Decode, func(_ context.Context, maxBytes int64) ([]byte, error) { + path := filepath.Join(root, "scripts", "heicbuild", "testdata", name) + if name == "basic.heic" { + return os.ReadFile(path) + } + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer func() { _ = file.Close() }() + reader, err := gzip.NewReader(file) + if err != nil { + return nil, err + } + defer func() { _ = reader.Close() }() + return io.ReadAll(io.LimitReader(reader, maxBytes+1)) + }) + if err != nil { + t.Logf("%s: elapsed=%s, result=%v", name, time.Since(start), err) + if engine != "interpreter" || !errors.Is(err, context.DeadlineExceeded) { + t.Fatal(err) + } + continue + } + t.Logf("%s: elapsed=%s, bounds=%v", name, time.Since(start), result.Image.Bounds()) + if name == "photo-gradient.heic.gz" && result.Image.Bounds() != image.Rect(0, 0, 4032, 3024) { + t.Fatal("gradient dimensions differ") + } + } + }) + } +} diff --git a/internal/heicdecode/client/native_platform_test.go b/internal/heicdecode/client/native_platform_test.go new file mode 100644 index 00000000..64af9873 --- /dev/null +++ b/internal/heicdecode/client/native_platform_test.go @@ -0,0 +1,84 @@ +//go:build heicnative && (linux || windows) && (amd64 || arm64) + +package client + +import ( + "compress/gzip" + "context" + "image" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/frathe/picfetch/internal/heicdecode" +) + +func TestNativeSandboxHelper(t *testing.T) { + root, err := filepath.Abs(filepath.Join("..", "..", "..")) + if err != nil { + t.Fatal(err) + } + + packageRoot := t.TempDir() + build := exec.Command("go", "run", "./scripts/heicpackage", "-os", runtime.GOOS, "-arch", runtime.GOARCH, "-out", packageRoot) + build.Dir = root + if output, buildErr := build.CombinedOutput(); buildErr != nil { + t.Fatalf("stage helper package: %v: %s", buildErr, output) + } + mainExecutable := filepath.Join(packageRoot, "picfetch") + if runtime.GOOS == "windows" { + mainExecutable += ".exe" + } + if err = os.WriteFile(mainExecutable, []byte("owned installation marker"), 0700); err != nil { + t.Fatal(err) + } + client, err := OpenInstalled(context.Background(), mainExecutable, filepath.Join(t.TempDir(), "heic-helpers"), heicdecode.DefaultLimits(0)) + if err != nil { + t.Fatal(err) + } + defer func() { client.Stop(); client.Wait() }() + for _, fixture := range []struct { + name string + bounds image.Rectangle + }{ + {"tenbit.heic", image.Rect(0, 0, 16, 16)}, + {"photo-gradient.heic.gz", image.Rect(0, 0, 4032, 3024)}, + } { + t.Run(fixture.name, func(t *testing.T) { + start := time.Now() + result, decodeErr := client.Do(context.Background(), heicdecode.Decode, func(_ context.Context, maxBytes int64) ([]byte, error) { + file, readErr := os.Open(filepath.Join(root, "scripts", "heicbuild", "testdata", fixture.name)) + if readErr != nil { + return nil, readErr + } + defer func() { _ = file.Close() }() + var reader io.Reader = file + if filepath.Ext(fixture.name) == ".gz" { + compressed, zipErr := gzip.NewReader(file) + if zipErr != nil { + return nil, zipErr + } + defer func() { _ = compressed.Close() }() + reader = compressed + } + return io.ReadAll(io.LimitReader(reader, maxBytes+1)) + }) + if decodeErr != nil { + t.Fatal(decodeErr) + } + if result.Image == nil || result.Image.Bounds() != fixture.bounds { + t.Fatalf("unexpected result: %+v", result.Config) + } + if fixture.name == "tenbit.heic" { + if _, ok := result.Image.(*image.NRGBA64); !ok { + t.Fatalf("ten-bit precision lost: %T", result.Image) + } + } + t.Logf("%s: elapsed=%s, bounds=%v", fixture.name, time.Since(start), result.Image.Bounds()) + }) + } +} diff --git a/internal/heicdecode/client/package.go b/internal/heicdecode/client/package.go new file mode 100644 index 00000000..040de013 --- /dev/null +++ b/internal/heicdecode/client/package.go @@ -0,0 +1,182 @@ +package client + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/frathe/picfetch/internal/heicdecode" +) + +// InstallationRoot discovers only the package containing the running binary. +// A macOS standalone executable is deliberately not a complete app package. +func InstallationRoot(executable, system string) (string, error) { + if !filepath.IsAbs(executable) { + return "", ErrUnavailable + } + executable, err := filepath.EvalSymlinks(executable) + if err != nil { + return "", err + } + dir := filepath.Dir(executable) + switch system { + case "darwin": + contents := filepath.Dir(dir) + root := filepath.Dir(contents) + if filepath.Base(dir) != "MacOS" || filepath.Base(contents) != "Contents" || !strings.HasSuffix(strings.ToLower(root), ".app") { + return "", ErrUnavailable + } + return root, nil + case "linux", "windows": + return dir, nil + default: + return "", ErrUnavailable + } +} + +// OpenInstalled creates the one app-owned client from a complete local package. +// Preparation never chooses another decoder or downloads a missing helper. +func OpenInstalled(ctx context.Context, executable, privateDir string, limits heicdecode.Limits) (*Client, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + root, err := InstallationRoot(executable, runtime.GOOS) + if err != nil { + return nil, err + } + helper, digest, err := LoadPackage(root, runtime.GOOS, runtime.GOARCH) + if err != nil { + return nil, err + } + helper, release, err := prepareInstalled(ctx, helper, digest, privateDir) + if err != nil { + return nil, err + } + owner, err := New(Config{Executable: helper, SHA256: digest, Limits: limits}) + if err != nil { + release() + return nil, err + } + owner.release = release + return owner, nil +} + +// PackageManifest records the post-signing helper identity within a trusted +// installed package. It is not an independent signature: package authenticity +// belongs to the enclosing TUF archive or platform bundle/package signature. +// No manifest field selects a path or relaxes the runtime resource policy. +type PackageManifest struct { + Version int `json:"version"` + GOOS string `json:"goos"` + GOARCH string `json:"goarch"` + ExecutableSHA256 string `json:"executableSHA256"` + GuestSHA256 string `json:"guestSHA256"` +} + +// PackagePaths defines the one supported helper and manifest location for each +// OS. root is the .app bundle on macOS, or the installation directory elsewhere. +func PackagePaths(root, system string) (executable, manifest string, err error) { + if !filepath.IsAbs(root) { + return "", "", errors.New("HEIC package root must be absolute") + } + switch system { + case "darwin": + return filepath.Join(root, "Contents", "Helpers", "HEICWorker.app", "Contents", "MacOS", "picfetch-heic-worker"), filepath.Join(root, "Contents", "Resources", "heic", "manifest.json"), nil + case "linux": + return filepath.Join(root, "heic", "picfetch-heic-worker"), filepath.Join(root, "heic", "manifest.json"), nil + case "windows": + return filepath.Join(root, "heic", "picfetch-heic-worker.exe"), filepath.Join(root, "heic", "manifest.json"), nil + default: + return "", "", errors.New("unsupported HEIC package platform") + } +} + +// LoadPackage refuses absent/malformed/wrong-target packages and pins the +// installed executable. Client verifies that pin again at every launch. The +// caller supplies the trusted installation root, never an image-selected path. +func LoadPackage(root, system, architecture string) (string, [32]byte, error) { + var empty [32]byte + if architecture != "amd64" && architecture != "arm64" { + return "", empty, ErrUnavailable + } + executable, manifest, err := PackagePaths(root, system) + if err != nil { + return "", empty, err + } + file, err := os.Open(manifest) + if err != nil { + return "", empty, err + } + data, readErr := io.ReadAll(io.LimitReader(file, 4097)) + closeErr := file.Close() + if readErr != nil { + return "", empty, readErr + } + if closeErr != nil { + return "", empty, closeErr + } + if len(data) > 4096 { + return "", empty, errors.New("oversized HEIC package manifest") + } + var record PackageManifest + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err = decoder.Decode(&record); err != nil { + return "", empty, err + } + if err = decoder.Decode(new(any)); err != io.EOF { + return "", empty, errors.New("trailing HEIC package manifest data") + } + if record.Version != 1 || record.GOOS != system || record.GOARCH != architecture { + return "", empty, errors.New("HEIC package target mismatch") + } + pin, err := packageDigest(record.ExecutableSHA256) + if err != nil { + return "", empty, err + } + if _, err = packageDigest(record.GuestSHA256); err != nil { + return "", empty, err + } + helper, err := os.Open(executable) + if err != nil { + return "", empty, err + } + defer func() { _ = helper.Close() }() + info, err := helper.Stat() + if err != nil { + return "", empty, err + } + if !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > 64*1024*1024 { + return "", empty, errors.New("invalid packaged HEIC executable") + } + hash := sha256.New() + if _, err = io.Copy(hash, io.LimitReader(helper, 64*1024*1024+1)); err != nil { + return "", empty, err + } + if !bytes.Equal(hash.Sum(nil), pin[:]) { + return "", empty, errors.New("packaged HEIC executable hash mismatch") + } + return executable, pin, nil +} + +func packageDigest(value string) ([32]byte, error) { + var digest [32]byte + decoded, err := hex.DecodeString(value) + if err != nil || len(decoded) != len(digest) || hex.EncodeToString(decoded) != value { + return digest, fmt.Errorf("invalid HEIC package digest") + } + copy(digest[:], decoded) + if digest == [32]byte{} { + return digest, errors.New("empty HEIC package digest") + } + return digest, nil +} diff --git a/internal/heicdecode/client/package_test.go b/internal/heicdecode/client/package_test.go new file mode 100644 index 00000000..c31e4796 --- /dev/null +++ b/internal/heicdecode/client/package_test.go @@ -0,0 +1,168 @@ +package client + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestPackagedHelperPinsOnlyFixedTarget(t *testing.T) { + for _, system := range []string{"darwin", "linux", "windows"} { + t.Run(system, func(t *testing.T) { + root := t.TempDir() + executable, manifest, err := PackagePaths(root, system) + if err != nil { + t.Fatal(err) + } + if err = os.MkdirAll(filepath.Dir(executable), 0700); err != nil { + t.Fatal(err) + } + if err = os.MkdirAll(filepath.Dir(manifest), 0700); err != nil { + t.Fatal(err) + } + helper := []byte("owned package executable") + if err = os.WriteFile(executable, helper, 0700); err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(helper) + guest := sha256.Sum256([]byte("owned package guest")) + record := PackageManifest{Version: 1, GOOS: system, GOARCH: "arm64", ExecutableSHA256: hex.EncodeToString(digest[:]), GuestSHA256: hex.EncodeToString(guest[:])} + data, err := json.Marshal(record) + if err != nil { + t.Fatal(err) + } + if err = os.WriteFile(manifest, data, 0600); err != nil { + t.Fatal(err) + } + got, pin, err := LoadPackage(root, system, "arm64") + if err != nil || got != executable || pin != digest { + t.Fatalf("package: path=%q pin=%x error=%v", got, pin, err) + } + if _, _, err = LoadPackage(root, system, "amd64"); err == nil { + t.Fatal("wrong architecture accepted") + } + if err = os.WriteFile(executable, []byte("changed helper"), 0700); err != nil { + t.Fatal(err) + } + if _, _, err = LoadPackage(root, system, "arm64"); err == nil { + t.Fatal("changed helper accepted") + } + }) + } +} + +func TestPackagedHelperRejectsUntrustedManifestChoices(t *testing.T) { + root := t.TempDir() + if _, _, err := LoadPackage(root, "linux", "amd64"); err == nil { + t.Fatal("missing package accepted") + } + _, manifest, err := PackagePaths(root, "linux") + if err != nil { + t.Fatal(err) + } + if err = os.MkdirAll(filepath.Dir(manifest), 0700); err != nil { + t.Fatal(err) + } + for _, data := range []string{`{}`, `{"Version":1,"Executable":"/other/program"}`, string(make([]byte, 4097))} { + if err = os.WriteFile(manifest, []byte(data), 0600); err != nil { + t.Fatal(err) + } + if _, _, err = LoadPackage(root, "linux", "amd64"); err == nil { + t.Fatalf("invalid manifest accepted: %.80q", data) + } + } + if _, _, err = PackagePaths(root, "unsupported"); err == nil { + t.Fatal("unsupported package target accepted") + } + if _, _, err = PackagePaths("relative", "linux"); err == nil { + t.Fatal("relative installation root accepted") + } +} + +func TestStagedHelperPublication(t *testing.T) { + source := filepath.Join(t.TempDir(), "packaged.exe") + data := []byte("owned packaged helper") + if err := os.WriteFile(source, data, 0700); err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(data) + root := t.TempDir() + prepared := 0 + prepare := func(path string) error { + prepared++ + got, err := os.ReadFile(path) + if err != nil || !bytes.Equal(got, data) { + t.Fatal("permissions prepared before verified copy existed") + } + if path == source { + t.Fatal("changed installed helper permissions") + } + return nil + } + staged, err := stageHelper(context.Background(), source, digest, root, prepare) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(staged, hex.EncodeToString(digest[:])) { + t.Fatal("copy is not content addressed") + } + first, err := os.Stat(staged) + if err != nil { + t.Fatal(err) + } + reused, err := stageHelper(context.Background(), source, digest, root, prepare) + if err != nil || reused != staged { + t.Fatalf("reuse: %q %v", reused, err) + } + second, err := os.Stat(reused) + if err != nil || !os.SameFile(first, second) { + t.Fatal("valid copy was replaced") + } + if err = os.WriteFile(staged, []byte("damaged"), 0700); err != nil { + t.Fatal(err) + } + repaired, err := stageHelper(context.Background(), source, digest, root, prepare) + if err != nil || repaired != staged { + t.Fatalf("repair: %q %v", repaired, err) + } + got, err := os.ReadFile(repaired) + if err != nil || !bytes.Equal(got, data) || prepared != 3 { + t.Fatal("damaged copy not repaired/prepared") + } + if err = os.WriteFile(source, []byte("changed installed source"), 0700); err != nil { + t.Fatal(err) + } + if _, err = stageHelper(context.Background(), source, digest, t.TempDir(), prepare); err == nil { + t.Fatal("published a changed source") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err = stageHelper(ctx, source, digest, root, prepare); !errors.Is(err, context.Canceled) { + t.Fatalf("cancellation: %v", err) + } +} + +func TestStagedHelperPreparationFailure(t *testing.T) { + source := filepath.Join(t.TempDir(), "packaged.exe") + data := []byte("owned helper") + if err := os.WriteFile(source, data, 0700); err != nil { + t.Fatal(err) + } + root := t.TempDir() + denied := errors.New("owned permission denial") + _, err := stageHelper(context.Background(), source, sha256.Sum256(data), root, func(_ string) error { return denied }) + if !errors.Is(err, denied) { + t.Fatalf("preparation error lost: %v", err) + } + entries, err := os.ReadDir(root) + if err != nil || len(entries) != 0 { + t.Fatalf("failed preparation published or leaked a copy: %v, %v", entries, err) + } +} diff --git a/internal/heicdecode/client/peer_other_test.go b/internal/heicdecode/client/peer_other_test.go new file mode 100644 index 00000000..82d464f9 --- /dev/null +++ b/internal/heicdecode/client/peer_other_test.go @@ -0,0 +1,7 @@ +//go:build !windows + +package client + +import "os" + +func installOwnedPeer(source, destination string) error { return os.Link(source, destination) } diff --git a/internal/heicdecode/client/peer_windows_test.go b/internal/heicdecode/client/peer_windows_test.go new file mode 100644 index 00000000..6cd970b4 --- /dev/null +++ b/internal/heicdecode/client/peer_windows_test.go @@ -0,0 +1,31 @@ +//go:build windows && (amd64 || arm64) + +package client + +import ( + "io" + "os" + + "github.com/frathe/picfetch/internal/heicdecode/winisolation" +) + +func installOwnedPeer(source, destination string) error { + input, err := os.Open(source) + if err != nil { + return err + } + defer func() { _ = input.Close() }() + output, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0700) + if err != nil { + return err + } + _, copyErr := io.Copy(output, input) + closeErr := output.Close() + if copyErr != nil { + return copyErr + } + if closeErr != nil { + return closeErr + } + return winisolation.PrepareExecutable(destination) +} diff --git a/internal/heicdecode/client/probes.go b/internal/heicdecode/client/probes.go new file mode 100644 index 00000000..478ea288 --- /dev/null +++ b/internal/heicdecode/client/probes.go @@ -0,0 +1,82 @@ +package client + +import ( + "context" + "fmt" + "io" + "net" + "time" +) + +// nativeProbes owns only disposable loopback listeners. The parent first +// verifies they work, so a helper timeout cannot stand for an absent listener. +type nativeProbes struct { + tcp net.Listener + udp net.PacketConn + udpDone chan struct{} +} + +func openNativeProbes(ctx context.Context) (*nativeProbes, error) { + tcp, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + return nil, err + } + p := &nativeProbes{tcp: tcp} + p.udp, err = net.ListenPacket("udp4", "127.0.0.1:0") + if err != nil { + p.close() + return nil, err + } + p.udpDone = make(chan struct{}) + go func() { + defer close(p.udpDone) + var packet [1]byte + for { + n, from, readErr := p.udp.ReadFrom(packet[:]) + if readErr != nil { + return + } + if n == 1 && packet[0] == 0 { + _ = p.udp.SetWriteDeadline(time.Now().Add(time.Second)) + _, _ = p.udp.WriteTo(packet[:], from) + } + } + }() + for i, address := range []string{p.tcp.Addr().String(), p.udp.LocalAddr().String()} { + network := "tcp4" + if i == 1 { + network = "udp4" + } + dialer := net.Dialer{Timeout: time.Second} + connection, checkErr := dialer.DialContext(ctx, network, address) + if connection != nil { + if i == 1 { + _ = connection.SetDeadline(time.Now().Add(time.Second)) + _, checkErr = connection.Write([]byte{0}) + if checkErr == nil { + var reply [1]byte + _, checkErr = io.ReadFull(connection, reply[:]) + if checkErr == nil && reply[0] != 0 { + checkErr = fmt.Errorf("unexpected native UDP probe reply") + } + } + } + _ = connection.Close() + } + if checkErr != nil { + p.close() + return nil, fmt.Errorf("verify owned %s listener: %w", network, checkErr) + } + } + return p, nil +} + +func (p *nativeProbes) close() { + _ = p.tcp.Close() + if p.udp != nil { + _ = p.udp.Close() + } + if p.udpDone != nil { + <-p.udpDone + } +} diff --git a/internal/heicdecode/client/process.go b/internal/heicdecode/client/process.go new file mode 100644 index 00000000..c7acac14 --- /dev/null +++ b/internal/heicdecode/client/process.go @@ -0,0 +1,182 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "sync" + + "github.com/frathe/picfetch/internal/heicdecode" +) + +func (c *Client) run(ctx context.Context, op heicdecode.Operation, input Input) (result heicdecode.Response, resultErr error) { + var empty heicdecode.Response + probe, err := os.MkdirTemp("", "picfetch-heic-probe-") + if err != nil { + return empty, fmt.Errorf("%w: setup: %v", ErrUnavailable, err) + } + defer func() { _ = os.RemoveAll(probe) }() + readPath, writePath := filepath.Join(probe, "read"), filepath.Join(probe, "write") + if err = os.WriteFile(readPath, []byte("PicFetch owned sandbox probe\n"), 0600); err != nil { + return empty, fmt.Errorf("%w: setup: %v", ErrUnavailable, err) + } + // Both endpoints belong to this request. A failed sandbox can reach only + // these disposable loopback sockets, never an unrelated network service. + probes, err := openNativeProbes(ctx) + if err != nil { + return empty, fmt.Errorf("%w: setup: %v", ErrUnavailable, err) + } + defer probes.close() + limitsJSON, err := json.Marshal(c.config.Limits) + if err != nil { + return empty, fmt.Errorf("%w: setup: %v", ErrUnavailable, err) + } + cmd := exec.Command(c.config.Executable, "--heic-worker-v2", string(limitsJSON), readPath, writePath, probes.tcp.Addr().String(), probes.udp.LocalAddr().String()) + cmd.Env = []string{"GOMAXPROCS=1"} + cmd.Dir = string(filepath.Separator) + var pipes []*os.File + defer func() { + for _, file := range pipes { + _ = file.Close() + } + }() + newPipe := func() (*os.File, *os.File, error) { + reader, writer, pipeErr := os.Pipe() + if pipeErr == nil { + pipes = append(pipes, reader, writer) + } + return reader, writer, pipeErr + } + inRead, inWrite, err := newPipe() + if err != nil { + return empty, fmt.Errorf("%w: setup: %v", ErrUnavailable, err) + } + outRead, outWrite, err := newPipe() + if err != nil { + return empty, fmt.Errorf("%w: setup: %v", ErrUnavailable, err) + } + errRead, errWrite, err := newPipe() + if err != nil { + return empty, fmt.Errorf("%w: setup: %v", ErrUnavailable, err) + } + cmd.Stdin, cmd.Stdout, cmd.Stderr = inRead, outWrite, errWrite + process, err := startProcess(cmd, c.config.Limits) + if err != nil { + return empty, fmt.Errorf("%w: launch: %v", ErrUnavailable, err) + } + _ = inRead.Close() + _ = outWrite.Close() + _ = errWrite.Close() + // Own every pipe rather than relying on exec.Cmd's background copy loops. + // On cancellation, close both transport directions and terminate the family. + ctx, cancel := context.WithCancel(ctx) + var cancelWork sync.WaitGroup + cancelWork.Add(1) + context.AfterFunc(ctx, func() { + defer cancelWork.Done() + process.Kill() + _ = inWrite.Close() + _ = outRead.Close() + _ = errRead.Close() + }) + var pipeWork sync.WaitGroup + var readinessFailed bool + var diagnostic bytes.Buffer // Read only after pipeWork.Wait. + var diagnosticErr error // Read only after pipeWork.Wait. + var stderrErr error // Read only after stderrDone closes. + pipeWork.Add(1) + stderrDone := make(chan struct{}) + go func() { + defer pipeWork.Done() + defer close(stderrDone) + count, readErr := io.Copy(&diagnostic, io.LimitReader(errRead, int64(c.config.Limits.MaxDiagnosticBytes)+1)) + if count > int64(c.config.Limits.MaxDiagnosticBytes) { + readErr = ErrDiagnosticLimit + diagnosticErr = readErr + cancel() + } + stderrErr = readErr + }() + // Every return after Start waits for process exit and all transport workers. + defer func() { + // Stop the producer, then drain its bounded diagnostics before closing + // our read end. Otherwise stdout EOF can race the overflow reader and + // turn a diagnostic-limit failure into an ordinary protocol error. + process.Kill() + <-stderrDone + cancel() + cancelWork.Wait() + // Kill the group before reaping its leader, so its PID cannot be + // recycled between Wait and a late cancellation callback. + processErr := process.Wait() + pipeWork.Wait() + if diagnosticErr != nil { + result = empty + resultErr = errors.Join(resultErr, diagnosticErr) + } else if readinessFailed && diagnostic.Len() > 0 { + // Readiness precedes all image input. Quote its bounded startup + // diagnostic, but never publish parser diagnostics after readiness. + resultErr = fmt.Errorf("%w: startup diagnostic %q", resultErr, diagnostic.String()) + } + if resultErr == nil && processErr != nil { + result = empty + resultErr = fmt.Errorf("HEIC helper exit: %w", processErr) + } + }() + ready, err := heicdecode.ReadReady(outRead) + if err != nil || ready.WASMMemoryBytes != c.config.Limits.WASMMemoryBytes || + ready.NativeMemoryBytes > c.config.Limits.OSProcessBytes || + (runtime.GOOS != "darwin" && ready.NativeMemoryBytes == 0) { + readinessFailed = true + process.Kill() + <-stderrDone + return empty, fmt.Errorf("%w: native readiness", ErrUnavailable) + } + // No loopback listener remains reachable when image bytes are admitted. + probes.close() + if err = ctx.Err(); err != nil { + return empty, err + } + data, err := input(ctx, c.config.Limits.MaxInputBytes) + if err != nil { + return empty, err + } + if err = ctx.Err(); err != nil { + return empty, err + } + writeDone := make(chan error, 1) + pipeWork.Go(func() { + writeErr := heicdecode.WriteRequest(inWrite, heicdecode.Request{Operation: op, Input: data}, c.config.Limits) + _ = inWrite.Close() + writeDone <- writeErr + }) + decoded, readErr := heicdecode.ReadResponse(outRead, op, c.config.Limits) + if readErr != nil { + return empty, readErr + } + if err = <-writeDone; err != nil { + return empty, err + } + <-stderrDone + if stderrErr != nil { + return empty, stderrErr + } + if err = ctx.Err(); err != nil { + return empty, err + } + return decoded, nil +} + +// Each platform owns family termination and a final join. Windows creates a +// suspended AppContainer process rather than asking exec.Cmd to start it. +type helperProcess interface { + Kill() + Wait() error +} diff --git a/internal/heicdecode/client/process_other.go b/internal/heicdecode/client/process_other.go new file mode 100644 index 00000000..6ade938a --- /dev/null +++ b/internal/heicdecode/client/process_other.go @@ -0,0 +1,13 @@ +//go:build !darwin && !linux && (!windows || (!amd64 && !arm64)) + +package client + +import ( + "os/exec" + + "github.com/frathe/picfetch/internal/heicdecode" +) + +func startProcess(_ *exec.Cmd, _ heicdecode.Limits) (helperProcess, error) { + return nil, ErrUnavailable +} diff --git a/internal/heicdecode/client/process_unix.go b/internal/heicdecode/client/process_unix.go new file mode 100644 index 00000000..5ebca4df --- /dev/null +++ b/internal/heicdecode/client/process_unix.go @@ -0,0 +1,28 @@ +//go:build darwin || linux + +package client + +import ( + "os/exec" + "syscall" + + "github.com/frathe/picfetch/internal/heicdecode" +) + +type unixProcess struct{ cmd *exec.Cmd } + +func startProcess(cmd *exec.Cmd, _ heicdecode.Limits) (helperProcess, error) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err := cmd.Start(); err != nil { + return nil, err + } + return &unixProcess{cmd: cmd}, nil +} + +func (p *unixProcess) Kill() { + if p.cmd.Process != nil { + _ = syscall.Kill(-p.cmd.Process.Pid, syscall.SIGKILL) + } +} + +func (p *unixProcess) Wait() error { return p.cmd.Wait() } diff --git a/internal/heicdecode/client/process_unix_test.go b/internal/heicdecode/client/process_unix_test.go new file mode 100644 index 00000000..5138868a --- /dev/null +++ b/internal/heicdecode/client/process_unix_test.go @@ -0,0 +1,72 @@ +//go:build darwin || linux + +package client + +import ( + "context" + "encoding/binary" + "errors" + "io" + "net" + "os" + "testing" + "time" + + "github.com/frathe/picfetch/internal/heicdecode" +) + +func TestOwnedPeerCancellationTerminatesDescendant(t *testing.T) { + listener, err := net.ListenTCP("tcp4", &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatal(err) + } + defer func() { _ = listener.Close() }() + if err = listener.SetDeadline(time.Now().Add(5 * time.Second)); err != nil { + t.Fatal(err) + } + client := ownedPeer(t, "family", 10*time.Second) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { + _, decodeErr := client.Do(ctx, heicdecode.Decode, func(_ context.Context, _ int64) ([]byte, error) { + return []byte(listener.Addr().String()), nil + }) + done <- decodeErr + }() + connection, err := listener.AcceptTCP() + if err != nil { + t.Fatal(err) + } + defer func() { _ = connection.Close() }() + if err = connection.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { + t.Fatal(err) + } + var pid uint32 + if err = binary.Read(connection, binary.LittleEndian, &pid); err != nil { + t.Fatal(err) + } + // If the group-kill guard regresses, clean up the still-connected owned + // descendant after the bounded EOF assertion. Never poll PID existence. + exited := false + defer func() { + if !exited { + if process, findErr := os.FindProcess(int(pid)); findErr == nil { + _ = process.Kill() + _ = process.Release() + } + } + }() + cancel() + if err = <-done; !errors.Is(err, context.Canceled) { + t.Fatalf("family cancellation: %v", err) + } + var unexpected [1]byte + _, err = connection.Read(unexpected[:]) + exited = errors.Is(err, io.EOF) + if !exited { + t.Fatalf("descendant kept its connection after client joined: %v", err) + } + client.Stop() + client.Wait() +} diff --git a/internal/heicdecode/client/process_windows.go b/internal/heicdecode/client/process_windows.go new file mode 100644 index 00000000..19230320 --- /dev/null +++ b/internal/heicdecode/client/process_windows.go @@ -0,0 +1,21 @@ +//go:build windows && (amd64 || arm64) + +package client + +import ( + "os" + "os/exec" + + "github.com/frathe/picfetch/internal/heicdecode" + "github.com/frathe/picfetch/internal/heicdecode/winisolation" +) + +func startProcess(cmd *exec.Cmd, limits heicdecode.Limits) (helperProcess, error) { + input, inOK := cmd.Stdin.(*os.File) + output, outOK := cmd.Stdout.(*os.File) + diagnostic, errOK := cmd.Stderr.(*os.File) + if !inOK || !outOK || !errOK { + return nil, ErrUnavailable + } + return winisolation.Start(cmd.Path, cmd.Args[1:], [3]*os.File{input, output, diagnostic}, limits) +} diff --git a/internal/heicdecode/client/remote.go b/internal/heicdecode/client/remote.go new file mode 100644 index 00000000..b133e0f0 --- /dev/null +++ b/internal/heicdecode/client/remote.go @@ -0,0 +1,149 @@ +package client + +import ( + "context" + "errors" + "io" + "sync" + + "github.com/frathe/picfetch/internal/heicdecode" +) + +// Remote uses an explicitly inherited pair of interruptible pipes to reach the +// application's admission owner. It cannot launch a helper itself. Cancellation +// retires this connection; its owning analysis process must obtain a new pair +// before later work. Each Remote permits one active call and no hidden backlog. +type Remote struct { + input io.ReadCloser + output io.WriteCloser + limits heicdecode.Limits + ctx context.Context + cancel context.CancelFunc + mu sync.Mutex + closed, active bool + work sync.WaitGroup +} + +func NewRemote(input io.ReadCloser, output io.WriteCloser, limits heicdecode.Limits) (*Remote, error) { + if err := limits.Validate(); err != nil { + return nil, err + } + if input == nil || output == nil { + return nil, ErrUnavailable + } + ctx, cancel := context.WithCancel(context.Background()) + return &Remote{input: input, output: output, limits: limits, ctx: ctx, cancel: cancel}, nil +} + +func (r *Remote) Stop() { + r.mu.Lock() + if r.closed { + r.mu.Unlock() + return + } + r.closed = true + r.cancel() + r.mu.Unlock() + _ = r.input.Close() + _ = r.output.Close() +} + +func (r *Remote) Wait() { r.work.Wait() } + +func (r *Remote) Do(ctx context.Context, op heicdecode.Operation, input Input) (result heicdecode.Response, resultErr error) { + r.mu.Lock() + if r.closed { + r.mu.Unlock() + return result, ErrUnavailable + } + if r.active { + r.mu.Unlock() + return result, ErrBusy + } + r.active = true + r.work.Add(1) + r.mu.Unlock() + defer func() { r.mu.Lock(); r.active = false; r.mu.Unlock(); r.work.Done() }() + ctx, cancel := context.WithCancel(ctx) + stopParent := watchCancellation(r.ctx, cancel) + stopIO := watchCancellation(ctx, r.Stop) + defer func() { + stopIO() + stopParent() + ctxErr := ctx.Err() + cancel() + if ctxErr != nil { + result = heicdecode.Response{} + resultErr = ctxErr + } + var failure *heicdecode.Failure + if resultErr != nil && !errors.As(resultErr, &failure) { + r.Stop() + } + }() + if err := ctx.Err(); err != nil { + return result, err + } + if err := writeBrokerHeader(r.output, brokerHeader{kind: brokerIntent, operation: op}); err != nil { + return result, err + } + header, err := readBrokerHeader(r.input) + if err != nil { + return result, err + } + if header.operation != op { + return result, heicdecode.ErrInvalidResponse + } + if header.kind == brokerResult { + // A helper refused before source admission. A successful pixel result + // here would violate the handshake and must not be published. + _, err = readBrokerResult(r.input, header.size, op, r.limits) + var failure *heicdecode.Failure + if errors.As(err, &failure) { + return result, err + } + return result, heicdecode.ErrInvalidResponse + } + if header.kind != brokerGrant || header.size == 0 || header.size > uint64(r.limits.MaxInputBytes) { + return result, heicdecode.ErrInvalidResponse + } + if err = ctx.Err(); err != nil { + return result, err + } + data, err := input(ctx, int64(header.size)) + if err != nil { + return result, err + } + if err = ctx.Err(); err != nil { + return result, err + } + if len(data) == 0 || uint64(len(data)) > header.size { + return result, heicdecode.ErrInvalidRequest + } + if err = writeBrokerHeader(r.output, brokerHeader{kind: brokerInput, operation: op, size: uint64(len(data)) + 24}); err != nil { + return result, err + } + if err = heicdecode.WriteRequest(r.output, heicdecode.Request{Operation: op, Input: data}, r.limits); err != nil { + return result, err + } + header, err = readBrokerHeader(r.input) + if err != nil { + return result, err + } + if header.kind != brokerResult || header.operation != op { + return result, heicdecode.ErrInvalidResponse + } + return readBrokerResult(r.input, header.size, op, r.limits) +} + +// watchCancellation returns a join, including when a callback has already +// started. Close functions must interrupt pending pipe operations. +func watchCancellation(ctx context.Context, action func()) func() { + done := make(chan struct{}) + stop := context.AfterFunc(ctx, func() { defer close(done); action() }) + return func() { + if !stop() { + <-done + } + } +} diff --git a/internal/heicdecode/client/remote_test.go b/internal/heicdecode/client/remote_test.go new file mode 100644 index 00000000..5ce84aee --- /dev/null +++ b/internal/heicdecode/client/remote_test.go @@ -0,0 +1,312 @@ +package client + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "image" + "io" + "net" + "sync" + "testing" + "time" + + "github.com/frathe/picfetch/internal/heicdecode" +) + +func TestRemoteRepeatedRequestsShareOwner(t *testing.T) { + owner := ownedPeer(t, "success", 5*time.Second) + parent, child := net.Pipe() + remote, err := NewRemote(child, child, owner.config.Limits) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- owner.Serve(ctx, parent, parent) }() + t.Cleanup(func() { remote.Stop(); remote.Wait(); cancel(); <-done }) + reads := 0 + for range 2 { + result, decodeErr := remote.Do(ctx, heicdecode.Decode, func(_ context.Context, maxBytes int64) ([]byte, error) { + reads++ + if maxBytes != owner.config.Limits.MaxInputBytes { + t.Errorf("grant=%d", maxBytes) + } + return []byte("owned input"), nil + }) + if decodeErr != nil { + t.Fatal(decodeErr) + } + if _, ok := result.Image.(*image.NRGBA64); !ok { + t.Fatalf("pixel layout changed: %T", result.Image) + } + } + if reads != 2 { + t.Fatalf("source reads=%d", reads) + } +} + +func TestRemotePreAdmissionRefusalDoesNotReadSource(t *testing.T) { + peer := ownedPeer(t, "success", 5*time.Second) + config := peer.config + config.SHA256[0] ^= 1 + owner, err := New(config) + if err != nil { + t.Fatal(err) + } + defer func() { owner.Stop(); owner.Wait() }() + parent, child := net.Pipe() + remote, err := NewRemote(child, child, config.Limits) + if err != nil { + t.Fatal(err) + } + defer func() { remote.Stop(); remote.Wait() }() + done := make(chan error, 1) + go func() { done <- owner.Serve(context.Background(), parent, parent) }() + read := false + _, err = remote.Do(context.Background(), heicdecode.Decode, func(_ context.Context, _ int64) ([]byte, error) { read = true; return []byte{1}, nil }) + var failure *heicdecode.Failure + if read || !errors.As(err, &failure) || failure.Status != heicdecode.StatusUnavailable { + t.Fatalf("read=%v refusal=%v", read, err) + } + if err = <-done; !errors.Is(err, io.EOF) { + t.Fatalf("refused connection not retired: %v", err) + } +} + +func TestBrokerResultRejectsOversizedTruncatedAndTrailingFrames(t *testing.T) { + limits := heicdecode.DefaultLimits(0) + var encoded bytes.Buffer + result := heicdecode.Response{Image: image.NewNRGBA64(image.Rect(0, 0, 1, 1))} + if err := heicdecode.WriteResponse(&encoded, heicdecode.Decode, result, limits); err != nil { + t.Fatal(err) + } + data := encoded.Bytes() + if _, err := readBrokerResult(bytes.NewReader(data), uint64(len(data)), heicdecode.Decode, limits); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name string + size uint64 + data []byte + }{ + {"oversized", ^uint64(0), nil}, + {"missing frame byte", uint64(len(data) + 1), data}, + {"short payload", uint64(len(data)), data[:len(data)-1]}, + {"trailing payload", uint64(len(data) + 1), append(bytes.Clone(data), 0)}, + } { + t.Run(tc.name, func(t *testing.T) { + if result, err := readBrokerResult(bytes.NewReader(tc.data), tc.size, heicdecode.Decode, limits); err == nil || result.Image != nil { + t.Fatal("invalid frame published pixels") + } + }) + } +} + +func TestRemoteRejectsGrantBeforeReadingSource(t *testing.T) { + limits := heicdecode.DefaultLimits(0) + for _, size := range []uint64{0, uint64(limits.MaxInputBytes) + 1, ^uint64(0)} { + parent, child := net.Pipe() + remote, err := NewRemote(child, child, limits) + if err != nil { + t.Fatal(err) + } + done := make(chan struct{}) + go func() { + defer close(done) + defer func() { _ = parent.Close() }() + if _, err := readBrokerHeader(parent); err == nil { + _ = writeBrokerHeader(parent, brokerHeader{kind: brokerGrant, operation: heicdecode.Decode, size: size}) + } + }() + read := false + _, err = remote.Do(context.Background(), heicdecode.Decode, func(_ context.Context, _ int64) ([]byte, error) { read = true; return []byte{1}, nil }) + remote.Stop() + remote.Wait() + <-done + if read || !errors.Is(err, heicdecode.ErrInvalidResponse) { + t.Fatalf("grant=%d read=%v error=%v", size, read, err) + } + } +} + +// Hold output inside a Write that Close can interrupt, just like a full pipe. +// The result header arrives only after helper cleanup and pixel validation. +type heldResultWriter struct { + io.WriteCloser + blocked, closed chan struct{} + once sync.Once +} + +func (w *heldResultWriter) Write(p []byte) (int, error) { + if len(p) == 16 && string(p[:4]) == "PHB1" && binary.LittleEndian.Uint16(p[4:]) == brokerResult { + close(w.blocked) + <-w.closed + return 0, io.ErrClosedPipe + } + return w.WriteCloser.Write(p) +} + +func (w *heldResultWriter) Close() error { + w.once.Do(func() { close(w.closed) }) + return w.WriteCloser.Close() +} + +func TestRemoteOutputRetainsAdmissionAndStopJoins(t *testing.T) { + owner := ownedPeer(t, "success", 5*time.Second) + parent, child := net.Pipe() + held := &heldResultWriter{WriteCloser: parent, blocked: make(chan struct{}), closed: make(chan struct{})} + remote, err := NewRemote(child, child, owner.config.Limits) + if err != nil { + t.Fatal(err) + } + serveDone, remoteDone := make(chan error, 1), make(chan error, 1) + go func() { serveDone <- owner.Serve(context.Background(), parent, held) }() + go func() { + _, err := remote.Do(context.Background(), heicdecode.Decode, func(_ context.Context, _ int64) ([]byte, error) { return []byte{1}, nil }) + remoteDone <- err + }() + defer func() { + owner.Stop() + owner.Wait() + remote.Stop() + remote.Wait() + <-serveDone + <-remoteDone + }() + select { + case <-held.blocked: + case <-time.After(5 * time.Second): + t.Fatal("result did not reach output") + } + // This enters the same lane used by foreground Do, synchronously; it + // cannot acquire a grant while the remote output is blocked. + next := owner.lane.enter(context.Background(), Foreground) + defer next.release() + select { + case <-next.ready: + t.Fatal("output escaped the shared admission bound") + default: + } + owner.Stop() + owner.Wait() + select { + case <-next.ready: + default: + t.Fatal("stopped output retained its grant") + } +} + +type readObserver struct { + io.ReadCloser + once sync.Once + started chan struct{} +} + +func (r *readObserver) Read(p []byte) (int, error) { + r.once.Do(func() { close(r.started) }) + return r.ReadCloser.Read(p) +} + +func TestBrokerBoundsIdleConnectionsAndJoinsStop(t *testing.T) { + owner := ownedPeer(t, "success", 5*time.Second) + var completions []chan error + var peers []net.Conn + defer func() { + for _, peer := range peers { + _ = peer.Close() + } + }() + for range maxServices { + parent, child := net.Pipe() + peers = append(peers, child) + reader := &readObserver{ReadCloser: parent, started: make(chan struct{})} + done := make(chan error, 1) + completions = append(completions, done) + go func() { done <- owner.Serve(context.Background(), reader, parent) }() + <-reader.started + } + parent, child := net.Pipe() + defer func() { _ = child.Close() }() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := owner.Serve(ctx, parent, parent); !errors.Is(err, ErrBusy) { + t.Fatalf("excess connection: %v", err) + } + owner.Stop() + owner.Wait() + for _, done := range completions { + <-done + } +} + +type intentObserver struct { + io.WriteCloser + once sync.Once + written chan struct{} +} + +func (w *intentObserver) Write(p []byte) (int, error) { + n, err := w.WriteCloser.Write(p) + w.once.Do(func() { close(w.written) }) + return n, err +} + +func TestRemoteQueuedCancellationReleasesService(t *testing.T) { + owner := ownedPeer(t, "success", 5*time.Second) + localCtx, cancelLocal := context.WithCancel(context.Background()) + entered, localDone := make(chan struct{}), make(chan error, 1) + go func() { + _, err := owner.Do(localCtx, heicdecode.Decode, func(ctx context.Context, _ int64) ([]byte, error) { + close(entered) + <-ctx.Done() + return nil, ctx.Err() + }) + localDone <- err + }() + t.Cleanup(func() { cancelLocal(); <-localDone }) + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("local request not admitted") + } + parent, child := net.Pipe() + observer := &intentObserver{WriteCloser: child, written: make(chan struct{})} + remote, err := NewRemote(child, observer, owner.config.Limits) + if err != nil { + t.Fatal(err) + } + serveCtx, stopServe := context.WithCancel(context.Background()) + serveDone := make(chan error, 1) + go func() { serveDone <- owner.Serve(serveCtx, parent, parent) }() + var serveJoined bool + t.Cleanup(func() { + remote.Stop() + remote.Wait() + stopServe() + if !serveJoined { + <-serveDone + } + }) + remoteCtx, cancelRemote := context.WithCancel(context.Background()) + remoteDone := make(chan error, 1) + go func() { + _, err := remote.Do(remoteCtx, heicdecode.Decode, func(_ context.Context, _ int64) ([]byte, error) { + t.Error("queued source was read before owner released its grant") + return []byte("owned input"), nil + }) + remoteDone <- err + }() + <-observer.written + cancelRemote() + if err = <-remoteDone; !errors.Is(err, context.Canceled) { + t.Fatalf("remote cancellation: %v", err) + } + select { + case <-serveDone: + serveJoined = true + case <-time.After(time.Second): + t.Fatal("disconnected queued service still waits for the foreground grant") + } +} diff --git a/internal/heicdecode/client/staging.go b/internal/heicdecode/client/staging.go new file mode 100644 index 00000000..20ae3be4 --- /dev/null +++ b/internal/heicdecode/client/staging.go @@ -0,0 +1,142 @@ +package client + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "runtime" +) + +const stagedExecutable = "picfetch-heic-worker.exe" + +// stageHelper runs while the caller holds the private cache's publication lock. +// Preparation operates on owned copies only, before their directory is published. +func stageHelper(ctx context.Context, source string, digest [32]byte, root string, prepare func(string) error) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + directory := filepath.Join(root, runtime.GOARCH+"-"+hex.EncodeToString(digest[:])) + target := filepath.Join(directory, stagedExecutable) + if info, err := os.Lstat(directory); err == nil { + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return "", ErrUnavailable + } + if err = verifyStaged(ctx, target, digest); err == nil { + if err = prepare(target); err != nil { + return "", err + } + return target, ctx.Err() + } + if ctx.Err() != nil { + return "", ctx.Err() + } + // A Windows lease prevents removing any copy used by another app instance. + // Remove only the known file and empty directory; never recurse into a cache entry. + if err = removeStaged(directory); err != nil { + return "", err + } + } else if !errors.Is(err, os.ErrNotExist) { + return "", err + } + temporary, err := os.MkdirTemp(root, ".stage-") + if err != nil { + return "", err + } + defer func() { _ = removeStaged(temporary) }() + copied := filepath.Join(temporary, stagedExecutable) + if err = copyStaged(ctx, source, copied); err != nil { + return "", err + } + if err = verifyStaged(ctx, copied, digest); err != nil { + return "", err + } + if err = prepare(copied); err != nil { + return "", err + } + if err = ctx.Err(); err != nil { + return "", err + } + if err = os.Rename(temporary, directory); err != nil { + return "", err + } + return target, nil +} + +func removeStaged(directory string) error { + if err := os.Remove(filepath.Join(directory, stagedExecutable)); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return os.Remove(directory) +} + +func copyStaged(ctx context.Context, source, destination string) error { + input, err := os.Open(source) + if err != nil { + return err + } + defer func() { _ = input.Close() }() + info, err := input.Stat() + if err != nil { + return err + } + if !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > 64*1024*1024 { + return ErrUnavailable + } + output, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0700) + if err != nil { + return err + } + defer func() { _ = output.Close() }() + n, err := io.Copy(output, io.LimitReader(stagingReader{ctx, input}, 64*1024*1024+1)) + if err != nil { + return err + } + if n != info.Size() { + return fmt.Errorf("%w: packaged helper size changed", ErrUnavailable) + } + if err = output.Sync(); err != nil { + return err + } + return output.Close() +} + +func verifyStaged(ctx context.Context, path string, digest [32]byte) error { + info, err := os.Lstat(path) + if err != nil { + return err + } + if !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > 64*1024*1024 { + return ErrUnavailable + } + input, err := os.Open(path) + if err != nil { + return err + } + defer func() { _ = input.Close() }() + hash := sha256.New() + n, err := io.Copy(hash, io.LimitReader(stagingReader{ctx, input}, 64*1024*1024+1)) + if err != nil { + return err + } + if n != info.Size() || string(hash.Sum(nil)) != string(digest[:]) { + return fmt.Errorf("%w: staged helper identity", ErrUnavailable) + } + return ctx.Err() +} + +type stagingReader struct { + ctx context.Context + reader io.Reader +} + +func (r stagingReader) Read(p []byte) (int, error) { + if err := r.ctx.Err(); err != nil { + return 0, err + } + return r.reader.Read(p) +} diff --git a/internal/heicdecode/limits.go b/internal/heicdecode/limits.go new file mode 100644 index 00000000..e70249a8 --- /dev/null +++ b/internal/heicdecode/limits.go @@ -0,0 +1,75 @@ +// Package heicdecode defines PicFetch's fail-closed boundary to the isolated +// HEIC/HEIF decoder helper. It contains no codec or rich container parser. +package heicdecode + +import ( + "errors" + "fmt" + "time" +) + +const ( + hardMaxInputBytes int64 = 64 * 1024 * 1024 + hardMaxPixels int64 = 64_000_000 + hardMaxOutputBytes = hardMaxPixels * 4 + hardMaxMetadataBytes = 64 * 1024 + hardMaxDiagnosticBytes = 4096 + hardMaxDuration = time.Minute +) + +var ErrInvalidLimits = errors.New("invalid HEIC decoder limits") + +// Limits is the complete finite resource contract for one helper request. +// WASMMemoryBytes is an enforced linear-memory ceiling. OSProcessBytes is the +// requested native-process budget; Ready separately reports the hard OS memory +// control actually installed. macOS explicitly permits an absent hard native +// cap, while retaining bounded WASM and transport and verified OS sandboxing. +type Limits struct { + Timeout time.Duration + WASMMemoryBytes int64 + OSProcessBytes int64 + MaxInputBytes int64 + MaxPixels int64 + MaxOutputBytes int64 + MaxMetadataBytes uint32 + MaxDiagnosticBytes uint32 + MaxThreads uint32 // Decoder threads; the native Go host has its own finite thread ceiling. + MaxLiveJobs uint32 +} + +// DefaultLimits returns the initial engineering limits. A positive user input +// ceiling can only lower the hard HEIC ceiling, never raise it. +func DefaultLimits(userMaxInputBytes int64) Limits { + input := hardMaxInputBytes + if userMaxInputBytes > 0 && userMaxInputBytes < input { + input = userMaxInputBytes + } + return Limits{ + Timeout: hardMaxDuration, + WASMMemoryBytes: 1 * 1024 * 1024 * 1024, + OSProcessBytes: 2 * 1024 * 1024 * 1024, + MaxInputBytes: input, + MaxPixels: hardMaxPixels, + MaxOutputBytes: hardMaxOutputBytes, + MaxMetadataBytes: hardMaxMetadataBytes, + MaxDiagnosticBytes: hardMaxDiagnosticBytes, + MaxThreads: 1, + MaxLiveJobs: 1, + } +} + +// Validate rejects relaxed, absent, or internally inconsistent limits. +func (l Limits) Validate() error { + if l.Timeout <= 0 || l.Timeout > hardMaxDuration || + l.WASMMemoryBytes <= 0 || l.WASMMemoryBytes > 1*1024*1024*1024 || l.WASMMemoryBytes%(64*1024) != 0 || + l.OSProcessBytes <= 0 || l.OSProcessBytes > 2*1024*1024*1024 || l.WASMMemoryBytes > l.OSProcessBytes || + l.MaxInputBytes <= 0 || l.MaxInputBytes > hardMaxInputBytes || + l.MaxPixels <= 0 || l.MaxPixels > hardMaxPixels || + l.MaxOutputBytes <= 0 || l.MaxOutputBytes > hardMaxOutputBytes || + l.MaxMetadataBytes == 0 || l.MaxMetadataBytes > hardMaxMetadataBytes || + l.MaxDiagnosticBytes == 0 || l.MaxDiagnosticBytes > hardMaxDiagnosticBytes || + l.MaxThreads != 1 || l.MaxLiveJobs != 1 { + return fmt.Errorf("%w: every resource must have a supported finite ceiling", ErrInvalidLimits) + } + return nil +} diff --git a/internal/heicdecode/metadata.go b/internal/heicdecode/metadata.go new file mode 100644 index 00000000..8ff1ebbb --- /dev/null +++ b/internal/heicdecode/metadata.go @@ -0,0 +1,111 @@ +package heicdecode + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "math" + "time" + "unicode/utf8" +) + +// Metadata contains normalized values only, never embedded Exif/TIFF bytes. +// Orientation describes the Exif tag; container transforms are already applied +// by the guest. The integration must resolve their precedence before exposure. +type Metadata struct { + Orientation int + Make string + Model string + Software string + DateTime string + DateTimeOriginal string + ExposureTime float64 + FNumber float64 + ISOSpeed int + FocalLength float64 + Flash int + GPSLatitude float64 + GPSLongitude float64 + GPSAltitude float64 + Copyright string + Artist string +} + +func (m Metadata) validate() error { + for _, s := range []string{m.Make, m.Model, m.Software, m.DateTime, m.DateTimeOriginal, m.Copyright, m.Artist} { + if len(s) > 1024 || !utf8.ValidString(s) { + return errors.New("invalid metadata text") + } + } + for _, n := range []float64{m.ExposureTime, m.FNumber, m.FocalLength, m.GPSLatitude, m.GPSLongitude, m.GPSAltitude} { + if math.IsNaN(n) || math.IsInf(n, 0) { + return errors.New("non-finite metadata value") + } + } + for _, date := range []string{m.DateTime, m.DateTimeOriginal} { + if date != "" { + if _, err := time.Parse("2006:01:02 15:04:05", date); err != nil { + return errors.New("invalid metadata date") + } + } + } + if m.Orientation < 0 || m.Orientation > 8 || m.ExposureTime < 0 || m.FNumber < 0 || m.FocalLength < 0 || m.ISOSpeed < 0 || m.ISOSpeed > 65535 || m.Flash < 0 || m.Flash > 65535 || m.GPSLatitude < -90 || m.GPSLatitude > 90 || m.GPSLongitude < -180 || m.GPSLongitude > 180 { + return errors.New("metadata value outside its range") + } + return nil +} + +func readMetadata(data []byte) (*Metadata, error) { + if len(data) == 0 { + return nil, nil + } + if !utf8.Valid(data) { + return nil, errors.New("invalid metadata encoding") + } + // encoding/json accepts duplicate keys; reject them before decoding the fixed + // schema. Raw values remain bounded by the already checked metadata length. + d := json.NewDecoder(bytes.NewReader(data)) + token, err := d.Token() + if err != nil || token != json.Delim('{') { + return nil, errors.New("metadata must be an object") + } + seen := make(map[string]bool) + for d.More() { + token, err = d.Token() + if err != nil { + return nil, err + } + key, ok := token.(string) + if !ok || seen[key] { + return nil, errors.New("duplicate metadata field") + } + switch key { + case "Orientation", "Make", "Model", "Software", "DateTime", "DateTimeOriginal", "ExposureTime", "FNumber", "ISOSpeed", "FocalLength", "Flash", "GPSLatitude", "GPSLongitude", "GPSAltitude", "Copyright", "Artist": + default: + return nil, errors.New("unknown metadata field") + } + seen[key] = true + var value json.RawMessage + if err = d.Decode(&value); err != nil { + return nil, err + } + if bytes.Equal(bytes.TrimSpace(value), []byte("null")) { + return nil, errors.New("null metadata field") + } + } + var m Metadata + d = json.NewDecoder(bytes.NewReader(data)) + d.DisallowUnknownFields() + if err = d.Decode(&m); err != nil { + return nil, err + } + var extra any + if err = d.Decode(&extra); !errors.Is(err, io.EOF) { + return nil, errors.New("trailing metadata") + } + if err = m.validate(); err != nil { + return nil, err + } + return &m, nil +} diff --git a/internal/heicdecode/metadata_test.go b/internal/heicdecode/metadata_test.go new file mode 100644 index 00000000..58ccb087 --- /dev/null +++ b/internal/heicdecode/metadata_test.go @@ -0,0 +1,18 @@ +package heicdecode + +import "testing" + +func TestMetadataRejectsUnvalidatedFields(t *testing.T) { + for _, data := range []string{ + `{"Orientation":9}`, `{"GPSLatitude":91}`, `{"GPSLongitude":-181}`, `{"FNumber":-1}`, + `{"DateTime":"yesterday"}`, `{"Make":null}`, `{"Orientation":1,"orientation":8}`, `{"Unknown":true}`, `{"Orientation":1,"Orientation":8}`, `{"Make":"ok"} {}`, + } { + if _, err := readMetadata([]byte(data)); err == nil { + t.Fatalf("accepted %s", data) + } + } + got, err := readMetadata([]byte(`{"Orientation":6,"GPSLatitude":0,"GPSLongitude":0,"DateTimeOriginal":"2026:09:15 12:30:00","Make":"Camera"}`)) + if err != nil || got == nil || got.Orientation != 6 || got.Make != "Camera" { + t.Fatalf("metadata = %+v, %v", got, err) + } +} diff --git a/internal/heicdecode/protocol.go b/internal/heicdecode/protocol.go new file mode 100644 index 00000000..08780792 --- /dev/null +++ b/internal/heicdecode/protocol.go @@ -0,0 +1,266 @@ +package heicdecode + +import ( + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "image" + "image/color" + "io" + "unicode/utf8" +) + +const ( + responseMagic = "PHR2" + protocolVersion = 2 + statusOK = 0 + pixelFormatRGBA8 = 1 // Straight alpha; v1's premultiplied interpretation was never shipped. + pixelFormatNRGBA64 = 2 + responseHeaderBytes = 40 +) + +var ErrInvalidResponse = errors.New("invalid HEIC helper response") + +// Status classifies a bounded worker refusal, independently of transport errors. +type Status uint16 + +// Worker statuses include values used only by the separate WASI guest module. +// noinspection GoUnusedConst +const ( + StatusRejected Status = 1 + iota + StatusUnsupported + StatusResourceLimit + StatusInternal + StatusUnavailable +) + +// Failure is a validated worker refusal. It does not imply an OS memory control +// was installed or identify a process's termination cause. +type Failure struct { + Status Status + Diagnostic string +} + +func (f *Failure) Error() string { + return fmt.Sprintf("HEIC worker status %d: %s", f.Status, f.Diagnostic) +} + +// Response holds only fully validated pixels and normalized metadata. +type Response struct { + Image image.Image + Config image.Config + Metadata *Metadata +} + +type responseHeader struct { + status Status + width, height, stride uint64 + depth, format uint16 + pixels uint64 + metadata, diagnostic uint32 +} + +func parseResponseHeader(b []byte) (responseHeader, error) { + if string(b[:4]) != responseMagic || binary.LittleEndian.Uint16(b[4:6]) != protocolVersion { + return responseHeader{}, invalidResponse("magic or protocol version", nil) + } + return responseHeader{ + status: Status(binary.LittleEndian.Uint16(b[6:8])), width: uint64(binary.LittleEndian.Uint32(b[8:12])), height: uint64(binary.LittleEndian.Uint32(b[12:16])), stride: uint64(binary.LittleEndian.Uint32(b[16:20])), depth: binary.LittleEndian.Uint16(b[20:22]), format: binary.LittleEndian.Uint16(b[22:24]), pixels: binary.LittleEndian.Uint64(b[24:32]), metadata: binary.LittleEndian.Uint32(b[32:36]), diagnostic: binary.LittleEndian.Uint32(b[36:40]), + }, nil +} + +func (h responseHeader) bytes() []byte { + b := make([]byte, responseHeaderBytes) + copy(b, responseMagic) + binary.LittleEndian.PutUint16(b[4:6], protocolVersion) + binary.LittleEndian.PutUint16(b[6:8], uint16(h.status)) + binary.LittleEndian.PutUint32(b[8:12], uint32(h.width)) + binary.LittleEndian.PutUint32(b[12:16], uint32(h.height)) + binary.LittleEndian.PutUint32(b[16:20], uint32(h.stride)) + binary.LittleEndian.PutUint16(b[20:22], h.depth) + binary.LittleEndian.PutUint16(b[22:24], h.format) + binary.LittleEndian.PutUint64(b[24:32], h.pixels) + binary.LittleEndian.PutUint32(b[32:36], h.metadata) + binary.LittleEndian.PutUint32(b[36:40], h.diagnostic) + return b +} + +func (h responseHeader) validate(op Operation, l Limits) error { + if !op.valid() || h.status > StatusUnavailable { + return invalidResponse("operation or status", nil) + } + if h.metadata > l.MaxMetadataBytes || h.diagnostic > l.MaxDiagnosticBytes { + return invalidResponse("bounded fields", nil) + } + if h.status != statusOK || op == DecodeExif { + if h.width != 0 || h.height != 0 || h.stride != 0 || h.depth != 0 || h.format != 0 || h.pixels != 0 || (h.status != statusOK && h.metadata != 0) || (h.status == statusOK && h.diagnostic != 0) { + return invalidResponse("non-pixel layout", nil) + } + return nil + } + if h.width == 0 || h.height == 0 || h.width > uint64(l.MaxPixels) || h.height > uint64(l.MaxPixels) || h.width*h.height > uint64(l.MaxPixels) { + return invalidResponse("dimensions", nil) + } + var size uint64 + switch { + case h.format == pixelFormatRGBA8 && h.depth == 8: + size = 4 + case h.format == pixelFormatNRGBA64 && h.depth == 16: + size = 8 + default: + return invalidResponse("pixel format or depth", nil) + } + // Width and height are bounded above by 64M before either product. + if h.stride != h.width*size || h.stride*h.height > uint64(l.MaxOutputBytes) || h.diagnostic != 0 { + return invalidResponse("pixel layout", nil) + } + if (op == Decode && h.pixels != h.stride*h.height) || (op == DecodeConfig && h.pixels != 0) { + return invalidResponse("pixel length", nil) + } + return nil +} + +// ReadResponse checks every field before allocation and requires EOF before +// returning any image. The launcher owns cancellation/deadline and pipe closure. +func ReadResponse(r io.Reader, op Operation, limits Limits) (Response, error) { + if err := limits.Validate(); err != nil { + return Response{}, err + } + var b [responseHeaderBytes]byte + if _, err := io.ReadFull(r, b[:]); err != nil { + return Response{}, invalidResponse("header", err) + } + h, err := parseResponseHeader(b[:]) + if err != nil { + return Response{}, err + } + if err = h.validate(op, limits); err != nil { + return Response{}, err + } + // All three lengths are independently bounded; their sum fits a 32-bit int. + payload := make([]byte, int(h.pixels)+int(h.metadata)+int(h.diagnostic)) + if _, err = io.ReadFull(r, payload); err != nil { + return Response{}, invalidResponse("payload", err) + } + if err = requireEOF(r); err != nil { + return Response{}, invalidResponse("trailing data", err) + } + if h.status != statusOK { + if !utf8.Valid(payload) { + return Response{}, invalidResponse("diagnostic encoding", nil) + } + return Response{}, &Failure{Status: h.status, Diagnostic: string(payload)} + } + metadata, err := readMetadata(payload[int(h.pixels):]) + if err != nil { + return Response{}, invalidResponse("metadata", err) + } + result := Response{Metadata: metadata} + if op == DecodeExif { + return result, nil + } + model := color.NRGBAModel + if h.depth == 16 { + model = color.NRGBA64Model + } + result.Config = image.Config{Width: int(h.width), Height: int(h.height), ColorModel: model} + if op == Decode { + pix := payload[:int(h.pixels):int(h.pixels)] + rect := image.Rect(0, 0, int(h.width), int(h.height)) + if h.depth == 16 { + result.Image = &image.NRGBA64{Pix: pix, Stride: int(h.stride), Rect: rect} + } else { + result.Image = &image.NRGBA{Pix: pix, Stride: int(h.stride), Rect: rect} + } + } + return result, nil +} + +// WriteResponse streams canonical straight-alpha pixels after checking the +// same header invariants as the reader. It never encodes a second image format. +func WriteResponse(w io.Writer, op Operation, result Response, limits Limits) error { + if err := limits.Validate(); err != nil { + return err + } + h := responseHeader{} + var pixels, metadata []byte + if result.Metadata != nil { + if err := result.Metadata.validate(); err != nil { + return err + } + var err error + metadata, err = json.Marshal(result.Metadata) + if err != nil { + return err + } + h.metadata = uint32(len(metadata)) + } + if op == Decode { + switch img := result.Image.(type) { + case *image.NRGBA: + if img == nil { + return invalidResponse("nil image", nil) + } + h.width, h.height, h.stride = uint64(img.Rect.Dx()), uint64(img.Rect.Dy()), uint64(img.Stride) + h.depth, h.format = 8, pixelFormatRGBA8 + pixels = img.Pix + case *image.NRGBA64: + if img == nil { + return invalidResponse("nil image", nil) + } + h.width, h.height, h.stride = uint64(img.Rect.Dx()), uint64(img.Rect.Dy()), uint64(img.Stride) + h.depth, h.format = 16, pixelFormatNRGBA64 + pixels = img.Pix + default: + return invalidResponse("image type", nil) + } + if result.Image.Bounds().Min != (image.Point{}) { + return invalidResponse("image origin", nil) + } + h.pixels = uint64(len(pixels)) + } else if op == DecodeConfig { + h.width, h.height = uint64(result.Config.Width), uint64(result.Config.Height) + switch result.Config.ColorModel { + case color.NRGBAModel: + h.depth, h.format, h.stride = 8, pixelFormatRGBA8, h.width*4 + case color.NRGBA64Model: + h.depth, h.format, h.stride = 16, pixelFormatNRGBA64, h.width*8 + default: + return invalidResponse("config color model", nil) + } + } + if err := h.validate(op, limits); err != nil { + return err + } + for _, p := range [][]byte{h.bytes(), pixels, metadata} { + if len(p) > 0 { + if err := writeAll(w, p); err != nil { + return err + } + } + } + return nil +} + +// WriteFailure emits a fixed-schema refusal without image or metadata content. +func WriteFailure(w io.Writer, status Status, diagnostic string, limits Limits) error { + if err := limits.Validate(); err != nil { + return err + } + if status < StatusRejected || status > StatusUnavailable || !utf8.ValidString(diagnostic) || len(diagnostic) > int(limits.MaxDiagnosticBytes) { + return invalidResponse("failure", nil) + } + h := responseHeader{status: status, diagnostic: uint32(len(diagnostic))} + if err := writeAll(w, h.bytes()); err != nil { + return err + } + return writeAll(w, []byte(diagnostic)) +} + +func invalidResponse(field string, cause error) error { + if cause != nil { + return fmt.Errorf("%w: %s: %v", ErrInvalidResponse, field, cause) + } + return fmt.Errorf("%w: %s", ErrInvalidResponse, field) +} diff --git a/internal/heicdecode/protocol_test.go b/internal/heicdecode/protocol_test.go new file mode 100644 index 00000000..74927ad6 --- /dev/null +++ b/internal/heicdecode/protocol_test.go @@ -0,0 +1,155 @@ +package heicdecode + +import ( + "bytes" + "encoding/binary" + "errors" + "image" + "testing" + "time" +) + +func TestLimitsValidate(t *testing.T) { + valid := DefaultLimits(32 * 1024 * 1024) + valid.Timeout = time.Minute + if err := valid.Validate(); err != nil { + t.Fatalf("default limits: %v", err) + } + + tests := []struct { + name string + mutate func(*Limits) + }{ + {"zero timeout", func(l *Limits) { l.Timeout = 0 }}, + {"timeout over hard ceiling", func(l *Limits) { l.Timeout = time.Minute + time.Nanosecond }}, + {"fractional WASM page", func(l *Limits) { l.WASMMemoryBytes = 1 }}, + {"guest exceeds OS budget", func(l *Limits) { l.OSProcessBytes = l.WASMMemoryBytes - 1 }}, + {"input over hard ceiling", func(l *Limits) { l.MaxInputBytes = hardMaxInputBytes + 1 }}, + {"zero pixels", func(l *Limits) { l.MaxPixels = 0 }}, + {"output too large", func(l *Limits) { l.MaxOutputBytes = hardMaxOutputBytes + 1 }}, + {"metadata too large", func(l *Limits) { l.MaxMetadataBytes = hardMaxMetadataBytes + 1 }}, + {"diagnostic too large", func(l *Limits) { l.MaxDiagnosticBytes = hardMaxDiagnosticBytes + 1 }}, + {"wrong live jobs", func(l *Limits) { l.MaxLiveJobs = 2 }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + limits := valid + tt.mutate(&limits) + if err := limits.Validate(); !errors.Is(err, ErrInvalidLimits) { + t.Fatalf("Validate() error = %v, want ErrInvalidLimits", err) + } + }) + } +} + +func TestDecodeResponseRejectsInvalidMessages(t *testing.T) { + limits := DefaultLimits(1024) + valid := responseBytes(t, 2, 1, pixelFormatRGBA8, 8, []byte{1, 2, 3, 4, 5, 6, 7, 8}, nil, nil) + + tests := []struct { + name string + data []byte + }{ + {"truncated", valid[:len(valid)-1]}, + {"trailing", append(append([]byte(nil), valid...), 0)}, + {"unknown version", replaceUint16(valid, 4, protocolVersion+1)}, + {"unknown status", replaceUint16(valid, 6, 99)}, + {"zero width", replaceUint32(valid, 8, 0)}, + {"bad stride", replaceUint32(valid, 16, 3)}, + {"unsupported bit depth", replaceUint16(valid, 20, 10)}, + {"unknown pixel format", replaceUint16(valid, 22, 99)}, + {"pixel length mismatch", replaceUint64(valid, 24, 7)}, + {"metadata over limit", responseBytes(t, 2, 1, pixelFormatRGBA8, 8, make([]byte, 8), make([]byte, limits.MaxMetadataBytes+1), nil)}, + {"diagnostic over limit", responseBytes(t, 2, 1, pixelFormatRGBA8, 8, make([]byte, 8), nil, make([]byte, limits.MaxDiagnosticBytes+1))}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := ReadResponse(bytes.NewReader(tt.data), Decode, limits); !errors.Is(err, ErrInvalidResponse) { + t.Fatalf("decodeResponse() error = %v, want ErrInvalidResponse", err) + } + }) + } +} + +func TestDefaultLimitsClampUserInput(t *testing.T) { + if got := DefaultLimits(0).MaxInputBytes; got != hardMaxInputBytes { + t.Fatalf("zero user limit = %d, want %d", got, hardMaxInputBytes) + } + if got := DefaultLimits(512 * 1024 * 1024).MaxInputBytes; got != hardMaxInputBytes { + t.Fatalf("large user limit = %d, want %d", got, hardMaxInputBytes) + } + if got := DefaultLimits(7).MaxInputBytes; got != 7 { + t.Fatalf("small user limit = %d, want 7", got) + } + if got := DefaultLimits(7).Timeout; got != time.Minute { + t.Fatalf("timeout = %v, want 60s", got) + } +} + +func responseBytes(t *testing.T, width, height uint32, format, depth uint16, pixels, metadata, diagnostic []byte) []byte { + t.Helper() + var out bytes.Buffer + out.WriteString(responseMagic) + values := []any{uint16(protocolVersion), uint16(statusOK), width, height, width * 4, depth, format, + uint64(len(pixels)), uint32(len(metadata)), uint32(len(diagnostic))} + for _, value := range values { + if err := binary.Write(&out, binary.LittleEndian, value); err != nil { + t.Fatal(err) + } + } + out.Write(pixels) + out.Write(metadata) + out.Write(diagnostic) + return out.Bytes() +} + +func replaceUint16(data []byte, offset int, value uint16) []byte { + out := append([]byte(nil), data...) + binary.LittleEndian.PutUint16(out[offset:offset+2], value) + return out +} + +func replaceUint32(data []byte, offset int, value uint32) []byte { + out := append([]byte(nil), data...) + binary.LittleEndian.PutUint32(out[offset:offset+4], value) + return out +} + +func replaceUint64(data []byte, offset int, value uint64) []byte { + out := append([]byte(nil), data...) + binary.LittleEndian.PutUint64(out[offset:offset+8], value) + return out +} + +func TestDecodeResponsePreservesStraightAlphaAndSixteenBitSamples(t *testing.T) { + for _, tt := range []struct { + name string + format uint16 + depth uint16 + pixels []byte + }{ + {"straight alpha", pixelFormatRGBA8, 8, []byte{255, 0, 0, 128}}, + {"sixteen bit", 2, 16, []byte{0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xff}}, + } { + t.Run(tt.name, func(t *testing.T) { + data := responseBytes(t, 1, 1, tt.format, tt.depth, tt.pixels, nil, nil) + binary.LittleEndian.PutUint32(data[16:20], uint32(len(tt.pixels))) + got, err := ReadResponse(bytes.NewReader(data), Decode, DefaultLimits(0)) + if err != nil { + t.Fatal(err) + } + switch img := any(got.Image).(type) { + case *image.NRGBA: + if !bytes.Equal(img.Pix, tt.pixels) { + t.Fatal("straight alpha samples changed") + } + case *image.NRGBA64: + if !bytes.Equal(img.Pix, tt.pixels) { + t.Fatal("sixteen-bit samples changed") + } + default: + t.Fatalf("decoded image type %T loses straight alpha or precision", got.Image) + } + }) + } +} diff --git a/internal/heicdecode/ready.go b/internal/heicdecode/ready.go new file mode 100644 index 00000000..41e9d6ee --- /dev/null +++ b/internal/heicdecode/ready.go @@ -0,0 +1,47 @@ +package heicdecode + +import ( + "encoding/binary" + "errors" + "io" +) + +// Ready is sent before any image input. File and network denial must have been +// observed by native probes. NativeMemoryBytes is zero when no hard OS memory +// ceiling was installed; macOS permits that explicitly approved limitation. +type Ready struct { + WASMMemoryBytes int64 + NativeMemoryBytes int64 +} + +// WriteReady publishes a fixed-size startup acknowledgement. The worker calls +// it only after platform policy and actual capability checks have succeeded. +func WriteReady(w io.Writer, ready Ready) error { + if !ready.valid() { + return errors.New("invalid HEIC worker readiness") + } + var data [24]byte + copy(data[:8], "PHREADY2") + binary.LittleEndian.PutUint64(data[8:16], uint64(ready.WASMMemoryBytes)) + binary.LittleEndian.PutUint64(data[16:24], uint64(ready.NativeMemoryBytes)) + return writeAll(w, data[:]) +} + +// ReadReady reads only the startup frame; the later response follows on the +// same pipe. It never consumes image output or infers native OS memory bounds. +func ReadReady(r io.Reader) (Ready, error) { + var data [24]byte + if _, err := io.ReadFull(r, data[:]); err != nil { + return Ready{}, err + } + ready := Ready{WASMMemoryBytes: int64(binary.LittleEndian.Uint64(data[8:16])), NativeMemoryBytes: int64(binary.LittleEndian.Uint64(data[16:24]))} + if string(data[:8]) != "PHREADY2" || !ready.valid() { + return Ready{}, errors.New("invalid HEIC worker readiness") + } + return ready, nil +} + +func (r Ready) valid() bool { + return r.WASMMemoryBytes > 0 && r.WASMMemoryBytes <= 1024*1024*1024 && r.WASMMemoryBytes%(64*1024) == 0 && + (r.NativeMemoryBytes == 0 || r.NativeMemoryBytes >= r.WASMMemoryBytes && r.NativeMemoryBytes <= 2*1024*1024*1024) +} diff --git a/internal/heicdecode/ready_test.go b/internal/heicdecode/ready_test.go new file mode 100644 index 00000000..9550fdaa --- /dev/null +++ b/internal/heicdecode/ready_test.go @@ -0,0 +1,30 @@ +package heicdecode + +import ( + "bytes" + "encoding/binary" + "testing" +) + +func TestReadinessReportsActualMemoryControl(t *testing.T) { + for _, native := range []int64{0, 2 * 1024 * 1024 * 1024} { + var stream bytes.Buffer + want := Ready{WASMMemoryBytes: 1024 * 1024 * 1024, NativeMemoryBytes: native} + if err := WriteReady(&stream, want); err != nil { + t.Fatal(err) + } + stream.WriteString("following response") + got, err := ReadReady(&stream) + if err != nil || got != want || stream.String() != "following response" { + t.Fatalf("readiness = %+v, %v, remaining=%q", got, err, stream.String()) + } + } + for _, memory := range []uint64{0, 1, 1024*1024*1024 + 65536, 1 << 63} { + var frame [24]byte + copy(frame[:8], "PHREADY2") + binary.LittleEndian.PutUint64(frame[8:16], memory) + if _, err := ReadReady(bytes.NewReader(frame[:])); err == nil { + t.Fatalf("accepted invalid memory control %d", memory) + } + } +} diff --git a/internal/heicdecode/request.go b/internal/heicdecode/request.go new file mode 100644 index 00000000..1c43d91a --- /dev/null +++ b/internal/heicdecode/request.go @@ -0,0 +1,110 @@ +package heicdecode + +import ( + "encoding/binary" + "errors" + "fmt" + "io" +) + +// Operation identifies a single still-image operation. Sequences are unsupported. +type Operation uint16 + +const ( + Decode Operation = 1 + iota + DecodeConfig + DecodeExif + requestMagic = "PHQ2" + requestHeaderBytes = 24 +) + +var ErrInvalidRequest = errors.New("invalid HEIC helper request") + +// Request carries bytes only. A caller must acquire family-wide admission before +// reading Input. This framing package does not grant worker admission. +type Request struct { + Operation Operation + Input []byte + MaxPixels int64 + MaxOutputBytes int64 +} + +func (o Operation) valid() bool { return o >= Decode && o <= DecodeExif } + +// WriteRequest validates a bounded request before writing any bytes. +func WriteRequest(w io.Writer, request Request, limits Limits) error { + if err := limits.Validate(); err != nil { + return err + } + if request.MaxPixels == 0 { + request.MaxPixels = limits.MaxPixels + } + if request.MaxOutputBytes == 0 { + request.MaxOutputBytes = limits.MaxOutputBytes + } + if err := validateRequest(request.Operation, uint64(len(request.Input)), request.MaxPixels, request.MaxOutputBytes, limits); err != nil { + return err + } + var header [requestHeaderBytes]byte + copy(header[:4], requestMagic) + binary.LittleEndian.PutUint16(header[4:6], protocolVersion) + binary.LittleEndian.PutUint16(header[6:8], uint16(request.Operation)) + binary.LittleEndian.PutUint64(header[8:16], uint64(len(request.Input))) + binary.LittleEndian.PutUint32(header[16:20], uint32(request.MaxPixels)) + binary.LittleEndian.PutUint32(header[20:24], uint32(request.MaxOutputBytes)) + if err := writeAll(w, header[:]); err != nil { + return err + } + return writeAll(w, request.Input) +} + +// ReadRequest reads one request and requires EOF. The launcher must close stdin +// after writing and enforce the wall-clock deadline while this function reads. +func ReadRequest(r io.Reader, limits Limits) (Request, error) { + if err := limits.Validate(); err != nil { + return Request{}, err + } + var header [requestHeaderBytes]byte + if _, err := io.ReadFull(r, header[:]); err != nil { + return Request{}, fmt.Errorf("%w: header: %v", ErrInvalidRequest, err) + } + if string(header[:4]) != requestMagic || binary.LittleEndian.Uint16(header[4:6]) != protocolVersion { + return Request{}, ErrInvalidRequest + } + req := Request{Operation: Operation(binary.LittleEndian.Uint16(header[6:8])), MaxPixels: int64(binary.LittleEndian.Uint32(header[16:20])), MaxOutputBytes: int64(binary.LittleEndian.Uint32(header[20:24]))} + n := binary.LittleEndian.Uint64(header[8:16]) + if err := validateRequest(req.Operation, n, req.MaxPixels, req.MaxOutputBytes, limits); err != nil { + return Request{}, err + } + req.Input = make([]byte, int(n)) + if _, err := io.ReadFull(r, req.Input); err != nil { + return Request{}, fmt.Errorf("%w: input: %v", ErrInvalidRequest, err) + } + if err := requireEOF(r); err != nil { + return Request{}, fmt.Errorf("%w: trailing data: %v", ErrInvalidRequest, err) + } + return req, nil +} + +func validateRequest(op Operation, n uint64, pixels, output int64, limits Limits) error { + if !op.valid() || n == 0 || n > uint64(limits.MaxInputBytes) || pixels <= 0 || pixels > limits.MaxPixels || output <= 0 || output > limits.MaxOutputBytes { + return ErrInvalidRequest + } + return nil +} + +func requireEOF(r io.Reader) error { + var trailing [1]byte + if n, err := r.Read(trailing[:]); n != 0 || !errors.Is(err, io.EOF) { + return errors.New("expected EOF") + } + return nil +} + +func writeAll(w io.Writer, p []byte) error { + n, err := w.Write(p) + if err == nil && n != len(p) { + return io.ErrShortWrite + } + return err +} diff --git a/internal/heicdecode/request_test.go b/internal/heicdecode/request_test.go new file mode 100644 index 00000000..a7d788c5 --- /dev/null +++ b/internal/heicdecode/request_test.go @@ -0,0 +1,29 @@ +package heicdecode + +import ( + "bytes" + "errors" + "testing" +) + +func TestRequestFraming(t *testing.T) { + limits := DefaultLimits(4) + var encoded bytes.Buffer + if err := WriteRequest(&encoded, Request{Operation: Decode, Input: []byte("data")}, limits); err != nil { + t.Fatal(err) + } + valid := append([]byte(nil), encoded.Bytes()...) + got, err := ReadRequest(bytes.NewReader(valid), limits) + if err != nil || got.Operation != Decode || string(got.Input) != "data" { + t.Fatalf("round trip = %+v, %v", got, err) + } + for _, data := range [][]byte{valid[:len(valid)-1], append(append([]byte(nil), valid...), 0), replaceUint16(valid, 6, 99), replaceUint64(valid, 8, 5)} { + if _, err := ReadRequest(bytes.NewReader(data), limits); !errors.Is(err, ErrInvalidRequest) { + t.Fatalf("invalid request accepted: %v", err) + } + } + encoded.Reset() + if err := WriteRequest(&encoded, Request{Operation: Decode, Input: []byte("excess")}, limits); !errors.Is(err, ErrInvalidRequest) || encoded.Len() != 0 { + t.Fatalf("oversized request wrote %d bytes: %v", encoded.Len(), err) + } +} diff --git a/internal/heicdecode/response_test.go b/internal/heicdecode/response_test.go new file mode 100644 index 00000000..d743868c --- /dev/null +++ b/internal/heicdecode/response_test.go @@ -0,0 +1,39 @@ +package heicdecode + +import ( + "bytes" + "errors" + "image" + "image/color" + "testing" +) + +func TestResponseOperationsAndFailures(t *testing.T) { + limits := DefaultLimits(0) + for _, op := range []Operation{Decode, DecodeConfig, DecodeExif} { + result := Response{Metadata: &Metadata{Orientation: 6, Make: "Camera"}} + if op != DecodeExif { + result.Config = image.Config{Width: 1, Height: 1, ColorModel: color.NRGBA64Model} + } + if op == Decode { + result.Image = &image.NRGBA64{Pix: []byte{1, 2, 3, 4, 5, 6, 7, 8}, Stride: 8, Rect: image.Rect(0, 0, 1, 1)} + } + var encoded bytes.Buffer + if err := WriteResponse(&encoded, op, result, limits); err != nil { + t.Fatal(err) + } + got, err := ReadResponse(&encoded, op, limits) + if err != nil || got.Metadata.Make != "Camera" || (got.Image == nil) != (op != Decode) { + t.Fatalf("operation %d = %+v, %v", op, got, err) + } + } + var encoded bytes.Buffer + if err := WriteFailure(&encoded, StatusUnsupported, "unsupported", limits); err != nil { + t.Fatal(err) + } + _, err := ReadResponse(&encoded, Decode, limits) + var failure *Failure + if !errors.As(err, &failure) || failure.Status != StatusUnsupported { + t.Fatalf("failure = %v", err) + } +} diff --git a/internal/heicdecode/winisolation/native_windows_test.go b/internal/heicdecode/winisolation/native_windows_test.go new file mode 100644 index 00000000..02c8e890 --- /dev/null +++ b/internal/heicdecode/winisolation/native_windows_test.go @@ -0,0 +1,190 @@ +//go:build windows && (amd64 || arm64) + +package winisolation + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "testing" + "time" + + "golang.org/x/sys/windows" + + "github.com/frathe/picfetch/internal/heicdecode" +) + +// This process is PicFetch's owned native control, with no image or codec. +func TestMain(m *testing.M) { + if len(os.Args) == 2 && os.Args[1] == "--owned-exit" { + os.Exit(0) + } + if len(os.Args) == 3 && os.Args[1] == "--owned-windows-policy" { + var limits heicdecode.Limits + if json.Unmarshal([]byte(os.Args[2]), &limits) != nil { + os.Exit(2) + } + if err := ownedPolicyControl(limits); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(3) + } + _, _ = fmt.Fprintln(os.Stdout, "verified") + _, _ = io.Copy(io.Discard, os.Stdin) + os.Exit(0) + } + os.Exit(m.Run()) +} + +func ownedPolicyControl(limits heicdecode.Limits) error { + if err := Verify(limits); err != nil { + return err + } + if os.Getenv("PICFETCH_OWNED_PARENT_ENVIRONMENT") != "" { + return errors.New("parent environment reached the helper") + } + windowsDirectory, err := windows.GetWindowsDirectory() + if err != nil { + return err + } + if os.Getenv("SystemRoot") != windowsDirectory { + return errors.New("helper SystemRoot does not match Windows directory") + } + if os.Getenv("LOCALAPPDATA") == "" { + return errors.New("helper has no AppContainer profile directory") + } + // The request is capped at 65 MiB even if the tested restriction is broken. + // No page is touched; an unexpected success is freed immediately. + if limits.OSProcessBytes != 64*1024*1024 { + return errors.New("owned memory control requires its small fixed budget") + } + address, err := windows.VirtualAlloc(0, 65*1024*1024, windows.MEM_RESERVE|windows.MEM_COMMIT, windows.PAGE_READWRITE) + if address != 0 { + _ = windows.VirtualFree(address, 0, windows.MEM_RELEASE) + return errors.New("job accepted commitment beyond its memory ceiling") + } + if !errors.Is(err, windows.ERROR_COMMITMENT_LIMIT) && !errors.Is(err, windows.ERROR_NOT_ENOUGH_MEMORY) && !errors.Is(err, windows.ERROR_NOT_ENOUGH_QUOTA) { + return fmt.Errorf("unexpected commitment refusal: %w", err) + } + // A second copy of this inert control must be refused by the kernel. + child, err := os.StartProcess(os.Args[0], []string{os.Args[0], "--owned-exit"}, &os.ProcAttr{ + Files: []*os.File{os.Stdin, os.Stdout, os.Stderr}, + }) + if child != nil { + _ = child.Kill() + _, _ = child.Wait() + return errors.New("job admitted a second process") + } + if !errors.Is(err, os.ErrPermission) && !errors.Is(err, windows.ERROR_NOT_ENOUGH_QUOTA) && !errors.Is(err, windows.ERROR_CHILD_PROCESS_BLOCKED) { + return fmt.Errorf("unexpected child-process refusal: %w", err) + } + return nil +} + +func TestNativeWindowsJobAndToken(t *testing.T) { + t.Setenv("PICFETCH_OWNED_PARENT_ENVIRONMENT", "owned test sentinel") + source, err := os.Executable() + if err != nil { + t.Fatal(err) + } + directory := t.TempDir() + executable := filepath.Join(directory, "owned-policy.exe") + data, err := os.ReadFile(source) + if err != nil { + t.Fatal(err) + } + if err = os.WriteFile(executable, data, 0700); err != nil { + t.Fatal(err) + } + if err = PrepareExecutable(executable); err != nil { + t.Fatal(err) + } + input, feed, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer func() { _ = input.Close(); _ = feed.Close() }() + output, response, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer func() { _ = output.Close(); _ = response.Close() }() + diagnostic, warnings, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer func() { _ = diagnostic.Close(); _ = warnings.Close() }() + limits := heicdecode.DefaultLimits(0) + limits.Timeout = 10 * time.Second + limits.OSProcessBytes = 64 * 1024 * 1024 + limits.WASMMemoryBytes = 16 * 1024 * 1024 + encoded, err := json.Marshal(limits) + if err != nil { + t.Fatal(err) + } + process, err := Start(executable, []string{"--owned-windows-policy", string(encoded)}, [3]*os.File{input, response, warnings}, limits) + if err != nil { + t.Fatal(err) + } + defer func() { process.Kill(); _ = process.Wait() }() + deadline := time.AfterFunc(limits.Timeout, process.Kill) + defer deadline.Stop() + _ = input.Close() + _ = response.Close() + _ = warnings.Close() + _ = feed.Close() + actual, readErr := io.ReadAll(output) + detail, detailErr := io.ReadAll(diagnostic) + waitErr := process.Wait() + if readErr != nil || detailErr != nil || waitErr != nil || string(actual) != "verified\n" { + t.Fatalf("native policy: output=%q diagnostics=%q read=%v/%v wait=%v", actual, detail, readErr, detailErr, waitErr) + } +} + +func TestNativeWindowsUnsandboxedRefused(t *testing.T) { + if Verify(heicdecode.DefaultLimits(0)) == nil { + t.Fatal("ordinary test process accepted as isolated helper") + } +} + +func TestLoopbackExemptionRefusesHelperIdentity(t *testing.T) { + expected, err := containerSID(false) + if err != nil { + t.Fatal(err) + } + defer func() { _ = windows.FreeSid(expected) }() + other, err := windows.StringToSid("S-1-15-2-1") + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name string + entries []windows.SIDAndAttributes + rejected bool + }{ + {"no exemptions", nil, false}, + {"unrelated identity", []windows.SIDAndAttributes{{Sid: other}}, false}, + {"helper identity", []windows.SIDAndAttributes{{Sid: other}, {Sid: expected}}, true}, + {"invalid identity", []windows.SIDAndAttributes{{Sid: nil}}, true}, + } { + t.Run(tc.name, func(t *testing.T) { + if err := validateLoopbackExemptions(expected, tc.entries); (err != nil) != tc.rejected { + t.Fatalf("loopback exemption policy: %v", err) + } + }) + } +} + +func TestLoopbackPermissionQueryFailureRefused(t *testing.T) { + if err := validateLoopbackQuery(uintptr(windows.ERROR_ACCESS_DENIED), 0, nil); !errors.Is(err, windows.ERROR_ACCESS_DENIED) { + t.Fatalf("permission-query failure accepted or obscured: %v", err) + } + if err := validateLoopbackQuery(0, 1, nil); err == nil { + t.Fatal("missing native query entries accepted") + } + if err := validateLoopbackQuery(0, 0, nil); err != nil { + t.Fatalf("successful empty native query rejected: %v", err) + } +} diff --git a/internal/heicdecode/winisolation/policy_windows.go b/internal/heicdecode/winisolation/policy_windows.go new file mode 100644 index 00000000..790fa270 --- /dev/null +++ b/internal/heicdecode/winisolation/policy_windows.go @@ -0,0 +1,174 @@ +//go:build windows && (amd64 || arm64) + +package winisolation + +import ( + "errors" + "fmt" + "runtime" + "time" + "unsafe" + + "golang.org/x/sys/windows" + + "github.com/frathe/picfetch/internal/heicdecode" +) + +// TOKEN_INFORMATION_CLASS values from the Windows SDK; x/sys does not expose +// these three declarations. https://learn.microsoft.com/windows/win32/api/winnt/ne-winnt-token_information_class +const ( + tokenIsAppContainer = 29 + tokenCapabilities = 30 + tokenAppContainerSID = 31 +) + +func jobLimits(limits heicdecode.Limits) windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION { + return windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{ + BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{ + PerJobUserTimeLimit: int64((limits.Timeout + 100*time.Nanosecond - 1) / (100 * time.Nanosecond)), // Windows counts 100-nanosecond units. + LimitFlags: windows.JOB_OBJECT_LIMIT_ACTIVE_PROCESS | windows.JOB_OBJECT_LIMIT_PROCESS_MEMORY | + windows.JOB_OBJECT_LIMIT_JOB_MEMORY | windows.JOB_OBJECT_LIMIT_JOB_TIME | windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + ActiveProcessLimit: 1, + }, + ProcessMemoryLimit: uintptr(limits.OSProcessBytes), JobMemoryLimit: uintptr(limits.OSProcessBytes), + } +} + +func verifyJob(job windows.Handle, limits heicdecode.Limits) error { + var actual windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION + // x/sys accepts a uintptr here, so its lazy DLL resolution cannot keep a + // Go stack address valid if the stack grows before the native call. + var pinned runtime.Pinner + pinned.Pin(&actual) + defer pinned.Unpin() + if err := windows.QueryInformationJobObject(job, windows.JobObjectExtendedLimitInformation, uintptr(unsafe.Pointer(&actual)), uint32(unsafe.Sizeof(actual)), nil); err != nil { + return fmt.Errorf("query HEIC job limits: %w", err) + } + wanted := jobLimits(limits) + if actual.BasicLimitInformation.LimitFlags != wanted.BasicLimitInformation.LimitFlags || + actual.BasicLimitInformation.ActiveProcessLimit != 1 || + actual.BasicLimitInformation.PerJobUserTimeLimit != wanted.BasicLimitInformation.PerJobUserTimeLimit || + actual.ProcessMemoryLimit != wanted.ProcessMemoryLimit || actual.JobMemoryLimit != wanted.JobMemoryLimit { + return errors.New("HEIC Job Object limits do not match the requested boundary") + } + return nil +} + +// Verify checks the current process's kernel token and immediate Job Object. +// The worker calls this before performing denial probes or reading any input. +func Verify(limits heicdecode.Limits) error { + if err := limits.Validate(); err != nil { + return err + } + if err := VerifyAppContainer(); err != nil { + return err + } + // A null handle queries the caller's immediate job even in a nested job. + return verifyJob(0, limits) +} + +// VerifyAppContainer checks the exact helper identity and zero capabilities. +// The launcher separately checks privileged loopback configuration before +// creation; the worker can recheck this token after its traffic probes. +func VerifyAppContainer() error { + token := windows.GetCurrentProcessToken() + var isContainer uint32 + var length uint32 + if err := windows.GetTokenInformation(token, tokenIsAppContainer, (*byte)(unsafe.Pointer(&isContainer)), uint32(unsafe.Sizeof(isContainer)), &length); err != nil { + return fmt.Errorf("query HEIC AppContainer token: %w", err) + } + if isContainer != 1 { + return errors.New("HEIC helper is not an AppContainer process") + } + // TOKEN_GROUPS and TOKEN_APPCONTAINER_INFORMATION contain native pointers. + // A byte array on the Go stack has no pointer-alignment guarantee. + var buffer [512]uintptr + data := (*byte)(unsafe.Pointer(&buffer[0])) + if err := windows.GetTokenInformation(token, tokenCapabilities, data, uint32(unsafe.Sizeof(buffer)), &length); err != nil { + return fmt.Errorf("query HEIC token capabilities: %w", err) + } + if length < 4 || (*windows.Tokengroups)(unsafe.Pointer(&buffer[0])).GroupCount != 0 { + return errors.New("HEIC helper has unexpected AppContainer capabilities") + } + if err := windows.GetTokenInformation(token, tokenAppContainerSID, data, uint32(unsafe.Sizeof(buffer)), &length); err != nil { + return fmt.Errorf("query HEIC AppContainer identity: %w", err) + } + if length < uint32(unsafe.Sizeof(uintptr(0))) { + return errors.New("HEIC helper has no AppContainer SID") + } + actual := *(**windows.SID)(unsafe.Pointer(&buffer[0])) + expected, err := containerSID(false) + if err != nil { + return err + } + defer func() { _ = windows.FreeSid(expected) }() + matches := actual != nil && actual.Equals(expected) + runtime.KeepAlive(buffer) + if !matches { + return errors.New("HEIC helper has an unexpected AppContainer identity") + } + return nil +} + +// The parent checks the native loopback exemption list on every launch. Windows +// denies this query to the restricted helper; no capability is added to it. +func verifyLoopbackIsolation(expected *windows.SID) (resultErr error) { + query := windows.NewLazySystemDLL("Firewallapi.dll").NewProc("NetworkIsolationGetAppContainerConfig") + kernel := windows.NewLazySystemDLL("kernel32.dll") + getHeap, free := kernel.NewProc("GetProcessHeap"), kernel.NewProc("HeapFree") + for _, proc := range []*windows.LazyProc{query, getHeap, free} { + if err := proc.Find(); err != nil { + return err + } + } + heap, _, heapErr := getHeap.Call() + if heap == 0 { + return fmt.Errorf("get HEIC isolation query heap: %w", heapErr) + } + var count uint32 + var entries *windows.SIDAndAttributes + code, _, _ := query.Call(uintptr(unsafe.Pointer(&count)), uintptr(unsafe.Pointer(&entries))) + if err := validateLoopbackQuery(code, count, entries); err != nil { + return err + } + items := unsafe.Slice(entries, count) + // The API allocates both the array and each SID on the process heap. + // https://learn.microsoft.com/windows/win32/api/networkisolation/nf-networkisolation-networkisolationgetappcontainerconfig + defer func() { + for _, item := range items { + if item.Sid != nil { + if ok, _, _ := free.Call(heap, 0, uintptr(unsafe.Pointer(item.Sid))); ok == 0 { + resultErr = errors.Join(resultErr, errors.New("free HEIC loopback exemption SID")) + } + } + } + if entries != nil { + if ok, _, _ := free.Call(heap, 0, uintptr(unsafe.Pointer(entries))); ok == 0 { + resultErr = errors.Join(resultErr, errors.New("free HEIC loopback exemption array")) + } + } + }() + return validateLoopbackExemptions(expected, items) +} + +func validateLoopbackExemptions(expected *windows.SID, items []windows.SIDAndAttributes) error { + for _, item := range items { + if item.Sid == nil { + return errors.New("HEIC loopback exemption query returned an invalid SID") + } + if expected.Equals(item.Sid) { + return errors.New("HEIC AppContainer has a loopback exemption") + } + } + return nil +} + +func validateLoopbackQuery(code uintptr, count uint32, entries *windows.SIDAndAttributes) error { + if code != 0 { + return fmt.Errorf("query HEIC loopback exemptions: %w", windows.Errno(code)) + } + if count != 0 && entries == nil { + return errors.New("HEIC loopback exemption query returned no entries") + } + return nil +} diff --git a/internal/heicdecode/winisolation/process_windows.go b/internal/heicdecode/winisolation/process_windows.go new file mode 100644 index 00000000..51908091 --- /dev/null +++ b/internal/heicdecode/winisolation/process_windows.go @@ -0,0 +1,221 @@ +//go:build windows && (amd64 || arm64) + +package winisolation + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "sync" + "unsafe" + + "golang.org/x/sys/windows" + + "github.com/frathe/picfetch/internal/heicdecode" +) + +const ( + securityCapabilitiesAttribute = 0x00020009 + childProcessPolicyAttribute = 0x0002000e + childProcessRestricted = 1 +) + +type securityCapabilities struct { + AppContainerSID *windows.SID + Capabilities *windows.SIDAndAttributes + CapabilityCount uint32 + Reserved uint32 +} + +// Process owns one noninheritable job and process handle. Kill is nonblocking; +// Wait joins the process and closes the final job handle. Callers stop their +// pipe work separately, then Wait once admission can be released. +type Process struct { + mu sync.Mutex + process, job windows.Handle + wait sync.Once + waitErr error +} + +// Start creates the helper suspended inside a zero-capability AppContainer, +// assigns and verifies its private job, then resumes its sole initial thread. +// Only the three duplicated stdio handles are inherited. Every setup failure +// terminates and joins the still-owned process before returning. +func Start(executable string, args []string, stdio [3]*os.File, limits heicdecode.Limits) (_ *Process, resultErr error) { + if err := limits.Validate(); err != nil { + return nil, err + } + if !filepath.IsAbs(executable) { + return nil, errors.New("HEIC helper path must be absolute") + } + sid, err := containerSID(true) + if err != nil { + return nil, err + } + defer func() { _ = windows.FreeSid(sid) }() + if err = verifyLoopbackIsolation(sid); err != nil { + return nil, err + } + job, err := windows.CreateJobObject(nil, nil) + if err != nil { + return nil, err + } + defer func() { + if resultErr != nil { + _ = windows.CloseHandle(job) + } + }() + policy := jobLimits(limits) + // SetInformationJobObject carries this address as uintptr through its + // Go wrapper; pin it across lazy DLL resolution and the native call. + var pinned runtime.Pinner + pinned.Pin(&policy) + defer pinned.Unpin() + if _, err = windows.SetInformationJobObject(job, windows.JobObjectExtendedLimitInformation, uintptr(unsafe.Pointer(&policy)), uint32(unsafe.Sizeof(policy))); err != nil { + return nil, fmt.Errorf("set HEIC job limits: %w", err) + } + if err = verifyJob(job, limits); err != nil { + return nil, err + } + var handles [3]windows.Handle + defer func() { + for _, handle := range handles { + if handle != 0 { + _ = windows.CloseHandle(handle) + } + } + }() + for i, file := range stdio { + if file == nil { + return nil, errors.New("HEIC helper requires three explicit pipes") + } + if err = windows.DuplicateHandle(windows.CurrentProcess(), windows.Handle(file.Fd()), windows.CurrentProcess(), &handles[i], 0, true, windows.DUPLICATE_SAME_ACCESS); err != nil { + return nil, err + } + } + attributes, err := windows.NewProcThreadAttributeList(3) + if err != nil { + return nil, err + } + defer attributes.Delete() + capability := securityCapabilities{AppContainerSID: sid} + children := uint32(childProcessRestricted) + if err = attributes.Update(securityCapabilitiesAttribute, unsafe.Pointer(&capability), unsafe.Sizeof(capability)); err != nil { + return nil, err + } + if err = attributes.Update(windows.PROC_THREAD_ATTRIBUTE_HANDLE_LIST, unsafe.Pointer(&handles[0]), unsafe.Sizeof(handles)); err != nil { + return nil, err + } + if err = attributes.Update(childProcessPolicyAttribute, unsafe.Pointer(&children), unsafe.Sizeof(children)); err != nil { + return nil, err + } + startup := windows.StartupInfoEx{ProcThreadAttributeList: attributes.List()} + startup.Cb = uint32(unsafe.Sizeof(startup)) + startup.Flags = windows.STARTF_USESTDHANDLES + startup.StdInput, startup.StdOutput, startup.StdErr = handles[0], handles[1], handles[2] + path, err := windows.UTF16PtrFromString(executable) + if err != nil { + return nil, err + } + command, err := windows.UTF16PtrFromString(windows.ComposeCommandLine(append([]string{executable}, args...))) + if err != nil { + return nil, err + } + // AppContainer creation needs LOCALAPPDATA to locate its profile. Obtain + // OS directory values directly, without inheriting the parent's environment + // or DLL search path. Windows redirects LOCALAPPDATA for the child. + environment, err := windows.UTF16FromString("GOMAXPROCS=1") + if err != nil { + return nil, err + } + profileDirectory, err := windows.KnownFolderPath(windows.FOLDERID_LocalAppData, 0) + if err != nil { + return nil, fmt.Errorf("locate local application data: %w", err) + } + localAppData, err := windows.UTF16FromString("LOCALAPPDATA=" + profileDirectory) + if err != nil { + return nil, err + } + environment = append(environment, localAppData...) + windowsDirectory, err := windows.GetWindowsDirectory() + if err != nil { + return nil, fmt.Errorf("locate Windows directory: %w", err) + } + systemRoot, err := windows.UTF16FromString("SystemRoot=" + windowsDirectory) + if err != nil { + return nil, err + } + environment = append(environment, systemRoot...) + environment = append(environment, 0) + directory, err := windows.UTF16PtrFromString(filepath.Dir(executable)) + if err != nil { + return nil, err + } + var information windows.ProcessInformation + // The helper uses only inherited pipes. Avoid hidden-console initialization + // and its console host inside this one-process AppContainer. + flags := uint32(windows.CREATE_SUSPENDED | windows.CREATE_UNICODE_ENVIRONMENT | windows.EXTENDED_STARTUPINFO_PRESENT | windows.DETACHED_PROCESS) + if err = windows.CreateProcess(path, command, nil, nil, true, flags, &environment[0], directory, &startup.StartupInfo, &information); err != nil { + return nil, fmt.Errorf("create AppContainer process: %w", err) + } + runtime.KeepAlive(capability) + runtime.KeepAlive(handles) + runtime.KeepAlive(children) + defer func() { _ = windows.CloseHandle(information.Thread) }() + defer func() { + if resultErr != nil { + _ = windows.TerminateProcess(information.Process, 1) + _, _ = windows.WaitForSingleObject(information.Process, windows.INFINITE) + _ = windows.CloseHandle(information.Process) + } + }() + if err = windows.AssignProcessToJobObject(job, information.Process); err != nil { + return nil, err + } + if err = verifyJob(job, limits); err != nil { + return nil, err + } + count, err := windows.ResumeThread(information.Thread) + if err != nil { + return nil, err + } + if count != 1 { + return nil, fmt.Errorf("HEIC initial thread had unexpected suspend count %d", count) + } + return &Process{process: information.Process, job: job}, nil +} + +func (p *Process) Kill() { + p.mu.Lock() + defer p.mu.Unlock() + if p.job != 0 { + _ = windows.TerminateJobObject(p.job, 1) + } +} + +func (p *Process) Wait() error { + p.wait.Do(func() { + state, err := windows.WaitForSingleObject(p.process, windows.INFINITE) + if err != nil { + p.waitErr = err + } else if state != windows.WAIT_OBJECT_0 { + p.waitErr = fmt.Errorf("HEIC process wait returned %d", state) + } else { + var code uint32 + if err = windows.GetExitCodeProcess(p.process, &code); err != nil { + p.waitErr = err + } else if code != 0 { + p.waitErr = fmt.Errorf("HEIC process exited with code %d", code) + } + } + p.mu.Lock() + defer p.mu.Unlock() + // Kill-on-close is retained until the process has been observed to exit. + _ = windows.CloseHandle(p.job) + _ = windows.CloseHandle(p.process) + p.job, p.process = 0, 0 + }) + return p.waitErr +} diff --git a/internal/heicdecode/winisolation/profile_windows.go b/internal/heicdecode/winisolation/profile_windows.go new file mode 100644 index 00000000..f02b8e8f --- /dev/null +++ b/internal/heicdecode/winisolation/profile_windows.go @@ -0,0 +1,133 @@ +//go:build windows && (amd64 || arm64) + +// Package winisolation owns the Windows HEIC helper's native privilege and +// process-family boundary. It contains no decoder or user-image handling. +package winisolation + +import ( + "errors" + "fmt" + "path/filepath" + "runtime" + "unsafe" + + "golang.org/x/sys/windows" +) + +const containerName = "PicFetch.HEICWorker.v2" + +// Profile APIs return HRESULT, not the thread's last-error value. Each SID is +// allocated by Windows and belongs to the caller until FreeSid. +func containerSID(create bool) (*windows.SID, error) { + name, err := windows.UTF16PtrFromString(containerName) + if err != nil { + return nil, err + } + library := windows.NewLazySystemDLL("userenv.dll") + var sid *windows.SID + if create { + // Profile registration is shared by all PicFetch processes for this + // user. Serialize it across processes; a Go mutex cannot do that. + // Win32 mutex ownership is bound to the acquiring OS thread. + runtime.LockOSThread() + defer runtime.UnlockOSThread() + user, userErr := windows.GetCurrentProcessToken().GetTokenUser() + if userErr != nil { + return nil, userErr + } + mutexName, nameErr := windows.UTF16PtrFromString(`Local\` + containerName + "." + user.User.Sid.String()) + if nameErr != nil { + return nil, nameErr + } + mutex, mutexErr := windows.CreateMutex(nil, false, mutexName) + if mutexErr != nil && !errors.Is(mutexErr, windows.ERROR_ALREADY_EXISTS) { + return nil, fmt.Errorf("open HEIC profile lock: %w", mutexErr) + } + defer func() { _ = windows.CloseHandle(mutex) }() + state, waitErr := windows.WaitForSingleObject(mutex, 10000) + if waitErr != nil { + return nil, fmt.Errorf("wait for HEIC profile lock: %w", waitErr) + } + if state != windows.WAIT_OBJECT_0 && state != windows.WAIT_ABANDONED { + return nil, fmt.Errorf("HEIC profile lock wait returned %d", state) + } + defer func() { _ = windows.ReleaseMutex(mutex) }() + procedure := library.NewProc("CreateAppContainerProfile") + if err = procedure.Find(); err != nil { + return nil, err + } + result, _, _ := procedure.Call(uintptr(unsafe.Pointer(name)), uintptr(unsafe.Pointer(name)), uintptr(unsafe.Pointer(name)), 0, 0, uintptr(unsafe.Pointer(&sid))) + runtime.KeepAlive(name) + if int32(result) >= 0 { + if sid == nil { + return nil, fmt.Errorf("HEIC AppContainer returned no SID") + } + return sid, nil + } + if sid != nil { + _ = windows.FreeSid(sid) + sid = nil + } + if uint32(result) != 0x800700b7 { + return nil, fmt.Errorf("create HEIC AppContainer: HRESULT %#x", uint32(result)) + } + } + procedure := library.NewProc("DeriveAppContainerSidFromAppContainerName") + if err = procedure.Find(); err != nil { + return nil, err + } + result, _, _ := procedure.Call(uintptr(unsafe.Pointer(name)), uintptr(unsafe.Pointer(&sid))) + runtime.KeepAlive(name) + if int32(result) < 0 { + if sid != nil { + _ = windows.FreeSid(sid) + } + return nil, fmt.Errorf("derive HEIC AppContainer: HRESULT %#x", uint32(result)) + } + if sid == nil { + return nil, fmt.Errorf("HEIC AppContainer returned no SID") + } + return sid, nil +} + +// PrepareExecutable grants only read/execute of a dedicated helper file and +// its containing directory to this AppContainer. Callers own this installation +// directory; no permission is inherited by other files or child directories. +// Launch does not change ACLs, so immutable packaged locations must already +// provide the necessary access or be staged into an owned helper directory. +func PrepareExecutable(executable string) error { + if !filepath.IsAbs(executable) { + return fmt.Errorf("HEIC helper path must be absolute") + } + sid, err := containerSID(true) + if err != nil { + return err + } + defer func() { _ = windows.FreeSid(sid) }() + var pinned runtime.Pinner + pinned.Pin(sid) + defer pinned.Unpin() + for _, path := range []string{filepath.Dir(executable), executable} { + descriptor, getErr := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION) + if getErr != nil { + return getErr + } + existing, _, getErr := descriptor.DACL() + if getErr != nil { + return getErr + } + access := windows.EXPLICIT_ACCESS{ + AccessPermissions: windows.FILE_GENERIC_READ | windows.FILE_GENERIC_EXECUTE, + AccessMode: windows.GRANT_ACCESS, Inheritance: windows.NO_INHERITANCE, + Trustee: windows.TRUSTEE{TrusteeForm: windows.TRUSTEE_IS_SID, TrusteeType: windows.TRUSTEE_IS_UNKNOWN, TrusteeValue: windows.TrusteeValueFromSID(sid)}, + } + acl, aclErr := windows.ACLFromEntries([]windows.EXPLICIT_ACCESS{access}, existing) + if aclErr != nil { + return aclErr + } + if err = windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION, nil, nil, acl, nil); err != nil { + return err + } + } + return nil +} diff --git a/internal/heicdecode/worker/decoder.wasm b/internal/heicdecode/worker/decoder.wasm new file mode 100644 index 00000000..ee7a3f23 Binary files /dev/null and b/internal/heicdecode/worker/decoder.wasm differ diff --git a/internal/heicdecode/worker/engine_compiler.go b/internal/heicdecode/worker/engine_compiler.go new file mode 100644 index 00000000..4c5fe77c --- /dev/null +++ b/internal/heicdecode/worker/engine_compiler.go @@ -0,0 +1,7 @@ +//go:build !heicinterpreter + +package worker + +import "github.com/tetratelabs/wazero" + +func runtimeConfig() wazero.RuntimeConfig { return wazero.NewRuntimeConfigCompiler() } diff --git a/internal/heicdecode/worker/engine_interpreter.go b/internal/heicdecode/worker/engine_interpreter.go new file mode 100644 index 00000000..ae71cfa8 --- /dev/null +++ b/internal/heicdecode/worker/engine_interpreter.go @@ -0,0 +1,9 @@ +//go:build heicinterpreter + +package worker + +import "github.com/tetratelabs/wazero" + +// The interpreter variant permits native performance/signing qualification +// without granting generated-code authority. It uses the same finite limits. +func runtimeConfig() wazero.RuntimeConfig { return wazero.NewRuntimeConfigInterpreter() } diff --git a/internal/heicdecode/worker/isolate_darwin.go b/internal/heicdecode/worker/isolate_darwin.go new file mode 100644 index 00000000..52b22657 --- /dev/null +++ b/internal/heicdecode/worker/isolate_darwin.go @@ -0,0 +1,37 @@ +//go:build darwin && cgo + +package worker + +/* +#cgo LDFLAGS: -framework Security -framework CoreFoundation +#include +#include + +static int picfetchAppSandbox(void) { + SecTaskRef task = SecTaskCreateFromSelf(kCFAllocatorDefault); + if (task == NULL) return 0; + CFTypeRef value = SecTaskCopyValueForEntitlement(task, CFSTR("com.apple.security.app-sandbox"), NULL); + int enabled = value != NULL && CFGetTypeID(value) == CFBooleanGetTypeID() && CFBooleanGetValue((CFBooleanRef)value); + if (value != NULL) CFRelease(value); + CFRelease(task); + return enabled; +} +*/ +import "C" + +import ( + "errors" + + "github.com/frathe/picfetch/internal/heicdecode" +) + +func isolate(_ heicdecode.Limits) (int64, error) { + // App Sandbox is established by macOS at exec from the signed helper + // bundle's entitlement. No deprecated custom Seatbelt API is used. + if C.picfetchAppSandbox() != 1 { + return 0, errors.New("HEIC helper lacks App Sandbox entitlement") + } + // Capability probes in Main must also observe actual file/network denial. + // Ronin explicitly accepted no guaranteed whole-native-process memory cap. + return 0, nil +} diff --git a/internal/heicdecode/worker/isolate_linux.go b/internal/heicdecode/worker/isolate_linux.go new file mode 100644 index 00000000..bbb2eef0 --- /dev/null +++ b/internal/heicdecode/worker/isolate_linux.go @@ -0,0 +1,147 @@ +//go:build linux && !cgo && (amd64 || arm64) + +package worker + +import ( + "errors" + "fmt" + "io" + "os" + "runtime" + "strconv" + "strings" + "time" + "unsafe" + + "golang.org/x/sys/unix" + + "github.com/frathe/picfetch/internal/heicdecode" +) + +// This policy is qualified for the pure Go helper, not arbitrary libc paths. +// cgo and unsupported Linux architectures select the refusing implementation. +func isolate(limits heicdecode.Limits) (int64, error) { + if err := limits.Validate(); err != nil { + return 0, err + } + runtime.LockOSThread() + defer runtime.UnlockOSThread() + if err := linuxLimits(limits); err != nil { + return 0, err + } + if err := unix.Prctl(unix.PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); err != nil { + return 0, fmt.Errorf("HEIC no_new_privs: %w", err) + } + raw, err := linuxFilter(nativeLinuxCalls(), uint32(os.Getpid())) + if err != nil { + return 0, err + } + // Keep this conversion local to the HEIC policy; sharing similarity's + // looser network-only installation would join unrelated trust boundaries. + //noinspection DuplicatedCode + filter := make([]unix.SockFilter, len(raw)) + for i, instruction := range raw { + filter[i] = unix.SockFilter{Code: instruction.Op, Jt: instruction.Jt, Jf: instruction.Jf, K: instruction.K} + } + program := unix.SockFprog{Len: uint16(len(filter)), Filter: &filter[0]} + result, _, errno := unix.Syscall(unix.SYS_SECCOMP, unix.SECCOMP_SET_MODE_FILTER, unix.SECCOMP_FILTER_FLAG_TSYNC, uintptr(unsafe.Pointer(&program))) + runtime.KeepAlive(filter) + if errno != 0 { + return 0, fmt.Errorf("HEIC seccomp: %w", errno) + } + if result != 0 { + return 0, errors.New("HEIC seccomp thread synchronization failed") + } + // Observe the installed limit with a no-access reservation, without + // committing or touching RAM. Success is a failed boundary, never readiness. + if err = verifyLinuxAddressLimit(limits.OSProcessBytes); err != nil { + return 0, err + } + return limits.OSProcessBytes, nil +} + +func nativeLinuxCalls() linuxCalls { + calls := linuxCalls{ + architecture: unix.AUDIT_ARCH_X86_64, threadFlags: 0xd0f00, + clone: unix.SYS_CLONE, mmap: unix.SYS_MMAP, mprotect: unix.SYS_MPROTECT, + futex: unix.SYS_FUTEX, fcntl: unix.SYS_FCNTL, tgkill: unix.SYS_TGKILL, + ordinary: []uint32{ + unix.SYS_READ, unix.SYS_WRITE, unix.SYS_CLOSE, + unix.SYS_MUNMAP, unix.SYS_MADVISE, + unix.SYS_RT_SIGACTION, unix.SYS_RT_SIGPROCMASK, unix.SYS_RT_SIGRETURN, unix.SYS_SIGALTSTACK, + unix.SYS_GETPID, unix.SYS_GETTID, unix.SYS_SCHED_YIELD, unix.SYS_NANOSLEEP, unix.SYS_CLOCK_GETTIME, + unix.SYS_EPOLL_CREATE1, unix.SYS_EPOLL_CTL, unix.SYS_EPOLL_PWAIT, unix.SYS_EVENTFD2, + unix.SYS_EXIT, unix.SYS_EXIT_GROUP, + }, + } + if runtime.GOARCH == "arm64" { + calls.architecture = unix.AUDIT_ARCH_AARCH64 + calls.threadFlags = 0x50f00 + } + return calls +} + +func linuxLimits(limits heicdecode.Limits) error { + // RLIMIT_AS constrains virtual mappings, not physical RSS. A lowered limit + // does not revoke existing mappings, so reject an already oversized helper. + file, err := os.Open("/proc/self/statm") + if err != nil { + return err + } + data, readErr := io.ReadAll(io.LimitReader(file, 4097)) + closeErr := file.Close() + if err = errors.Join(readErr, closeErr); err != nil { + return err + } + fields := strings.Fields(string(data)) + if len(data) > 4096 || len(fields) == 0 { + return errors.New("HEIC address-space accounting unavailable") + } + pages, err := strconv.ParseUint(fields[0], 10, 64) + if err != nil || pages > uint64(limits.OSProcessBytes)/uint64(os.Getpagesize()) { + return errors.New("HEIC initial address space exceeds native limit") + } + for _, ceiling := range []struct { + resource int + value uint64 + }{ + {unix.RLIMIT_AS, uint64(limits.OSProcessBytes)}, + {unix.RLIMIT_CPU, uint64((limits.Timeout + time.Second - 1) / time.Second)}, + {unix.RLIMIT_CORE, 0}, {unix.RLIMIT_FSIZE, 0}, {unix.RLIMIT_NOFILE, 32}, + } { + var previous unix.Rlimit + if err = unix.Getrlimit(ceiling.resource, &previous); err != nil { + return err + } + value := min(ceiling.value, previous.Cur, previous.Max) + bound := unix.Rlimit{Cur: value, Max: value} + if err = unix.Setrlimit(ceiling.resource, &bound); err != nil { + return err + } + var installed unix.Rlimit + if err = unix.Getrlimit(ceiling.resource, &installed); err != nil { + return err + } + if installed != bound { + return errors.New("HEIC native resource limit not installed") + } + // Readiness reports the requested AS ceiling; refuse inherited tighter + // AS limits rather than reporting a different control as that value. + if ceiling.resource == unix.RLIMIT_AS && value != ceiling.value { + return errors.New("HEIC inherited address-space limit is too small") + } + } + return nil +} + +func verifyLinuxAddressLimit(maxBytes int64) error { + mapping, err := unix.Mmap(-1, 0, int(maxBytes)+os.Getpagesize(), unix.PROT_NONE, unix.MAP_PRIVATE|unix.MAP_ANON) + if err == nil { + _ = unix.Munmap(mapping) + return errors.New("HEIC native address-space limit not enforced") + } + if !errors.Is(err, unix.ENOMEM) { + return fmt.Errorf("HEIC native memory probe: %w", err) + } + return nil +} diff --git a/internal/heicdecode/worker/isolate_other.go b/internal/heicdecode/worker/isolate_other.go new file mode 100644 index 00000000..a4a2ebf6 --- /dev/null +++ b/internal/heicdecode/worker/isolate_other.go @@ -0,0 +1,13 @@ +//go:build (!darwin || !cgo) && (!linux || cgo || (!amd64 && !arm64)) && (!windows || (!amd64 && !arm64)) + +package worker + +import ( + "errors" + + "github.com/frathe/picfetch/internal/heicdecode" +) + +func isolate(_ heicdecode.Limits) (int64, error) { + return 0, errors.New("HEIC native sandbox is unavailable on this build") +} diff --git a/internal/heicdecode/worker/isolate_windows.go b/internal/heicdecode/worker/isolate_windows.go new file mode 100644 index 00000000..9cd0fa9c --- /dev/null +++ b/internal/heicdecode/worker/isolate_windows.go @@ -0,0 +1,37 @@ +//go:build windows && (amd64 || arm64) + +package worker + +import ( + "context" + "errors" + "net" + + "github.com/frathe/picfetch/internal/heicdecode" + "github.com/frathe/picfetch/internal/heicdecode/winisolation" +) + +func isolate(limits heicdecode.Limits) (int64, error) { + if err := winisolation.Verify(limits); err != nil { + return 0, err + } + return limits.OSProcessBytes, nil +} + +// Windows drops blocked AppContainer loopback packets. A timeout therefore +// needs independent policy evidence; it never suffices by itself. +func confirmNetworkDenial(ctx context.Context, host string, probeErr error) error { + if err := ctx.Err(); err != nil { + return err + } + var networkErr net.Error + if host != "127.0.0.1" || !errors.As(probeErr, &networkErr) || !networkErr.Timeout() { + return errors.New("expected a timed-out owned loopback probe") + } + // Start already refused an explicit loopback exemption before creation. + // That privileged query is denied inside the zero-capability container. + if err := winisolation.VerifyAppContainer(); err != nil { + return err + } + return ctx.Err() +} diff --git a/internal/heicdecode/worker/linux_filter.go b/internal/heicdecode/worker/linux_filter.go new file mode 100644 index 00000000..403844d5 --- /dev/null +++ b/internal/heicdecode/worker/linux_filter.go @@ -0,0 +1,66 @@ +package worker + +import "golang.org/x/net/bpf" + +// linuxCalls keeps kernel numbers supplied by the native build separate from +// the portable policy evaluator. Arguments are 64-bit little-endian fields. +type linuxCalls struct { + architecture, threadFlags uint32 + clone, mmap, mprotect, futex, fcntl, tgkill uint32 + ordinary []uint32 +} + +func linuxFilter(calls linuxCalls, pid uint32) ([]bpf.RawInstruction, error) { + const deny = 0x00050001 // SECCOMP_RET_ERRNO | EPERM + const allow = 0x7fff0000 + instructions := []bpf.Instruction{ + bpf.LoadAbsolute{Off: 4, Size: 4}, + bpf.JumpIf{Cond: bpf.JumpEqual, Val: calls.architecture, SkipTrue: 1}, + bpf.RetConstant{Val: deny}, + bpf.LoadAbsolute{Off: 0, Size: 4}, + bpf.JumpIf{Cond: bpf.JumpGreaterOrEqual, Val: 0x40000000, SkipFalse: 1}, + bpf.RetConstant{Val: deny}, + } + for _, call := range calls.ordinary { + instructions = append(instructions, + bpf.JumpIf{Cond: bpf.JumpEqual, Val: call, SkipFalse: 1}, + bpf.RetConstant{Val: allow}) + } + // Each matched rule terminates. Its argument checks cannot fall through to + // another syscall rule with an argument still loaded in the accumulator. + rule := func(call uint32, conditions ...[]bpf.Instruction) { + var body []bpf.Instruction + for _, condition := range conditions { + body = append(body, condition...) + } + body = append(body, bpf.RetConstant{Val: allow}) + instructions = append(instructions, bpf.JumpIf{Cond: bpf.JumpEqual, Val: call, SkipFalse: uint8(len(body))}) + instructions = append(instructions, body...) + } + // Restrict scalar arguments, including their high word. Pointer contents + // cannot be inspected by seccomp and are not treated as an authority check. + argument := func(index uint32, values ...uint32) []bpf.Instruction { + check := []bpf.Instruction{ + bpf.LoadAbsolute{Off: 20 + index*8, Size: 4}, + bpf.JumpIf{Cond: bpf.JumpEqual, Val: 0, SkipTrue: 1}, + bpf.RetConstant{Val: deny}, + bpf.LoadAbsolute{Off: 16 + index*8, Size: 4}, + } + for i, value := range values { + check = append(check, bpf.JumpIf{Cond: bpf.JumpEqual, Val: value, SkipTrue: uint8(len(values) - i)}) + } + return append(check, bpf.RetConstant{Val: deny}) + } + // Go's exact CLONE_THREAD form permits runtime threads, never a new process + // or namespace. clone3 and all unlisted process-launch calls remain denied. + rule(calls.clone, argument(0, calls.threadFlags)) + // Only private anonymous mappings; huge-page attempts fail and wazero falls + // back to normal pages. Compiled code transitions RW -> RX via mprotect. + rule(calls.mmap, argument(2, 0, 1, 3), argument(3, 0x22, 0x32)) + rule(calls.mprotect, argument(2, 0, 1, 3, 5)) + rule(calls.futex, argument(1, 128, 129)) + rule(calls.fcntl, argument(1, 1, 2, 3, 4)) + rule(calls.tgkill, argument(0, pid)) + instructions = append(instructions, bpf.RetConstant{Val: deny}) + return bpf.Assemble(instructions) +} diff --git a/internal/heicdecode/worker/linux_filter_test.go b/internal/heicdecode/worker/linux_filter_test.go new file mode 100644 index 00000000..75b688db --- /dev/null +++ b/internal/heicdecode/worker/linux_filter_test.go @@ -0,0 +1,85 @@ +package worker + +import ( + "encoding/binary" + "testing" + + "golang.org/x/net/bpf" +) + +// Evaluate policy on every host without installing a native filter. Each +// instruction input represents a PicFetch-owned boundary decision, no image. +func TestLinuxFilterRuntimeAndAuthority(t *testing.T) { + for _, calls := range []linuxCalls{ + {0xc000003e, 0xd0f00, 56, 9, 10, 202, 72, 234, []uint32{0, 1, 3, 60, 231}}, + {0xc00000b7, 0x50f00, 220, 222, 226, 98, 25, 131, []uint32{63, 64, 57, 93, 94}}, + } { + raw, err := linuxFilter(calls, 42) + if err != nil { + t.Fatal(err) + } + instructions, decoded := bpf.Disassemble(raw) + if !decoded { + t.Fatal("unknown BPF instructions") + } + vm, err := bpf.NewVM(instructions) + if err != nil { + t.Fatal(err) + } + check := func(name string, arch, call uint32, args [6]uint64, allow bool) { + t.Helper() + // BPF's portable VM loads network order; seccomp loads host order. + // Encode each scalar separately to preserve the kernel's low/high + // 32-bit layout of little-endian 64-bit syscall arguments. + data := make([]byte, 64) + binary.BigEndian.PutUint32(data, call) + binary.BigEndian.PutUint32(data[4:], arch) + for i, arg := range args { + binary.BigEndian.PutUint32(data[16+i*8:], uint32(arg)) + binary.BigEndian.PutUint32(data[20+i*8:], uint32(arg>>32)) + } + got, err := vm.Run(data) + want := 0x00050001 + if allow { + want = 0x7fff0000 + } + if err != nil || got != want { + t.Errorf("%s arch=%#x: decision=%#x error=%v, want %#x", name, arch, got, err, want) + } + } + for _, call := range calls.ordinary { + check("runtime", calls.architecture, call, [6]uint64{}, true) + } + check("unknown architecture", calls.architecture^1, calls.ordinary[0], [6]uint64{}, false) + check("x32", calls.architecture, calls.ordinary[0]|0x40000000, [6]uint64{}, false) + for _, flags := range []uint64{uint64(calls.threadFlags), 0, 17, uint64(calls.threadFlags) | 0x10000000, uint64(calls.threadFlags) | (1 << 32)} { + check("clone", calls.architecture, calls.clone, [6]uint64{flags}, flags == uint64(calls.threadFlags)) + } + for _, flags := range []uint64{0x22, 0x32, 0x02, 0x21, 0x4022, 0x22 | (1 << 32)} { + check("mmap flags", calls.architecture, calls.mmap, [6]uint64{0, 4096, 3, flags}, flags == 0x22 || flags == 0x32) + } + for _, protection := range []uint64{0, 1, 3, 5, 7, 0x100000003} { + check("mmap protection", calls.architecture, calls.mmap, [6]uint64{0, 4096, protection, 0x22}, protection == 0 || protection == 1 || protection == 3) + check("mprotect", calls.architecture, calls.mprotect, [6]uint64{0, 4096, protection}, protection == 0 || protection == 1 || protection == 3 || protection == 5) + } + for _, operation := range []uint64{0, 1, 128, 129, 0x100000080} { + check("private futex", calls.architecture, calls.futex, [6]uint64{0, operation}, operation == 128 || operation == 129) + } + for _, command := range []uint64{0, 1, 2, 3, 4, 5, 1030} { + check("fcntl", calls.architecture, calls.fcntl, [6]uint64{0, command}, command >= 1 && command <= 4) + } + check("own thread signal", calls.architecture, calls.tgkill, [6]uint64{42, 43, 23}, true) + check("other process signal", calls.architecture, calls.tgkill, [6]uint64{41, 43, 23}, false) + // Common and architecture-specific file/network/process/privilege paths + // must remain denied. Values differ between the two Linux ABIs. + denied := []uint32{425, 426, 427, 435, 9999} + if calls.architecture == 0xc000003e { + denied = append(denied, 2, 41, 42, 53, 57, 58, 59, 101, 157, 257, 272, 302, 308, 319, 322) + } else { + denied = append(denied, 56, 117, 167, 198, 199, 203, 221, 261, 268, 279, 281) + } + for _, call := range denied { + check("denied authority", calls.architecture, call, [6]uint64{}, false) + } + } +} diff --git a/internal/heicdecode/worker/main.go b/internal/heicdecode/worker/main.go new file mode 100644 index 00000000..4dc3579a --- /dev/null +++ b/internal/heicdecode/worker/main.go @@ -0,0 +1,114 @@ +package worker + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os" + "runtime" + "runtime/debug" + "syscall" + "time" + + "github.com/frathe/picfetch/internal/heicdecode" +) + +// Main serves one request in the dedicated, disposable helper process. Probe +// paths and loopback endpoints are parent-owned launch controls, never supplied +// by an image. Any missing platform restriction exits before reading stdin. +// The separate cmd/picfetch-heic-worker entry point is its production caller. +// +// noinspection GoUnusedExportedFunction +func Main(args []string) int { + if len(args) != 6 || args[0] != "--heic-worker-v2" || len(args[1]) > 2048 { + return 2 + } + var limits heicdecode.Limits + if err := json.Unmarshal([]byte(args[1]), &limits); err != nil || limits.Validate() != nil { + return 2 + } + for _, arg := range args[2:] { + if len(arg) == 0 || len(arg) > 4096 { + return 2 + } + } + runtime.GOMAXPROCS(1) + debug.SetMaxThreads(32) + // A Go runtime target, explicitly not a hard cap for native process memory. + debug.SetMemoryLimit(limits.OSProcessBytes - limits.OSProcessBytes/4) + nativeMemory, err := isolate(limits) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + return 3 + } + ctx, cancel := context.WithTimeout(context.Background(), limits.Timeout) + defer cancel() + if err = verifyDenial(ctx, args[2], args[3], args[4], args[5]); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + return 3 + } + if err = heicdecode.WriteReady(os.Stdout, heicdecode.Ready{WASMMemoryBytes: limits.WASMMemoryBytes, NativeMemoryBytes: nativeMemory}); err != nil { + return 4 + } + if err = execute(ctx, decoder, os.Stdin, os.Stdout, os.Stderr, limits); err != nil { + return 4 + } + return 0 +} + +func verifyDenial(ctx context.Context, readPath, writePath, tcpAddress, udpAddress string) error { + file, err := os.Open(readPath) + if file != nil { + _ = file.Close() + } + if !permissionDenied(err) { + return errors.New("HEIC native file-read denial unavailable") + } + file, err = os.OpenFile(writePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if file != nil { + _ = file.Close() + } + if !permissionDenied(err) { + return errors.New("HEIC native file-write denial unavailable") + } + for i, address := range []string{tcpAddress, udpAddress} { + host, _, splitErr := net.SplitHostPort(address) + if splitErr != nil || host != "127.0.0.1" { + return errors.New("HEIC capability probe must be loopback") + } + network := "tcp4" + if i == 1 { + network = "udp4" + } + dialer := net.Dialer{Timeout: time.Second} + connection, dialErr := dialer.DialContext(ctx, network, address) + if connection != nil { + if i == 1 { + _ = connection.SetWriteDeadline(time.Now().Add(time.Second)) + _, dialErr = connection.Write([]byte{0}) + if runtime.GOOS == "windows" && dialErr == nil { + _ = connection.SetReadDeadline(time.Now().Add(time.Second)) + var reply [1]byte + _, dialErr = io.ReadFull(connection, reply[:]) + } + } + _ = connection.Close() + } + if !permissionDenied(dialErr) { + if confirmErr := confirmNetworkDenial(ctx, host, dialErr); confirmErr != nil { + return fmt.Errorf("HEIC native %s denial unavailable: %v (%w)", network, dialErr, confirmErr) + } + } + } + return ctx.Err() +} + +func permissionDenied(err error) bool { + // Winsock reports WSAEACCES (10013), while file APIs use the ordinary + // permission mapping. A timeout alone never proves denial; Windows may + // separately confirm its dropped loopback traffic through the native API. + return errors.Is(err, os.ErrPermission) || (runtime.GOOS == "windows" && errors.Is(err, syscall.Errno(10013))) +} diff --git a/internal/heicdecode/worker/native_linux_test.go b/internal/heicdecode/worker/native_linux_test.go new file mode 100644 index 00000000..20b739a6 --- /dev/null +++ b/internal/heicdecode/worker/native_linux_test.go @@ -0,0 +1,134 @@ +//go:build heicnative && linux && !cgo && (amd64 || arm64) + +package worker + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "runtime" + "runtime/debug" + "sync" + "testing" + "time" + + "golang.org/x/sys/unix" + + "github.com/frathe/picfetch/internal/heicdecode" +) + +func TestMain(m *testing.M) { + if len(os.Args) == 2 && (os.Args[1] == "--owned-linux-policy" || os.Args[1] == "--owned-no-address-limit") { + if err := runOwnedLinuxControl(os.Args[1]); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + os.Exit(0) + } + os.Exit(m.Run()) +} + +func runOwnedLinuxControl(mode string) error { + limits := heicdecode.DefaultLimits(0) + if mode == "--owned-no-address-limit" { + return verifyLinuxAddressLimit(limits.OSProcessBytes) + } + runtime.GOMAXPROCS(1) + debug.SetMaxThreads(32) + threads, err := os.ReadDir("/proc/self/task") + if err != nil { + return err + } + if len(threads) > 24 { + return errors.New("unexpected initial thread count") + } + // Hold one existing thread across TSYNC, then hold more threads than + // existed beforehand. This exercises both synchronization and inheritance. + start, release := make(chan struct{}), make(chan struct{}) + started := make(chan struct{}, 32) + results := make(chan error, 32) + var work sync.WaitGroup + defer func() { close(release); work.Wait() }() + launch := func() { + work.Add(1) + go func() { + defer work.Done() + runtime.LockOSThread() + defer runtime.UnlockOSThread() + started <- struct{}{} + select { + case <-start: + case <-release: + return + } + fd, openErr := unix.Open("/proc/self/statm", unix.O_RDONLY, 0) + if openErr == nil { + _ = unix.Close(fd) + } + results <- openErr + <-release + }() + } + launch() + <-started + nativeMemory, err := isolate(limits) + if err != nil { + return err + } + if nativeMemory != limits.OSProcessBytes { + return errors.New("wrong native limit") + } + close(start) + for range len(threads) + 1 { + launch() + } + for range len(threads) + 2 { + if err = <-results; !errors.Is(err, unix.EPERM) { + return fmt.Errorf("native thread file denial: %v", err) + } + } + // No process or executable is contacted: a null pathname must be refused + // by seccomp as EPERM before kernel pathname validation could return EFAULT. + _, _, errno := unix.RawSyscall(unix.SYS_EXECVE, 0, 0, 0) + if errno != unix.EPERM { + return fmt.Errorf("exec denial: %v", errno) + } + // Runtime-private futexes remain usable; unknown clone3 and io_uring calls + // are denied before reading their absent argument structures. + for _, call := range []uintptr{unix.SYS_CLONE3, unix.SYS_IO_URING_SETUP, unix.SYS_IO_URING_ENTER, unix.SYS_IO_URING_REGISTER} { + _, _, errno = unix.RawSyscall(call, 0, 0, 0) + if errno != unix.EPERM { + return fmt.Errorf("syscall %d denial: %v", call, errno) + } + } + // A fixed PicFetch-owned loop verifies that the sandbox still lets wazero + // compile code and cancel execution; it contains no decoder/image input. + limits.Timeout = 50 * time.Millisecond + loop := []byte{0x03, 0x40, 0x0c, 0, 0x0b, 0x0b} + err = execute(context.Background(), ownedModule(1, loop), bytes.NewReader(nil), io.Discard, io.Discard, limits) + if !errors.Is(err, context.DeadlineExceeded) { + return fmt.Errorf("sandboxed WASI cancellation: %v", err) + } + _, err = fmt.Fprintln(os.Stdout, "owned Linux policy controls passed") + return err +} + +func TestNativeLinuxRuntimePolicy(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + command := exec.CommandContext(ctx, os.Args[0], "--owned-linux-policy") + if out, err := command.CombinedOutput(); err != nil { + t.Fatalf("owned native controls: %v: %s", err, out) + } + // This negative control omits the native AS limit. The same no-access + // reservation check must detect that omission without committing RAM. + command = exec.CommandContext(ctx, os.Args[0], "--owned-no-address-limit") + out, err := command.CombinedOutput() + if err == nil || !bytes.Contains(out, []byte("address-space limit not enforced")) { + t.Fatalf("missing-limit control: error=%v output=%s", err, out) + } +} diff --git a/internal/heicdecode/worker/network_other.go b/internal/heicdecode/worker/network_other.go new file mode 100644 index 00000000..10178062 --- /dev/null +++ b/internal/heicdecode/worker/network_other.go @@ -0,0 +1,12 @@ +//go:build !windows || (!amd64 && !arm64) + +package worker + +import ( + "context" + "errors" +) + +func confirmNetworkDenial(_ context.Context, _ string, _ error) error { + return errors.New("explicit native permission refusal required") +} diff --git a/internal/heicdecode/worker/runtime.go b/internal/heicdecode/worker/runtime.go new file mode 100644 index 00000000..2647405b --- /dev/null +++ b/internal/heicdecode/worker/runtime.go @@ -0,0 +1,75 @@ +// Package worker hosts the fixed WASI HEIC module in the dedicated helper. +// Only cmd/picfetch-heic-worker may import it in a production binary. +package worker + +import ( + "context" + _ "embed" + "errors" + "io" + + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1" + + "github.com/frathe/picfetch/internal/heicdecode" +) + +// decoder is reproduced and verified by scripts/heicbuild. It is never loaded +// from a request, a user preference, or an environment variable. +// +//go:embed decoder.wasm +var decoder []byte + +// execute requires the caller to establish native isolation first. Production +// reaches it only through Main; package tests also use small owned WASM modules +// to observe the runtime's memory and cancellation boundaries independently. +func execute(ctx context.Context, module []byte, stdin io.Reader, stdout, stderr io.Writer, limits heicdecode.Limits) error { + if err := limits.Validate(); err != nil { + return err + } + ctx, cancel := context.WithTimeout(ctx, limits.Timeout) + defer cancel() + config := runtimeConfig(). + WithMemoryLimitPages(uint32(limits.WASMMemoryBytes / (64 * 1024))). + WithCloseOnContextDone(true) + runtime := wazero.NewRuntimeWithConfig(ctx, config) + defer func() { _ = runtime.Close(context.Background()) }() + if _, err := wasi_snapshot_preview1.Instantiate(ctx, runtime); err != nil { + return err + } + // All stream limits include their fixed protocol header. No image-sized + // host buffer is used: WASI copies directly between guest memory and pipes. + output := &limitedWriter{writer: stdout, remaining: limits.MaxOutputBytes + int64(limits.MaxMetadataBytes) + int64(limits.MaxDiagnosticBytes) + 40} + diagnostic := &limitedWriter{writer: stderr, remaining: int64(limits.MaxDiagnosticBytes)} + moduleConfig := wazero.NewModuleConfig(). + WithStdin(io.LimitReader(stdin, limits.MaxInputBytes+24+1)). + WithStdout(output).WithStderr(diagnostic) + // No filesystem, environment, clock, random source, or socket capability. + _, err := runtime.InstantiateWithConfig(ctx, module, moduleConfig) + if ctx.Err() != nil { + return ctx.Err() + } + if output.exceeded || diagnostic.exceeded { + return errors.New("HEIC guest exceeded stream limit") + } + return err +} + +type limitedWriter struct { + writer io.Writer + remaining int64 + exceeded bool +} + +func (w *limitedWriter) Write(p []byte) (int, error) { + if int64(len(p)) > w.remaining { + w.exceeded = true + return 0, errors.New("HEIC stream limit") + } + n, err := w.writer.Write(p) + w.remaining -= int64(n) + if n < len(p) && err == nil { + err = io.ErrShortWrite + } + return n, err +} diff --git a/internal/heicdecode/worker/runtime_test.go b/internal/heicdecode/worker/runtime_test.go new file mode 100644 index 00000000..7c948ac0 --- /dev/null +++ b/internal/heicdecode/worker/runtime_test.go @@ -0,0 +1,194 @@ +package worker + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "path/filepath" + "testing" + "time" + + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1" + + "github.com/frathe/picfetch/internal/heicdecode" +) + +// This runtime seam tests WASI bounds only. Native sandbox qualification is a +// separate subprocess test; it cannot be inferred from an in-process runtime. +func TestRuntimeOrdinaryFixture(t *testing.T) { + input, err := os.ReadFile(filepath.Join("..", "..", "..", "scripts", "heicbuild", "testdata", "tenbit.heic")) + if err != nil { + t.Fatal(err) + } + limits := heicdecode.DefaultLimits(1024 * 1024) + limits.WASMMemoryBytes = 128 * 1024 * 1024 + limits.MaxPixels = 1024 * 1024 + limits.MaxOutputBytes = 8 * 1024 * 1024 + var in, out, diagnostic bytes.Buffer + if err = heicdecode.WriteRequest(&in, heicdecode.Request{Operation: heicdecode.Decode, Input: input}, limits); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), limits.Timeout) + defer cancel() + if err = execute(ctx, decoder, &in, &out, &diagnostic, limits); err != nil { + t.Fatalf("execute: %v (%s)", err, diagnostic.String()) + } + result, err := heicdecode.ReadResponse(&out, heicdecode.Decode, limits) + if err != nil { + t.Fatal(err) + } + if result.Image == nil || result.Image.Bounds().Dx() != 16 || result.Image.Bounds().Dy() != 16 { + t.Fatalf("unexpected ordinary fixture result: %+v", result.Config) + } +} + +func TestRuntimeEnforcesMemoryLimit(t *testing.T) { + limits := heicdecode.DefaultLimits(0) + if err := execute(context.Background(), ownedModule(2, []byte{0x0b}), bytes.NewReader(nil), io.Discard, io.Discard, limits); err != nil { + t.Fatalf("owned positive control is invalid: %v", err) + } + limits.WASMMemoryBytes = 64 * 1024 + // This owned module declares two pages; no image or codec is involved. + if err := execute(context.Background(), ownedModule(2, []byte{0x0b}), bytes.NewReader(nil), io.Discard, io.Discard, limits); err == nil { + t.Fatal("module exceeding initial linear-memory limit was admitted") + } + // memory.grow(1) must return -1 at the one-page limit. Trap if it succeeds. + grow := []byte{0x41, 1, 0x40, 0, 0x41, 0x7f, 0x47, 0x04, 0x40, 0, 0x0b, 0x0b} + if err := execute(context.Background(), ownedModule(1, grow), bytes.NewReader(nil), io.Discard, io.Discard, limits); err != nil { + t.Fatalf("growth was not refused at the limit: %v", err) + } +} + +func TestRuntimeCancelsOwnedLoop(t *testing.T) { + limits := heicdecode.DefaultLimits(0) + limits.Timeout = 50 * time.Millisecond + // A PicFetch-owned loop exercises context termination, not a decoder defect. + loop := []byte{0x03, 0x40, 0x0c, 0, 0x0b, 0x0b} + err := execute(context.Background(), ownedModule(1, loop), bytes.NewReader(nil), io.Discard, io.Discard, limits) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("loop termination = %v", err) + } +} + +func TestRuntimeBoundsGuestDiagnostics(t *testing.T) { + limits := heicdecode.DefaultLimits(0) + var diagnostic bytes.Buffer + if err := execute(context.Background(), diagnosticModule(), bytes.NewReader(nil), io.Discard, &diagnostic, limits); err != nil || diagnostic.String() != "ok" { + t.Fatalf("owned diagnostic positive control: err=%v, bytes=%q", err, diagnostic.String()) + } + diagnostic.Reset() + limits.MaxDiagnosticBytes = 1 + // The owned module writes two bytes to stderr. Both the WASI errno and + // process result must report refusal; the host must not forward those bytes. + err := execute(context.Background(), diagnosticModule(), bytes.NewReader(nil), io.Discard, &diagnostic, limits) + if err == nil || diagnostic.Len() != 0 { + t.Fatalf("diagnostic bound: err=%v, forwarded=%d", err, diagnostic.Len()) + } +} + +func TestRuntimeHasNoFilesystemPreopen(t *testing.T) { + module := preopenModule() + limits := heicdecode.DefaultLimits(0) + if err := execute(context.Background(), module, bytes.NewReader(nil), io.Discard, io.Discard, limits); err != nil { + t.Fatalf("filesystem descriptor available to guest: %v", err) + } + // Deliberately give the owned control a temporary directory. Its assertion + // must now trap, demonstrating that it observes a real preopen capability. + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + runtime := wazero.NewRuntimeWithConfig(ctx, runtimeConfig().WithCloseOnContextDone(true)) + defer func() { _ = runtime.Close(context.Background()) }() + if _, err := wasi_snapshot_preview1.Instantiate(ctx, runtime); err != nil { + t.Fatal(err) + } + config := wazero.NewModuleConfig().WithFSConfig(wazero.NewFSConfig().WithDirMount(t.TempDir(), "/")) + if _, err := runtime.InstantiateWithConfig(ctx, module, config); err == nil { + t.Fatal("owned control did not detect the deliberately installed preopen") + } +} + +// preopenModule traps unless fd_prestat_get(3) returns WASI EBADF (8). +// Literal module layouts keep each capability control independently readable. +// +// noinspection DuplicatedCode +func preopenModule() []byte { + module := []byte{0, 0x61, 0x73, 0x6d, 1, 0, 0, 0} + section := func(id byte, content []byte) { + module = append(module, id, byte(len(content))) + module = append(module, content...) + } + section(1, []byte{2, 0x60, 0, 0, 0x60, 2, 0x7f, 0x7f, 1, 0x7f}) + imports := append([]byte{1, 22}, []byte("wasi_snapshot_preview1")...) + imports = append(imports, 14) + imports = append(imports, []byte("fd_prestat_get")...) + section(2, append(imports, 0, 1)) + section(3, []byte{1, 0}) + section(5, []byte{1, 1, 1, 1}) + section(7, []byte{2, 6, '_', 's', 't', 'a', 'r', 't', 0, 1, 6, 'm', 'e', 'm', 'o', 'r', 'y', 2, 0}) + body := []byte{0, 0x41, 3, 0x41, 0, 0x10, 0, 0x41, 8, 0x47, 0x04, 0x40, 0, 0x0b, 0x0b} + section(10, append([]byte{1, byte(len(body))}, body...)) + return module +} + +// diagnosticModule retains its literal layout for an independent stream control. +// +// noinspection DuplicatedCode +func diagnosticModule() []byte { + module := []byte{0, 0x61, 0x73, 0x6d, 1, 0, 0, 0} + section := func(id byte, content []byte) { + module = append(module, id, byte(len(content))) + module = append(module, content...) + } + section(1, []byte{2, 0x60, 0, 0, 0x60, 4, 0x7f, 0x7f, 0x7f, 0x7f, 1, 0x7f}) + imports := append([]byte{1, 22}, []byte("wasi_snapshot_preview1")...) + imports = append(imports, 8) + imports = append(imports, []byte("fd_write")...) + section(2, append(imports, 0, 1)) + section(3, []byte{1, 0}) + section(5, []byte{1, 1, 1, 1}) + section(7, []byte{2, 6, '_', 's', 't', 'a', 'r', 't', 0, 1, 6, 'm', 'e', 'm', 'o', 'r', 'y', 2, 0}) + // fd_write(stderr, iovec at 0, count 1, result at 12); discard errno. + body := []byte{0, 0x41, 2, 0x41, 0, 0x41, 1, 0x41, 12, 0x10, 0, 0x1a, 0x0b} + section(10, append([]byte{1, byte(len(body))}, body...)) + // One iovec points to the two literal bytes following it. + section(11, []byte{1, 0, 0x41, 0, 0x0b, 10, 8, 0, 0, 0, 2, 0, 0, 0, 'o', 'k'}) + return module +} + +// ownedModule builds one function and a one-or-two-page memory from literal +// instructions. It has no imports, image input, or third-party parser code. +func ownedModule(pages byte, body []byte) []byte { + module := []byte{0, 0x61, 0x73, 0x6d, 1, 0, 0, 0, + 1, 4, 1, 0x60, 0, 0, // one () -> () type + 3, 2, 1, 0, // one function + 5, 4, 1, 1, pages, 2, // bounded memory + 7, 10, 1, 6, '_', 's', 't', 'a', 'r', 't', 0, 0, + 10, byte(len(body) + 3), 1, byte(len(body) + 1), 0} + return append(module, body...) +} + +func TestNativeDenialErrorsRequirePermission(t *testing.T) { + for _, err := range []error{os.ErrPermission, &os.PathError{Op: "open", Path: "owned", Err: os.ErrPermission}} { + if !permissionDenied(err) { + t.Fatalf("permission refusal not recognized: %v", err) + } + } + for _, err := range []error{nil, os.ErrNotExist, context.DeadlineExceeded, errors.New("connection refused")} { + if permissionDenied(err) { + t.Fatalf("non-permission failure counted as isolation: %v", err) + } + } +} + +func TestNativeNetworkDenialRejectsUnisolatedProcess(t *testing.T) { + // Even on Windows a timeout is insufficient in an ordinary process. The + // native API must confirm the nonexempt AppContainer identity and policy. + for _, probeErr := range []error{nil, os.ErrDeadlineExceeded, context.Canceled, errors.New("connection refused")} { + if err := confirmNetworkDenial(context.Background(), "127.0.0.1", probeErr); err == nil { + t.Fatalf("unisolated network failure accepted as denial: %v", probeErr) + } + } +} diff --git a/internal/imaging/exif.go b/internal/imaging/exif.go index 172aa4a6..5461fba9 100644 --- a/internal/imaging/exif.go +++ b/internal/imaging/exif.go @@ -233,6 +233,9 @@ func (m Metadata) Empty() bool { // field (or all of them) blank rather than returning an error - there is no // error to report, only "nothing to show". func ReadMetadata(data []byte) Metadata { + if routeSource(data) == sourceIsolated { + return Metadata{} + } if len(data) >= 4 && data[0] == 0xFF && data[1] == 0xD8 { return jpegMetadata(data) } @@ -271,6 +274,9 @@ func jpegMetadata(data []byte) Metadata { // AVIF package's box parser, without invoking its image decoder. JPEG APP1 // and TIFF IFD0 are handled before this is called. func isobmffMetadata(data []byte) Metadata { + if routeSource(data) != sourceAVIF { + return Metadata{} + } if ex, err := avif.DecodeExif(bytes.NewReader(data)); err == nil { return metadataFromISOBMFFExif(ex.Make, ex.Model, ex.ExposureTime, ex.FNumber, ex.ISOSpeed, ex.FocalLength, ex.DateTimeOriginal, ex.DateTime, ex.GPSLatitude, ex.GPSLongitude) } diff --git a/internal/imaging/ico.go b/internal/imaging/ico.go index c5f0807b..0b912048 100644 --- a/internal/imaging/ico.go +++ b/internal/imaging/ico.go @@ -25,6 +25,9 @@ var errInvalidICO = errors.New("invalid or unsupported ICO") // The desktop driver also registers an ICO decoder. Dispatch explicitly so // the viewer's admission cannot depend on global decoder registration order. func decodeRasterConfig(data []byte) (image.Config, string, error) { + if routeSource(data) == sourceIsolated { + return image.Config{}, "", image.ErrFormat + } if bytes.HasPrefix(data, []byte("\x00\x00\x01\x00")) { cfg, err := decodeICOConfig(bytes.NewReader(data)) return cfg, "ico", err @@ -33,6 +36,9 @@ func decodeRasterConfig(data []byte) (image.Config, string, error) { } func decodeRaster(data []byte) (image.Image, string, error) { + if routeSource(data) == sourceIsolated { + return nil, "", image.ErrFormat + } if bytes.HasPrefix(data, []byte("\x00\x00\x01\x00")) { img, err := decodeICO(bytes.NewReader(data)) return img, "ico", err diff --git a/internal/imaging/loader.go b/internal/imaging/loader.go index 9c31302c..9551e4ca 100644 --- a/internal/imaging/loader.go +++ b/internal/imaging/loader.go @@ -21,7 +21,6 @@ import ( "time" "fyne.io/fyne/v2" - "fyne.io/fyne/v2/storage" _ "github.com/fyne-io/image/xpm" // registers XPM with image.Decode _ "github.com/gen2brain/avif" // registers AVIF with image.Decode (WASM/wazero, no cgo) _ "golang.org/x/image/bmp" // registers BMP with image.Decode @@ -227,12 +226,9 @@ func (e *InputTooLargeError) Error() string { return fmt.Sprintf("file exceeds the %d-byte input limit", e.limit) } -// ctxReader wraps r so a Read call fails with ctx's error once ctx is -// done, instead of running r's Read to completion for a result a -// cancelled load has already discarded. readRawBytes's io.ReadAll loop -// calls Read repeatedly for anything bigger than one chunk, so this stops -// a large or slow (e.g. network-mounted) file's read partway through -// rather than only catching the cancellation before the next file starts. +// ctxReader checks cancellation between reads. Reader also closes its source +// on cancellation and joins the close callback; interruption of an already +// blocked storage operation depends on that backend's Close implementation. type ctxReader struct { ctx context.Context r io.Reader @@ -245,49 +241,6 @@ func (cr ctxReader) Read(p []byte) (int, error) { return cr.r.Read(p) } -// readRawBytes reads u's contents into memory, up to MaxEncodedBytes - the -// first step shared by ReadAndProbe (which goes on to decode the header) -// and CaptureDate (which only needs the bytes to walk for Exif). ctx is -// checked once up front, before even opening u - cheap enough there's no -// reason not to, mirroring internal/filesort's Order - and then on every -// Read the io.ReadAll loop makes, via ctxReader, so a load abandoned -// partway through a large read stops doing I/O for it instead of finishing -// unseen. -func readRawBytes(ctx context.Context, u fyne.URI) ([]byte, error) { - if err := ctx.Err(); err != nil { - return nil, err - } - - rc, err := storage.Reader(u) - if err != nil { - return nil, err - } - defer func() { _ = rc.Close() }() - - // Read one byte past the limit rather than exactly up to it: io.ReadAll - // on a LimitReader returns the same short slice whether the file ended - // at the limit or was cut off there, so the extra byte is what - // distinguishes "fits exactly" from "too large". - limit := MaxEncodedBytes() - - data, err := io.ReadAll(io.LimitReader(ctxReader{ctx: ctx, r: rc}, limit+1)) - if err != nil { - return nil, err - } - - // A backend may return its last bytes with EOF while cancellation arrives. - // Do not accept that read just because ReadAll needs no further Read call. - if err := ctx.Err(); err != nil { - return nil, err - } - - if int64(len(data)) > limit { - return nil, &InputTooLargeError{limit: limit} - } - - return data, nil -} - // CaptureDate is the compatibility form of CaptureDateContext. Unreadable or // absent metadata reports ok=false; cancellable callers use CaptureDateContext. func CaptureDate(u fyne.URI) (time.Time, bool) { @@ -301,37 +254,21 @@ func CaptureDate(u fyne.URI) (time.Time, bool) { // Context checks surround the non-interruptible metadata walk. A Read already // blocked in the storage backend must return before cancellation can stop I/O. func CaptureDateContext(ctx context.Context, u fyne.URI) (time.Time, bool, error) { - data, err := readRawBytes(ctx, u) - if err != nil { - return time.Time{}, false, err - } - date := ReadMetadata(data).DateTakenTime - if err := ctx.Err(); err != nil { - return time.Time{}, false, err - } - return date, !date.IsZero(), nil + return (Reader{}).CaptureDate(ctx, u) } -// ReadAndProbe reads u's raw bytes and decodes just its header - via -// image.DecodeConfig, so no pixel data is touched - to learn its final -// display size and reject a zero or absurdly large one instantly, without -// paying for a full decode that was only going to be thrown away. bounds -// already accounts for any Exif orientation swap (a 90/270 degree rotation -// exchanges width and height), so a caller can resize the window to it -// ahead of the full pixel decode in DecodeLoaded. This is also the natural -// hook for a future downsampling pass on huge-but-valid images. -// -// ctx is threaded through to readRawBytes, which is where the actual I/O -// happens - see its own comment. A caller (internal/ui's attemptLoad/ -// preloadOne) whose generation has been superseded by a newer navigation -// or drop cancels ctx instead of just discarding the result once it comes -// back, so an abandoned load stops doing I/O instead of finishing unseen. +// ReadAndProbe is the byte-oriented compatibility path for ordinary images. +// Bounds include Exif orientation. HEIC-aware callers use their injected Reader +// and Source.Decode, because isolated output has no native encoded-byte path. func ReadAndProbe(ctx context.Context, u fyne.URI) (data []byte, bounds image.Rectangle, err error) { - data, err = readRawBytes(ctx, u) - + source, err := (Reader{}).Read(ctx, u) if err != nil { return nil, image.Rectangle{}, err } + return source.encoded, source.bounds, nil +} + +func probeEncoded(data []byte) ([]byte, image.Rectangle, error) { if isSVGData(data) { if len(data) > maxSVGBytes { diff --git a/internal/imaging/preview.go b/internal/imaging/preview.go index 3bd30b2d..8dcb3c51 100644 --- a/internal/imaging/preview.go +++ b/internal/imaging/preview.go @@ -3,7 +3,6 @@ package imaging import ( "bytes" "context" - "fmt" "image" "image/gif" @@ -19,13 +18,10 @@ const previewGIFDecodeBytes int64 = 256 * 1024 * 1024 // animationBytes bounds retained RGBA pixels and frame objects; large source decodes fall back to // a static first frame, with AnimationTruncated set. No preview is upscaled. func LoadAnimatedPreviewContext(ctx context.Context, u fyne.URI, maxEdge int, animationBytes int64) (*LoadedImage, error) { - if maxEdge <= 0 { - return nil, fmt.Errorf("preview edge must be positive: %d", maxEdge) - } - data, bounds, err := ReadAndProbe(ctx, u) - if err != nil { - return nil, err - } + return (Reader{}).Preview(ctx, u, maxEdge, animationBytes) +} + +func decodePreview(ctx context.Context, data []byte, bounds image.Rectangle, maxEdge int, animationBytes int64) (*LoadedImage, error) { if err := ctx.Err(); err != nil { return nil, err } diff --git a/internal/imaging/raw.go b/internal/imaging/raw.go index 631d1332..2a48b9c0 100644 --- a/internal/imaging/raw.go +++ b/internal/imaging/raw.go @@ -30,10 +30,6 @@ func isRAF(data []byte) bool { return bytes.HasPrefix(data, []byte("FUJIFILM")) } -func isISOBMFF(data []byte) bool { - return len(data) >= 8 && string(data[4:8]) == "ftyp" -} - func tiffOrder(data []byte) (binary.ByteOrder, bool) { if len(data) < 8 { return nil, false @@ -58,7 +54,8 @@ func tiffOrder(data []byte) (binary.ByteOrder, bool) { func looksLikePreviewContainer(data []byte) bool { _, isTIFF := tiffOrder(data) - return isTIFF || isISOBMFF(data) || isRAF(data) + kind := routeSource(data) + return isTIFF || kind == sourceCR3 || kind == sourceAVIF || isRAF(data) } // embeddedJPEGPreview returns the largest valid JPEG payload embedded in a diff --git a/internal/imaging/source.go b/internal/imaging/source.go new file mode 100644 index 00000000..4e50b7ec --- /dev/null +++ b/internal/imaging/source.go @@ -0,0 +1,303 @@ +package imaging + +import ( + "bytes" + "context" + "crypto/sha256" + "fmt" + "image" + "io" + "math" + "strings" + "sync" + "time" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/storage" + + "github.com/frathe/picfetch/internal/heicdecode" + "github.com/frathe/picfetch/internal/heicdecode/client" +) + +// HEICDecoder supplies the application's shared admission owner or an inherited +// analysis connection. It returns only protocol-validated pixels and metadata. +type HEICDecoder func(context.Context, heicdecode.Operation, client.Input) (heicdecode.Response, error) + +// Reader is immutable and safe to share. The zero value handles ordinary images +// and refuses HEIC. A configured reader never selects its own executable. +type Reader struct{ heic HEICDecoder } + +// Source owns ordinary encoded bytes or already decoded HEIC pixels. HEIC bytes +// never escape admission: only their byte count and digest survive. Callers must +// treat returned pixels as immutable, just like existing shared cache frames. +type Source struct { + encoded []byte + bounds image.Rectangle + pixels image.Image + metadata Metadata + size int64 + digest [32]byte +} + +func NewReader(decode HEICDecoder) Reader { return Reader{heic: decode} } + +// SupportedExtensions describes this reader's immutable decode capability. +// Package-level declarations deliberately exclude experimental formats. +func (r Reader) SupportedExtensions() []string { + extensions := SupportedExtensions() + if r.heic != nil { + extensions = append(extensions, ".heic", ".heif") + } + return extensions +} + +// IsSupportedImage admits still HEIC only when this reader has an owner. +// It preserves the ordinary extension-first lookup and never decodes a source. +func (r Reader) IsSupportedImage(u fyne.URI) bool { + switch strings.ToLower(u.Extension()) { + case ".heic", ".heif": + return r.heic != nil + case ".heics", ".heifs": + return false + } + if IsSupportedImage(u) { + return true + } + if r.heic != nil { + switch strings.ToLower(u.MimeType()) { + case "image/heic", "image/heif": + return true + } + } + return false +} + +// Read probes ordinary images without decoding their pixels; HEIC bounds and +// pixels arrive together so a later Decode cannot start another helper job. +func (r Reader) Read(ctx context.Context, u fyne.URI) (*Source, error) { + return r.read(ctx, u, heicdecode.Decode) +} + +func (r Reader) Metadata(ctx context.Context, u fyne.URI) (Metadata, error) { + info, err := r.InspectMetadata(ctx, u) + if err != nil { + return Metadata{}, err + } + return info.Values, nil +} + +// MetadataInfo includes the editing capability established from the same bytes +// as its values. HEIC metadata never enables the JPEG-only strip operation. +type MetadataInfo struct { + Values Metadata + CanStrip bool + FileSize int64 +} + +func (r Reader) InspectMetadata(ctx context.Context, u fyne.URI) (MetadataInfo, error) { + source, err := r.read(ctx, u, heicdecode.DecodeExif) + if err != nil { + return MetadataInfo{}, err + } + m := source.Metadata() + canStrip := source.encoded != nil && CanStripJPEGMetadata(source.encoded) && !m.Empty() + if err = ctx.Err(); err != nil { + return MetadataInfo{}, err + } + return MetadataInfo{Values: m, CanStrip: canStrip, FileSize: source.size}, nil +} + +func (r Reader) CaptureDate(ctx context.Context, u fyne.URI) (time.Time, bool, error) { + m, err := r.Metadata(ctx, u) + if err != nil { + return time.Time{}, false, err + } + return m.DateTakenTime, !m.DateTakenTime.IsZero(), nil +} + +func (r Reader) read(ctx context.Context, u fyne.URI, op heicdecode.Operation) (*Source, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + ext := strings.ToLower(u.Extension()) + isolated := ext == ".heic" || ext == ".heif" || ext == ".heics" || ext == ".heifs" + if isolated && r.heic == nil { + return nil, client.ErrUnavailable + } + file, err := storage.Reader(u) + if err != nil { + return nil, err + } + var once sync.Once + closeFile := func() { once.Do(func() { _ = file.Close() }) } + joinClose := closeSourceOnCancel(ctx, closeFile) + defer func() { closeFile(); joinClose() }() + limit := MaxEncodedBytes() + var prefix []byte + if !isolated { + prefix, err = sourcePrefix(ctxReader{ctx: ctx, r: file}, limit) + if err != nil { + return nil, sourceReadError(ctx, err) + } + isolated = routeSource(prefix) == sourceIsolated + } + read := func(ctx context.Context, maxBytes int64) ([]byte, error) { + join := closeSourceOnCancel(ctx, closeFile) + defer join() + return readSourceBytes(ctx, io.MultiReader(bytes.NewReader(prefix), file), min(limit, maxBytes)) + } + if isolated { + if r.heic == nil { + return nil, client.ErrUnavailable + } + source := &Source{} + result, err := r.heic(ctx, op, func(ctx context.Context, maxBytes int64) ([]byte, error) { + data, err := read(ctx, maxBytes) + if err == nil { + source.size = int64(len(data)) + source.digest = sha256.Sum256(data) + } + return data, err + }) + if err != nil { + return nil, sourceReadError(ctx, err) + } + if err := ctx.Err(); err != nil { + return nil, err + } + if op == heicdecode.Decode { + if result.Image == nil || result.Image.Bounds() != image.Rect(0, 0, result.Config.Width, result.Config.Height) { + return nil, heicdecode.ErrInvalidResponse + } + source.pixels, source.bounds = result.Image, result.Image.Bounds() + } + if m := result.Metadata; m != nil { + source.metadata = metadataFromISOBMFFExif(m.Make, m.Model, m.ExposureTime, m.FNumber, m.ISOSpeed, m.FocalLength, m.DateTimeOriginal, m.DateTime, m.GPSLatitude, m.GPSLongitude) + } + return source, nil + } + data, err := read(ctx, limit) + if err != nil { + return nil, err + } + source := &Source{encoded: data, size: int64(len(data))} + if op == heicdecode.Decode { + _, source.bounds, err = probeEncoded(data) + if err != nil { + return nil, err + } + } + if err = ctx.Err(); err != nil { + return nil, err + } + return source, nil +} + +func (s *Source) Bounds() image.Rectangle { return s.bounds } + +func (s *Source) SHA256() [32]byte { + if s.encoded != nil { + return sha256.Sum256(s.encoded) + } + return s.digest +} + +func (s *Source) Metadata() Metadata { + if s.encoded != nil { + return ReadMetadata(s.encoded) + } + return s.metadata +} + +func (s *Source) Decode(ctx context.Context, maxAnimBytes int64) (*LoadedImage, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if s.pixels != nil { + // Container transforms have already been applied by the guest. Exif + // orientation is descriptive metadata, never a second pixel transform. + return &LoadedImage{Frames: []image.Image{s.pixels}, FileSize: s.size, HasEXIF: !s.metadata.Empty()}, nil + } + return DecodeRecord(ctx, s.encoded, maxAnimBytes) +} + +func (r Reader) Thumbnail(ctx context.Context, u fyne.URI, maxEdge int) (image.Image, image.Rectangle, error) { + if maxEdge <= 0 { + return nil, image.Rectangle{}, fmt.Errorf("thumbnail edge must be positive: %d", maxEdge) + } + source, err := r.Read(ctx, u) + if err != nil { + return nil, image.Rectangle{}, err + } + pixels, err := source.thumbnail(ctx, maxEdge) + if err != nil { + return nil, image.Rectangle{}, err + } + return pixels, source.bounds, nil +} + +func (s *Source) thumbnail(ctx context.Context, maxEdge int) (image.Image, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + var pixels image.Image + var err error + if s.pixels != nil { + pixels = scaleToFit(s.pixels, maxEdge) + } else { + pixels, err = decodeThumbnailAtEdge(ctx, s.encoded, s.bounds, maxEdge) + } + if cancelled := ctx.Err(); cancelled != nil { + return nil, cancelled + } + return pixels, err +} + +func (r Reader) Preview(ctx context.Context, u fyne.URI, maxEdge int, animationBytes int64) (*LoadedImage, error) { + if maxEdge <= 0 { + return nil, fmt.Errorf("preview edge must be positive: %d", maxEdge) + } + source, err := r.Read(ctx, u) + if err != nil { + return nil, err + } + if source.pixels == nil { + return decodePreview(ctx, source.encoded, source.bounds, maxEdge, animationBytes) + } + pixels, err := source.thumbnail(ctx, maxEdge) + if err != nil { + return nil, err + } + return &LoadedImage{Frames: []image.Image{pixels}}, nil +} + +func readSourceBytes(ctx context.Context, input io.Reader, limit int64) ([]byte, error) { + if limit <= 0 || limit == math.MaxInt64 { + return nil, &InputTooLargeError{limit: limit} + } + data, err := io.ReadAll(io.LimitReader(ctxReader{ctx: ctx, r: input}, limit+1)) + if err = sourceReadError(ctx, err); err != nil { + return nil, err + } + if int64(len(data)) > limit { + return nil, &InputTooLargeError{limit: limit} + } + return data, nil +} + +func sourceReadError(ctx context.Context, err error) error { + if ctx.Err() != nil { + return ctx.Err() + } + return err +} + +func closeSourceOnCancel(ctx context.Context, closeFile func()) func() { + done := make(chan struct{}) + stop := context.AfterFunc(ctx, func() { defer close(done); closeFile() }) + return func() { + if !stop() { + <-done + } + } +} diff --git a/internal/imaging/source_kind.go b/internal/imaging/source_kind.go new file mode 100644 index 00000000..ba41a1be --- /dev/null +++ b/internal/imaging/source_kind.go @@ -0,0 +1,102 @@ +package imaging + +import ( + "encoding/binary" + "errors" + "io" +) + +const maxSourcePrefix = 64 * 1024 + +type sourceKind uint8 + +const ( + sourceOrdinary sourceKind = iota + sourceAVIF + sourceCR3 + sourceIsolated +) + +// Inspect only the leading file-type box. Unknown/oversized BMFF types and +// containers beginning with another common box go to isolation, never a native +// fallback. This is dispatch, not a walk of image items, properties or metadata. +func routeSource(data []byte) sourceKind { + if len(data) < 8 { + return sourceOrdinary + } + switch string(data[4:8]) { + case "free", "skip", "wide", "meta", "moov", "mdat": + return sourceIsolated + case "ftyp": + default: + return sourceOrdinary + } + size, header := sourceTypeSize(data) + if size < header+8 || size > maxSourcePrefix || size > uint64(len(data)) || (size-header)%4 != 0 { + return sourceIsolated + } + kind := sourceIsolated + for offset := header; offset < size; offset += 4 { + if offset == header+4 { // minor version, not a compatible brand + continue + } + switch string(data[offset : offset+4]) { + case "heic", "heix", "hevc", "hevx", "heim", "heis", "hevm", "hevs": + return sourceIsolated + case "avif", "avis": + kind = sourceAVIF + case "crx ": + if kind != sourceAVIF { + kind = sourceCR3 + } + } + } + return kind +} + +func sourceTypeSize(data []byte) (size, header uint64) { + size, header = uint64(binary.BigEndian.Uint32(data[:4])), 8 + if size == 1 { + if len(data) < 16 { + return 0, 16 + } + return binary.BigEndian.Uint64(data[8:16]), 16 + } + return size, header +} + +func sourcePrefix(r io.Reader, limit int64) ([]byte, error) { + var prefix []byte + readTo := func(size uint64) error { + size = min(size, uint64(maxSourcePrefix), uint64(max(0, limit))) + if size <= uint64(len(prefix)) { + return nil + } + old := len(prefix) + prefix = append(prefix, make([]byte, int(size)-old)...) + n, err := io.ReadFull(r, prefix[old:]) + prefix = prefix[:old+n] + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + return nil + } + return err + } + if err := readTo(8); err != nil { + return nil, err + } + if len(prefix) < 8 || string(prefix[4:8]) != "ftyp" { + return prefix, nil + } + if binary.BigEndian.Uint32(prefix[:4]) == 1 { + if err := readTo(16); err != nil { + return nil, err + } + } + size, _ := sourceTypeSize(prefix) + if size <= maxSourcePrefix { + if err := readTo(size); err != nil { + return nil, err + } + } + return prefix, nil +} diff --git a/internal/imaging/source_test.go b/internal/imaging/source_test.go new file mode 100644 index 00000000..98de7587 --- /dev/null +++ b/internal/imaging/source_test.go @@ -0,0 +1,281 @@ +package imaging + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/binary" + "errors" + "image" + "image/color" + "io" + "os" + "path/filepath" + "slices" + "sync" + "sync/atomic" + "testing" + + "fyne.io/fyne/v2/storage" + + "github.com/frathe/picfetch/internal/heicdecode" + "github.com/frathe/picfetch/internal/heicdecode/client" + "github.com/frathe/picfetch/internal/uitest" +) + +func TestExperimentalHEICFormats(t *testing.T) { + configured := NewReader(func(_ context.Context, _ heicdecode.Operation, _ client.Input) (heicdecode.Response, error) { + t.Fatal("format admission decoded a source") + return heicdecode.Response{}, nil + }) + for _, ext := range []string{".heic", ".HEIF"} { + u := uitest.FakeURI{FileName: "photo" + ext, Ext: ext} + if !configured.IsSupportedImage(u) || (Reader{}).IsSupportedImage(u) || IsSupportedImage(u) { + t.Fatalf("unexpected session admission for %s", ext) + } + } + for _, ext := range []string{".heics", ".heifs"} { + if configured.IsSupportedImage(uitest.FakeURI{FileName: "sequence" + ext, Ext: ext}) { + t.Fatalf("sequence admitted: %s", ext) + } + } + for _, ext := range SupportedExtensions() { + u := uitest.FakeURI{FileName: "ordinary" + ext, Ext: ext} + if !configured.IsSupportedImage(u) || !(Reader{}).IsSupportedImage(u) { + t.Fatalf("ordinary format lost: %s", ext) + } + } + formats := configured.SupportedExtensions() + if !slices.Contains(formats, ".heic") || !slices.Contains(formats, ".heif") || slices.Contains(SupportedExtensions(), ".heic") { + t.Fatal("experimental format declarations escaped the reader") + } + formats[len(formats)-1] = ".changed" + if slices.Contains(configured.SupportedExtensions(), ".changed") { + t.Fatal("format result aliases reader state") + } +} + +func TestSourceHEICAdmissionAndPixels(t *testing.T) { + data, err := os.ReadFile(filepath.Join("testdata", "test_exif.heic")) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"photo.heic", "photo.heif", "renamed.jpg"} { + t.Run(name, func(t *testing.T) { + var read, closed atomic.Int64 + stream := bytes.NewReader(data) + u := uitest.ReaderURI(storage.NewFileURI(name), func() (io.ReadCloser, error) { + return uitest.ReadCloser{ReadFunc: func(p []byte) (int, error) { + n, err := stream.Read(p) + read.Add(int64(n)) + return n, err + }, CloseFunc: func() error { closed.Add(1); return nil }}, nil + }) + pixels := image.NewNRGBA64(image.Rect(0, 0, 2, 3)) + pixels.SetNRGBA64(1, 2, color.NRGBA64{R: 0x1234, G: 0xabcd, B: 0x5678, A: 0x4321}) + calls := 0 + r := NewReader(func(ctx context.Context, op heicdecode.Operation, input client.Input) (heicdecode.Response, error) { + calls++ + if op != heicdecode.Decode { + t.Fatalf("operation = %v", op) + } + if name != "renamed.jpg" && read.Load() != 0 { + t.Fatal("extension source read before admission") + } + if read.Load() >= int64(len(data)) { + t.Fatal("full source read before admission") + } + got, err := input(ctx, int64(len(data))) + if err != nil || !bytes.Equal(got, data) { + t.Fatalf("input = %d bytes, %v", len(got), err) + } + return heicdecode.Response{Image: pixels, Config: image.Config{Width: 2, Height: 3, ColorModel: color.NRGBA64Model}, Metadata: &heicdecode.Metadata{Orientation: 6, Make: "Camera", DateTimeOriginal: "2026:09:16 12:34:56"}}, nil + }) + source, err := r.Read(context.Background(), u) + if err != nil { + t.Fatal(err) + } + if source.Bounds() != pixels.Bounds() || source.SHA256() != sha256.Sum256(data) { + t.Fatal("source bounds/digest changed") + } + for range 2 { + loaded, err := source.Decode(context.Background(), 0) + if err != nil { + t.Fatal(err) + } + if len(loaded.Frames) != 1 || loaded.Frames[0] != pixels || loaded.FileSize != int64(len(data)) || !loaded.HasEXIF { + t.Fatalf("source record = %+v", loaded) + } + } + if source.Metadata().Make != "Camera" || calls != 1 || read.Load() != int64(len(data)) || closed.Load() != 1 { + t.Fatal("source decoded/read twice or lost metadata/closure") + } + }) + } +} + +func TestSourceMetadataUsesIsolatedOperation(t *testing.T) { + data := []byte("owned source callback bytes") + path := writeTempFile(t, "photo.heic", data) + r := NewReader(func(ctx context.Context, op heicdecode.Operation, input client.Input) (heicdecode.Response, error) { + if op != heicdecode.DecodeExif { + t.Fatalf("metadata operation = %v", op) + } + if _, err := input(ctx, int64(len(data))); err != nil { + return heicdecode.Response{}, err + } + return heicdecode.Response{Metadata: &heicdecode.Metadata{Make: "Owned", GPSLatitude: 50.8, GPSLongitude: 4.3}}, nil + }) + m, err := r.Metadata(context.Background(), storage.NewFileURI(path)) + if err != nil || m.Make != "Owned" || !m.HasGPS || m.Latitude != 50.8 { + t.Fatalf("metadata = %+v, %v", m, err) + } +} + +func TestSourceCancellationClosesAdmittedInput(t *testing.T) { + started, stopped := make(chan struct{}), make(chan struct{}) + var once sync.Once + u := uitest.ReaderURI(storage.NewFileURI("owned.heic"), func() (io.ReadCloser, error) { + return uitest.ReadCloser{ReadFunc: func(_ []byte) (int, error) { close(started); <-stopped; return 0, io.ErrClosedPipe }, CloseFunc: func() error { once.Do(func() { close(stopped) }); return nil }}, nil + }) + r := NewReader(func(ctx context.Context, _ heicdecode.Operation, input client.Input) (heicdecode.Response, error) { + _, err := input(ctx, 128) + return heicdecode.Response{}, err + }) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { _, err := r.Read(ctx, u); done <- err }() + select { + case err := <-done: + t.Fatalf("reader returned before admission: %v", err) + case <-started: + } + cancel() + if err := <-done; !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled read = %v", err) + } +} + +func TestSourceWithoutOwnerRejectsHEIC(t *testing.T) { + path := writeTempFile(t, "photo.heic", []byte("owned bytes")) + if _, err := (Reader{}).Read(context.Background(), storage.NewFileURI(path)); !errors.Is(err, client.ErrUnavailable) { + t.Fatalf("unconfigured source = %v", err) + } +} + +func TestSourceOrdinaryOrientationAndFacts(t *testing.T) { + data := halfRedHalfBlueJPEG(t, 20, 10, 6) + path := writeTempFile(t, "photo.jpg", data) + source, err := (Reader{}).Read(context.Background(), storage.NewFileURI(path)) + if err != nil { + t.Fatal(err) + } + wantData, wantBounds, err := ReadAndProbe(context.Background(), storage.NewFileURI(path)) + if err != nil { + t.Fatal(err) + } + want, err := DecodeRecord(context.Background(), wantData, 0) + if err != nil { + t.Fatal(err) + } + got, err := source.Decode(context.Background(), 0) + if err != nil { + t.Fatal(err) + } + if source.Bounds() != wantBounds || got.Frames[0].Bounds() != want.Frames[0].Bounds() || got.FileSize != want.FileSize || got.HasEXIF != want.HasEXIF || source.SHA256() != sha256.Sum256(data) { + t.Fatal("ordinary source facts changed") + } +} + +func TestSourceDispatchTypes(t *testing.T) { + for _, c := range []struct { + name string + brands []string + want sourceKind + }{ + {"heic", []string{"heic", "mif1"}, sourceIsolated}, + {"generic HEIF", []string{"mif1"}, sourceIsolated}, + {"avif", []string{"avif", "mif1"}, sourceAVIF}, + {"cr3", []string{"crx "}, sourceCR3}, + {"mixed", []string{"avif", "heic"}, sourceIsolated}, + } { + t.Run(c.name, func(t *testing.T) { + data := make([]byte, 12+4*len(c.brands)) + binary.BigEndian.PutUint32(data[:4], uint32(len(data))) + copy(data[4:8], "ftyp") + copy(data[8:12], c.brands[0]) + for i, brand := range c.brands[1:] { + copy(data[16+4*i:], brand) + } + if got := routeSource(data); got != c.want { + t.Fatalf("route = %v, want %v", got, c.want) + } + }) + } +} + +func TestByteMetadataAndPreviewRefuseHEIC(t *testing.T) { + data, err := os.ReadFile(filepath.Join("testdata", "test_exif.heic")) + if err != nil { + t.Fatal(err) + } + if !ReadMetadata(data).Empty() { + t.Fatal("byte metadata reached native HEIC container parsing") + } + if looksLikePreviewContainer(data) { + t.Fatal("HEIC admitted to native embedded-preview scan") + } +} + +func TestSourceDerivedViewsAndCaptureDate(t *testing.T) { + path := writeTempFile(t, "photo.heic", []byte("owned read bytes")) + u := storage.NewFileURI(path) + var operations []heicdecode.Operation + r := NewReader(func(ctx context.Context, op heicdecode.Operation, input client.Input) (heicdecode.Response, error) { + operations = append(operations, op) + if _, err := input(ctx, 128); err != nil { + return heicdecode.Response{}, err + } + return heicdecode.Response{Image: image.NewNRGBA64(image.Rect(0, 0, 40, 20)), Config: image.Config{Width: 40, Height: 20}, Metadata: &heicdecode.Metadata{DateTimeOriginal: "2026:09:16 12:34:56"}}, nil + }) + thumb, bounds, err := r.Thumbnail(context.Background(), u, 10) + if err != nil || thumb.Bounds().Size() != image.Pt(10, 5) || bounds.Size() != image.Pt(40, 20) { + t.Fatalf("thumbnail = %v, %v", bounds, err) + } + preview, err := r.Preview(context.Background(), u, 20, 1024) + if err != nil { + t.Fatal(err) + } + if len(preview.Frames) != 1 || preview.Frames[0].Bounds().Size() != image.Pt(20, 10) || len(preview.Delays) != 0 { + t.Fatal("HEIC preview changed geometry or became animated") + } + date, ok, err := r.CaptureDate(context.Background(), u) + if err != nil || !ok || date.Format("2006-01-02") != "2026-09-16" { + t.Fatalf("capture date = %v, %v, %v", date, ok, err) + } + if len(operations) != 3 || operations[0] != heicdecode.Decode || operations[1] != heicdecode.Decode || operations[2] != heicdecode.DecodeExif { + t.Fatalf("operations = %v", operations) + } +} + +func TestSourceAdmittedCancellationClosesInput(t *testing.T) { + started, stopped := make(chan struct{}), make(chan struct{}) + var once sync.Once + u := uitest.ReaderURI(storage.NewFileURI("owned.heic"), func() (io.ReadCloser, error) { + return uitest.ReadCloser{ReadFunc: func(_ []byte) (int, error) { close(started); <-stopped; return 0, io.ErrClosedPipe }, CloseFunc: func() error { once.Do(func() { close(stopped) }); return nil }}, nil + }) + admitted, cancel := context.WithCancel(context.Background()) + defer cancel() + r := NewReader(func(_ context.Context, _ heicdecode.Operation, input client.Input) (heicdecode.Response, error) { + _, err := input(admitted, 128) + return heicdecode.Response{}, err + }) + done := make(chan error, 1) + go func() { _, err := r.Read(context.Background(), u); done <- err }() + <-started + cancel() + if err := <-done; !errors.Is(err, context.Canceled) { + t.Fatalf("admitted cancellation = %v", err) + } +} diff --git a/internal/imaging/testdata/README.md b/internal/imaging/testdata/README.md index bc123082..cfad8c0a 100644 --- a/internal/imaging/testdata/README.md +++ b/internal/imaging/testdata/README.md @@ -1,7 +1,9 @@ # Image fixtures -`test_exif.heic` is retained solely to test rejection of unsupported HEIC -images. It contains image data, not decoder code. It was copied from +`test_exif.heic` tests production rejection of unsupported HEIC images and the +candidate sandboxed helper's ordinary metadata/container-rotation behavior. +It is a synthetic TestCam/Model123 fixture, not a camera photograph. It contains +image data, not decoder code. It was copied from [gen2brain/heic](https://github.com/gen2brain/heic/tree/v0.7.1/testdata), whose [MIT license](https://github.com/gen2brain/heic/blob/v0.7.1/LICENSE) is retained below. The HEIC decoder is not a PicFetch dependency. diff --git a/internal/mosaic/generator.go b/internal/mosaic/generator.go index 0852d494..77b8e2f5 100644 --- a/internal/mosaic/generator.go +++ b/internal/mosaic/generator.go @@ -36,8 +36,13 @@ type Generator struct { } // New creates a mosaic generator backed by PicFetch's canonical image loader. -func New() *Generator { - return &Generator{load: loadCanonicalSource, cacheBytes: defaultRepeatCacheBytes, previewClock: time.Now} +func New() *Generator { return NewWithReader(imaging.Reader{}) } + +// NewWithReader binds every source to the caller's canonical reader. +func NewWithReader(reader imaging.Reader) *Generator { + return &Generator{load: func(ctx context.Context, uri fyne.URI) (*loadedSource, error) { + return loadSourceWithReader(ctx, uri, reader) + }, cacheBytes: defaultRepeatCacheBytes, previewClock: time.Now} } // Generate renders one validated request with a fresh production generator. @@ -234,11 +239,15 @@ func validateStoredRequest(request Request) error { } func loadCanonicalSource(ctx context.Context, uri fyne.URI) (*loadedSource, error) { - data, bounds, err := imaging.ReadAndProbe(ctx, uri) + return loadSourceWithReader(ctx, uri, imaging.Reader{}) +} + +func loadSourceWithReader(ctx context.Context, uri fyne.URI, reader imaging.Reader) (*loadedSource, error) { + source, err := reader.Read(ctx, uri) if err != nil { return nil, err } - decoded, err := imaging.DecodeLoaded(ctx, data, 0) + decoded, err := source.Decode(ctx, 0) if err != nil { return nil, err } @@ -247,7 +256,7 @@ func loadCanonicalSource(ctx context.Context, uri fyne.URI) (*loadedSource, erro } sourceBounds := decoded.Frames[0].Bounds() if decoded.Vector != nil { - sourceBounds = bounds + sourceBounds = source.Bounds() } return &loadedSource{ diff --git a/internal/preferences/preferences.go b/internal/preferences/preferences.go index 89f7927b..3cae7466 100644 --- a/internal/preferences/preferences.go +++ b/internal/preferences/preferences.go @@ -38,6 +38,7 @@ const ( keyWindowPosSet = "windowPosSet" keyFavoritePreviewCache = "favoritePreviewCache" + keyExperimentalHEIC = "experimentalHEIC" keySimilarityFavoriteCache = "similarityFavoriteCache" keySimilarityLooseCache = "similarityLooseCache" keyAnalysisCacheLimitMiB = "analysisCacheLimitMiB" @@ -162,6 +163,9 @@ type State struct { // check) so that a user who explicitly turns it off can have that // choice persist. FavoritePreviewCache bool + // ExperimentalHEIC is saved intent for the next application launch. + // Runtime capability belongs to the application's immutable image services. + ExperimentalHEIC bool // SimilarityFavoriteCache and SimilarityAutoFit default on; automatic map updates default off. SimilarityFavoriteCache, SimilarityAutoUpdate, SimilarityAutoFit bool SimilarityLooseCache bool @@ -234,6 +238,7 @@ func Save(app fyne.App, s State) { p.SetString(keyThemeMode, s.ThemeMode.PrefValue()) p.SetBool(keySlideShuffle, s.SlideShuffle) p.SetBool(keyFavoritePreviewCache, s.FavoritePreviewCache) + p.SetBool(keyExperimentalHEIC, s.ExperimentalHEIC) p.SetBool(keySimilarityFavoriteCache, s.SimilarityFavoriteCache) p.SetBool(keySimilarityLooseCache, s.SimilarityLooseCache) if s.AnalysisCacheLimitMiB > 0 && uint64(s.AnalysisCacheLimitMiB) <= ^uint64(0)/(1024*1024) { @@ -387,6 +392,7 @@ func Load(app fyne.App) State { MosaicWindow: loadGeometry(p, mosaicWinKeys), MosaicSettings: mosaicSettings, FavoritePreviewCache: p.BoolWithFallback(keyFavoritePreviewCache, true), + ExperimentalHEIC: p.Bool(keyExperimentalHEIC), SimilarityFavoriteCache: p.BoolWithFallback(keySimilarityFavoriteCache, true), SimilarityLooseCache: p.BoolWithFallback(keySimilarityLooseCache, true), AnalysisCacheLimitMiB: analysisCacheLimit(p.IntWithFallback(keyAnalysisCacheLimitMiB, 2048)), diff --git a/internal/preferences/preferences_test.go b/internal/preferences/preferences_test.go index ea05c6b8..e53bb9df 100644 --- a/internal/preferences/preferences_test.go +++ b/internal/preferences/preferences_test.go @@ -50,6 +50,22 @@ func TestAnalysisCachePreferences(t *testing.T) { } } +func TestExperimentalHEICPreferences(t *testing.T) { + app := test.NewApp() + if Load(app).ExperimentalHEIC { + t.Fatal("missing preference enabled HEIC") + } + for _, enabled := range []bool{true, false} { + Save(app, State{ExperimentalHEIC: enabled}) + if got := Load(app).ExperimentalHEIC; got != enabled { + t.Fatalf("saved HEIC = %v, want %v", got, enabled) + } + if app.Preferences().Bool("experimentalHEIC") != enabled { + t.Fatal("HEIC preference stored under the wrong key") + } + } +} + func TestMosaicPreferences_DefaultsAndRoundTrip(t *testing.T) { app := test.NewApp() if got := Load(app).MosaicSettings; got != mosaic.DefaultSettings() { diff --git a/internal/similarity/analyze.go b/internal/similarity/analyze.go index 0ffb92db..a31073f2 100644 --- a/internal/similarity/analyze.go +++ b/internal/similarity/analyze.go @@ -3,7 +3,6 @@ package similarity import ( "bytes" "context" - "crypto/sha256" "fmt" "image/jpeg" "os" @@ -125,13 +124,13 @@ func analyzeLocal(ctx context.Context, req request, controls <-chan Control, emi } if sourceErr == nil && reused && (item.Facts.Version != FactsVersion || item.Facts.Width <= 0 || item.Facts.Height <= 0) { factsStart := time.Now() - data, bounds, readErr := imaging.ReadAndProbe(ctx, storage.NewFileURI(path)) + source, readErr := req.reader.Read(ctx, storage.NewFileURI(path)) sourceErr = readErr - if sourceErr == nil && fmt.Sprintf("%x", sha256.Sum256(data)) != item.SHA256 { + if sourceErr == nil && fmt.Sprintf("%x", source.SHA256()) != item.SHA256 { sourceErr = fmt.Errorf("source changed since cached representation") } if sourceErr == nil { - item.Facts = imageFacts(path, data, bounds) + item.Facts = imageFacts(path, source) backfilled = true } event.Measurements.DecodeSeconds += time.Since(factsStart).Seconds() @@ -147,12 +146,12 @@ func analyzeLocal(ctx context.Context, req request, controls <-chan Control, emi } } decodeStart := time.Now() - data, bounds, readErr := imaging.ReadAndProbe(ctx, storage.NewFileURI(path)) + source, readErr := req.reader.Read(ctx, storage.NewFileURI(path)) sourceErr = readErr if sourceErr == nil { - item.Facts = imageFacts(path, data, bounds) - item.SHA256 = fmt.Sprintf("%x", sha256.Sum256(data)) - loaded, decodeErr := imaging.DecodeLoaded(ctx, data, 1) + item.Facts = imageFacts(path, source) + item.SHA256 = fmt.Sprintf("%x", source.SHA256()) + loaded, decodeErr := source.Decode(ctx, 1) event.Measurements.DecodeSeconds += time.Since(decodeStart).Seconds() sourceErr = decodeErr if sourceErr == nil { diff --git a/internal/similarity/cache_lifecycle_test.go b/internal/similarity/cache_lifecycle_test.go index 154e81ae..6b2a6987 100644 --- a/internal/similarity/cache_lifecycle_test.go +++ b/internal/similarity/cache_lifecycle_test.go @@ -364,7 +364,7 @@ func TestAnalysisCacheConfinementManagedUsageAndTemps(t *testing.T) { } } files, err := favstore.Load(policy.Roots.FavoritesDir, "Trip") - if err != nil || len(files) != 1 || files[0].Path() != item.Path { + if err != nil || len(files) != 1 || filepath.FromSlash(files[0].Path()) != item.Path { t.Fatalf("cleanup changed Favorite membership: %v, %v", files, err) } if data, err := os.ReadFile(item.Path); err != nil || string(data) != "source" { diff --git a/internal/similarity/cache_records.go b/internal/similarity/cache_records.go index 2f3972c5..d17e0c9b 100644 --- a/internal/similarity/cache_records.go +++ b/internal/similarity/cache_records.go @@ -3,6 +3,7 @@ package similarity import ( "context" "errors" + "fmt" "io/fs" "os" "regexp" @@ -37,7 +38,7 @@ func (i *analysisInventory) scan(ctx context.Context, roots CacheRoots, progress var root *os.Root var err error if favorite != nil { - root, err = favorite.root.OpenRoot("analysis") + root, err = openAnalysisDirectory(favorite.root, "analysis") } else { parent, openErr := os.OpenRoot(base) if errors.Is(openErr, os.ErrNotExist) { @@ -47,7 +48,7 @@ func (i *analysisInventory) scan(ctx context.Context, roots CacheRoots, progress failures = append(failures, openErr) return } - root, err = parent.OpenRoot(relative) + root, err = openAnalysisDirectory(parent, relative) _ = parent.Close() } if errors.Is(err, os.ErrNotExist) { @@ -124,3 +125,20 @@ func (i *analysisInventory) scan(ctx context.Context, roots CacheRoots, progress i.usage.Incomplete = i.usage.General.Incomplete || i.usage.Favorite.Incomplete return errors.Join(failures...) } + +// Windows can report a regular file passed to OpenRoot as ErrNotExist. Keep +// that invalid cache layout visible as a partial inventory, rather than +// reporting an empty, healthy cache. The opened parent pins this check. +func openAnalysisDirectory(parent *os.Root, name string) (*os.Root, error) { + root, err := parent.OpenRoot(name) + if errors.Is(err, os.ErrNotExist) { + info, statErr := parent.Stat(name) + if statErr == nil && !info.IsDir() { + return nil, fmt.Errorf("analysis cache %s is not a directory", name) + } + if statErr != nil && !errors.Is(statErr, os.ErrNotExist) { + return nil, statErr + } + } + return root, err +} diff --git a/internal/similarity/cache_test.go b/internal/similarity/cache_test.go index 68ff329a..fddbe81c 100644 --- a/internal/similarity/cache_test.go +++ b/internal/similarity/cache_test.go @@ -5,6 +5,7 @@ import ( "errors" "os" "path/filepath" + "runtime" "testing" "fyne.io/fyne/v2" @@ -62,10 +63,17 @@ func TestFavoriteAnalysisFollowsOpenedDirectory(t *testing.T) { t.Fatal(err) } defer func() { _ = root.Close() }() - if err := os.Rename(favstore.Dir(dir, "Trip"), filepath.Join(dir, "Moved")); err != nil { - t.Fatal(err) + renameErr := os.Rename(favstore.Dir(dir, "Trip"), filepath.Join(dir, "Moved")) + if runtime.GOOS == "windows" { + // Windows pins an os.Root directory against rename. Verify the + // same membership guarantee through that platform's refusal. + if renameErr == nil { + t.Fatal("Windows renamed a retained directory root") + } + } else if renameErr != nil { + t.Fatal(renameErr) } - if replacement { + if replacement && renameErr == nil { if err := favstore.Save(dir, "Trip", []fyne.URI{storage.NewFileURI(other)}); err != nil { t.Fatal(err) } @@ -77,6 +85,14 @@ func TestFavoriteAnalysisFollowsOpenedDirectory(t *testing.T) { if len(favorite.members) != 1 || !favorite.members[original] || favorite.members[other] || !favorite.current() { t.Fatalf("opened directory associated with replacement membership: %v", favorite.members) } + if renameErr != nil { + if err := root.Close(); err != nil { + t.Fatal(err) + } + if err := os.Rename(favstore.Dir(dir, "Trip"), filepath.Join(dir, "Moved")); err != nil { + t.Fatalf("released directory still cannot be renamed: %v", err) + } + } }) } } diff --git a/internal/similarity/client.go b/internal/similarity/client.go index f4fdf825..d4e780c6 100644 --- a/internal/similarity/client.go +++ b/internal/similarity/client.go @@ -9,11 +9,13 @@ import ( "io" "net/http" "os" + "os/exec" "os/signal" "path/filepath" "syscall" "time" + heicclient "github.com/frathe/picfetch/internal/heicdecode/client" "github.com/frathe/picfetch/internal/imaging" ) @@ -45,6 +47,9 @@ func (d *workerDecoder) Decode(value any) error { // Assets may override the installed assets directory for a local trial. type Client struct { Assets string + // HEIC is the application's shared admission owner. Nil preserves the + // existing unavailable-format behavior; workers never launch their own. + HEIC *heicclient.Client // HTTPClient configures asset downloads; analysis never uses it. HTTPClient *http.Client // FavoritesDir supplies membership and, unless disabled, Favorite analysis. @@ -58,13 +63,15 @@ type Client struct { } type request struct { - Search *SearchRequest `json:",omitempty"` + HEIC *heicclient.PipeConfig `json:",omitempty"` + Search *SearchRequest `json:",omitempty"` Assets string FavoritesDir string GeneralAnalysisDir string DisableFavoriteCache bool Paths []string MaxEncodedBytes int64 + reader imaging.Reader } // Analyze streams serialized immutable snapshots and waits for worker exit. @@ -87,6 +94,15 @@ func (c Client) Analyze(ctx context.Context, paths []string, controls <-chan Con req := request{Assets: assets, FavoritesDir: c.FavoritesDir, GeneralAnalysisDir: c.GeneralAnalysisDir, DisableFavoriteCache: c.DisableFavoriteCache, Paths: paths, MaxEncodedBytes: imaging.MaxEncodedBytes()} cmd := workerCommand(ctx, executable) cmd.Env = append(os.Environ(), workerEnvironment+"=1") + return c.analyzeCommand(ctx, cmd, req, controls, emit) +} + +func (c Client) analyzeCommand(ctx context.Context, cmd *exec.Cmd, req request, controls <-chan Control, emit func(Event)) error { + link, err := c.attachHEIC(ctx, cmd, &req) + if err != nil { + return err + } + defer closeHEICAttachment(link) input, err := cmd.StdinPipe() if err != nil { return err @@ -102,6 +118,9 @@ func (c Client) Analyze(ctx context.Context, paths []string, controls <-chan Con if err := cmd.Start(); err != nil { return err } + if link != nil { + link.Started() + } stopWriter := make(chan struct{}) writerDone := make(chan struct{}) go func() { @@ -215,51 +234,56 @@ func WorkerMain() bool { } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - var req request input, err := controlInput() if err != nil { _, _ = fmt.Fprintln(os.Stderr, err) os.Exit(1) } defer func() { _ = input.Close() }() - decoder := newWorkerDecoder(input) - err = decoder.Decode(&req) - if err == nil { - imaging.SetMaxEncodedBytes(req.MaxEncodedBytes) - if req.Search != nil { - if err := searchWorker(ctx, req, decoder, input); err != nil { - _, _ = fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - return true - } - readCtx, cancelRead := context.WithCancel(ctx) - controls := make(chan Control, 1) - readDone := make(chan struct{}) - go func() { - defer close(readDone) - defer close(controls) - for { - var control Control - if err := decoder.Decode(&control); err != nil { - return - } - select { - case controls <- control: - case <-readCtx.Done(): - return - } - } - }() - output := json.NewEncoder(os.Stdout) - err = analyzeLocal(ctx, req, controls, func(event Event) error { return output.Encode(event) }) - cancelRead() - _ = input.Close() - <-readDone - } - if err != nil { + if err = runWorker(ctx, input); err != nil { _, _ = fmt.Fprintln(os.Stderr, err) os.Exit(1) } return true } + +func runWorker(ctx context.Context, input io.ReadCloser) error { + var req request + decoder := newWorkerDecoder(input) + if err := decoder.Decode(&req); err != nil { + return err + } + cleanup, err := openWorkerSource(&req) + if err != nil { + return err + } + defer cleanup() + imaging.SetMaxEncodedBytes(req.MaxEncodedBytes) + if req.Search != nil { + return searchWorker(ctx, req, decoder, input) + } + readCtx, cancelRead := context.WithCancel(ctx) + controls := make(chan Control, 1) + readDone := make(chan struct{}) + go func() { + defer close(readDone) + defer close(controls) + for { + var control Control + if err := decoder.Decode(&control); err != nil { + return + } + select { + case controls <- control: + case <-readCtx.Done(): + return + } + } + }() + output := json.NewEncoder(os.Stdout) + err = analyzeLocal(ctx, req, controls, func(event Event) error { return output.Encode(event) }) + cancelRead() + _ = input.Close() + <-readDone + return err +} diff --git a/internal/similarity/facts.go b/internal/similarity/facts.go index 1b0964c2..b8b69416 100644 --- a/internal/similarity/facts.go +++ b/internal/similarity/facts.go @@ -1,15 +1,15 @@ package similarity import ( - "image" "path/filepath" "strings" "github.com/frathe/picfetch/internal/imaging" ) -func imageFacts(path string, data []byte, bounds image.Rectangle) ImageFacts { - metadata := imaging.ReadMetadata(data) +func imageFacts(path string, source *imaging.Source) ImageFacts { + metadata := source.Metadata() + bounds := source.Bounds() facts := ImageFacts{Version: FactsVersion, Width: bounds.Dx(), Height: bounds.Dy(), Format: strings.ToLower(strings.TrimPrefix(filepath.Ext(path), ".")), Make: strings.TrimSpace(metadata.Make), Model: strings.TrimSpace(metadata.Model)} diff --git a/internal/similarity/heic.go b/internal/similarity/heic.go new file mode 100644 index 00000000..c4dfc946 --- /dev/null +++ b/internal/similarity/heic.go @@ -0,0 +1,43 @@ +package similarity + +import ( + "context" + "os/exec" + + heicclient "github.com/frathe/picfetch/internal/heicdecode/client" + "github.com/frathe/picfetch/internal/imaging" +) + +func (c Client) attachHEIC(ctx context.Context, cmd *exec.Cmd, req *request) (*heicclient.Attachment, error) { + req.HEIC = nil + if c.HEIC == nil { + return nil, nil + } + link, err := c.HEIC.Attach(ctx, cmd) + if err != nil { + return nil, err + } + req.HEIC = &link.Config + return link, nil +} + +func closeHEICAttachment(link *heicclient.Attachment) { + if link != nil { + link.Stop() + link.Wait() + } +} + +// Setup and disposal belong to the worker request, before any source work and +// before WorkerMain's exit. A remote has no executable or fallback decoder. +func openWorkerSource(req *request) (func(), error) { + if req.HEIC == nil { + return func() {}, nil + } + remote, err := heicclient.OpenRemote(*req.HEIC) + if err != nil { + return nil, err + } + req.reader = imaging.NewReader(remote.Do) + return func() { remote.Stop(); remote.Wait() }, nil +} diff --git a/internal/similarity/heic_native_test.go b/internal/similarity/heic_native_test.go new file mode 100644 index 00000000..83e1ec48 --- /dev/null +++ b/internal/similarity/heic_native_test.go @@ -0,0 +1,115 @@ +//go:build heicnative && (darwin || linux || windows) && (amd64 || arm64) + +package similarity + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + "time" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/storage" + + "github.com/frathe/picfetch/internal/filescan" + "github.com/frathe/picfetch/internal/heicdecode" + heicclient "github.com/frathe/picfetch/internal/heicdecode/client" + "github.com/frathe/picfetch/internal/imaging" +) + +func TestNativeHEICAnalysisPixels(t *testing.T) { + repository, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) + } + root := t.TempDir() + if runtime.GOOS == "darwin" { + root = filepath.Join(root, "PicFetch.app") + } + build := exec.Command("go", "run", "./scripts/heicpackage", "-os", runtime.GOOS, "-arch", runtime.GOARCH, "-out", root) + build.Dir = repository + if output, buildErr := build.CombinedOutput(); buildErr != nil { + t.Fatalf("stage native analysis helper: %v: %s", buildErr, output) + } + marker := filepath.Join(root, "picfetch") + if runtime.GOOS == "darwin" { + marker = filepath.Join(root, "Contents", "MacOS", "picfetch") + + } + if err = os.MkdirAll(filepath.Dir(marker), 0700); err != nil { + t.Fatal(err) + } + if err = os.WriteFile(marker, []byte("owned package marker"), 0700); err != nil { + t.Fatal(err) + } + owner, err := heicclient.OpenInstalled(context.Background(), marker, filepath.Join(t.TempDir(), "cache"), heicdecode.DefaultLimits(0)) + if err != nil { + t.Fatal(err) + } + defer func() { owner.Stop(); owner.Wait() }() + _ = RegisterLocalFiles() + reader := imaging.NewReader(owner.Do) + uri := storage.NewFileURI(filepath.Join(repository, "scripts", "heicbuild", "testdata", "tenbit.heic")) + admitted, truncated := filescan.Images(context.Background(), []fyne.URI{uri}, 2, nil, filescan.WithAdmission(reader.IsSupportedImage)) + if truncated || len(admitted) != 1 { + t.Fatal("native analysis source was not admitted") + } + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + for _, search := range []bool{false, true} { + for _, cancelAfterDelivery := range []bool{false, true} { + t.Run(fmt.Sprintf("search=%v/cancel=%v", search, cancelAfterDelivery), func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, executable, "-test.run=^TestHEICAnalysisHelper$", "-test.timeout=25s") + cmd.Env = append(os.Environ(), "PICFETCH_OWNED_HEIC_ANALYSIS=pixels") + req := request{Paths: []string{admitted[0].Path()}} + client := Client{HEIC: owner} + seen := 0 + var runErr error + if search { + req.Search = &SearchRequest{SessionID: 17, Paths: req.Paths} + queries := make(chan SearchQuery, 2) + runErr = client.searchCommand(ctx, cmd, req, queries, func(event SearchEvent) { + if event.Kind == SearchReady { + queries <- SearchQuery{ID: 1, ReferencePath: req.Paths[0]} + } else if event.Kind == SearchFinal { + seen++ + if seen == 1 { + queries <- SearchQuery{ID: 2, ReferencePath: req.Paths[0]} + } else if cancelAfterDelivery { + cancel() + } + } + }) + } else { + runErr = client.analyzeCommand(ctx, cmd, req, nil, func(event Event) { + if event.Complete { + seen++ + if cancelAfterDelivery { + cancel() + } + } + }) + } + want := 1 + if search { + want = 2 + } + if seen != want || (!cancelAfterDelivery && runErr != nil) || (cancelAfterDelivery && !errors.Is(runErr, context.Canceled)) { + t.Fatalf("native pixel deliveries=%d, want %d: %v", seen, want, runErr) + } + if cmd.ProcessState == nil { + t.Fatal("native pixel consumer returned before process join") + } + }) + } + } +} diff --git a/internal/similarity/heic_test.go b/internal/similarity/heic_test.go new file mode 100644 index 00000000..8242e843 --- /dev/null +++ b/internal/similarity/heic_test.go @@ -0,0 +1,247 @@ +package similarity + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "image" + "image/jpeg" + "io" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "fyne.io/fyne/v2/storage" + + "github.com/frathe/picfetch/internal/heicdecode" + heicclient "github.com/frathe/picfetch/internal/heicdecode/client" + "github.com/frathe/picfetch/internal/imaging" +) + +func TestAnalysisWorkersUseHEICOwner(t *testing.T) { + for _, search := range []bool{false, true} { + for _, cancelAfterDelivery := range []bool{false, true} { + t.Run(fmt.Sprintf("search=%v/cancel=%v", search, cancelAfterDelivery), func(t *testing.T) { + runAnalysisHEICOwner(t, search, cancelAfterDelivery) + }) + } + } +} + +func runAnalysisHEICOwner(t *testing.T, search, cancelAfterDelivery bool) { + t.Helper() + // A mismatched pin exercises the owner's typed refusal before source input. + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + owner, err := heicclient.New(heicclient.Config{Executable: executable, SHA256: [32]byte{1}, Limits: heicdecode.DefaultLimits(0)}) + if err != nil { + t.Fatal(err) + } + defer func() { owner.Stop(); owner.Wait() }() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, executable, "-test.run=^TestHEICAnalysisHelper$", "-test.timeout=8s") + mode := "exit" + if cancelAfterDelivery { + mode = "wait" + } + cmd.Env = append(os.Environ(), "PICFETCH_OWNED_HEIC_ANALYSIS="+mode) + req := request{} + client := Client{HEIC: owner} + seen := false + observe := func(delivered bool) { + seen = delivered + if cancelAfterDelivery { + cancel() + } + } + if search { + req.Search = &SearchRequest{SessionID: 17} + err = client.searchCommand(ctx, cmd, req, nil, func(event SearchEvent) { observe(event.Kind == SearchReady) }) + } else { + err = client.analyzeCommand(ctx, cmd, req, nil, func(event Event) { observe(event.Complete) }) + } + if !seen || (!cancelAfterDelivery && err != nil) || (cancelAfterDelivery && !errors.Is(err, context.Canceled)) { + t.Fatalf("shared owner result: seen=%v, %v", seen, err) + } + if cmd.ProcessState == nil { + t.Fatal("analysis return preceded process join") + } +} + +func TestHEICAnalysisHelper(_ *testing.T) { + if os.Getenv("PICFETCH_OWNED_HEIC_ANALYSIS") == "" { + return + } + if err := ownedHEICAnalysis(); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + os.Exit(0) +} + +func ownedHEICAnalysis() error { + var req request + decoder := json.NewDecoder(os.Stdin) + if err := decoder.Decode(&req); err != nil { + return err + } + if req.HEIC == nil { + return fmt.Errorf("analysis did not receive the owner's pipe handles") + } + cleanup, err := openWorkerSource(&req) + if err != nil { + return err + } + defer cleanup() + _ = RegisterLocalFiles() + if len(req.Paths) == 1 { + return ownedHEICPixels(req, decoder) + } + // The HEIC suffix forces dispatch; the owner refuses before invoking + // the bulk input callback, so no encoded file content is needed. + file, err := os.CreateTemp("", "picfetch-owned-analysis-*.heic") + if err != nil { + return err + } + defer func() { _ = os.Remove(file.Name()) }() + if err = file.Close(); err != nil { + return err + } + _, err = req.reader.Read(context.Background(), storage.NewFileURI(file.Name())) + var failure *heicdecode.Failure + if !errors.As(err, &failure) || failure.Status != heicdecode.StatusUnavailable { + return fmt.Errorf("expected owner refusal, got %v", err) + } + if req.Search != nil { + err = json.NewEncoder(os.Stdout).Encode(SearchEvent{SessionID: req.Search.SessionID, Revision: 1, Kind: SearchReady}) + } else { + err = json.NewEncoder(os.Stdout).Encode(Event{Complete: true}) + } + if err == nil && os.Getenv("PICFETCH_OWNED_HEIC_ANALYSIS") == "wait" { + _, err = io.Copy(io.Discard, os.Stdin) + } + return err +} + +// The native fixture uses real decoder pixels through the inherited owner. +// Model inference is outside this pipe/preview boundary test. +func ownedHEICPixels(req request, decoder *json.Decoder) error { + path := req.Paths[0] + source, err := req.reader.Read(context.Background(), storage.NewFileURI(path)) + if err != nil { + return err + } + pixels, err := source.Decode(context.Background(), 1) + if err != nil || pixels == nil || len(pixels.Frames) != 1 || pixels.Frames[0].Bounds().Size() != image.Pt(16, 16) { + return fmt.Errorf("native analysis pixels: %v", err) + } + output := json.NewEncoder(os.Stdout) + if req.Search == nil { + return output.Encode(Event{Complete: true}) + } + if err = output.Encode(SearchEvent{SessionID: req.Search.SessionID, Revision: 1, Kind: SearchReady}); err != nil { + return err + } + info, err := os.Stat(path) + if err != nil { + return err + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + p := searchPreparer{reader: req.reader, versions: map[string]os.FileInfo{path: info}} + for revision := uint64(2); revision <= 3; revision++ { + var query SearchQuery + if err = decoder.Decode(&query); err != nil { + return err + } + if query.ReferencePath != path || query.ID != revision-1 { + return fmt.Errorf("unexpected retained query") + } + item, previewErr := p.completePreview(context.Background(), Item{Path: path, SHA256: fmt.Sprintf("%x", sha256.Sum256(data))}) + if previewErr != nil { + return previewErr + } + preview, previewErr := jpeg.Decode(bytes.NewReader(item.Preview)) + if previewErr != nil || preview.Bounds().Size() != image.Pt(16, 16) { + return fmt.Errorf("native retained preview: %v", previewErr) + } + if err = output.Encode(SearchEvent{SessionID: req.Search.SessionID, QueryID: query.ID, Revision: revision, Kind: SearchFinal, Processed: 1, Total: 1}); err != nil { + return err + } + } + return nil +} + +func TestAnalysisHEICAttachmentStartFailureJoins(t *testing.T) { + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + owner, err := heicclient.New(heicclient.Config{Executable: executable, SHA256: [32]byte{1}, Limits: heicdecode.DefaultLimits(0)}) + if err != nil { + t.Fatal(err) + } + defer func() { owner.Stop(); owner.Wait() }() + client := Client{HEIC: owner} + for _, search := range []bool{false, true} { + for range 10 { + cmd := exec.Command(filepath.Join(t.TempDir(), "picfetch-owned-missing-executable")) + if search { + err = client.searchCommand(context.Background(), cmd, request{Search: &SearchRequest{}}, nil, func(_ SearchEvent) {}) + } else { + err = client.analyzeCommand(context.Background(), cmd, request{}, nil, func(_ Event) {}) + } + if err == nil || errors.Is(err, heicclient.ErrBusy) { + t.Fatalf("start failure leaked service admission: %v", err) + } + } + } +} + +func TestSearchPreviewUsesInjectedHEICReader(t *testing.T) { + _ = RegisterLocalFiles() + data := []byte("owned HEIC source callback") + path := filepath.Join(t.TempDir(), "photo.heic") + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + calls := 0 + reader := imaging.NewReader(func(ctx context.Context, op heicdecode.Operation, input heicclient.Input) (heicdecode.Response, error) { + calls++ + if op != heicdecode.Decode { + t.Fatalf("preview operation = %v", op) + } + got, err := input(ctx, 128) + if err != nil { + return heicdecode.Response{}, err + } + if !bytes.Equal(got, data) { + return heicdecode.Response{}, io.ErrUnexpectedEOF + } + return heicdecode.Response{Image: image.NewNRGBA64(image.Rect(0, 0, 4, 6)), Config: image.Config{Width: 4, Height: 6}, Metadata: &heicdecode.Metadata{Make: "Owned camera", DateTimeOriginal: "2026:09:16 12:34:56"}}, nil + }) + p := searchPreparer{reader: reader, versions: map[string]os.FileInfo{path: info}} + item := Item{Path: path, SHA256: fmt.Sprintf("%x", sha256.Sum256(data))} + got, err := p.completePreview(context.Background(), item) + if err != nil { + t.Fatal(err) + } + pixels, err := jpeg.Decode(bytes.NewReader(got.Preview)) + if err != nil || pixels.Bounds().Size() != image.Pt(4, 6) || calls != 1 { + t.Fatalf("preview decoder: calls=%d, %v", calls, err) + } +} diff --git a/internal/similarity/search_client.go b/internal/similarity/search_client.go index b56d4489..6e2f68ad 100644 --- a/internal/similarity/search_client.go +++ b/internal/similarity/search_client.go @@ -36,10 +36,16 @@ func (c Client) Search(ctx context.Context, search SearchRequest, queries <-chan req := request{Assets: assets, MaxEncodedBytes: imaging.MaxEncodedBytes(), Search: &search} cmd := workerCommand(ctx, executable) cmd.Env = append(os.Environ(), workerEnvironment+"=1") - return searchCommand(ctx, cmd, req, queries, emit) + return c.searchCommand(ctx, cmd, req, queries, emit) } -func searchCommand(ctx context.Context, cmd *exec.Cmd, req request, queries <-chan SearchQuery, emit func(SearchEvent)) error { +func (c Client) searchCommand(ctx context.Context, cmd *exec.Cmd, req request, queries <-chan SearchQuery, emit func(SearchEvent)) error { + link, err := c.attachHEIC(ctx, cmd, &req) + if err != nil { + return err + } + defer closeHEICAttachment(link) + // Reject large path inventories before JSON encoding can duplicate them. if req.Search != nil { remaining := workerRequestLimit @@ -73,6 +79,9 @@ func searchCommand(ctx context.Context, cmd *exec.Cmd, req request, queries <-ch if err := cmd.Start(); err != nil { return err } + if link != nil { + link.Started() + } stop := make(chan struct{}) done := make(chan struct{}) go func() { diff --git a/internal/similarity/search_protocol_test.go b/internal/similarity/search_protocol_test.go index 4a9a4c67..aee60f03 100644 --- a/internal/similarity/search_protocol_test.go +++ b/internal/similarity/search_protocol_test.go @@ -25,7 +25,7 @@ func TestSearchProtocolInitialFlowAndControlClosure(t *testing.T) { queries := searchProtocolQueries(t, ctx, req) cmd := searchProtocolCommand(t, ctx, "clean") var events []SearchEvent - err := searchCommand(ctx, cmd, req, queries, func(event SearchEvent) { + err := (Client{}).searchCommand(ctx, cmd, req, queries, func(event SearchEvent) { events = append(events, event) if event.Kind == SearchReady { close(queries) @@ -57,7 +57,7 @@ func TestSearchProtocolRejectsInvalidStreams(t *testing.T) { queries := searchProtocolQueries(t, ctx, req) cmd := searchProtocolCommand(t, ctx, mode) var events []SearchEvent - err := searchCommand(ctx, cmd, req, queries, func(event SearchEvent) { events = append(events, event) }) + err := (Client{}).searchCommand(ctx, cmd, req, queries, func(event SearchEvent) { events = append(events, event) }) if err == nil || errors.Is(err, context.DeadlineExceeded) { t.Fatalf("invalid stream was accepted or stalled until timeout: %v", err) } @@ -82,7 +82,7 @@ func TestSearchProtocolCancellationJoinsProcessAndControls(t *testing.T) { queries := make(chan SearchQuery) cmd := searchProtocolCommand(t, ctx, mode) delivered := 0 - err := searchCommand(ctx, cmd, req, queries, func(event SearchEvent) { + err := (Client{}).searchCommand(ctx, cmd, req, queries, func(event SearchEvent) { delivered++ if event.Kind != SearchProgress { t.Errorf("unexpected pre-cancellation event: %+v", event) @@ -122,7 +122,7 @@ func TestSearchProtocolCancellationJoinsProcessAndControls(t *testing.T) { done := make(chan struct{}) go func() { defer close(done) - completed <- searchCommand(checking, cmd, req, queries, func(_ SearchEvent) { cancel() }) + completed <- (Client{}).searchCommand(checking, cmd, req, queries, func(_ SearchEvent) { cancel() }) }() t.Cleanup(func() { cancel(); releaseWriter(); searchProtocolWait(t, done) }) searchProtocolWait(t, checking.writerEntered) @@ -154,7 +154,7 @@ func TestSearchProtocolExitBeforeReady(t *testing.T) { queries := searchProtocolQueries(t, ctx, req) cmd := searchProtocolCommand(t, ctx, mode) delivered := 0 - err := searchCommand(ctx, cmd, req, queries, func(_ SearchEvent) { delivered++ }) + err := (Client{}).searchCommand(ctx, cmd, req, queries, func(_ SearchEvent) { delivered++ }) wantError := "before readiness" if mode == "provider_error" { wantError = "fixture worker setup failed" @@ -368,7 +368,7 @@ func TestSearchProtocolRejectsOversizedRequestBeforeLaunch(t *testing.T) { req.Search.Paths[i] = fmt.Sprintf("%s%d", path, i) } cmd := searchProtocolCommand(t, context.Background(), "early_exit") - err := searchCommand(context.Background(), cmd, req, nil, func(_ SearchEvent) {}) + err := (Client{}).searchCommand(context.Background(), cmd, req, nil, func(_ SearchEvent) {}) if err == nil || !strings.Contains(err.Error(), "request exceeds") || cmd.Process != nil { t.Fatalf("oversized scope launched worker instead of rejecting admission: process=%v err=%v", cmd.Process, err) } diff --git a/internal/similarity/search_worker.go b/internal/similarity/search_worker.go index 653b944b..ee030ee0 100644 --- a/internal/similarity/search_worker.go +++ b/internal/similarity/search_worker.go @@ -3,7 +3,6 @@ package similarity import ( "bytes" "context" - "crypto/sha256" "errors" "fmt" "image/jpeg" @@ -15,6 +14,7 @@ import ( ) type searchPreparer struct { + reader imaging.Reader assets string cache *representationStore encoder *Encoder @@ -43,11 +43,11 @@ func (p *searchPreparer) prepare(ctx context.Context, path string) (Item, bool, reused = true } if !reused { - data, bounds, err := imaging.ReadAndProbe(ctx, storage.NewFileURI(path)) + source, err := p.reader.Read(ctx, storage.NewFileURI(path)) if err != nil { return item, false, err } - loaded, err := imaging.DecodeLoaded(ctx, data, 1) + loaded, err := source.Decode(ctx, 1) if err != nil { return item, false, err } @@ -61,8 +61,8 @@ func (p *searchPreparer) prepare(ctx context.Context, path string) (Item, bool, if err != nil { return item, false, err } - item.Facts = imageFacts(path, data, bounds) - item.SHA256 = fmt.Sprintf("%x", sha256.Sum256(data)) + item.Facts = imageFacts(path, source) + item.SHA256 = fmt.Sprintf("%x", source.SHA256()) var preview bytes.Buffer if err := jpeg.Encode(&preview, imaging.ScaleForExport(loaded.Frames[0], 160), &jpeg.Options{Quality: 80}); err != nil { return item, false, err @@ -107,7 +107,7 @@ func searchLocal(ctx context.Context, req request, queries <-chan SearchQuery, e if cache != nil { defer cache.close() } - p := searchPreparer{assets: req.Assets, cache: cache, versions: map[string]os.FileInfo{}} + p := searchPreparer{reader: req.reader, assets: req.Assets, cache: cache, versions: map[string]os.FileInfo{}} if cacheErr != nil { p.warning = cacheErr.Error() } @@ -172,14 +172,14 @@ func (p *searchPreparer) refreshFavorites(ctx context.Context, items []Item) { } func (p *searchPreparer) completePreview(ctx context.Context, item Item) (Item, error) { - data, _, err := imaging.ReadAndProbe(ctx, storage.NewFileURI(item.Path)) + source, err := p.reader.Read(ctx, storage.NewFileURI(item.Path)) if err != nil { return item, err } - if fmt.Sprintf("%x", sha256.Sum256(data)) != item.SHA256 { + if fmt.Sprintf("%x", source.SHA256()) != item.SHA256 { return item, fmt.Errorf("source changed before Favorite persistence") } - loaded, err := imaging.DecodeLoaded(ctx, data, 1) + loaded, err := source.Decode(ctx, 1) if err != nil { return item, err } diff --git a/internal/ui/build.go b/internal/ui/build.go index a9049c13..09e0cf1b 100644 --- a/internal/ui/build.go +++ b/internal/ui/build.go @@ -42,7 +42,7 @@ func buildViewer(application fyne.App, startup startupState) (*viewer, fyne.Wind var view *viewer cache := imaging.NewImgCache(int64(prefs.MaxImageCacheMB) * bytesPerMB) - presentation := display.New(display.Config{Cache: cache, Callbacks: display.Callbacks{ + presentation := display.New(display.Config{Reader: startup.images.foreground, PreloadReader: startup.images.background, Cache: cache, Callbacks: display.Callbacks{ Repaint: func() { view.ForceRepaint() }, Requested: func(id display.Identity) { view.imageRequested(id) }, Probed: func(bounds image.Rectangle) { view.imageProbed(bounds) }, @@ -69,6 +69,7 @@ func buildViewer(application fyne.App, startup startupState) (*viewer, fyne.Wind loadingBar.Hide() view = &viewer{ + images: startup.images, app: application, win: window, quit: application.Quit, diff --git a/internal/ui/compare.go b/internal/ui/compare.go index 646ed942..ff0e4cd2 100644 --- a/internal/ui/compare.go +++ b/internal/ui/compare.go @@ -69,14 +69,14 @@ func (v *viewer) loadComparedImage(ctx context.Context, uri fyne.URI) (*imaging. } continue } - data, _, err := imaging.ReadAndProbe(ctx, uri) + source, err := v.images.foreground.Read(ctx, uri) if err != nil { if !writer.Current() { continue } return nil, err } - loaded, err := imaging.DecodeRecord(ctx, data, v.imgCache.Budget()) + loaded, err := source.Decode(ctx, v.imgCache.Budget()) if !writer.Current() { continue } diff --git a/internal/ui/display/feature.go b/internal/ui/display/feature.go index 6cf9fc96..861e69e3 100644 --- a/internal/ui/display/feature.go +++ b/internal/ui/display/feature.go @@ -35,6 +35,8 @@ type Snapshot struct { // Config supplies instance dependencies before presentation starts. type Config struct { + Reader imaging.Reader + PreloadReader imaging.Reader Queue UIQueue AnimationAfter func(time.Duration) <-chan time.Time Callbacks Callbacks diff --git a/internal/ui/display/load.go b/internal/ui/display/load.go index ae9ac186..5e5f9b83 100644 --- a/internal/ui/display/load.go +++ b/internal/ui/display/load.go @@ -56,8 +56,9 @@ func (f *Feature) attemptLoad(token requestToken, request Request, done func()) return } f.loadWorkers.Go(func() { - data, bounds, err := imaging.ReadAndProbe(token.context(), u) + source, err := f.config.Reader.Read(token.context(), u) if err == nil && f.config.Callbacks.Probed != nil { + bounds := source.Bounds() f.config.Queue.Do(func() { if token.current() && writer.Current() { f.config.Callbacks.Probed(bounds) @@ -66,7 +67,7 @@ func (f *Feature) attemptLoad(token requestToken, request Request, done func()) } var loaded *imaging.LoadedImage if err == nil { - loaded, err = imaging.DecodeRecord(token.context(), data, f.config.Cache.Budget()) + loaded, err = source.Decode(token.context(), f.config.Cache.Budget()) } f.config.Queue.Do(func() { if !token.current() { diff --git a/internal/ui/display/load_contract_test.go b/internal/ui/display/load_contract_test.go index 04a3b27c..82c25b32 100644 --- a/internal/ui/display/load_contract_test.go +++ b/internal/ui/display/load_contract_test.go @@ -15,11 +15,51 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/storage" + "github.com/frathe/picfetch/internal/heicdecode" + heicclient "github.com/frathe/picfetch/internal/heicdecode/client" "github.com/frathe/picfetch/internal/imaging" "github.com/frathe/picfetch/internal/ui/display" "github.com/frathe/picfetch/internal/uitest" ) +func TestHEICDisplayAndPreloadUseInjectedReaders(t *testing.T) { + uri := func(name string) fyne.URI { + return uitest.ReaderURI(storage.NewFileURI("/"+name+".heic"), func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader([]byte(name))), nil + }) + } + first, next := uri("first"), uri("next") + var foreground, background atomic.Int64 + reader := func(calls *atomic.Int64, width int) imaging.Reader { + return imaging.NewReader(func(ctx context.Context, op heicdecode.Operation, input heicclient.Input) (heicdecode.Response, error) { + calls.Add(1) + if _, err := input(ctx, 128); err != nil { + return heicdecode.Response{}, err + } + if op != heicdecode.Decode { + return heicdecode.Response{}, heicdecode.ErrInvalidRequest + } + pixels := image.NewNRGBA64(image.Rect(0, 0, width, 3)) + return heicdecode.Response{Image: pixels, Config: image.Config{Width: width, Height: 3}, Metadata: &heicdecode.Metadata{Make: "Owned"}}, nil + }) + } + f := newPresentation(t, display.Config{Reader: reader(&foreground, 2), PreloadReader: reader(&background, 4), Callbacks: display.Callbacks{Presented: func(_ display.Snapshot) []fyne.URI { return []fyne.URI{next} }}}) + f.Load(display.Request{Source: first}) + f.Settle() + if f.Surface().Image == nil || f.Surface().Image.Bounds().Size() != image.Pt(2, 3) || !f.Snapshot().HasEXIF { + t.Fatal("foreground source did not reach presentation") + } + loaded, ok := f.cache.Get(next.String()) + if !ok || loaded.Frames[0].Bounds().Size() != image.Pt(4, 3) || !loaded.HasEXIF || loaded.FileSize != 4 { + t.Fatal("preload source did not retain complete cache facts") + } + f.Load(display.Request{Source: next}) + f.Settle() + if foreground.Load() != 1 || background.Load() != 1 || f.Surface().Image != loaded.Frames[0] { + t.Fatal("preload handoff decoded again or changed pixels") + } +} + func loadContract(t *testing.T) { t.Run("cache generation", loadCacheGenerationContract) t.Run("retired worker", loadRetiredWorkerContract) diff --git a/internal/ui/display/preload.go b/internal/ui/display/preload.go index 46ba42cb..3232a42d 100644 --- a/internal/ui/display/preload.go +++ b/internal/ui/display/preload.go @@ -38,7 +38,7 @@ func (f *Feature) preloadOne(token requestToken, u fyne.URI) { return } - data, bounds, err := imaging.ReadAndProbe(token.context(), u) + source, err := f.config.PreloadReader.Read(token.context(), u) if err != nil { return } @@ -54,11 +54,11 @@ func (f *Feature) preloadOne(token requestToken, u fyne.URI) { // current image, so bail on the header alone rather than paying // for the decode first. Half the budget is where the current image // and one neighbor stop both fitting. - if imaging.EstimateDecodedBytes(bounds) > budget/2 { + if imaging.EstimateDecodedBytes(source.Bounds()) > budget/2 { return } - loaded, err := imaging.DecodeRecord(token.context(), data, budget) + loaded, err := source.Decode(token.context(), budget) if err != nil { return } diff --git a/internal/ui/drop.go b/internal/ui/drop.go index a55f0c1e..6b38817b 100644 --- a/internal/ui/drop.go +++ b/internal/ui/drop.go @@ -10,7 +10,6 @@ import ( "fyne.io/fyne/v2/storage" "github.com/frathe/picfetch/internal/filescan" - "github.com/frathe/picfetch/internal/imaging" ) // cancelScan aborts a scan in progress (Escape while v.scanOp.active is true). @@ -138,13 +137,14 @@ func (v *viewer) handleCollectionDrop(uris []fyne.URI, favoriteDir string) { } } - expandSiblings := favoriteDir == "" && !merging && !hasDirs && len(uris) == 1 && imaging.IsSupportedImage(uris[0]) + admit := v.images.foreground.IsSupportedImage + expandSiblings := favoriteDir == "" && !merging && !hasDirs && len(uris) == 1 && admit(uris[0]) scan := func(progress func(int)) (images []fyne.URI, truncated bool) { if expandSiblings { - return filescan.Siblings(token.context(), uris[0], maxScan, progress) + return filescan.Siblings(token.context(), uris[0], maxScan, progress, filescan.WithAdmission(admit)) } - return filescan.Images(token.context(), uris, maxScan, progress) + return filescan.Images(token.context(), uris, maxScan, progress, filescan.WithAdmission(admit)) } if !hasDirs && !expandSiblings { diff --git a/internal/ui/exifwin/exifwin.go b/internal/ui/exifwin/exifwin.go index d2a10412..7b03af73 100644 --- a/internal/ui/exifwin/exifwin.go +++ b/internal/ui/exifwin/exifwin.go @@ -62,8 +62,9 @@ type Host interface { // Singleton): a second request raises the existing window rather than // stacking up duplicates. type Window struct { - app fyne.App - host Host + reader imaging.Reader + app fyne.App + host Host win widgets.Singleton @@ -134,7 +135,13 @@ type Window struct { // New returns the EXIF window for application. host.DisplayedFile is called // on every open and refresh to find the file to read. func New(application fyne.App, host Host) *Window { + return NewWithReader(application, host, imaging.Reader{}) +} + +// NewWithReader installs the canonical source dependency before work starts. +func NewWithReader(application fyne.App, host Host, reader imaging.Reader) *Window { w := &Window{ + reader: reader, app: application, host: host, tiles: newTileFetcher(osmTiles, nil), diff --git a/internal/ui/exifwin/metadata.go b/internal/ui/exifwin/metadata.go index 00e9a2f5..fa5dbcb2 100644 --- a/internal/ui/exifwin/metadata.go +++ b/internal/ui/exifwin/metadata.go @@ -62,15 +62,8 @@ func (w *Window) Refresh() { generation := w.metadata.generation done := w.metadata.done.Begin() w.metadata.workers.Go(func() { - data, _, err := imaging.ReadAndProbe(ctx, u) - var metadata imaging.Metadata - var canStrip bool - if err == nil && ctx.Err() == nil { - metadata = imaging.ReadMetadata(data) - if ctx.Err() == nil { - canStrip = imaging.CanStripJPEGMetadata(data) && !metadata.Empty() - } - } + info, err := w.reader.InspectMetadata(ctx, u) + metadata, canStrip := info.Values, info.CanStrip if ctx.Err() != nil { cancel() done() diff --git a/internal/ui/explorer/feature.go b/internal/ui/explorer/feature.go index 6b82697a..f001a60e 100644 --- a/internal/ui/explorer/feature.go +++ b/internal/ui/explorer/feature.go @@ -11,6 +11,7 @@ import ( "github.com/frathe/picfetch/internal/explorerpresets" "github.com/frathe/picfetch/internal/explorertrial" "github.com/frathe/picfetch/internal/favstore" + "github.com/frathe/picfetch/internal/imaging" "github.com/frathe/picfetch/internal/similarity" ) @@ -54,6 +55,7 @@ type Options struct { App fyne.App Discussions func() Client similarity.Client + Images imaging.Reader Analyze similarity.Provider Queue UIQueue Presets *explorerpresets.Store @@ -92,6 +94,7 @@ type Feature struct { stopping bool introSeen, assetsReady bool client similarity.Client + images imaging.Reader supported bool trial *explorertrial.Session trialRun, trialEvent int @@ -128,6 +131,7 @@ func NewFeature(host WorkflowHost, options Options) *Feature { func (f *Feature) Configure(options Options) { f.app, f.discussions = options.App, options.Discussions f.client, f.analyze = options.Client, options.Analyze + f.images = options.Images f.ui = options.Queue if f.ui == nil { f.ui = featureQueue{} @@ -142,7 +146,7 @@ func (f *Feature) Configure(options Options) { func (f *Feature) Options() Options { return Options{ App: f.app, Discussions: f.discussions, - Client: f.client, Analyze: f.analyze, Queue: f.ui, Presets: f.presets, + Client: f.client, Images: f.images, Analyze: f.analyze, Queue: f.ui, Presets: f.presets, Supported: f.supported, AssetsReady: f.assetsReady, Settings: f.Settings(), Trial: f.trial, } diff --git a/internal/ui/explorer/feature_test.go b/internal/ui/explorer/feature_test.go index c694e637..f6f2888e 100644 --- a/internal/ui/explorer/feature_test.go +++ b/internal/ui/explorer/feature_test.go @@ -17,6 +17,9 @@ import ( "github.com/frathe/picfetch/internal/explorerpresets" "github.com/frathe/picfetch/internal/explorertrial" + "github.com/frathe/picfetch/internal/heicdecode" + heicclient "github.com/frathe/picfetch/internal/heicdecode/client" + "github.com/frathe/picfetch/internal/imaging" "github.com/frathe/picfetch/internal/similarity" "github.com/frathe/picfetch/internal/ui/explorer" "github.com/frathe/picfetch/internal/uitest" @@ -310,3 +313,78 @@ func TestFeatureCanceledBarrierRecordsTrialExit(t *testing.T) { t.Fatalf("canceled predecessor wait lost exit observation: analyzed=%v summary=%+v", analyzed, summary) } } + +func TestExperimentalHEICPresetFormats(t *testing.T) { + for _, active := range []bool{false, true} { + t.Run(fmt.Sprint(active), func(t *testing.T) { + app := test.NewApp() + t.Cleanup(app.Quit) + host := &featureHost{win: app.NewWindow("Explorer")} + reader := imaging.Reader{} + if active { + reader = imaging.NewReader(func(_ context.Context, _ heicdecode.Operation, _ heicclient.Input) (heicdecode.Response, error) { + t.Fatal("format choices attempted decoding") + return heicdecode.Response{}, nil + }) + } + store := &explorerpresets.Store{Dir: t.TempDir()} + provider := func(_ context.Context, paths []string, _ <-chan similarity.Control, emit func(similarity.Event)) error { + var items []similarity.Item + for _, path := range paths { + items = append(items, similarity.Item{Path: path, Cohort: "unassigned", Preview: uitest.EncodeJPEG(t, 4, 3, color.White)}) + } + emit(similarity.Event{Complete: true, Total: len(paths), Successful: len(paths), Items: items}) + return nil + } + f := explorer.NewFeature(host, explorer.Options{App: app, Queue: &uitest.UIQueue{}, Images: reader, Presets: store, Analyze: provider}) + t.Cleanup(func() { f.Stop(); f.Settle() }) + host.win.SetContent(f.Surface().Overlay()) + f.Open(explorer.OpenRequest{Sources: []string{"/a.jpg", "/b.jpg"}}) + f.Settle() + f.ShowSimilarityPresets() + f.Settle() + test.Tap(featureButton(t, host, "New preset")) + var formats *widget.Select + walkFeature(host.win.Canvas().Overlays().Top(), func(o fyne.CanvasObject) { + if field, ok := o.(*widget.Select); ok && field.PlaceHolder == lang.L("File type") { + formats = field + } + }) + if formats == nil { + t.Fatal("file type control is not in the preset dialog") + } + for _, ext := range []string{"heic", "heif"} { + if slices.Contains(formats.Options, ext) != active { + t.Fatalf("%s offered = %v, active = %v", ext, formats.Options, active) + } + } + f.Close() + f.Open(explorer.OpenRequest{Sources: []string{"/a.jpg", "/b.jpg"}}) + f.Settle() + // A saved HEIC rule remains editable and persistable with HEIC disabled. + saved, err := store.Save(context.Background(), explorerpresets.Preset{Name: "Saved HEIC", Rule: explorerpresets.Rule{Format: "heic"}}) + if err != nil { + t.Fatalf("saved HEIC rule rejected: %v", err) + } + f.ShowSimilarityPresets() + f.Settle() + test.Tap(featureButton(t, host, "Saved HEIC")) + formats = nil + walkFeature(host.win.Canvas().Overlays().Top(), func(o fyne.CanvasObject) { + if field, ok := o.(*widget.Select); ok && field.PlaceHolder == lang.L("File type") { + formats = field + } + }) + if formats == nil || formats.Selected != "heic" { + t.Fatal("existing HEIC rule was lost") + } + featureEntry(t, host, "Preset name", "Renamed HEIC") + test.Tap(featureButton(t, host, "Save preset")) + f.Settle() + records, err := store.Load(context.Background()) + if err != nil || len(records) != 1 || records[0].ID != saved.ID || records[0].Rule.Format != "heic" || records[0].Name != "Renamed HEIC" { + t.Fatalf("saved rule changed unexpectedly: %v, %v", records, err) + } + }) + } +} diff --git a/internal/ui/explorer/preset_fields.go b/internal/ui/explorer/preset_fields.go index 289a5c49..f9587cd9 100644 --- a/internal/ui/explorer/preset_fields.go +++ b/internal/ui/explorer/preset_fields.go @@ -15,12 +15,12 @@ import ( "github.com/frathe/picfetch/internal/imaging" ) -func presetRuleFields(rule explorerpresets.Rule, changed func()) (*widget.Form, func() explorerpresets.Rule) { +func presetRuleFields(reader imaging.Reader, rule explorerpresets.Rule, changed func()) (*widget.Form, func() explorerpresets.Rule) { makeName := widget.NewEntry() makeName.SetPlaceHolder(lang.L("Camera make")) makeName.SetText(rule.Make) formats := []string{lang.L("Any file type")} - for _, ext := range imaging.SupportedExtensions() { + for _, ext := range reader.SupportedExtensions() { ext = strings.TrimPrefix(ext, ".") if ext == "jpeg" { ext = "jpg" diff --git a/internal/ui/explorer/preset_workflow.go b/internal/ui/explorer/preset_workflow.go index 43abaa4b..bd2de362 100644 --- a/internal/ui/explorer/preset_workflow.go +++ b/internal/ui/explorer/preset_workflow.go @@ -95,7 +95,7 @@ func (f *Feature) editSimilarityPreset(p explorerpresets.Preset) { name.SetPlaceHolder(lang.L("Preset name")) name.SetText(p.Name) var refresh func(string) - rules, readRule := presetRuleFields(p.Rule, func() { + rules, readRule := presetRuleFields(f.images, p.Rule, func() { if refresh != nil { refresh("") } diff --git a/internal/ui/favthumbs.go b/internal/ui/favthumbs.go index 5233807f..96d4eabb 100644 --- a/internal/ui/favthumbs.go +++ b/internal/ui/favthumbs.go @@ -66,7 +66,7 @@ func (v *viewer) SyncFavoritePreviews(favDir string, files []fyne.URI) { defer done() defer token.cancelContext() - if err := favthumbs.Sync(token.context(), favDir, files, sink); err != nil { + if err := favthumbs.SyncWithReader(token.context(), favDir, files, sink, v.images.background); err != nil { // A superseded pass returns context.Canceled, which is this // design working rather than anything failing. if errors.Is(err, context.Canceled) { diff --git a/internal/ui/features.go b/internal/ui/features.go index d0562951..09587d59 100644 --- a/internal/ui/features.go +++ b/internal/ui/features.go @@ -38,7 +38,7 @@ var _ mosaicwin.Host = (*viewer)(nil) func registerFeatures(view *viewer, application fyne.App, window fyne.Window, prefs preferences.State) { view.help = help.New(application, appTitle, assets.ComparingWebP) view.welcomeArt.onCircles = view.help.ShowFinis - view.spiral = spiral.New(application) + view.spiral = spiral.NewWithReader(application, view.images.foreground) view.spiral.SetOnManual(view.help.ShowManual) view.help.SetOnSpiral(view.openSpiral) @@ -46,7 +46,7 @@ func registerFeatures(view *viewer, application fyne.App, window fyne.Window, pr // position poller; both doors reach the viewer-owned Spiral. view.spiralDrag = wingesture.New(wingesture.Config{}) view.spiralGesture = view.openSpiralForGesture - view.exif = exifwin.New(application, view) + view.exif = exifwin.NewWithReader(application, view, view.images.foreground) // Resolve these callbacks against the viewer at call time so tests can // replace keyModifiers after construction. @@ -111,12 +111,12 @@ func registerFeatures(view *viewer, application fyne.App, window fyne.Window, pr // The thumbnail-cache setter reaches into the grid, so the grid must be // registered before saved cache limits are applied. - view.grid = grid.New(view, window, view.dupes) + view.grid = grid.NewWithReader(view, window, view.dupes, view.images.background) view.explorer = explorerui.NewFeature(explorerHost{view}, explorerui.Options{ - App: application, Discussions: view.help.ShowDiscussions, Supported: similarity.SupportedPlatform(), + Client: similarity.Client{HEIC: view.images.owner}, Images: view.images.foreground, App: application, Discussions: view.help.ShowDiscussions, Supported: similarity.SupportedPlatform(), Settings: explorerui.Settings{CacheFavorites: prefs.SimilarityFavoriteCache, AutoFit: prefs.SimilarityAutoFit, Automatic: prefs.SimilarityAutoUpdate, IntroSeen: prefs.SimilarityIntroSeen}, }) - view.visualsearch = searchui.New(searchHost{view}, searchui.Options{}) + view.visualsearch = searchui.New(searchHost{view}, searchui.Options{Provider: (similarity.Client{HEIC: view.images.owner}).Search}) view.grid.SetOnRankedOpen(view.searchImageOpened) view.compare = compareui.New( func(ctx context.Context, uri fyne.URI) (*imaging.LoadedImage, error) { @@ -144,6 +144,7 @@ func registerFeatures(view *viewer, application fyne.App, window fyne.Window, pr view.pushDuplicateDistance(view.DuplicateDistance()) view.SetFavoritePreviewCache(prefs.FavoritePreviewCache) + view.settings.experimentalHEIC = prefs.ExperimentalHEIC // Restore update prefs without SetCheckForUpdates: that setter starts a // network check. Day must be in place before startViewerRuntime's // maybeStartUpdateCheck so Due sees the saved calendar day. diff --git a/internal/ui/filework.go b/internal/ui/filework.go index 35688564..4f34699f 100644 --- a/internal/ui/filework.go +++ b/internal/ui/filework.go @@ -202,12 +202,12 @@ func (v *viewer) refreshWrittenFile(result imaging.WriteResult, reload, refreshE done() return } - var data []byte + var metadata imaging.MetadataInfo var infoErr error if !reload { - data, _, infoErr = imaging.ReadAndProbe(ctx, storage.NewFileURI(path)) + metadata, infoErr = v.images.foreground.InspectMetadata(ctx, storage.NewFileURI(path)) } - hasEXIF := !imaging.ReadMetadata(data).Empty() + hasEXIF := !metadata.Values.Empty() if ctx.Err() != nil { done() return @@ -227,7 +227,7 @@ func (v *viewer) refreshWrittenFile(result imaging.WriteResult, reload, refreshE return } if infoErr == nil { - v.info.SetFile(int64(len(data)), hasEXIF, false) + v.info.SetFile(metadata.FileSize, hasEXIF, false) v.syncInfoOverlayVisibility() v.updateInfoOverlay() } diff --git a/internal/ui/grid/grid.go b/internal/ui/grid/grid.go index 5e31addf..8e6459ac 100644 --- a/internal/ui/grid/grid.go +++ b/internal/ui/grid/grid.go @@ -92,6 +92,7 @@ type Host interface { // Overview is the grid overlay and the state behind it. type Overview struct { + reader imaging.Reader ranked, pendingRanked *RankedVisit visitIndex *visitSourceIndex rankedBar *fyne.Container @@ -361,7 +362,13 @@ func unpackGridCell(cell *fyne.Container) (*canvas.Image, *canvas.Rectangle, *ca // real Fyne canvas focus, which this app deliberately never hands to // GridWrap (see Close's comment on why). func New(host Host, win fyne.Window, model *dupes.Model) *Overview { + return NewWithReader(host, win, model, imaging.Reader{}) +} + +// NewWithReader installs the canonical source dependency before work starts. +func NewWithReader(host Host, win fyne.Window, model *dupes.Model, reader imaging.Reader) *Overview { g := &Overview{ + reader: reader, host: host, win: win, sel: selection.New(), diff --git a/internal/ui/grid/hashengine.go b/internal/ui/grid/hashengine.go index d0651634..dc32eead 100644 --- a/internal/ui/grid/hashengine.go +++ b/internal/ui/grid/hashengine.go @@ -41,6 +41,7 @@ const hideApplyMinInterval = 250 * time.Millisecond // does have to touch the overlay comes back through Run's apply callback // instead (Overview.hashFactsReady). type hashEngine struct { + reader imaging.Reader // host, pool, thumbs, model and ui are the Overview's own, shared // rather than copied: the engine hashes onto the same decode pool the // cells decode on - which is what keeps Settle's decodes.Wait barrier @@ -172,7 +173,7 @@ func (e *hashEngine) Run(ctx context.Context, apply func(remaining int32, gen ui if thumb == nil { var err error version, _ = favthumbs.EntryName(file) - thumb, native, err = imaging.LoadThumbnailAndBoundsContext(ctx, file) + thumb, native, err = e.reader.Thumbnail(ctx, file, imaging.ThumbnailSize) if workCancelled(ctx, err) { return } @@ -203,7 +204,11 @@ func (e *hashEngine) Run(ctx context.Context, apply func(remaining int32, gen ui return } } else { - _, b, err := imaging.ReadAndProbe(ctx, file) + source, err := e.reader.Read(ctx, file) + var b image.Rectangle + if err == nil { + b = source.Bounds() + } if workCancelled(ctx, err) { return } diff --git a/internal/ui/grid/thumbs.go b/internal/ui/grid/thumbs.go index 97f96452..2c0fca7c 100644 --- a/internal/ui/grid/thumbs.go +++ b/internal/ui/grid/thumbs.go @@ -56,7 +56,7 @@ func (g *Overview) Warm() error { } version, _ := favthumbs.EntryName(u) - thumb, native, err := imaging.LoadThumbnailAndBoundsContext(ctx, u) + thumb, native, err := g.reader.Thumbnail(ctx, u, imaging.ThumbnailSize) if err != nil { return err } @@ -267,7 +267,7 @@ func (g *Overview) requestThumbnail(key *fyne.Container, img *canvas.Image, id i var err error var native image.Rectangle version, _ = favthumbs.EntryName(u) - if thumb, native, err = imaging.LoadThumbnailAndBoundsContext(ctx, u); err != nil { + if thumb, native, err = g.reader.Thumbnail(ctx, u, imaging.ThumbnailSize); err != nil { // No retry here: release lets the cell's next update pass // claim and try again, and the normal viewing path is // where the file's actual error surfaces to the user. diff --git a/internal/ui/grid/work.go b/internal/ui/grid/work.go index 0e5efb84..c0439cd2 100644 --- a/internal/ui/grid/work.go +++ b/internal/ui/grid/work.go @@ -37,7 +37,7 @@ func (g *Overview) restartWork() { g.work.generation = g.host.Generation() g.work.revision++ g.work.facts = g.dupes.CaptureFacts() - g.hashes = &hashEngine{host: g.host, pool: g.decodes, thumbs: g.thumbs, model: g.dupes, facts: g.work.facts, ui: g.ui} + g.hashes = &hashEngine{reader: g.reader, host: g.host, pool: g.decodes, thumbs: g.thumbs, model: g.dupes, facts: g.work.facts, ui: g.ui} } func (g *Overview) workContext() context.Context { diff --git a/internal/ui/harness_test.go b/internal/ui/harness_test.go index c029124f..0e271e52 100644 --- a/internal/ui/harness_test.go +++ b/internal/ui/harness_test.go @@ -78,7 +78,17 @@ func TestMain(m *testing.M) { // defer, so a second Close panics partway through and leaves that mutex // permanently locked - wedging every later test in the package that touches // a window, not just this one. -func newTestUI(t *testing.T) (v *viewer, win fyne.Window, closed func() bool) { +func newTestUI(t *testing.T) (*viewer, fyne.Window, func() bool) { + t.Helper() + return newTestUIWithStartupImages(t, nil) +} + +func newTestUIWithImages(t *testing.T, images imageServices) (*viewer, fyne.Window, func() bool) { + t.Helper() + return newTestUIWithStartupImages(t, &images) +} + +func newTestUIWithStartupImages(t *testing.T, override *imageServices) (v *viewer, win fyne.Window, closed func() bool) { t.Helper() // Reassert the shared app as the current one before building: the @@ -101,10 +111,17 @@ func newTestUI(t *testing.T) (v *viewer, win fyne.Window, closed func() bool) { } } - v, win = buildStartupViewer(testApp) + startup := loadStartupState(testApp) + if override != nil { + startup.images.Stop() + startup.images.Wait() + startup.images = *override + } + images := startup.images + v, win = buildConfiguredViewer(testApp, startup) v.display.SetUIQueue(&uitest.UIQueue{}) v.grid.SetUIQueue(&uitest.UIQueue{}) - v.visualsearch.Configure(searchui.Options{Queue: &uitest.UIQueue{}}) + v.visualsearch.Configure(searchui.Options{Provider: (similarity.Client{HEIC: images.owner}).Search, Queue: &uitest.UIQueue{}}) v.searchView.overlayUI = &uitest.UIQueue{} v.analysisDir = t.TempDir() v.analysisCache.Configure(analysiscache.Options{Roots: v.analysisRoots(), Queue: &uitest.UIQueue{}, ConfirmClear: v.settingsWin.ConfirmClearAnalysis, Changed: v.syncMenus}) @@ -194,6 +211,7 @@ func drain(t *testing.T, v *viewer) { // this test has already closed. Clearing it first also means nothing // can start a fresh scan behind the waits below. openwith.SetHandler(nil) + v.images.Stop() v.stopSearchOverlayWait() v.searchView.overlayWorkers.Wait() if v.searchView.overlayUI != nil { @@ -300,6 +318,7 @@ func drain(t *testing.T, v *viewer) { v.slides.Settle() v.spiral.Settle() v.exif.Settle() + v.images.Wait() close(settled) }() diff --git a/internal/ui/images.go b/internal/ui/images.go new file mode 100644 index 00000000..65159390 --- /dev/null +++ b/internal/ui/images.go @@ -0,0 +1,55 @@ +package ui + +import ( + "context" + + "github.com/frathe/picfetch/internal/heicdecode" + heicclient "github.com/frathe/picfetch/internal/heicdecode/client" + "github.com/frathe/picfetch/internal/imaging" + "github.com/frathe/picfetch/internal/preferences" +) + +// imageServices owns one admission lane for this viewer and its analysis +// descendants. Readers are immutable dependencies installed before any work. +type imageServices struct { + owner *heicclient.Client + foreground, background imaging.Reader + startupError error +} + +func installedImageServices(prefs preferences.State, executable, privateDir string) imageServices { + if !prefs.ExperimentalHEIC { + return imageServices{} + } + // Readers apply the current file-size setting to each request. Keep the + // shared owner's hard ceiling independent of that live preference. + owner, err := heicclient.OpenInstalled(context.Background(), executable, privateDir, heicdecode.DefaultLimits(0)) + if err != nil { + return imageServices{startupError: err} + } + return newImageServices(owner) +} + +func (s imageServices) unavailable() bool { + return s.startupError != nil || (s.owner != nil && s.owner.Unavailable()) +} + +func newImageServices(owner *heicclient.Client) imageServices { + if owner == nil { + return imageServices{} + } + return imageServices{owner: owner, foreground: imaging.NewReader(owner.Do), background: imaging.NewReader(func(ctx context.Context, op heicdecode.Operation, input heicclient.Input) (heicdecode.Response, error) { + return owner.DoWithPriority(ctx, heicclient.Background, op, input) + })} +} + +func (s imageServices) Stop() { + if s.owner != nil { + s.owner.Stop() + } +} +func (s imageServices) Wait() { + if s.owner != nil { + s.owner.Wait() + } +} diff --git a/internal/ui/images_native_other_test.go b/internal/ui/images_native_other_test.go new file mode 100644 index 00000000..4468bd24 --- /dev/null +++ b/internal/ui/images_native_other_test.go @@ -0,0 +1,14 @@ +//go:build heicnative && (darwin || linux) && (amd64 || arm64) + +package ui + +import ( + "os" + "testing" +) + +func verifyNativeHEICIdentity(t *testing.T) { + t.Helper() + t.Logf("native execution uid=%d", os.Getuid()) +} +func preserveNativeHEICInstallation(_ *testing.T, _ string) func() { return func() {} } diff --git a/internal/ui/images_native_test.go b/internal/ui/images_native_test.go new file mode 100644 index 00000000..a477376a --- /dev/null +++ b/internal/ui/images_native_test.go @@ -0,0 +1,480 @@ +//go:build heicnative && (darwin || linux || windows) && (amd64 || arm64) + +package ui + +import ( + "context" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "image" + "image/color" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "sync" + "sync/atomic" + "testing" + "time" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/storage" + "fyne.io/fyne/v2/test" + + "github.com/frathe/picfetch/internal/distribution" + "github.com/frathe/picfetch/internal/heicdecode" + heicclient "github.com/frathe/picfetch/internal/heicdecode/client" + "github.com/frathe/picfetch/internal/imaging" + "github.com/frathe/picfetch/internal/preferences" + "github.com/frathe/picfetch/internal/uitest" +) + +// Only the test executable understands these fixture environment variables. +// The production entry point has no HEIC override or qualification dispatcher. +func TestNativePackagedHEICActivation(t *testing.T) { + if os.Getenv("PICFETCH_HEIC_ACTIVATION_CHILD") != "1" { + runOwnedHEICApplication(t) + return + } + verifyNativeHEICIdentity(t) + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + root, err := heicclient.InstallationRoot(executable, runtime.GOOS) + if err != nil { + t.Fatal(err) + } + if failure := os.Getenv("PICFETCH_HEIC_ACTIVATION_FAILURE"); failure != "" { + if failure == "sandbox readiness" { + t.Run("TestExperimentalHEICReadinessRefusal", testNativeHEICReadinessRefusal) + } else { + t.Run("TestExperimentalHEICUnavailable/"+failure, testNativeHEICUnavailable) + } + return + } + helper, digest, err := heicclient.LoadPackage(root, runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Fatal(err) + } + unchanged := preserveNativeHEICInstallation(t, helper) + defer unchanged() + t.Logf("application=%s helper=%s sha256=%x os=%s arch=%s store=%v", executable, helper, digest, runtime.GOOS, runtime.GOARCH, distribution.StoreManaged) + before := testApp + testApp = nativeHEICApp{App: before, cache: nativeHEICCache{Cache: before.Cache(), root: storage.NewFileURI(t.TempDir())}} + t.Cleanup(func() { testApp = before; fyne.SetCurrentApp(before) }) + preferences.Save(testApp, preferences.State{MaxFileSizeMB: 1}) + uri := storage.NewFileURI(filepath.Join(t.TempDir(), "owned.heic")) + fixture, err := os.ReadFile(os.Getenv("PICFETCH_HEIC_ACTIVATION_FIXTURE")) + if err != nil { + t.Fatal(err) + } + if err = os.WriteFile(uri.Path(), fixture, 0600); err != nil { + t.Fatal(err) + } + heif := storage.NewFileURI(filepath.Join(filepath.Dir(uri.Path()), "owned.heif")) + if err = os.WriteFile(heif.Path(), fixture, 0600); err != nil { + t.Fatal(err) + } + ordinary := storage.NewFileURI(filepath.Join(filepath.Dir(uri.Path()), "ordinary.png")) + if err = os.WriteFile(ordinary.Path(), uitest.EncodePNG(t, 3, 2, color.White), 0600); err != nil { + t.Fatal(err) + } + t.Run("enable stays inactive until restart", func(t *testing.T) { + v, _, _ := newTestUI(t) + if v.images.owner != nil || v.images.foreground.IsSupportedImage(uri) { + t.Fatal("default startup enabled HEIC") + } + check := experimentalHEICCheckbox(t, v) + if check.Checked { + t.Fatal("checkbox did not default off") + } + test.Tap(check) + preferences.Save(testApp, v.currentPreferences()) + if v.images.owner != nil || !preferences.Load(testApp).ExperimentalHEIC { + t.Fatal("checkbox changed this session or lost intent") + } + }) + t.Run("restarted viewer decodes through native helper", func(t *testing.T) { + v, _, _ := newTestUI(t) + if v.images.owner == nil { + t.Fatalf("packaged startup unavailable: %v", v.images.startupError) + } + if v.storeManaged != distribution.StoreManaged || v.explorer.Options().Client.HEIC != v.images.owner { + t.Fatal("distribution or shared ownership changed") + } + dropAndWait(t, v, uri, heif) + if v.img.Image == nil || v.img.Image.Bounds().Dx() != 16 || v.img.Image.Bounds().Dy() != 16 { + t.Fatal("native HEIC viewing did not produce the owned fixture") + } + if err := v.grid.Warm(); err != nil { + t.Fatal(err) + } + if !v.grid.Cached(uri) || !v.grid.Cached(heif) { + t.Fatal("native HEIC did not reach grid previews") + } + t.Run("TestExperimentalHEICMixedDirectoryNavigation", func(t *testing.T) { + dropAndWait(t, v, storage.NewFileURI(filepath.Dir(uri.Path()))) + if len(v.state.files) != 3 { + t.Fatalf("mixed directory admitted %d images, want 3", len(v.state.files)) + } + for i, file := range v.state.files { + v.ShowImage(i) + waitUntilLoaded(t, v) + want := image.Pt(16, 16) + if file.String() == ordinary.String() { + want = image.Pt(3, 2) + } + if v.img.Image == nil || v.img.Image.Bounds().Size() != want || v.display.Snapshot().Displayed.Source.String() != file.String() { + t.Fatalf("mixed directory navigation did not display %s", file.Name()) + } + } + v.display.Settle() + }) + t.Run("live file-size limit", func(t *testing.T) { + // A valid free-space box grows the owned image above the initial + // one-MiB preference without changing its decoded pixels. + free := make([]byte, 1024*1024) + binary.BigEndian.PutUint32(free, uint32(len(free))) + copy(free[4:], "free") + large := storage.NewFileURI(uitest.WriteTempFile(t, "larger.heic", append(slices.Clone(fixture), free...))) + owner := v.images.owner + for _, reader := range []imaging.Reader{v.images.foreground, v.images.background} { + v.SetMaxFileSizeMB(1) + var tooLarge *imaging.InputTooLargeError + if _, err := reader.Read(context.Background(), large); !errors.As(err, &tooLarge) { + t.Fatalf("lowered live limit admitted the image: %v", err) + } + v.SetMaxFileSizeMB(2) + source, err := reader.Read(context.Background(), large) + if err != nil { + t.Fatalf("raised live limit still rejected the image: %v", err) + } + if source.Bounds().Dx() != 16 || source.Bounds().Dy() != 16 || v.images.owner != owner { + t.Fatal("live limit changed native pixels or replaced the shared owner") + } + } + v.SetMaxFileSizeMB(128) + checked := errors.New("input ceiling checked") + _, err := owner.Do(context.Background(), heicdecode.Decode, func(_ context.Context, maxBytes int64) ([]byte, error) { + if maxBytes != 64*1024*1024 { + t.Errorf("native hard input ceiling changed: %d", maxBytes) + } + return nil, checked + }) + if !errors.Is(err, checked) { + t.Fatalf("native hard input ceiling was not checked: %v", err) + } + }) + entered, closed := make(chan struct{}), make(chan struct{}) + var readOnce, closeOnce sync.Once + unblock := func() { closeOnce.Do(func() { close(closed) }) } + defer unblock() + held := uitest.ReaderURI(storage.NewFileURI(uitest.WriteTempFile(t, "a-held.heic", fixture)), func() (io.ReadCloser, error) { + return uitest.ReadCloser{ + ReadFunc: func(_ []byte) (int, error) { + readOnce.Do(func() { close(entered) }) + <-closed + return 0, os.ErrClosed + }, + CloseFunc: func() error { unblock(); return nil }, + }, nil + }) + // The source's first byte read follows actual native readiness. Hold + // that foreground read until navigation closes it through cancellation. + v.handleDrop([]fyne.URI{held, ordinary}) + waitForScan(t, v) + waitForSort(t, v) + select { + case <-entered: + case <-time.After(testTimeout): + t.Fatal("native foreground source was not admitted") + } + owner := v.images.owner + test.Tap(experimentalHEICCheckbox(t, v)) + preferences.Save(testApp, v.currentPreferences()) + if v.images.owner != owner || !v.images.foreground.IsSupportedImage(uri) { + t.Fatal("disabling replaced the running decoder") + } + select { + case <-closed: + t.Fatal("Settings retired active native work") + default: + } + retired := v.display.LoadDone() + replacement := storage.NewFileURI(uitest.WriteTempFile(t, "replacement.png", uitest.EncodePNG(t, 5, 4, color.White))) + dropAndWait(t, v, replacement) + waitHandle(t, "retired native HEIC load", retired) + select { + case <-closed: + default: + t.Fatal("navigation did not close the retired native source") + } + v.display.Settle() + if len(v.state.files) != 1 || v.img.Image == nil || v.img.Image.Bounds().Size() != image.Pt(5, 4) || v.display.Snapshot().Displayed.Source.String() != replacement.String() { + t.Fatal("retired native work replaced ordinary viewing") + } + // Disabling remains restart-only even after cancellation and replacement. + dropAndWait(t, v, uri, heif) + if v.img.Image == nil || v.img.Image.Bounds().Size() != image.Pt(16, 16) || v.images.owner != owner { + t.Fatal("active capability did not survive Settings and source replacement") + } + }) + t.Run("disabled restart and ordinary viewing", func(t *testing.T) { + v, _, _ := newTestUI(t) + if v.images.owner != nil || v.images.foreground.IsSupportedImage(uri) { + t.Fatal("restart did not disable HEIC") + } + ordinary := storage.NewFileURI(uitest.WriteTempFile(t, "ordinary.png", uitest.EncodePNG(t, 3, 2, color.White))) + dropAndWait(t, v, uri, ordinary) + if len(v.state.files) != 1 || v.img.Image == nil || v.img.Image.Bounds().Dx() != 3 { + t.Fatal("ordinary viewing changed with HEIC disabled") + } + }) + _, after, err := heicclient.LoadPackage(root, runtime.GOOS, runtime.GOARCH) + if err != nil || after != digest { + t.Fatal("activation modified the installed helper package") + } + t.Log("native application startup, admission, cancellation and shutdown completed") +} + +func testNativeHEICUnavailable(t *testing.T) { + preferences.Save(testApp, preferences.State{ExperimentalHEIC: true}) + v, _, _ := newTestUI(t) + if v.images.owner != nil || v.images.startupError == nil || !v.images.unavailable() || v.settingsWin.Open() { + t.Fatal("invalid installed package did not fail closed without opening Settings") + } + dir := t.TempDir() + heic := storage.NewFileURI(filepath.Join(dir, "unavailable.heic")) + if err := os.WriteFile(heic.Path(), []byte("owned source must remain unadmitted"), 0600); err != nil { + t.Fatal(err) + } + ordinary := storage.NewFileURI(filepath.Join(dir, "ordinary.png")) + if err := os.WriteFile(ordinary.Path(), uitest.EncodePNG(t, 5, 4, color.White), 0600); err != nil { + t.Fatal(err) + } + dropAndWait(t, v, storage.NewFileURI(dir)) + if v.FileCount() != 1 || v.state.files[0].String() != ordinary.String() || v.img.Image == nil || v.img.Image.Bounds().Size() != image.Pt(5, 4) { + t.Fatal("invalid native package disrupted ordinary directory viewing") + } + if !experimentalHEICCheckbox(t, v).Checked || !preferences.Load(testApp).ExperimentalHEIC || v.images.foreground.IsSupportedImage(heic) { + t.Fatal("invalid native package lost intent or admitted HEIC") + } +} + +func testNativeHEICReadinessRefusal(t *testing.T) { + preferences.Save(testApp, preferences.State{ExperimentalHEIC: true}) + v, _, _ := newTestUI(t) + if v.images.owner == nil || v.images.unavailable() { + t.Fatalf("readiness fixture failed before native launch: %v", v.images.startupError) + } + owner := v.images.owner + var bulkRead atomic.Bool + heic := uitest.ReaderURI(storage.NewFileURI(uitest.WriteTempFile(t, "a-unready.heic", []byte("owned"))), func() (io.ReadCloser, error) { + return uitest.ReadCloser{ + ReadFunc: func(_ []byte) (int, error) { bulkRead.Store(true); return 0, io.EOF }, + CloseFunc: func() error { return nil }, + }, nil + }) + ordinary := storage.NewFileURI(uitest.WriteTempFile(t, "ordinary.png", uitest.EncodePNG(t, 5, 4, color.White))) + dropAndWait(t, v, heic, ordinary) + if bulkRead.Load() || !v.images.unavailable() || v.images.owner != owner { + t.Fatal("native readiness refusal read source bytes, lost status or replaced ownership") + } + if v.FileCount() != 1 || v.state.files[0].String() != ordinary.String() || v.img.Image == nil || v.img.Image.Bounds().Size() != image.Pt(5, 4) || v.settingsWin.Open() { + t.Fatal("native readiness refusal disrupted ordinary viewing or opened Settings") + } + if !experimentalHEICCheckbox(t, v).Checked || !v.images.foreground.IsSupportedImage(heic) { + t.Fatal("native readiness refusal changed saved intent or immutable capability") + } +} + +type nativeHEICApp struct { + fyne.App + cache fyne.Cache +} + +func (a nativeHEICApp) Cache() fyne.Cache { return a.cache } + +type nativeHEICCache struct { + fyne.Cache + root fyne.URI +} + +func (c nativeHEICCache) RootURI() fyne.URI { return c.root } + +func runOwnedHEICApplication(t *testing.T) { + t.Helper() + verifyNativeHEICIdentity(t) + repository, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) + } + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + root := t.TempDir() + destination := filepath.Join(root, "picfetch") + if runtime.GOOS == "windows" { + destination += ".exe" + } + if runtime.GOOS == "darwin" { + root = filepath.Join(root, "PicFetch.app") + destination = filepath.Join(root, "Contents", "MacOS", "picfetch") + } + if err = os.MkdirAll(filepath.Dir(destination), 0700); err != nil { + t.Fatal(err) + } + source, err := os.Open(executable) + if err != nil { + t.Fatal(err) + } + defer func() { _ = source.Close() }() + output, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0700) + if err != nil { + t.Fatal(err) + } + _, copyErr := io.Copy(output, source) + closeErr := output.Close() + if err = errors.Join(copyErr, closeErr); err != nil { + t.Fatal(err) + } + run := func(t *testing.T, command *exec.Cmd) { + t.Helper() + command.Dir = repository + output, err := command.CombinedOutput() + t.Logf("%s\n%s", command.String(), output) + if err != nil { + t.Fatalf("native application fixture: %v", err) + } + } + run(t, exec.Command("go", "run", "./scripts/heicpackage", "-os", runtime.GOOS, "-arch", runtime.GOARCH, "-out", root)) + if runtime.GOOS == "darwin" { + plist := `CFBundleIdentifiercom.frathe.PicFetch.HEICQualificationCFBundleExecutablepicfetchCFBundlePackageTypeAPPL` + if err = os.WriteFile(filepath.Join(root, "Contents", "Info.plist"), []byte(plist), 0600); err != nil { + t.Fatal(err) + } + run(t, exec.Command("/usr/bin/codesign", "--force", "--sign", "-", "--options", "runtime", root)) + run(t, exec.Command("/usr/bin/codesign", "--verify", "--deep", "--strict", "--verbose=2", root)) + } + bytes, err := os.ReadFile(destination) + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(bytes) + t.Logf("owned application sha256=%s", hex.EncodeToString(digest[:])) + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute) + defer cancel() + command := exec.CommandContext(ctx, destination, "-test.run=^TestNativePackagedHEICActivation$", "-test.v", "-test.timeout=3m") + command.Env = append(os.Environ(), "PICFETCH_HEIC_ACTIVATION_CHILD=1", fmt.Sprintf("PICFETCH_HEIC_ACTIVATION_FIXTURE=%s", filepath.Join(repository, "scripts", "heicbuild", "testdata", "tenbit.heic"))) + run(t, command) + + // These mutations apply only to this disposable standalone fixture after + // its successful child has exited. Installed MSIX bytes are never changed. + helper, manifest, err := heicclient.PackagePaths(root, runtime.GOOS) + if err != nil { + t.Fatal(err) + } + manifestBytes, err := os.ReadFile(manifest) + if err != nil { + t.Fatal(err) + } + helperBytes, err := os.ReadFile(helper) + if err != nil { + t.Fatal(err) + } + runFailure := func(t *testing.T, failure string) { + t.Helper() + failureContext, stop := context.WithTimeout(context.Background(), time.Minute) + defer stop() + probe := exec.CommandContext(failureContext, destination, "-test.run=^TestNativePackagedHEICActivation$", "-test.v", "-test.timeout=45s") + probe.Env = append(os.Environ(), "PICFETCH_HEIC_ACTIVATION_CHILD=1", "PICFETCH_HEIC_ACTIVATION_FAILURE="+failure) + output, err := probe.CombinedOutput() + t.Logf("failure=%s\n%s", failure, output) + if err != nil { + t.Fatalf("native package refusal: %v", err) + } + } + for _, failure := range []string{"missing helper", "missing manifest", "invalid manifest", "wrong target", "helper identity"} { + t.Run(failure, func(t *testing.T) { + // Restore both inputs before each independent failure scenario. + if err := os.WriteFile(manifest, manifestBytes, 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(helper, helperBytes, 0700); err != nil { + t.Fatal(err) + } + var mutationErr error + switch failure { + case "missing helper": + mutationErr = os.Remove(helper) + case "missing manifest": + mutationErr = os.Remove(manifest) + case "invalid manifest": + mutationErr = os.WriteFile(manifest, []byte("invalid owned manifest"), 0600) + case "wrong target": + var record heicclient.PackageManifest + mutationErr = json.Unmarshal(manifestBytes, &record) + if mutationErr == nil { + record.GOARCH = "amd64" + if runtime.GOARCH == record.GOARCH { + record.GOARCH = "arm64" + } + var data []byte + data, mutationErr = json.Marshal(record) + if mutationErr == nil { + mutationErr = os.WriteFile(manifest, data, 0600) + } + } + case "helper identity": + mutationErr = os.WriteFile(helper, []byte("changed owned helper"), 0700) + } + if mutationErr != nil { + t.Fatal(mutationErr) + } + runFailure(t, failure) + }) + } + if runtime.GOOS == "darwin" { + t.Run("sandbox readiness", func(t *testing.T) { + if err := os.WriteFile(helper, helperBytes, 0700); err != nil { + t.Fatal(err) + } + // Independently sign this owned negative helper without App Sandbox. + // The real worker must refuse readiness; no production policy changes. + entitlements := filepath.Join(t.TempDir(), "empty.plist") + if err := os.WriteFile(entitlements, []byte(``), 0600); err != nil { + t.Fatal(err) + } + bundle := filepath.Dir(filepath.Dir(filepath.Dir(helper))) + run(t, exec.Command("/usr/bin/codesign", "--force", "--sign", "-", "--options", "runtime", "--entitlements", entitlements, bundle)) + data, err := os.ReadFile(helper) + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(data) + var record heicclient.PackageManifest + if err = json.Unmarshal(manifestBytes, &record); err != nil { + t.Fatal(err) + } + record.ExecutableSHA256 = hex.EncodeToString(digest[:]) + data, err = json.Marshal(record) + if err != nil { + t.Fatal(err) + } + if err = os.WriteFile(manifest, data, 0600); err != nil { + t.Fatal(err) + } + run(t, exec.Command("/usr/bin/codesign", "--force", "--sign", "-", "--options", "runtime", root)) + run(t, exec.Command("/usr/bin/codesign", "--verify", "--deep", "--strict", "--verbose=2", root)) + runFailure(t, "sandbox readiness") + }) + } +} diff --git a/internal/ui/images_native_windows_test.go b/internal/ui/images_native_windows_test.go new file mode 100644 index 00000000..38a95c5a --- /dev/null +++ b/internal/ui/images_native_windows_test.go @@ -0,0 +1,134 @@ +//go:build heicnative && windows && (amd64 || arm64) + +package ui + +import ( + "encoding/json" + "log" + "os" + "path/filepath" + "runtime" + "testing" + "unsafe" + + "golang.org/x/sys/windows" + + "github.com/frathe/picfetch/internal/distribution" +) + +func verifyNativeHEICIdentity(t *testing.T) { + t.Helper() + token := windows.GetCurrentProcessToken() + user, err := token.GetTokenUser() + if err != nil { + t.Fatal(err) + } + groups, err := token.GetTokenGroups() + if err != nil { + t.Fatal(err) + } + administrators, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + if err != nil { + t.Fatal(err) + } + for _, group := range groups.AllGroups() { + if windows.EqualSid(group.Sid, administrators) { + t.Fatal("native HEIC qualification requires a standard user, including no filtered administrator membership") + } + } + t.Logf("standard-user sid=%s", user.User.Sid.String()) + if os.Getenv("PICFETCH_HEIC_ACTIVATION_MSIX") != "1" { + return + } + if !distribution.StoreManaged { + t.Fatal("installed test-MSIX is not Store-managed") + } + procedure := windows.NewLazySystemDLL("kernel32.dll").NewProc("GetCurrentPackageFullName") + if err = procedure.Find(); err != nil { + t.Fatal(err) + } + var count uint32 + result, _, _ := procedure.Call(uintptr(unsafe.Pointer(&count)), 0) + if result != uintptr(windows.ERROR_INSUFFICIENT_BUFFER) || count == 0 || count > 32768 { + t.Fatalf("installed test-MSIX has no package identity: %d", result) + } + name := make([]uint16, count) + result, _, _ = procedure.Call(uintptr(unsafe.Pointer(&count)), uintptr(unsafe.Pointer(&name[0]))) + if result != 0 { + t.Fatalf("read package identity: %d", result) + } + t.Logf("installed MSIX identity=%s", windows.UTF16ToString(name)) +} + +func preserveNativeHEICInstallation(t *testing.T, helper string) func() { + t.Helper() + read := func() string { + t.Helper() + descriptor, err := windows.GetNamedSecurityInfo(helper, windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION) + if err != nil { + t.Fatal(err) + } + return descriptor.String() + } + before := read() + return func() { + if read() != before { + t.Error("activation modified immutable installed helper permissions") + } + } +} + +// The installed test package declares this probe as a separate application. +// Windows launches its installed executable with the package's real identity. +func TestNativeInstalledHEICActivation(t *testing.T) { + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + root := filepath.Dir(executable) + configBytes, err := os.ReadFile(filepath.Join(root, "heic-activation.json")) + if err != nil { + t.Fatal("installed test-MSIX configuration is required: ", err) + } + var config struct{ Evidence, Commit, UserSID string } + if err = json.Unmarshal(configBytes, &config); err != nil || !filepath.IsAbs(config.Evidence) || config.Commit == "" || config.UserSID == "" { + t.Fatal("invalid installed test-MSIX evidence configuration") + } + output, err := os.OpenFile(config.Evidence+".log", os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + t.Fatal(err) + } + // This process runs only this native fixture. Keep its log open through the + // testing package's final PASS/FAIL output; process exit closes the handle. + os.Stdout, os.Stderr = output, output + log.SetOutput(output) + t.Cleanup(func() { + record := struct { + Test, OS, Arch, Commit string + Passed bool + }{ + "TestNativeInstalledHEICActivation", runtime.GOOS, runtime.GOARCH, config.Commit, !t.Failed(), + } + data, encodeErr := json.MarshalIndent(record, "", " ") + if encodeErr != nil { + t.Error(encodeErr) + return + } + temporary := config.Evidence + ".pending" + if writeErr := os.WriteFile(temporary, data, 0600); writeErr != nil { + t.Error(writeErr) + return + } + if renameErr := os.Rename(temporary, config.Evidence+".json"); renameErr != nil { + t.Error(renameErr) + } + }) + user, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil || user.User.Sid.String() != config.UserSID { + t.Fatal("installed activation did not use the provisioned standard account") + } + t.Setenv("PICFETCH_HEIC_ACTIVATION_CHILD", "1") + t.Setenv("PICFETCH_HEIC_ACTIVATION_MSIX", "1") + t.Setenv("PICFETCH_HEIC_ACTIVATION_FIXTURE", filepath.Join(root, "heic-activation-fixture.heic")) + TestNativePackagedHEICActivation(t) +} diff --git a/internal/ui/images_test.go b/internal/ui/images_test.go new file mode 100644 index 00000000..fe698576 --- /dev/null +++ b/internal/ui/images_test.go @@ -0,0 +1,581 @@ +package ui + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "image" + "image/color" + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "strings" + "sync/atomic" + "testing" + "time" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/lang" + "fyne.io/fyne/v2/storage" + "fyne.io/fyne/v2/test" + "fyne.io/fyne/v2/widget" + + "github.com/frathe/picfetch/internal/filesort" + "github.com/frathe/picfetch/internal/heicdecode" + heicclient "github.com/frathe/picfetch/internal/heicdecode/client" + "github.com/frathe/picfetch/internal/imaging" + mosaiccore "github.com/frathe/picfetch/internal/mosaic" + "github.com/frathe/picfetch/internal/preferences" + "github.com/frathe/picfetch/internal/similarity" + explorerui "github.com/frathe/picfetch/internal/ui/explorer" + "github.com/frathe/picfetch/internal/uitest" +) + +func ownedHEICInstallation(t *testing.T) (executable, helper, private string) { + t.Helper() + root := t.TempDir() + executable = filepath.Join(root, "picfetch") + if runtime.GOOS == "darwin" { + root = filepath.Join(root, "PicFetch.app") + executable = filepath.Join(root, "Contents", "MacOS", "picfetch") + } + if err := os.MkdirAll(filepath.Dir(executable), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(executable, []byte("owned main executable"), 0700); err != nil { + t.Fatal(err) + } + private = t.TempDir() + helper, manifest, err := heicclient.PackagePaths(root, runtime.GOOS) + if err != nil { + t.Fatal(err) + } + for _, dir := range []string{filepath.Dir(helper), filepath.Dir(manifest)} { + if err = os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + } + data := []byte("owned helper, never executed by this test") + if err = os.WriteFile(helper, data, 0700); err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(data) + record, err := json.Marshal(heicclient.PackageManifest{Version: 1, GOOS: runtime.GOOS, GOARCH: runtime.GOARCH, + ExecutableSHA256: hex.EncodeToString(digest[:]), GuestSHA256: hex.EncodeToString(digest[:])}) + if err != nil { + t.Fatal(err) + } + if err = os.WriteFile(manifest, record, 0600); err != nil { + t.Fatal(err) + } + return executable, helper, private +} + +func TestExperimentalHEICStartup(t *testing.T) { + executable, helper, private := ownedHEICInstallation(t) + if got := installedImageServices(preferences.State{}, "missing", private); got.owner != nil || got.startupError != nil { + t.Fatal("disabled startup attempted HEIC activation") + } + prefs := preferences.State{ExperimentalHEIC: true} + missing := installedImageServices(prefs, filepath.Join(t.TempDir(), "missing"), private) + if missing.owner != nil || missing.startupError == nil { + t.Fatal("enabled missing package did not report unavailability") + } + services := installedImageServices(prefs, executable, private) + defer func() { services.Stop(); services.Wait() }() + if services.owner == nil || services.startupError != nil || !services.foreground.IsSupportedImage(storage.NewFileURI("photo.heic")) { + t.Fatalf("complete opt-in package not activated: %v", services.startupError) + } + if runtime.GOOS == "windows" { + // Native client tests cover the copied executable's protected identity. + return + } + if err := os.WriteFile(helper, []byte("changed"), 0700); err != nil { + t.Fatal(err) + } + _, err := services.owner.Do(context.Background(), heicdecode.Decode, func(_ context.Context, _ int64) ([]byte, error) { + t.Fatal("changed helper read source input") + return nil, nil + }) + if !errors.Is(err, heicclient.ErrUnavailable) || !services.unavailable() { + t.Fatalf("changed helper not reported unavailable: %v", err) + } +} + +func TestExperimentalHEICAdmission(t *testing.T) { + reader := imaging.NewReader(func(ctx context.Context, op heicdecode.Operation, input heicclient.Input) (heicdecode.Response, error) { + if _, err := input(ctx, 128); err != nil { + return heicdecode.Response{}, err + } + result := heicdecode.Response{} + if op == heicdecode.Decode { + result.Image = image.NewNRGBA(image.Rect(0, 0, 3, 2)) + result.Config = image.Config{Width: 3, Height: 2} + } + return result, nil + }) + for _, active := range []bool{false, true} { + for _, route := range []string{"direct", "folder", "siblings", "restored", "favorite"} { + name := route + "/disabled" + services := imageServices{} + if active { + name = route + "/active" + services = imageServices{foreground: reader, background: reader} + } + t.Run(name, func(t *testing.T) { + v, _, _ := newTestUIWithImages(t, services) + dir := t.TempDir() + var files []fyne.URI + for _, filename := range []string{"still.heic", "still.heif", "ordinary.png"} { + data := []byte("owned source") + if filename == "ordinary.png" { + data = uitest.EncodePNG(t, 3, 2, color.White) + } + path := filepath.Join(dir, filename) + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } + files = append(files, storage.NewFileURI(path)) + } + switch route { + case "direct": + v.handleDrop(files) + case "folder": + v.handleDrop([]fyne.URI{storage.NewFileURI(dir)}) + case "siblings": + v.handleDrop(files[:1]) + case "restored": + v.savedSession = files + v.restoreSession() + case "favorite": + v.OpenFavorite(t.TempDir(), files) + } + waitForScan(t, v) + want := 1 + if active { + want = 3 + } else if route == "siblings" { + want = 0 + } + if want > 0 { + waitForSort(t, v) + waitUntilLoaded(t, v) + } + if len(v.state.files) != want { + t.Fatalf("admitted %d sources, want %d", len(v.state.files), want) + } + }) + } + } +} + +func TestHEICSourceConsumers(t *testing.T) { + var foreground, background atomic.Int64 + reader := func(calls *atomic.Int64) imaging.Reader { + return imaging.NewReader(func(ctx context.Context, op heicdecode.Operation, input heicclient.Input) (heicdecode.Response, error) { + calls.Add(1) + data, err := input(ctx, 128) + if err != nil { + return heicdecode.Response{}, err + } + date := "2026:09:16 12:34:56" + if string(data) == "early" { + date = "2026:09:15 12:34:56" + } + result := heicdecode.Response{Metadata: &heicdecode.Metadata{Make: "Owned camera", DateTimeOriginal: date}} + if op == heicdecode.Decode { + result.Image = image.NewNRGBA64(image.Rect(0, 0, 8, 6)) + result.Config = image.Config{Width: 8, Height: 6} + } + return result, nil + }) + } + v, _, _ := newTestUIWithImages(t, imageServices{foreground: reader(&foreground), background: reader(&background)}) + late := storage.NewFileURI(uitest.WriteTempFile(t, "late.heic", []byte("late"))) + early := storage.NewFileURI(uitest.WriteTempFile(t, "early.heic", []byte("early"))) + files := []fyne.URI{late, early} + dropAndWait(t, v, files...) + v.display.Settle() + if foreground.Load() == 0 || !v.display.Snapshot().HasEXIF { + t.Fatal("viewer did not inject its foreground reader") + } + v.imgCache.Purge() + compared, err := v.loadComparedImage(context.Background(), early) + if err != nil || compared.FileSize != 5 || !compared.HasEXIF { + t.Fatalf("comparison source = %v", err) + } + v.exif.Show() + v.exif.Settle() + if !strings.Contains(v.exif.Text().Text, "Owned camera") || v.exif.StripButton().Visible() { + t.Fatal("EXIF source values or editing capability were lost") + } + if err := v.grid.Warm(); err != nil { + t.Fatal(err) + } + if !v.grid.Cached(early) || background.Load() == 0 { + t.Fatal("grid did not use the background reader") + } + v.SetSortMode(filesort.ByCaptureDate) + waitForSort(t, v) + waitUntilLoaded(t, v) + if v.state.files[0].String() != early.String() { + t.Fatal("capture-date sort ignored isolated metadata") + } + request, err := mosaiccore.NewRequest(files, image.Pt(320, 180), mosaiccore.DefaultSettings(), 1) + if err != nil { + t.Fatal(err) + } + result, err := v.GenerateMosaic(context.Background(), request, nil) + if err != nil || result.Bounds().Size() != image.Pt(320, 180) { + t.Fatalf("mosaic source = %v", err) + } + // Use an uncached third source so Favorite persistence must decode. + favorite := storage.NewFileURI(uitest.WriteTempFile(t, "favorite.heic", []byte("favorite"))) + favDir := t.TempDir() + v.SetFavoritePreviewCache(true) + v.SyncFavoritePreviews(favDir, []fyne.URI{favorite}) + settleFavoritePreviews(t, v) + if got := previewNames(t, favDir); len(got) != 1 { + t.Fatalf("Favorite previews = %v", got) + } +} + +// A notice observes callback submission, so preview assertions never infer +// completion from a read counter or race the worker's subsequent delivery. +type heicPreviewQueue struct { + uitest.UIQueue + notice chan struct{} +} + +func (q *heicPreviewQueue) Do(f func()) { + q.UIQueue.Do(f) + select { + case q.notice <- struct{}{}: + default: + } +} + +func TestExperimentalHEICPreviewConsumers(t *testing.T) { + var decoded atomic.Int64 + reader := imaging.NewReader(func(ctx context.Context, op heicdecode.Operation, input heicclient.Input) (heicdecode.Response, error) { + if _, err := input(ctx, 128); err != nil { + return heicdecode.Response{}, err + } + if op != heicdecode.Decode { + return heicdecode.Response{}, nil + } + decoded.Add(1) + pixels := image.NewNRGBA(image.Rect(0, 0, 8, 6)) + for y := 0; y < 6; y++ { + for x := 0; x < 8; x++ { + pixels.SetNRGBA(x, y, color.NRGBA{R: uint8(x * 30), G: uint8(y * 40), A: 255}) + } + } + return heicdecode.Response{Image: pixels, Config: image.Config{Width: 8, Height: 6}}, nil + }) + v, _, _ := newTestUIWithImages(t, imageServices{foreground: reader, background: reader}) + first := storage.NewFileURI(uitest.WriteTempFile(t, "first.heic", []byte("owned"))) + second := storage.NewFileURI(uitest.WriteTempFile(t, "second.heif", []byte("owned"))) + dropAndWait(t, v, first, second) + v.display.Settle() + t.Run("uncached duplicate previews", func(t *testing.T) { + if v.grid.Cached(first) || v.grid.Cached(second) { + t.Fatal("duplicate fixture already has cached thumbnails") + } + before := decoded.Load() + v.grid.SetBrowsingDuplicates(true) + v.grid.Settle() + if !v.grid.BrowseReady() || len(v.grid.ResultIndexes()) != 2 || decoded.Load() <= before { + t.Fatal("admitted HEIC sources did not produce a duplicate group") + } + for _, uri := range []fyne.URI{first, second} { + pixels, ok := v.grid.CachedThumb(uri) + if !ok || pixels.Bounds().Dx() == 0 || pixels.Bounds().Dy() == 0 { + t.Fatal("duplicate browsing did not produce HEIC previews") + } + } + }) + t.Run("uncached Spiral preview", func(t *testing.T) { + queue := &heicPreviewQueue{notice: make(chan struct{}, 1)} + v.spiral.SetUIQueue(queue) + before := decoded.Load() + v.openSpiral() + defer func() { v.spiral.Close(); v.spiral.Settle() }() + var shader *canvas.Shader + for _, win := range testApp.Driver().AllWindows() { + if win.Title() == lang.L("Hypno Spiral") { + shader, _ = win.Content().(*canvas.Shader) + } + } + if shader == nil { + t.Fatal("Spiral shader is absent from the window") + } + deadline := time.NewTimer(testTimeout) + defer deadline.Stop() + for { + select { + case <-queue.notice: + queue.Drain() + pixels := shader.Textures["traveller0"] + if pixels != nil && pixels.Bounds().Size() == image.Pt(8, 6) { + if decoded.Load() <= before { + t.Fatal("Spiral used cached pixels instead of its HEIC reader") + } + return + } + case <-deadline.C: + t.Fatal("admitted HEIC preview did not reach the Spiral shader") + } + } + }) +} + +func TestExperimentalHEICActiveAnalysis(t *testing.T) { + before := preferences.Load(testApp) + t.Cleanup(func() { preferences.Save(testApp, before) }) + preferences.Save(testApp, preferences.State{ExperimentalHEIC: true}) + reader := imaging.NewReader(func(ctx context.Context, op heicdecode.Operation, input heicclient.Input) (heicdecode.Response, error) { + if _, err := input(ctx, 128); err != nil { + return heicdecode.Response{}, err + } + if op != heicdecode.Decode { + return heicdecode.Response{}, nil + } + return heicdecode.Response{Image: image.NewNRGBA(image.Rect(0, 0, 8, 6)), Config: image.Config{Width: 8, Height: 6}}, nil + }) + v, _, _ := newTestUIWithImages(t, imageServices{foreground: reader, background: reader}) + first := storage.NewFileURI(uitest.WriteTempFile(t, "first.heic", []byte("owned"))) + second := storage.NewFileURI(uitest.WriteTempFile(t, "second.heif", []byte("owned"))) + dropAndWait(t, v, first, second) + v.display.Settle() + started, stopped := make(chan error, 1), make(chan struct{}) + preview := uitest.EncodeJPEG(t, 8, 6, color.White) + configureExplorer(v, func(options *explorerui.Options) { + options.Analyze = func(ctx context.Context, paths []string, _ <-chan similarity.Control, emit func(similarity.Event)) error { + defer close(stopped) + source, err := v.images.background.Read(ctx, storage.NewFileURI(paths[0])) + if err == nil && source.Bounds().Size() != image.Pt(8, 6) { + err = errors.New("analysis did not receive HEIC pixels") + } + started <- err + if err != nil { + return err + } + <-ctx.Done() + emit(similarity.Event{Complete: true, Total: len(paths), Successful: len(paths), Items: []similarity.Item{{Path: paths[0], Cohort: "retired", Position: []float32{0, 0}, Preview: preview}}}) + return ctx.Err() + } + }) + explorerMenu(t, v).Action() + select { + case err := <-started: + if err != nil { + t.Fatal(err) + } + case <-time.After(testTimeout): + t.Fatal("HEIC analysis did not begin") + } + analysis := observeExplorer(v.explorer) + check := experimentalHEICCheckbox(t, v) + test.Tap(check) + preferences.Save(testApp, v.currentPreferences()) + if preferences.Load(testApp).ExperimentalHEIC || !v.images.foreground.IsSupportedImage(first) || !v.images.background.IsSupportedImage(second) { + t.Fatal("active analysis Settings edit changed session capability or lost saved intent") + } + if !analysis.current() { + t.Fatal("saved HEIC edit retired the active analysis session") + } + select { + case <-stopped: + t.Fatal("saved HEIC edit cancelled active analysis") + default: + } + replacement := storage.NewFileURI(uitest.WriteTempFile(t, "replacement.png", uitest.EncodePNG(t, 5, 4, color.White))) + dropAndWait(t, v, replacement) + select { + case <-stopped: + case <-time.After(testTimeout): + t.Fatal("source replacement did not cancel HEIC analysis") + } + v.settleExplorer() + if len(explorerPiles(v)) != 0 || v.FileCount() != 1 || v.img.Image == nil || v.img.Image.Bounds().Size() != image.Pt(5, 4) { + t.Fatal("retired HEIC analysis changed replacement viewing") + } + dropAndWait(t, v, first, second) + if v.FileCount() != 2 || v.img.Image == nil || v.img.Image.Bounds().Size() != image.Pt(8, 6) { + t.Fatal("saved disable stopped HEIC before restart") + } +} + +func TestHEICOwnerStopsWithViewer(t *testing.T) { + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + owner, err := heicclient.New(heicclient.Config{Executable: executable, SHA256: [32]byte{1}, Limits: heicdecode.DefaultLimits(0)}) + if err != nil { + t.Fatal(err) + } + defer func() { owner.Stop(); owner.Wait() }() + v, _, _ := newTestUIWithImages(t, newImageServices(owner)) + if v.explorer.Options().Client.HEIC != owner { + t.Fatal("Explorer received a different admission owner") + } + link, err := owner.Attach(context.Background(), exec.Command(executable)) + if err != nil { + t.Fatal(err) + } + defer func() { link.Stop(); link.Wait() }() + lifecycle, ok := testApp.Lifecycle().(interface{ OnStopped() func() }) + if !ok { + t.Fatal("test lifecycle has no stopped hook") + } + previous := lifecycle.OnStopped() + t.Cleanup(func() { testApp.Lifecycle().SetOnStopped(previous) }) + registerShutdown(testApp, v) + lifecycle.OnStopped()() + v.waitForShutdown() + _, err = owner.Do(context.Background(), heicdecode.Decode, func(_ context.Context, _ int64) ([]byte, error) { + t.Fatal("stopped owner read a source") + return nil, nil + }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("viewer left owner admission open: %v", err) + } +} + +func experimentalHEICCheckbox(t *testing.T, v *viewer) *widget.Check { + t.Helper() + before := slices.Clone(testApp.Driver().AllWindows()) + if v.settingsWin.Open() { + t.Fatal("fixture requires this viewer's Settings to be closed") + } + v.showSettings() + for _, win := range testApp.Driver().AllWindows() { + if slices.Contains(before, win) || win.Title() != lang.L("Settings") { + continue + } + surface := win.Content() + if wrapper, ok := surface.(*fyne.Container); ok && len(wrapper.Objects) == 1 { + surface = wrapper.Objects[0] + } + tabs, ok := surface.(*container.AppTabs) + if !ok { + t.Fatal("Settings surface has no tabs") + } + tab := tabs.Items[len(tabs.Items)-1] + if tab.Text != lang.L("Experimental") { + t.Fatal("Experimental is not the last Settings tab") + } + t.Cleanup(func() { + if slices.Contains(testApp.Driver().AllWindows(), win) { + win.Close() + } + }) + tabs.Select(tab) + var found *widget.Check + var unavailable *widget.Label + var walk func(fyne.CanvasObject) + walk = func(obj fyne.CanvasObject) { + switch value := obj.(type) { + case *fyne.Container: + for _, child := range value.Objects { + walk(child) + } + case *container.Scroll: + walk(value.Content) + case *widget.Check: + if value.Text == lang.L("Experimental HEIC support") { + found = value + } + case *widget.Label: + if value.Text == lang.L("Experimental HEIC support is unavailable. Check the installed helper package and sandbox permissions.") { + unavailable = value + } + } + } + walk(tab.Content) + if found == nil { + t.Fatal("Experimental checkbox is not in the Settings surface") + } + if v.images.unavailable() && (unavailable == nil || !unavailable.Visible()) { + t.Fatal("unavailable HEIC explanation is not visible in Experimental") + } + return found + } + t.Fatal("Settings window was not opened") + return nil +} + +func TestExperimentalHEICRestartOnly(t *testing.T) { + // Another viewer's Settings surface must not receive this viewer's edits. + prior := testApp.NewWindow(lang.L("Settings")) + prior.SetContent(container.NewAppTabs(container.NewTabItem(lang.L("Experimental"), widget.NewCheck(lang.L("Experimental HEIC support"), nil)))) + prior.Show() + t.Cleanup(func() { + if slices.Contains(testApp.Driver().AllWindows(), prior) { + prior.Close() + } + }) + before := preferences.Load(testApp) + t.Cleanup(func() { preferences.Save(testApp, before) }) + preferences.Save(testApp, preferences.State{}) + executable, _, private := ownedHEICInstallation(t) + heic := storage.NewFileURI("owned.heic") + t.Run("enable requires restart", func(t *testing.T) { + v, _, _ := newTestUI(t) + check := experimentalHEICCheckbox(t, v) + if check.Checked || v.images.foreground.IsSupportedImage(heic) { + t.Fatal("HEIC enabled by default") + } + test.Tap(check) + preferences.Save(testApp, v.currentPreferences()) + if !preferences.Load(testApp).ExperimentalHEIC || v.images.foreground.IsSupportedImage(heic) || v.images.owner != nil { + t.Fatal("enabling changed the running capability or lost the preference") + } + }) + t.Run("disable requires restart", func(t *testing.T) { + services := installedImageServices(preferences.Load(testApp), executable, private) + if services.owner == nil { + t.Fatalf("restart did not activate saved preference: %v", services.startupError) + } + v, _, _ := newTestUIWithImages(t, services) + check := experimentalHEICCheckbox(t, v) + if !check.Checked { + t.Fatal("saved preference was not restored to the checkbox") + } + test.Tap(check) + preferences.Save(testApp, v.currentPreferences()) + if preferences.Load(testApp).ExperimentalHEIC || v.images.owner != services.owner || !v.images.foreground.IsSupportedImage(heic) { + t.Fatal("disabling replaced the running owner or lost the preference") + } + }) + t.Run("restart disables", func(t *testing.T) { + services := installedImageServices(preferences.Load(testApp), executable, private) + v, _, _ := newTestUIWithImages(t, services) + if v.images.owner != nil || v.images.foreground.IsSupportedImage(heic) { + t.Fatal("disabled preference did not take effect after restart") + } + }) + t.Run("unavailable retains intent and ordinary viewing", func(t *testing.T) { + prefs := preferences.Load(testApp) + prefs.ExperimentalHEIC = true + preferences.Save(testApp, prefs) + services := installedImageServices(prefs, filepath.Join(t.TempDir(), "missing"), private) + v, _, _ := newTestUIWithImages(t, services) + if !experimentalHEICCheckbox(t, v).Checked || !v.images.unavailable() { + t.Fatal("unavailable package lost saved intent or explanation state") + } + ordinary := storage.NewFileURI(uitest.WriteTempFile(t, "ordinary.png", uitest.EncodePNG(t, 3, 2, color.White))) + dropAndWait(t, v, ordinary) + if !v.currentPreferences().ExperimentalHEIC || v.images.owner != nil || v.img.Image == nil { + t.Fatal("ordinary viewing did not survive unavailable HEIC") + } + }) +} diff --git a/internal/ui/load.go b/internal/ui/load.go index 68410068..333457c9 100644 --- a/internal/ui/load.go +++ b/internal/ui/load.go @@ -63,6 +63,7 @@ func (v *viewer) imageProbed(bounds image.Rectangle) { // imagePresented runs synchronously after coherent display publication and // before the owner admits animation or neighbor work and completes the load. func (v *viewer) imagePresented(snapshot display.Snapshot) []fyne.URI { + v.settingsWin.SetHEICUnavailable(v.images.unavailable()) v.syncPresentationLogicalSize() v.dropzone.Hide() v.welcomeArt.Hide() @@ -107,6 +108,7 @@ func (v *viewer) applyLoadedTitle(snapshot display.Snapshot) { } func (v *viewer) imageLoadFailed(source fyne.URI, err error) fyne.URI { + v.settingsWin.SetHEICUnavailable(v.images.unavailable()) msg := fmt.Sprintf(lang.L("could not read %q: %v"), source.Name(), err) var dimensions *imaging.InvalidDimensionsError var tooLarge *imaging.InputTooLargeError diff --git a/internal/ui/memlimits.go b/internal/ui/memlimits.go index de82cc73..b6a98c41 100644 --- a/internal/ui/memlimits.go +++ b/internal/ui/memlimits.go @@ -63,6 +63,7 @@ type settings struct { // from preferences.State.FavoritePreviewCache in features.go and read // back into it by currentPreferences (run.go). favPreviewCache bool + experimentalHEIC bool looseAnalysisCache bool analysisCacheMiB int @@ -232,6 +233,7 @@ func (v *viewer) settingsState() preferences.State { MaxThumbCacheMB: v.MaxThumbCacheMB(), MaxFileSizeMB: v.MaxFileSizeMB(), FavoritePreviewCache: v.FavoritePreviewCache(), + ExperimentalHEIC: v.settings.experimentalHEIC, SimilarityFavoriteCache: v.explorer.Settings().CacheFavorites, SimilarityLooseCache: v.settings.looseAnalysisCache, AnalysisCacheLimitMiB: v.settings.analysisCacheMiB, @@ -244,6 +246,11 @@ func (v *viewer) settingsState() preferences.State { } } +func (v *viewer) showSettings() { + v.settingsWin.SetHEICUnavailable(v.images.unavailable()) + v.settingsWin.Show(v.settingsState(), v.storeManaged) +} + func applySettingChange[T comparable](prev, next T, apply func(T)) { if next != prev { apply(next) @@ -267,6 +274,9 @@ func (v *viewer) applyLimitSettings(prev, next preferences.State) { } func (v *viewer) applyIntegrationSettings(prev, next preferences.State) { + applySettingChange(prev.ExperimentalHEIC, next.ExperimentalHEIC, func(on bool) { + v.settings.experimentalHEIC = on + }) applySettingChange(prev.SimilarityFavoriteCache, next.SimilarityFavoriteCache, func(on bool) { settings := v.explorer.Settings() settings.CacheFavorites = on diff --git a/internal/ui/menu.go b/internal/ui/menu.go index 8888e107..3109a213 100644 --- a/internal/ui/menu.go +++ b/internal/ui/menu.go @@ -23,7 +23,7 @@ func buildMainMenu(view *viewer) *fyne.MainMenu { SaveRotation: func() { view.saveRotation() }, PromptExport: func() { view.promptExport() }, CloseFiles: func() { view.closeFiles() }, - ShowSettings: func() { view.settingsWin.Show(view.settingsState(), view.storeManaged) }, + ShowSettings: view.showSettings, ShowViewer: view.showViewer, ShowExplorer: view.showExplorer, diff --git a/internal/ui/mosaic.go b/internal/ui/mosaic.go index 27d1fa90..be13a09e 100644 --- a/internal/ui/mosaic.go +++ b/internal/ui/mosaic.go @@ -127,7 +127,7 @@ func (v *viewer) showMosaic() { } func (v *viewer) GenerateMosaic(ctx context.Context, request mosaiccore.Request, report func(mosaiccore.Progress)) (mosaiccore.Result, error) { - return mosaiccore.GenerateWithProgress(ctx, request, report) + return mosaiccore.NewWithReader(v.images.background).GenerateWithProgress(ctx, request, report) } func (v *viewer) InspectMosaicDisplays() (displays.Snapshot, error) { diff --git a/internal/ui/run.go b/internal/ui/run.go index 3405a8d6..a5dc319a 100644 --- a/internal/ui/run.go +++ b/internal/ui/run.go @@ -133,6 +133,7 @@ func (v *viewer) waitForShutdown() { v.visualsearch.Wait() v.searchView.overlayWorkers.Wait() v.analysisCache.Wait() + v.images.Wait() } // Runtime side effects start only after feature construction and geometry @@ -166,6 +167,7 @@ func registerShutdown(application fyne.App, view *viewer) { // same guaranteed-synchronous flush instead of racing it. application.Lifecycle().SetOnStopped(func() { view.stopping = true + view.images.Stop() view.spiral.Close() view.closeExplorer() view.stopSearchOverlayWait() @@ -206,6 +208,9 @@ func registerShutdown(application fyne.App, view *viewer) { session.Save(application, view.state.unsortedFiles) preferences.Save(application, view.currentPreferences()) if !view.storeManaged && view.explorer.Trial() == nil { + // Helper files can be replaced by this update. Its native/pipe + // work has no remaining UI delivery dependency after Stop. + view.images.Wait() view.updater.ApplyStagedUpdate() } }) @@ -239,6 +244,7 @@ func (v *viewer) currentPreferences() preferences.State { MaxThumbCacheMB: v.settings.thumbCacheMB, MaxFileSizeMB: v.settings.maxFileMB, FavoritePreviewCache: v.settings.favPreviewCache, + ExperimentalHEIC: v.settings.experimentalHEIC, SimilarityFavoriteCache: v.explorer.Settings().CacheFavorites, SimilarityLooseCache: v.settings.looseAnalysisCache, AnalysisCacheLimitMiB: v.settings.analysisCacheMiB, diff --git a/internal/ui/savework_test.go b/internal/ui/savework_test.go index 310fe015..84ec120b 100644 --- a/internal/ui/savework_test.go +++ b/internal/ui/savework_test.go @@ -285,11 +285,13 @@ func (p *heldSavePixels) At(x, y int) color.Color { } func TestSaveChangesLeavesQueuedUIResponsive(t *testing.T) { - v, _, _ := newTestUI(t) - source := storage.NewFileURI(uitest.WriteTempFile(t, "save.png", uitest.EncodePNG(t, 8, 16, color.White))) - dropAndWait(t, v, source) - v.rotateBy(1) synctest.Test(t, func(t *testing.T) { + // Source cancellation registers with fileWork.ctx. Construct and + // clean up its owner in this same bubble as the worker's signal. + v, _, _ := newTestUI(t) + source := storage.NewFileURI(uitest.WriteTempFile(t, "save.png", uitest.EncodePNG(t, 8, 16, color.White))) + dropAndWait(t, v, source) + v.rotateBy(1) pixels := &heldSavePixels{Image: v.img.Image, entered: make(chan struct{}), release: make(chan struct{})} v.img.Image = pixels actions := make(chan func(), 2) diff --git a/internal/ui/settingswin/settingswin.go b/internal/ui/settingswin/settingswin.go index 92587afe..c7c247a6 100644 --- a/internal/ui/settingswin/settingswin.go +++ b/internal/ui/settingswin/settingswin.go @@ -31,7 +31,7 @@ import ( ) const ( - windowW = 520.0 + windowW = 640.0 windowH = 520.0 // time.Duration is an int64 nanosecond count. Reject a larger number of @@ -68,6 +68,8 @@ type Window struct { updatesManagedByStore bool cacheContent func() fyne.CanvasObject cacheClosed func() + heicUnavailable bool + heicExplanation *widget.Label // prefs is the form snapshot Show seeded, mutated by each control, and // pushed back through Host.ApplySettings. Ignored while the window is @@ -85,6 +87,7 @@ type Window struct { mergeCheck, shuffleCheck *widget.Check favPreviewCheck, updateCheck *widget.Check staticSizeCheck *widget.Check + experimentalHEIC *widget.Check updateNow *widget.Button updateVersion *widget.Label updateManaged *widget.Label @@ -125,6 +128,19 @@ func (w *Window) SetCacheTab(content func() fyne.CanvasObject, closed func()) { w.cacheContent, w.cacheClosed = content, closed } +// SetHEICUnavailable supplies the current session's status before Show. +// Saved intent remains in the preference snapshot. +func (w *Window) SetHEICUnavailable(unavailable bool) { + w.heicUnavailable = unavailable + if w.heicExplanation != nil { + if unavailable { + w.heicExplanation.Show() + } else { + w.heicExplanation.Hide() + } + } +} + // ConfirmClearAnalysis owns confirmation on the live Settings window. func (w *Window) ConfirmClearAnalysis(answer func(bool)) { if w.win.Window() == nil { @@ -152,6 +168,8 @@ func (w *Window) Show(prefs preferences.State, updatesManagedByStore bool) { w.mergeCheck, w.shuffleCheck = nil, nil w.favPreviewCheck, w.updateCheck = nil, nil w.staticSizeCheck = nil + w.experimentalHEIC = nil + w.heicExplanation = nil w.updateNow = nil w.updateVersion = nil w.updateManaged = nil @@ -400,6 +418,17 @@ func (w *Window) build() fyne.CanvasObject { if w.cacheContent != nil { tabs.Append(container.NewTabItem(lang.L("Cache"), container.NewPadded(container.NewVScroll(w.cacheContent())))) } + w.experimentalHEIC = widget.NewCheck(lang.L("Experimental HEIC support"), func(on bool) { + w.apply(func(s *preferences.State) { s.ExperimentalHEIC = on }) + }) + w.experimentalHEIC.Checked = w.prefs.ExperimentalHEIC + warning := widget.NewLabel(lang.L("Restart PicFetch to apply. Colors may be inaccurate; HDR is not supported.")) + warning.Wrapping = fyne.TextWrapWord + w.heicExplanation = widget.NewLabel(lang.L("Experimental HEIC support is unavailable. Check the installed helper package and sandbox permissions.")) + w.heicExplanation.Wrapping = fyne.TextWrapWord + w.SetHEICUnavailable(w.heicUnavailable) + experimental := container.NewVBox(w.experimentalHEIC, warning, w.heicExplanation) + tabs.Append(container.NewTabItem(lang.L("Experimental"), container.NewPadded(container.NewVScroll(experimental)))) return tabs } diff --git a/internal/ui/settingswin/settingswin_test.go b/internal/ui/settingswin/settingswin_test.go index 0ad451ba..7aa81e29 100644 --- a/internal/ui/settingswin/settingswin_test.go +++ b/internal/ui/settingswin/settingswin_test.go @@ -1,14 +1,20 @@ package settingswin import ( + "encoding/json" "errors" + "fmt" + "image/png" "os" + "path/filepath" "testing" "time" "fyne.io/fyne/v2" + fynecanvas "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/test" + "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" "github.com/frathe/picfetch/internal/appearance" @@ -698,7 +704,7 @@ func TestSettingsCacheTabCompositionAndClose(t *testing.T) { } }) tabs := settingsTabs(t, w) - if len(tabs.Items) != 5 || !containsCanvasObject(tabs.Items[4].Content, content) { + if len(tabs.Items) != 6 || !containsCanvasObject(tabs.Items[4].Content, content) || tabs.Items[5].Text != "Experimental" { t.Fatal("cache content was not attached to Settings") } w.win.Window().Close() @@ -753,7 +759,7 @@ func TestSettingsTabs_GroupControlsAndOpenOnGeneral(t *testing.T) { w := newUpdateTestWindow(t, &fakeHost{}) tabs := settingsTabs(t, w) - wantLabels := []string{"General", "Appearance", "Updates", "Limits"} + wantLabels := []string{"General", "Appearance", "Updates", "Limits", "Experimental"} if len(tabs.Items) != len(wantLabels) { t.Fatalf("tab count = %d, want %d", len(tabs.Items), len(wantLabels)) } @@ -842,6 +848,46 @@ func TestSettingsTabs_GroupControlsAndOpenOnGeneral(t *testing.T) { } } +func TestExperimentalHEICControl(t *testing.T) { + for _, store := range []bool{false, true} { + host := &fakeHost{} + w := New(testApp, host) + w.SetHEICUnavailable(true) + w.Show(host.prefs, store) + tabs := settingsTabs(t, w) + experimental := tabs.Items[len(tabs.Items)-1] + if experimental.Text != "Experimental" || tabs.SelectedIndex() != 0 || w.experimentalHEIC.Checked { + t.Fatal("experimental tab or default preference changed") + } + content := tabVBox(t, experimental) + if len(content.Objects) != 3 || content.Objects[0] != w.experimentalHEIC { + t.Fatal("experimental control is not in the tab surface") + } + if label, ok := content.Objects[1].(*widget.Label); !ok || label.Text != "Restart PicFetch to apply. Colors may be inaccurate; HDR is not supported." { + t.Fatal("restart/color explanation is not beneath the checkbox") + } + if label, ok := content.Objects[2].(*widget.Label); !ok || label.Text != "Experimental HEIC support is unavailable. Check the installed helper package and sandbox permissions." { + t.Fatal("unavailable package has no localized explanation") + } + for _, tab := range tabs.Items[:len(tabs.Items)-1] { + if containsCanvasObject(tab.Content, w.experimentalHEIC) { + t.Fatal("HEIC control occurs outside Experimental") + } + } + test.Tap(w.experimentalHEIC) + if !lastApply(t, host).ExperimentalHEIC || host.prevCalls[0].ExperimentalHEIC { + t.Fatal("checkbox did not apply the saved choice") + } + tabs.Select(experimental) + w.win.Window().Close() + w.Show(host.prefs, store) + if settingsTabs(t, w).SelectedIndex() != 0 || !w.experimentalHEIC.Checked { + t.Fatal("reopening lost saved intent or selected Experimental") + } + w.win.Window().Close() + } +} + func TestUpdatesTab_ShowsCurrentVersionAndBuild(t *testing.T) { app := metadataApp{ App: testApp, @@ -1102,3 +1148,120 @@ func TestApply_PassesThePreviousSnapshot(t *testing.T) { t.Error("second prev is not the first push's next — the chain is broken") } } + +func TestExperimentalHEICUnavailableStatus(t *testing.T) { + w := newUpdateTestWindow(t, &fakeHost{}) + tabs := settingsTabs(t, w) + content := tabVBox(t, tabs.Items[len(tabs.Items)-1]) + w.SetHEICUnavailable(true) + w.Show(preferences.State{}, false) + if len(content.Objects) != 3 || !content.Objects[2].Visible() { + t.Fatal("late helper failure did not reach the already-open Settings surface") + } + if tabs.SelectedIndex() != 0 { + t.Fatal("unavailable status selected Experimental automatically") + } + w.SetHEICUnavailable(false) + if content.Objects[2].Visible() { + t.Fatal("successful later launch retained stale unavailability") + } +} + +func TestExperimentalHEICLayout(t *testing.T) { + original := testApp.Settings().Theme() + t.Cleanup(func() { testApp.Settings().SetTheme(original) }) + testApp.Settings().SetTheme(theme.DefaultTheme()) + for _, locale := range []string{"en", "de"} { + data, err := os.ReadFile(filepath.Join("..", "..", "..", "translations", locale+".json")) + if err != nil { + t.Fatal(err) + } + var strings map[string]string + if err = json.Unmarshal(data, &strings); err != nil { + t.Fatal(err) + } + for _, mode := range []appearance.Mode{appearance.Light, appearance.Dark} { + for _, cache := range []bool{false, true} { + for _, store := range []bool{false, true} { + name := fmt.Sprintf("%s-%s-cache%v-store%v", locale, mode.PrefValue(), cache, store) + t.Run(name, func(t *testing.T) { + appearance.Apply(testApp, mode) + w := New(testApp, &fakeHost{}) + w.SetHEICUnavailable(true) + if cache { + w.SetCacheTab(func() fyne.CanvasObject { return widget.NewLabel("Owned cache surface") }, nil) + } + w.Show(preferences.State{}, store) + t.Cleanup(func() { w.win.Window().Close(); w.WaitForTracking() }) + tabs := settingsTabs(t, w) + for _, tab := range tabs.Items { + tab.Text = strings[tab.Text] + } + tab := tabs.Items[len(tabs.Items)-1] + content := tabVBox(t, tab) + w.experimentalHEIC.Text = strings[w.experimentalHEIC.Text] + w.experimentalHEIC.Refresh() + for _, obj := range content.Objects[1:] { + label := obj.(*widget.Label) + label.SetText(strings[label.Text]) + } + tabs.Refresh() + tabs.Select(tab) + win := w.win.Window() + win.Resize(fyne.NewSize(windowW, windowH)) + canvas := win.Canvas() + canvas.Content().Refresh() + if !visibleTabLabel(tabs, tab.Text) { + t.Fatal("Experimental tab label is hidden behind overflow at the default size") + } + if minimum := canvas.Content().MinSize(); minimum.Width > windowW || minimum.Height > windowH { + t.Fatalf("Settings no longer fits its default size: %v", minimum) + } + for _, obj := range content.Objects { + if obj.Size().Width <= 0 || obj.Position().X+obj.Size().Width > content.Size().Width+1 { + t.Fatalf("Experimental content exceeds its surface: %T %v %v", obj, obj.Position(), obj.Size()) + } + } + if directory := os.Getenv("PICFETCH_SETTINGS_LAYOUT_OUTPUT"); directory != "" { + if err := os.MkdirAll(directory, 0700); err != nil { + t.Fatal(err) + } + file, err := os.Create(filepath.Join(directory, name+".png")) + if err != nil { + t.Fatal(err) + } + encodeErr := png.Encode(file, canvas.Capture()) + closeErr := file.Close() + if err = errors.Join(encodeErr, closeErr); err != nil { + t.Fatal(err) + } + } + }) + } + } + } + } +} + +func visibleTabLabel(object fyne.CanvasObject, text string) bool { + if !object.Visible() { + return false + } + switch value := object.(type) { + case *fynecanvas.Text: + return value.Text == text + case *fyne.Container: + for _, child := range value.Objects { + if visibleTabLabel(child, text) { + return true + } + } + case fyne.Widget: + for _, child := range test.WidgetRenderer(value).Objects() { + if visibleTabLabel(child, text) { + return true + } + } + } + return false +} diff --git a/internal/ui/sort.go b/internal/ui/sort.go index 93d86236..2bb61060 100644 --- a/internal/ui/sort.go +++ b/internal/ui/sort.go @@ -110,7 +110,7 @@ func (v *viewer) startSort(mode filesort.Mode, unsorted []fyne.URI, onDone func( v.ForceRepaint() go func() { - ordered := filesort.Order(token.context(), mode, unsorted) + ordered := filesort.OrderWithReader(token.context(), mode, unsorted, v.images.background) fyne.Do(func() { v.finishSort(token, ordered, sortDone, onDone) }) diff --git a/internal/ui/spiral/spiral.go b/internal/ui/spiral/spiral.go index c0675c6d..ce5b26bf 100644 --- a/internal/ui/spiral/spiral.go +++ b/internal/ui/spiral/spiral.go @@ -36,6 +36,8 @@ import ( "sync/atomic" "time" + "github.com/frathe/picfetch/internal/imaging" + "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/lang" @@ -58,7 +60,8 @@ const followEpsilon = 0.5 // without opening anything, so the app can construct it at startup and pay // nothing until someone actually finds it. type Spiral struct { - app fyne.App + reader imaging.Reader + app fyne.App // win is the open window, nil whenever it is closed. Read and written // only on Fyne's UI goroutine (Show, Close, the SetOnClosed callback, @@ -124,7 +127,13 @@ type Spiral struct { // construct unconditionally at startup: no window, no goroutine, no shader // - just the state the first Show will seed its uniforms from. func New(app fyne.App) *Spiral { + return NewWithReader(app, imaging.Reader{}) +} + +// NewWithReader installs the canonical source dependency before work starts. +func NewWithReader(app fyne.App, reader imaging.Reader) *Spiral { return &Spiral{ + reader: reader, app: app, st: newState(), frameInterval: defaultFrameInterval, diff --git a/internal/ui/spiral/tunnel.go b/internal/ui/spiral/tunnel.go index 34d5acf3..64c8d0f1 100644 --- a/internal/ui/spiral/tunnel.go +++ b/internal/ui/spiral/tunnel.go @@ -9,8 +9,6 @@ import ( "time" "fyne.io/fyne/v2" - - "github.com/frathe/picfetch/internal/imaging" ) const tunnelPreviewEdge = 512 @@ -156,7 +154,7 @@ func (s *Spiral) loadTunnelPreview(t *tunnelSession, index int) { queue := s.ui s.previewWorkers.Go(func() { defer cancel() - loaded, err := imaging.LoadAnimatedPreviewContext(ctx, u, tunnelPreviewEdge, tunnelAnimationBytes) + loaded, err := s.reader.Preview(ctx, u, tunnelPreviewEdge, tunnelAnimationBytes) var preview *tunnelPreview if err == nil && ctx.Err() == nil { preview = newTunnelPreview(index, loaded) diff --git a/internal/ui/spiral/tunnel_test.go b/internal/ui/spiral/tunnel_test.go index ce46cba9..eb4d91a2 100644 --- a/internal/ui/spiral/tunnel_test.go +++ b/internal/ui/spiral/tunnel_test.go @@ -2,6 +2,7 @@ package spiral import ( "bytes" + "context" "fmt" "image" "image/color" @@ -16,9 +17,39 @@ import ( "fyne.io/fyne/v2/storage" "fyne.io/fyne/v2/test" + "github.com/frathe/picfetch/internal/heicdecode" + heicclient "github.com/frathe/picfetch/internal/heicdecode/client" + "github.com/frathe/picfetch/internal/imaging" "github.com/frathe/picfetch/internal/uitest" ) +func TestTunnelUsesInjectedHEICReader(t *testing.T) { + reader := imaging.NewReader(func(ctx context.Context, op heicdecode.Operation, input heicclient.Input) (heicdecode.Response, error) { + if op != heicdecode.Decode { + return heicdecode.Response{}, heicdecode.ErrInvalidRequest + } + if _, err := input(ctx, 128); err != nil { + return heicdecode.Response{}, err + } + return heicdecode.Response{Image: image.NewNRGBA64(image.Rect(0, 0, 8, 6)), Config: image.Config{Width: 8, Height: 6}}, nil + }) + s := NewWithReader(test.NewApp(), reader) + s.SetUIQueue(&uitest.UIQueue{}) + s.frameInterval = time.Minute + s.st.randomness = 0 + s.now = func() time.Time { return time.Unix(1000, 0) } + t.Cleanup(func() { s.Close(); waitSettled(t, s) }) + u := storage.NewFileURI(uitest.WriteTempFile(t, "owned.heic", []byte("owned source"))) + s.Show([]fyne.URI{u}) + s.win.Resize(fyne.NewSize(800, 600)) + s.previewWorkers.Wait() + s.ui.Drain() + pixels := s.shader.Textures["traveller0"] + if pixels == nil || pixels.Bounds().Size() != image.Pt(8, 6) { + t.Fatal("isolated preview did not reach the Spiral shader") + } +} + func BenchmarkAdvanceTunnel(b *testing.B) { a := test.NewApp() s := New(a) diff --git a/internal/ui/startup.go b/internal/ui/startup.go index 66538868..d66c2a35 100644 --- a/internal/ui/startup.go +++ b/internal/ui/startup.go @@ -5,6 +5,9 @@ package ui import ( + "os" + "path/filepath" + "fyne.io/fyne/v2" "github.com/frathe/picfetch/internal/filescan" @@ -16,6 +19,7 @@ import ( // startupState is the persisted input snapshot consumed by buildViewer and // geometry restoration. type startupState struct { + images imageServices savedSession []fyne.URI prefs preferences.State } @@ -23,16 +27,37 @@ type startupState struct { // loadStartupState reads persistence and fills only preference defaults that // have no distinct zero-value meaning. func loadStartupState(application fyne.App) startupState { + prefs := normalizePreferenceDefaults(preferences.Load(application)) + images := imageServices{} + if prefs.ExperimentalHEIC { + executable, err := os.Executable() + if err != nil { + images.startupError = err + } else { + var privateDir string + if root := application.Cache().RootURI(); root != nil && root.Scheme() == "file" { + privateDir = filepath.Join(root.Path(), "heic-helpers") + } + images = installedImageServices(prefs, executable, privateDir) + } + if images.startupError != nil { + fyne.LogError("Experimental HEIC package is unavailable", images.startupError) + } + } return startupState{ + images: images, savedSession: session.Load(application), - prefs: normalizePreferenceDefaults(preferences.Load(application)), + prefs: prefs, } } // buildStartupViewer is the shared load, construct, then restore entry point. // It leaves noPollerStop installed for startViewerRuntime to replace. func buildStartupViewer(application fyne.App) (*viewer, fyne.Window) { - startup := loadStartupState(application) + return buildConfiguredViewer(application, loadStartupState(application)) +} + +func buildConfiguredViewer(application fyne.App, startup startupState) (*viewer, fyne.Window) { view, window := buildViewer(application, startup) restoreStartupGeometry(view, window, startup) return view, window diff --git a/internal/ui/viewer.go b/internal/ui/viewer.go index 2e231f45..7ebeed4b 100644 --- a/internal/ui/viewer.go +++ b/internal/ui/viewer.go @@ -41,8 +41,9 @@ import ( // viewer bundles the UI elements and the navigation state so the drop // handler and the key handler can share them without package-level globals. type viewer struct { - app fyne.App - win fyne.Window + images imageServices + app fyne.App + win fyne.Window // stopping retires title/menu updates before shutdown cancels features. // Fyne may run OnStopped after the native event loop has drained. stopping bool diff --git a/internal/update/apply.go b/internal/update/apply.go index 00a5befe..edfda2c9 100644 --- a/internal/update/apply.go +++ b/internal/update/apply.go @@ -12,10 +12,12 @@ type ApplyOptions struct { // Apply replaces the running executable at dest with stage.BinaryPath. // dest is os.Executable() from UI glue. A var so tests stub it. var Apply = func(stage Stage, dest string, options ApplyOptions) error { - switch runtime.GOOS { - case "windows": - return applyWindows(stage, dest, options) - default: - return applyUnix(stage, dest, options) - } + return applyWithCompanions(stage, dest, func() error { + switch runtime.GOOS { + case "windows": + return applyWindows(stage, dest, options) + default: + return applyUnix(stage, dest, options) + } + }) } diff --git a/internal/update/apply_test.go b/internal/update/apply_test.go index 24cc1226..aa39a315 100644 --- a/internal/update/apply_test.go +++ b/internal/update/apply_test.go @@ -432,3 +432,40 @@ func TestUnixRelaunchCommand_WaitsForOldProcessAndPassesPathAsArgument(t *testin t.Error("post-exit relaunch errors are not connected to PicFetch stderr") } } + +func TestApplyUnixPreservesWholeVerifiedMacBundle(t *testing.T) { + directory := t.TempDir() + installedRoot := filepath.Join(directory, "installed", "PicFetch.app") + stagedRoot := filepath.Join(directory, "staged", "PicFetch.app") + relative := []string{"Contents/MacOS/picfetch", "Contents/Info.plist", "Contents/_CodeSignature/CodeResources", "Contents/Helpers/HEICWorker.app/Contents/MacOS/picfetch-heic-worker", "Contents/Resources/heic/manifest.json", "Contents/Resources/heic/notices/h265-LICENSE"} + for _, name := range relative { + for _, side := range []struct{ root, version string }{{installedRoot, "old"}, {stagedRoot, "new"}} { + path := filepath.Join(side.root, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(side.version+" "+name), 0755); err != nil { + t.Fatal(err) + } + } + } + files, err := companionDigests(stagedRoot) + if err != nil { + t.Fatal(err) + } + stage := Stage{BinaryPath: filepath.Join(stagedRoot, "Contents", "MacOS", "picfetch"), PlistPath: filepath.Join(stagedRoot, "Contents", "Info.plist"), verification: stageVerification{GOOS: "darwin", CompanionDigests: files}} + launched := false + err = applyUnixWithLauncher(stage, filepath.Join(installedRoot, "Contents", "MacOS", "picfetch"), ApplyOptions{Relaunch: true}, func(_ string) error { + launched = true + for _, name := range relative { + data, readErr := os.ReadFile(filepath.Join(installedRoot, filepath.FromSlash(name))) + if readErr != nil || string(data) != "new "+name { + t.Errorf("relaunch before complete bundle: %s = %q (%v)", name, data, readErr) + } + } + return nil + }) + if err != nil || !launched { + t.Fatalf("bundle installation/relaunch: %v, launched=%v", err, launched) + } +} diff --git a/internal/update/apply_unix.go b/internal/update/apply_unix.go index 2265117a..f8c1f646 100644 --- a/internal/update/apply_unix.go +++ b/internal/update/apply_unix.go @@ -19,6 +19,9 @@ func applyUnix(stage Stage, dest string, options ApplyOptions) error { // test. launch is called only after the executable and optional plist have // both been installed successfully. func applyUnixWithLauncher(stage Stage, dest string, options ApplyOptions, launch func(string) error) error { + if stage.verification.GOOS == "darwin" && len(stage.verification.CompanionDigests) != 0 { + return applyMacBundle(stage, dest, options, launch) + } dest, err := filepath.EvalSymlinks(dest) if err != nil { return err diff --git a/internal/update/companions.go b/internal/update/companions.go new file mode 100644 index 00000000..bf7ba2a0 --- /dev/null +++ b/internal/update/companions.go @@ -0,0 +1,138 @@ +package update + +import ( + "errors" + "fmt" + "io/fs" + "maps" + "os" + "path/filepath" + + heicclient "github.com/frathe/picfetch/internal/heicdecode/client" +) + +// Package roots are derived from the authenticated main executable, never +// supplied by a companion manifest. macOS installs the complete signed bundle; +// other platforms replace only the dedicated helper directory beside the app. +func companionRoots(binary, system string) (installation, companions string, err error) { + if !filepath.IsAbs(binary) { + return "", "", errors.New("update package binary is not absolute") + } + installation = filepath.Dir(binary) + if system == "darwin" { + if filepath.Base(installation) != "MacOS" || filepath.Base(filepath.Dir(installation)) != "Contents" { + return "", "", errors.New("update package is not a macOS app bundle") + } + installation = filepath.Dir(filepath.Dir(installation)) + if filepath.Ext(installation) != ".app" { + return "", "", errors.New("update package has no app bundle root") + } + return installation, installation, nil + } + if system != "linux" && system != "windows" { + return "", "", errors.New("unsupported update companion platform") + } + return installation, filepath.Join(installation, "heic"), nil +} + +func captureCompanions(binary, system, architecture string) (map[string]string, error) { + installation, root, err := companionRoots(binary, system) + if err != nil { + // Legacy stages can have no helper or .app layout; their existing binary + // and plist verification remains authoritative until a complete package. + if system == "darwin" { + return nil, nil + } + return nil, err + } + _, manifest, err := heicclient.PackagePaths(installation, system) + if err != nil { + return nil, err + } + if _, err = os.Lstat(manifest); errors.Is(err, os.ErrNotExist) { + helper, _, pathErr := heicclient.PackagePaths(installation, system) + if pathErr != nil { + return nil, pathErr + } + if _, helperErr := os.Lstat(helper); errors.Is(helperErr, os.ErrNotExist) { + return nil, nil + } + return nil, errors.New("update helper has no manifest") + } else if err != nil { + return nil, err + } + if _, _, err = heicclient.LoadPackage(installation, system, architecture); err != nil { + return nil, fmt.Errorf("update helper package: %w", err) + } + return companionDigests(root) +} + +func companionDigests(root string) (map[string]string, error) { + files := make(map[string]string) + var total int64 + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.Type()&os.ModeSymlink != 0 { + return fmt.Errorf("update companion is a symbolic link: %s", path) + } + if entry.IsDir() { + return nil + } + info, err := entry.Info() + if err != nil { + return err + } + if !info.Mode().IsRegular() || info.Size() < 0 { + return errors.New("update companion is not a regular file") + } + total += info.Size() + if len(files) >= 4096 || total > 256*1024*1024 { + return errors.New("update companion package exceeds finite limits") + } + name, err := filepath.Rel(root, path) + if err != nil || !filepath.IsLocal(name) { + return errors.New("invalid update companion path") + } + digest, err := fileSHA256(path) + if err != nil { + return err + } + files[filepath.ToSlash(name)] = digest + return nil + }) + if err != nil { + return nil, err + } + if len(files) == 0 { + return nil, errors.New("empty update companion package") + } + return files, nil +} + +func validateCompanions(stage Stage) error { + expected := stage.verification.CompanionDigests + if len(expected) == 0 { + actual, err := captureCompanions(stage.BinaryPath, stage.verification.GOOS, stage.verification.GOARCH) + if err != nil { + return err + } + if len(actual) != 0 { + return errors.New("update helper package has no verified companion digests") + } + return nil + } + _, root, err := companionRoots(stage.BinaryPath, stage.verification.GOOS) + if err != nil { + return err + } + actual, err := companionDigests(root) + if err != nil { + return err + } + if !maps.Equal(actual, expected) { + return errors.New("update companion package changed after archive verification") + } + return nil +} diff --git a/internal/update/companions_test.go b/internal/update/companions_test.go new file mode 100644 index 00000000..32ff4142 --- /dev/null +++ b/internal/update/companions_test.go @@ -0,0 +1,107 @@ +package update + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "testing" + + heicclient "github.com/frathe/picfetch/internal/heicdecode/client" +) + +func TestDownloadedCompanionsRemainVerifiedAfterPersistence(t *testing.T) { + helper := []byte("owned helper executable") + digest := sha256.Sum256(helper) + guest := sha256.Sum256([]byte("owned guest")) + manifest, err := json.Marshal(heicclient.PackageManifest{Version: 1, GOOS: "linux", GOARCH: "amd64", ExecutableSHA256: hex.EncodeToString(digest[:]), GuestSHA256: hex.EncodeToString(guest[:])}) + if err != nil { + t.Fatal(err) + } + archivePath := writeTarGz(t, t.TempDir(), "picfetch-linux-amd64.tar.gz", map[string][]byte{ + "picfetch-linux-amd64": []byte("owned main executable"), + "heic/picfetch-heic-worker": helper, + "heic/manifest.json": manifest, + "heic/notices/h265-LICENSE": []byte("owned fixture notice"), + }) + archive, err := os.ReadFile(archivePath) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(archive) + server := serveDownload(t, "picfetch-linux-amd64.tar.gz", archive, okAttest) + stageDir := t.TempDir() + release := Release{Version: "v0.2.6", AssetName: "picfetch-linux-amd64.tar.gz", AssetURL: server.URL + "/picfetch-linux-amd64.tar.gz", AssetDigest: hex.EncodeToString(sum[:])} + staged, err := downloadClient(t, server, &fakeVerifier{}, stageDir).Download(context.Background(), release) + if err != nil { + t.Fatal(err) + } + if err = ValidateStage(staged); err != nil { + t.Fatal(err) + } + loaded, err := LoadStage(stageDir) + if err != nil { + t.Fatal(err) + } + withoutProof := loaded + withoutProof.verification.CompanionDigests = nil + if err = ValidateStage(withoutProof); err == nil { + t.Fatal("helper package accepted after removing its companion provenance") + } + companion := filepath.Join(filepath.Dir(loaded.BinaryPath), "heic", "notices", "h265-LICENSE") + if err = os.WriteFile(companion, []byte("changed after archive verification"), 0600); err != nil { + t.Fatal(err) + } + if err = ValidateStage(loaded); err == nil { + t.Fatal("changed companion accepted after provenance round trip") + } +} + +func TestApplyInstallsCompanionsAndRollsBackOnBinaryFailure(t *testing.T) { + for _, fail := range []bool{false, true} { + t.Run(map[bool]string{false: "install", true: "binary failure"}[fail], func(t *testing.T) { + directory := t.TempDir() + installed := filepath.Join(directory, "installed", "picfetch") + staged := filepath.Join(directory, "staged", "picfetch") + for path, data := range map[string]string{installed: "old main", staged: "new main", filepath.Join(filepath.Dir(installed), "heic", "helper"): "old helper", filepath.Join(filepath.Dir(staged), "heic", "helper"): "new helper"} { + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(data), 0700); err != nil { + t.Fatal(err) + } + } + files, err := companionDigests(filepath.Join(filepath.Dir(staged), "heic")) + if err != nil { + t.Fatal(err) + } + stage := Stage{BinaryPath: staged, verification: stageVerification{GOOS: "linux", CompanionDigests: files}} + if fail { + if err = os.Remove(staged); err != nil { + t.Fatal(err) + } + } + err = Apply(stage, installed, ApplyOptions{}) + if (err != nil) != fail { + t.Fatalf("apply: %v", err) + } + main, readErr := os.ReadFile(installed) + if readErr != nil { + t.Fatal(readErr) + } + helper, readErr := os.ReadFile(filepath.Join(filepath.Dir(installed), "heic", "helper")) + if readErr != nil { + t.Fatal(readErr) + } + wanted := "new" + if fail { + wanted = "old" + } + if string(main) != wanted+" main" || string(helper) != wanted+" helper" { + t.Fatalf("installed main=%q helper=%q", main, helper) + } + }) + } +} diff --git a/internal/update/download.go b/internal/update/download.go index 767ffc80..7b9d1eb0 100644 --- a/internal/update/download.go +++ b/internal/update/download.go @@ -29,16 +29,17 @@ type DownloadProgress struct { } type stageFile struct { - Version string `json:"version"` - Notes string `json:"notes"` - BinaryPath string `json:"binaryPath"` - PlistPath string `json:"plistPath,omitempty"` - VerifiedAssetName string `json:"verifiedAssetName,omitempty"` - VerifiedArchiveDigest string `json:"verifiedArchiveDigest,omitempty"` - VerifiedBinaryDigest string `json:"verifiedBinaryDigest,omitempty"` - VerifiedPlistDigest string `json:"verifiedPlistDigest,omitempty"` - VerifiedGOOS string `json:"verifiedGoos,omitempty"` - VerifiedGOARCH string `json:"verifiedGoarch,omitempty"` + VerifiedCompanionDigests map[string]string `json:"verifiedCompanionDigests,omitempty"` + Version string `json:"version"` + Notes string `json:"notes"` + BinaryPath string `json:"binaryPath"` + PlistPath string `json:"plistPath,omitempty"` + VerifiedAssetName string `json:"verifiedAssetName,omitempty"` + VerifiedArchiveDigest string `json:"verifiedArchiveDigest,omitempty"` + VerifiedBinaryDigest string `json:"verifiedBinaryDigest,omitempty"` + VerifiedPlistDigest string `json:"verifiedPlistDigest,omitempty"` + VerifiedGOOS string `json:"verifiedGoos,omitempty"` + VerifiedGOARCH string `json:"verifiedGoarch,omitempty"` } // Download fetches rel's archive, SHA-256s it, compares to AssetDigest when @@ -96,7 +97,16 @@ func (c *Client) DownloadWithProgress(ctx context.Context, rel Release, progress if err := os.MkdirAll(c.cfg.StageDir, 0o700); err != nil { return Stage{}, err } - bin, plist, err := extract(ctx, tmp, c.cfg.StageDir) + payload, err := os.MkdirTemp(c.cfg.StageDir, "payload-") + if err != nil { + return Stage{}, err + } + defer func() { + if err != nil { + _ = os.RemoveAll(payload) + } + }() + bin, plist, err := extract(ctx, tmp, payload) if err != nil { return Stage{}, err } @@ -121,18 +131,23 @@ func (c *Client) DownloadWithProgress(ctx context.Context, rel Release, progress return Stage{}, err } } + companions, err := captureCompanions(bin, c.cfg.GOOS, c.cfg.GOARCH) + if err != nil { + return Stage{}, err + } st = Stage{ Version: rel.Version, Notes: rel.Notes, BinaryPath: bin, PlistPath: plist, verification: stageVerification{ - AssetName: rel.AssetName, - ArchiveDigest: hex.EncodeToString(sum[:]), - BinaryDigest: binaryDigest, - PlistDigest: plistDigest, - GOOS: c.cfg.GOOS, - GOARCH: c.cfg.GOARCH, + AssetName: rel.AssetName, + ArchiveDigest: hex.EncodeToString(sum[:]), + BinaryDigest: binaryDigest, + CompanionDigests: companions, + PlistDigest: plistDigest, + GOOS: c.cfg.GOOS, + GOARCH: c.cfg.GOARCH, }, } if err := SaveStage(c.cfg.StageDir, st); err != nil { @@ -292,16 +307,17 @@ func SaveStage(dir string, s Stage) error { return err } data, err := json.Marshal(stageFile{ - Version: s.Version, - Notes: s.Notes, - BinaryPath: bin, - PlistPath: plist, - VerifiedAssetName: s.verification.AssetName, - VerifiedArchiveDigest: s.verification.ArchiveDigest, - VerifiedBinaryDigest: s.verification.BinaryDigest, - VerifiedPlistDigest: s.verification.PlistDigest, - VerifiedGOOS: s.verification.GOOS, - VerifiedGOARCH: s.verification.GOARCH, + Version: s.Version, + Notes: s.Notes, + BinaryPath: bin, + PlistPath: plist, + VerifiedAssetName: s.verification.AssetName, + VerifiedArchiveDigest: s.verification.ArchiveDigest, + VerifiedBinaryDigest: s.verification.BinaryDigest, + VerifiedCompanionDigests: s.verification.CompanionDigests, + VerifiedPlistDigest: s.verification.PlistDigest, + VerifiedGOOS: s.verification.GOOS, + VerifiedGOARCH: s.verification.GOARCH, }) if err != nil { return err @@ -327,12 +343,13 @@ func LoadStage(dir string) (Stage, error) { BinaryPath: sf.BinaryPath, PlistPath: sf.PlistPath, verification: stageVerification{ - AssetName: sf.VerifiedAssetName, - ArchiveDigest: sf.VerifiedArchiveDigest, - BinaryDigest: sf.VerifiedBinaryDigest, - PlistDigest: sf.VerifiedPlistDigest, - GOOS: sf.VerifiedGOOS, - GOARCH: sf.VerifiedGOARCH, + AssetName: sf.VerifiedAssetName, + ArchiveDigest: sf.VerifiedArchiveDigest, + BinaryDigest: sf.VerifiedBinaryDigest, + CompanionDigests: sf.VerifiedCompanionDigests, + PlistDigest: sf.VerifiedPlistDigest, + GOOS: sf.VerifiedGOOS, + GOARCH: sf.VerifiedGOARCH, }, }, nil } @@ -353,6 +370,9 @@ func ValidateStage(s Stage) error { if err := verifyFileSHA256(s.BinaryPath, v.BinaryDigest); err != nil { return fmt.Errorf("update: staged binary verification: %w", err) } + if err := validateCompanions(s); err != nil { + return err + } if s.PlistPath == "" { if v.PlistDigest != "" { return errors.New("update: staged plist provenance has no file") diff --git a/internal/update/package_install.go b/internal/update/package_install.go new file mode 100644 index 00000000..81e4edd6 --- /dev/null +++ b/internal/update/package_install.go @@ -0,0 +1,172 @@ +package update + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "slices" +) + +type directorySwap struct { + destination string + backup string + hadPrevious bool +} + +// installDirectory prepares and verifies every file before renaming any +// installed directory. All temporary/backup directories share its filesystem. +func installDirectory(source, destination string, expected map[string]string, rename func(string, string) error) (*directorySwap, error) { + if len(expected) == 0 { + return nil, errors.New("update directory has no verified files") + } + parent := filepath.Dir(destination) + prepared, err := os.MkdirTemp(parent, ".picfetch-package-new-") + if err != nil { + return nil, err + } + defer func() { _ = os.RemoveAll(prepared) }() + names := make([]string, 0, len(expected)) + for name := range expected { + names = append(names, name) + } + slices.Sort(names) + for _, name := range names { + local := filepath.FromSlash(name) + if !filepath.IsLocal(local) || filepath.ToSlash(filepath.Clean(local)) != name || !validSHA256(expected[name]) { + return nil, errors.New("invalid verified companion path or digest") + } + src, target := filepath.Join(source, local), filepath.Join(prepared, local) + info, statErr := os.Lstat(src) + if statErr != nil { + return nil, statErr + } + if !info.Mode().IsRegular() { + return nil, errors.New("update companion source is not a regular file") + } + if err = copyFile(src, target); err != nil { + return nil, err + } + if err = os.Chmod(target, info.Mode().Perm()); err != nil { + return nil, err + } + if err = verifyFileSHA256(target, expected[name]); err != nil { + return nil, err + } + } + // A package root must remain traversable by its existing consumers, including + // an AppContainer with explicit read/execute provisioning after installation. + if err = os.Chmod(prepared, 0755); err != nil { + return nil, err + } + backup, err := os.MkdirTemp(parent, ".picfetch-package-old-") + if err != nil { + return nil, err + } + if err = os.Remove(backup); err != nil { + return nil, err + } + transaction := &directorySwap{destination: destination, backup: backup} + if info, statErr := os.Lstat(destination); statErr == nil { + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return nil, errors.New("installed companion root is not an ordinary directory") + } + if err = rename(destination, backup); err != nil { + return nil, err + } + transaction.hadPrevious = true + } else if !errors.Is(statErr, os.ErrNotExist) { + return nil, statErr + } + if err = rename(prepared, destination); err != nil { + if transaction.hadPrevious { + err = errors.Join(err, rename(backup, destination)) + } + return nil, err + } + return transaction, nil +} + +func (s *directorySwap) rollback() error { + if err := os.RemoveAll(s.destination); err != nil { + return err + } + if s.hadPrevious { + return os.Rename(s.backup, s.destination) + } + return nil +} + +func (s *directorySwap) commit() { + if s.hadPrevious { + _ = os.RemoveAll(s.backup) + } +} + +func applyWithCompanions(stage Stage, destination string, apply func() error) error { + if len(stage.verification.CompanionDigests) == 0 || stage.verification.GOOS == "darwin" { + return apply() + } + if err := validateCompanions(stage); err != nil { + return err + } + _, source, err := companionRoots(stage.BinaryPath, stage.verification.GOOS) + if err != nil { + return err + } + destination, err = filepath.EvalSymlinks(destination) + if err != nil { + return err + } + _, target, err := companionRoots(destination, stage.verification.GOOS) + if err != nil { + return err + } + transaction, err := installDirectory(source, target, stage.verification.CompanionDigests, os.Rename) + if err != nil { + return &ApplyError{Op: "companions", Path: target, Err: err} + } + if err = apply(); err != nil { + // Relaunch errors occur after the executable has committed. Its companion + // package must stay paired with that newly installed executable. + if failure, ok := errors.AsType[*ApplyError](err); ok && failure.Op == "relaunch" { + transaction.commit() + return err + } + if rollbackErr := transaction.rollback(); rollbackErr != nil { + return &ApplyError{Op: "restore", Path: target, Err: errors.Join(err, rollbackErr)} + } + return err + } + transaction.commit() + return nil +} + +func applyMacBundle(stage Stage, destination string, options ApplyOptions, launch func(string) error) error { + if err := validateCompanions(stage); err != nil { + return err + } + _, source, err := companionRoots(stage.BinaryPath, "darwin") + if err != nil { + return err + } + destination, err = filepath.EvalSymlinks(destination) + if err != nil { + return err + } + _, target, err := companionRoots(destination, "darwin") + if err != nil { + return err + } + transaction, err := installDirectory(source, target, stage.verification.CompanionDigests, os.Rename) + if err != nil { + return fmt.Errorf("install verified app bundle: %w", err) + } + transaction.commit() + if options.Relaunch { + if err = launch(destination); err != nil { + return &ApplyError{Op: "relaunch", Path: destination, Err: err} + } + } + return nil +} diff --git a/internal/update/package_native_darwin_test.go b/internal/update/package_native_darwin_test.go new file mode 100644 index 00000000..adbb3702 --- /dev/null +++ b/internal/update/package_native_darwin_test.go @@ -0,0 +1,172 @@ +//go:build heicnative && darwin + +package update + +import ( + "errors" + "maps" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// This owns complete signed fixture bundles. It never launches the fixture app +// or touches an installed application, preferences or a user's image files. +func TestNativeMacUpdatePreservesSignedBundle(t *testing.T) { + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) + } + parent := t.TempDir() + unrelated := filepath.Join(parent, "owned-user-data") + if err = os.WriteFile(unrelated, []byte("preserved"), 0600); err != nil { + t.Fatal(err) + } + previous := nativeSignedBundle(t, root, filepath.Join(parent, "installed", "PicFetch.app"), "1") + candidate := nativeSignedBundle(t, root, filepath.Join(parent, "staged", "PicFetch.app"), "2") + before, err := companionDigests(previous) + if err != nil { + t.Fatal(err) + } + after, err := companionDigests(candidate) + if err != nil { + t.Fatal(err) + } + staged := Stage{BinaryPath: filepath.Join(candidate, "Contents", "MacOS", "picfetch"), PlistPath: filepath.Join(candidate, "Contents", "Info.plist"), verification: stageVerification{GOOS: "darwin", CompanionDigests: after}} + failedInstall := false + _, err = installDirectory(candidate, previous, after, func(from, to string) error { + if strings.HasPrefix(filepath.Base(from), ".picfetch-package-new-") && to == previous { + failedInstall = true + return os.ErrPermission + } + return os.Rename(from, to) + }) + if !failedInstall || !errors.Is(err, os.ErrPermission) { + t.Fatalf("commit failure not observed: %v", err) + } + restored, err := companionDigests(previous) + if err != nil || !maps.Equal(before, restored) { + t.Fatalf("rollback changed signed bundle files: %v", err) + } + verifyNativeBundle(t, previous) + if err = Apply(staged, filepath.Join(previous, "Contents", "MacOS", "picfetch"), ApplyOptions{}); err != nil { + t.Fatal(err) + } + installed, err := companionDigests(previous) + if err != nil || !maps.Equal(after, installed) { + t.Fatalf("updated bundle differs from verified candidate: %v", err) + } + verifyNativeBundle(t, previous) + data, err := os.ReadFile(unrelated) + if err != nil || string(data) != "preserved" { + t.Fatalf("neighboring user data changed: %q (%v)", data, err) + } +} + +func nativeSignedBundle(t *testing.T, root, bundle, version string) string { + t.Helper() + main := filepath.Join(bundle, "Contents", "MacOS", "picfetch") + if err := os.MkdirAll(filepath.Dir(main), 0755); err != nil { + t.Fatal(err) + } + source, err := os.Executable() + if err != nil { + t.Fatal(err) + } + if err = copyFile(source, main); err != nil { + t.Fatal(err) + } + plist := `CFBundleIdentifierio.github.frathe.picfetch.owned-updateCFBundleExecutablepicfetchCFBundlePackageTypeAPPLCFBundleVersion` + version + `` + if err = os.WriteFile(filepath.Join(bundle, "Contents", "Info.plist"), []byte(plist), 0644); err != nil { + t.Fatal(err) + } + packageHelper := exec.Command("go", "run", "./scripts/heicpackage", "-os", "darwin", "-arch", runtime.GOARCH, "-out", bundle) + packageHelper.Dir = root + if output, buildErr := packageHelper.CombinedOutput(); buildErr != nil { + t.Fatalf("package owned helper: %v: %s", buildErr, output) + } + // Distinguish both helper and enclosing sealed resources between versions. + // Otherwise a binary/plist-only update could accidentally produce the same + // bytes as the candidate because its unchanged helper still matched. + helperBundle := filepath.Join(bundle, "Contents", "Helpers", "HEICWorker.app") + helperPlist := filepath.Join(helperBundle, "Contents", "Info.plist") + helperMetadata, err := os.ReadFile(helperPlist) + if err != nil { + t.Fatal(err) + } + helperMetadata = []byte(strings.Replace(string(helperMetadata), "1", ""+version+"", 1)) + if err = os.WriteFile(helperPlist, helperMetadata, 0644); err != nil { + t.Fatal(err) + } + helperSign := exec.Command("/usr/bin/codesign", "--force", "--sign", "-", "--options", "runtime", "--entitlements", filepath.Join(root, "packaging", "heic", "macos.entitlements.plist"), helperBundle) + if output, signErr := helperSign.CombinedOutput(); signErr != nil { + t.Fatalf("sign changed owned helper: %v: %s", signErr, output) + } + finalize := exec.Command("go", "run", "./scripts/heicpackage", "-mode", "finalize", "-os", "darwin", "-arch", runtime.GOARCH, "-out", bundle) + finalize.Dir = root + if output, finalizeErr := finalize.CombinedOutput(); finalizeErr != nil { + t.Fatalf("finalize changed owned helper: %v: %s", finalizeErr, output) + } + if err = os.WriteFile(filepath.Join(bundle, "Contents", "Resources", "owned-version"), []byte(version), 0644); err != nil { + t.Fatal(err) + } + sign := exec.Command("/usr/bin/codesign", "--force", "--sign", "-", bundle) + if output, signErr := sign.CombinedOutput(); signErr != nil { + t.Fatalf("sign owned bundle: %v: %s", signErr, output) + } + verifyNativeBundle(t, bundle) + return bundle +} + +func verifyNativeBundle(t *testing.T, bundle string) { + t.Helper() + command := exec.Command("/usr/bin/codesign", "--verify", "--deep", "--strict", "--verbose=2", bundle) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("complete bundle signature: %v: %s", err, output) + } +} + +func TestNativeMacLegacyUpdateRequiresCompleteReinstall(t *testing.T) { + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) + } + parent := t.TempDir() + previous := nativeSignedBundle(t, root, filepath.Join(parent, "installed", "PicFetch.app"), "1") + for _, path := range []string{filepath.Join(previous, "Contents", "Helpers"), filepath.Join(previous, "Contents", "Resources", "heic")} { + if err = os.RemoveAll(path); err != nil { + t.Fatal(err) + } + } + sign := exec.Command("/usr/bin/codesign", "--force", "--sign", "-", previous) + if output, signErr := sign.CombinedOutput(); signErr != nil { + t.Fatalf("sign owned legacy bundle: %v: %s", signErr, output) + } + verifyNativeBundle(t, previous) + stageDirectory := filepath.Join(parent, "legacy-stage") + candidate := nativeSignedBundle(t, root, filepath.Join(stageDirectory, "PicFetch.app"), "2") + // The released updater knows only BinaryPath/PlistPath and removes its + // stage after a successful application. Exercise that retained legacy path. + staged := Stage{BinaryPath: filepath.Join(candidate, "Contents", "MacOS", "picfetch"), PlistPath: filepath.Join(candidate, "Contents", "Info.plist")} + if err = applyUnix(staged, filepath.Join(previous, "Contents", "MacOS", "picfetch"), ApplyOptions{}); err != nil { + t.Fatal(err) + } + if err = RemoveStage(stageDirectory); err != nil { + t.Fatal(err) + } + if _, err = os.Stat(filepath.Join(previous, "Contents", "Resources", "heic", "manifest.json")); !os.IsNotExist(err) { + t.Fatalf("legacy updater unexpectedly installed helper manifest: %v", err) + } + if _, err = os.Stat(stageDirectory); !os.IsNotExist(err) { + t.Fatalf("legacy stage remains recoverable: %v", err) + } + command := exec.Command("/usr/bin/codesign", "--verify", "--deep", "--strict", previous) + if output, verifyErr := command.CombinedOutput(); verifyErr == nil { + t.Fatal("legacy binary/plist replacement unexpectedly preserved the new enclosing signature") + } else { + t.Logf("owned legacy update requires complete reinstall: %v: %s", verifyErr, output) + } +} diff --git a/internal/update/update.go b/internal/update/update.go index 498a7dfc..499bdbd5 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -62,12 +62,13 @@ type Stage struct { } type stageVerification struct { - AssetName string - ArchiveDigest string - BinaryDigest string - PlistDigest string - GOOS string - GOARCH string + CompanionDigests map[string]string + AssetName string + ArchiveDigest string + BinaryDigest string + PlistDigest string + GOOS string + GOARCH string } type Client struct { diff --git a/packaging/heic/macos.Info.plist b/packaging/heic/macos.Info.plist new file mode 100644 index 00000000..9e2cc855 --- /dev/null +++ b/packaging/heic/macos.Info.plist @@ -0,0 +1,16 @@ + + + + + CFBundleIdentifier + io.github.frathe.picfetch.heic-worker + CFBundleExecutable + picfetch-heic-worker + CFBundleName + PicFetch HEIC Worker + CFBundlePackageType + APPL + CFBundleVersion + 1 + + diff --git a/packaging/heic/macos.entitlements.plist b/packaging/heic/macos.entitlements.plist new file mode 100644 index 00000000..e76d714c --- /dev/null +++ b/packaging/heic/macos.entitlements.plist @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + + com.apple.security.cs.allow-unsigned-executable-memory + + + diff --git a/packaging/heic/qualify-windows-child.ps1 b/packaging/heic/qualify-windows-child.ps1 new file mode 100644 index 00000000..74905abe --- /dev/null +++ b/packaging/heic/qualify-windows-child.ps1 @@ -0,0 +1,81 @@ +param([Parameter(Mandatory = $true)][string]$Configuration, [switch]$RequireInstalledMSIX) +$ErrorActionPreference = 'Stop' +$config = Get-Content -LiteralPath $Configuration -Raw | ConvertFrom-Json +if ($RequireInstalledMSIX -and $config.Scenario -ne 'msix') { throw 'Installed-MSIX configuration is required for the complete Windows native gate.' } +Set-Location -LiteralPath $config.Repository +# Start-Process with credentials still inherits the runner's environment. +# Replace profile values with the loaded standard user's native environment +# before any known-folder query. This changes only the owned CI test process. +Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Security.Principal; +public static class HEICStandardUserEnvironment { + [DllImport("userenv.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + static extern bool CreateEnvironmentBlock(out IntPtr block, IntPtr token, [MarshalAs(UnmanagedType.Bool)] bool inherit); + [DllImport("userenv.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + static extern bool DestroyEnvironmentBlock(IntPtr block); + public static void Apply() { + using (var identity = WindowsIdentity.GetCurrent()) { + IntPtr block; + if (!CreateEnvironmentBlock(out block, identity.Token, false)) throw new Win32Exception(Marshal.GetLastWin32Error()); + try { + for (IntPtr cursor = block; Marshal.ReadInt16(cursor) != 0;) { + string entry = Marshal.PtrToStringUni(cursor); + cursor = IntPtr.Add(cursor, (entry.Length + 1) * 2); + int separator = entry.IndexOf('='); + if (separator > 0) Environment.SetEnvironmentVariable(entry.Substring(0, separator), entry.Substring(separator + 1)); + } + } finally { if (!DestroyEnvironmentBlock(block)) throw new Win32Exception(Marshal.GetLastWin32Error()); } + } + } +} +'@ +[HEICStandardUserEnvironment]::Apply() +$env:PATH = (Split-Path -Parent $config.Go) + ';' + $env:PATH +$profileDirectory = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData) +if (-not $profileDirectory) { throw 'The standard user has no accessible local application-data folder.' } +Write-Output "Standard-user local application data: $profileDirectory" +$env:GOCACHE = Join-Path $config.Work 'go-cache' +$env:GOPATH = Join-Path $config.Work 'go-path' +$env:CGO_ENABLED = '0' +$env:TEMP = Join-Path $config.Work 'user-temp' +$env:TMP = $env:TEMP +New-Item -ItemType Directory -Path $env:TEMP -Force | Out-Null +& whoami /all +if ($LASTEXITCODE -ne 0) { throw 'Cannot record native execution identity.' } +if ($config.Scenario -eq 'standalone') { + & $config.Go run ./scripts/nativeguards -suite heic-windows -capture (Join-Path $config.Evidence 'native-windows.json') + if ($LASTEXITCODE -ne 0) { throw 'Native HEIC guards failed under the standard account.' } + exit 0 +} +$package = $null +try { + Add-AppxPackage -Path $config.Package -DependencyPath $config.Dependency + $package = Get-AppxPackage -Name $config.PackageName + if (-not $package) { throw 'The test-MSIX was not installed for the standard user.' } + $package | Format-List Name, PackageFullName, PackageFamilyName, InstallLocation, Architecture + # Normal installed executable launch lets Windows resolve package identity. + # The probe requires actual identity and the exact standard-user token; + # running an unpackaged copy cannot satisfy the installed-MSIX guard. + $start = [System.Diagnostics.ProcessStartInfo]::new() + $start.FileName = Join-Path $package.InstallLocation 'heic-activation.test.exe' + $start.Arguments = '-test.run=^TestNativeInstalledHEICActivation$ -test.v -test.timeout=4m' + $start.WorkingDirectory = $config.Work + $start.UseShellExecute = $false + $process = [System.Diagnostics.Process]::Start($start) + if (-not $process) { throw 'Windows did not start the installed test application.' } + if (-not $process.WaitForExit(300000)) { $process.Kill(); throw 'Installed activation did not terminate within five minutes.' } + $resultPath = Join-Path $config.Evidence 'installed-msix.json' + if (-not (Test-Path -LiteralPath $resultPath)) { throw 'Installed activation did not produce its completion record.' } + $result = Get-Content -LiteralPath $resultPath -Raw | ConvertFrom-Json + Get-Content -LiteralPath (Join-Path $config.Evidence 'installed-msix.log') + if ($process.ExitCode -ne 0 -or -not $result.Passed -or $result.Test -ne 'TestNativeInstalledHEICActivation' -or $result.OS -ne 'windows' -or $result.Arch -ne $config.Arch -or $result.Commit -ne $config.Commit) { + throw 'Installed standard-user MSIX activation did not pass for this build.' + } +} finally { + if ($package) { Remove-AppxPackage -Package $package.PackageFullName } +} diff --git a/packaging/heic/qualify-windows.ps1 b/packaging/heic/qualify-windows.ps1 new file mode 100644 index 00000000..6bb27615 --- /dev/null +++ b/packaging/heic/qualify-windows.ps1 @@ -0,0 +1,137 @@ +# Provisions disposable CI-only account/package state. Application tests run +# under that standard account; none of this provisioning is in PicFetch. +param( + [ValidateSet('standalone', 'msix')][string]$Scenario = 'standalone', + [Parameter(Mandatory = $true)][string]$EvidenceDirectory, + [string]$Executable, + [string]$RuntimeArchive +) +$ErrorActionPreference = 'Stop' +if ($env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted') { + throw 'Disposable-account provisioning is restricted to GitHub-hosted CI. Run nativeguards directly as a standard user for local standalone qualification.' +} +$repository = (Get-Location).Path +$go = (Get-Command go).Source +$architecture = (& $go env GOARCH).Trim() +if ($architecture -notin @('amd64', 'arm64')) { throw 'A native amd64 or arm64 host is required.' } +$identity = [Guid]::NewGuid().ToString('N') +$userName = 'pfheic' + $identity.Substring(0, 10) +$workRoot = Join-Path $env:RUNNER_TEMP ('heic-owned-' + $identity) +$copy = Join-Path $workRoot 'repository' +$evidence = Join-Path $workRoot 'evidence' +$certificate = $null +$trusted = $null +$account = $null +New-Item -ItemType Directory -Path $workRoot, $evidence -Force | Out-Null +New-Item -ItemType Directory -Path $EvidenceDirectory -Force | Out-Null +try { + $secret = ConvertTo-SecureString ('Pf!' + [Guid]::NewGuid().ToString('N') + '9a') -AsPlainText -Force + $account = New-LocalUser -Name $userName -Password $secret -Description 'Disposable PicFetch HEIC qualification account' + Add-LocalGroupMember -SID 'S-1-5-32-545' -Member $account + $credential = [PSCredential]::new("$env:COMPUTERNAME\$userName", $secret) + & icacls $workRoot /grant "*$($account.SID.Value):(OI)(CI)M" | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'Could not prepare the owned qualification workspace.' } + & robocopy $repository $copy /E /XJ /XD .git .scratch bin fyne-cross /NFL /NDL /NJH /NJS | Out-Null + if ($LASTEXITCODE -ge 8) { throw 'Could not copy the source into the owned qualification workspace.' } + $config = @{ + Scenario = $Scenario; Repository = $copy; Go = $go; Work = $workRoot + Evidence = $evidence; Arch = $architecture; Commit = $env:GITHUB_SHA + } + if ($Scenario -eq 'msix') { + if (-not $Executable -or -not $RuntimeArchive) { throw 'MSIX qualification requires the built Store executable and pinned runtime archive.' } + $stage = Join-Path $workRoot 'stage' + & $go run -tags no_emoji,nodynamic ./scripts/msixstage -arch $architecture -exe $Executable -runtime-archive $RuntimeArchive -out $stage + if ($LASTEXITCODE -ne 0) { throw 'MSIX staging failed.' } + & $go run ./scripts/heicpackage -os windows -arch $architecture -out $stage + if ($LASTEXITCODE -ne 0) { throw 'HEIC helper staging failed.' } + & $go test -tags no_emoji,nodynamic,heicnative,microsoftstore -c -o (Join-Path $stage 'heic-activation.test.exe') ./internal/ui + if ($LASTEXITCODE -ne 0) { throw 'Application activation probe build failed.' } + Copy-Item -LiteralPath (Join-Path $repository 'scripts/heicbuild/testdata/tenbit.heic') -Destination (Join-Path $stage 'heic-activation-fixture.heic') + @{ Evidence = (Join-Path $evidence 'installed-msix'); Commit = $env:GITHUB_SHA; UserSID = $account.SID.Value } | + ConvertTo-Json | Set-Content -LiteralPath (Join-Path $stage 'heic-activation.json') -Encoding utf8NoBOM + $manifestPath = Join-Path $stage 'AppxManifest.xml' + [xml]$manifest = Get-Content -LiteralPath $manifestPath -Raw + $publisher = 'CN=PicFetch HEIC Qualification ' + $identity + $packageName = 'PicFetch.HEIC.' + $identity + $manifest.Package.Identity.SetAttribute('Name', $packageName) + $manifest.Package.Identity.SetAttribute('Publisher', $publisher) + $probe = $manifest.Package.Applications.Application.CloneNode($true) + $probe.SetAttribute('Id', 'HEICQualification') + $probe.SetAttribute('Executable', 'heic-activation.test.exe') + $probe.RemoveChild($probe.Extensions) | Out-Null + $manifest.Package.Applications.AppendChild($probe) | Out-Null + $manifest.Save($manifestPath) + $sdk = 'C:\Program Files (x86)\Windows Kits\10\bin' + $makeappx = Get-ChildItem -LiteralPath $sdk -Filter MakeAppx.exe -Recurse | Where-Object FullName -Match '\\x64\\MakeAppx\.exe$' | Sort-Object FullName -Descending | Select-Object -First 1 + $signtool = Get-ChildItem -LiteralPath $sdk -Filter SignTool.exe -Recurse | Where-Object FullName -Match '\\x64\\SignTool\.exe$' | Sort-Object FullName -Descending | Select-Object -First 1 + if (-not $makeappx -or -not $signtool) { throw 'The Windows SDK packaging/signing tools are required.' } + $package = Join-Path $workRoot 'qualification.msix' + & $makeappx.FullName pack /o /h SHA256 /d $stage /p $package + if ($LASTEXITCODE -ne 0) { throw 'Test-MSIX packing failed.' } + $certificate = New-SelfSignedCertificate -Type Custom -Subject $publisher -KeyUsage DigitalSignature -CertStoreLocation 'Cert:\CurrentUser\My' -TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.3') + $trusted = [System.Security.Cryptography.X509Certificates.X509Store]::new('TrustedPeople', 'LocalMachine') + $trusted.Open('ReadWrite') + $trusted.Add($certificate) + & $signtool.FullName sign /fd SHA256 /sha1 $certificate.Thumbprint $package + if ($LASTEXITCODE -ne 0) { throw 'Disposable package signing failed.' } + & $signtool.FullName verify /pa /all /v $package + if ($LASTEXITCODE -ne 0) { throw 'Disposable package verification failed.' } + $frameworkArch = if ($architecture -eq 'amd64') { 'x64' } else { 'arm64' } + $frameworkRoot = 'C:\Program Files (x86)\Microsoft SDKs\Windows Kits\10\ExtensionSDKs\Microsoft.VCLibs.Desktop\14.0' + $framework = Get-ChildItem -LiteralPath $frameworkRoot -Filter "*$frameworkArch*.appx" -Recurse | Where-Object FullName -Match '\\Retail\\' | Sort-Object FullName -Descending | Select-Object -First 1 + if (-not $framework) { throw 'The architecture-matched Desktop C++ framework is required.' } + $dependency = Join-Path $workRoot $framework.Name + Copy-Item -LiteralPath $framework.FullName -Destination $dependency + $config.Package = $package; $config.PackageName = $packageName; $config.Dependency = $dependency + @{ + Commit = $env:GITHUB_SHA; Arch = $architecture; StandardUserSID = $account.SID.Value + Package = $packageName; SHA256 = (Get-FileHash -LiteralPath $package -Algorithm SHA256).Hash + TestCertificate = $certificate.Thumbprint + Helper = (Get-Content -LiteralPath (Join-Path $stage 'heic/manifest.json') -Raw | ConvertFrom-Json) + } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $evidence 'package-setup.json') -Encoding utf8NoBOM + } + $configPath = Join-Path $workRoot 'configuration.json' + $config | ConvertTo-Json | Set-Content -LiteralPath $configPath -Encoding utf8NoBOM + $child = Join-Path $copy 'packaging/heic/qualify-windows-child.ps1' + $powershell = (Get-Command pwsh).Source + $activationStarted = Get-Date + try { + $process = Start-Process -FilePath $powershell -Credential $credential -LoadUserProfile -WorkingDirectory $copy -ArgumentList @('-NoProfile', '-File', "`"$child`"", '-Configuration', "`"$configPath`"") -PassThru -Wait -RedirectStandardOutput (Join-Path $evidence 'standard-user.stdout.log') -RedirectStandardError (Join-Path $evidence 'standard-user.stderr.log') + } finally { + if ($Scenario -eq 'msix') { + $events = foreach ($channel in @('Microsoft-Windows-AppModel-Runtime/Admin', 'Microsoft-Windows-AppXDeploymentServer/Operational')) { + try { + $packageEvents = @(Get-WinEvent -FilterHashtable @{ LogName = $channel; StartTime = $activationStarted } -MaxEvents 100 | + Where-Object { $_.Message -like "*$packageName*" } | Select-Object TimeCreated, Id, LevelDisplayName, Message) + @{ Channel = $channel; Events = $packageEvents } + } catch { @{ Channel = $channel; QueryError = $_.Exception.Message } } + } + ConvertTo-Json -InputObject @($events) -Depth 5 | Set-Content -LiteralPath (Join-Path $evidence 'activation-events.json') -Encoding utf8NoBOM + } + } + Get-Content -LiteralPath (Join-Path $evidence 'standard-user.stdout.log') + Get-Content -LiteralPath (Join-Path $evidence 'standard-user.stderr.log') + if ($process.ExitCode -ne 0) { throw "Standard-user qualification failed with exit $($process.ExitCode)." } +} finally { + try { + Copy-Item -Path (Join-Path $evidence '*') -Destination $EvidenceDirectory -Recurse -Force + } finally { + try { + if ($trusted) { + try { if ($certificate) { $trusted.Remove($certificate) } } finally { $trusted.Close() } + } + if ($certificate) { Remove-Item -LiteralPath ("Cert:\CurrentUser\My\" + $certificate.Thumbprint) } + } finally { + try { + if ($account) { + try { + Get-CimInstance -ClassName Win32_UserProfile -Filter "SID='$($account.SID.Value)'" | Remove-CimInstance + } finally { Remove-LocalUser -SID $account.SID } + } + } finally { Remove-Item -LiteralPath $workRoot -Recurse -Force } + } + } +} +# Robocopy's successful "files copied" status is nonzero. Do not let that +# stale native-command status fail GitHub's wrapper after all checks/cleanup. +exit 0 diff --git a/plans/2026-09-15-isolated-heic-restoration.md b/plans/2026-09-15-isolated-heic-restoration.md new file mode 100644 index 00000000..4a9d8de3 --- /dev/null +++ b/plans/2026-09-15-isolated-heic-restoration.md @@ -0,0 +1,1174 @@ +# Isolated HEIC/HEIF restoration + +**Current status:** production HEIC remains disabled. This chronological plan's +original 30-second/all-platform hard-memory requirements were superseded by +the recorded 60-second ceiling and accepted macOS native-memory limitation. +Use [qualification](../docs/heic/qualification.md) and +[history reconciliation](../docs/heic/history-reconciliation.md) for the current +implementation and the disposition of the original branch's changes. + +**Next specified work (2026-09-16):** +[Experimental HEIC opt-in across all platforms](../.scratch/experimental-heic-opt-in/spec.md) +is the ready-for-agent specification for a default-off, restart-only Settings +option, shared immutable admission, Windows private helper staging and installed +MSIX activation. It supersedes this plan's original format-exposure scope for +that preview: package-level format queries and OS associations stay unchanged. +Native packaged activation on both architectures of all three operating systems, +standard-user Windows and installed test-MSIX remains required. Permission-query +failure is a blocker; the accepted macOS memory limitation remains unchanged. +This is specification only. Production activation, implementation evidence and +release clearance remain pending. + +Date: 2026-09-15. Route: **Deep SDD/TDD**. The deliverable is decode-only +HEIC/HEIF support through one bounded, disposable WASI decoder helper, admitted +only on platforms where both process-family memory enforcement and capability +isolation have been exercised at runtime. This plan is also the durable evidence +record. It does not authorize merge or release. + +## Intent and honest limit + +Restore HEIC/HEIF without loading a codec, container parser, or native HEIC +library into the desktop process. Untrusted container, sequence, tile and pixel +work belongs in a pinned WASI guest inside a minimal helper. The desktop parent +does bounded brand recognition and validates a versioned response before it +allocates or publishes pixels. + +Compilation is not platform qualification. Until a platform's mandatory +isolation and process-family memory controls pass in the packaged application, +admission on that platform fails closed and PicFetch does not advertise HEIC +there. The current Codex Cloud runner cannot provide Docker, Windows, macOS, +cgroup delegation, signing, MSIX, AppContainer, Job Object, or XPC runtime +evidence. HEVC may require patent licences in some jurisdictions; this work does +not provide patent clearance and does not change PicFetch's MIT licence. + +## Decisions (do not relitigate) + +| Topic | Decision | +| --- | --- | +| Decoder | `github.com/gen2brain/h265/heic`; never `github.com/gen2brain/heic`, `gav1d`, the removed Rust payload, or native fallback. | +| Boundary | Dedicated `cmd/picfetch-heic-worker`; no Fyne self-exec initialization. Codec/parser/sequences/tiles/pixels remain in its WASI guest. | +| Protocol | Versioned, length-prefixed binary IPC over explicitly inherited pipes. One request and response per disposable helper; reject unknown versions, statuses, fields and trailing bytes. No paths. | +| Capabilities | Guest receives byte streams only: no preopened filesystem, environment, clock beyond runtime need, secrets, or network. Helper receives only IPC/control handles. | +| Admission | One live lane per app instance. Acquire before the expensive source read. FIFO aging with bounded foreground preference; cancellation removes a waiter. No retry loop. | +| Proposed ceilings | 30 s total; 1 GiB WASM linear memory; 2 GiB OS-enforced process-family committed memory; input `min(user positive limit, 64 MiB)`; 64M pixels; checked RGBA output derivation; 64 KiB metadata; 4096-byte diagnostics; one guest thread/process and one live job. Lower after measurements, never make unbounded. | +| Output | Still image only. Reject sequences, unsupported bit depth/pixel format, zero/oversized dimensions, invalid stride, length mismatch, excessive metadata/diagnostics and extra data. Parent converts only a completely validated result. | +| Integration | `internal/imaging` remains canonical for probe, decode, record, capture date, metadata, thumbnails and previews. Similarity keeps its existing capability behavior and never gains a fallback parser. | +| Exposure | Chooser, MIME, manuals, translations and packaging change only for runtime-qualified fail-closed availability. | + +## Dependency, source and licence inventory + +The selected source must be pinned by immutable commit and Go module checksum, +not merely a tag. Qualification is incomplete until every row has an exact +source digest, artifact digest, licence and shipped notice location. + +| Component | Exact source / artifact | Licence and obligations | Status | +| --- | --- | --- | --- | +| `github.com/gen2brain/h265/heic` | v0.2.3 at `b2d46ba787d8f0a2025bd106443ab1b1c7cd010f`; module/archive/source hashes and static v0.2.2 comparison in `docs/heic/qualification.md`. | Upstream licence and complete generated/translated-source provenance must be inspected; ship its notice. | Source recovered locally; MIT notice retained. Exact translated-source lineage and full decoder qualification remain incomplete. Sequence fallback is refused inside the guest. | +| WASI guest | Reproducible build recipe will name compiler version, target, flags and source tree digest; checked-in artifact gets SHA-256. | Same closure as selected decoder plus compiler/runtime notices where distributed. | Development artifact built and reproduced; production qualification pending. | +| wazero | Existing wazero v1.12.0; now directly used by the development fixture generator and ABI tests. No version upgrade. | Apache-2.0 notice and NOTICE obligations. | Apache-2.0 license and NOTICE preserved in `docs/heic/notices`; no production HEIC runtime. | +| PicFetch host/helper/protocol | This repository and guest manifest input hashes. | MIT. | Protocol/guest implemented; production client/helper pending OS controls. | +| OS restriction helpers | Standard-library/syscall use plus existing pinned `golang.org/x/sys`; no new native runtime. | Existing BSD-3-Clause notice. | Planned per platform. | + +The old removed implementation used `github.com/gen2brain/heic` with a Rust +`heic` 0.1.6 payload under AGPL-3.0-only or commercial terms. It is excluded +from this design and must not reappear in either host or guest closure. + +## File map + +| Path | Responsibility | +| --- | --- | +| `internal/heicdecode/` | Context-bearing `Client` (`Decode`, `DecodeConfig`, `DecodeExif`), validated `Limits`, admission, typed failures, protocol validation, process lifecycle. | +| `internal/heicdecode/platform_*.go` | Platform availability and mandatory restriction setup; failure is an unavailable typed error, never unrestricted execution. | +| `cmd/picfetch-heic-worker/` | Minimal private helper dispatch and guest invocation; no UI imports. | +| `internal/imaging/{loader,exif,thumbnail,preview}.go` | Canonical HEIC routing after runtime qualification; ordinary 200M-pixel and encoded-input semantics stay unchanged. | +| `scripts/heicprovenance/`, `scripts/heicimports/` | Reproducible source/artifact and forbidden-import checks backing Make targets. | +| `scripts/nativeguards/` | Actual Linux, Windows, macOS/store isolation acceptance. | +| `docs/heic/robustness-testing.md` | Permitted defensive scope, budgets, observables and explicit omissions. | +| `THIRD-PARTY-NOTICES.md`, packaging/manual/translation files | Exact notices and exposure, only after qualification. | +| `ARCHITECTURE.md`, `todos.md`, this plan | Package map, remaining work and evidence. | + +Every new `_test.go` receives an exact `qodana.yaml` DuplicatedCode exclusion. +Any new top-level `internal/ui` test receives a sorted shard assignment and +updated count. + +## Acceptance criteria and commands + +1. **Provenance and closure.** Exact immutable source and guest artifact are + reproducible, checksummed, licensed and noticed; forbidden decoders/native + backends are absent. + `make heic-check-provenance && make heic-check-imports` +2. **Bounded protocol/client.** Owned protocol property tests and fake helpers + cover malformed/trailing/unknown replies, output arithmetic, metadata and + diagnostic caps, cancellation, timeout, crash, startup failure, queue + priority/no-starvation, cleanup and next-request recovery. + `go test -race -tags=no_emoji -count=1 ./internal/heicdecode/...` +3. **Canonical integration.** Valid licensed fixtures cover probe/decode, + renamed input, metadata/orientation/alpha/bit depth, grid and existing routes; + ordinary formats, AVIF, 200M pixels, cache generations and encoded limits + remain intact. + `go test -race -tags=no_emoji -count=1 ./internal/imaging ./internal/favthumbs ./internal/filescan` +4. **Similarity/UI lifecycle.** Existing capability reporting is preserved and + root/UI work settles through existing queues and shutdown barriers. Focused + named tests are recorded in the evidence ledger before completion. +5. **Packaging and locale.** Associations reflect runtime-qualified availability + only; no missing locale key or Unicode arrow. + `go test ./scripts/plistdoctypes ./scripts/msixstage` plus the repository's + named locale/manual guards. +6. **Platform isolation.** Native guards exercise Linux cgroup v2 memory.max + (including swap/overshoot/permissions) and all-thread seccomp; Windows + restricted token/AppContainer, explicit handles, no network/user files and + Job Object memory/CPU/process/kill-on-close in direct executable and MSIX; + macOS signed minimal sandbox/XPC helper, denied capabilities and supported + hard-memory control on Intel and Apple Silicon. Missing mandatory enforcement + produces a deterministic unavailable error. +7. **Defensive security.** `make security-govulncheck` includes host and guest; + static/import/provenance checks pass. Testing remains within + `docs/heic/robustness-testing.md`; no exploit corpus or vulnerability discovery. +8. **Repository gates.** `make fmt`; `make check-qodana-test-exclusions`; + `make check-test-shards-direct`; `make verify-build`; and `make verify` on + native Linux/amd64 Docker/CI. Changed Go files receive GoLand inspections. + +## Task graph and budgets + +`T0 -> T1 -> T2 -> T3 -> T4 -> T5`. Platform implementations may proceed +independently only after T1 contracts are fixed. The lead owns architecture, +review and every fix; no review agent is used. + +### T0 - feasibility and provenance +Owner: T0 inline. Files: this plan, source/licence records, provenance scripts. +Contract: exact reviewed source/artifact pins or an explicit blocker; no decoder +code enters the graph first. Test: provenance/import guards fail for missing or +forbidden closure. Verify: AC1. Budget: 0 spawns, 1 review, no full suite. + +### T1 - protocol, client, helper and runtime +Owner: T0 inline. Files: `internal/heicdecode`, helper, tests. Depends: T0. +Contract: context-bearing API and typed deterministic failures under validated +limits. Test: AC2 with owned fake helper/guest. Verify: AC2. Budget: 0 spawns, +2 reviews, no full suite. + +### T2 - restrictions and admission +Owner: T0 inline. Files: platform pairs and nativeguards. Depends: T1. Contract: +one fair priority lane; mandatory controls fail closed and kill/join descendants. +Test: benign capability/memory probes. Verify: AC6. Budget: 0 spawns, one review +per executable platform, no full suite. + +### T3 - imaging and process-family integration +Owner: T0 inline. Files: canonical imaging callers and existing tests. Depends: +T1-T2. Contract: one path for full images and all derived consumers; no rich +parent parsing or fallback. Test/verify: AC3-AC4. Budget: 0 spawns, 2 reviews. + +### T4 - native/package qualification and exposure +Owner: T0 inline. Files: native guards, packaging, docs/manual/translations. +Depends: T3 and actual runtime evidence. Contract: expose only qualified runtime +targets. Test/verify: AC5-AC7. Budget: 0 spawns, platform evidence required. + +### T5 - final review, land and publication +Owner: T0. Depends: all tasks. Update architecture/todos/ledger; inspect changed +Go files; run AC8 once; commit and publish draft PR without merge/release. +Budget: 0 spawns, one final review and one full suite. + +Delegation gate: all tasks stay with the lead because architecture, platform +security, dependency judgement and cross-package contracts fail G2/G3/G4; review +and fixes are never delegated. Rule S applies only to deterministic catalogue or +manifest generation. Rule W is satisfied. + +## Safe validation scope + +Use ordinary licensed HEIC photographs and PicFetch-owned bounded fake +helpers/guests. Benign probes may request denied file/network access, bounded +over-allocation, hang, crash and malformed IPC. Do not create malicious images, +run historical exploit corpora, conduct third-party vulnerability discovery, or +route rejected work through another model. Protocol property tests generate +only bounded PicFetch-owned byte messages. No fuzz coverage is claimed unless an +actual bounded fuzz command and duration are entered below. + +## Evidence ledger + +| Date | Task/check | Result and evidence | +| --- | --- | --- | +| 2026-09-15 | Repository/base | User supplied trusted connector evidence: public `frathe/picfetch`, default `main`, pull/push allowed, `main` = `0335a44384435a7680773c323b43016e839c07de`, and no HEIC branch collision. Local HEAD matched and branch `codex/heic-isolated-decoder` was created. | +| 2026-09-15 | Historical context | Read `finished_refactorings/2026-09-14-remove-heic-decoder.md`; recovered the removed research records from commit `aeb7176` for context. Historical prototype commit `fc127b44e1c99447b8d150256563c6d4f99b8ad3` is absent locally, as permitted by the task. | +| 2026-09-15 | Upstream feasibility | `go list -m -json github.com/gen2brain/h265@v0.2.3` failed at `proxy.golang.org` with HTTP 403. No h265 source exists in the module cache. Exact upstream diff, immutable revision, source checksum, licence closure and reproducible guest artifact therefore remain unverified; no decoder dependency or artifact may be added yet. | +| 2026-09-15 | T1 bounded response foundation | Red test first failed on absent `Limits`/protocol identifiers. Added finite-limit validation and a strict response reader that checks version/status, dimensions, multiplication, stride, bit depth, pixel format, every length and EOF before publishing RGBA pixels. `go test -race -tags=no_emoji -count=1 ./internal/heicdecode/...` passed in 1.010s. The client, helper and guest remain pending T0. | +| 2026-09-15 | Repository guards | `make check-qodana-test-exclusions`, `make check-test-shards-direct`, `make fmt`, and `git diff --check` passed. No top-level UI test was added. | +| 2026-09-15 | Existing imaging regressions | The brief's literal `go test -race -tags=no_emoji ...` failed at setup because the repository deliberately requires `nodynamic` for imaging (`internal/avifpolicy` otherwise has no selected file). The convention-compliant `go test -race -tags=no_emoji,nodynamic -count=1 ./internal/imaging ./internal/favthumbs ./internal/filescan` passed: 42.165s, 1.114s, 1.067s. | +| 2026-09-15 | Packaging/build | `go test -tags=no_emoji,nodynamic ./scripts/plistdoctypes ./scripts/msixstage` passed (0.005s, 0.297s). `make verify-build` passed its TUF, vector, asset, updater-notice and repository-wide vet checks. | + +## Local continuation (2026-09-15) + +Recovered the cloud's eight-file, 533-line foundation through its supported +publication control. Draft PR #28 exposes the same foundation as `a3c4d92` +(the cloud-local commit was `fb2c4a9`); the published branch is +`codex/implement-authorized-heic-restoration-in-codex`. Continue this branch. +Remote `main` still equals `0335a44`. The local host is macOS 27.0 build 26A428, +arm64, Go 1.27.1. Docker is installed but has no running daemon. + +The source-access blocker is resolved: the standard Go module registry returned +v0.2.3 at `b2d46ba787d8f0a2025bd106443ab1b1c7cd010f`, checksum +`h1:+fEP2Xf1CoZ21SxA2YpqnPZb6Y/hAEkcgjU1gMsOhrk=`. v0.2.2 is available for +static comparison. No historical reproducer or upstream test corpus is run. + +Local work sequence, before adding a production runtime: + +1. Record source/license/API differences and actual OS memory semantics in + `docs/heic/qualification.md`. A small disposable native macOS probe tests + whether the proposed address-space budget can even be installed; it never + allocates up to that budget. Public API/source evidence does not count as + runtime qualification. Lead owns conclusions. +2. Complete the independent wire contract: straight-alpha NRGBA8/NRGBA64, + explicit operations and failure statuses, bounded structured metadata, + request framing, exact output/EOF validation. Share the existing codec-free + `internal/heicdecode` package with the separate guest module. Preserve 16-bit output + without increasing the 256,000,000-byte output ceiling (32M pixels at 16 bits). + Verify with `go test -race -tags no_emoji,nodynamic ./internal/heicdecode/...`. +3. If source inspection permits a development guest, add a separate WASI-only + module at `scripts/heicguest`, reproducible build/provenance/import guards, + and tests using only ordinary licensed fixtures. No shipped/production helper + runs without qualified OS controls. The guest accepts byte streams and emits + the shared wire contract. Source digests and notices accompany the artifact. +4. Integrate production decoding only after T2 can be enforced and measured. + A missing whole-worker memory mechanism blocks that dependent work; no soft + substitute, per-process semaphore, or compilation-only platform claim. + +One read-only OS-documentation scout was used alongside cloud recovery. G1: +bounded primary-source question; G2: cited public APIs/kernel implementation; +G3: zero file writes; G4/G5: independent OS source sweep before lead review. +Rule S cannot resolve accounting semantics from a textual substitution; Rule W +holds. No implementation/review/fix delegation. This revises T0's spawn budget +from zero to one. All reviews/fixes remain with the lead. + +The literal imaging commands above require `-tags no_emoji,nodynamic` under the +current repository policy. Native platform execution and GoLand availability +will be recorded separately from build-only checks. + +## Cloud cost ledger (historical) + +| Task | Spawns budget/actual | Review rounds | Full suite | Notes | +| --- | --- | --- | --- | --- | +| T0 | 0 / 0 | 0 | no | Source retrieval blocked; independent plan/recon completed. | +| T1 | 0 / 0 | 1 | no | Limits and response validation complete; client/helper/guest pending T0 immutable source qualification. | +| T2 | 0 / 0 | 0 | no | Pending fixed runtime contract. | +| T3 | 0 / 0 | 0 | no | Pending qualified platform admission. | +| T4 | 0 / 0 | 0 | no | Native platform evidence unavailable in Cloud. | +| T5 | 0 / 0 | 0 | no | Pending. | + +## Local evidence ledger + +| Check | Actual result | +| --- | --- | +| Source recovery/publication | Draft PR #28 exposes the cloud foundation at `a3c4d92`; continued locally on its published branch. Upstream module and v0.2.2 comparison retrieved through the standard registry, with exact source/archive/module pins recorded in `docs/heic/qualification.md`. | +| Test-led protocol corrections | New tests first rejected premultiplied RGBA interpretation and absent 16-bit layout, then passed after NRGBA8/NRGBA64 support. Request/operation/metadata tests first failed for missing contracts. Additional limits tests rejected previously accepted fractional WASM pages and guest memory above the outer budget. | +| Bounded native feasibility probe | macOS 27.0 arm64: `setrlimit(RLIMIT_AS, 2 GiB)` returned EINVAL below existing virtual mappings. Tiny disposable C process, no large allocation. This does not rule out other macOS strategies; separate research is ongoing. | +| Guest/source guards | `make heic-build heic-check-provenance heic-check-imports` passed. Independent byte-identical rebuild; known module/archive/source/notice checks and native-graph checks. Actual artifact-byte mutation made the provenance command fail; removing the fixture generator's WASI-only build guard made the import command fail. Both exact files restored. Unit guards also refuse altered source content, missing artifact and native codec import. | +| Positive guest ABI | Four cases / three distinct licensed images; upstream basic/main10 are byte-identical. RGB, alpha and PicFetch-owned explicit ten-bit gradient pass Decode, DecodeConfig and DecodeExif. NRGBA64 and alpha preserved. No camera compatibility, colorimetric/orientation precedence or native sandbox conclusion follows from these checks. | +| Focused race regression | `go test -race -tags no_emoji,nodynamic -count=1 ./internal/heicdecode ./scripts/heicbuild ./internal/imaging ./internal/favthumbs ./internal/filescan ./scripts/plistdoctypes ./scripts/msixstage` passed. Imaging 28.130s, favorites 1.682s, scanner 1.505s, plist 1.283s, MSIX staging 5.460s. After final protocol/guard refinements, boundary and guest repeated and passed in 1.243s / 58.194s. | +| Advisory scan | `make security-govulncheck` passed for the native graph and separate WASI module. No reachable or imported-package vulnerability found. Native verbose scan identified existing module-only GO-2026-5932 in unimported `golang.org/x/crypto/openpgp`; this change neither imports it nor changes x/crypto. Guest scan found no vulnerabilities. This is known-advisory coverage, not decoder security certification. | +| Local build | `make verify-build` completed format/TUF/assets/notices/provenance/import/vet/build. The appended direct shard command selected macOS tests and failed on the pre-existing `TestMergeWindowMenus_FoldsEveryDuplicate`; the Make target explicitly requires a prepared Linux/amd64 runner. Do not change the Linux manifest or bypass the guard for a macOS run. Canonical shard validation remains a CI gate. | +| Full suite | `make verify` could not start: Docker daemon socket absent. Native Linux/amd64 CI is required. No seccomp, shard or isolation guard was relaxed. | +| GoLand | Connected IDE reports only `/Users/ronin/Projects/picfetch`; this isolated worktree is not an open GoLand project. Inspections of changed files remain unverified. Qodana is a separate CI check, not a replacement claim. | +| Production/platform status | No helper/client/broker/OS sandbox or memory control installed. Linux, Windows/MSIX and macOS production HEIC admission all remain disabled. No main/imaging/UI integration, format declarations or packaging exposure changed. | + +### Local cost/status ledger + +One bounded read-only scout, all implementation/review/fixes by the lead. +T0 source/build provenance is implemented; translated-source lineage and OS +qualification are incomplete. T1 protocol and development guest are implemented; +production client/helper lifecycle is pending the resource decision. T2-T4 +remain blocked on qualified enforcement and then shared admission, compatibility +and packaging. T5 publishes a reviewable candidate only, without merge/release. +No native capability/crash/timeout/memory-exhaustion campaign or upstream fuzz +corpus was executed. CI outcomes/publication are recorded below when observed. + +## Accepted macOS memory tradeoff (supersedes the original T0/T2 gate) + +After a separate explicit decision, Ronin approved enabling macOS HEIC once +sandboxing and decoder memory limits are verified, accepting that the native +helper has no guaranteed hard total-memory cap and can still cause system +memory pressure or crashes. Unauthorized file/network actions remain +unacceptable. The former 2 GiB value was an engineering proposal, not his numeric +requirement. This approval does not permit weaker sandboxing, unvalidated IPC, +native codec loading, unbounded decoder/input/output/jobs/time, or false claims +about undiscovered vulnerabilities. + +Continue beyond this candidate into a minimal disposable protected helper, +family-wide app/analysis admission, deterministic cancellation/join and canonical +imaging integration. macOS must fail closed if its actual sandbox cannot be +established. Windows/Linux use strong practical restrictions and OS memory +controls where available, with truthful per-platform reporting. Native/package +qualification remains required before exposure. All commits/pushes remain +permitted; no merge/release. The dedicated macOS research task supplies further +primary-source implementation guidance. No alternative runtime migration is +selected by this approval. + +## Production continuation task graph + +P1 runtime/helper -> P2 native launch and sandbox -> P3 family admission -> +P4 canonical source integration -> P5 packaging/platform verification. The lead +owns all implementation, review and fixes. Each boundary lands with focused +tests before consumers are changed; platform refusal remains explicit. + +| Task | Files / contract | Required evidence | +| --- | --- | --- | +| P1 | `internal/heicdecode/worker`: fixed embedded WASI artifact, bounded streaming stdin/stdout/stderr, linear memory ceiling and context termination. `cmd/picfetch-heic-worker`: minimal entry point, no Fyne/native codec. Move the artifact into the worker package; maintain provenance. | Owned runtime boundary controls and ordinary fixtures with `go test -tags no_emoji,nodynamic ./internal/heicdecode/worker ./scripts/heicbuild`; import and reproducibility guards. | +| P2 | Native platform launch establishes restrictions before image input; parent verifies helper identity/readiness and owns timeout, pipes and process join. Capability evidence distinguishes actual OS controls from soft native-memory targets. | Native owned file/socket controls plus ordinary decode; absent sandbox and invalid identity fail closed; cancellation joins helper and all pipe work. Cross-builds are recorded separately from native execution. | +| P3 | One app-family admission owner serves GUI and analysis consumers. Acquire before bulk source reads; bounded waiting and cancelled queues; release only after helper/output cleanup. | Concurrent GUI/analysis fixture consumers never exceed one admitted HEIC request; queue cancellation and shutdown join are observable. | +| P4 | Canonical imaging source carries either ordinary encoded bytes or already validated HEIC pixels/metadata, so probe/decode do not retain a second queued HEIC buffer or decode twice. | Display, thumbnail, metadata and similarity focused regressions; ordinary formats unchanged. | +| P5 | Build/package helper identity and notices; truthful per-platform admission and format declarations; native Linux CI and available macOS tests, GoLand inspections. | Package checks, import/provenance guards, native platform tests and full required CI. Missing native evidence is unverified. | + +One additional bounded read-only scout maps source retention and subprocess/UI +lifetime owners across consumers while the lead implements P1. This independent +breadth question needs call-path context beyond a grep; it performs no edits or +review and chooses no architecture. P1-P5 budget: one scout, zero implementation +spawns, one focused review per boundary plus the final gate. The prior whole +worker cap gate above is historical and superseded only for the explicitly +approved macOS limitation. + +Publication: candidate `b64cca35430917cec6e59bd1c26ee4e6c86310b0` is pushed +and GitHub reports its signature valid. The cloud foundation `a3c4d92` is +unsigned. No history rewrite is authorized or performed; the full PR range +requires signing cleanup before merge. + +### P1/P2 continuation evidence (2026-09-16) + +- Fixed artifact moved to the helper-only worker package; native import guard + rejects accidentally linking it into the viewer. The parent owns hash pinning, + bounded waiting/streams, pre-read admission, readiness, process-group teardown + before leader reap, and joined pipe workers. Shared GUI/analysis admission is + still P3; the new Client alone does not satisfy that criterion. +- Owned WASM initial/growth/deadline/diagnostic controls and ordinary decode pass. + Owned parent peers pass changed identity, blocked writer timeout, crash, + diagnostic overflow, active/queued cancellation and Stop/Wait under race. +- Real macOS 27.0 arm64 helper bundle verifies App Sandbox and denied owned + file-read/create/TCP/UDP before input. Ordinary ten-bit decode and cancellation + pass with strict bundle signature verification. Native other-platform startup + still fails closed. Direct no-cgo macOS also fails closed. +- Hardened Runtime without an executable-memory exception refused compiler + decoding. Pinned wazero uses RW -> RX without MAP_JIT; Apple's broader unsigned + executable memory entitlement is needed by this implementation. It is confined + to the helper; no library-validation, DYLD, network or user-file exception. +- Interpreter comparison with unchanged finite bounds: basic 320x240 1.5206 s; + owned valid 4032x3024 gradient reached 30.0093 s and was terminated/joined. + Compiler: 1.6174 s and 7.4040 s respectively. Keep compiler on this evidence, + document increased trusted native executable-memory authority and own-container + rights. These two fixtures are not broad camera qualification. +- Candidate b64cca3 CI failed only on the race-instrumented development + interpreter's 15-second basic/main10 deadline. Fixture ABI tests now use the + production compiler, one test-owned compiled module and the actual 30-second + contract. No production resource bound was increased. Other CI jobs passed. +- The independent macOS research has completed; public App Sandbox supports the + selected bundle route. No new memory-risk approval is needed. User-owned + historical THREAT-MODEL.md in the separate saved checkout is not imported or + edited by this feature work. + +P2's platform continuation uses one further read-only scout to map the pinned +Windows API surface and official launch/job/AppContainer semantics while the +lead completes macOS validation. It writes no code and performs no review or +security testing. This bounded independent breadth survey replaces repeated +cold exploration; all design and fixes stay with the lead. Cumulative scout +count is three including the original source/OS phase; no implementation spawns. + +### Worker publication gate and updated source requirement + +- Focused race tests pass: protocol 1.205 s, client 1.718 s, worker 15.419 s, + guest/build guards 16.506 s, nativeguard tooling 1.236 s. +- `make verify-build` passes formatting, generated assets/notices, exact Qodana + exclusions, source/artifact reproducibility, native import guards, vet and + build. The first restricted run could not write Go's module stat cache; the + same target passed with authorized standard cache access. Full native + Linux/amd64 race and platform CI remain required; no local Docker daemon. +- `make heic-native-macos` passes both required native guards, 46.377 s total. + Missing entitlement refuses before source read; real ten-bit decode, + read/create/TCP/UDP denial, cancellation and owned descendant cleanup pass. + Canonical compressed gradient: compiler 7.1366 s; interpreter terminated and + joined at 30.0108 s under the unchanged 30-second deadline. +- Owned preopen control passes without filesystem access and traps when given + a temporary directory. Temporarily changing group termination to leader-only + makes the descendant test fail at its bounded EOF assertion; exact source + restored and the focused/native suites pass afterward. +- GoLand worktree access is resolved. All 34 changed Go files were inspected, + including weak warnings. Fixed fixture cleanup, ignored errors, nullable test + metadata and redundant types/conversions. Guest-only protocol enum values use + a verified `GoUnusedConst` suppression on that declaration; literal owned + WASM controls use verified function-scoped duplication suppressions and the + existing exact test-file Qodana exclusion. Final re-inspections are clear. +- Qodana run 35024568046's post-suppression SARIF was retrieved and its b64cca3 + revision verified. All seven results were inspected: five corrected issues + and the two separately compiled guest-status false positives above. This is + disposition of the prior commit, not a clean report for the pending commit. +- One read-only evidence-retrieval scout downloaded that report while the lead + inspected/fixed code. Another bounded source inventory now maps historical + local patches; it performs no review, edits or upstream test/fuzz execution. + These two additional independent scouts exceed the prior count explicitly; + all architecture, equivalence assessment and implementation remain with T0. + +The coordinator relayed newer user source instructions: retain the maintained +`third_party/h265` hardening until equivalent upstream checks are verified; +never add native/in-process fallback. Read the historical `PICFETCH.md` in the +separate saved checkout. It records additional coded-work, extent/table, NAL, +frame-retention and transformed-config changes beyond this candidate's source +selection. P0 now reconciles that source and records each patch disposition +before P4/P5 activation; the independently verified P1/P2 worker boundary can +be published while HEIC remains disabled. Do not copy or edit the user's +historical `THREAT-MODEL.md`. No source-equivalence conclusion follows solely +from the newer upstream version or the updated AGENTS wording. + +### P0 source preservation and native deadline diagnosis + +P0 files: `third_party/h265`, root/guest module replacements, +`scripts/heicbuild/source.go`, provenance, ordinary guest checks and the source +qualification record. Owner T0; no implementation delegation. Contract: exact +maintained production bytes from fc127b44, WASI-only application use, baseline +and replacement validation before builds. Verify with `make check-heic-wasm +test-h265`, focused worker/guest race tests and native guards. One source +inventory scout and the earlier evidence-retrieval scout were reused; source +assessment, patches and findings stayed with the lead. + +- Read historical `third_party/h265/PICFETCH.md`; source subtree was clean at + saved HEAD 73cb3c9, last source change fc127b44. Restored 107 production/source + notice files exactly (57 Go, 47 assembly, LICENSE, README and go.mod). No + historical test corpus or THREAT-MODEL.md was imported or edited. +- Preserve all local hardening instead of substituting unmodified v0.2.3. + Reviewed v0.2.2 -> v0.2.3 production diff: missing metadata, coded-frame bounds + and transform bounds already exist locally; decoder/deblock changes are + comments. Keep the complete local bounded sequence implementation without + claiming equivalence to upstream's different representation. Patch inventory + and exact source manifest are in the maintained directory. +- The new input-inventory/native-import test first failed because maintained + source was absent from provenance. It passes after source inventory and + module isolation are recognized. Missing or redirected replacements are + refused. An actual appended comment in the maintained budget source made + `heicbuild build` fail on its baseline hash before compilation; exact bytes + restored. `make test-h265` then passed source/rebuild/import checks and the + ordinary WASI fixture test (1.563 s). +- The maintained worker and guest pass focused race tests (14.730 / 15.802 s). + All imported Go/assembly files were inspected. Assembly dialect errors and + retained declarations/algorithms have exact Qodana exclusions; existing weak + style suggestions and partial switch/EOF behavior were assessed and recorded + in PICFETCH.md. The IDE does not consume Qodana scopes: those reports are + reviewed dispositions, not a claim of zero generic-assembly diagnostics. +- Published c4a89d1 Qodana had one result: worker Main appeared unused although + cmd/picfetch-heic-worker calls it directly. Added a function-scoped suppression + with that caller documented; GoLand re-inspection is clear. Fresh source-phase + Qodana evidence remains pending. +- c4a89d1 CI passed six jobs; Linux race and Intel native guards failed on the + 30-second timing proposal. Linux's real guest compilation under race took + 37.08 s; every small owned runtime boundary control passed. Intel sandbox, + small ordinary decode and cancellation passed, while the 12MP compiler job + reached 30.060 s and its interpreter reached 30.049 s. No guard was skipped. +- Diagnosis retained the original deadline. The maintained x86 helper under + Rosetta decoded basic at 2.290 s and 12MP at 5.967 s. Its first diagnostic + launch hit the installed xcrun's missing x86 slice; using ARM clang to build + the x86 helper resolved setup. This exercises x86 transport/compiler behavior + but is not native Intel performance qualification. A non-race real worker + fixture test completed in 0.89 s; instrumentation/host throughput explain the + observed timing evidence better than a general transport stall. +- The coordinator clarified that 30 seconds was an engineering proposal. + Proposed a finite 60-second ceiling to measure common-camera completion on + native Intel, with no byte/memory/job/sandbox relaxation and no test removals. + The updated default/max-duration tests went red at the prior 30-second limit, + then green; values above 60 seconds are refused. Fresh native Intel completion + time is still required before qualification. Longer single-lane occupation + is the availability cost, recorded in the qualification document. +- Final local P0 checks: `make verify-build test-h265` passes. Focused race + protocol/client/worker/guest/nativeguard packages pass in 1.310 / 1.911 / + 14.998 / 15.903 / 1.225 s. Native Apple Silicon suite passes in 75.328 s; + compiler 12MP 6.713 s, interpreter finite rejection at 60.012 s. All native + file/network, missing-entitlement, cancellation and descendant guards remain. + The new source tooling and changed boundary files re-inspect clean in GoLand. + +### P2 Linux candidate (native execution pending) + +Files: worker Linux policy/installation and owned native controls, client native +fixture test, nativeguard runner, CI matrix, exact test exclusions and platform +qualification record. Lead owns design/implementation/review. Verify with +`make heic-native-linux` on native Linux amd64 and arm64, the portable filter +test on every host, cross-builds and GoLand. No Linux production exposure follows +from cross-build success alone. + +- Reused one read-only scout for installed Go 1.27.1/wazero 1.12.0 syscall + facts while the lead implemented policy. A second read-only Windows scout + mapped existing package/CI launch points. Neither wrote policy, reviewed code, + ran security tests or made edits; all implementation remained with the lead. +- Exact pure-Go thread flags differ: amd64 0xd0f00 includes CLONE_SETTLS, + arm64 0x50f00 does not. Reject all other clone forms, clone3, exec, file/network + operations, io_uring, new policies/namespaces and unspecified syscalls. + Runtime memory/signal/poll/stdio operations are the explicit allowlist. +- Portable BPF tests first failed on the refusing placeholder, then passed + required runtime calls and denied authority for both ABIs. Native-suite + registration/CI tests first failed for the absent Linux suite and invocation, + then passed after wiring. Native guards cannot be executed on this macOS host; + no local Docker daemon is available. Their execution is still unverified. +- RLIMIT_AS is a virtual-address-space ceiling, not a measured RSS quota. + Read bounded /proc/self/statm before installation and reject an oversized + starting process. Read back every hard/soft limit, then require ENOMEM from + an oversized PROT_NONE reservation before input; no RAM pressure test. + CPU/core/file-size/descriptor limits are finite; actual process creation is + denied, while debug.SetMaxThreads remains only a Go runtime thread ceiling. +- Native controls retain one thread across TSYNC and create enough locked + threads to exercise inheritance. They also observe syscall denial, a small + owned WASI cancellation and the omitted-address-limit negative control. + The real helper must decode ordinary ten-bit and 12MP fixtures after its + parent-owned file/TCP/UDP readiness controls under the unchanged 60 seconds. +- Signed P0 publication is temporarily waiting on Secretive's signature request. + Its original staged checkpoint remains separate from these working edits. + +### P3 family admission and pipe transport + +Lead-only implementation in `internal/heicdecode/client`. One application-owned +Client admits foreground and background operations; FIFO within class and no +more than three foreground grants before a waiting background grant. A bounded +pipe service shares that Client with analysis processes. The remote sends a +small intent first and reads encoded bytes only after the owner's native-ready +grant. Framed canonical responses use existing validation, without a second +image encoding or an image-sized envelope copy. Retain admission through +downstream response delivery, bound idle connections and queue entries, close +both stream directions on cancellation, and join their service work at Stop. + +Tests: controlled local/remote source callbacks cannot overlap, remote bulk +reads follow the grant, blocked output retains admission until cancellation or +delivery, cancelled queues never read sources, repeated responses remain +framed, and Stop joins active/idle service work. Verify focused client race +tests, GoLand and native Mac regression after this boundary. Pipe inheritance +and app/similarity wiring are subsequent P3/P4 work, not satisfied by these +component tests alone. Existing platform refusal remains unchanged. + +- The fairness test went red on the refusing queue placeholder, then passed + FIFO selection, the three-foreground maximum, cancellation and idempotent + release. Existing client lifecycle regressions pass after lane replacement. +- Repeated remote requests went red on the refusing transport placeholder, + then passed canonical NRGBA64 transfer. A queued-disconnect regression + exposed a service waiting behind active foreground work; one bounded control + byte now observes disconnect, cancels admission and is joined on every exit. + Pre-admission refusal retires the connection without reading source bytes. +- The focused client race suite passes (5.200 s), including bounds for frame + lengths/grants, eight idle connections, Stop/Wait and held output. An actual + early-release mutation made the held-output test fail; exact source restored. + GoLand inspections are clear after moving deliberate retained test peers to + one explicit cleanup. Final post-fix native/local checks are recorded next. + +- Final component client race run: 5.275 s. `make verify-build` passes. Real + macOS App Sandbox helper/cancellation regression passes in 3.343 s; the + runtime-choice measurements remain the earlier unchanged-runtime evidence. +- Pipe inheritance now proceeds through a Client-owned attachment and a small + descriptor/limits configuration. UNIX appends explicit ExtraFiles and makes + inherited child pipes pollable; Windows uses explicit inherited handles. + Attachments must register service work before asynchronous dispatch, close + parent copies of child endpoints immediately after Start, and stop/join on + every failure/exit. A reused read-only Windows scout checks pinned Go pipe + Close/handle-list semantics while the lead implements the UNIX side. + +- Actual inherited subprocess decoding, child cancellation, and cleanup before + process Start pass under the race detector. The full client race suite passes + in 8.841 s; nativeguard tests and `make verify-build` pass. Latest changed Go + files have no GoLand findings. Windows amd64/arm64 and Linux arm64 test binaries + cross-build; the new Windows native pipe CI suite is still unexecuted. + +### P4 source contract and first integration gate + +Owner: lead for design, tests, implementation and review. Reused one read-only +caller scout to locate constructor and shutdown injection points across GUI +features while the lead inspected imaging/analysis. No edits or review delegated. + +- `imaging.Reader` is an immutable value containing an optional HEIC decode + function. Its zero value keeps HEIC unavailable. `Read` returns a `Source` + with bounds, complete decode, metadata, encoded digest, thumbnail and preview + methods. Ordinary sources retain encoded bytes and the existing algorithms; + HEIC sources retain validated pixels/normalized facts and discard source bytes. +- Dispatch reads at most 64 KiB before admission, from one open source stream. + HEIC extensions route directly; renamed HEIC and ambiguous BMFF file types + route to the isolated decoder. Only positively identified AVIF/CR3 brands + enter existing native metadata/preview handling. No full container walk occurs + in the parent. Bulk HEIC reads and digest calculation occur inside admission. +- The guest's transformed pixels are authoritative. The source adapter does + not apply Exif orientation again; ordinary image orientation stays unchanged. + Native camera/container/Exif precedence still needs qualification before + production enablement. Existing normalized GPS absence ambiguity is preserved. +- Metadata-only reads use the guest metadata operation. Cancellation closes + the open stream and joins that close callback. Opening or a storage backend + that cannot interrupt a blocked read retains the existing I/O limitation. +- First gate: owned decoder callbacks prove no bulk read before admission, + renamed dispatch, exact digest/size, one decode, 16-bit pixels, no repeated + orientation, source cancellation and metadata-only routing. Ordinary JPEG, + AVIF, CR3, GIF/SVG, thumbnails and capture-date regressions must pass. + Verify `go test -race -tags no_emoji,nodynamic ./internal/imaging`, then the + affected consumer packages, `make verify-build`, and changed-file GoLand. + App/similarity injection and shutdown tests are a separate subsequent gate. + +- First source gate passed: image-source contracts went red against the refusing + placeholder, then green. Native byte-metadata refusal first failed on the + existing ordinary HEIC fixture, then passed after dispatch guards. An actual + early full-read mutation failed the renamed-file admission test; exact source + restored. Full imaging race tests pass in 23.429 s; expanded focused source + tests pass in 1.386 s. GoLand is clear on new source/dispatch/tests and changed + preview, metadata, RAW and raster files. The loader import retains the already + recorded IDE module-tag error (no_emoji/nodynamic not applied to its live + module); command-line tagged builds validate that import. No suppression was + added for the missing IDE configuration. The first build gate caught import + grouping in two new files; corrected with repository goimports settings. + +P4 analysis connection gate: `similarity.Client` accepts the app-owned HEIC +client; Analyze and retained Search attach explicit pipe pairs before process +start and close/join them on every exit. Only descriptor/limit values enter the +request. Worker request setup owns one Remote and injects `imaging.Reader` into +analysis and search preparation; disposal runs before WorkerMain can exit. +Source hashes/facts use Source methods, with no raw HEIC metadata path. Owned +analysis/search subprocesses must observe a typed owner refusal without reading +source bytes, including start failure and cancellation. Pixel transport itself +is covered by P3's real inherited subprocess tests. Verify focused similarity +protocol/source tests, affected regressions, Windows cross-build and GoLand. + +- Analysis/search owned-process connection tests went red for missing setup and + missing inherited handles, then green. Search preview injection went red on + the old byte path, then green after Source migration. Full similarity race + suite passes in 13.543 s. Expanded cancellation/start-failure/preview tests pass + in 3.754 s. Windows ARM64 test binary cross-builds; native execution remains + separate. All eight changed analysis code/test files re-inspect clear in + GoLand. No inference model or real HEIC camera qualification follows from + these owned transport and source tests. + +P4 GUI injection gate: immutable foreground/background Readers enter display +Config and feature constructors before workers start. Viewer owns both readers +and the one optional HEIC Client; Explorer and visual search receive that same +Client. Stop closes HEIC admission before feature teardown and Wait joins it off +UI in production and the harness. Comparison, EXIF/file reconciliation, grid +hash/thumb work, Favorite previews, sorting, Spiral and mosaic use those Readers. +Zero-valued readers keep production HEIC disabled until package qualification. +Verify feature-level HEIC callback contracts, root wiring/shutdown assertions, +focused package regressions, exact UI shard assignments, and GoLand. + +P4 GUI evidence (2026-09-16): + +- Root source-consumer and owner-shutdown race tests pass (2.890 s); display + suite 2.696 s, grid 10.780 s, EXIF 5.517 s, Spiral 2.523 s, filesort 1.397 s, + Favorite previews 1.679 s and mosaic 51.267 s. Expanded Spiral HEIC callback + test passes (1.882 s); source/metadata/date regressions pass (1.903 s). +- Focused viewer capture-sort, EXIF navigation, Favorite, comparison, save/export + and HEIC regressions pass (45.573 s). The first run exposed a pre-existing + synctest ownership mismatch: a viewer created outside the bubble acquired a + context Done channel inside it after source cancellation became observable. + Its cleanup cancelled that channel outside the bubble. Moving that test's + viewer/source setup inside its existing bubble makes creation and cleanup + share the lifetime; no production cancellation was weakened. The minimized + test failed before the change and passes (2.624 s) afterward. +- All 29 changed GUI files were inspected, confirmed issues corrected, and + touched files re-inspected clear. The source loader retains the known IDE + build-tag configuration diagnostic for avifpolicy; tagged command-line + verification passes. Live IDE tag refresh is still recorded as incomplete. +- Both new top-level UI tests are assigned to ui-1 and its count is 238. + Qodana exact-file exclusions are synchronized. Format/provenance/import/vet/ + build gates pass. Canonical Linux shard validation and the complete race + suite remain unverified locally because the Docker daemon is unavailable. + Direct host shard checking selects an existing Darwin-only test absent from + the Linux manifest; that does not justify changing the canonical manifest. + +P2 Windows launch gate: the lead owns implementation of suspended AppContainer +creation with zero granted capabilities, an explicit three-handle stdio list, +one-process Job Object, finite committed-memory/CPU limits and kill-on-close. +The child verifies its token and active job before readiness; failures retire +all handles and the suspended child. No native image parser is added. Native +owned controls and ordinary WASI fixtures must pass on both Windows architectures +before activation. Cross-builds alone are not native evidence. The read-only +Windows scout was reused for pinned standard-library/x/sys API inventory while +the lead verified P4 (G1 bounded API question; G2 source declarations/official +links; G3 no edits; G4 narrow platform facts; G5 no lead implementation overlap). + +P2 Windows candidate evidence (2026-09-16): + +- Added `internal/heicdecode/winisolation`: suspended zero-capability AppContainer + launch, explicit stdio handle list, child-process restriction and private job + with process-count/commit-memory/user-CPU/kill-on-close limits. The worker + checks token identity/capabilities and immediate job before the common owned + file/network denial probes. Provisioning grants read/execute only to a + dedicated helper and its directory, without ACL inheritance; launch itself + does not change permissions. +- The owned native control has a 64 MiB budget, checks a fixed 65 MiB commitment + refusal without touching pages, rejects a second inert copy and checks that + a parent environment sentinel is absent. An unsandboxed process must fail + verification. These Windows tests first failed to compile for the missing + boundary; native red/green execution remains pending, not inferred from that + compile failure. AMD64/ARM64 test binaries and the AMD64 helper cross-build. +- Native suite registration tests failed for missing Windows guards/CI wiring, + then pass (0.267 s). `heic-windows` replaces the pipe-only suite, retaining + its analysis/pipe controls and adding token/job controls and the same ordinary + ten-bit/12MP helper fixtures as Linux. The shared fixture test is now + `client/native_platform_test.go`; native Linux inventory is updated with it. +- A portable denial-classification control failed for standard permission + errors, then passes (0.263 s). Windows file access-denied and Winsock WSAEACCES + are recognized; connection failures/timeouts are not isolation evidence. +- Client process-lifetime regressions pass under race (8.935 s). Windows HEIC + packages pass vet; Linux AMD64 and Windows ARM64 native client inventories + cross-build. All 19 affected existing/new code files inspected clear in + GoLand. `make verify-build` passes including format, source/artifact proof, + imports, notices, vet and build; exact Qodana exclusions are synchronized. +- Packaging discovery reused the read-only caller scout for twelve-file-bounded + artifact-layout facts while the lead implemented Windows isolation. G1-G5: + bounded inventory, file/line oracle, no edits, cold independent package paths, + no lead design/review overlap. P5 retains all decisions and implementation + with the lead. Additional scout call recorded rather than hidden in budget. + +- Real signed Apple Silicon App Sandbox helper still passes after the process + abstraction/permission changes (3.385 s). + +P5 packaging contract: helper and exact notices live at fixed package-relative +paths, with a bounded manifest recording the post-signing executable SHA-256, +platform and embedded-guest SHA-256. This manifest belongs to the trusted +installed package (TUF archive or platform package/bundle signing); it is not +an independent signature or protection against replacement of the whole trusted +installation by its owner. No image, environment override or manifest field +chooses an executable path. Readiness and isolation checks still run for every +request. Package construction finalizes the manifest after helper signing, and +any later helper change must fail the executable pin. Production admission and +format exposure remain disabled until native/package qualification is complete. +The next acceptance seam is package loading with absent, malformed, wrong-target +and changed-helper controls, followed by actual staged-bundle qualification. + +P5 packaging evidence in progress: + +- Fixed package paths and bounded manifests now have race-tested absent, + malformed, wrong-architecture and changed-helper controls (1.368 s). + `scripts/heicpackage` builds the minimal helper, signs/verifies its macOS + bundle, copies exact h265/Go/wazero notice files and writes the final hash + manifest. Wrong binary format is refused before manifest creation; package + tool controls pass (1.367 s). Linux AMD64 and Windows ARM64 artifacts stage + successfully here; those are cross-builds, not native isolation results. +- Native macOS acceptance now uses this same staged package; ordinary decode, + missing-sandbox refusal and cancellation pass (3.070 s). `make package-mac` + builds the complete app with its nested helper/notices. Strict deep codesign + verification passes for both outer app and nested helper. The automatic + Fyne build-number increment (469 to 470) was restored after verification. +- Release ZIP/tar and MSIX assembly include the helper/notices. Windows signing + signs both executables, finalizes the helper manifest afterward, and retains + the helper directory when rebuilding the ZIP. Real Windows Authenticode/MSIX + package execution remains unverified. +- Packaging inspection found a further required P5 path: the in-app updater + currently installs only the main executable and macOS plist. It will need + authenticated companion-file staging/install (with rollback) or a complete + package transaction before HEIC activation. No updater installation behavior + has been changed yet. Production remains disabled while this work and native + qualification are incomplete. + +GoLand configuration follow-up: using the live project Build Tags settings, +set the required `no_emoji nodynamic` editor tags. The changed imaging loader +now re-inspects with no findings; its previously reported avifpolicy import +warning is resolved without a suppression. This is project-local IDE state, +not a source change. The known nested guest module resolution issue is separate +from the native loader diagnostic. + +P5 updater continuation contract: after authenticated archive extraction, retain +and persist exact companion-file digests. Validate every staged companion again +before installation. Linux/Windows install the dedicated `heic` directory with +rollback around the existing executable transaction. macOS installs a verified +complete app bundle so the outer signature and nested helper remain consistent. +Legacy stages without companion provenance retain their existing behavior; +this also means the first upgrade from an older updater can omit the helper and +must remain fail-closed until a complete package is installed. The shutdown path +must join the HEIC owner before replacing any helper files. Acceptance tests use +owned archives/files: companion tampering after Save/Load is rejected; install +preserves the package; preparation/apply failures restore prior installed bytes; +existing update/relaunch controls still pass. Native Windows/MSIX execution and +the old-updater transition stay explicit qualification limits. + +P5 updater evidence (2026-09-16): + +- The companion-provenance test failed because a changed notice was accepted + after Save/Load, then passes after exact bounded inventories were added. + Each authenticated extraction now owns a fresh payload directory so stale + cached files cannot be incorporated into a newer archive's companion proof. +- The install test failed with a new main executable paired to the old helper, + then passes after the helper-directory transaction/rollback. Full updater + race suite passes (4.204 s), updater UI suite (1.627 s) and focused root + shutdown/update regressions (4.446 s). Windows AMD64/ARM64 updater test + binaries cross-build; native execution remains a gate. +- Native macOS whole-bundle install and an injected final-rename failure both + preserve coherent signatures and all expected bytes, without modifying a + neighboring owned user-data file. An initial fixture had unchanged helper + resources and incorrectly survived the negative binary-only mutation; it was + strengthened to change helper metadata/signature, its manifest and another + sealed resource. The same real mutation now fails for package mismatch; + restored implementation passes (1.639 s). This supersedes the weaker fixture + evidence. Strict deep codesign verification covers the enclosing bundle and + nested helper, not merely the standalone helper signature. +- The retained legacy updater path was tested separately with an owned initial + bundle that has no helper. It omits the new helper, removes the staged payload + and fails enclosing codesign verification (invalid resource directory). The + expected-limitation control passes (1.748 s). This proves no cache-based + automatic migration is available after the old updater's normal cleanup. + A complete reinstall or separately qualified bridge is a first-release + requirement; production HEIC remains disabled. The originating task received + this concrete finding and the fact that work continues locally. + +- Removing a helper-bearing stage's companion provenance initially passed + validation. The new control fails before the fix and passes after rejecting + helper packages without their verified digests. Legacy stages with no helper + remain supported. Full updater race regressions pass again (4.091 s). +- Final updater/native-guard GoLand inspections are clear, and the three + modified workflow documents parse as YAML. Explicit Windows companion + provenance/install/rollback requirements were missing from the guard list; + a failing registration control exposed this and the list is corrected. +- A final read-only fixture inventory reused the caller scout while the lead + verified updater changes. G1-G5: repository-only ordinary-fixture coverage, + named tests/file-line evidence, no writes or decoder execution, independent + compatibility facts, and no delegated implementation or review. It confirms + that the existing fixtures cover basic/alpha/explicit ten-bit transport and + synthetic 12MP throughput, but no recorded real-camera provenance or + HDR/color reference. Adapter metadata stubs are not camera extraction proof. +- Final consolidated `make heic-native-macos` passes all four mandatory guards: + sandbox helper, interpreter/compiler comparison, signed bundle install and + rollback, and the legacy first-upgrade limitation. Compiler 12MP decode is + 6.772 s; the interpreter is terminated/joined at 60.015 s. The two native + update guards pass in 1.310 s and 1.220 s. Raw events are retained in + `.scratch/heic-qualification/native-macos.json`. Native guard registration + regressions pass under race in 1.344 s; changed guard files re-inspect clear. +- Final `make verify-build` passes again after the updater provenance and CI + guard corrections. Publication remains blocked on the pending Secretive + signing request; subsequent work stays unstaged to preserve that in-flight + checkpoint. A refreshed PR description is prepared locally for publication. + +### Ordinary compatibility continuation (2026-09-16) + +The originating task requested bounded normal-camera and color qualification +before treating signing as the only remaining local dependency. The lead owns +all checks and conclusions. A read-only scout searched first-party records for +at most two ordinary licensed samples while the lead tested existing fixtures; +it downloaded no image bytes and executed no decoder. This is the existing +compatibility gate, with no new HDR feature, negative corpus or stress work. + +- Real sandboxed helper checks pass for the existing synthetic TestCam metadata + and container rotation, alpha, basic color pattern and owned ten-bit ramp. + ImageIO/ExifTool provide independent metadata/orientation and eight-bit SDR + comparisons. The known generated ramp is the numeric oracle for ten-bit + values, because ImageIO expands/clips that fixture's range differently. +- Added the metadata/config/decode and known-ramp assertions to the existing + native sandbox guard. The complete guard passes in 5.906 s, and GoLand is + clear. Full details, reference methodology and observed pixel differences + are in `docs/heic/compatibility-2026-09-16.md`. +- Static maintained-source/API evidence establishes an implementation limit: + ICC/primaries/transfer handling and HDR gain-map/tone-map presentation are + absent, and the guest drops the color description. Those color classes are + not explicitly rejected. This supersedes the earlier vague description of + HDR/color as merely unqualified; no implementation behavior was expanded. +- One photographer-released public-domain Samsung S23 Ultra sample with an + immutable source manifest and paired SDR reference was located. Only that + ordinary file is being retrieved with pinned size/hash and finite transfer + limits; incomplete bytes will never be decoded. All native OS CI and signed + publication requirements remain unchanged. +- The camera sample was fully retrieved and matched its pinned 2,872,198-byte + size and SHA-256 before decoding. The real helper succeeds in 20.414 s, + preserving upright 3000x4000 pixels and expected Samsung metadata. It carries + a P3 profile; comparison with color-managed ImageIO yields RGB MAE + 7.529/8.283/7.644, so this is decode/metadata/orientation evidence, not faithful + P3 display. The public paired PNG was identified but not downloaded or used. +- The bounded compatibility continuation is complete. Native tests, GoLand + and `make verify-build` pass. Remaining camera breadth, color/HDR support, + native CI, distribution and signing requirements are recorded explicitly; + no production activation or new color implementation was added. + +### Complete checkpoint publication (2026-09-16) + +Ronin requested publication of the entire owned checkpoint to the existing +draft PR #28. Fresh inspection confirms the old signing process has exited; +the configured SSH key fingerprint matches a currently available Secretive +key. Signing stays enabled and the configured signer is used without a key +override. The prior P0-only staged subset is superseded by all 226 related +changed/new files; no unrelated local changes were identified. + +The lead reviewed the intended source, platform, shared-admission, imaging, +packaging/updater, CI/test and documentation scope against the existing plan +and verification record. Source/artifact/import guards and exact Qodana test +exclusions are rechecked. The UI manifest has unique assignments for both new +root tests, sorted blocks and matching counts (238/248/202); complete Linux +build-selected inventory and race verification remain native CI gates. +Prior focused/native/GoLand/build evidence remains applicable; broad local +tests are not rerun merely for publication. + +A read-only scout inventoried the two separately owned documentation commits +while the lead handled the checkpoint (bounded commit/path facts; no edits, +review or historical code import). They are intentionally absent here: +`cac90b9e4b2e7039c1489bc84e96327ec1e84f46` adds +`docs/heic/macos-memory-protection-research.md`; root-local +`73cb3c9c568728ecd4cd36df10dd6907f3d47a3c` adds `THREAT-MODEL.md` scoped to the +historical prototype. Neither branch nor the historical hardening commit is +merged. The missing documents are reported for separate intentional integration. +HEIC activation remains disabled, all qualification limitations remain explicit, +and no history rewrite, merge or release is authorized by this checkpoint. + + +### Historical omission reconciliation (2026-09-16) + +Ronin authorized restoring useful omissions from `feature/heic-hardening` +commits `fc127b44` and `73cb3c9` into the existing PR, including signed commit +and push. The earlier checkpoint's deliberate document omission is superseded +by this authorization. The CI/review agent remains paused; no merge, release, +activation or history rewrite is included. + +The lead restored capped one-time guest backing storage, YAML validation before +Qodana path checks, four unchanged ordinary positive fixtures, stronger alpha +coverage, the generated Fyne metadata ignore and an adapted app-wide threat +model. The 107 maintained production/source-license files were already present +byte for byte; the current helper architecture replaces the older worker. +[The reconciliation ledger](../docs/heic/history-reconciliation.md) accounts +for every historical non-vendored path and the selected/omitted test coverage. + +The lead owned implementation, all review and fixes. One bounded read-only +scout checked ordinary fixture provenance and final source/fixture identity +while the lead reviewed code and ran checks (no writes, decoder execution or +review delegation). It confirmed 107/107 source/license files and 4/4 restored +fixtures with no mismatches. This adds evidence to the existing Deep plan. + +The Make gate first accepted malformed YAML; the restored parser rejects it. +Both memory-growth cases first replaced the backing buffer; preallocation makes +them pass without raising the ceiling. Focused worker/build-tool/validator race +regressions and interpreter memory/cancellation tests pass. All four native +Apple Silicon guards pass after preallocation. The guest regenerates unchanged; +`make verify-build` and GoLand inspections of all changed Go files pass. + +Canonical shard validation could not complete locally: the Linux-only direct +target was accidentally run on macOS, and the documented Docker target then +found no running daemon. No UI test or assignment changed. Current baseline CI +has passing Linux/macOS native guards and UI shards, but failing Windows HEIC +and non-UI Linux race jobs. Fresh CI must qualify the new configuration. These +and distribution/color/upgrade/signing gates remain explicit in `todos.md`; +this reconciliation does not claim feature or review-loop completion. + + +### Native regression follow-up before reconciliation handoff + +The signed reconciliation `30f5b10` was pushed and verified as PR #28's head. +Fresh native Linux amd64 and arm64 guards then failed on both ordinary decoder +fixtures while the native policy guard passed. The captured amd64 events show +bounded diagnostic overflow and a closed response pipe; they do not expose the +inner runtime fatal message. This is a new regression relative to `52ed2df`. + +Withdraw the optional eager WASM backing reservation, the sole production +runtime change, restoring both runtime files exactly to the passing baseline. +Do not enlarge native/guest limits, alter seccomp, suppress tests or introduce +an experimental allocator. Keep the other reconciled omissions. The existing +native Linux guard is the regression check; its new CI result must establish +whether this correction restores the prior behavior. Current threat and +qualification documents retain temporary backing-buffer overlap as a residual +memory cost. Earlier preallocation evidence above describes the trial only. + +### GitHub Codex review loop (2026-09-16) + +Ronin resumed the review loop on PR #28 at `08a71f2`. This authorizes fix +commits, pushes and review discussion; merge, release, production activation +and history rewriting remain outside scope. Existing code/security reviews +cover only `a3c4d92`, and there are no unresolved review threads at entry. + +Route: Deep continuation for native Windows failures. Lead owns all assessment, +implementation and fixes. One read-only scout retrieves Qodana/CodeQL evidence +while the lead diagnoses CI: G1 bounded artifact collection, G2 retained API/ +SARIF outputs, G3 no tracked writes, G4/G5 independent evidence not yet held by +lead. Budget: one scout, no implementation delegation, focused local tests; +the complete suite runs in native CI as required by the review-loop agreement. + +Acceptance commands/evidence: + +- `go test -tags no_emoji,nodynamic ./scripts/msixstage ./scripts/nativeguards` + verifies packaging notice contracts and native runner selection. +- Focused changed HEIC/client/cache tests and Windows cross-compilation verify + local changes; native `heic-windows` CI on both architectures must run every + required sandbox/pipe guard without skips. No relaxed isolation controls. +- Inspect changed code with GoLand, and fetch post-suppression Qodana SARIF, + CodeQL analyses and failed job events after every relevant push. +- Final `gh pr view` plus review/thread API evidence must establish a fresh + clean Codex code review and completed security review on the latest head, + actionable static findings resolved, and required CI passing. + +Round 1 local evidence: focused `msixstage`, `nativeguards`, `qodanaconfig`, +HEIC client and affected similarity tests pass. Both Windows architecture +guard binaries cross-compile. The notice assertion was observed failing on +the old commands, then passing after updating its archive contract; removing +the privacy notice from the new archive command fails it again. GoLand reports +no findings for all seven changed Go files. Formatting and exact Qodana +configuration checks pass. Native Windows behavior awaits hosted execution. + +At the entry head, CodeQL Go/Actions each report zero SARIF results (merge +`575fde0` has `08a71f2` as head parent), and the PR-ref alerts list is empty. +Qodana's 14 post-suppression findings are dispositioned with exact-file scopes: +five false `errors.As` target warnings (`*heicdecode.Failure` implements error), +four harmless maintained-source casts, two scoped builtin-shadowing names, +one partial NAL switch followed by explicit remaining-type handling, and two +retained codec constructors. The nested guest uses NewDecodeBudget; retained +encoder API does not require deletion. Fresh reports must verify these scopes. + +Round 1 native Windows x64 now passes the cache cases, and profile-creation +errors are absent. AppContainer creation still returns ERROR_ENVVAR_NOT_FOUND; +adding SystemRoot alone did not solve startup. The next correction adds only +LOCALAPPDATA from SHGetKnownFolderPath. Chromium's `CreateFilteredEnvironment` +documents this AppContainer requirement, independently confirmed by the +maintainer's environment bisection in Convira/convira-sandbox issue #1. Native +guards remain the acceptance evidence; no unrestricted launch is substituted. + +Round 2 (`fa89ab6`) creates the process successfully on Windows but its native +control exits before Go initialization with STATUS_DLL_INIT_FAILED +(`0xc0000142`). Test the console-initialization hypothesis by using +DETACHED_PROCESS for this pipe-only helper instead of CREATE_NO_WINDOW. +The same AppContainer, child-process restriction, handles and job limits apply. +The native positive policy and ordinary decode guards must still pass. + +Round 3 (`11e0583`) passes Windows owned-peer, inherited transport and +cancellation tests. The native control now enters Go and returns +ERROR_NOACCESS during policy verification. The x/sys job-query/set wrappers +accept raw uintptr addresses and perform lazy DLL resolution; compiler escape +output confirms the pointed-to structs remain on the movable Go stack. Pin +both buffers across those calls and add stage-specific errors. Token queries +already accept typed pointers. Native policy/ordinary-image guards are the +regression oracle; no syscall result or sandbox check is bypassed. + +Round 4 (`a86bb35`) identifies the remaining ERROR_NOACCESS specifically in +GetTokenInformation(TokenCapabilities), before any job query. Pinning job +buffers fixes a separate pointer-lifetime defect, not this observed failure. +The token buffer was a byte array with byte alignment, although TOKEN_GROUPS +and TOKEN_APPCONTAINER_INFORMATION contain native pointers. Use the same +4096-byte capacity in a uintptr array to guarantee alignment. The native +positive guard remains mandatory on both architectures. +The Windows amd64 compiler listing confirms the defect and correction: the +old buffer starts at `SP+79`; the uintptr buffer starts at `SP+88`. Windows +ARM64 vet and GoLand inspection pass. + +The security review of `11e0583` reported P1 thread `4024388304`: repository +`go run` in the authenticated Windows signing job can use the live signer. +Confirmed. Remove checkout/setup-go from that job and replace finalization +with fixed PowerShell JSON validation and SHA-256 commands; verify both signed +executables after these data operations and before packing. The signing action, +environment approval and artifact names stay fixed. Regression +`TestReleaseSigningDoesNotExecuteRepositoryCode` was observed red for checkout, +toolchain and Go execution; it must pass after the workflow correction. +No real signing service, release tag or publication is exercised by this loop. + +Round 5 (`6537ac0`) passes the native token and job queries and the bounded +commitment refusal. Its inert child-process control fails in Go before reaching +the kernel: syscall.StartProcess returns EINVAL when ProcAttr.Files has fewer +than three entries. Supply the owned control's three standard handles and keep +requiring an actual permission/quota/child-policy refusal. The real helper still +fails readiness; add bounded, quoted startup diagnostics to distinguish native +policy, file denial and TCP/UDP denial. They are available only before any image +input; diagnostics after readiness remain private and overflow remains fatal. +Owned-peer regression tests cover this boundary. No denial criterion changes. + +Qodana `35078625681` has zero post-suppression findings for `6537ac0`. CodeQL +`35078625690` has zero Go/Actions results and no open PR alerts; its merge parents +include that exact head. CI `35078625610` passes every job except the two Windows +HEIC guards. Focused client/worker/package tests, ARM64 cross-compilation, +format/exclusion checks and GoLand inspections pass for the next corrections. +`TestWindowsHEICManifestFinalization` extracts the actual fixed PowerShell data +transformation and runs it on inert helper bytes, including both targets, an +invalid target, a zero guest digest and an oversized manifest. Native Windows +CI requires this guard without skips; local macOS only compiles it. No test +authenticates a signer or runs the fixture as an executable. + +The SSH signing delay cleared on resumption: security fix `c77d9bd` is signed +and present on PR #28. Focused tests, vet, format/exclusion checks and GoLand +inspections pass for the remaining local changes. Required remaining work: +publish native diagnostics and the PowerShell fixture test, dispose of thread +`4024388304`, complete another fresh review, pass both Windows native guards, +and inspect final CI/static reports for the eventual head. + +Round 6 (`3973811`) passes Windows token/job, commitment, process denial and +the real PowerShell manifest-transformation guard. Both real helper fixtures +stop on a TCP loopback timeout. Windows' AppContainer loopback filter drops +packets rather than necessarily returning WSAEACCES. Chromium's native tests +likewise observe TCP/UDP timeouts; Microsoft's diagnostic API can identify a +missing network capability. Sources: +[Windows loopback filtering](https://learn.microsoft.com/en-us/windows/security/operating-system-security/network-security/windows-firewall/filter-origin-documentation#appcontainer-loopback), +[native Chromium controls](https://chromium.googlesource.com/chromium/src/+/90ebe709881f7572f250d30e9ec77a39b5d8a482/sandbox/win/src/app_container_test.cc), +[NetworkIsolationDiagnoseConnectFailureAndGetInfo](https://learn.microsoft.com/en-us/windows/win32/api/netfw/nf-netfw-networkisolationdiagnoseconnectfailureandgetinfo). + +Lead-owned correction: retain empty-capability AppContainer/job verification, +all file denial checks and existing sandbox limits. Validate both parent-owned +loopback listeners before launching the helper. Keep a bounded one-byte UDP +echo worker with close/join cleanup; Windows must receive no echo. A Windows +timeout qualifies only while the request is live and the native diagnostic API +successfully identifies a missing capability. An ordinary timeout without this +policy evidence, a refused connection, successful communication, cancellation +or an API error still fails closed. Linux/macOS still require permission errors. +Focused client/worker tests, Windows cross-builds and both native Windows CI +jobs are the acceptance commands; GoLand must inspect all changed code. + +The ordinary Windows suite additionally exposed an existing jq fixture check +that expected LF-only output; normalize surrounding whitespace while retaining +the exact `false` result and failing exit requirement. No environment policy is +changed. The startup-diagnostic test was also negatively verified locally by +removing its delivery, observing the expected failure, restoring and passing. + +Local evidence for round 6: focused client, worker, msixstage and nativeguards +tests pass; the new probe lifetime and startup cases pass under race. Windows +amd64 cross-build and ARM64 focused vet pass. Every changed Go file has clear +GoLand inspections, including weak warnings. Qodana/CodeQL for `3973811` contain +zero results; full CI passes except the three Windows jobs described above. +Code review comment `5695648748` and security comment `5695706814` explicitly +report no findings on `c77d9bd`; thread `4024388304` is resolved with fix evidence. + +Round 7 (`6021a5f`) passes the ordinary Windows suite. The native capability +diagnostic returns NETISO_ERROR_TYPE_NONE for loopback, so readiness correctly +remains closed. Loopback filtering is distinct from Internet/private-network +capability checks. Replace that diagnostic with the documented +[NetworkIsolationGetAppContainerConfig](https://learn.microsoft.com/en-us/windows/win32/api/networkisolation/nf-networkisolation-networkisolationgetappcontainerconfig) +query: verify the exact empty-capability AppContainer token again and require +its SID to be absent from the explicit loopback exemption list. Query errors, +invalid results and exemptions refuse readiness. Retain the positively checked +owned listeners, negative traffic observations and all existing OS/WASI limits. +The generic capability API is removed. The unsandboxed-process regression and +both native Windows helper fixtures remain required. The UDP listener guard was +negatively verified by breaking the echo, observing setup refusal, restoring +and passing; a broken listener cannot qualify as blocked worker traffic. + +The completed `6021a5f` Intel macOS job also exposed a diagnostic cleanup race: +`TestOwnedPeerCrashAndDiagnosticsFailClosed/diagnostics` observed stdout EOF +without the expected diagnostic-overflow error. Cleanup cancelled and closed +the stderr reader before its goroutine had drained the helper's already-written +bytes. Terminate the producer first, wait for the bounded stderr drain, then +close the transport and join all work. External cancellation/deadline still +closes reads immediately. Preserve the existing overflow regression and run it +repeatedly under race; native Intel CI must pass it as well. + +Round 8 (`665dd4b`) confirms that Windows denies the loopback-configuration API +inside AppContainer. The read belongs to the trusted launcher: before creating +each helper, it must successfully query the native list and refuse the exact +helper SID if exempt. No result is cached. The child independently verifies +its exact empty-capability token and job, observes blocked traffic to checked +owned listeners, and rechecks the token before accepting a timeout. Missing +parent policy evidence prevents process creation; this grants no new child +permission. Synthetic exemption-list cases cover the exact identity, an +unrelated SID, no exemption and invalid entries without changing OS policy. +Windows also reproduces the diagnostic-drain race. Its correction passes 25 +focused race repetitions plus the client package; GoLand reports no findings. + +### Passing code checkpoint and final documentation follow-up + +At `ee5cc67`, [CI 35084746959](https://github.com/frathe/picfetch/actions/runs/35084746959) +passes validation, all four Linux race partitions, Linux/Windows amd64+arm64 +HEIC native suites, ordinary Windows and macOS Intel/Apple Silicon native +suites. Required native guards run without skips. Windows ordinary ten-bit +decoding takes 4.316 s (amd64) / 4.104 s (ARM64); the 12MP fixture takes +17.396 s / 14.740 s. The actual PowerShell manifest transformation passes on +inert fixtures; the diagnostic-overflow regression passes on both Windows +architectures and Intel macOS. Hosted evidence now supersedes cross-build-only +qualification statements, without claiming standard-user or packaged Windows +qualification. + +Code-review comment `5695988308` reports no findings on this exact checkpoint. +The confirmed security thread `4024388304` is fixed by `c77d9bd`, replied to and +resolved; later code/security reviews on `c77d9bd` and `6021a5f` are clean. +The current security review and a fresh complete review/CI round for the final +documentation-only follow-up remain mandatory before loop completion. Final +head, review comments, check conclusions and static-report provenance are +recorded in the PR discussion and `.scratch/pr28-review/` after those services +finish; this evidence capture does not change the reviewed source tree. + +The follow-up updates this plan, `todos.md`, architecture, threat model and +native qualification evidence only. HEIC stays disabled. Distribution/license +closure, real Authenticode/MSIX, nonadministrator Windows behavior, broader +camera/color qualification, the first released-updater transition and the +unsigned initial foundation's merge prerequisite remain explicit. No merge, +release, production activation or history rewrite is included in this loop. + +### Experimental activation follow-up, 2026-09-16 + +The accepted opt-in implementation is tracked in +[the new active plan](2026-09-16-experimental-heic-opt-in.md). Its default-off +startup wiring supersedes earlier statements here that production always +constructs an empty image service. This plan's earlier execution/review results +remain historical; they do not qualify the later private Windows cache or +installed application paths. Decoder source and native limits are unchanged. diff --git a/plans/2026-09-16-experimental-heic-opt-in.md b/plans/2026-09-16-experimental-heic-opt-in.md new file mode 100644 index 00000000..d1807c52 --- /dev/null +++ b/plans/2026-09-16-experimental-heic-opt-in.md @@ -0,0 +1,666 @@ +# Experimental HEIC opt-in implementation + +Date: 2026-09-16. Route: Deep SDD/TDD. Status: implementation integrated; +remaining checklist evidence, installed-MSIX qualification and an external AI +scan keep final acceptance open. + +Resuming agent: start with the [handoff](../.scratch/experimental-heic-opt-in/handoff.md) +and reconciled ticket checklists. On 2026-09-16, 48/57 items are checked and +tickets 01/02/03/04/06/07 are resolved. Later handoff notes distinguish completed native +constructor tests from compound application scenarios still needing proof. + +Deliver the accepted [spec](../.scratch/experimental-heic-opt-in/spec.md) through +the [nine approved tickets](../.scratch/experimental-heic-opt-in/issues/README.md). +Ronin invoked `/implement use SDD and TDD`; the preceding breakdown is accepted. +The prior restoration plan remains the historical helper qualification record. +Initial HEAD: `fad62e69d896171aa3dd59508fb1c4cf536e7fb5`; existing plan/todos edits +are the specification pointers prepared in this conversation. + +## Contracts and limits + +- `imaging.Reader.IsSupportedImage(fyne.URI) bool` and + `SupportedExtensions() []string` describe the reader's immutable capability. + Zero reader and package-level queries remain unchanged. +- `filescan.Option` and `WithAdmission(func(fyne.URI) bool) Option` extend + `Images`/`Siblings` with optional arguments; omitted/nil admission uses the + existing default. All viewer opens capture its foreground reader predicate. +- `preferences.State.ExperimentalHEIC` persists as `experimentalHEIC`, false by + default. Settings edits affect the saved intent only. Startup loads preferences + before constructing one shared HEIC owner; readers never change mid-session. +- Startup package discovery derives the installation from the running executable. + Package/launch failures retain intent and supply localized unavailable status. + Fixed helper identity and all existing readiness/admission/limits remain intact. +- Windows preparation publishes verified content-addressed copies in private + app storage; its exact lifetime contract is pinned before T04 code. Safe + cleanup may defer, but it cannot remove a copy in use or publish partial bytes. +- No decoder source/dependency/OS association/CLI override changes. Mac total + native-memory limitation remains accepted. Production signing, distribution + clearance and broad color qualification remain release gates. + +## Task graph and file map + +`01 -> 02 -> {03, 04, 06, 07}`; `04 -> {05, 08}`; +`{03, 05, 06, 07, 08} -> 09`. + +| Ticket | Files / modules | Test and verification | Owner / budget | +| --- | --- | --- | --- | +| 01 | imaging source, filescan, UI drop/images tests; architecture | Real admission through injected reader; T01 execution gate | Lead; 0 implementation spawns; 1 review; focused only | +| 02 | preferences, UI startup/images/settings/run/features, settingswin, translations | Actual Settings surface and saved choice across viewer lifetimes; T02 gate | Lead; 0 implementation spawns; 2 reviews; focused only | +| 03 | UI consumers, Explorer preset choices, similarity integration tests | Admitted uncached inputs reach shared readers/owner; T03 gate | Lead; 0 implementation spawns; 1 review; focused only | +| 04 | HEIC client package/preparation platform files, winisolation, startup | Verified copied launch, concurrent publishers, damaged cache, lifecycle; T04 gate | Lead; 0 implementation spawns; 2 reviews; native evidence required | +| 05 | msixstage/Store workflow, nativeguards and package activation fixtures | Installed test-MSIX under standard user on both architectures; T05 gate | Lead; 0 implementation spawns; 1 review; native evidence required | +| 06 | macOS packaged activation fixtures/nativeguards/CI | Complete native app on amd64/arm64; T06 gate | Lead; 0 implementation spawns; 1 review; native evidence required | +| 07 | Linux packaged activation fixtures/nativeguards/CI | Complete native app on amd64/arm64; T07 gate | Lead; 0 implementation spawns; 1 review; native evidence required | +| 08 | Windows native account/package fixture/nativeguards/CI | Standard-user package on amd64/arm64; T08 gate | Lead; 0 implementation spawns; 1 review; native evidence required | +| 09 | all touched code inspections, plan/todos/architecture/threat/qualification | Parent AC1–12, complete CI and fresh code/security reviews | Lead; 0 implementation spawns; final review; full suite once | + +Each ticket's exact commands and acceptance cases are in the linked execution +record. Run one behavioral red/green slice at a time, using the accepted seams. +Missing native environments and permission-query failures remain unverified +blockers. No helper-only or cross-build result substitutes for packaged runtime. +Update this file with exact new native invocation contracts before qualification. + +## Delegation + +One read-only Windows API/lifetime scout runs alongside lead-owned ticket 01. +G1: bounded current API question; G2: reported call sites checked with source; +G3: no writes; G4: three platform files rather than full feature context; +G5: lead has not traced this platform lifetime. No design, review, platform +implementation or user-visible strings are delegated. Additional scouts require +a recorded bounded question; no concurrent code ownership overlaps. + +## Evidence and cost ledger + +Resume 2026-09-16: retain Deep routing and approved seams. One read-only scout +locates existing consumer evidence and completion observables in the UI tests, +Spiral and Grid while the lead traces native activation fixtures. G1: bounded +consumer/lifetime question; G2: source-location claims checked with `rg`/reads; +G3: no writes; G4: consumer sweep is smaller than the complete feature context; +G5: lead has not traced these consumers in this resumed session. No design, +review or fixes delegated. Lead adds missing behavioral coverage one slice at +a time, negatively verifies new guards, and keeps native environment blockers +explicit. Budget: one scout, focused tests, complete suite through CI. + +Resumed slice A (lead): extend `internal/ui/images_native_test.go` through the +existing packaged constructor to prove mixed-directory HEIC/HEIF/PNG navigation, +saved disable during an admitted native request, cancellation of a retired +foreground load on collection replacement, ordinary replacement pixels, and +continued HEIC use before restart. Existing package construction and execution +commands below remain unchanged. Verify with +`go test -tags no_emoji,nodynamic,heicnative -count=1 -run '^TestNativePackagedHEICActivation$' -v ./internal/ui`; +observe a negative control before recording green. No new runtime seam or +decoder behavior is planned. Other architectures require fresh native CI. + +| Slice | Red / green / inspection evidence | Status | +| --- | --- | --- | +| Publication | Nine tickets published, ticket 01 claimed; prior spec preserved | Complete | +| 01 | Real-admission consumer red: unsupported scan never began sort; green imaging/filescan and direct/folder/sibling/session/Favorite UI cases | Ticket resolved; integrated final gate separate | +| 02 | Red: missing Experimental tab/default activation; green preference and real-checkbox separate-lifetime tests. Later red: stale open Settings; green live status update | Ticket resolved; platform/final gates separate | +| 03 | Red: active HEIC choices absent and saved rule rejected; green Explorer/source consumers and native analysis/retained queries. Resumed uncached duplicate/Spiral and active-analysis/source-replacement guards pass with negative controls | 6/6 items complete; final integrated native gate remains separate | +| 04 | Red: no staging publication; green owned bounded copy/reuse/repair/cancel/preparation-refusal cases. Windows cache/concurrent-process/lease tests pass natively on both architectures at 69fef1a | Ticket resolved for staging; installed-MSIX context separate | +| 05, 08 | Standard-user standalone guards pass on both architectures at 69fef1a. Disposable signed MSIX installs, but both COM and direct activation fail before the test process starts | Installed-MSIX qualification blocked on a suitable interactive standard-user environment | +| 06 | Both native macOS architectures pass expanded package failure, sandbox-readiness refusal, navigation/cancellation and analysis guards at c1b6890 | 6/6 items complete; production GUI smoke and release clearance remain separate | +| 07 | Both native Linux architectures pass mixed-directory, package-failure recovery, navigation/cancellation, analysis and helper/seccomp/resource guards at c1b6890 | 6/6 items complete | +| 09 | Complete Linux race partitions and validation pass at 69fef1a. Fresh code/security reviews have no findings; Qodana has zero final results, CodeQL only its two existing dismissed false positives. IDE and sixteen Settings layouts verified | Installed-MSIX and the external GitHub AI scanner remain blocked | + +Budget: one initial read-only scout; lead implementation and fixes; focused +tests during iteration; complete suite once at final gate. No qualification, +commit, push, review or release is claimed by this plan's creation. + +Second read-only scout: locate packaged-activation runners and evidence hooks +in `scripts/nativeguards/main.go`, `.github/workflows/ci.yml`, and +`.github/workflows/microsoft-store.yml`. G1–G5: bounded three-file read, +source-location oracle, no writes, independent native context, not yet traced +by lead. Lead continues restart/Explorer implementation. No design delegated. + +### Native application fixture contract + +`TestNativePackagedHEICActivation` is a `heicnative` application-package test. +The native guard runner must require it separately from the broad helper suites. +It copies the application test executable into the fixed installation layout, +runs `go run ./scripts/heicpackage -os -arch -out `, +and on macOS independently verifies the helper and ad-hoc signs/verifies the +enclosing application. The relocated executable runs the existing shared +startup/viewer harness, actual Settings checkbox, normal admission and native +helper. This is application-constructor evidence using Fyne's test driver; +rendered layout and production GUI/package qualification remain separate checks. +Windows execution additionally requires a real non-administrator token. Installed +MSIX uses a disposable signed test package and requires real package identity; +its immutable source helper bytes and ACLs are compared around activation. +No production CLI flag or activation environment variable is introduced. + +### Local verification checkpoint + +- `go test -race -tags no_emoji,nodynamic -count=1 ./internal/heicdecode/... ./internal/filescan ./internal/imaging ./internal/preferences ./internal/ui/settingswin ./internal/ui/explorer`: all pass (client 8.654 s, worker 14.357 s, imaging 24.185 s; remaining packages 1.199–5.924 s). +- `make verify-build`: pass, including vet/build, all existing notice/asset checks, + native import guard and reproduced WASI artifact. +- `make generate-heic-wasm`: refreshed only the build-tool status-message digest + in decoder.json; guest hash remains `cdaf9af71d8a8624c620a2ca8f90865a00a2d2dc228bb8bdb058564726d769ed`. +- `make check-qodana-test-exclusions`: pass. Root UI tests are assigned in ui-2. +- `make verify` and `make check-test-shards`: blocked by absent Docker daemon + socket. A direct host shard check correctly cannot qualify the Linux/amd64 + manifest (its macOS-only runnable differs); no assignment is added for that + platform-only test. Do not bypass the canonical platform requirement. +- `gh pr view`: existing PR #28 remains at initial `fad62e69`; all current work + is local and uncommitted. No fresh CI/review is claimed for these changes. +- Layout PNGs: `.scratch/experimental-heic-opt-in/evidence/layout/` (16 locale, + theme, optional Cache and Store combinations). Existing small saved geometry + retains Fyne's overflow behavior; a new default Settings window is 640x520. + +Lead review found a pre-input native-probe storage failure that was not classified +unavailable. Its owned-file regression failed first, then passed after setup +errors were classified without changing any policy or limit. Request cancellation +continues to take precedence over availability diagnostics. The lead owns all +review/fixes; the code-review skill's separate standards/spec axes are applied +inline because the repository agreement prohibits delegated reviews. + +### Final local review checkpoint + +The lead applied both standards and spec review axes and fixed the evidence gap +in successful analysis subprocess coverage. `TestNativeHEICAnalysisPixels` +uses a complete native helper package, admits the owned fixture through filescan, +and verifies pixels through the shared owner into analysis and two uncached +preview queries in one retained search subprocess. It also checks cancellation +and process joining. Removing the attached owner produced the expected failure +before restoration. No model inference or color-fidelity claim follows from it. +All three platform suites now require this guard; the registry test was observed +failing before registration and passing afterward. + +- Focused UI/Settings race regressions: pass (`internal/ui` 14.941 s, + `settingswin` 4.076 s); translation/manual guards pass. +- Native analysis/attachment/preview race regressions: pass (14.379 s). +- `GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go vet -tags no_emoji,nodynamic ./internal/...`: pass. +- Final installed Store UI and native analysis test binaries compile for Windows + amd64 and arm64; execution remains unverified. +- `make security-govulncheck`: no reachable vulnerabilities in the application; + one required-module finding outside imported packages/called symbols. Separate + WASI guest scan reports no vulnerabilities. Dependency versions are unchanged. +- Packaging/registry tests pass: nativeguards, heicpackage, plistdoctypes, + msixstage (3.987 s); Explorer preset validation exercised by UI tests. +- Final GoLand inspection covers 49 changed code/configuration files, including + weak warnings, with no timeouts. Two intentional duplicate-test-setup reports + are covered by existing exact DuplicatedCode exclusions. Machine-readable + evidence: `.scratch/experimental-heic-opt-in/evidence/goland-inspections.json`. +- Sixteen Settings renders pass. English dark and German light with Cache were + visually inspected at 640x520; all tabs and full explanatory text fit. + +A third bounded read-only scout task inventoried analysis/retained-search test +names and source-entry paths while the lead finished verification. It reused +the existing scout (one spawned agent total; three bounded tasks across recon +and final evidence phases). Its source citations exposed the positive-pixel +coverage gap; the lead designed, wrote and verified the additional tests. +No review or fix was delegated. Review budget for ticket 03 increased from one +to two rounds to close that gap; native gate rerun is justified by its new +required guard. No complete broad race suite was repeated. + +Outstanding before acceptance: native Linux and Windows on both architectures, +installed standard-user test-MSIX on both architectures, macOS Intel, complete +native Linux/amd64 suite and canonical shard check, fresh latest-commit CI, +post-suppression Qodana/CodeQL and Codex code/security reviews. Docker is stopped +locally. The branch still requires explicit authorization to commit/push under +AGENTS.md; no publication or review-loop invocation has occurred. Production GUI +smoke and release licensing/signing/camera/color gates remain separately open. + +Final native capture: `make heic-native-macos` passes all six required guards, +including signed helper/runtime, signed update/reinstall, packaged application +and successful analysis pixels. The final application test took 5.120 s and the +analysis test 8.640 s; package totals: client 77.488 s, update 4.209 s, +similarity 10.627 s, UI 5.626 s. No mandatory guard skipped; the unrelated +Store-only policy test correctly skips in this non-Store suite. Raw events are +`.scratch/heic-qualification/native-macos.json`; command output is +`.scratch/experimental-heic-opt-in/evidence/native-macos-run.log`. +Final formatting, Qodana exclusion, provenance and native import checks pass. +The import check needed ordinary Go module-cache access after a sandboxed cache +write failed; rerun with that access passed without policy/source changes. + +## PR review loop — f41fe63 + +Ronin committed/pushed the implementation as `f41fe630158baa1c65f8dc75122c332dcddd4a1d` +and resumed the requested CI/Codex loop on September 16. The working tree was +clean on entry. Fix commits, pushes, review requests/replies and dispositions +follow the repository's invoked review-loop authorization; no merge/release is +included. One read-only scout task located existing Linux CGo setup while the +lead investigated provenance. All fixes and reviews remain lead-owned. + +CI 35103830559 passes both macOS native architectures, ordinary Windows, the +Store executable build, the non-UI race partition and UI shards 1/3. The remaining +failures are addressed in this first correction, pending a fresh run: + +- Regenerate provenance after committed `SplitSeq` build-tool cleanup. The guest + artifact is unchanged and both provenance/import checks pass locally. +- Keep Linux helper/analysis guards no-CGo, but enable CGo for the separately + executed GUI inventory/fixture and install the existing X11/C build inputs. + The runner regression failed for both GUI command modes before correction. +- The restart test selected another viewer's Settings surface. An owned prior + window reproduces the exact failure; selecting only the window opened by the + tested viewer fixes it. Focused UI race tests pass (2.947 s). +- The disposable standard account passes SID/nonadministrator checks, but + known-folder lookup is denied. The fixture now imports its loaded user's + environment with CreateEnvironmentBlock before native activation, replacing + inherited privileged-runner profile values. No application fallback or + permission-query relaxation is added; native CI must qualify the correction. +- Shorten the disposable MSIX identity to fit the manifest's 50-character limit. +- Qodana's final post-suppression SARIF has one confirmed unused test function; + remove both platform variants now that OpenInstalled owns helper preparation. + Start-report warnings are not the final result set. + +Focused nativeguards/client tests pass, along with changed-file GoLand checks. +The three mixed-receiver IDE warnings in the runner are fixed. Two intentional +local test-setup duplication notices remain documented under their existing exact +Qodana exclusions; no production warning is suppressed. GoLand build reports +success but cannot collect detailed build messages in this project. + +Docker is available again and its canonical shard check passes: 691 runnables, +three shards. The daemon is Linux/aarch64; the complete native AMD64 gate correctly +refuses emulation. Full race verification stays in hosted native CI. + +## PR review loop — 085d185 + +CI 35105325751 passes validation, every Linux race partition (including the +previously failing Settings shard), ordinary Windows, the Store executable +build and both native macOS architectures. Both standard-user Windows native +event streams contain all required passes and no failed events, including the +permission query, AppContainer/job checks, concurrent staging, native application +activation and analysis. Their parent jobs report failure only because GitHub's +PowerShell wrapper propagates Robocopy's successful copy status of 1. An explicit +success exit after all checks and cleanup corrects that wrapper; exceptions still +fail before reaching it. + +Both Linux native inventories expose another CGo consumer: similarity's test +fixtures import the desktop stubs. The runner regression first failed for its +inventory and execution commands, then passed after separating its complete +suite with CGo enabled. The helper/client/worker suites retain CGO_ENABLED=0. +The focused nativeguards package passes; Linux Docker compilation and the +analysis-owner regression pass with heicnative (0.403 s). This emulated check +does not qualify native seccomp or resource controls. + +Both installed-MSIX jobs now pack, sign and install successfully for the actual +standard account, but IApplicationActivationManager returns 0x80070520 (no logon +session). Microsoft documents that a [packaged application runs as an interactive +user](https://learn.microsoft.com/en-us/windows/msix/desktop/desktop-to-uwp-debug). +The loaded alternate-user profile is not proof of an interactive Windows logon. +Installed activation remains a failed gate; no elevated/debug-token substitute +or successful skip is accepted. A fifth bounded read-only scout task checks +Microsoft's documented direct executable launch semantics while the lead owns +the fixture decision and other fixes; it reuses the existing scout and changes +no files. + +Qodana's final post-suppression SARIF at 085d185 contains zero findings. Codex's +security report for that commit says no security issues; its code review is +still running. CodeQL's previous two results are existing dismissed false +positives, revalidated against the current exact-name archive admission and +checked integer conversion; no open alerts were returned. Current-head CodeQL +results and all final-head reviews must still be inspected before acceptance. + +### Installed executable launch follow-up + +The native installed-MSIX activation failure is the red evidence for a bounded +fixture correction. A [Microsoft Terminal maintainer's explanation](https://github.com/microsoft/terminal/discussions/20060) +establishes that ordinary CreateProcess on an installed WindowsApps executable +can resolve its package identity. It does not guarantee success in our +alternate-user session. The fixture now uses that ordinary launch path, leaving +the declared test application and every runtime package identity, exact user, +nonadministrator, Store policy, installed-byte/ACL and native-helper assertion +intact. No debug activation, access-control changes or fake identity is used. +Both native architectures must pass; a launch or permission failure still blocks +qualification. The existing child-script registration guard now names the test +entry point instead of the removed local COM wrapper class. No production code +changes. + +The 085d185 Go CodeQL analysis could not be processed (HTTP 422, analysis +1786545678, rules_count 0, Unknown Error), despite its successful workflow. This +is unverified, not an empty clean analysis; a fresh run must produce a readable +result set. No repository self-hosted runners are configured. + +### Code review disposition and native progress + +Codex's code review of 085d185 reports one confirmed P2: startup permanently +captured a low file-size preference in the immutable HEIC owner. The native +application regression starts at 1 MiB and uses the owned image with a valid +free-space box to exceed that limit. Raising to 2 MiB failed with the original +1 MiB error before the fix. Startup now uses the fixed hard HEIC limits; +imaging.Reader already applies the current user limit on each new read. +Foreground and background reads pass both increase/decrease cases through the +same owner, and a 128 MiB setting still grants at most 64 MiB to input. +The signed native macOS application regression passes (6.913 s). Evidence: +`native-live-input-{red,green}.log` in the opt-in evidence directory. No new +top-level test, Qodana scope or shard entry is introduced. Changed-code GoLand +inspection is clear, including weak warnings. + +At 9ea4dde, both Linux and standard-user Windows native jobs pass, as do all +Linux race partitions, ordinary Windows and validation. Qodana's final SARIF +again has zero findings. CodeQL Go processing recovered (analysis 1786683225): +34 rules and the same two existing dismissed false positives; Actions has 17 +rules and zero results. Installed-MSIX qualification remains pending the +direct-launch fixture. The fresh code/security review must cover the final +fix commit after the P2 thread is answered and resolved. + +The final 9ea4dde CI run (35107489388) completes with every job passing except +installed-MSIX activation on amd64/arm64, both still reporting 0x80070520 before +the direct-launch correction. Focused UI race regressions for the new fix pass +(5.872 s); formatting is clean. The correction and direct-launch fixture are +staged, but the configured SSH signer refused the commit on September 16 at +14:26 UTC (`commit.gpgsign=true`, agent refused operation). Signing remains +enabled. Publication, the P2 reply/resolution and fresh final-head reviews await +the user's signing-agent unlock; no new commit or native MSIX success is claimed. + +## PR review loop — 4aca7be + +Ronin unlocked the signer and authorized retry. The signed fix commit is pushed; +the confirmed P2 thread was answered with red/green evidence and resolved. Fresh +Codex code review reports no findings on 4aca7be; security review is running. +All six standalone native OS/architecture jobs pass, as do all four Linux race +partitions, ordinary Windows, validation and Store executable construction. +Final Qodana SARIF has zero findings. CodeQL Go analysis 1786844866 processes +successfully with the same two dismissed false positives; Actions has zero +results. Raw native artifacts are retained under the opt-in evidence directory. + +Direct installed-MSIX launch still fails before the test process starts: +Access denied. The fixture had selected the protected WindowsApps package as +its current directory. One bounded follow-up starts from its already writable +owned workspace, retaining the absolute installed executable, real identity +and every other guard. This tests whether directory access is the remaining +launcher issue; it is not proof that the alternate-user session is sufficient. +Failed launch or permission checks remain blockers. + +GitHub's separate dynamic AI code-scanning job fails before analysis with a +service error: HTTP 400, "The requested model is not supported." Its log is +`ai-scanning-4aca7be.log`; this is distinct from the passing CodeQL workflow +and requested Codex reviews. No scanner setting or gate is disabled. + +## Qualification checkpoint — 69fef1a + +CI run 35111412255 is complete. Every job passes except installed-MSIX activation +on both architectures. The direct launcher also returns Access denied when +started from the owned writable workspace, so the extra working-directory +permission demand was not the blocker. The installed test process never starts; +no package-context helper or sandbox success is claimed. The next qualification +requires native x64/ARM64 environments with interactive standard-user sessions. +All identity/permission checks and the failing gates remain intact. + +Fresh Codex code review (comment 5699569800) and security review (5699579439) +report no findings on 69fef1a. Both prior review threads are resolved. Final +Qodana SARIF has zero findings. CodeQL Go analysis 1786920301 has the same two +previously assessed dismissed false positives; Actions analysis 1786878185 has +zero findings, and the open-alert query is empty. The separate dynamic AI scan +35111413724 fails before analysis with the same unsupported-model service error. + +Evidence is under `.scratch/experimental-heic-opt-in/evidence/`: native artifacts +for all six platform/architecture combinations at 4aca7be, corresponding final +CI runs, `ci-msix-{amd64,arm64}-69fef1a.log`, `qodana-69fef1a.sarif.json`, +`codeql-69fef1a-go.sarif.json`, and `ai-scanning-69fef1a.log`. No broad local race +suite was duplicated. Local Docker remains aarch64; its canonical shard inventory +passes, while full AMD64 verification stays in native CI. Production GUI smoke, +licensing/distribution, production signing and broader camera/color gates remain +separate. This plan stays active because required installed-MSIX qualification +is incomplete; the draft PR is not ready for acceptance. + +### Online investigation of alternate-user MSIX activation + +At Ronin's request, stop speculative launcher changes and examine primary +sources. Microsoft's [WindowsAppSDK issue 2555](https://github.com/microsoft/WindowsAppSDK/issues/2555) +records the same `0x80070520` during packaged-component activation as another +user inside the desktop session owner's session. The maintainer specifically +identifies the [CreateProcessWithLogonW/MSIX interaction](https://github.com/microsoft/WindowsAppSDK/issues/2555#issuecomment-1190815856) +as an OS issue. Its closed-not-planned status and later OS escalation do not +establish a fix. This strongly matches our COM failure; the exact failed OS +check behind our direct-launch Access denied remains untraced. + +The next qualification needs the standard test user to own the desktop session +and run the ordinary activation launcher from that session. Record both process +SID and desktop-session ownership before launching. A credential process with +a loaded profile does not establish this arrangement. Do not label GitHub ARM +runners categorically headless: the [runner maintainer's investigation](https://github.com/actions/runner-images/issues/14049#issuecomment-5217338266) +found an interactive desktop for the runner account. No supported workaround +for our secondary-account launch was found, and no further CI retry is justified +without changing that prerequisite. Native installed-MSIX proof remains open. + +The detailed source assessment and next qualification procedure are recorded in +`.scratch/experimental-heic-opt-in/evidence/msix-activation-research.md`. +The current hosted-only provisioner must be split from session-local execution +before offering a manual or interactive-runner path. No implementation or gate +was changed by this research. Reused the existing read-only scout for one bounded +primary-source issue search (zero new spawns); lead verified cited comments with +`gh api`, retained all assessment/design ownership and wrote the record. + +The unchanged Windows ARM64 standalone retry at `8aa317a` passes all five native +packages with no failed event records. Its original analysis-pixel failure is +retained as unexplained intermittent evidence, not reported as fixed. Fresh +Codex code/security reviews of that head are clean (comments 5699763980 and +5699899622); Qodana has zero final results and CodeQL only the two existing +dismissed false positives. Both installed-MSIX jobs and the separate unsupported- +model AI scanner remain unsuccessful. + +### Resumed consumer and packaged-application evidence — 2026-09-16 + +Lead-owned slice B extends `images_test.go` at the approved startup/admission, +Grid, Spiral shader and Explorer Analyze seams. The two new root tests are +assigned to ui-2 (253 entries). `TestExperimentalHEICPreviewConsumers` establishes +uncached duplicate previews and actual Spiral shader pixels after ordinary +admission. `TestExperimentalHEICActiveAnalysis` establishes positive HEIC pixels, +saved disable while analysis remains current, source replacement/cancellation, +late-map refusal and continued HEIC use until restart. Native inherited analysis +and retained-search proof remains in the existing native suite. + +Slice A now holds an actual foreground source read after native readiness, +changes the checkbox while that load is active, replaces its collection and +observes source closure plus retired-load completion. The fresh ordinary surface +survives, and the same owner decodes HEIC again before restart. It also opens a +mixed HEIC/HEIF/PNG directory and navigates each source. Each owned standalone +package is then relaunched with a missing helper, missing/invalid manifest, +wrong architecture or changed helper identity. All cases preserve intent and +the localized explanation and display the ordinary member of a mixed directory. +These fixture mutations never touch installed MSIX files. + +On macOS a final owned negative helper is ad-hoc signed with an empty entitlement +plist using `codesign --force --sign - --options runtime --entitlements +`. Its manifest pins those exact bytes; the enclosing app +is re-signed and checked with `codesign --verify --deep --strict --verbose=2`. +Actual readiness refusal must precede any bulk source read; the configured owner +and saved intent remain stable and ordinary PNG viewing recovers. Production +entitlements, code, dependencies and decoder bytes are unchanged. + +Meaningful negative controls (all temporary edits restored): removing Grid and +Spiral reader injection fails both preview cases; closing Explorer on the saved +choice fails the active-session assertion; discarding startup errors fails every +invalid-package case; giving the negative helper its normal sandbox entitlements +fails the readiness-refusal assertion. Logs are `evidence/resume-*-negative.log`. +These are new coverage for already implemented behavior; no production defect +or runtime fix is claimed. + +Local green: the T03 HEIC/injected-reader race gate, Explorer/preset package gate, +and client race gate pass. The client gate initially failed because the tool +sandbox prohibited its owned loopback binds; the identical unsandboxed command +passes (8.643 s), with both logs retained. The expanded real macOS arm64 packaged +fixture passes with race detection (25.637 s), including all six refusal cases. +Other native architectures and the complete suite require the fresh CI round. +Installed-MSIX/session prerequisites, Windows combined ACL/query/concurrent-app +failure evidence and the external AI scanner remain open. One read-only consumer +scout was used; all tests, negative controls, assessment and fixes stayed with +the lead. Final gates/inspection and new-head CI/review results follow below. + +Final local gates: `make verify-build` passes, including formatting, exact Qodana +exclusions, reproducible guest/provenance, native import guard, vet and build. +Its first sandboxed attempt was blocked by Go-cache writes; the unchanged +unsandboxed command passed. `make check-test-shards` passes the canonical +Linux/amd64 inventory with 693 tests. Package/native-runner/plist tests pass. +`make security-govulncheck` reports zero reachable native vulnerabilities and +no guest vulnerabilities (one module-only native advisory remains unreachable). +GoLand inspections of both changed code files are clean, including warnings; +`resume-goland-inspections.json` retains the results. New test inventory is +retained in `resume-test-inventory.log`. The full race suite remains a CI gate +under the authorized review-loop procedure. + +### Integrated checkpoint — c1b6890 + +[CI 35120395881](https://github.com/frathe/picfetch/actions/runs/35120395881) +passes validation, all four Linux race partitions, ordinary Windows, Store input +construction and all six standalone native platform/architecture targets on the +first attempt. Retained `resume-{linux,macos,windows}-{amd64,arm64}` artifacts +contain the mandatory package guards, with no skipped or failed application +scenarios. The prior unexplained ARM64 analysis failure did not recur; no fix is +claimed. Both installed-MSIX jobs still install successfully and then fail +`Process.Start` with Access denied before the test app produces evidence. + +Fresh [code review](https://github.com/frathe/picfetch/pull/28#issuecomment-5700753505) +and [security review](https://github.com/frathe/picfetch/pull/28#issuecomment-5700821584) +report no findings for c1b6890. Both earlier review threads remain resolved. +Qodana run 35120395935 has zero post-suppression SARIF results. CodeQL run +35120395948 passes: Actions analysis 1787413575 has zero results, Go analysis +1787457184 has exactly the two existing dismissed false positives (fixed runtime +archive allowlist and bounded integer conversion), validated against unchanged +source; no open alerts. GitHub AI scan 35120401536 still fails before analysis +with HTTP 400, requested model unsupported. Its failed log and both new MSIX +logs are retained as `resume-*-failure.log`; neither failure counts as success. + +48/57 ticket items are complete: 01/02/03/04/06/07 are resolved. Ticket 08 retains +combined application-level private-storage/ACL and failed-query recovery plus +concurrent application lifetime evidence; native navigation/source replacement +is now demonstrated. Ticket 05 needs the provisioner/executor split and native +x64/ARM64 desktops owned by the standard test account. No environment access was +supplied during this resume, and no such access was inferred. Ticket 09 remains +open for those Windows cases and complete required CI/external scan results. +Release, production signing and distribution/color qualification remain separate. + +The documentation follow-up records this completed implementation checkpoint; +it changes no executable or test inputs. Its fresh PR checks/reviews remain +visible on PR #28. The local handoff and evidence directory preserve current +operational state without rewriting the historical native results above. + +### Installed-MSIX CI repair — 2026-09-16 + +User asks to fix the two failing CI workers. Continue the Deep route, with the +lead owning diagnosis, implementation and review. The existing installed +`TestNativeInstalledHEICActivation` seam is the acceptance test: both native +architectures must execute it successfully from the registered package, under +the exact standard-user SID, retaining every sandbox and resource assertion. +`gh run view 35121709978 --job --log-failed` reproduces the observed red +verdict (104882311939 amd64; 104882311951 arm64); retained logs show successful +installation followed by Access denied before any test completion record. +Native CI is the only available execution environment, so its minutes-long +loop is the honest exception to the diagnosing skill's seconds-long target. + +Ranked hypotheses: desktop-owner/credential SID mismatch; direct executable +launch instead of registered activation; incomplete registration/permissions. +Qualification must not use debugging tokens, relaxed WindowsApps ACLs, +administrator execution, an extracted binary or skipped guards. + +Read-only source scout delegation: G1 yes (one bounded Windows API/source +question); G2 yes (lead fetches and checks every cited primary-source URL); +G3 yes (no writes); G4 yes (independent API/source discovery); G5 yes (new +session/token source search, not existing code review). S/W: cannot answer via +one repository search; no implementation specification delegated. Existing +scout can be reused. Lead concurrently investigates the actual launcher and +CI logs. Cost: one scout, no delegated design/review/fixes. + +First hypothesis probe changes only the CI account/session arrangement, retaining +direct installed-executable launch and all existing application guards. A new +shared PowerShell session reader checks WTS owner SID against the process user; +the child and installed test require the provisioned SID/session. Hosted MSIX +provisioning resets only the ephemeral runner account password, removes its local +memberships except Users before fresh authentication, and restores memberships +with its retained administrative token. No persistent-host execution is admitted. +Standalone qualification still uses a newly created disposable account. + +Microsoft documents token creation on authentication and its group/logon SIDs: +https://learn.microsoft.com/en-us/windows/win32/secauthz/access-tokens . A Microsoft +engineer confirms Windows 11 supports installed executable activation with +identity: https://github.com/microsoft/WindowsAppSDK/discussions/2391 . +Those primitives do not guarantee same-SID fresh-logon MSIX behavior: native CI +is the decisive experiment, not a claimed success. The existing installed test +is the real regression seam, already red on both jobs; no source-string test +is substituted for execution. + +Pre-push focused tests: nativeguards, msixstage and heicpackage pass; GoLand +reports no findings in all four changed/new code files, including warnings. +The first cross-compile attempt was blocked by sandboxed Go-cache access; +unsandboxed architecture-matched builds are used for that check. + +Both amd64 and arm64 installed-test cross-compiles pass. The complete native +CI jobs remain the required green signal for the account/session change. + + +Experiment result: **not a fix**. At `98d3895`, native CI 35125370261 passes +every job except the same two installed-MSIX workers. Jobs 104894975201 and +104894975308 fail before package construction when removing the desktop account +from Administrators: NTSTATUS 3221225764 / 0xC0000124 (STATUS_SPECIAL_ACCOUNT). +Microsoft documents that the built-in Administrator cannot be removed from +Administrators: +https://learn.microsoft.com/en-us/windows/security/identity-protection/access-control/local-accounts . +The fresh standard-token experiment therefore cannot run on either hosted image. +Retained raw logs: `evidence/fix-98d3895-{amd64,arm64}.log`. No installed test +completed, and no positive package-context result is claimed. + +The failed account/password mutation and its dependent session assertions are +removed. The previously existing fixture and all mandatory gates are restored; +no WindowsApps ACL, standard-user requirement, identity check, sandbox control or +required job is relaxed. This avoids leaving an unsupported test setup in the +branch. The user has been asked whether native x64/ARM64 standard-user desktop +environments are available; no external access or paid infrastructure is assumed. + +Local `make verify-build`, focused package tests and both Windows cross-compiles +passed for the experiment. Native CI's other thirteen ordinary jobs passed. +Actual post-suppression Qodana report has zero findings. Fresh Codex code review +5701461799 reports no findings on 98d3895; this does not change the failed CI +verdict. Only read-only source discovery was delegated; all changes, assessment +and cleanup were performed by the lead. + + +Remaining API hypothesis: the failure may depend specifically on +CreateProcessWithLogonW's token/logon handling rather than every different-SID +process in that desktop. Microsoft documents LogonUserW -> primary token -> +CreateProcessWithTokenW as a supported ordinary process launch: +https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-createprocesswithtokenw . +It does not guarantee MSIX activation. The second experiment changes only the +MSIX child launcher to that explicit authentication path; the old standalone +launcher, disposable Users-only account, installed executable launch, identity +checks and sandbox tests stay unchanged. The child's PowerShell transcript +preserves output without adding a command-shell quoting layer or inherited +administrator token. The same two native jobs are the red/green oracle. If the +same activation error remains, this API-specific hypothesis is rejected too. + +The lead wrote the CI-only helper; source scout found no confirmed supported +cross-user MSIX workaround. No secondary design/fix/review was delegated. +Temporary PowerShell tooling under /tmp is used only for syntax/C# compilation; +it adds no shipped dependency or runtime change. + +Second experiment pre-push: all three PowerShell scripts parse with the temporary +PowerShell 7.5.3 runtime; the embedded C# compiles. Focused nativeguards/msixstage/ +heicpackage tests pass, and GoLand reports no findings in the three script files. +The failed first experiment's code and its dependent Go assertions are removed. + +At `e8ea7d4`, CI 35127126778 again passes every ordinary job and fails only the +two installed-MSIX jobs. Both explicit-token processes time out after ten +minutes. Both evidence artifacts contain package setup and an empty stderr file, +but no PowerShell transcript or installed-test completion record. This does not +establish successful script startup or test execution. Raw logs and artifacts +are retained under `evidence/fix-e8ea7d4-*`. Qodana post-suppression findings: 0; +CodeQL has only the two existing dismissed results in unchanged files. Fresh +security review 5701706446 is clean. The code reviewer correctly identified a +stale ARCHITECTURE.md claim about COM activation (thread PRRT_kwDOT5ODVc6jCf-e); +the lead corrected it to direct installed WindowsApps executable launch. + +Next controlled probe uses CREATE_NO_WINDOW for the noninteractive PowerShell +process, as supported by CreateProcessWithTokenW. No app token, package identity +or helper policy changes. The loop now checks transcript creation as an explicit +startup observation with a one-minute limit, while retaining ten minutes after +startup for complete qualification. The parent also captures bounded package- +specific AppModel/deployment events separately from the test result. This narrows +future failure evidence; no lack of evidence can satisfy the gate. PowerShell +parsing, embedded C# compilation, nativeguards tests and GoLand inspections pass. + +At `dea62b9`, CI 35129438751 fails only the same two installed-MSIX jobs. Both +console-free launches now hit the one-minute missing-transcript deadline; +AppModel/deployment event queries on amd64 find no events in that interval. The +experiment still never supplies an installed test result. Thus it does not fix +the original gate, and the explicit-token launcher, transcript switch and startup +polling are removed. Standard-user creation and execution are restored exactly +to their previously tested implementation. Only bounded Windows event capture +and the corrected architecture/evidence documentation remain from this repair +attempt. No production Go, package contents, token guard or sandbox control is +changed relative to 7f5c1da. + +Final focused package tests, PowerShell parsing and GoLand script inspections +pass. The user has not provided additional native environments; the two installed +qualification gates remain unresolved. The working tree must not imply a successful +fix, and no gate is skipped or replaced by elevated/debug-token execution. diff --git a/qodana.yaml b/qodana.yaml index 7221716b..4c1ce464 100644 --- a/qodana.yaml +++ b/qodana.yaml @@ -41,6 +41,8 @@ profile: #Specify Qodana linter for analysis (Applied in CI/CD pipeline) linter: jetbrains/qodana-go:2026.2 include: + # Preserve reviewed codec variants for exact source comparison. Keep this + # generated paths list contiguous so sync-qodana-test-exclusions can update it. - name: DuplicatedCode # Shared table-test scaffolding is intentionally excluded from DuplicatedCode # because it reads better repeated than extracted; production duplication @@ -61,12 +63,27 @@ exclude: # Explicit types in fixtures are intentional test scaffolding. - "internal/imaging/orientation_test.go" - "internal/ui/mosaic_test.go" + # Preserve the exact maintained codec source; these casts are harmless. + - "third_party/h265/hevc/itransform.go" + - "third_party/h265/hevc/lossyenc.go" + - "third_party/h265/hevc/decoder.go" # The JPEG fast path handles four subsampling ratios; other ratios use the # generic scaler below. TestScaleForExport_PreservesJPEGFilterPixels covers # all six ratios and verifies exact CatmullRom output. - name: GoSwitchMissingCasesForIotaConsts paths: - "internal/imaging/thumbnail.go" + # Remaining NAL types have explicit EOS/non-VCL handling after the switch. + - "third_party/h265/hevc/decoder.go" + # *heicdecode.Failure implements error in protocol.go. Qodana's PR analysis + # fails to resolve that method; these standard **Failure targets are valid + # and covered by broker refusal/remote admission tests and go vet. + - name: GoErrorsAs + paths: + - "internal/heicdecode/client/broker.go" + - "internal/heicdecode/client/remote.go" + - "internal/heicdecode/client/remote_test.go" + - "internal/similarity/heic_test.go" # Product names (Microsoft/Store/GitHub) keep their capitalization in CLI # diagnostics; these files do not report sentence-capitalization defects. - name: GoErrorStringFormat @@ -94,11 +111,16 @@ exclude: - name: GoReservedWordUsedAsName paths: - "internal/ui/exif_test.go" + # Scoped locals in the maintained codec retain their source identities. + - "third_party/h265/hevc/itransform.go" + - "third_party/h265/hevc/lossyenc.go" # writeTempPNGFile takes ownership and closes the file on every path; # the Darwin/Windows callers remove the returned filename after use. - name: GoResourceLeak paths: - "internal/clipboard/clipboard.go" + # Preserve reviewed codec variants for exact source comparison. Keep this + # generated paths list contiguous so sync-qodana-test-exclusions can update it. - name: DuplicatedCode paths: - "internal/appearance/appearance_test.go" @@ -131,16 +153,36 @@ exclude: - "internal/hdbscan/hdbscan_test.go" - "internal/hdbscan/labels_test.go" - "internal/hdbscan/sets_test.go" + - "internal/heicdecode/client/admission_test.go" + - "internal/heicdecode/client/attachment_test.go" + - "internal/heicdecode/client/client_test.go" + - "internal/heicdecode/client/native_darwin_test.go" + - "internal/heicdecode/client/native_platform_test.go" + - "internal/heicdecode/client/package_test.go" + - "internal/heicdecode/client/installed_windows_test.go" + - "internal/heicdecode/client/peer_other_test.go" + - "internal/heicdecode/client/peer_windows_test.go" + - "internal/heicdecode/client/process_unix_test.go" + - "internal/heicdecode/client/remote_test.go" + - "internal/heicdecode/metadata_test.go" + - "internal/heicdecode/protocol_test.go" + - "internal/heicdecode/ready_test.go" + - "internal/heicdecode/request_test.go" + - "internal/heicdecode/response_test.go" + - "internal/heicdecode/winisolation/native_windows_test.go" + - "internal/heicdecode/worker/linux_filter_test.go" + - "internal/heicdecode/worker/native_linux_test.go" + - "internal/heicdecode/worker/runtime_test.go" + - "internal/imaging/avif_policy_test.go" - "internal/imaging/bytecache_test.go" - "internal/imaging/dhash_test.go" - "internal/imaging/exif_test.go" - "internal/imaging/exifformat_test.go" - "internal/imaging/exififd_test.go" - "internal/imaging/gif_test.go" + - "internal/imaging/ico_test.go" - "internal/imaging/jpegexif_test.go" - "internal/imaging/jpegseg_test.go" - - "internal/imaging/avif_policy_test.go" - - "internal/imaging/ico_test.go" - "internal/imaging/loader_test.go" - "internal/imaging/mutations_test.go" - "internal/imaging/orientation_benchmark_test.go" @@ -148,6 +190,7 @@ exclude: - "internal/imaging/preview_test.go" - "internal/imaging/raw_test.go" - "internal/imaging/save_test.go" + - "internal/imaging/source_test.go" - "internal/imaging/svg_test.go" - "internal/imaging/thumbnail_test.go" - "internal/imaging/tiffbounds_test.go" @@ -171,6 +214,8 @@ exclude: - "internal/similarity/cache_store_test.go" - "internal/similarity/cache_test.go" - "internal/similarity/grouping_test.go" + - "internal/similarity/heic_native_test.go" + - "internal/similarity/heic_test.go" - "internal/similarity/offline_test.go" - "internal/similarity/search_pipeline_test.go" - "internal/similarity/search_protocol_test.go" @@ -271,6 +316,10 @@ exclude: - "internal/ui/help/mascot_test.go" - "internal/ui/help/search_test.go" - "internal/ui/help/whatsnew_test.go" + - "internal/ui/images_test.go" + - "internal/ui/images_native_test.go" + - "internal/ui/images_native_other_test.go" + - "internal/ui/images_native_windows_test.go" - "internal/ui/imgcache_test.go" - "internal/ui/info_test.go" - "internal/ui/infoview/card_test.go" @@ -342,9 +391,11 @@ exclude: - "internal/update/await_test.go" - "internal/update/await_windows_test.go" - "internal/update/checksums_test.go" + - "internal/update/companions_test.go" - "internal/update/download_test.go" - "internal/update/extract_test.go" - "internal/update/github_test.go" + - "internal/update/package_native_darwin_test.go" - "internal/update/swap_test.go" - "internal/update/tufroot_repo_test.go" - "internal/update/tufroot_test.go" @@ -368,10 +419,14 @@ exclude: - "scripts/explorereval/search_test.go" - "scripts/explorereval/search_trial_test.go" - "scripts/explorereval/trial_test.go" + - "scripts/heicbuild/guest_test.go" + - "scripts/heicbuild/main_test.go" + - "scripts/heicpackage/main_test.go" - "scripts/msixstage/msixstage_test.go" - "scripts/nativeguards/main_test.go" - "scripts/plistdoctypes/doctypes_test.go" - "scripts/plistdoctypes/main_test.go" + - "scripts/qodanaconfig/main_test.go" - "scripts/releasenotes/main_test.go" - "scripts/releasenotes/notes_test.go" - "scripts/storepublish/main_test.go" @@ -381,3 +436,105 @@ exclude: - "scripts/updaternotices/main_test.go" - "scripts/wingettag/tag_test.go" - "scripts/wingettag/workflow_test.go" + - "third_party/h265/heic/color_amd64.go" + - "third_party/h265/heic/color_arm64.go" + - "third_party/h265/heic/color_riscv64.go" + - "third_party/h265/heic/color_row.go" + - "third_party/h265/heic/gridenc.go" + - "third_party/h265/hevc/ctu.go" + - "third_party/h265/hevc/dsp_amd64.go" + - "third_party/h265/hevc/dsp_arm64.go" + - "third_party/h265/hevc/dsp_riscv64.go" + - "third_party/h265/hevc/headers.go" + - "third_party/h265/hevc/inter.go" + - "third_party/h265/hevc/interpred.go" + - "third_party/h265/hevc/lossyenc.go" + - "third_party/h265/hevc/rdoq.go" + - "third_party/h265/hevc/residual.go" + - "third_party/h265/hevc/residualenc.go" + - "third_party/h265/hevc/sao.go" + - "third_party/h265/hevc/saoenc.go" + - "third_party/h265/hevc/slice.go" + - "third_party/h265/hevc/transformenc.go" + + # Retained decoder source: build-specific/public API declarations and Go + # assembly are preserved byte-for-byte. See third_party/h265/PICFETCH.md. + - name: GoUnusedParameter + paths: + - "third_party/h265/heic/color_amd64.go" + - "third_party/h265/heic/color_arm64.go" + - "third_party/h265/heic/color_riscv64.go" + - "third_party/h265/hevc/dsp_amd64.go" + - "third_party/h265/hevc/dsp_arm64.go" + - "third_party/h265/hevc/dsp_riscv64.go" + - name: GoUnusedExportedFunction + paths: + - "third_party/h265/heic/encode.go" + - "third_party/h265/heic/exif.go" + - "third_party/h265/heic/heic.go" + - "third_party/h265/hevc/nal.go" + # Public retained codec API; NewDecodeBudget is used by the nested module. + - "third_party/h265/hevc/budget.go" + - "third_party/h265/hevc/encoder.go" + - name: GoUnusedFunction + paths: + - "third_party/h265/heic/ycbcr.go" + - "third_party/h265/hevc/itransform.go" + - "third_party/h265/hevc/nal.go" + - "third_party/h265/hevc/residual.go" + - name: GoUnusedConst + paths: + - "third_party/h265/hevc/cabac_tables.go" + - "third_party/h265/hevc/nal.go" + - name: GoUnusedType + paths: + - "third_party/h265/hevc/mvpred.go" + - name: All + paths: + - "third_party/h265/heic/color_amd64.s" + - "third_party/h265/heic/color_arm64.s" + - "third_party/h265/heic/color_riscv64.s" + - "third_party/h265/heic/cpu_amd64.s" + - "third_party/h265/hevc/cpu_amd64.s" + - "third_party/h265/hevc/deblock_amd64.s" + - "third_party/h265/hevc/deblock_arm64.s" + - "third_party/h265/hevc/deblock_riscv64.s" + - "third_party/h265/hevc/dequant_amd64.s" + - "third_party/h265/hevc/dequant_arm64.s" + - "third_party/h265/hevc/dequant_riscv64.s" + - "third_party/h265/hevc/idct_amd64.s" + - "third_party/h265/hevc/idct_arm64.s" + - "third_party/h265/hevc/idct_riscv64.s" + - "third_party/h265/hevc/interpred_amd64.s" + - "third_party/h265/hevc/interpred_arm64.s" + - "third_party/h265/hevc/interpred_bi_amd64.s" + - "third_party/h265/hevc/interpred_bi_arm64.s" + - "third_party/h265/hevc/interpred_bi_riscv64.s" + - "third_party/h265/hevc/interpred_copy_amd64.s" + - "third_party/h265/hevc/interpred_copy_arm64.s" + - "third_party/h265/hevc/interpred_copy_riscv64.s" + - "third_party/h265/hevc/interpred_riscv64.s" + - "third_party/h265/hevc/interpred_tap_amd64.s" + - "third_party/h265/hevc/interpred_tap_arm64.s" + - "third_party/h265/hevc/interpred_tap_riscv64.s" + - "third_party/h265/hevc/interpred_tapv_amd64.s" + - "third_party/h265/hevc/interpred_tapv_arm64.s" + - "third_party/h265/hevc/interpred_tapv_riscv64.s" + - "third_party/h265/hevc/intrapred_amd64.s" + - "third_party/h265/hevc/intrapred_arm64.s" + - "third_party/h265/hevc/intrapred_riscv64.s" + - "third_party/h265/hevc/itransform_amd64.s" + - "third_party/h265/hevc/itransform_arm64.s" + - "third_party/h265/hevc/itransform_riscv64.s" + - "third_party/h265/hevc/satd_amd64.s" + - "third_party/h265/hevc/satd_arm64.s" + - "third_party/h265/hevc/satd_riscv64.s" + - "third_party/h265/hevc/sse_amd64.s" + - "third_party/h265/hevc/sse_arm64.s" + - "third_party/h265/hevc/sse_riscv64.s" + - "third_party/h265/hevc/transformenc_amd64.s" + - "third_party/h265/hevc/transformenc_arm64.s" + - "third_party/h265/hevc/transformenc_riscv64.s" + - "third_party/h265/hevc/transpose_amd64.s" + - "third_party/h265/hevc/transpose_arm64.s" + - "third_party/h265/hevc/transpose_riscv64.s" diff --git a/scripts/heicbuild/fixture.go b/scripts/heicbuild/fixture.go new file mode 100644 index 00000000..2c959856 --- /dev/null +++ b/scripts/heicbuild/fixture.go @@ -0,0 +1,78 @@ +package main + +import ( + "bytes" + "compress/gzip" + "context" + "errors" + "os" + "path/filepath" + "strings" + "time" + + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1" +) + +// generateFixture runs only the fixed-input fixture generator. It accepts no +// image path or input data. It cannot be used as an unrestricted decoder helper. +func generateFixture(root string) error { + return generateFixedFixture(root, "./fixturegen", "tenbit.heic", 128*1024*1024, 64*1024, 15*time.Second, wazero.NewRuntimeConfigInterpreter()) +} + +func generatePhotoFixture(root string) error { + return generateFixedFixture(root, "./fixturephoto", "photo-gradient.heic.gz", 1024*1024*1024, 64*1024*1024, 30*time.Second, wazero.NewRuntimeConfigCompiler()) +} + +func generateFixedFixture(root, source, target string, memory, outputBytes int64, timeout time.Duration, config wazero.RuntimeConfig) error { + dir, err := os.MkdirTemp("", "picfetch-heic-fixture-") + if err != nil { + return err + } + defer func() { _ = os.RemoveAll(dir) }() + artifact := filepath.Join(dir, "fixture.wasm") + if _, err = goCommand(root, true, "build", "-mod=readonly", "-tags=noasm", "-trimpath", "-buildvcs=false", "-ldflags=-s -w -buildid=", "-o", artifact, source); err != nil { + return err + } + wasm, err := os.ReadFile(artifact) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + runtime := wazero.NewRuntimeWithConfig(ctx, config.WithMemoryLimitPages(uint32(memory/(64*1024))).WithCloseOnContextDone(true)) + defer func() { _ = runtime.Close(context.Background()) }() + if _, err = wasi_snapshot_preview1.Instantiate(ctx, runtime); err != nil { + return err + } + output := &fixtureOutput{limit: outputBytes} + if _, err = runtime.InstantiateWithConfig(ctx, wasm, wazero.NewModuleConfig().WithStdout(output)); err != nil { + return err + } + data := output.Bytes() + if strings.HasSuffix(target, ".gz") { + var compressed bytes.Buffer + writer := gzip.NewWriter(&compressed) + if _, err = writer.Write(data); err != nil { + _ = writer.Close() + return err + } + if err = writer.Close(); err != nil { + return err + } + data = compressed.Bytes() + } + return os.WriteFile(filepath.Join(root, "scripts/heicbuild/testdata", target), data, 0644) +} + +type fixtureOutput struct { + bytes.Buffer + limit int64 +} + +func (w *fixtureOutput) Write(p []byte) (int, error) { + if int64(len(p)) > w.limit-int64(w.Len()) { + return 0, errors.New("fixed fixture exceeded output limit") + } + return w.Buffer.Write(p) +} diff --git a/scripts/heicbuild/guest_test.go b/scripts/heicbuild/guest_test.go new file mode 100644 index 00000000..5672bb8e --- /dev/null +++ b/scripts/heicbuild/guest_test.go @@ -0,0 +1,118 @@ +package main + +import ( + "bytes" + "context" + "errors" + "image" + "os" + "path/filepath" + "testing" + + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1" + + "github.com/frathe/picfetch/internal/heicdecode" +) + +// Only unchanged licensed small ordinary fixtures enter the development guest. +// This proves the guest ABI, not native helper resource or sandbox enforcement. +func TestWASIGuestOrdinaryFixtures(t *testing.T) { + wasm, err := os.ReadFile(filepath.Join("..", "..", "internal", "heicdecode", "worker", "decoder.wasm")) + if err != nil { + t.Fatal(err) + } + // Compile the fixed artifact once for ABI checks. Native helper tests + // separately cover the real cold-start deadline for every disposable job. + ctx, cancel := context.WithTimeout(context.Background(), heicdecode.DefaultLimits(0).Timeout) + defer cancel() + runtime := wazero.NewRuntimeWithConfig(ctx, wazero.NewRuntimeConfigCompiler().WithMemoryLimitPages(2048).WithCloseOnContextDone(true)) + defer func() { _ = runtime.Close(context.Background()) }() + if _, err = wasi_snapshot_preview1.Instantiate(ctx, runtime); err != nil { + t.Fatal(err) + } + compiled, err := runtime.CompileModule(ctx, wasm) + if err != nil { + t.Fatal(err) + } + defer func() { _ = compiled.Close(context.Background()) }() + for _, tt := range []struct { + name string + depth int + alpha bool + }{ + {"basic.heic", 8, false}, {"main10.heic", 8, false}, {"tenbit.heic", 16, false}, {"alpha.heic", 8, true}, + {"chroma422.heic", 0, false}, {"chroma444.heic", 0, false}, {"lossless.heic", 0, false}, {"thumb.heic", 0, false}, + } { + t.Run(tt.name, func(t *testing.T) { + input, err := os.ReadFile(filepath.Join("testdata", tt.name)) + if err != nil { + t.Fatal(err) + } + result := runGuest(t, runtime, compiled, input, heicdecode.Decode) + if result.Image == nil || result.Image.Bounds().Empty() { + t.Fatal("missing decoded image") + } + depth := 8 + if _, sixteen := result.Image.(*image.NRGBA64); sixteen { + depth = 16 + } + if tt.depth != 0 && depth != tt.depth { + t.Fatalf("pixel type = %T", result.Image) + } + if tt.alpha { + img, ok := result.Image.(*image.NRGBA) + if !ok { + t.Fatalf("alpha type = %T", result.Image) + } + left := img.NRGBAAt(0, 0).A + middle := img.NRGBAAt(img.Bounds().Dx()/2, 0).A + right := img.NRGBAAt(img.Bounds().Dx()-1, 0).A + if left > 8 || right < 240 || middle <= left || middle >= right { + t.Fatalf("alpha ramp was lost: left=%d middle=%d right=%d", left, middle, right) + } + } + config := runGuest(t, runtime, compiled, input, heicdecode.DecodeConfig) + if config.Image != nil || config.Config.Width != result.Image.Bounds().Dx() || config.Config.Height != result.Image.Bounds().Dy() { + t.Fatal("config differs from displayed dimensions") + } + _ = runGuest(t, runtime, compiled, input, heicdecode.DecodeExif) + }) + } +} + +func runGuest(t *testing.T, runtime wazero.Runtime, compiled wazero.CompiledModule, input []byte, op heicdecode.Operation) heicdecode.Response { + t.Helper() + limits := heicdecode.DefaultLimits(1024 * 1024) + ctx, cancel := context.WithTimeout(context.Background(), limits.Timeout) + defer cancel() + limits.MaxPixels = 1024 * 1024 + limits.MaxOutputBytes = 8 * 1024 * 1024 + var request bytes.Buffer + if err := heicdecode.WriteRequest(&request, heicdecode.Request{Operation: op, Input: input}, limits); err != nil { + t.Fatal(err) + } + // No preopens, environment, real clock, random source, or socket handles. + output := &boundedOutput{limit: int(limits.MaxOutputBytes) + int(limits.MaxMetadataBytes) + 4096} + config := wazero.NewModuleConfig().WithStdin(&request).WithStdout(output).WithStderr(&boundedOutput{limit: 4096}) + if _, err := runtime.InstantiateModule(ctx, compiled, config); err != nil { + t.Fatal(err) + } + result, err := heicdecode.ReadResponse(bytes.NewReader(output.Bytes()), op, limits) + if err != nil { + t.Fatal(err) + } + return result +} + +type boundedOutput struct { + bytes.Buffer + limit int +} + +func (w *boundedOutput) Write(p []byte) (int, error) { + if len(p) > w.limit-w.Len() { + return 0, errors.New("test guest exceeded output bound") + } + return w.Buffer.Write(p) +} diff --git a/scripts/heicbuild/main.go b/scripts/heicbuild/main.go new file mode 100644 index 00000000..9c357e40 --- /dev/null +++ b/scripts/heicbuild/main.go @@ -0,0 +1,379 @@ +// Command heicbuild verifies and reproduces the development-only WASI guest. +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "go/parser" + "go/token" + "io/fs" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "time" +) + +const ( + decoderModule = "github.com/gen2brain/h265" + decoderVersion = "v0.2.2" + decoderRevision = "665fd95984177afef4a7efca7d50638e4b695c7a" + decoderSum = "h1:rnpfo8I4PFhohbij8ySYZlJFn07TTwV+/uWBrKsOEas=" + decoderZipSHA = "57a197c95e25b481abcd6d20773ebfb17d859e6d864017304aff6ec0e0f19757" + compilerVersion = "go1.27.1" + guestDir = "scripts/heicguest" + guestArtifact = "internal/heicdecode/worker/decoder.wasm" + manifestPath = guestDir + "/decoder.json" +) + +type manifest struct { + Version int + Module, Revision, ModuleSum, ModuleZipSHA256, Compiler string + MaintainedRevision, MaintainedRecordSHA256 string + Files map[string]string +} + +func main() { + if len(os.Args) != 2 { + _, _ = fmt.Fprintln(os.Stderr, "usage: heicbuild build|check|imports|fixture|photo-fixture") + os.Exit(2) + } + if err := run(os.Args[1]); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(mode string) error { + root, err := os.Getwd() + if err != nil { + return err + } + if mode == "imports" { + return checkImports(root) + } + if mode != "build" && mode != "check" && mode != "fixture" && mode != "photo-fixture" { + return errors.New("unknown heicbuild mode") + } + if err = verifySource(root); err != nil { + return err + } + if mode == "fixture" { + return generateFixture(root) + } + if mode == "photo-fixture" { + return generatePhotoFixture(root) + } + files, err := buildInputs(root) + if err != nil { + return err + } + if mode == "check" { + data, err := os.ReadFile(filepath.Join(root, manifestPath)) + if err != nil { + return err + } + var m manifest + if err = json.Unmarshal(data, &m); err != nil { + return err + } + if m.Version != 2 || m.Module != decoderModule+"@"+decoderVersion || m.Revision != decoderRevision || m.ModuleSum != decoderSum || m.ModuleZipSHA256 != decoderZipSHA || m.Compiler != compilerVersion || m.MaintainedRevision != maintainedRevision || m.MaintainedRecordSHA256 != maintainedRecordSHA { + return errors.New("guest manifest disagrees with reviewed source/toolchain pins") + } + expected := append(append([]string(nil), files...), guestArtifact) + if len(m.Files) != len(expected) { + return errors.New("guest input inventory changed") + } + for _, name := range expected { + if _, ok := m.Files[name]; !ok { + return fmt.Errorf("missing guest input %s", name) + } + } + if err = checkDigests(root, m.Files); err != nil { + return err + } + } + dir, err := os.MkdirTemp("", "picfetch-heic-build-") + if err != nil { + return err + } + defer func() { _ = os.RemoveAll(dir) }() + artifact := filepath.Join(dir, "decoder.wasm") + if _, err = goCommand(root, true, "build", "-mod=readonly", "-tags=noasm", "-trimpath", "-buildvcs=false", "-ldflags=-s -w -buildid=", "-o", artifact, "."); err != nil { + return err + } + built, err := os.ReadFile(artifact) + if err != nil { + return err + } + if mode == "check" { + original, err := os.ReadFile(filepath.Join(root, guestArtifact)) + if err != nil { + return err + } + if !bytes.Equal(original, built) { + return errors.New("guest rebuild differs from checked artifact") + } + fmt.Println("HEIC development guest: source, notices, input inventory and reproducible artifact verified") + return nil + } + if err = os.WriteFile(filepath.Join(root, guestArtifact), built, 0644); err != nil { + return err + } + m := manifest{Version: 2, Module: decoderModule + "@" + decoderVersion, Revision: decoderRevision, ModuleSum: decoderSum, ModuleZipSHA256: decoderZipSHA, Compiler: compilerVersion, MaintainedRevision: maintainedRevision, MaintainedRecordSHA256: maintainedRecordSHA, Files: make(map[string]string)} + for _, name := range append(files, guestArtifact) { + sum, err := fileDigest(filepath.Join(root, name)) + if err != nil { + return err + } + m.Files[name] = sum + } + data, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err + } + if err = os.WriteFile(filepath.Join(root, manifestPath), append(data, '\n'), 0644); err != nil { + return err + } + fmt.Println("Built isolated HEIC guest and refreshed its provenance manifest; activation remains opt-in") + return nil +} + +func goCommand(root string, guest bool, args ...string) ([]byte, error) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + cmd := exec.CommandContext(ctx, "go", args...) + cmd.Dir = root + overrides := map[string]string{"GOWORK": "off", "GOTOOLCHAIN": "local", "GOEXPERIMENT": "", "GOFLAGS": "", "GOWASM": "", "GOAMD64": "v1", "GOARM64": "v8.0", "CGO_ENABLED": "0"} + if guest { + cmd.Dir = filepath.Join(root, guestDir) + overrides["GOOS"] = "wasip1" + overrides["GOARCH"] = "wasm" + } + for _, item := range os.Environ() { + key, _, _ := strings.Cut(item, "=") + if _, overridden := overrides[key]; !overridden { + cmd.Env = append(cmd.Env, item) + } + } + for key, value := range overrides { + cmd.Env = append(cmd.Env, key+"="+value) + } + output, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("go %s: %w\n%s", strings.Join(args, " "), err, output) + } + return output, nil +} + +func verifySource(root string) error { + if err := verifyMaintainedSource(root); err != nil { + return err + } + if err := verifyReplacements(root); err != nil { + return err + } + version, err := goCommand(root, true, "env", "GOVERSION") + if err != nil { + return err + } + if strings.TrimSpace(string(version)) != compilerVersion { + return fmt.Errorf("guest requires reviewed compiler %s", compilerVersion) + } + data, err := goCommand(root, true, "mod", "download", "-json", decoderModule+"@"+decoderVersion) + if err != nil { + return err + } + var module struct { + Path, Version, Sum, Zip, Dir string + Origin struct{ Hash string } + } + if err = json.Unmarshal(data, &module); err != nil { + return err + } + if module.Path != decoderModule || module.Version != decoderVersion || module.Sum != decoderSum || module.Origin.Hash != decoderRevision { + return errors.New("upstream module provenance differs from pin") + } + sum, err := fileDigest(module.Zip) + if err != nil { + return err + } + if sum != decoderZipSHA { + return errors.New("upstream module archive digest mismatch") + } + if _, err = goCommand(root, true, "mod", "verify"); err != nil { + return err + } + shipped, err := os.ReadFile(filepath.Join(root, "docs/heic/notices/h265-LICENSE")) + if err != nil { + return err + } + original, err := os.ReadFile(filepath.Join(module.Dir, "LICENSE")) + if err != nil { + return err + } + if !bytes.Equal(shipped, original) { + return errors.New("h265 notice differs from pinned upstream") + } + return nil +} + +func buildInputs(root string) ([]string, error) { + var files []string + for _, dir := range []string{"internal/heicdecode", guestDir, "docs/heic/notices", "scripts/heicbuild/testdata", "third_party/h265"} { + err := filepath.WalkDir(filepath.Join(root, dir), func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + // The native host is outside the guest's compiled dependency graph. + // Its embedded artifact is inventoried explicitly below. + if dir == "internal/heicdecode" && path != filepath.Join(root, dir) { + return filepath.SkipDir + } + return nil + } + name := filepath.ToSlash(strings.TrimPrefix(path, root+string(filepath.Separator))) + if strings.HasSuffix(name, "_test.go") || name == guestArtifact || name == manifestPath { + return nil + } + files = append(files, name) + return nil + }) + if err != nil { + return nil, err + } + } + files = append(files, "go.mod", "scripts/heicbuild/main.go", "scripts/heicbuild/fixture.go", "scripts/heicbuild/source.go") + sort.Strings(files) + return files, nil +} + +func fileDigest(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]), nil +} + +func checkDigests(root string, records map[string]string) error { + for name, want := range records { + if !filepath.IsLocal(name) { + return errors.New("nonlocal provenance path") + } + got, err := fileDigest(filepath.Join(root, name)) + if err != nil { + return err + } + if got != want { + return fmt.Errorf("digest mismatch: %s", name) + } + } + return nil +} + +func forbiddenImport(path string) bool { + for _, module := range []string{decoderModule, "github.com/gen2brain/heic", "github.com/frathe/heic"} { + if path == module || strings.HasPrefix(path, module+"/") { + return true + } + } + return false +} + +func checkNativeSources(root string) error { + return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + rel = filepath.ToSlash(rel) + if d.IsDir() { + if rel == guestDir || rel == "third_party/h265" || (rel != "." && (strings.HasPrefix(d.Name(), ".") || d.Name() == "vendor" || d.Name() == "bin" || d.Name() == "fyne-cross")) { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") { + return nil + } + file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly) + if err != nil { + return err + } + for _, item := range file.Imports { + imported, err := strconv.Unquote(item.Path.Value) + if err != nil { + return err + } + if forbiddenImport(imported) { + return fmt.Errorf("forbidden native codec import %s in %s", imported, rel) + } + if imported == "github.com/frathe/picfetch/internal/heicdecode/worker" && rel != "cmd/picfetch-heic-worker/main.go" { + return fmt.Errorf("embedded HEIC runtime is helper-only: %s", rel) + } + } + return nil + }) +} + +func checkImports(root string) error { + if err := verifyReplacements(root); err != nil { + return err + } + if err := checkNativeSources(root); err != nil { + return err + } + data, err := goCommand(root, true, "list", "-mod=readonly", "-deps", "-f", "{{if .Module}}{{.Module.Path}} {{.Module.Version}}{{end}}", ".") + if err != nil { + return err + } + for line := range strings.SplitSeq(string(data), "\n") { + line = strings.TrimSpace(line) + if line != "" && line != "github.com/frathe/picfetch v0.0.0" && line != decoderModule+" "+decoderVersion && line != "github.com/frathe/picfetch/scripts/heicguest" { + return fmt.Errorf("unreviewed guest dependency: %s", line) + } + } + data, err = goCommand(root, false, "list", "-mod=readonly", "-tags=no_emoji,nodynamic", "-deps", "./internal/heicdecode", "./internal/imaging", "./internal/similarity") + if err != nil { + return err + } + for line := range strings.SplitSeq(string(data), "\n") { + if forbiddenImport(line) { + return fmt.Errorf("native graph contains %s", line) + } + } + if err = filepath.WalkDir(filepath.Join(root, guestDir), func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !strings.HasSuffix(path, ".go") { + return nil + } + source, err := os.ReadFile(path) + if err != nil { + return err + } + if !bytes.HasPrefix(source, []byte("//go:build wasip1 && wasm\n")) { + return fmt.Errorf("guest source must remain WASI-only: %s", path) + } + return nil + }); err != nil { + return err + } + + fmt.Println("HEIC imports: codec only in reviewed WASI guest; native source and imaging/analysis graph clear") + return nil +} diff --git a/scripts/heicbuild/main_test.go b/scripts/heicbuild/main_test.go new file mode 100644 index 00000000..393f1bfe --- /dev/null +++ b/scripts/heicbuild/main_test.go @@ -0,0 +1,111 @@ +package main + +import ( + "os" + "path/filepath" + "slices" + "testing" +) + +func TestProvenanceRejectsChangedArtifactAndSources(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "source.go"), []byte("original"), 0600); err != nil { + t.Fatal(err) + } + records := map[string]string{"source.go": "0682c5f2076f099c34cfdd15a9e063849ed437a49677e6fcc5b4198c76575be5"} + if err := checkDigests(root, records); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "source.go"), []byte("changed"), 0600); err != nil { + t.Fatal(err) + } + if err := checkDigests(root, records); err == nil { + t.Fatal("changed source accepted") + } + if err := checkDigests(root, map[string]string{"missing.wasm": "00"}); err == nil { + t.Fatal("missing artifact accepted") + } +} + +func TestImportGuardRejectsNativeCodec(t *testing.T) { + root := t.TempDir() + file := filepath.Join(root, "sample.go") + if err := os.WriteFile(file, []byte("package sample\nimport _ \"github.com/gen2brain/h265/heic\"\n"), 0600); err != nil { + t.Fatal(err) + } + if err := checkNativeSources(root); err == nil { + t.Fatal("native codec accepted") + } + if err := os.WriteFile(file, []byte("package sample\nimport _ \"image/png\"\n"), 0600); err != nil { + t.Fatal(err) + } + if err := checkNativeSources(root); err != nil { + t.Fatal(err) + } +} + +func TestImportGuardKeepsGuestRuntimeOutOfViewer(t *testing.T) { + root := t.TempDir() + file := filepath.Join(root, "viewer.go") + if err := os.WriteFile(file, []byte("package sample\nimport _ \"github.com/frathe/picfetch/internal/heicdecode/worker\"\n"), 0600); err != nil { + t.Fatal(err) + } + if err := checkNativeSources(root); err == nil { + t.Fatal("viewer imported the embedded guest runtime") + } +} + +func TestMaintainedSourceIsInventoriedAndCannotBecomeNative(t *testing.T) { + root := t.TempDir() + for _, dir := range []string{"internal/heicdecode", guestDir, "docs/heic/notices", "scripts/heicbuild/testdata", "third_party/h265/heic"} { + if err := os.MkdirAll(filepath.Join(root, dir), 0700); err != nil { + t.Fatal(err) + } + } + const source = "third_party/h265/heic/source.go" + if err := os.WriteFile(filepath.Join(root, source), []byte("package heic\nimport _ \"github.com/gen2brain/h265/hevc\"\n"), 0600); err != nil { + t.Fatal(err) + } + files, err := buildInputs(root) + if err != nil || !slices.Contains(files, source) { + t.Fatalf("maintained source missing from artifact inputs: %v, %v", files, err) + } + if err = checkNativeSources(root); err != nil { + t.Fatalf("the separate decoder module is not a native application import: %v", err) + } + if err = os.WriteFile(filepath.Join(root, "native.go"), []byte("package sample\nimport _ \"github.com/gen2brain/h265/heic\"\n"), 0600); err != nil { + t.Fatal(err) + } + if err = checkNativeSources(root); err == nil { + t.Fatal("maintained source was allowed into a native application") + } +} + +func TestMaintainedReplacementCannotBeRemovedOrRedirected(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, guestDir), 0700); err != nil { + t.Fatal(err) + } + rootModule := "module example.test/app\n\ngo 1.27.1\n\nreplace github.com/gen2brain/h265 => ./third_party/h265\n" + guestModule := "module example.test/guest\n\ngo 1.27.1\n\nrequire github.com/gen2brain/h265 v0.2.2\n\nreplace github.com/gen2brain/h265 => ../../third_party/h265\n" + write := func(name, data string) { + t.Helper() + if err := os.WriteFile(filepath.Join(root, name), []byte(data), 0600); err != nil { + t.Fatal(err) + } + } + write("go.mod", rootModule) + write(guestDir+"/go.mod", guestModule) + if err := verifyReplacements(root); err != nil { + t.Fatal(err) + } + write(guestDir+"/go.mod", "module example.test/guest\n") + if err := verifyReplacements(root); err == nil { + t.Fatal("removed guest replacement accepted") + } + write(guestDir+"/go.mod", guestModule) + write("go.mod", "module example.test/app\nreplace github.com/gen2brain/h265 => ./unreviewed-source\n") + if err := verifyReplacements(root); err == nil { + t.Fatal("redirected root replacement accepted") + } +} diff --git a/scripts/heicbuild/source.go b/scripts/heicbuild/source.go new file mode 100644 index 00000000..f7f0f972 --- /dev/null +++ b/scripts/heicbuild/source.go @@ -0,0 +1,90 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + + "golang.org/x/mod/modfile" +) + +const maintainedDir = "third_party/h265" +const maintainedRecord = "PICFETCH-SOURCE.json" +const maintainedRevision = "fc127b44e1c99447b8d150256563c6d4f99b8ad3" +const maintainedRecordSHA = "583c5ab3b021675acfac0bc7344a7ff7acdc8f0ae1ab94e804d7b931e3618b9b" + +// The baseline records the exact previously maintained production copy. A +// decoder update must intentionally review this record as well as the guest. +func verifyMaintainedSource(root string) error { + dir := filepath.Join(root, maintainedDir) + path := filepath.Join(dir, maintainedRecord) + digest, err := fileDigest(path) + if err != nil { + return err + } + if digest != maintainedRecordSHA { + return errors.New("maintained decoder source record changed without review") + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + var record struct { + Files map[string]string + } + if err = json.Unmarshal(data, &record); err != nil { + return err + } + if err = checkDigests(dir, record.Files); err != nil { + return err + } + return filepath.WalkDir(dir, func(path string, entry fs.DirEntry, err error) error { + if err != nil || entry.IsDir() { + return err + } + rel, err := filepath.Rel(dir, path) + if err != nil { + return err + } + rel = filepath.ToSlash(rel) + if rel == maintainedRecord || rel == "PICFETCH.md" { + return nil + } + if _, ok := record.Files[rel]; !ok { + return fmt.Errorf("unreviewed maintained decoder file: %s", rel) + } + return nil + }) +} + +func verifyReplacements(root string) error { + for _, name := range []string{"go.mod", guestDir + "/go.mod"} { + path := filepath.Join(root, name) + data, err := os.ReadFile(path) + if err != nil { + return err + } + module, err := modfile.Parse(path, data, nil) + if err != nil { + return err + } + found := false + for _, replacement := range module.Replace { + if replacement.Old.Path != decoderModule { + continue + } + if replacement.Old.Version != "" || replacement.New.Version != "" || + filepath.Clean(filepath.Join(filepath.Dir(path), replacement.New.Path)) != filepath.Join(root, maintainedDir) { + return fmt.Errorf("%s does not select the maintained decoder source", name) + } + found = true + } + if !found { + return fmt.Errorf("%s lacks the maintained decoder replacement", name) + } + } + return nil +} diff --git a/scripts/heicbuild/testdata/README.md b/scripts/heicbuild/testdata/README.md new file mode 100644 index 00000000..6ed01a25 --- /dev/null +++ b/scripts/heicbuild/testdata/README.md @@ -0,0 +1,57 @@ +# Ordinary development fixtures + +`basic.heic`, `alpha.heic`, and `main10.heic` are unchanged files from +`github.com/gen2brain/h265` v0.2.3, commit +`b2d46ba787d8f0a2025bd106443ab1b1c7cd010f`, directory `heic/testdata`. +The upstream repository's MIT notice is preserved in +`docs/heic/notices/h265-LICENSE`; the artifact manifest hashes these exact files. +No upstream fuzz seeds, exploit reproductions, or upstream tests are copied/run. + +`main10.heic` returns NRGBA8 in this pinned decoder. Its name is not used as +proof of high-bit-depth support. `tenbit.heic` is PicFetch's own ordinary 16x16 +grayscale gradient with explicitly encoded ten-bit samples, generated by +`scripts/heicguest/fixturegen/main.go` inside WASI using `make heic-fixture`. +This deterministic generator accepts no input and is capped at 128 MiB of WASM +linear memory, 64 KiB output, and 15 seconds. It is a test-asset tool, not an +application encoder. The generator and generated fixture use PicFetch's MIT +license. Generation uses unmodified h265 v0.2.3, with its MIT notice retained. + +`photo-gradient.heic.gz` contains one PicFetch-owned ordinary 4032x3024 color +gradient, encoded as eight-bit 4:2:0 lossless HEIC by `fixturephoto` inside WASI. +Reproduce with `make heic-photo-fixture` (30 s, 1 GiB guest memory, 64 MiB output). +The gzip wrapper only reduces repository size; native qualification decompresses +it through a byte limit after admission. The 18,384,780 decoded file bytes have +SHA-256 `f3600151fccf6a016fc870a4faaf051480aa12f53bc1e9083c0c1c092a7b955f`. +This is a throughput fixture, not a claim of camera/color/HDR compatibility. +It has the same PicFetch MIT/upstream encoder notice obligations above. + +The tests cover successful byte transport, nonempty pixels, alpha retention, +NRGBA64 output for the explicit ten-bit fixture, and matching transformed +config/decode dimensions. They do not establish colorimetric fidelity, broad +camera compatibility, Exif/container-orientation precedence, production sandbox +qualification, or a whole-worker memory ceiling. Runtime memory is not measured +by the WASM linear-memory cap. + +## Recovered ordinary fixtures + +The following unchanged positive fixtures were recovered from +fc127b44e1c99447b8d150256563c6d4f99b8ad3 under +third_party/h265/heic/testdata and compared byte for byte with upstream h265 +v0.2.2, commit 665fd95984177afef4a7efca7d50638e4b695c7a. They cover ordinary +4:2:2, 4:4:4, lossless and embedded-thumbnail containers. The repository MIT +license and both copyright notices are retained in the existing h265 notice; +no additional original-media provenance is asserted. + +| File | Bytes | SHA-256 | +| --- | ---: | --- | +| chroma422.heic | 22466 | 66ab3939ee65e456c5dd251c11c3bbc06988a97b3204fe07783a3099c5feac03 | +| chroma444.heic | 21527 | 922d8b5fbc5016b03c23fb1cbb8a481055657134c05197b264c56a2d124b5dca | +| lossless.heic | 37654 | ed6be32433f7faa64fea894ecf8119fbdffbdc91420d7d08933812577ace594a | +| thumb.heic | 20337 | 45c4dd0be12e0dc796ec057ba783ac8e6dfac04351a3b93279a430d9c0017531 | + +All eight small fixtures exercise Decode, DecodeConfig and DecodeExif through +the bounded WASI guest. Configuration must agree with displayed dimensions; +the alpha fixture must retain its endpoint and midpoint ramp. This restores +useful positive coverage without asserting pixel equivalence or importing the +historical full codec tests/benchmark/encoder/reproducer suites. Existing +libde265 YUV-plane digests are not RGBA-protocol pixel references. diff --git a/scripts/heicbuild/testdata/alpha.heic b/scripts/heicbuild/testdata/alpha.heic new file mode 100644 index 00000000..051ff032 Binary files /dev/null and b/scripts/heicbuild/testdata/alpha.heic differ diff --git a/scripts/heicbuild/testdata/basic.heic b/scripts/heicbuild/testdata/basic.heic new file mode 100644 index 00000000..8c192524 Binary files /dev/null and b/scripts/heicbuild/testdata/basic.heic differ diff --git a/scripts/heicbuild/testdata/chroma422.heic b/scripts/heicbuild/testdata/chroma422.heic new file mode 100644 index 00000000..ebfa58ac Binary files /dev/null and b/scripts/heicbuild/testdata/chroma422.heic differ diff --git a/scripts/heicbuild/testdata/chroma444.heic b/scripts/heicbuild/testdata/chroma444.heic new file mode 100644 index 00000000..d08b5fb8 Binary files /dev/null and b/scripts/heicbuild/testdata/chroma444.heic differ diff --git a/scripts/heicbuild/testdata/lossless.heic b/scripts/heicbuild/testdata/lossless.heic new file mode 100644 index 00000000..39465196 Binary files /dev/null and b/scripts/heicbuild/testdata/lossless.heic differ diff --git a/scripts/heicbuild/testdata/main10.heic b/scripts/heicbuild/testdata/main10.heic new file mode 100644 index 00000000..8c192524 Binary files /dev/null and b/scripts/heicbuild/testdata/main10.heic differ diff --git a/scripts/heicbuild/testdata/photo-gradient.heic.gz b/scripts/heicbuild/testdata/photo-gradient.heic.gz new file mode 100644 index 00000000..e5bb9927 Binary files /dev/null and b/scripts/heicbuild/testdata/photo-gradient.heic.gz differ diff --git a/scripts/heicbuild/testdata/tenbit.heic b/scripts/heicbuild/testdata/tenbit.heic new file mode 100644 index 00000000..2cc11e61 Binary files /dev/null and b/scripts/heicbuild/testdata/tenbit.heic differ diff --git a/scripts/heicbuild/testdata/thumb.heic b/scripts/heicbuild/testdata/thumb.heic new file mode 100644 index 00000000..ab28ecd2 Binary files /dev/null and b/scripts/heicbuild/testdata/thumb.heic differ diff --git a/scripts/heicguest/decoder.json b/scripts/heicguest/decoder.json new file mode 100644 index 00000000..bc9ef658 --- /dev/null +++ b/scripts/heicguest/decoder.json @@ -0,0 +1,150 @@ +{ + "Version": 2, + "Module": "github.com/gen2brain/h265@v0.2.2", + "Revision": "665fd95984177afef4a7efca7d50638e4b695c7a", + "ModuleSum": "h1:rnpfo8I4PFhohbij8ySYZlJFn07TTwV+/uWBrKsOEas=", + "ModuleZipSHA256": "57a197c95e25b481abcd6d20773ebfb17d859e6d864017304aff6ec0e0f19757", + "Compiler": "go1.27.1", + "MaintainedRevision": "fc127b44e1c99447b8d150256563c6d4f99b8ad3", + "MaintainedRecordSHA256": "583c5ab3b021675acfac0bc7344a7ff7acdc8f0ae1ab94e804d7b931e3618b9b", + "Files": { + "docs/heic/notices/Go-LICENSE": "911f8f5782931320f5b8d1160a76365b83aea6447ee6c04fa6d5591467db9dad", + "docs/heic/notices/Go-PATENTS": "96f408bfae65bf137fc2525d3ecb030271c50c1e90799f87abf8846d8dd505cc", + "docs/heic/notices/h265-LICENSE": "a53831ee9730340d31a911148e69fdd15cc7d1c408b06f3a923b170c4578634d", + "docs/heic/notices/wazero-LICENSE": "c46f033d017a5af71a1de0105ec56c41bd47f81a0bbdf779fffe316336dc7c1f", + "docs/heic/notices/wazero-NOTICE": "e6f0c5c151a3ff1eba8f5930a31ec084ca6d7adda43c8675ab81f7d36af50b4f", + "go.mod": "d53b9545520c72fb5a46d2005ad8eb8a4ab9bb4f3817fd8f744109c8d1c8a9c2", + "internal/heicdecode/limits.go": "e0a31ce5704a052bef8936243bca311d1cb3a00697dd5cd1a53e718b8458c1da", + "internal/heicdecode/metadata.go": "ee88ad5b55708636fc5b3470737cb68feb6822e0acf2bf6773c023ea1818656b", + "internal/heicdecode/protocol.go": "a656424ab09ef1c6b2e1e52fa0fe72b611960f5a10717fc17a37a9c2b77c2d2a", + "internal/heicdecode/ready.go": "423ff2a78d9fd86507b7e09af180e0bc1d5740406850f2bc880d31675f3b4329", + "internal/heicdecode/request.go": "39bd8fe6af5e228681e0c819e82159c3ecb1c5109ae56513fe9b5faa6ff75ed3", + "internal/heicdecode/worker/decoder.wasm": "cdaf9af71d8a8624c620a2ca8f90865a00a2d2dc228bb8bdb058564726d769ed", + "scripts/heicbuild/fixture.go": "dd205a231c22f464ff86a5d922e1ee95bdca03f1e4b1ac864439cf4fdea9b512", + "scripts/heicbuild/main.go": "ef335b3738f657b1b88012c0eae85477a48150667e6fe1a229d28adc3a389ecf", + "scripts/heicbuild/source.go": "fa21510be758ac3a4634e0db028f0920747c9efb05bc903c2c4429bed0d46857", + "scripts/heicbuild/testdata/README.md": "bad7e52474096919ae14e0d82cddb5bc73b79434473f9402c1c82ada91ae19f5", + "scripts/heicbuild/testdata/alpha.heic": "130c9f6c9a9bdefe2fc4b17427ff37b78423f2d3a935f82cfe55a9782f6d0f51", + "scripts/heicbuild/testdata/basic.heic": "b3e3ee1e64f7ae42948bd9f83465c9ea110116a662d603d677d3ff262b8e4cfe", + "scripts/heicbuild/testdata/chroma422.heic": "66ab3939ee65e456c5dd251c11c3bbc06988a97b3204fe07783a3099c5feac03", + "scripts/heicbuild/testdata/chroma444.heic": "922d8b5fbc5016b03c23fb1cbb8a481055657134c05197b264c56a2d124b5dca", + "scripts/heicbuild/testdata/lossless.heic": "ed6be32433f7faa64fea894ecf8119fbdffbdc91420d7d08933812577ace594a", + "scripts/heicbuild/testdata/main10.heic": "b3e3ee1e64f7ae42948bd9f83465c9ea110116a662d603d677d3ff262b8e4cfe", + "scripts/heicbuild/testdata/photo-gradient.heic.gz": "231ad09fcb41297c9c1facf29cbd2249b3f9e945dda6b71cd81bdb18e226c183", + "scripts/heicbuild/testdata/tenbit.heic": "e8026ee08b531a212a29250b66f663e36fc3fb953ce7d7c5d7c9ed048e499eba", + "scripts/heicbuild/testdata/thumb.heic": "45c4dd0be12e0dc796ec057ba783ac8e6dfac04351a3b93279a430d9c0017531", + "scripts/heicguest/fixturegen/main.go": "b5bc075560ca280ff6b37b50cc48d4758e03c726c0c3235fc3accb28f402701b", + "scripts/heicguest/fixturephoto/main.go": "798bfc8742bb2a16fb69d4a31d70e71960c702123108aaf0f47c3b1d2509f474", + "scripts/heicguest/go.mod": "d14233c7c06337b99f56f934d76a99a8ac94086b13cc2176ce3fa9920f7ff1a5", + "scripts/heicguest/main.go": "30c24f8f4cd92e1b6f06a1601d42e7e6702f7b53551ebfbbb0a744ea437f6c12", + "third_party/h265/LICENSE": "a53831ee9730340d31a911148e69fdd15cc7d1c408b06f3a923b170c4578634d", + "third_party/h265/PICFETCH-SOURCE.json": "583c5ab3b021675acfac0bc7344a7ff7acdc8f0ae1ab94e804d7b931e3618b9b", + "third_party/h265/PICFETCH.md": "29888c61df88b49f10c38c25a843d23270a3d93e7a05aacb2e8d496bda24a5b0", + "third_party/h265/README.md": "d10ee10f3d98e3fd1808d37b26aa770d94689d2731794d06a3ea714152554838", + "third_party/h265/go.mod": "ab1c01f4919d1116d207771b0274e985c76ef0c1380ee4e0c0e3d4495a186942", + "third_party/h265/heic/color.go": "6eb86c0968f624d7081ee38cce7bfa301dfea2d362b63bf207dab183babf4beb", + "third_party/h265/heic/color_amd64.go": "c056bb073e4a11e28a6a719375b9c12483b028ecef5e92eb2ac4501bae1b8c4d", + "third_party/h265/heic/color_amd64.s": "801dfb6905769c6fcdc4c179eb89a2c5b806efd5822ec6b78554dcdb0b2ab946", + "third_party/h265/heic/color_arm64.go": "459e3af6924ca246c1e909638d4212b77b19c91b4ae9fc70c91f4548faf16c1f", + "third_party/h265/heic/color_arm64.s": "3cc39098aa91591db0a2dc0add555296f57905b6d5aa46504549483e49ec95ca", + "third_party/h265/heic/color_noasm.go": "8658b79899068b92b12cb62c3a1b358712382a6ecd0706f5f874e24b674bdac0", + "third_party/h265/heic/color_riscv64.go": "2eaec7518c8b970b42b7ea63015b2e03b4f042bbb569d16db186051004144113", + "third_party/h265/heic/color_riscv64.s": "ec3aca76667078e3d56d6cf703a01491bd3d343e385ca6fb6735e87f7d9f2eb3", + "third_party/h265/heic/color_row.go": "d2467b8d79b3c7ea35c990cf82a0e7c88a04d932a317b4c7f712511dec5088a5", + "third_party/h265/heic/cpu_amd64.go": "7ac53bab8a9fedbbd5d08020703b37a6c967feb10a2cee651c780961621f4414", + "third_party/h265/heic/cpu_amd64.s": "462ed6010f30c535a8f2c1f5682aff46f1a9fc13ca6ff7303527135458e30165", + "third_party/h265/heic/encode.go": "08ab928a36dd612135155f8ccf8bf941dc6df1cb56adafa05467329db50fccfd", + "third_party/h265/heic/exif.go": "5c863616f6ea4bc6d830d92682ef5325bf13f7c8df2520ba26d46079f8434759", + "third_party/h265/heic/grid.go": "d4c91e57122a53d9fd6c93cc02d207830ca9060a74b86e6919336e16f6f7fcc9", + "third_party/h265/heic/gridenc.go": "87613918826f80509083abf6c94a2f89acaae13b23014dc3bc640fdb5cce549b", + "third_party/h265/heic/heic.go": "2d6e508e2e28192d48192d92b124a7b1446dab23d0a141d91cc6c6dd4a26e46b", + "third_party/h265/heic/image.go": "88788bdb63d755c99d557eb99c782f804850ac926b0ee05f40cecd965d216624", + "third_party/h265/heic/isobmff.go": "187e638e5d2da55051ade4d1faa36639bef102fd80c1bc6109d57f38adf0e9a8", + "third_party/h265/heic/limits.go": "ff336caa0c194fcc81e2ea5cf581ce30eda9a6e038ec9bbe98ba5c2d15831dc2", + "third_party/h265/heic/sequence.go": "8c5c9b8e042cdd254cc52cd25438632fd77ec4b507526d42bdb474f64159c8fa", + "third_party/h265/heic/transform.go": "11c75e77a33a21f891e9d5e1401c9cfb3ddbdcbbcb0e96745a9ff4519f841423", + "third_party/h265/heic/ycbcr.go": "e190dffd37342e4375fba5de7d6d3a4a5bfaab228cd953978a3cb922f688d4d4", + "third_party/h265/hevc/budget.go": "62ed954c0300d8a848d262e1a9dc33514b6058613caf22e8662e591c7ef0d735", + "third_party/h265/hevc/cabac.go": "4b3426446d9e2d5636fd3137b790fa2641899f68e8c8c8b15857efb7998bfbcd", + "third_party/h265/hevc/cabac_tables.go": "39ae240713d741df33bdac8551ac7b463351d459069cd07feafda88058e5472a", + "third_party/h265/hevc/cabacenc.go": "6f010e27c000afe826b2155f424af303db05d2179b9b3cefb2f99c44b1ff96d5", + "third_party/h265/hevc/cpu_amd64.s": "ce04f6cb753589d1b49c52d8e767cfcee707c6f57e42e0b3f80a7b370f60e619", + "third_party/h265/hevc/ctu.go": "fa320bc14f90828450d0b3cf42bd4146811e507964a707dd6e4411502f7bbc00", + "third_party/h265/hevc/deblock.go": "36f66c96e38e26b1f0b0885c68b026cb67350600ae3056d6b082d20975cabf87", + "third_party/h265/hevc/deblock_amd64.s": "086a0a4ecb4f1aacabdd6896bd497027724867a74c3963b954a256fcb90e4ae3", + "third_party/h265/hevc/deblock_arm64.s": "f0fc2c9566139df256c80554cdb4497513f10357168720e7d185dc3bcdeeef26", + "third_party/h265/hevc/deblock_riscv64.s": "2871547768141f10d9129f006df25158efc696899b89830e43757f71241b3ee3", + "third_party/h265/hevc/deblockenc.go": "b2c9996e3d527124859f3238663d1ee4a3d298e83c336f66acc2876dde3ebe51", + "third_party/h265/hevc/decoder.go": "98c2d0d6348babc6068f6815e6a94802731a9d105ad19628c14b9d729eb27030", + "third_party/h265/hevc/dequant_amd64.s": "6ed52f89420aa8fb1fdc3a9da8473f381716a2431beceddeb41e38775f123d4a", + "third_party/h265/hevc/dequant_arm64.s": "8162781c26dcac679d0af9db257c3915d8d7f83849b1e47c9269a2bf62da68a8", + "third_party/h265/hevc/dequant_riscv64.s": "ea86abff13f9b065be2591a5bb2fcf2b5ccde4a9f63992e085615c65295cf904", + "third_party/h265/hevc/dpb.go": "36aa82767d7e3c73474de3c77a0f6756ad83ddb78a2666b2176e3ec972be18c1", + "third_party/h265/hevc/dsp.go": "07b1155187c9ce66130902f2c58e3c0d5ab7a30fba7d572006cb86b263c84eab", + "third_party/h265/hevc/dsp_amd64.go": "6be0f3d7698f8ef58403285c03feeb46e1d3ce218e6660d083297c184aaeba69", + "third_party/h265/hevc/dsp_arm64.go": "d6dd5a569163543bf5af811ea6c47f78a609d1cd3512e545e19aebeb6655eabd", + "third_party/h265/hevc/dsp_noasm.go": "33369a09b678286c8b0fdb996d4a8f89165aeaaf95222fea37123894ddeaf6d4", + "third_party/h265/hevc/dsp_riscv64.go": "7530087bd0466d79a010c17e96dbdc8272502a2a90bb9b00365df7e179ebd4a5", + "third_party/h265/hevc/encoder.go": "71d15aa3e9f3bb7f1909e859a259e2abdaac6ef242a5ec60462e72b3716c4751", + "third_party/h265/hevc/getbits.go": "508e223966df9a8d2e6bf9c666f6f85979397f7e17a8d01c2579f35e0b33e6ab", + "third_party/h265/hevc/headers.go": "44bf44cdf4596e6fe855b14e0a323f71d6f91a5f58b3873dc81469ae4b359cc5", + "third_party/h265/hevc/headersenc.go": "951fdab46b602e620008d5018fe228239808baa1997158e9765cdc9f8eebe0fa", + "third_party/h265/hevc/idct_amd64.s": "d69467d1fdbab834d69e51f76adc213c1c1a4b15e2f4d810e150a89902f2c3ef", + "third_party/h265/hevc/idct_arm64.s": "46d9bffa1d0aa85a7ad97b64fcb4ee63169b171074545e126e689b11e88d4956", + "third_party/h265/hevc/idct_riscv64.s": "4392199c1793f68c4712b59e07a1bb399af0e2e4d1b41571e4c53f826fb3e483", + "third_party/h265/hevc/inter.go": "88f2ed37174ebda06461c8f7b1b94b5b36a6dca6697ac83b4c3eff33e6a715cc", + "third_party/h265/hevc/interpred.go": "f603090da8dd4658a219000a6c7e3b0dc1ed739e6e49bf4270ec449f0133dd22", + "third_party/h265/hevc/interpred_amd64.s": "afadb8be1fe68e899bade0c0ba97dfd637ce399f7c2c91d44a17e0858b6126b2", + "third_party/h265/hevc/interpred_arm64.s": "2c5ca0ad46f332d85e822df2b20e61a2f31674146f1709b25235354b2cde7ca3", + "third_party/h265/hevc/interpred_bi_amd64.s": "669cf7e97dba239a16c3f6c64d6754c2fefece6fffc47ae26528bc78f4cd4902", + "third_party/h265/hevc/interpred_bi_arm64.s": "6de5ef4910e026aa31e8a5c0413510084b0f4e28798500127e728153c533badd", + "third_party/h265/hevc/interpred_bi_riscv64.s": "63b5b4eddb4e049d6894387c9b9d058714e236753281060d7bbf36b60741c4c1", + "third_party/h265/hevc/interpred_copy_amd64.s": "d10091b5770abbe1ab20227e4bc0b7a6f932a5c3877e11173f94afcc4f335c5f", + "third_party/h265/hevc/interpred_copy_arm64.s": "182b3c0f54a312cda71e98023f0e3c3cc6abcc1f249b536905801836338394eb", + "third_party/h265/hevc/interpred_copy_riscv64.s": "fc3fae4f3005a81654f6e28c5cf718c3ca78249544e2f72b75e3c5fb2a43ec9f", + "third_party/h265/hevc/interpred_riscv64.s": "f2a475c83a145b3e79a2c09733b5e6ec58439f74da3f8c7bd1da6c0557eb1771", + "third_party/h265/hevc/interpred_tap_amd64.s": "376d23dc8bf277835a99a25e789a3e71367a8f80c290ac28083f75da4f941529", + "third_party/h265/hevc/interpred_tap_arm64.s": "22f9e53b17bc3a0ab34be673d2e4fde17ac0c45ccfb61695ed830adbbfe2e47a", + "third_party/h265/hevc/interpred_tap_riscv64.s": "10773290647302a93e0cfc52f95750be2f5a71686a8cfedb77d5c388b5861de7", + "third_party/h265/hevc/interpred_tapv_amd64.s": "e5af8c2938d579a7c57462ef1175cf270eae57fcdcd5cf47c460c4fdd0ea693c", + "third_party/h265/hevc/interpred_tapv_arm64.s": "4d9811345444f8021a1b5c8059904cb3c42f37180261cc76fdaeb8e2272baa59", + "third_party/h265/hevc/interpred_tapv_riscv64.s": "40e06ac4933e48e77afebc8ca8a7a4c11c05f7c4fcb002711513824cc66eda2e", + "third_party/h265/hevc/intrapred.go": "33f0f0020b4651b827ffcc40b478e90b78368235fbbf901ddce5cb28a3285a32", + "third_party/h265/hevc/intrapred_amd64.s": "e2d37602d999ac6a7ce5184ee89ba823ee603d678a8883aac2377e82f0a85fc5", + "third_party/h265/hevc/intrapred_arm64.s": "5f0d4acf19e04bf7be7a7bafc12a3b3ff33aeeb0dfd6577187a58870b5ed1779", + "third_party/h265/hevc/intrapred_riscv64.s": "d102ea1c92eb40a184487a9d06c4c78bd811d04b47b46007e73b16caa1534c00", + "third_party/h265/hevc/itransform.go": "1503a2c78f000c6ecb69855263af5bfb55ff33050883d861e107dfce1707f2a5", + "third_party/h265/hevc/itransform_amd64.s": "5b5c72fec386e0daa8cca3d435d2c0fddc6465d3935ff39dc2b4311cb7b8bef9", + "third_party/h265/hevc/itransform_arm64.s": "89aa40a067a4bbf840523cb49cde23eedd65bdd4b0ba874e05d1b0d2fb24f5c8", + "third_party/h265/hevc/itransform_riscv64.s": "4984e77173ed04f5143027bc4f0aa224bd6457ae6ef279b6077cdb8f1a51e1ac", + "third_party/h265/hevc/lossyenc.go": "01db7098badb86405765f7111692d755c6a2893ee3713ba083067e19c635d22f", + "third_party/h265/hevc/mvpred.go": "e17744d8abbaa3971eed072e699f16b8a3740770c66127e305096e7638eb88ea", + "third_party/h265/hevc/nal.go": "997dfa297fab9abac82dab15225b9a2cbaa2c8965097f7d43a6da74aa4c736c3", + "third_party/h265/hevc/pcmenc.go": "9889f9843316916aa288965c6f9846ef8ea00224e9d44e7dea1c56bbb6aa5276", + "third_party/h265/hevc/picture.go": "50256e63278cf4805db1faa23c252fb0ba596ca034f09b29dff2be3ddcbeb087", + "third_party/h265/hevc/putbits.go": "0e825ddc335b656da8373d61f593142578732fb6fa6ad64cca2e08188c39d72d", + "third_party/h265/hevc/rdoq.go": "4ed95b2b3113861ce3b5471707c33d8a38dfafc1ef8ddd42ec45f1f46b3feb7c", + "third_party/h265/hevc/residual.go": "7d73bcbb65b0f6a2749828637f776ee6415918f28762544f1b8dcc933911119b", + "third_party/h265/hevc/residualenc.go": "ada98252c054278baffa2e51d78e0f629dfff9ad294e9c36015bab19c73eab82", + "third_party/h265/hevc/sao.go": "d16a669a2968d4d9105a188a3a4762d111a0ff16fde91927a1ce5d257932ef7d", + "third_party/h265/hevc/saoenc.go": "7659e919ba66ac8c2cfb0c16f367f5d99b23acb1237262d35dcbcdc7c21f16f9", + "third_party/h265/hevc/satd_amd64.s": "ff488aad240ba382fa8ca72528c24f0626e12c649951e27324421a28fa43e96a", + "third_party/h265/hevc/satd_arm64.s": "d904d792f8de0e8fc95ca6ceabf7415bfc523b734fb940dd8bd78378f769a1f4", + "third_party/h265/hevc/satd_riscv64.s": "82f815d2fe0e815b54cc252eac5435c8d0bd8a8b7ea26aed56fe301c2a564d44", + "third_party/h265/hevc/scaling.go": "8646fd156e943971ceb49489d648d1e3dc972ec80ae5fb7466ece230f17285a5", + "third_party/h265/hevc/scan.go": "68dccfd7329a661117e8d4578d04af287435b639c87b19a6d7528ab3ac98f8e4", + "third_party/h265/hevc/slice.go": "d419998030613086d5cffaf86d073299923afd8a20f3c8c2c9771300f8951918", + "third_party/h265/hevc/sse_amd64.s": "c6347b34ebb84b36a3c676d39601478ee2ee7f47246dadd5728e0dc3af572494", + "third_party/h265/hevc/sse_arm64.s": "bc7c6d7b96641eef5f30efcdb00ace9f0ef79f822412fb7e3008d9eca03313b0", + "third_party/h265/hevc/sse_riscv64.s": "c4d874c26577042141c987b473b78090a1040a1afbe95daf3dd9b344e0cc788b", + "third_party/h265/hevc/transformenc.go": "5b6271131527ca3eb141dd26d3f77ed0005252c938ca591c5a5dcfa0d9300d6a", + "third_party/h265/hevc/transformenc_amd64.s": "46c031c6dd354625a43c49fa524f317a15af42e9c3a367ff1cf360dbf18d2e81", + "third_party/h265/hevc/transformenc_arm64.s": "4d3a14051b93e11dbf2646e58fc1d89fe9695f30c0cd6d4d081752c69a005f17", + "third_party/h265/hevc/transformenc_riscv64.s": "65a41ccf3da20f659a1b995fbe79713585cc54f0b61a63819c9e2bba6c0ee9bb", + "third_party/h265/hevc/transpose_amd64.s": "e7d482947d2a3b9fc7f06d3b58c111e19d2558524022f7dc68dbf2bd84d5cf9a", + "third_party/h265/hevc/transpose_arm64.s": "d37ab80cf794befb44f2077e8405cf925debafde2b15193585c7658564ba1aff", + "third_party/h265/hevc/transpose_riscv64.s": "d0ce8035aa654f15527e539d93b15948562e0a8bc962ed9e6e42229dad2c7aef", + "third_party/h265/hevc/wavefront.go": "ef89091a2ec5f99e5253ac8ecb10950faf9385f34a8766877b0e8ef032f22585", + "third_party/h265/hevc/wavefrontenc.go": "fd3faf659474175b00c8989ebbbc81dd3579084edb3e1c599360d98f9217a029" + } +} diff --git a/scripts/heicguest/fixturegen/main.go b/scripts/heicguest/fixturegen/main.go new file mode 100644 index 00000000..50d95309 --- /dev/null +++ b/scripts/heicguest/fixturegen/main.go @@ -0,0 +1,26 @@ +//go:build wasip1 && wasm + +// fixturegen writes one ordinary PicFetch-owned 16x16 gradient with explicit +// ten-bit samples. It is a development tool, not application encoding support. +package main + +import ( + "image" + "image/color" + "os" + + "github.com/gen2brain/h265/heic" +) + +func main() { + img := image.NewNRGBA64(image.Rect(0, 0, 16, 16)) + for y := 0; y < 16; y++ { + for x := 0; x < 16; x++ { + gray := uint16((x + y) * 2000) + img.SetNRGBA64(x, y, color.NRGBA64{R: gray, G: gray, B: gray, A: 65535}) + } + } + if err := heic.Encode(os.Stdout, img, heic.EncodeOptions{BitDepth: 10, Chroma: heic.Chroma444, Lossless: true}); err != nil { + os.Exit(1) + } +} diff --git a/scripts/heicguest/fixturephoto/main.go b/scripts/heicguest/fixturephoto/main.go new file mode 100644 index 00000000..58032899 --- /dev/null +++ b/scripts/heicguest/fixturephoto/main.go @@ -0,0 +1,29 @@ +//go:build wasip1 && wasm + +// fixturephoto writes one ordinary 12-megapixel color gradient for bounded +// decoder throughput qualification. No input, fuzzing, or malformed data. +package main + +import ( + "image" + "os" + + "github.com/gen2brain/h265/heic" +) + +func main() { + const width, height = 4032, 3024 + img := image.NewNRGBA(image.Rect(0, 0, width, height)) + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + offset := y*img.Stride + x*4 + img.Pix[offset] = byte(x * 255 / (width - 1)) + img.Pix[offset+1] = byte(y * 255 / (height - 1)) + img.Pix[offset+2] = 128 + img.Pix[offset+3] = 255 + } + } + if err := heic.Encode(os.Stdout, img, heic.EncodeOptions{BitDepth: 8, Chroma: heic.Chroma420, Lossless: true}); err != nil { + os.Exit(1) + } +} diff --git a/scripts/heicguest/go.mod b/scripts/heicguest/go.mod new file mode 100644 index 00000000..46165019 --- /dev/null +++ b/scripts/heicguest/go.mod @@ -0,0 +1,12 @@ +module github.com/frathe/picfetch/scripts/heicguest + +go 1.27.1 + +require ( + github.com/frathe/picfetch v0.0.0 + github.com/gen2brain/h265 v0.2.2 +) + +replace github.com/frathe/picfetch => ../.. + +replace github.com/gen2brain/h265 => ../../third_party/h265 diff --git a/scripts/heicguest/main.go b/scripts/heicguest/main.go new file mode 100644 index 00000000..3dd73ebe --- /dev/null +++ b/scripts/heicguest/main.go @@ -0,0 +1,166 @@ +//go:build wasip1 && wasm + +// The development guest is WASI-only. It is not embedded in or launched by the +// application until mandatory host isolation and memory controls are qualified. +package main + +import ( + "bytes" + "encoding/binary" + "errors" + "image" + "io" + "os" + + "github.com/gen2brain/h265/heic" + + "github.com/frathe/picfetch/internal/heicdecode" +) + +func main() { + limits := heicdecode.DefaultLimits(0) + request, err := heicdecode.ReadRequest(os.Stdin, limits) + if err != nil { + _ = heicdecode.WriteFailure(os.Stdout, heicdecode.StatusRejected, "invalid request", limits) + return + } + limits.MaxPixels = request.MaxPixels + limits.MaxOutputBytes = request.MaxOutputBytes + result, status := decode(request, limits) + if status != 0 { + _ = heicdecode.WriteFailure(os.Stdout, status, "decode refused", limits) + return + } + if err = heicdecode.WriteResponse(os.Stdout, request.Operation, result, limits); err != nil { + // A transport failure may leave a partial response. The parent rejects it. + os.Exit(1) + } +} + +func decode(request heicdecode.Request, limits heicdecode.Limits) (result heicdecode.Response, status heicdecode.Status) { + defer func() { + if recover() != nil { + result = heicdecode.Response{} + status = heicdecode.StatusInternal + } + }() + if status = stillContainer(request.Input); status != 0 { + return result, status + } + reader := bytes.NewReader(request.Input) + exif, err := heic.DecodeExif(reader) + if err != nil && !errors.Is(err, heic.ErrNoExif) { + return result, heicdecode.StatusRejected + } + if exif != nil { + result.Metadata = &heicdecode.Metadata{Orientation: exif.Orientation, Make: exif.Make, Model: exif.Model, Software: exif.Software, DateTime: exif.DateTime, DateTimeOriginal: exif.DateTimeOriginal, ExposureTime: exif.ExposureTime, FNumber: exif.FNumber, ISOSpeed: exif.ISOSpeed, FocalLength: exif.FocalLength, Flash: exif.Flash, GPSLatitude: exif.GPSLatitude, GPSLongitude: exif.GPSLongitude, GPSAltitude: exif.GPSAltitude, Copyright: exif.Copyright, Artist: exif.Artist} + } + if request.Operation == heicdecode.DecodeExif { + return result, 0 + } + // Config also decodes so it reports transformed dimensions consistently. + // A header-only transformed-config API must be qualified before optimizing it. + if _, err = reader.Seek(0, io.SeekStart); err != nil { + return result, heicdecode.StatusInternal + } + img, err := heic.Decode(reader, heic.Options{AutoRotate: true, FrameSizeLimit: int(limits.MaxPixels), Threads: 1}) + if err != nil { + if errors.Is(err, heic.ErrUnsupported) { + return result, heicdecode.StatusUnsupported + } + return result, heicdecode.StatusRejected + } + img, status = canonicalPixels(img, limits) + if status != 0 { + return heicdecode.Response{}, status + } + result.Config = image.Config{Width: img.Bounds().Dx(), Height: img.Bounds().Dy(), ColorModel: img.ColorModel()} + if request.Operation == heicdecode.Decode { + result.Image = img + } + return result, 0 +} + +// Reject all movie boxes, including a still item accompanied by a sequence. The +// upstream still API otherwise falls back to decoding every sequence frame. +// This scan runs exclusively inside the guest, before upstream container work. +func stillContainer(data []byte) heicdecode.Status { + seenType, seenMeta := false, false + for len(data) > 0 { + if len(data) < 8 { + return heicdecode.StatusRejected + } + size := uint64(binary.BigEndian.Uint32(data[:4])) + kind := string(data[4:8]) + header := uint64(8) + if size == 1 { + if len(data) < 16 { + return heicdecode.StatusRejected + } + size = binary.BigEndian.Uint64(data[8:16]) + header = 16 + } else if size == 0 { + size = uint64(len(data)) + } + if size < header || size > uint64(len(data)) { + return heicdecode.StatusRejected + } + switch kind { + case "moov": + return heicdecode.StatusUnsupported + case "ftyp": + seenType = true + case "meta": + seenMeta = true + } + data = data[int(size):] + } + if !seenType || !seenMeta { + return heicdecode.StatusRejected + } + return 0 +} + +func canonicalPixels(img image.Image, limits heicdecode.Limits) (image.Image, heicdecode.Status) { + if img == nil { + return nil, heicdecode.StatusRejected + } + var pix []byte + var stride, size int + var rect image.Rectangle + switch m := img.(type) { + case *image.NRGBA: + if m == nil { + return nil, heicdecode.StatusRejected + } + pix, stride, size, rect = m.Pix, m.Stride, 4, m.Rect + case *image.NRGBA64: + if m == nil { + return nil, heicdecode.StatusRejected + } + pix, stride, size, rect = m.Pix, m.Stride, 8, m.Rect + default: + return nil, heicdecode.StatusUnsupported + } + width, height := int64(rect.Dx()), int64(rect.Dy()) + if width <= 0 || height <= 0 || width > limits.MaxPixels || height > limits.MaxPixels || width*height > limits.MaxPixels || width*height*int64(size) > limits.MaxOutputBytes { + return nil, heicdecode.StatusResourceLimit + } + row := int(width) * size + if stride < row || int64(stride)*(height-1)+int64(row) > int64(len(pix)) { + return nil, heicdecode.StatusRejected + } + if rect.Min == (image.Point{}) && stride == row && len(pix) == row*int(height) { + return img, 0 + } + // Normalize padded/cropped output without color conversion or bit-depth loss. + out := make([]byte, row*int(height)) + for y := 0; y < int(height); y++ { + copy(out[y*row:(y+1)*row], pix[y*stride:y*stride+row]) + } + bounds := image.Rect(0, 0, int(width), int(height)) + if size == 8 { + return &image.NRGBA64{Pix: out, Stride: row, Rect: bounds}, 0 + } + return &image.NRGBA{Pix: out, Stride: row, Rect: bounds}, 0 +} diff --git a/scripts/heicpackage/main.go b/scripts/heicpackage/main.go new file mode 100644 index 00000000..a75bc22e --- /dev/null +++ b/scripts/heicpackage/main.go @@ -0,0 +1,204 @@ +// Command heicpackage stages the isolated helper and its notices. Staging does +// not enable HEIC viewing or claim native/platform qualification. +package main + +import ( + "crypto/sha256" + "debug/elf" + "debug/macho" + "debug/pe" + "encoding/hex" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "github.com/frathe/picfetch/internal/heicdecode/client" +) + +type options struct{ mode, root, out, system, architecture, identity string } + +func main() { + var opts options + flag.StringVar(&opts.mode, "mode", "build", "build the helper or finalize its manifest after signing") + flag.StringVar(&opts.root, "root", ".", "repository root") + flag.StringVar(&opts.out, "out", "", "installation directory, or .app bundle root on macOS") + flag.StringVar(&opts.system, "os", runtime.GOOS, "target OS: darwin, linux, windows") + flag.StringVar(&opts.architecture, "arch", runtime.GOARCH, "target Go architecture: amd64 or arm64") + flag.StringVar(&opts.identity, "identity", "-", "macOS codesign identity; default is local ad-hoc signing") + flag.Parse() + if err := stage(opts); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func stage(opts options) error { + if opts.out == "" || (opts.architecture != "amd64" && opts.architecture != "arm64") || (opts.mode != "build" && opts.mode != "finalize") { + return errors.New("invalid HEIC package target/output/mode") + } + root, err := filepath.Abs(opts.root) + if err != nil { + return err + } + destination, err := filepath.Abs(opts.out) + if err != nil { + return err + } + executable, manifest, err := client.PackagePaths(destination, opts.system) + if err != nil { + return err + } + if opts.mode == "build" { + if opts.system == "darwin" && runtime.GOOS != "darwin" { + return errors.New("macOS HEIC packaging requires a native macOS host") + } + if err = os.MkdirAll(filepath.Dir(executable), 0755); err != nil { + return err + } + command := exec.Command("go", "build", "-trimpath", "-buildvcs=false", "-tags", "no_emoji,nodynamic", "-ldflags=-s -w -buildid=", "-o", executable, "./cmd/picfetch-heic-worker") + command.Dir = root + cgo := "0" + if opts.system == "darwin" { + cgo = "1" + } + command.Env = buildEnvironment(map[string]string{"GOOS": opts.system, "GOARCH": opts.architecture, "CGO_ENABLED": cgo, "GOTOOLCHAIN": "local", "GOWORK": "off", "GOFLAGS": "", "GOEXPERIMENT": "", "GOAMD64": "v1", "GOARM64": "v8.0"}) + if output, buildErr := command.CombinedOutput(); buildErr != nil { + return fmt.Errorf("build HEIC helper: %w: %s", buildErr, output) + } + if opts.system == "darwin" { + bundle := filepath.Dir(filepath.Dir(filepath.Dir(executable))) + if err = copyFile(filepath.Join(root, "packaging", "heic", "macos.Info.plist"), filepath.Join(bundle, "Contents", "Info.plist")); err != nil { + return err + } + command = exec.Command("/usr/bin/codesign", "--force", "--sign", opts.identity, "--options", "runtime", "--entitlements", filepath.Join(root, "packaging", "heic", "macos.entitlements.plist"), bundle) + if output, signErr := command.CombinedOutput(); signErr != nil { + return fmt.Errorf("sign HEIC helper: %w: %s", signErr, output) + } + command = exec.Command("/usr/bin/codesign", "--verify", "--strict", "--verbose=2", bundle) + if output, verifyErr := command.CombinedOutput(); verifyErr != nil { + return fmt.Errorf("verify HEIC helper signature: %w: %s", verifyErr, output) + } + } + } + if err = verifyTarget(executable, opts.system, opts.architecture); err != nil { + return err + } + notices := filepath.Join(filepath.Dir(manifest), "notices") + if err = os.MkdirAll(notices, 0755); err != nil { + return err + } + for _, name := range []string{"h265-LICENSE", "Go-LICENSE", "Go-PATENTS", "wazero-LICENSE", "wazero-NOTICE"} { + if err = copyFile(filepath.Join(root, "docs", "heic", "notices", name), filepath.Join(notices, name)); err != nil { + return err + } + } + executableDigest, err := fileDigest(executable) + if err != nil { + return err + } + guestDigest, err := fileDigest(filepath.Join(root, "internal", "heicdecode", "worker", "decoder.wasm")) + if err != nil { + return err + } + record := client.PackageManifest{Version: 1, GOOS: opts.system, GOARCH: opts.architecture, ExecutableSHA256: executableDigest, GuestSHA256: guestDigest} + data, err := json.MarshalIndent(record, "", " ") + if err != nil { + return err + } + if err = os.WriteFile(manifest, append(data, '\n'), 0644); err != nil { + return err + } + _, _, err = client.LoadPackage(destination, opts.system, opts.architecture) + return err +} + +func buildEnvironment(overrides map[string]string) []string { + var environment []string + for _, value := range os.Environ() { + key, _, _ := strings.Cut(value, "=") + if _, overridden := overrides[strings.ToUpper(key)]; !overridden { + environment = append(environment, value) + } + } + for key, value := range overrides { + environment = append(environment, key+"="+value) + } + return environment +} + +func copyFile(source, destination string) error { + data, err := os.ReadFile(source) + if err != nil { + return err + } + return os.WriteFile(destination, data, 0644) +} + +func fileDigest(path string) (string, error) { + file, err := os.Open(path) + if err != nil { + return "", err + } + defer func() { _ = file.Close() }() + hash := sha256.New() + n, err := io.Copy(hash, io.LimitReader(file, 64*1024*1024+1)) + if err != nil { + return "", err + } + if n == 0 || n > 64*1024*1024 { + return "", errors.New("HEIC package input size is invalid") + } + return hex.EncodeToString(hash.Sum(nil)), nil +} + +func verifyTarget(path, system, architecture string) error { + switch system { + case "darwin": + file, err := macho.Open(path) + if err != nil { + return err + } + defer func() { _ = file.Close() }() + expected := macho.CpuAmd64 + if architecture == "arm64" { + expected = macho.CpuArm64 + } + if file.Cpu == expected && file.Type == macho.TypeExec { + return nil + } + case "linux": + file, err := elf.Open(path) + if err != nil { + return err + } + defer func() { _ = file.Close() }() + expected := elf.EM_X86_64 + if architecture == "arm64" { + expected = elf.EM_AARCH64 + } + if file.Machine == expected && file.Class == elf.ELFCLASS64 && file.Type == elf.ET_EXEC { + return nil + } + case "windows": + file, err := pe.Open(path) + if err != nil { + return err + } + defer func() { _ = file.Close() }() + expected := uint16(pe.IMAGE_FILE_MACHINE_AMD64) + if architecture == "arm64" { + expected = pe.IMAGE_FILE_MACHINE_ARM64 + } + if file.Machine == expected && file.Characteristics&pe.IMAGE_FILE_EXECUTABLE_IMAGE != 0 && file.Characteristics&pe.IMAGE_FILE_DLL == 0 { + return nil + } + } + return errors.New("HEIC helper binary target does not match its package") +} diff --git a/scripts/heicpackage/main_test.go b/scripts/heicpackage/main_test.go new file mode 100644 index 00000000..0db3feab --- /dev/null +++ b/scripts/heicpackage/main_test.go @@ -0,0 +1,50 @@ +package main + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/frathe/picfetch/internal/heicdecode/client" +) + +func TestFinalizeRefusesWrongBinaryTargetBeforeManifest(t *testing.T) { + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + wrong := "windows" + if runtime.GOOS == wrong { + wrong = "linux" + } + root := t.TempDir() + target, manifest, err := client.PackagePaths(root, wrong) + if err != nil { + t.Fatal(err) + } + if err = os.MkdirAll(filepath.Dir(target), 0700); err != nil { + t.Fatal(err) + } + if err = copyFile(executable, target); err != nil { + t.Fatal(err) + } + if err = stage(options{mode: "finalize", root: "../..", out: root, system: wrong, architecture: runtime.GOARCH}); err == nil { + t.Fatal("wrong binary format accepted") + } + if _, err = os.Stat(manifest); !os.IsNotExist(err) { + t.Fatalf("invalid package created a manifest: %v", err) + } +} + +func TestPackageRefusesIncompleteBuildTarget(t *testing.T) { + for _, opts := range []options{ + {mode: "finalize", out: t.TempDir(), system: "linux", architecture: "386"}, + {mode: "unknown", out: t.TempDir(), system: "linux", architecture: "amd64"}, + {mode: "build", system: "linux", architecture: "amd64"}, + } { + if err := stage(opts); err == nil { + t.Fatalf("invalid options accepted: %+v", opts) + } + } +} diff --git a/scripts/msixstage/msixstage_test.go b/scripts/msixstage/msixstage_test.go index 677a283b..3fedccdd 100644 --- a/scripts/msixstage/msixstage_test.go +++ b/scripts/msixstage/msixstage_test.go @@ -3,6 +3,7 @@ package main import ( "bytes" "context" + "crypto/sha256" "encoding/json" "encoding/xml" "errors" @@ -14,10 +15,15 @@ import ( "os/exec" "path/filepath" "regexp" + "runtime" "strconv" "strings" "testing" + "time" + "go.yaml.in/yaml/v3" + + "github.com/frathe/picfetch/internal/heicdecode/client" "github.com/frathe/picfetch/internal/imaging" ) @@ -292,8 +298,9 @@ func TestStandaloneArchivesRetainNotices(t *testing.T) { t.Fatal(err) } for _, want := range []string{ - `"picfetch.exe" ../LICENSE ../THIRD-PARTY-NOTICES.md ../PRIVACY.md -j`, - `-C .. LICENSE THIRD-PARTY-NOTICES.md PRIVACY.md`, + `cp ../LICENSE ../THIRD-PARTY-NOTICES.md ../PRIVACY.md "heic-windows-$arch/"`, + `picfetch.exe heic LICENSE THIRD-PARTY-NOTICES.md PRIVACY.md)`, + `-C "heic-linux-$arch" heic -C "$GITHUB_WORKSPACE" LICENSE THIRD-PARTY-NOTICES.md PRIVACY.md`, `@('LICENSE', 'THIRD-PARTY-NOTICES.md', 'PRIVACY.md')`, `Compress-Archive -Path $packageFiles`, } { @@ -310,6 +317,143 @@ func TestStandaloneArchivesRetainNotices(t *testing.T) { } } +func TestReleaseSigningDoesNotExecuteRepositoryCode(t *testing.T) { + data, err := os.ReadFile("../../.github/workflows/release.yml") + if err != nil { + t.Fatal(err) + } + var workflow struct { + Jobs map[string]struct { + Environment string + Steps []struct{ Uses, Run string } + } + } + if err := yaml.Unmarshal(data, &workflow); err != nil { + t.Fatal(err) + } + signing, ok := workflow.Jobs["sign-windows"] + if !ok || signing.Environment != "release-signing" { + t.Fatal("protected signing job is missing") + } + var commands strings.Builder + for _, step := range signing.Steps { + if step.Uses != "" && !strings.HasPrefix(step.Uses, "actions/download-artifact@") && + !strings.HasPrefix(step.Uses, "actions/upload-artifact@") && + step.Uses != "dismine/windows-app-signing-setup-action@89ae3b032d4bc7a5b98d1a42a34e61ecb6faad64" { + t.Errorf("signing job admits a checkout, toolchain or unreviewed action: %s", step.Uses) + } + commands.WriteString(step.Run) + } + script := commands.String() + if regexp.MustCompile(`\bgo\s+(run|build|test|generate)\b`).MatchString(script) { + t.Error("repository Go code executes in the signing environment") + } + for _, want := range []string{ + "Get-FileHash -LiteralPath $helper -Algorithm SHA256", + "ConvertTo-Json", + "Set-Content -LiteralPath $manifestPath -Encoding utf8NoBOM", + "foreach ($signedFile in @($executable, $helper))", + "verify /pa /all /v /tw $signedFile", + } { + if !strings.Contains(script, want) { + t.Errorf("fixed manifest/signature finalization missing: %s", want) + } + } + if strings.LastIndex(script, "verify /pa /all /v /tw $signedFile") < strings.LastIndex(script, "Set-Content -LiteralPath $manifestPath") { + t.Error("final signature verification must follow manifest finalization") + } +} + +// Execute only the fixed data transformation from the real workflow, with an +// inert helper file. This never authenticates a signer or executes an artifact. +func TestWindowsHEICManifestFinalization(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("requires the native Windows PowerShell used by release signing") + } + data, err := os.ReadFile("../../.github/workflows/release.yml") + if err != nil { + t.Fatal(err) + } + var workflow struct { + Jobs map[string]struct{ Steps []struct{ Run string } } + } + if err = yaml.Unmarshal(data, &workflow); err != nil { + t.Fatal(err) + } + var transform string + for _, step := range workflow.Jobs["sign-windows"].Steps { + start := strings.Index(step.Run, "$manifestPath =") + end := strings.Index(step.Run, "foreach ($signedFile") + if start >= 0 && end > start { + transform = step.Run[start:end] + } + } + if transform == "" { + t.Fatal("fixed manifest transformation is missing") + } + for _, tc := range []struct { + name, arch, target, guest string + oversized, rejected bool + }{ + {"amd64", "amd64", "amd64", strings.Repeat("a", 64), false, false}, + {"arm64", "arm64", "arm64", strings.Repeat("b", 64), false, false}, + {"wrong target", "amd64", "arm64", strings.Repeat("a", 64), false, true}, + {"zero guest", "amd64", "amd64", strings.Repeat("0", 64), false, true}, + {"oversized", "amd64", "amd64", strings.Repeat("a", 64), true, true}, + } { + t.Run(tc.name, func(t *testing.T) { + root := filepath.Join(t.TempDir(), "owned ' data") + if err := os.MkdirAll(filepath.Join(root, "heic"), 0700); err != nil { + t.Fatal(err) + } + helper, manifest, err := client.PackagePaths(root, "windows") + if err != nil { + t.Fatal(err) + } + inert := []byte("owned inert helper hash fixture") + if err = os.WriteFile(helper, inert, 0600); err != nil { + t.Fatal(err) + } + record := client.PackageManifest{Version: 1, GOOS: "windows", GOARCH: tc.target, ExecutableSHA256: strings.Repeat("0", 64), GuestSHA256: tc.guest} + encoded, err := json.Marshal(record) + if err != nil { + t.Fatal(err) + } + if tc.oversized { + encoded = append(encoded, bytes.Repeat([]byte(" "), 4096)...) + } + if err = os.WriteFile(manifest, encoded, 0600); err != nil { + t.Fatal(err) + } + script := "param([string]$unpacked, [string]$arch)\n$ErrorActionPreference = 'Stop'\n$helper = Join-Path $unpacked 'heic/picfetch-heic-worker.exe'\n" + transform + path := filepath.Join(t.TempDir(), "finalize.ps1") + if err = os.WriteFile(path, []byte(script), 0600); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + output, runErr := exec.CommandContext(ctx, "pwsh", "-NoProfile", "-NonInteractive", "-File", path, root, tc.arch).CombinedOutput() + if ctx.Err() != nil || (runErr != nil) != tc.rejected { + t.Fatalf("finalization: %v, context=%v, output=%s", runErr, ctx.Err(), output) + } + if tc.rejected { + return + } + _, digest, err := client.LoadPackage(root, "windows", tc.arch) + if err != nil || digest != sha256.Sum256(inert) { + t.Fatalf("finalized package: digest=%x err=%v", digest, err) + } + updated, err := os.ReadFile(manifest) + if err != nil { + t.Fatal(err) + } + if err = json.Unmarshal(updated, &record); err != nil || record.GuestSHA256 != tc.guest { + t.Fatalf("guest identity changed: %+v, %v", record, err) + } + }) + } +} + func TestMicrosoftStoreWorkflowAndBuildTarget(t *testing.T) { root := filepath.Clean(filepath.Join("..", "..")) workflow, err := os.ReadFile(filepath.Join(root, ".github", "workflows", "microsoft-store.yml")) @@ -453,7 +597,7 @@ func testStoreEnvironmentPolicy(t *testing.T) { cmd := exec.Command("jq", "-e", "-s", "-f", "../storepublish/environment-policy.jq") cmd.Stdin = strings.NewReader(environment + "\n" + branches + "\n" + custom) out, err := cmd.CombinedOutput() - if (err == nil) != (change == "valid") || (err != nil && string(out) != "false\n") { + if (err == nil) != (change == "valid") || (err != nil && strings.TrimSpace(string(out)) != "false") { t.Fatalf("policy %s: %s (%v)", change, out, err) } }) diff --git a/scripts/nativeguards/main.go b/scripts/nativeguards/main.go index 9f117739..5cb4d046 100644 --- a/scripts/nativeguards/main.go +++ b/scripts/nativeguards/main.go @@ -23,6 +23,7 @@ type suite struct { name, goos, tags string packages []string guards []guard + focused map[string]string } type goRunner func(context.Context, []string, io.Writer) error @@ -49,9 +50,10 @@ func suiteFor(name, hostOS string) (suite, error) { s.require("internal/wallpaper", "TestSetWindows_TargetPreservesOpaqueIDAndUnicodePath", "TestSetWindows_TargetValidationFailsBeforeMutation") s.require("internal/clipboard", "TestCopyFilesWindows_DecodesUTF8WithNonUTF8Default") s.require("internal/filepicker", "TestWindowsPickerTransport_EmitsUTF8PathArrays") - s.require("internal/update", "TestApplyWindows_ReplacesDestAndKeepsOld", "TestApplyWindows_MissingStagedBinaryRestoresDest", "TestWindowsRelaunchCommand_PassesThePIDInTheInheritedEnvironment", "TestClassifyApplyError_WindowsErrno", "TestWaitMilliseconds_NeverConvertsToAnUnboundedWait") + s.require("internal/update", "TestApplyWindows_ReplacesDestAndKeepsOld", "TestApplyWindows_MissingStagedBinaryRestoresDest", "TestWindowsRelaunchCommand_PassesThePIDInTheInheritedEnvironment", "TestClassifyApplyError_WindowsErrno", "TestWaitMilliseconds_NeverConvertsToAnUnboundedWait", "TestDownloadedCompanionsRemainVerifiedAfterPersistence", "TestApplyInstallsCompanionsAndRollsBackOnBinaryFailure") s.require("internal/ui/autoupdate", "TestUpdater_AutomaticAndManualShareCompleteTransaction", "TestApplyStagedUpdate_SuccessRemovesTheStageOnEveryPlatform") s.require("internal/distribution", "TestStoreManaged_DefaultBuildIsFalse") + s.require("scripts/msixstage", "TestWindowsHEICManifestFinalization") case "macos": s.goos = "darwin" s.require("", "TestInstall_GraftsOntoGLFWsDelegate") @@ -59,6 +61,24 @@ func suiteFor(name, hostOS string) (suite, error) { s.require("internal/displays", "TestDisplaySnapshot_PreservesNativeBoundsAndIDs") s.require("internal/winpos", "TestPoller_StopDiscardsQueuedReadWithoutDrainingUI", "TestPoller_StopDuringNativeReadWaitsForReadWithoutPublishing") s.require("internal/filepicker", "TestDarwinPathTransport_RoundTripsNativeURLPaths") + case "heic-macos": + s.goos = "darwin" + s.tags = "heicnative" + s.require("internal/heicdecode/client", "TestNativeMacSandboxHelper", "TestNativeMacRuntimeChoice") + s.require("internal/update", "TestNativeMacUpdatePreservesSignedBundle", "TestNativeMacLegacyUpdateRequiresCompleteReinstall") + case "heic-linux": + s.goos = "linux" + s.tags = "heicnative" + s.require("internal/heicdecode/worker", "TestNativeLinuxRuntimePolicy") + s.require("internal/heicdecode/client", "TestNativeSandboxHelper") + case "heic-windows": + s.goos = "windows" + s.tags = "heicnative" + s.require("internal/heicdecode/winisolation", "TestNativeWindowsJobAndToken", "TestNativeWindowsUnsandboxedRefused", "TestLoopbackPermissionQueryFailureRefused") + s.require("internal/heicdecode/worker", "TestNativeNetworkDenialRejectsUnisolatedProcess") + s.require("internal/heicdecode/client", "TestNativeSandboxHelper", "TestWindowsInstalledHelperCache", "TestWindowsCacheLeaseWaitsForShutdown", "TestWindowsConcurrentHelperStaging", "TestWindowsHelperCacheRejectsHardLinksAndNonDirectories") + s.require("internal/heicdecode/client", "TestInheritedRemoteUsesOwner", "TestInheritedRemoteCancellationJoins", "TestAttachmentStopWithoutProcessStartJoinsOwner", "TestRemoteQueuedCancellationReleasesService", "TestRemoteOutputRetainsAdmissionAndStopJoins") + s.require("internal/similarity", "TestAnalysisWorkersUseHEICOwner", "TestAnalysisHEICAttachmentStartFailureJoins") case "store": s.tags = "microsoftstore" s.require("internal/distribution", "TestStoreManaged_MicrosoftStoreBuildIsTrue") @@ -66,6 +86,16 @@ func suiteFor(name, hostOS string) (suite, error) { default: return suite{}, fmt.Errorf("unknown native suite %q", name) } + if strings.HasPrefix(name, "heic-") { + s.require("internal/ui", "TestNativePackagedHEICActivation") + s.require("internal/similarity", "TestNativeHEICAnalysisPixels") + s.focused = map[string]string{"./internal/ui": "^TestNativePackagedHEICActivation$"} + if name == "heic-linux" { + // Analysis tests import desktop stubs. Run their complete suite with + // CGo separately from the standalone helper's pure Go tests. + s.focused["./internal/similarity"] = "." + } + } if s.goos != "" && s.goos != hostOS { return suite{}, fmt.Errorf("suite %s requires native %s, running on %s", name, s.goos, hostOS) } @@ -100,8 +130,26 @@ func runSuite(ctx context.Context, s suite, execute goRunner, log, capture io.Wr } } var raw bytes.Buffer - args := append(s.testArgs("-json", "-v", "-timeout=30m"), s.packages...) - executionErr := execute(ctx, args, io.MultiWriter(capture, &raw)) + var executionErrors []error + var full []string + for _, pkg := range s.packages { + if s.focused[pkg] == "" { + full = append(full, pkg) + } + } + if len(full) > 0 { + args := append(s.testArgs("-json", "-v", "-timeout=30m"), full...) + executionErrors = append(executionErrors, execute(ctx, args, io.MultiWriter(capture, &raw))) + } + // UI golden tests belong to Linux CI. These suites require the native + // application boundary fixture without rerunning unrelated UI surfaces. + for _, pkg := range s.packages { + if focus := s.focused[pkg]; focus != "" { + args := append(s.testArgs("-json", "-v", "-timeout=30m", "-run", focus), pkg) + executionErrors = append(executionErrors, execute(ctx, args, io.MultiWriter(capture, &raw))) + } + } + executionErr := errors.Join(executionErrors...) evidenceErr := validateEvents(&raw, s.guards, log) return errors.Join(executionErr, evidenceErr) } @@ -158,10 +206,24 @@ func validateEvents(input io.Reader, required []guard, log io.Writer) error { return errors.Join(failures...) } +func (s *suite) command(ctx context.Context, args []string) *exec.Cmd { + cmd := exec.CommandContext(ctx, "go", args...) + if s.name == "heic-linux" { + // Keep the helper qualification on its pure Go runtime. The separately + // executed application tests need the existing C/X11 adapters. + cgo := "0" + if slices.Contains(args, "./internal/ui") || slices.Contains(args, "./internal/similarity") { + cgo = "1" + } + cmd.Env = append(os.Environ(), "CGO_ENABLED="+cgo) + } + return cmd +} + func run(args []string, stdout, stderr io.Writer) error { flags := flag.NewFlagSet("nativeguards", flag.ContinueOnError) flags.SetOutput(stderr) - name := flags.String("suite", "", "windows, macos, or store") + name := flags.String("suite", "", "windows, macos, heic-macos, heic-linux, heic-windows, or store") capturePath := flags.String("capture", "", "raw go test JSON output path") if err := flags.Parse(args); err != nil { if errors.Is(err, flag.ErrHelp) { @@ -170,7 +232,7 @@ func run(args []string, stdout, stderr io.Writer) error { return err } if flags.NArg() != 0 || *capturePath == "" { - return errors.New("usage: nativeguards -suite windows|macos|store -capture ") + return errors.New("usage: nativeguards -suite windows|macos|heic-macos|heic-linux|heic-windows|store -capture ") } s, err := suiteFor(*name, runtime.GOOS) if err != nil { @@ -183,7 +245,7 @@ func run(args []string, stdout, stderr io.Writer) error { ctx, cancel := context.WithTimeout(context.Background(), 40*time.Minute) defer cancel() execute := func(ctx context.Context, args []string, out io.Writer) error { - cmd := exec.CommandContext(ctx, "go", args...) + cmd := s.command(ctx, args) cmd.Stdout = out cmd.Stderr = stderr return cmd.Run() diff --git a/scripts/nativeguards/main_test.go b/scripts/nativeguards/main_test.go index be57272c..0da2ef85 100644 --- a/scripts/nativeguards/main_test.go +++ b/scripts/nativeguards/main_test.go @@ -19,8 +19,25 @@ func TestNativeSuitesSelectPlatformAndDistributionGuards(t *testing.T) { {"windows", "windows", "github.com/frathe/picfetch/internal/clipboard", "TestCopyFilesWindows_DecodesUTF8WithNonUTF8Default", ""}, {"windows", "windows", "github.com/frathe/picfetch/internal/filepicker", "TestWindowsPickerTransport_EmitsUTF8PathArrays", ""}, {"windows", "windows", "github.com/frathe/picfetch/internal/update", "TestApplyWindows_MissingStagedBinaryRestoresDest", ""}, + {"windows", "windows", "github.com/frathe/picfetch/internal/update", "TestDownloadedCompanionsRemainVerifiedAfterPersistence", ""}, + {"windows", "windows", "github.com/frathe/picfetch/internal/update", "TestApplyInstallsCompanionsAndRollsBackOnBinaryFailure", ""}, {"windows", "windows", "github.com/frathe/picfetch/internal/distribution", "TestStoreManaged_DefaultBuildIsFalse", ""}, {"macos", "darwin", "github.com/frathe/picfetch", "TestInstall_GraftsOntoGLFWsDelegate", ""}, + {"heic-macos", "darwin", "github.com/frathe/picfetch/internal/heicdecode/client", "TestNativeMacSandboxHelper", "heicnative"}, + {"heic-macos", "darwin", "github.com/frathe/picfetch/internal/update", "TestNativeMacUpdatePreservesSignedBundle", "heicnative"}, + {"heic-macos", "darwin", "github.com/frathe/picfetch/internal/update", "TestNativeMacLegacyUpdateRequiresCompleteReinstall", "heicnative"}, + {"heic-linux", "linux", "github.com/frathe/picfetch/internal/heicdecode/client", "TestNativeSandboxHelper", "heicnative"}, + {"heic-macos", "darwin", "github.com/frathe/picfetch/internal/ui", "TestNativePackagedHEICActivation", "heicnative"}, + {"heic-macos", "darwin", "github.com/frathe/picfetch/internal/similarity", "TestNativeHEICAnalysisPixels", "heicnative"}, + {"heic-linux", "linux", "github.com/frathe/picfetch/internal/similarity", "TestNativeHEICAnalysisPixels", "heicnative"}, + {"heic-windows", "windows", "github.com/frathe/picfetch/internal/similarity", "TestNativeHEICAnalysisPixels", "heicnative"}, + {"heic-linux", "linux", "github.com/frathe/picfetch/internal/ui", "TestNativePackagedHEICActivation", "heicnative"}, + {"heic-windows", "windows", "github.com/frathe/picfetch/internal/ui", "TestNativePackagedHEICActivation", "heicnative"}, + {"heic-linux", "linux", "github.com/frathe/picfetch/internal/heicdecode/worker", "TestNativeLinuxRuntimePolicy", "heicnative"}, + {"heic-windows", "windows", "github.com/frathe/picfetch/internal/heicdecode/client", "TestInheritedRemoteCancellationJoins", "heicnative"}, + {"heic-windows", "windows", "github.com/frathe/picfetch/internal/similarity", "TestAnalysisWorkersUseHEICOwner", "heicnative"}, + {"heic-windows", "windows", "github.com/frathe/picfetch/internal/heicdecode/winisolation", "TestNativeWindowsJobAndToken", "heicnative"}, + {"heic-windows", "windows", "github.com/frathe/picfetch/internal/heicdecode/client", "TestNativeSandboxHelper", "heicnative"}, {"store", "darwin", "github.com/frathe/picfetch/internal/distribution", "TestStoreManaged_MicrosoftStoreBuildIsTrue", "microsoftstore"}, } { t.Run(tc.name+"/"+tc.test, func(t *testing.T) { @@ -144,12 +161,88 @@ func TestNativeCIExecutesAndRetainsEveryDeclaredSuite(t *testing.T) { t.Fatal(err) } text := string(data) - for _, name := range []string{"windows", "macos", "store"} { + for _, name := range []string{"windows", "macos", "heic-macos", "heic-linux", "store"} { if !strings.Contains(text, "./scripts/nativeguards -suite "+name+" -capture") { t.Errorf("CI omits %s guard runner", name) } } + child, err := os.ReadFile("../../packaging/heic/qualify-windows-child.ps1") + if err != nil { + t.Fatal(err) + } + for _, required := range []string{"./packaging/heic/qualify-windows.ps1 -Scenario standalone", "./packaging/heic/qualify-windows.ps1 -Scenario msix", "native-guards-Windows-MSIX-${{ matrix.arch }}", "heic-standard-user/", "heic-installed-msix/"} { + if !strings.Contains(text, required) { + t.Errorf("CI omits Windows qualification contract %q", required) + } + } + if !strings.Contains(string(child), "./scripts/nativeguards -suite heic-windows -capture") || !strings.Contains(string(child), "TestNativeInstalledHEICActivation") { + t.Fatal("standard-user runner lost native or installed activation") + } if !strings.Contains(text, "native-guards-${{ runner.os }}") || !strings.Contains(text, "if: always()") { t.Fatal("raw native guard evidence not retained") } } + +func TestNativeApplicationGuardRunsWithHelperSuite(t *testing.T) { + s, err := suiteFor("heic-linux", "linux") + if err != nil { + t.Fatal(err) + } + var calls [][]string + execute := func(_ context.Context, args []string, out io.Writer) error { + calls = append(calls, slices.Clone(args)) + for _, g := range s.guards { + pkg := "./" + strings.TrimPrefix(g.Package, "github.com/frathe/picfetch/") + if !slices.Contains(args, pkg) { + continue + } + if slices.Contains(args, "-list") { + _, _ = fmt.Fprintln(out, g.Test) + } else { + _, _ = io.WriteString(out, eventsFor(g, "run", "pass")) + } + } + return nil + } + if err = runSuite(context.Background(), s, execute, io.Discard, io.Discard); err != nil { + t.Fatal(err) + } + var helper, application, analysis bool + for _, args := range calls { + if slices.Contains(args, "-list") { + continue + } + if slices.Contains(args, "./internal/ui") { + application = slices.Contains(args, "-run") && slices.Contains(args, "^TestNativePackagedHEICActivation$") + } else if slices.Contains(args, "./internal/similarity") { + analysis = slices.Contains(args, "-run") && slices.Contains(args, ".") && !slices.Contains(args, "./internal/heicdecode/client") + } else { + helper = !slices.Contains(args, "-run") + } + } + if !helper || !application || !analysis { + t.Fatalf("native runner lost helper, application or analysis coverage: %v", calls) + } +} + +func TestLinuxApplicationUsesCGOWithoutChangingHelperBuild(t *testing.T) { + t.Setenv("CGO_ENABLED", "unexpected") + s, err := suiteFor("heic-linux", "linux") + if err != nil { + t.Fatal(err) + } + for _, pkg := range []string{"./internal/ui", "./internal/heicdecode/client", "./internal/heicdecode/worker", "./internal/similarity"} { + for _, flags := range [][]string{{"-list", "."}, {"-json", "-run", "TestNative"}} { + command := s.command(context.Background(), append(s.testArgs(flags...), pkg)) + want := "CGO_ENABLED=0" + if pkg == "./internal/ui" || pkg == "./internal/similarity" { + want = "CGO_ENABLED=1" + } + for _, value := range command.Environ() { + if strings.HasPrefix(value, "CGO_ENABLED=") && value != want { + t.Errorf("%s %v: %s, want %s", pkg, flags, value, want) + } + } + } + } +} diff --git a/scripts/qodanaconfig/main.go b/scripts/qodanaconfig/main.go new file mode 100644 index 00000000..14f48d56 --- /dev/null +++ b/scripts/qodanaconfig/main.go @@ -0,0 +1,25 @@ +// Command qodanaconfig validates YAML before the exclusion inventory check. +package main + +import ( + "fmt" + "os" + + "go.yaml.in/yaml/v3" +) + +func main() { + data, err := os.ReadFile("qodana.yaml") + if err == nil { + err = validate(data) + } + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "invalid qodana.yaml: %v\n", err) + os.Exit(1) + } +} + +func validate(data []byte) error { + var document map[string]any + return yaml.Unmarshal(data, &document) +} diff --git a/scripts/qodanaconfig/main_test.go b/scripts/qodanaconfig/main_test.go new file mode 100644 index 00000000..853954f8 --- /dev/null +++ b/scripts/qodanaconfig/main_test.go @@ -0,0 +1,22 @@ +package main + +import "testing" + +func TestValidateQodanaSyntax(t *testing.T) { + for _, tt := range []struct { + name, data string + valid bool + }{ + {"ordinary configuration", "version: \"1.0\"\nexclude:\n - name: DuplicatedCode\n paths:\n - sample_test.go\n", true}, + {"unfinished sequence", "version: \"1.0\"\nexclude: [\n", false}, + {"duplicate root key", "exclude: []\nexclude: []\n", false}, + {"commented parent mapping", "version: \"1.0\"\n#exclude:\n - name: DuplicatedCode\n", false}, + {"sequence root", "- DuplicatedCode\n", false}, + } { + t.Run(tt.name, func(t *testing.T) { + if err := validate([]byte(tt.data)); (err == nil) != tt.valid { + t.Fatalf("valid = %v, error = %v", tt.valid, err) + } + }) + } +} diff --git a/third_party/h265/LICENSE b/third_party/h265/LICENSE new file mode 100644 index 00000000..91d5b917 --- /dev/null +++ b/third_party/h265/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2025 roticv +Copyright (c) 2026 Karpeles Lab Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/h265/PICFETCH-SOURCE.json b/third_party/h265/PICFETCH-SOURCE.json new file mode 100644 index 00000000..cf8a74aa --- /dev/null +++ b/third_party/h265/PICFETCH-SOURCE.json @@ -0,0 +1,113 @@ +{ + "sourceCommit": "fc127b44e1c99447b8d150256563c6d4f99b8ad3", + "sourceTree": "df5936bb22bd65eae70be1f6a50237644c5fa999", + "files": { + "LICENSE": "a53831ee9730340d31a911148e69fdd15cc7d1c408b06f3a923b170c4578634d", + "README.md": "d10ee10f3d98e3fd1808d37b26aa770d94689d2731794d06a3ea714152554838", + "go.mod": "ab1c01f4919d1116d207771b0274e985c76ef0c1380ee4e0c0e3d4495a186942", + "heic/color.go": "6eb86c0968f624d7081ee38cce7bfa301dfea2d362b63bf207dab183babf4beb", + "heic/color_amd64.go": "c056bb073e4a11e28a6a719375b9c12483b028ecef5e92eb2ac4501bae1b8c4d", + "heic/color_amd64.s": "801dfb6905769c6fcdc4c179eb89a2c5b806efd5822ec6b78554dcdb0b2ab946", + "heic/color_arm64.go": "459e3af6924ca246c1e909638d4212b77b19c91b4ae9fc70c91f4548faf16c1f", + "heic/color_arm64.s": "3cc39098aa91591db0a2dc0add555296f57905b6d5aa46504549483e49ec95ca", + "heic/color_noasm.go": "8658b79899068b92b12cb62c3a1b358712382a6ecd0706f5f874e24b674bdac0", + "heic/color_riscv64.go": "2eaec7518c8b970b42b7ea63015b2e03b4f042bbb569d16db186051004144113", + "heic/color_riscv64.s": "ec3aca76667078e3d56d6cf703a01491bd3d343e385ca6fb6735e87f7d9f2eb3", + "heic/color_row.go": "d2467b8d79b3c7ea35c990cf82a0e7c88a04d932a317b4c7f712511dec5088a5", + "heic/cpu_amd64.go": "7ac53bab8a9fedbbd5d08020703b37a6c967feb10a2cee651c780961621f4414", + "heic/cpu_amd64.s": "462ed6010f30c535a8f2c1f5682aff46f1a9fc13ca6ff7303527135458e30165", + "heic/encode.go": "08ab928a36dd612135155f8ccf8bf941dc6df1cb56adafa05467329db50fccfd", + "heic/exif.go": "5c863616f6ea4bc6d830d92682ef5325bf13f7c8df2520ba26d46079f8434759", + "heic/grid.go": "d4c91e57122a53d9fd6c93cc02d207830ca9060a74b86e6919336e16f6f7fcc9", + "heic/gridenc.go": "87613918826f80509083abf6c94a2f89acaae13b23014dc3bc640fdb5cce549b", + "heic/heic.go": "2d6e508e2e28192d48192d92b124a7b1446dab23d0a141d91cc6c6dd4a26e46b", + "heic/image.go": "88788bdb63d755c99d557eb99c782f804850ac926b0ee05f40cecd965d216624", + "heic/isobmff.go": "187e638e5d2da55051ade4d1faa36639bef102fd80c1bc6109d57f38adf0e9a8", + "heic/limits.go": "ff336caa0c194fcc81e2ea5cf581ce30eda9a6e038ec9bbe98ba5c2d15831dc2", + "heic/sequence.go": "8c5c9b8e042cdd254cc52cd25438632fd77ec4b507526d42bdb474f64159c8fa", + "heic/transform.go": "11c75e77a33a21f891e9d5e1401c9cfb3ddbdcbbcb0e96745a9ff4519f841423", + "heic/ycbcr.go": "e190dffd37342e4375fba5de7d6d3a4a5bfaab228cd953978a3cb922f688d4d4", + "hevc/budget.go": "62ed954c0300d8a848d262e1a9dc33514b6058613caf22e8662e591c7ef0d735", + "hevc/cabac.go": "4b3426446d9e2d5636fd3137b790fa2641899f68e8c8c8b15857efb7998bfbcd", + "hevc/cabac_tables.go": "39ae240713d741df33bdac8551ac7b463351d459069cd07feafda88058e5472a", + "hevc/cabacenc.go": "6f010e27c000afe826b2155f424af303db05d2179b9b3cefb2f99c44b1ff96d5", + "hevc/cpu_amd64.s": "ce04f6cb753589d1b49c52d8e767cfcee707c6f57e42e0b3f80a7b370f60e619", + "hevc/ctu.go": "fa320bc14f90828450d0b3cf42bd4146811e507964a707dd6e4411502f7bbc00", + "hevc/deblock.go": "36f66c96e38e26b1f0b0885c68b026cb67350600ae3056d6b082d20975cabf87", + "hevc/deblock_amd64.s": "086a0a4ecb4f1aacabdd6896bd497027724867a74c3963b954a256fcb90e4ae3", + "hevc/deblock_arm64.s": "f0fc2c9566139df256c80554cdb4497513f10357168720e7d185dc3bcdeeef26", + "hevc/deblock_riscv64.s": "2871547768141f10d9129f006df25158efc696899b89830e43757f71241b3ee3", + "hevc/deblockenc.go": "b2c9996e3d527124859f3238663d1ee4a3d298e83c336f66acc2876dde3ebe51", + "hevc/decoder.go": "98c2d0d6348babc6068f6815e6a94802731a9d105ad19628c14b9d729eb27030", + "hevc/dequant_amd64.s": "6ed52f89420aa8fb1fdc3a9da8473f381716a2431beceddeb41e38775f123d4a", + "hevc/dequant_arm64.s": "8162781c26dcac679d0af9db257c3915d8d7f83849b1e47c9269a2bf62da68a8", + "hevc/dequant_riscv64.s": "ea86abff13f9b065be2591a5bb2fcf2b5ccde4a9f63992e085615c65295cf904", + "hevc/dpb.go": "36aa82767d7e3c73474de3c77a0f6756ad83ddb78a2666b2176e3ec972be18c1", + "hevc/dsp.go": "07b1155187c9ce66130902f2c58e3c0d5ab7a30fba7d572006cb86b263c84eab", + "hevc/dsp_amd64.go": "6be0f3d7698f8ef58403285c03feeb46e1d3ce218e6660d083297c184aaeba69", + "hevc/dsp_arm64.go": "d6dd5a569163543bf5af811ea6c47f78a609d1cd3512e545e19aebeb6655eabd", + "hevc/dsp_noasm.go": "33369a09b678286c8b0fdb996d4a8f89165aeaaf95222fea37123894ddeaf6d4", + "hevc/dsp_riscv64.go": "7530087bd0466d79a010c17e96dbdc8272502a2a90bb9b00365df7e179ebd4a5", + "hevc/encoder.go": "71d15aa3e9f3bb7f1909e859a259e2abdaac6ef242a5ec60462e72b3716c4751", + "hevc/getbits.go": "508e223966df9a8d2e6bf9c666f6f85979397f7e17a8d01c2579f35e0b33e6ab", + "hevc/headers.go": "44bf44cdf4596e6fe855b14e0a323f71d6f91a5f58b3873dc81469ae4b359cc5", + "hevc/headersenc.go": "951fdab46b602e620008d5018fe228239808baa1997158e9765cdc9f8eebe0fa", + "hevc/idct_amd64.s": "d69467d1fdbab834d69e51f76adc213c1c1a4b15e2f4d810e150a89902f2c3ef", + "hevc/idct_arm64.s": "46d9bffa1d0aa85a7ad97b64fcb4ee63169b171074545e126e689b11e88d4956", + "hevc/idct_riscv64.s": "4392199c1793f68c4712b59e07a1bb399af0e2e4d1b41571e4c53f826fb3e483", + "hevc/inter.go": "88f2ed37174ebda06461c8f7b1b94b5b36a6dca6697ac83b4c3eff33e6a715cc", + "hevc/interpred.go": "f603090da8dd4658a219000a6c7e3b0dc1ed739e6e49bf4270ec449f0133dd22", + "hevc/interpred_amd64.s": "afadb8be1fe68e899bade0c0ba97dfd637ce399f7c2c91d44a17e0858b6126b2", + "hevc/interpred_arm64.s": "2c5ca0ad46f332d85e822df2b20e61a2f31674146f1709b25235354b2cde7ca3", + "hevc/interpred_bi_amd64.s": "669cf7e97dba239a16c3f6c64d6754c2fefece6fffc47ae26528bc78f4cd4902", + "hevc/interpred_bi_arm64.s": "6de5ef4910e026aa31e8a5c0413510084b0f4e28798500127e728153c533badd", + "hevc/interpred_bi_riscv64.s": "63b5b4eddb4e049d6894387c9b9d058714e236753281060d7bbf36b60741c4c1", + "hevc/interpred_copy_amd64.s": "d10091b5770abbe1ab20227e4bc0b7a6f932a5c3877e11173f94afcc4f335c5f", + "hevc/interpred_copy_arm64.s": "182b3c0f54a312cda71e98023f0e3c3cc6abcc1f249b536905801836338394eb", + "hevc/interpred_copy_riscv64.s": "fc3fae4f3005a81654f6e28c5cf718c3ca78249544e2f72b75e3c5fb2a43ec9f", + "hevc/interpred_riscv64.s": "f2a475c83a145b3e79a2c09733b5e6ec58439f74da3f8c7bd1da6c0557eb1771", + "hevc/interpred_tap_amd64.s": "376d23dc8bf277835a99a25e789a3e71367a8f80c290ac28083f75da4f941529", + "hevc/interpred_tap_arm64.s": "22f9e53b17bc3a0ab34be673d2e4fde17ac0c45ccfb61695ed830adbbfe2e47a", + "hevc/interpred_tap_riscv64.s": "10773290647302a93e0cfc52f95750be2f5a71686a8cfedb77d5c388b5861de7", + "hevc/interpred_tapv_amd64.s": "e5af8c2938d579a7c57462ef1175cf270eae57fcdcd5cf47c460c4fdd0ea693c", + "hevc/interpred_tapv_arm64.s": "4d9811345444f8021a1b5c8059904cb3c42f37180261cc76fdaeb8e2272baa59", + "hevc/interpred_tapv_riscv64.s": "40e06ac4933e48e77afebc8ca8a7a4c11c05f7c4fcb002711513824cc66eda2e", + "hevc/intrapred.go": "33f0f0020b4651b827ffcc40b478e90b78368235fbbf901ddce5cb28a3285a32", + "hevc/intrapred_amd64.s": "e2d37602d999ac6a7ce5184ee89ba823ee603d678a8883aac2377e82f0a85fc5", + "hevc/intrapred_arm64.s": "5f0d4acf19e04bf7be7a7bafc12a3b3ff33aeeb0dfd6577187a58870b5ed1779", + "hevc/intrapred_riscv64.s": "d102ea1c92eb40a184487a9d06c4c78bd811d04b47b46007e73b16caa1534c00", + "hevc/itransform.go": "1503a2c78f000c6ecb69855263af5bfb55ff33050883d861e107dfce1707f2a5", + "hevc/itransform_amd64.s": "5b5c72fec386e0daa8cca3d435d2c0fddc6465d3935ff39dc2b4311cb7b8bef9", + "hevc/itransform_arm64.s": "89aa40a067a4bbf840523cb49cde23eedd65bdd4b0ba874e05d1b0d2fb24f5c8", + "hevc/itransform_riscv64.s": "4984e77173ed04f5143027bc4f0aa224bd6457ae6ef279b6077cdb8f1a51e1ac", + "hevc/lossyenc.go": "01db7098badb86405765f7111692d755c6a2893ee3713ba083067e19c635d22f", + "hevc/mvpred.go": "e17744d8abbaa3971eed072e699f16b8a3740770c66127e305096e7638eb88ea", + "hevc/nal.go": "997dfa297fab9abac82dab15225b9a2cbaa2c8965097f7d43a6da74aa4c736c3", + "hevc/pcmenc.go": "9889f9843316916aa288965c6f9846ef8ea00224e9d44e7dea1c56bbb6aa5276", + "hevc/picture.go": "50256e63278cf4805db1faa23c252fb0ba596ca034f09b29dff2be3ddcbeb087", + "hevc/putbits.go": "0e825ddc335b656da8373d61f593142578732fb6fa6ad64cca2e08188c39d72d", + "hevc/rdoq.go": "4ed95b2b3113861ce3b5471707c33d8a38dfafc1ef8ddd42ec45f1f46b3feb7c", + "hevc/residual.go": "7d73bcbb65b0f6a2749828637f776ee6415918f28762544f1b8dcc933911119b", + "hevc/residualenc.go": "ada98252c054278baffa2e51d78e0f629dfff9ad294e9c36015bab19c73eab82", + "hevc/sao.go": "d16a669a2968d4d9105a188a3a4762d111a0ff16fde91927a1ce5d257932ef7d", + "hevc/saoenc.go": "7659e919ba66ac8c2cfb0c16f367f5d99b23acb1237262d35dcbcdc7c21f16f9", + "hevc/satd_amd64.s": "ff488aad240ba382fa8ca72528c24f0626e12c649951e27324421a28fa43e96a", + "hevc/satd_arm64.s": "d904d792f8de0e8fc95ca6ceabf7415bfc523b734fb940dd8bd78378f769a1f4", + "hevc/satd_riscv64.s": "82f815d2fe0e815b54cc252eac5435c8d0bd8a8b7ea26aed56fe301c2a564d44", + "hevc/scaling.go": "8646fd156e943971ceb49489d648d1e3dc972ec80ae5fb7466ece230f17285a5", + "hevc/scan.go": "68dccfd7329a661117e8d4578d04af287435b639c87b19a6d7528ab3ac98f8e4", + "hevc/slice.go": "d419998030613086d5cffaf86d073299923afd8a20f3c8c2c9771300f8951918", + "hevc/sse_amd64.s": "c6347b34ebb84b36a3c676d39601478ee2ee7f47246dadd5728e0dc3af572494", + "hevc/sse_arm64.s": "bc7c6d7b96641eef5f30efcdb00ace9f0ef79f822412fb7e3008d9eca03313b0", + "hevc/sse_riscv64.s": "c4d874c26577042141c987b473b78090a1040a1afbe95daf3dd9b344e0cc788b", + "hevc/transformenc.go": "5b6271131527ca3eb141dd26d3f77ed0005252c938ca591c5a5dcfa0d9300d6a", + "hevc/transformenc_amd64.s": "46c031c6dd354625a43c49fa524f317a15af42e9c3a367ff1cf360dbf18d2e81", + "hevc/transformenc_arm64.s": "4d3a14051b93e11dbf2646e58fc1d89fe9695f30c0cd6d4d081752c69a005f17", + "hevc/transformenc_riscv64.s": "65a41ccf3da20f659a1b995fbe79713585cc54f0b61a63819c9e2bba6c0ee9bb", + "hevc/transpose_amd64.s": "e7d482947d2a3b9fc7f06d3b58c111e19d2558524022f7dc68dbf2bd84d5cf9a", + "hevc/transpose_arm64.s": "d37ab80cf794befb44f2077e8405cf925debafde2b15193585c7658564ba1aff", + "hevc/transpose_riscv64.s": "d0ce8035aa654f15527e539d93b15948562e0a8bc962ed9e6e42229dad2c7aef", + "hevc/wavefront.go": "ef89091a2ec5f99e5253ac8ecb10950faf9385f34a8766877b0e8ef032f22585", + "hevc/wavefrontenc.go": "fd3faf659474175b00c8989ebbbc81dd3579084edb3e1c599360d98f9217a029" + } +} diff --git a/third_party/h265/PICFETCH.md b/third_party/h265/PICFETCH.md new file mode 100644 index 00000000..355f715e --- /dev/null +++ b/third_party/h265/PICFETCH.md @@ -0,0 +1,87 @@ +# PicFetch's maintained HEIC source + +## Source selection + +This is the exact production source previously maintained by PicFetch at +commit `fc127b44e1c99447b8d150256563c6d4f99b8ad3`, subtree +`df5936bb22bd65eae70be1f6a50237644c5fa999`. `PICFETCH-SOURCE.json` records every +copied production file. The baseline is upstream `github.com/gen2brain/h265` +v0.2.2, commit `665fd95984177afef4a7efca7d50638e4b695c7a`, with the local +hardening retained unchanged. Root and guest modules explicitly replace h265 +with this directory. Only the separate WASI guest imports its decoder. + +The restoration's earlier unmodified v0.2.3 guest was a development candidate. +That release does not contain all the maintained source's work-budget, +metadata-accounting, bounded NAL traversal, sequence-retention and transformed +configuration changes. Selecting a newer version alone would discard those +changes. This restoration instead preserves the complete maintained production +copy; it does not claim complete equivalence between two different sequence +implementations. + +## Retained local changes + +- Finite movie sample/track counts, table payload and duplicate checks, deferred + timing expansion and checked linear sample layout. +- Missing-metadata handling, checked extent arithmetic and aggregate selected + bytes, finite parsed metadata entries and HEVC configuration units. +- Coded-frame limits for primary images, grids, alpha and tracks, plus one + shared atomic `DecodeBudget` for aggregate coded work. Finite decoded-picture + buffering and reordering counts. +- The 32-sample transform limit, originally taken from upstream commit + `5138267a04330b8f0e36ddeabbcf6f49b664fa88`, and checked NAL length arithmetic. +- Streaming NAL traversal with finite counts, one-picture still items, bounded + first-frame traversal and finite aggregate retained sequence output. +- `DecodeConfigWithOptions` for container-transformed bounds, explicit + `AutoRotate` and one decoder thread in the application guest. +- Existing parameter-name annotations for the project's inspections. + +The restoration guest additionally rejects movie containers, admits at most +64 MiB input, and applies its independently enforced WASM/IPC/output/deadline +ceilings. Library limits are defense in depth and do not replace that boundary. +The historical negative-limit API remains in the source; PicFetch never uses it. + +## Distribution and verification + +MIT; retain `LICENSE`, including the roticv and Karpeles Lab notices. Upstream +credits rust_h265 and oxideav-h265; exact translated-source revisions remain a +traceability gap. No HEVC patent clearance is asserted. The source has no module +dependencies, embedded runtime or native codec library. Native assembly is +retained for exact source provenance, but `wasip1/wasm -tags=noasm` selects the +scalar decoder used by PicFetch. + +`make generate-heic-wasm` rebuilds the fixed guest and provenance. +`make check-heic-wasm` checks the production copy, replacements, complete guest +inputs, independent reproduction and absence of native decoder imports. +`make test-h265` exercises this nested source through the bounded WASI guest +using the reviewed ordinary fixtures; root `go test ./...` alone does not +traverse a nested module. It is scoped compatibility coverage, not the upstream +or historical full test suite. Historical malformed-input/fuzz seeds and test +files are not copied or executed by this restoration. Their original record +remains in the historical checkout, which is not modified here. + +Before upgrades, review every local change against the proposed upstream +source, preserve checks without verified equivalents, update the exact source +record deliberately, reproduce the guest, and repeat bounded ordinary-image and +owned-boundary qualification. Never add an in-process or native codec fallback. +Current native platform, memory and compatibility status is recorded in +`../../docs/heic/qualification.md`; a source copy does not establish activation. + +## Inspection disposition for this restoration + +All 57 Go and 47 assembly files were inspected with GoLand, including weak +warnings. The 47 assembly reports reject Go's assembler dialect in the generic +assembly parser; those unchanged files are retained for provenance and excluded +from the WASI build. Their exact paths are excluded from Qodana. Native assembly +execution/compilation is not newly qualified by this restoration. + +Exact-file inspection exclusions preserve assembly-backed parameter names, +build-specific helpers, public API/format constants and the unused retained +reference helpers/type. Exact duplication exclusions preserve reviewed codec +variants instead of rewriting the maintained algorithms. Remaining suggestions +are redundant conversions and scoped local `any`/`max` names; they do not change +behavior. The partial NAL switch is followed by explicit EOS/non-VCL handling. +Direct EOF comparisons are fed by the bounded in-memory guest reader; other +errors return a refusal. No confirmed new defect was found in these reports. +The IDE does not apply Qodana's scopes, so this is a reviewed disposition, not a +claim that its generic assembly reports disappeared. Fresh Qodana verification +of the configured scopes remains pending. diff --git a/third_party/h265/README.md b/third_party/h265/README.md new file mode 100644 index 00000000..5c843f23 --- /dev/null +++ b/third_party/h265/README.md @@ -0,0 +1,75 @@ +## h265 +[![Status](https://github.com/gen2brain/h265/actions/workflows/test.yml/badge.svg)](https://github.com/gen2brain/h265/actions) +[![Go Reference](https://pkg.go.dev/badge/github.com/gen2brain/h265.svg)](https://pkg.go.dev/github.com/gen2brain/h265) + +[HEVC](https://en.wikipedia.org/wiki/High_Efficiency_Video_Coding) video and +[HEIC](https://en.wikipedia.org/wiki/High_Efficiency_Image_File_Format) image codec in pure Go, +decoding and encoding. No CGo, no dependencies. + +The decoder is byte-exact on the JCT-VC HEVC v1 conformance suite. What the encoder writes is +held to libde265, FFmpeg and libheif sample for sample. + +SIMD support for amd64 (AVX2, AVX-512), arm64 (NEON) and riscv64 (RVV, with `GORISCV64=rva23u64`). +Build with `-tags noasm` for pure Go everywhere. + +### Decoding + +```go +img, err := heic.Decode(r) +``` + +`heic.Decode` returns `*image.NRGBA`, or `*image.NRGBA64` above 8 bits, and registers itself with +`image.RegisterFormat`. The `hevc` package decodes the bitstream on its own, a NAL unit at a time: + +```go +d := hevc.Decoder{} + +for _, nal := range nals { + pics, err := d.DecodeNAL(nal) + ... +} +``` + +### Encoding + +`heic.Encode` writes any image as a HEIC still, keeping alpha as the auxiliary item of +ISO/IEC 23008-12 Annex F. + +```go +err := heic.Encode(w, img, heic.EncodeOptions{Quality: 60}) +``` + +`hevc.Encoder` writes the bitstream on its own, as self-contained intra IDR access units: + +```go +enc, err := hevc.NewEncoder(hevc.EncoderOptions{Width: 1920, Height: 1080, QP: 26}) +nals, err := enc.Encode(hevc.Frame{ + Y: y, Cb: cb, Cr: cr, + StrideY: yStride, StrideC: cStride, +}) +stream := hevc.MarshalAnnexB(nals) +``` + +### Supported + +Decoding: 8-16 bit, 4:2:0/4:2:2/4:4:4/monochrome, tiles, wavefronts, dependent slice segments, +PCM, lossless, scaling lists, and the range extensions other than cross-component prediction, +RDPCM and CABAC bypass alignment, which are refused rather than decoded wrongly. In the container +alpha, `grid`, `clap`/`irot`/`imir`, `colr`, image sequences, Exif and XMP. + +Encoding: 8-12 bit, 4:2:0/4:2:2/4:4:4/monochrome, intra only, deblocking, sample adaptive +offset, wavefronts, PCM lossless, and alpha, `grid`, `colr`, Exif and XMP in the container. A picture too large for +any HEVC level is written as a grid of tiles. + +Decoding is threaded over grid tiles and wavefront rows; `heic.Options.Threads` and +`hevc.Decoder.Threads` bound it. + +### License + +MIT, in [LICENSE](LICENSE). The decoder is a port of the pure-Rust +[rust_h265](https://github.com/roticv/rust_h265) and +[oxideav-h265](https://github.com/OxideAV/oxideav-h265) decoders and carries their notices. + +This software implements a decoder and an encoder. It gives you no special rights on the HEVC +patents. HEVC is covered by patents held by several pools and by unpooled holders; if you +distribute or use this software you may need a licence from them. diff --git a/third_party/h265/go.mod b/third_party/h265/go.mod new file mode 100644 index 00000000..a56d7fa5 --- /dev/null +++ b/third_party/h265/go.mod @@ -0,0 +1,3 @@ +module github.com/gen2brain/h265 + +go 1.26.4 diff --git a/third_party/h265/heic/color.go b/third_party/h265/heic/color.go new file mode 100644 index 00000000..089156ba --- /dev/null +++ b/third_party/h265/heic/color.go @@ -0,0 +1,438 @@ +package heic + +import ( + "github.com/gen2brain/h265/hevc" +) + +const ( + mcIdentity = 0 + mcBT709 = 1 + mcUnspec = 2 + mcFCC = 4 + mcBT470BG = 5 + mcBT601 = 6 + mcSMPTE240 = 7 + mcYCgCo = 8 + mcBT2020NCL = 9 +) + +func yuvCoefficients(matrix int) (float32, float32, float32) { + kr, kb := float32(0.299), float32(0.114) + + switch matrix { + case mcBT709: + kr, kb = 0.2126, 0.0722 + case mcFCC: + kr, kb = 0.30, 0.11 + case mcBT470BG, mcBT601: + kr, kb = 0.299, 0.114 + case mcSMPTE240: + kr, kb = 0.212, 0.087 + case mcBT2020NCL: + kr, kb = 0.2627, 0.0593 + } + + return kr, 1 - kr - kb, kb +} + +// planeView is one plane of a decoded picture with the conformance window +// already applied, so the rest of this package never sees the crop. +type planeView struct { + p8 []uint8 + p16 []uint16 + stride int + w, h int +} + +func (v planeView) valid() bool { return v.p8 != nil || v.p16 != nil } + +func viewOf(p8 []uint8, p16 []uint16, stride, x, y, w, h int) planeView { + v := planeView{stride: stride, w: w, h: h} + + off := y*stride + x + if p16 != nil { + v.p16 = p16[off:] + } else if p8 != nil { + v.p8 = p8[off:] + } + + return v +} + +// views returns the luma and chroma planes of pic, cropped to the conformance +// window. Chroma is empty for monochrome. +func views(pic *hevc.Picture) (y, cb, cr planeView) { + cw, ch := pic.CropW, pic.CropH + sw, sh := 1, 1 + + switch pic.ChromaFormat { + case 1: + sw, sh = 2, 2 + case 2: + sw = 2 + } + + y = viewOf(pic.Y, pic.Y16, pic.StrideY, pic.CropX, pic.CropY, cw, ch) + + if pic.ChromaFormat == 0 { + return y, cb, cr + } + + ux, uy := pic.CropX/sw, pic.CropY/sh + uw, uh := (cw+sw-1)/sw, (ch+sh-1)/sh + + cb = viewOf(pic.Cb, pic.Cb16, pic.StrideC, ux, uy, uw, uh) + cr = viewOf(pic.Cr, pic.Cr16, pic.StrideC, ux, uy, uw, uh) + + return y, cb, cr +} + +type colorState struct { + matrix int + fullRange bool + depth int + maxChannel int + outMax float32 + + kr, kg, kb float32 + crCoef, cbCoef float32 + gcr, gcb float32 + + tableY []float32 + tableUV []float32 + pixLUT []uint16 + + biasY, rangeY float32 + biasUV, rangeUV float32 + + ssHor, ssVer int + hasColor bool + unsupported bool + + uvIdx, uvAdj []int + yRow []uint16 + uRow, vRow [2][]uint16 + cbRow, crRow []float32 + + yf []float32 + uPad, vPad [2][]float32 + row convertRow + row16 convertRow16 + consts rowConsts +} + +func newColorState(pic *hevc.Picture, ci ColorInfo, outDepth int) *colorState { + s := &colorState{ + matrix: int(ci.Matrix), + fullRange: ci.FullRange, + depth: pic.BitDepth, + } + + // ISO/IEC 23091-2: the identity matrix describes GBR, which subsampled + // chroma cannot carry. A sequence that declares it anyway is describing + // nothing usable, so it is read as unspecified rather than refused. + if s.matrix == mcIdentity && pic.ChromaFormat != 3 && pic.ChromaFormat != 0 { + s.matrix = mcUnspec + } + + if s.matrix == mcUnspec { + s.matrix = mcBT601 + } + + s.maxChannel = 1< 1 { + return 1 + } + + return v +} + +// prepare sizes the row scratch and precomputes the chroma sample each output +// column interpolates against. +func (s *colorState) prepare(w int) { + uw := (w + s.ssHor) >> s.ssHor + + s.yRow = make([]uint16, w) + + for i := range s.uRow { + s.uRow[i] = make([]uint16, uw) + s.vRow[i] = make([]uint16, uw) + } + + s.cbRow = make([]float32, w) + s.crRow = make([]float32, w) + + s.yf = make([]float32, w) + + for i := range s.uPad { + s.uPad[i] = make([]float32, uw+2) + s.vPad[i] = make([]float32, uw+2) + } + + s.uvIdx = make([]int, w) + s.uvAdj = make([]int, w) + + for x := range w { + uvX := x >> s.ssHor + adj := 0 + + if x != 0 && !(x == w-1 && x%2 != 0) { + if x%2 != 0 { + adj = 1 + } else { + adj = -1 + } + } + + s.uvIdx[x] = uvX + s.uvAdj[x] = min(max(uvX+adj, 0), uw-1) + } +} + +// fillRow copies one row of a plane into 16-bit scratch, which lets the LUTs +// be indexed the same way at every bit depth. +func (s *colorState) fillRow(v planeView, y, n int, dst []uint16) { + dst = dst[:n] + + if v.p16 != nil { + row := v.p16[y*v.stride:] + maxCh := uint16(s.maxChannel) + + for i := range dst { + dst[i] = min(row[i], maxCh) + } + + return + } + + row := v.p8[y*v.stride : y*v.stride+n] + for i, c := range row { + dst[i] = uint16(c) + } +} + +func (s *colorState) lumaRow(v planeView, y, w int) []uint16 { + s.fillRow(v, y, w, s.yRow) + + return s.yRow +} + +// chromaRow expands one row of Cb and Cr to full width. 4:2:0 interpolates +// with the 9/3/3/1 weights libavif uses, so both packages upsample alike. +func (s *colorState) chromaRow(cb, cr planeView, y, w, h int) { + uvY := y >> s.ssVer + uw := (w + s.ssHor) >> s.ssHor + + s.fillRow(cb, uvY, uw, s.uRow[0]) + s.fillRow(cr, uvY, uw, s.vRow[0]) + + tab := s.tableUV + cbRow, crRow := s.cbRow[:w], s.crRow[:w] + + if s.ssHor == 0 { + u, v := s.uRow[0][:w], s.vRow[0][:w] + + for x := range cbRow { + cbRow[x] = tab[u[x]] + crRow[x] = tab[v[x]] + } + + return + } + + adjRow := 0 + if s.ssVer != 0 && y != 0 && !(y == h-1 && y%2 != 0) { + if y%2 != 0 { + adjRow = 1 + } else { + adjRow = -1 + } + } + + u1, v1 := s.uRow[0], s.vRow[0] + + if adjRow != 0 { + ay := min(max(uvY+adjRow, 0), cb.h-1) + + s.fillRow(cb, ay, uw, s.uRow[1]) + s.fillRow(cr, ay, uw, s.vRow[1]) + + u1, v1 = s.uRow[1], s.vRow[1] + } + + const w0, w1, w2, w3 = 9.0 / 16.0, 3.0 / 16.0, 3.0 / 16.0, 1.0 / 16.0 + + u0, v0 := s.uRow[0], s.vRow[0] + + for x := range w { + i, j := s.uvIdx[x], s.uvAdj[x] + + cbRow[x] = w0*tab[u0[i]] + w1*tab[u0[j]] + w2*tab[u1[i]] + w3*tab[u1[j]] + crRow[x] = w0*tab[v0[i]] + w1*tab[v0[j]] + w2*tab[v1[i]] + w3*tab[v1[j]] + } +} + +// rgbRow converts one row to RGB at the output depth. +func (s *colorState) rgbRow(y, cb, cr planeView, row int, dst []uint16) { + w := y.w + luma := s.lumaRow(y, row, w) + + if !s.hasColor { + for x := range w { + v := s.pixLUT[luma[x]] + dst[3*x], dst[3*x+1], dst[3*x+2] = v, v, v + } + + return + } + + s.chromaRow(cb, cr, row, w, y.h) + + tab := s.tableY + cbf, crf := s.cbRow, s.crRow + + switch s.matrix { + case mcIdentity: + for x := range w { + g, b, r := tab[luma[x]], cbf[x], crf[x] + s.store(dst[3*x:], r, g, b) + } + + case mcYCgCo: + for x := range w { + yv, cg, co := tab[luma[x]], cbf[x], crf[x] + t := yv - cg + s.store(dst[3*x:], t+co, yv+cg, t-co) + } + + default: + for x := range w { + yv, u, v := tab[luma[x]], cbf[x], crf[x] + + r := yv + s.crCoef*v + b := yv + s.cbCoef*u + g := yv - (s.gcr*v+s.gcb*u)*2/s.kg + + s.store(dst[3*x:], r, g, b) + } + } +} + +func (s *colorState) store(dst []uint16, r, g, b float32) { + dst[0] = uint16(0.5 + clampF(r)*s.outMax) + dst[1] = uint16(0.5 + clampF(g)*s.outMax) + dst[2] = uint16(0.5 + clampF(b)*s.outMax) +} + +func (s *colorState) alphaRow(v planeView, row, w int, dst []uint16) { + luma := s.lumaRow(v, row, w) + + for x := range w { + dst[x] = s.pixLUT[luma[x]] + } +} diff --git a/third_party/h265/heic/color_amd64.go b/third_party/h265/heic/color_amd64.go new file mode 100644 index 00000000..9ba29fe0 --- /dev/null +++ b/third_party/h265/heic/color_amd64.go @@ -0,0 +1,126 @@ +//go:build amd64 && !noasm + +package heic + +//go:noescape +func convertRow8AVX2(dst *uint8, yf, u0, u1, v0, v1 *float32, a *uint8, n int, c *rowConsts) + +//go:noescape +func convertRow444AVX2(dst *uint8, yf, u0, u1, v0, v1 *float32, a *uint8, n int, c *rowConsts) + +func rowFn(ssHor int) convertRow { + if ssHor == 0 { + if !hasAVX2 { + return convertRow444Go + } + + return convertRow444AVX2Row + } + if !hasAVX2 { + return convertRowGo + } + + return convertRowAVX2 +} + +func convertRow444AVX2Row(dst []uint8, yf, u0, u1, v0, v1 []float32, a []uint8, + n int, c *rowConsts, +) { + if n8 := n &^ 7; n8 > 0 { + convertRow444AVX2(&dst[0], &yf[0], &u0[0], &u1[0], &v0[0], &v1[0], &a[0], n8, c) + if n8 == n { + return + } + dst, yf, a, n = dst[4*n8:], yf[n8:], a[n8:], n-n8 + u0, u1, v0, v1 = u0[n8:], u1[n8:], v0[n8:], v1[n8:] + } + + convertRow444Go(dst, yf, u0, u1, v0, v1, a, n, c) +} + +func convertRowAVX2(dst []uint8, yf, u0, u1, v0, v1 []float32, a []uint8, + n int, c *rowConsts, +) { + if n8 := n &^ 7; n8 > 0 { + convertRow8AVX2(&dst[0], &yf[0], &u0[0], &u1[0], &v0[0], &v1[0], &a[0], n8, c) + if n8 == n { + return + } + dst, yf, a, n = dst[4*n8:], yf[n8:], a[n8:], n-n8 + u0, u1 = u0[n8/2:], u1[n8/2:] + v0, v1 = v0[n8/2:], v1[n8/2:] + } + + convertRowGo(dst, yf, u0, u1, v0, v1, a, n, c) +} + +//go:noescape +func convertRow16x8AVX2(dst *uint8, yf, u0, u1, v0, v1 *float32, a *uint16, n int, c *rowConsts) + +//go:noescape +func convertRow16x444AVX2(dst *uint8, yf, u0, u1, v0, v1 *float32, a *uint16, n int, c *rowConsts) + +func rowFn16(ssHor int) convertRow16 { + if ssHor == 0 { + if !hasAVX2 { + return convertRow16x444Go + } + + return convertRow16x444AVX2Row + } + if !hasAVX2 { + return convertRow16Go + } + + return convertRow16AVX2 +} + +func convertRow16x444AVX2Row(dst []uint8, yf, u0, u1, v0, v1 []float32, a []uint16, + n int, c *rowConsts, +) { + if n8 := n &^ 7; n8 > 0 { + convertRow16x444AVX2(&dst[0], &yf[0], &u0[0], &u1[0], &v0[0], &v1[0], &a[0], n8, c) + if n8 == n { + return + } + dst, yf, a, n = dst[8*n8:], yf[n8:], a[n8:], n-n8 + u0, u1, v0, v1 = u0[n8:], u1[n8:], v0[n8:], v1[n8:] + } + + convertRow16x444Go(dst, yf, u0, u1, v0, v1, a, n, c) +} + +func convertRow16AVX2(dst []uint8, yf, u0, u1, v0, v1 []float32, a []uint16, + n int, c *rowConsts, +) { + if n8 := n &^ 7; n8 > 0 { + convertRow16x8AVX2(&dst[0], &yf[0], &u0[0], &u1[0], &v0[0], &v1[0], &a[0], n8, c) + if n8 == n { + return + } + dst, yf, a, n = dst[8*n8:], yf[n8:], a[n8:], n-n8 + u0, u1 = u0[n8/2:], u1[n8/2:] + v0, v1 = v0[n8/2:], v1[n8/2:] + } + + convertRow16Go(dst, yf, u0, u1, v0, v1, a, n, c) +} + +//go:noescape +func normRow8AVX2(dst *float32, src *uint8, n int, bias, rng float32) + +func normRow(dst []float32, src []uint8, bias, rng float32) { + if n8 := len(dst) &^ 7; hasAVX2 && n8 > 0 { + normRow8AVX2(&dst[0], &src[0], n8, bias, rng) + if n8 == len(dst) { + return + } + dst, src = dst[n8:], src[n8:] + } + + normRowGo(dst, src, bias, rng) +} + +func normRow16(dst []float32, src []uint16, maxCh uint16, bias, rng float32) { + normRow16Go(dst, src, maxCh, bias, rng) +} diff --git a/third_party/h265/heic/color_amd64.s b/third_party/h265/heic/color_amd64.s new file mode 100644 index 00000000..45801fe1 --- /dev/null +++ b/third_party/h265/heic/color_amd64.s @@ -0,0 +1,214 @@ +//go:build amd64 && !noasm + +#include "textflag.h" + +DATA rowPermC<>+0(SB)/8, $0x0000000100000001 +DATA rowPermC<>+8(SB)/8, $0x0000000200000002 +DATA rowPermC<>+16(SB)/8, $0x0000000300000003 +DATA rowPermC<>+24(SB)/8, $0x0000000400000004 +GLOBL rowPermC<>(SB), RODATA|NOPTR, $32 + +DATA rowPermN<>+0(SB)/8, $0x0000000200000000 +DATA rowPermN<>+8(SB)/8, $0x0000000300000001 +DATA rowPermN<>+16(SB)/8, $0x0000000400000002 +DATA rowPermN<>+24(SB)/8, $0x0000000500000003 +GLOBL rowPermN<>(SB), RODATA|NOPTR, $32 + +DATA rowShuf<>+0(SB)/8, $0x0d0905010c080400 +DATA rowShuf<>+8(SB)/8, $0x0f0b07030e0a0602 +GLOBL rowShuf<>(SB), RODATA|NOPTR, $16 + +#define CHROMA(P0, P1, OUT) \ + VMOVUPS (P0), Y3; \ + VPERMPS Y3, Y13, Y4; \ + VPERMPS Y3, Y14, Y5; \ + VMULPS Y8, Y4, OUT; \ + VMULPS Y9, Y5, Y5; \ + VADDPS Y5, OUT, OUT; \ + VMOVUPS (P1), Y3; \ + VPERMPS Y3, Y13, Y4; \ + VPERMPS Y3, Y14, Y5; \ + VMULPS Y9, Y4, Y4; \ + VADDPS Y4, OUT, OUT; \ + VMULPS Y10, Y5, Y5; \ + VADDPS Y5, OUT, OUT + +#define CHROMA444(P0, P1, OUT) \ + VMOVUPS 4(P0), OUT + +#define PIXEL(V) \ + VMAXPS Y1, V, V; \ + VMINPS Y2, V, V; \ + VMULPS Y6, V, V; \ + VADDPS Y7, V, V; \ + VCVTTPS2DQ V, V + +DATA rowSwap<>+0(SB)/8, $0x0607040502030001 +DATA rowSwap<>+8(SB)/8, $0x0e0f0c0d0a0b0809 +GLOBL rowSwap<>(SB), RODATA|NOPTR, $16 + +#define ROW8(CH, UVSTEP) \ + MOVQ dst+0(FP), DI; \ + MOVQ yf+8(FP), SI; \ + MOVQ u0+16(FP), R8; \ + MOVQ u1+24(FP), R9; \ + MOVQ v0+32(FP), R10; \ + MOVQ v1+40(FP), R11; \ + MOVQ a+48(FP), R12; \ + MOVQ n+56(FP), DX; \ + MOVQ c+64(FP), CX; \ + VBROADCASTSS 0(CX), Y8; \ + VBROADCASTSS 4(CX), Y9; \ + VBROADCASTSS 8(CX), Y10; \ + VBROADCASTSS 12(CX), Y11; \ + VBROADCASTSS 16(CX), Y12; \ + VMOVDQU rowPermC<>(SB), Y13; \ + VMOVDQU rowPermN<>(SB), Y14; \ + VBROADCASTI128 rowShuf<>(SB), Y15; \ +loop:; \ + VMOVUPS (SI), Y0; \ + CH(R8, R9, Y1); \ + CH(R10, R11, Y2); \ + VMULPS Y11, Y2, Y3; \ + VADDPS Y3, Y0, Y3; \ + VMULPS Y12, Y1, Y4; \ + VADDPS Y4, Y0, Y4; \ + VBROADCASTSS 20(CX), Y5; \ + VMULPS Y5, Y2, Y5; \ + VBROADCASTSS 24(CX), Y6; \ + VMULPS Y6, Y1, Y6; \ + VADDPS Y6, Y5, Y5; \ + VADDPS Y5, Y5, Y5; \ + VBROADCASTSS 28(CX), Y6; \ + VDIVPS Y6, Y5, Y5; \ + VSUBPS Y5, Y0, Y5; \ + VPXOR Y1, Y1, Y1; \ + VBROADCASTSS 36(CX), Y2; \ + VBROADCASTSS 32(CX), Y6; \ + VBROADCASTSS 40(CX), Y7; \ + PIXEL(Y3); \ + PIXEL(Y5); \ + PIXEL(Y4); \ + VPMOVZXBD (R12), Y1; \ + VPACKUSDW Y5, Y3, Y3; \ + VPACKUSDW Y1, Y4, Y4; \ + VPACKUSWB Y4, Y3, Y3; \ + VPSHUFB Y15, Y3, Y3; \ + VMOVDQU Y3, (DI); \ + ADDQ $32, SI; \ + ADDQ $UVSTEP, R8; \ + ADDQ $UVSTEP, R9; \ + ADDQ $UVSTEP, R10; \ + ADDQ $UVSTEP, R11; \ + ADDQ $8, R12; \ + ADDQ $32, DI; \ + SUBQ $8, DX; \ + JNZ loop; \ + VZEROUPPER + +#define ROW16(CH, UVSTEP) \ + MOVQ dst+0(FP), DI; \ + MOVQ yf+8(FP), SI; \ + MOVQ u0+16(FP), R8; \ + MOVQ u1+24(FP), R9; \ + MOVQ v0+32(FP), R10; \ + MOVQ v1+40(FP), R11; \ + MOVQ a+48(FP), R12; \ + MOVQ n+56(FP), DX; \ + MOVQ c+64(FP), CX; \ + VBROADCASTSS 0(CX), Y8; \ + VBROADCASTSS 4(CX), Y9; \ + VBROADCASTSS 8(CX), Y10; \ + VBROADCASTSS 12(CX), Y11; \ + VBROADCASTSS 16(CX), Y12; \ + VMOVDQU rowPermC<>(SB), Y13; \ + VMOVDQU rowPermN<>(SB), Y14; \ + VBROADCASTI128 rowSwap<>(SB), Y15; \ +loop16:; \ + VMOVUPS (SI), Y0; \ + CH(R8, R9, Y1); \ + CH(R10, R11, Y2); \ + VMULPS Y11, Y2, Y3; \ + VADDPS Y3, Y0, Y3; \ + VMULPS Y12, Y1, Y4; \ + VADDPS Y4, Y0, Y4; \ + VBROADCASTSS 20(CX), Y5; \ + VMULPS Y5, Y2, Y5; \ + VBROADCASTSS 24(CX), Y6; \ + VMULPS Y6, Y1, Y6; \ + VADDPS Y6, Y5, Y5; \ + VADDPS Y5, Y5, Y5; \ + VBROADCASTSS 28(CX), Y6; \ + VDIVPS Y6, Y5, Y5; \ + VSUBPS Y5, Y0, Y5; \ + VPXOR Y1, Y1, Y1; \ + VBROADCASTSS 36(CX), Y2; \ + VBROADCASTSS 32(CX), Y6; \ + VBROADCASTSS 40(CX), Y7; \ + PIXEL(Y3); \ + PIXEL(Y5); \ + PIXEL(Y4); \ + VPMOVZXWD (R12), Y1; \ + VPACKUSDW Y5, Y3, Y3; \ + VPACKUSDW Y1, Y4, Y4; \ + VPUNPCKLWD Y4, Y3, Y0; \ + VPUNPCKHWD Y4, Y3, Y2; \ + VPUNPCKLWD Y2, Y0, Y5; \ + VPUNPCKHWD Y2, Y0, Y6; \ + VPERM2I128 $0x20, Y6, Y5, Y7; \ + VPERM2I128 $0x31, Y6, Y5, Y1; \ + VPSHUFB Y15, Y7, Y7; \ + VPSHUFB Y15, Y1, Y1; \ + VMOVDQU Y7, (DI); \ + VMOVDQU Y1, 32(DI); \ + ADDQ $32, SI; \ + ADDQ $UVSTEP, R8; \ + ADDQ $UVSTEP, R9; \ + ADDQ $UVSTEP, R10; \ + ADDQ $UVSTEP, R11; \ + ADDQ $16, R12; \ + ADDQ $64, DI; \ + SUBQ $8, DX; \ + JNZ loop16; \ + VZEROUPPER + +// func convertRow8AVX2(dst *uint8, yf, u0, u1, v0, v1 *float32, a *uint8, n int, c *rowConsts) +TEXT ·convertRow8AVX2(SB), NOSPLIT, $0-72 + ROW8(CHROMA, 16) + RET + +// func convertRow444AVX2(dst *uint8, yf, u0, u1, v0, v1 *float32, a *uint8, n int, c *rowConsts) +TEXT ·convertRow444AVX2(SB), NOSPLIT, $0-72 + ROW8(CHROMA444, 32) + RET + +// func convertRow16x8AVX2(dst *uint8, yf, u0, u1, v0, v1 *float32, a *uint16, n int, c *rowConsts) +TEXT ·convertRow16x8AVX2(SB), NOSPLIT, $0-72 + ROW16(CHROMA, 16) + RET + +// func convertRow16x444AVX2(dst *uint8, yf, u0, u1, v0, v1 *float32, a *uint16, n int, c *rowConsts) +TEXT ·convertRow16x444AVX2(SB), NOSPLIT, $0-72 + ROW16(CHROMA444, 32) + RET + +// func normRow8AVX2(dst *float32, src *uint8, n int, bias, rng float32) +TEXT ·normRow8AVX2(SB), NOSPLIT, $0-32 + MOVQ dst+0(FP), DI + MOVQ src+8(FP), SI + MOVQ n+16(FP), DX + VBROADCASTSS bias+24(FP), Y1 + VBROADCASTSS rng+28(FP), Y2 + +loop8: + VPMOVZXBD (SI), Y0 + VCVTDQ2PS Y0, Y0 + VSUBPS Y1, Y0, Y0 + VDIVPS Y2, Y0, Y0 + VMOVUPS Y0, (DI) + ADDQ $8, SI + ADDQ $32, DI + SUBQ $8, DX + JNZ loop8 + VZEROUPPER + RET diff --git a/third_party/h265/heic/color_arm64.go b/third_party/h265/heic/color_arm64.go new file mode 100644 index 00000000..d375136c --- /dev/null +++ b/third_party/h265/heic/color_arm64.go @@ -0,0 +1,112 @@ +//go:build arm64 && !noasm + +package heic + +//go:noescape +func convertRow4NEON(dst *uint8, yf, u0, u1, v0, v1 *float32, a *uint8, n int, c *rowConsts) + +//go:noescape +func convertRow444NEON(dst *uint8, yf, u0, u1, v0, v1 *float32, a *uint8, n int, c *rowConsts) + +func rowFn(ssHor int) convertRow { + if ssHor == 0 { + return convertRow444NEONRow + } + + return convertRowNEON +} + +func convertRow444NEONRow(dst []uint8, yf, u0, u1, v0, v1 []float32, a []uint8, + n int, c *rowConsts, +) { + if n4 := n &^ 3; n4 > 0 { + convertRow444NEON(&dst[0], &yf[0], &u0[0], &u1[0], &v0[0], &v1[0], &a[0], n4, c) + if n4 == n { + return + } + dst, yf, a, n = dst[4*n4:], yf[n4:], a[n4:], n-n4 + u0, u1, v0, v1 = u0[n4:], u1[n4:], v0[n4:], v1[n4:] + } + + convertRow444Go(dst, yf, u0, u1, v0, v1, a, n, c) +} + +func convertRowNEON(dst []uint8, yf, u0, u1, v0, v1 []float32, a []uint8, + n int, c *rowConsts, +) { + if n4 := n &^ 3; n4 > 0 { + convertRow4NEON(&dst[0], &yf[0], &u0[0], &u1[0], &v0[0], &v1[0], &a[0], n4, c) + if n4 == n { + return + } + dst, yf, a, n = dst[4*n4:], yf[n4:], a[n4:], n-n4 + u0, u1 = u0[n4/2:], u1[n4/2:] + v0, v1 = v0[n4/2:], v1[n4/2:] + } + + convertRowGo(dst, yf, u0, u1, v0, v1, a, n, c) +} + +//go:noescape +func convertRow16x4NEON(dst *uint8, yf, u0, u1, v0, v1 *float32, a *uint16, n int, c *rowConsts) + +//go:noescape +func convertRow16x444NEON(dst *uint8, yf, u0, u1, v0, v1 *float32, a *uint16, n int, c *rowConsts) + +func rowFn16(ssHor int) convertRow16 { + if ssHor == 0 { + return convertRow16x444NEONRow + } + + return convertRow16NEON +} + +func convertRow16x444NEONRow(dst []uint8, yf, u0, u1, v0, v1 []float32, a []uint16, + n int, c *rowConsts, +) { + if n4 := n &^ 3; n4 > 0 { + convertRow16x444NEON(&dst[0], &yf[0], &u0[0], &u1[0], &v0[0], &v1[0], &a[0], n4, c) + if n4 == n { + return + } + dst, yf, a, n = dst[8*n4:], yf[n4:], a[n4:], n-n4 + u0, u1, v0, v1 = u0[n4:], u1[n4:], v0[n4:], v1[n4:] + } + + convertRow16x444Go(dst, yf, u0, u1, v0, v1, a, n, c) +} + +func convertRow16NEON(dst []uint8, yf, u0, u1, v0, v1 []float32, a []uint16, + n int, c *rowConsts, +) { + if n4 := n &^ 3; n4 > 0 { + convertRow16x4NEON(&dst[0], &yf[0], &u0[0], &u1[0], &v0[0], &v1[0], &a[0], n4, c) + if n4 == n { + return + } + dst, yf, a, n = dst[8*n4:], yf[n4:], a[n4:], n-n4 + u0, u1 = u0[n4/2:], u1[n4/2:] + v0, v1 = v0[n4/2:], v1[n4/2:] + } + + convertRow16Go(dst, yf, u0, u1, v0, v1, a, n, c) +} + +//go:noescape +func normRow8NEON(dst *float32, src *uint8, n int, bias, rng float32) + +func normRow(dst []float32, src []uint8, bias, rng float32) { + if n8 := len(dst) &^ 7; n8 > 0 { + normRow8NEON(&dst[0], &src[0], n8, bias, rng) + if n8 == len(dst) { + return + } + dst, src = dst[n8:], src[n8:] + } + + normRowGo(dst, src, bias, rng) +} + +func normRow16(dst []float32, src []uint16, maxCh uint16, bias, rng float32) { + normRow16Go(dst, src, maxCh, bias, rng) +} diff --git a/third_party/h265/heic/color_arm64.s b/third_party/h265/heic/color_arm64.s new file mode 100644 index 00000000..3f82d497 --- /dev/null +++ b/third_party/h265/heic/color_arm64.s @@ -0,0 +1,259 @@ +//go:build arm64 && !noasm + +#include "textflag.h" + +#define FMUL(Vd, Vn, Vm) WORD $(0x6E20DC00 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define FADD(Vd, Vn, Vm) WORD $(0x4E20D400 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define FSUB(Vd, Vn, Vm) WORD $(0x4EA0D400 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define FDIV(Vd, Vn, Vm) WORD $(0x6E20FC00 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define FMAX(Vd, Vn, Vm) WORD $(0x4E20F400 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define FMIN(Vd, Vn, Vm) WORD $(0x4EA0F400 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define FCVTZS(Vd, Vn) WORD $(0x4EA1B800 | ((Vn) << 5) | (Vd)) + +DATA rowSelC<>+0(SB)/8, $0x0706050407060504 +DATA rowSelC<>+8(SB)/8, $0x0b0a09080b0a0908 +GLOBL rowSelC<>(SB), RODATA|NOPTR, $16 + +DATA rowSelN<>+0(SB)/8, $0x0b0a090803020100 +DATA rowSelN<>+8(SB)/8, $0x0f0e0d0c07060504 +GLOBL rowSelN<>(SB), RODATA|NOPTR, $16 + +DATA rowSelP<>+0(SB)/8, $0x3424140430201000 +DATA rowSelP<>+8(SB)/8, $0x3c2c1c0c38281808 +GLOBL rowSelP<>(SB), RODATA|NOPTR, $16 + +#define CHROMA(P0, P1, OUT, VOUT) \ + VLD1 (P0), [V4.S4]; \ + VTBL V16.B16, [V4.B16], V5.B16; \ + VTBL V17.B16, [V4.B16], V6.B16; \ + FMUL(OUT, 5, 20); \ + FMUL(6, 6, 21); \ + FADD(OUT, OUT, 6); \ + VLD1 (P1), [V4.S4]; \ + VTBL V16.B16, [V4.B16], V5.B16; \ + VTBL V17.B16, [V4.B16], V6.B16; \ + FMUL(5, 5, 21); \ + FADD(OUT, OUT, 5); \ + FMUL(6, 6, 22); \ + FADD(OUT, OUT, 6) + +#define CHROMA444(P0, P1, OUT, VOUT) \ + ADD $4, P0, R9; \ + VLD1 (R9), [VOUT] + +#define PIXEL(V) \ + FMAX(V, V, 24); \ + FMIN(V, V, 25); \ + FMUL(V, V, 26); \ + FADD(V, V, 27); \ + FCVTZS(V, V) + +#define ROW8(CH, UVSTEP) \ + MOVD dst+0(FP), R0; \ + MOVD yf+8(FP), R1; \ + MOVD u0+16(FP), R2; \ + MOVD u1+24(FP), R3; \ + MOVD v0+32(FP), R4; \ + MOVD v1+40(FP), R5; \ + MOVD a+48(FP), R6; \ + MOVD n+56(FP), R7; \ + MOVD c+64(FP), R8; \ + MOVD $rowSelC<>(SB), R9; \ + VLD1 (R9), [V16.B16]; \ + MOVD $rowSelN<>(SB), R9; \ + VLD1 (R9), [V17.B16]; \ + MOVD $rowSelP<>(SB), R9; \ + VLD1 (R9), [V18.B16]; \ + MOVW 0(R8), R9; \ + VDUP R9, V20.S4; \ + MOVW 4(R8), R9; \ + VDUP R9, V21.S4; \ + MOVW 8(R8), R9; \ + VDUP R9, V22.S4; \ + MOVW 12(R8), R9; \ + VDUP R9, V23.S4; \ + MOVW 16(R8), R9; \ + VDUP R9, V28.S4; \ + MOVW 20(R8), R9; \ + VDUP R9, V29.S4; \ + MOVW 24(R8), R9; \ + VDUP R9, V30.S4; \ + MOVW 28(R8), R9; \ + VDUP R9, V31.S4; \ + MOVW 32(R8), R9; \ + VDUP R9, V26.S4; \ + MOVW 36(R8), R9; \ + VDUP R9, V25.S4; \ + MOVW 40(R8), R9; \ + VDUP R9, V27.S4; \ + VEOR V24.B16, V24.B16, V24.B16; \ +loop: \ + VLD1 (R1), [V0.S4]; \ + CH(R2, R3, 1, V1.S4); \ + CH(R4, R5, 2, V2.S4); \ + FMUL(7, 2, 23); \ + FADD(7, 0, 7); \ + FMUL(8, 1, 28); \ + FADD(8, 0, 8); \ + FMUL(9, 2, 29); \ + FMUL(10, 1, 30); \ + FADD(9, 9, 10); \ + FADD(9, 9, 9); \ + FDIV(9, 9, 31); \ + FSUB(9, 0, 9); \ + PIXEL(7); \ + PIXEL(9); \ + PIXEL(8); \ + VLD1 (R6), [V10.B8]; \ + VUSHLL $0, V10.B8, V10.H8; \ + VUSHLL $0, V10.H4, V10.S4; \ + VMOV V7.B16, V12.B16; \ + VMOV V9.B16, V13.B16; \ + VMOV V8.B16, V14.B16; \ + VMOV V10.B16, V15.B16; \ + VTBL V18.B16, [V12.B16, V13.B16, V14.B16, V15.B16], V11.B16; \ + VST1 [V11.B16], (R0); \ + ADD $16, R1; \ + ADD $UVSTEP, R2; \ + ADD $UVSTEP, R3; \ + ADD $UVSTEP, R4; \ + ADD $UVSTEP, R5; \ + ADD $4, R6; \ + ADD $16, R0; \ + SUB $4, R7; \ + CBNZ R7, loop + +#define ROW16(CH, UVSTEP) \ + MOVD dst+0(FP), R0; \ + MOVD yf+8(FP), R1; \ + MOVD u0+16(FP), R2; \ + MOVD u1+24(FP), R3; \ + MOVD v0+32(FP), R4; \ + MOVD v1+40(FP), R5; \ + MOVD a+48(FP), R6; \ + MOVD n+56(FP), R7; \ + MOVD c+64(FP), R8; \ + MOVD $rowSelC<>(SB), R9; \ + VLD1 (R9), [V16.B16]; \ + MOVD $rowSelN<>(SB), R9; \ + VLD1 (R9), [V17.B16]; \ + MOVD $rowSelD<>(SB), R9; \ + VLD1 (R9), [V18.B16]; \ + MOVD $rowSelE<>(SB), R9; \ + VLD1 (R9), [V19.B16]; \ + MOVW 0(R8), R9; \ + VDUP R9, V20.S4; \ + MOVW 4(R8), R9; \ + VDUP R9, V21.S4; \ + MOVW 8(R8), R9; \ + VDUP R9, V22.S4; \ + MOVW 12(R8), R9; \ + VDUP R9, V23.S4; \ + MOVW 16(R8), R9; \ + VDUP R9, V28.S4; \ + MOVW 20(R8), R9; \ + VDUP R9, V29.S4; \ + MOVW 24(R8), R9; \ + VDUP R9, V30.S4; \ + MOVW 28(R8), R9; \ + VDUP R9, V31.S4; \ + MOVW 32(R8), R9; \ + VDUP R9, V26.S4; \ + MOVW 36(R8), R9; \ + VDUP R9, V25.S4; \ + MOVW 40(R8), R9; \ + VDUP R9, V27.S4; \ + VEOR V24.B16, V24.B16, V24.B16; \ +loop16: \ + VLD1 (R1), [V0.S4]; \ + CH(R2, R3, 1, V1.S4); \ + CH(R4, R5, 2, V2.S4); \ + FMUL(7, 2, 23); \ + FADD(7, 0, 7); \ + FMUL(8, 1, 28); \ + FADD(8, 0, 8); \ + FMUL(9, 2, 29); \ + FMUL(10, 1, 30); \ + FADD(9, 9, 10); \ + FADD(9, 9, 9); \ + FDIV(9, 9, 31); \ + FSUB(9, 0, 9); \ + PIXEL(7); \ + PIXEL(9); \ + PIXEL(8); \ + VLD1 (R6), [V10.H4]; \ + VUSHLL $0, V10.H4, V10.S4; \ + VMOV V7.B16, V12.B16; \ + VMOV V9.B16, V13.B16; \ + VMOV V8.B16, V14.B16; \ + VMOV V10.B16, V15.B16; \ + VTBL V18.B16, [V12.B16, V13.B16, V14.B16, V15.B16], V11.B16; \ + VST1.P [V11.B16], 16(R0); \ + VTBL V19.B16, [V12.B16, V13.B16, V14.B16, V15.B16], V11.B16; \ + VST1.P [V11.B16], 16(R0); \ + ADD $16, R1; \ + ADD $UVSTEP, R2; \ + ADD $UVSTEP, R3; \ + ADD $UVSTEP, R4; \ + ADD $UVSTEP, R5; \ + ADD $8, R6; \ + SUB $4, R7; \ + CBNZ R7, loop16 + +DATA rowSelD<>+0(SB)/8, $0x3031202110110001 +DATA rowSelD<>+8(SB)/8, $0x3435242514150405 +GLOBL rowSelD<>(SB), RODATA|NOPTR, $16 + +DATA rowSelE<>+0(SB)/8, $0x3839282918190809 +DATA rowSelE<>+8(SB)/8, $0x3c3d2c2d1c1d0c0d +GLOBL rowSelE<>(SB), RODATA|NOPTR, $16 + +// func convertRow4NEON(dst *uint8, yf, u0, u1, v0, v1 *float32, a *uint8, n int, c *rowConsts) +TEXT ·convertRow4NEON(SB), NOSPLIT, $0-72 + ROW8(CHROMA, 8) + RET + +// func convertRow444NEON(dst *uint8, yf, u0, u1, v0, v1 *float32, a *uint8, n int, c *rowConsts) +TEXT ·convertRow444NEON(SB), NOSPLIT, $0-72 + ROW8(CHROMA444, 16) + RET + +// func convertRow16x4NEON(dst *uint8, yf, u0, u1, v0, v1 *float32, a *uint16, n int, c *rowConsts) +TEXT ·convertRow16x4NEON(SB), NOSPLIT, $0-72 + ROW16(CHROMA, 8) + RET + +// func convertRow16x444NEON(dst *uint8, yf, u0, u1, v0, v1 *float32, a *uint16, n int, c *rowConsts) +TEXT ·convertRow16x444NEON(SB), NOSPLIT, $0-72 + ROW16(CHROMA444, 16) + RET + +#define UCVTF(Vd, Vn) WORD $(0x6E21D800 | ((Vn) << 5) | (Vd)) +#define UMINH(Vd, Vn, Vm) WORD $(0x6E606C00 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) + +// func normRow8NEON(dst *float32, src *uint8, n int, bias, rng float32) +TEXT ·normRow8NEON(SB), NOSPLIT, $0-32 + MOVD dst+0(FP), R0 + MOVD src+8(FP), R1 + MOVD n+16(FP), R2 + MOVW bias+24(FP), R3 + VDUP R3, V1.S4 + MOVW rng+28(FP), R3 + VDUP R3, V2.S4 + +loop8: + VLD1.P 8(R1), [V0.B8] + VUSHLL $0, V0.B8, V0.H8 + VUSHLL $0, V0.H4, V3.S4 + VUSHLL2 $0, V0.H8, V4.S4 + UCVTF(3, 3) + UCVTF(4, 4) + FSUB(3, 3, 1) + FSUB(4, 4, 1) + FDIV(3, 3, 2) + FDIV(4, 4, 2) + VST1.P [V3.S4, V4.S4], 32(R0) + SUB $8, R2 + CBNZ R2, loop8 + + RET diff --git a/third_party/h265/heic/color_noasm.go b/third_party/h265/heic/color_noasm.go new file mode 100644 index 00000000..ccc35eba --- /dev/null +++ b/third_party/h265/heic/color_noasm.go @@ -0,0 +1,27 @@ +//go:build noasm || (!amd64 && !arm64 && !(riscv64 && riscv64.rva23u64)) + +package heic + +func rowFn(ssHor int) convertRow { + if ssHor == 0 { + return convertRow444Go + } + + return convertRowGo +} + +func rowFn16(ssHor int) convertRow16 { + if ssHor == 0 { + return convertRow16x444Go + } + + return convertRow16Go +} + +func normRow(dst []float32, src []uint8, bias, rng float32) { + normRowGo(dst, src, bias, rng) +} + +func normRow16(dst []float32, src []uint16, maxCh uint16, bias, rng float32) { + normRow16Go(dst, src, maxCh, bias, rng) +} diff --git a/third_party/h265/heic/color_riscv64.go b/third_party/h265/heic/color_riscv64.go new file mode 100644 index 00000000..95953884 --- /dev/null +++ b/third_party/h265/heic/color_riscv64.go @@ -0,0 +1,92 @@ +//go:build riscv64 && riscv64.rva23u64 && !noasm + +package heic + +//go:noescape +func convertRowRVV(dst *uint8, yf, u0, u1, v0, v1 *float32, a *uint8, n int, c *rowConsts) + +//go:noescape +func convertRow444RVV(dst *uint8, yf, u0, u1, v0, v1 *float32, a *uint8, n int, c *rowConsts) + +func rowFn(ssHor int) convertRow { + if ssHor == 0 { + return convertRow444Vec + } + + return convertRowVec +} + +func convertRow444Vec(dst []uint8, yf, u0, u1, v0, v1 []float32, a []uint8, + n int, c *rowConsts, +) { + if n > 0 { + convertRow444RVV(&dst[0], &yf[0], &u0[0], &u1[0], &v0[0], &v1[0], &a[0], n, c) + } +} + +func convertRowVec(dst []uint8, yf, u0, u1, v0, v1 []float32, a []uint8, + n int, c *rowConsts, +) { + if n4 := n &^ 3; n4 > 0 { + convertRowRVV(&dst[0], &yf[0], &u0[0], &u1[0], &v0[0], &v1[0], &a[0], n4, c) + if n4 == n { + return + } + dst, yf, a, n = dst[4*n4:], yf[n4:], a[n4:], n-n4 + u0, u1 = u0[n4/2:], u1[n4/2:] + v0, v1 = v0[n4/2:], v1[n4/2:] + } + + convertRowGo(dst, yf, u0, u1, v0, v1, a, n, c) +} + +//go:noescape +func convertRow16RVV(dst *uint8, yf, u0, u1, v0, v1 *float32, a *uint16, n int, c *rowConsts) + +//go:noescape +func convertRow16x444RVV(dst *uint8, yf, u0, u1, v0, v1 *float32, a *uint16, n int, c *rowConsts) + +func rowFn16(ssHor int) convertRow16 { + if ssHor == 0 { + return convertRow16x444Vec + } + + return convertRow16Vec +} + +func convertRow16x444Vec(dst []uint8, yf, u0, u1, v0, v1 []float32, a []uint16, + n int, c *rowConsts, +) { + if n > 0 { + convertRow16x444RVV(&dst[0], &yf[0], &u0[0], &u1[0], &v0[0], &v1[0], &a[0], n, c) + } +} + +func convertRow16Vec(dst []uint8, yf, u0, u1, v0, v1 []float32, a []uint16, + n int, c *rowConsts, +) { + if n4 := n &^ 3; n4 > 0 { + convertRow16RVV(&dst[0], &yf[0], &u0[0], &u1[0], &v0[0], &v1[0], &a[0], n4, c) + if n4 == n { + return + } + dst, yf, a, n = dst[8*n4:], yf[n4:], a[n4:], n-n4 + u0, u1 = u0[n4/2:], u1[n4/2:] + v0, v1 = v0[n4/2:], v1[n4/2:] + } + + convertRow16Go(dst, yf, u0, u1, v0, v1, a, n, c) +} + +//go:noescape +func normRow8RVV(dst *float32, src *uint8, n int, bias, rng float32) + +func normRow(dst []float32, src []uint8, bias, rng float32) { + if len(dst) > 0 { + normRow8RVV(&dst[0], &src[0], len(dst), bias, rng) + } +} + +func normRow16(dst []float32, src []uint16, maxCh uint16, bias, rng float32) { + normRow16Go(dst, src, maxCh, bias, rng) +} diff --git a/third_party/h265/heic/color_riscv64.s b/third_party/h265/heic/color_riscv64.s new file mode 100644 index 00000000..4237735d --- /dev/null +++ b/third_party/h265/heic/color_riscv64.s @@ -0,0 +1,392 @@ +//go:build riscv64 && riscv64.rva23u64 && !noasm + +#include "textflag.h" + +#define CHROMA(P0, P1, OUT) \ + VLE32V (P0), V3; \ + VRGATHERVV V6, V3, V4; \ + VRGATHERVV V7, V3, V5; \ + VFMULVF F0, V4, OUT; \ + VFMULVF F1, V5, V5; \ + VFADDVV V5, OUT, OUT; \ + VLE32V (P1), V3; \ + VRGATHERVV V6, V3, V4; \ + VRGATHERVV V7, V3, V5; \ + VFMULVF F1, V4, V4; \ + VFADDVV V4, OUT, OUT; \ + VFMULVF F2, V5, V5; \ + VFADDVV V5, OUT, OUT + +#define PIXEL(V) \ + VFMAXVF F11, V, V; \ + VFMINVF F9, V, V; \ + VFMULVF F8, V, V; \ + VFADDVF F10, V, V; \ + VFCVTRTZXFV V, V + +#define NARROW(SRC, DST) \ + VSETVLI X5, E16, MF2, TA, MA, X6; \ + VNSRLWI $0, SRC, V20; \ + VSETVLI X5, E8, MF4, TA, MA, X6; \ + VNSRLWI $0, V20, DST; \ + VSETVLI X5, E32, M1, TA, MA, X6 + +// func convertRowRVV(dst *uint8, yf, u0, u1, v0, v1 *float32, a *uint8, n int, c *rowConsts) +TEXT ·convertRowRVV(SB), NOSPLIT, $0-72 + MOV dst+0(FP), X10 + MOV yf+8(FP), X11 + MOV u0+16(FP), X12 + MOV u1+24(FP), X13 + MOV v0+32(FP), X14 + MOV v1+40(FP), X15 + MOV a+48(FP), X16 + MOV n+56(FP), X17 + MOV c+64(FP), X18 + + MOVF 0(X18), F0 + MOVF 4(X18), F1 + MOVF 8(X18), F2 + MOVF 12(X18), F3 + MOVF 16(X18), F4 + MOVF 20(X18), F5 + MOVF 24(X18), F6 + MOVF 28(X18), F7 + MOVF 32(X18), F8 + MOVF 36(X18), F9 + MOVF 40(X18), F10 + MOVF 44(X18), F11 + +loop: + MOV $16, X19 + MIN X17, X19, X19 + VSETVLI X19, E32, M1, TA, MA, X5 + + VIDV V3 + VSRLVI $1, V3, V4 + VADDVI $1, V4, V6 + VANDVI $1, V3, V5 + VADDVV V5, V5, V5 + VADDVV V5, V4, V7 + + VLE32V (X11), V0 + CHROMA(X12, X13, V1) + CHROMA(X14, X15, V2) + + VFMULVF F3, V2, V8 + VFADDVV V0, V8, V8 + + VFMULVF F4, V1, V10 + VFADDVV V0, V10, V10 + + VFMULVF F5, V2, V9 + VFMULVF F6, V1, V4 + VFADDVV V4, V9, V9 + VFADDVV V9, V9, V9 + VFDIVVF F7, V9, V9 + VFSUBVV V9, V0, V9 + + PIXEL(V8) + PIXEL(V9) + PIXEL(V10) + + VSETVLI X5, E8, MF4, TA, MA, X6 + VLE8V (X16), V11 + VSETVLI X5, E32, M1, TA, MA, X6 + VZEXTVF4 V11, V12 + + NARROW(V8, V16) + NARROW(V9, V17) + NARROW(V10, V18) + NARROW(V12, V19) + + VSETVLI X5, E8, MF4, TA, MA, X6 + VSSEG4E8V V16, (X10) + VSETVLI X5, E32, M1, TA, MA, X6 + + SLLI $2, X5, X6 + ADD X6, X11 + ADD X6, X10 + ADD X6, X10 + ADD X6, X10 + ADD X6, X10 + SUB X6, X10 + SUB X6, X10 + SUB X6, X10 + SRLI $1, X6, X20 + ADD X20, X12 + ADD X20, X13 + ADD X20, X14 + ADD X20, X15 + ADD X5, X16 + SUB X5, X17 + BNEZ X17, loop + + RET + +#define SWAP16(SRC, DST) \ + VSETVLI X5, E16, MF2, TA, MA, X6; \ + VNSRLWI $0, SRC, DST; \ + VSRLVI $8, DST, V20; \ + VSLLVI $8, DST, DST; \ + VORVV V20, DST, DST; \ + VSETVLI X5, E32, M1, TA, MA, X6 + +// func convertRow16RVV(dst *uint8, yf, u0, u1, v0, v1 *float32, a *uint16, n int, c *rowConsts) +TEXT ·convertRow16RVV(SB), NOSPLIT, $0-72 + MOV dst+0(FP), X10 + MOV yf+8(FP), X11 + MOV u0+16(FP), X12 + MOV u1+24(FP), X13 + MOV v0+32(FP), X14 + MOV v1+40(FP), X15 + MOV a+48(FP), X16 + MOV n+56(FP), X17 + MOV c+64(FP), X18 + + MOVF 0(X18), F0 + MOVF 4(X18), F1 + MOVF 8(X18), F2 + MOVF 12(X18), F3 + MOVF 16(X18), F4 + MOVF 20(X18), F5 + MOVF 24(X18), F6 + MOVF 28(X18), F7 + MOVF 32(X18), F8 + MOVF 36(X18), F9 + MOVF 40(X18), F10 + MOVF 44(X18), F11 + +loop16: + MOV $16, X19 + MIN X17, X19, X19 + VSETVLI X19, E32, M1, TA, MA, X5 + + VIDV V3 + VSRLVI $1, V3, V4 + VADDVI $1, V4, V6 + VANDVI $1, V3, V5 + VADDVV V5, V5, V5 + VADDVV V5, V4, V7 + + VLE32V (X11), V0 + CHROMA(X12, X13, V1) + CHROMA(X14, X15, V2) + + VFMULVF F3, V2, V8 + VFADDVV V0, V8, V8 + + VFMULVF F4, V1, V10 + VFADDVV V0, V10, V10 + + VFMULVF F5, V2, V9 + VFMULVF F6, V1, V4 + VFADDVV V4, V9, V9 + VFADDVV V9, V9, V9 + VFDIVVF F7, V9, V9 + VFSUBVV V9, V0, V9 + + PIXEL(V8) + PIXEL(V9) + PIXEL(V10) + + VSETVLI X5, E16, MF2, TA, MA, X6 + VLE16V (X16), V11 + VSETVLI X5, E32, M1, TA, MA, X6 + VZEXTVF2 V11, V12 + + SWAP16(V8, V16) + SWAP16(V9, V17) + SWAP16(V10, V18) + SWAP16(V12, V19) + + VSETVLI X5, E16, MF2, TA, MA, X6 + VSSEG4E16V V16, (X10) + VSETVLI X5, E32, M1, TA, MA, X6 + + SLLI $2, X5, X6 + ADD X6, X11 + SLLI $3, X5, X21 + ADD X21, X10 + SRLI $1, X6, X20 + ADD X20, X12 + ADD X20, X13 + ADD X20, X14 + ADD X20, X15 + SLLI $1, X5, X22 + ADD X22, X16 + SUB X5, X17 + BNEZ X17, loop16 + + RET + +// func convertRow444RVV(dst *uint8, yf, u0, u1, v0, v1 *float32, a *uint8, n int, c *rowConsts) +TEXT ·convertRow444RVV(SB), NOSPLIT, $0-72 + MOV dst+0(FP), X10 + MOV yf+8(FP), X11 + MOV u0+16(FP), X12 + MOV v0+32(FP), X14 + MOV a+48(FP), X16 + MOV n+56(FP), X17 + MOV c+64(FP), X18 + + MOVF 12(X18), F3 + MOVF 16(X18), F4 + MOVF 20(X18), F5 + MOVF 24(X18), F6 + MOVF 28(X18), F7 + MOVF 32(X18), F8 + MOVF 36(X18), F9 + MOVF 40(X18), F10 + MOVF 44(X18), F11 + +l444: + MOV $16, X19 + MIN X17, X19, X19 + VSETVLI X19, E32, M1, TA, MA, X5 + + VLE32V (X11), V0 + ADD $4, X12, X23 + VLE32V (X23), V1 + ADD $4, X14, X23 + VLE32V (X23), V2 + + VFMULVF F3, V2, V8 + VFADDVV V0, V8, V8 + + VFMULVF F4, V1, V10 + VFADDVV V0, V10, V10 + + VFMULVF F5, V2, V9 + VFMULVF F6, V1, V4 + VFADDVV V4, V9, V9 + VFADDVV V9, V9, V9 + VFDIVVF F7, V9, V9 + VFSUBVV V9, V0, V9 + + PIXEL(V8) + PIXEL(V9) + PIXEL(V10) + + VSETVLI X5, E8, MF4, TA, MA, X6 + VLE8V (X16), V11 + VSETVLI X5, E32, M1, TA, MA, X6 + VZEXTVF4 V11, V12 + + NARROW(V8, V16) + NARROW(V9, V17) + NARROW(V10, V18) + NARROW(V12, V19) + + VSETVLI X5, E8, MF4, TA, MA, X6 + VSSEG4E8V V16, (X10) + VSETVLI X5, E32, M1, TA, MA, X6 + + SLLI $2, X5, X6 + ADD X6, X11 + ADD X6, X12 + ADD X6, X14 + ADD X6, X10 + ADD X5, X16 + SUB X5, X17 + BNEZ X17, l444 + + RET + +// func convertRow16x444RVV(dst *uint8, yf, u0, u1, v0, v1 *float32, a *uint16, n int, c *rowConsts) +TEXT ·convertRow16x444RVV(SB), NOSPLIT, $0-72 + MOV dst+0(FP), X10 + MOV yf+8(FP), X11 + MOV u0+16(FP), X12 + MOV v0+32(FP), X14 + MOV a+48(FP), X16 + MOV n+56(FP), X17 + MOV c+64(FP), X18 + + MOVF 12(X18), F3 + MOVF 16(X18), F4 + MOVF 20(X18), F5 + MOVF 24(X18), F6 + MOVF 28(X18), F7 + MOVF 32(X18), F8 + MOVF 36(X18), F9 + MOVF 40(X18), F10 + MOVF 44(X18), F11 + +l16444: + MOV $16, X19 + MIN X17, X19, X19 + VSETVLI X19, E32, M1, TA, MA, X5 + + VLE32V (X11), V0 + ADD $4, X12, X23 + VLE32V (X23), V1 + ADD $4, X14, X23 + VLE32V (X23), V2 + + VFMULVF F3, V2, V8 + VFADDVV V0, V8, V8 + + VFMULVF F4, V1, V10 + VFADDVV V0, V10, V10 + + VFMULVF F5, V2, V9 + VFMULVF F6, V1, V4 + VFADDVV V4, V9, V9 + VFADDVV V9, V9, V9 + VFDIVVF F7, V9, V9 + VFSUBVV V9, V0, V9 + + PIXEL(V8) + PIXEL(V9) + PIXEL(V10) + + VSETVLI X5, E16, MF2, TA, MA, X6 + VLE16V (X16), V11 + VSETVLI X5, E32, M1, TA, MA, X6 + VZEXTVF2 V11, V12 + + SWAP16(V8, V16) + SWAP16(V9, V17) + SWAP16(V10, V18) + SWAP16(V12, V19) + + VSETVLI X5, E16, MF2, TA, MA, X6 + VSSEG4E16V V16, (X10) + VSETVLI X5, E32, M1, TA, MA, X6 + + SLLI $2, X5, X6 + ADD X6, X11 + ADD X6, X12 + ADD X6, X14 + SLLI $3, X5, X21 + ADD X21, X10 + SLLI $1, X5, X22 + ADD X22, X16 + SUB X5, X17 + BNEZ X17, l16444 + + RET + +// func normRow8RVV(dst *float32, src *uint8, n int, bias, rng float32) +TEXT ·normRow8RVV(SB), NOSPLIT, $0-32 + MOV dst+0(FP), X10 + MOV src+8(FP), X11 + MOV n+16(FP), X12 + MOVF bias+24(FP), F0 + MOVF rng+28(FP), F1 + +loop8: + VSETVLI X12, E32, M1, TA, MA, X13 + VLE8V (X11), V1 + VZEXTVF4 V1, V2 + VFCVTFXUV V2, V2 + VFSUBVF F0, V2, V2 + VFDIVVF F1, V2, V2 + VSE32V V2, (X10) + ADD X13, X11 + SLLI $2, X13, X14 + ADD X14, X10 + SUB X13, X12 + BNEZ X12, loop8 + + RET diff --git a/third_party/h265/heic/color_row.go b/third_party/h265/heic/color_row.go new file mode 100644 index 00000000..caafbda0 --- /dev/null +++ b/third_party/h265/heic/color_row.go @@ -0,0 +1,209 @@ +package heic + +import "encoding/binary" + +type rowConsts struct { + w0, w1, w2 float32 + crCoef, cbCoef float32 + gcr, gcb, kg float32 + outMax float32 + one, half float32 + zero float32 +} + +type convertRow func(dst []uint8, yf, u0, u1, v0, v1 []float32, a []uint8, + n int, c *rowConsts) + +type convertRow16 func(dst []uint8, yf, u0, u1, v0, v1 []float32, a []uint16, + n int, c *rowConsts) + +// forceScalarRow drops back to rgbRow, so a test can compare the two paths. +var forceScalarRow bool + +func (s *colorState) fastRow(outDepth int) bool { + if forceScalarRow { + return false + } + + if !s.hasColor { + return false + } + if s.depth == 8 != (outDepth == 8) { + return false + } + switch s.matrix { + case mcIdentity, mcYCgCo: + return false + } + + return true +} + +func (s *colorState) rowConsts() rowConsts { + const w0, w1, w2 = 9.0 / 16.0, 3.0 / 16.0, 1.0 / 16.0 + + return rowConsts{ + w0: w0, w1: w1, w2: w2, + crCoef: s.crCoef, cbCoef: s.cbCoef, + gcr: s.gcr, gcb: s.gcb, kg: s.kg, + outMax: s.outMax, one: 1, half: 0.5, + } +} + +func normRowGo(dst []float32, src []uint8, bias, rng float32) { + src = src[:len(dst)] + for i, v := range src { + dst[i] = (float32(v) - bias) / rng + } +} + +func normRow16Go(dst []float32, src []uint16, maxCh uint16, bias, rng float32) { + src = src[:len(dst)] + for i, v := range src { + dst[i] = (float32(min(v, maxCh)) - bias) / rng + } +} + +func convertRow444Go(dst []uint8, yf, u0, _, v0, _ []float32, a []uint8, + n int, c *rowConsts, +) { + for x := range n { + yy := yf[x] + cb, cr := u0[x+1], v0[x+1] + + r := yy + c.crCoef*cr + b := yy + c.cbCoef*cb + g := yy - (2*((c.gcr*cr)+(c.gcb*cb)))/c.kg + + dst[4*x] = uint8(0.5 + clampF(r)*c.outMax) + dst[4*x+1] = uint8(0.5 + clampF(g)*c.outMax) + dst[4*x+2] = uint8(0.5 + clampF(b)*c.outMax) + dst[4*x+3] = a[x] + } +} + +func convertRow16x444Go(dst []uint8, yf, u0, _, v0, _ []float32, a []uint16, + n int, c *rowConsts, +) { + for x := range n { + yy := yf[x] + cb, cr := u0[x+1], v0[x+1] + + r := yy + c.crCoef*cr + b := yy + c.cbCoef*cb + g := yy - (2*((c.gcr*cr)+(c.gcb*cb)))/c.kg + + o := dst[8*x : 8*x+8 : 8*x+8] + binary.BigEndian.PutUint16(o[0:], uint16(0.5+clampF(r)*c.outMax)) + binary.BigEndian.PutUint16(o[2:], uint16(0.5+clampF(g)*c.outMax)) + binary.BigEndian.PutUint16(o[4:], uint16(0.5+clampF(b)*c.outMax)) + binary.BigEndian.PutUint16(o[6:], a[x]) + } +} + +func convertRow16Go(dst []uint8, yf, u0, u1, v0, v1 []float32, a []uint16, + n int, c *rowConsts, +) { + for x := range n { + k := x >> 1 + j := k + if x&1 != 0 { + j = k + 2 + } + k++ + + yy := yf[x] + cb := u0[k]*c.w0 + u0[j]*c.w1 + u1[k]*c.w1 + u1[j]*c.w2 + cr := v0[k]*c.w0 + v0[j]*c.w1 + v1[k]*c.w1 + v1[j]*c.w2 + + r := yy + c.crCoef*cr + b := yy + c.cbCoef*cb + g := yy - (2*((c.gcr*cr)+(c.gcb*cb)))/c.kg + + o := dst[8*x : 8*x+8 : 8*x+8] + binary.BigEndian.PutUint16(o[0:], uint16(0.5+clampF(r)*c.outMax)) + binary.BigEndian.PutUint16(o[2:], uint16(0.5+clampF(g)*c.outMax)) + binary.BigEndian.PutUint16(o[4:], uint16(0.5+clampF(b)*c.outMax)) + binary.BigEndian.PutUint16(o[6:], a[x]) + } +} + +func convertRowGo(dst []uint8, yf, u0, u1, v0, v1 []float32, a []uint8, + n int, c *rowConsts, +) { + for x := range n { + k := x >> 1 + j := k + if x&1 != 0 { + j = k + 2 + } + k++ + + yy := yf[x] + cb := u0[k]*c.w0 + u0[j]*c.w1 + u1[k]*c.w1 + u1[j]*c.w2 + cr := v0[k]*c.w0 + v0[j]*c.w1 + v1[k]*c.w1 + v1[j]*c.w2 + + r := yy + c.crCoef*cr + b := yy + c.cbCoef*cb + g := yy - (2*((c.gcr*cr)+(c.gcb*cb)))/c.kg + + dst[4*x] = uint8(0.5 + clampF(r)*c.outMax) + dst[4*x+1] = uint8(0.5 + clampF(g)*c.outMax) + dst[4*x+2] = uint8(0.5 + clampF(b)*c.outMax) + dst[4*x+3] = a[x] + } +} + +// rowPlanes normalises the chroma rows one output row needs, padded by one +// sample at each end so the kernels can read a neighbour without a bounds +// check at the edges. +func (s *colorState) rowPlanes(cb, cr planeView, y, w, h int) (u0, u1, v0, v1 []float32) { + uvY := y >> s.ssVer + uw := (w + s.ssHor) >> s.ssHor + + adjRow := 0 + if s.ssVer != 0 && y != 0 && !(y == h-1 && y%2 != 0) { + if y%2 != 0 { + adjRow = 1 + } else { + adjRow = -1 + } + } + + u0 = s.padRow(s.uPad[0], cb, uvY, uw) + v0 = s.padRow(s.vPad[0], cr, uvY, uw) + + if adjRow == 0 { + return u0, u0, v0, v0 + } + + ay := min(max(uvY+adjRow, 0), cb.h-1) + + u1 = s.padRow(s.uPad[1], cb, ay, uw) + v1 = s.padRow(s.vPad[1], cr, ay, uw) + + return u0, u1, v0, v1 +} + +func (s *colorState) padRow(dst []float32, v planeView, y, uw int) []float32 { + if v.p16 != nil { + normRow16(dst[1:1+uw], v.p16[y*v.stride:], uint16(s.maxChannel), s.biasUV, s.rangeUV) + } else { + normRow(dst[1:1+uw], v.p8[y*v.stride:y*v.stride+uw], s.biasUV, s.rangeUV) + } + + dst[0] = dst[1] + dst[uw+1] = dst[uw] + + return dst +} + +func (s *colorState) lumaRowF(v planeView, y, w int) []float32 { + if v.p16 != nil { + normRow16(s.yf[:w], v.p16[y*v.stride:], uint16(s.maxChannel), s.biasY, s.rangeY) + } else { + normRow(s.yf[:w], v.p8[y*v.stride:y*v.stride+w], s.biasY, s.rangeY) + } + + return s.yf +} diff --git a/third_party/h265/heic/cpu_amd64.go b/third_party/h265/heic/cpu_amd64.go new file mode 100644 index 00000000..39e4bc22 --- /dev/null +++ b/third_party/h265/heic/cpu_amd64.go @@ -0,0 +1,7 @@ +//go:build amd64 && !noasm + +package heic + +var hasAVX2 = cpuidAVX2() + +func cpuidAVX2() bool diff --git a/third_party/h265/heic/cpu_amd64.s b/third_party/h265/heic/cpu_amd64.s new file mode 100644 index 00000000..a1071c5c --- /dev/null +++ b/third_party/h265/heic/cpu_amd64.s @@ -0,0 +1,35 @@ +//go:build amd64 && !noasm + +#include "textflag.h" + +// cpuidAVX2 reports whether the CPU has AVX2 and the OS saves the YMM state. +TEXT ·cpuidAVX2(SB), NOSPLIT, $0-1 + MOVL $0, AX + CPUID + CMPL AX, $7 + JL no + + MOVL $1, AX + MOVL $0, CX + CPUID + BTL $27, CX // OSXSAVE + JNC no + + MOVL $0, CX + XGETBV + ANDL $6, AX // XMM and YMM state + CMPL AX, $6 + JNE no + + MOVL $7, AX + MOVL $0, CX + CPUID + BTL $5, BX // AVX2 + JNC no + + MOVB $1, ret+0(FP) + RET + +no: + MOVB $0, ret+0(FP) + RET diff --git a/third_party/h265/heic/encode.go b/third_party/h265/heic/encode.go new file mode 100644 index 00000000..c4e2e9db --- /dev/null +++ b/third_party/h265/heic/encode.go @@ -0,0 +1,561 @@ +package heic + +import ( + "encoding/binary" + "image" + "io" + "math" + + "github.com/gen2brain/h265/hevc" +) + +// DefaultQuality is the quality Encode uses when none is given. +const DefaultQuality = 60 + +// The written planes are what image.YCbCr holds, which is full range BT.601 +// carrying sRGB primaries and transfer. +const ( + encPrimaries = 1 + encTransfer = 13 + encMatrix = 6 +) + +// alphaURN is the auxiliary type of ISO/IEC 23008-12 Annex F, which names an +// item as the alpha channel of the picture it is tied to. +const alphaURN = "urn:mpeg:hevc:2015:auxid:1" + +// maxBoxOverhead bounds the header bytes before the sample data, so a file +// needing 64 bit box sizes is refused rather than truncated. +const maxBoxOverhead = 1 << 16 + +// defaultTile is what a picture too large for any level is split into. +const defaultTile = 512 + +// ChromaFormat is the chroma sampling [Encode] writes. The zero value is 4:2:0. +type ChromaFormat = hevc.ChromaFormat + +const ( + Chroma420 = hevc.Chroma420 + Chroma422 = hevc.Chroma422 + Chroma444 = hevc.Chroma444 + ChromaGray = hevc.ChromaMono +) + +// EncodeOptions are the encoding parameters. +type EncodeOptions struct { + // Quality in the range [0,100]. Default is DefaultQuality. + Quality int + // Lossless codes the samples as PCM and ignores Quality. + Lossless bool + // Chroma is the sampling to code in. The zero value is 4:2:0. + Chroma ChromaFormat + // BitDepth is the sample size, 8 through 12. The zero value is 8. + BitDepth int + // SAO fits the offsets of 8.7.3 to each coding tree block, for about 2.2x + // the time and 3.5% of the bitrate. + SAO bool + // Tile splits the picture over a grid of items of this many samples each + // way. Zero tiles only a picture too large to code as one. + Tile int + // ICC is a color profile, written alongside the nclx description that + // says how the samples are coded. It aliases the input. + ICC []byte + // Exif is a TIFF payload, written as an Exif item describing the picture. + Exif []byte + // XMP is an XMP packet, written as a metadata item describing the picture. + XMP []byte +} + +// ratioOf is the image.YCbCr subsampling that matches the coded one. Without +// chroma nothing constrains the stored size, which is what 4:4:4 also says. +func ratioOf(c ChromaFormat) image.YCbCrSubsampleRatio { + switch c { + case Chroma422: + return image.YCbCrSubsampleRatio422 + case Chroma444, ChromaGray: + return image.YCbCrSubsampleRatio444 + default: + return image.YCbCrSubsampleRatio420 + } +} + +// Encode writes img to w as a HEIC still. Any image will do; one that is not +// already in [EncodeOptions.Chroma] as an *image.YCbCr is converted. An image +// that carries alpha keeps it, in a monochrome auxiliary item coded at the +// same quality. +func Encode(w io.Writer, img image.Image, opts ...EncodeOptions) error { + var o EncodeOptions + + if len(opts) > 0 { + o = opts[0] + } + + switch { + case o.Quality <= 0: + o.Quality = DefaultQuality + case o.Quality > 100: + o.Quality = 100 + } + + if o.Chroma < Chroma420 || o.Chroma > ChromaGray { + return ErrUnsupported + } + + if o.BitDepth == 0 { + o.BitDepth = 8 + } + + if o.BitDepth < 8 || o.BitDepth > 12 { + return ErrUnsupported + } + + b := img.Bounds() + width, height := b.Dx(), b.Dy() + sw, sh := ratioSub(ratioOf(o.Chroma)) + even := image.Rect(0, 0, roundUp(width, sw), roundUp(height, sh)) + + qp := 51 - o.Quality*50/100 + deep := o.BitDepth > 8 + + var ( + frame hevc.Frame + alpha []uint8 + alpha6 []uint16 + ) + + switch { + case deep: + alpha6 = alphaDeep(img, even, o.BitDepth) + _, y, cb, cr := toDeep(img, ratioOf(o.Chroma), o.BitDepth, alpha6 != nil) + frame = hevc.Frame{Y16: y, StrideY: even.Dx()} + + if o.Chroma != ChromaGray { + frame.Cb16, frame.Cr16, frame.StrideC = cb, cr, even.Dx()/sw + } + case o.Chroma == ChromaGray: + alpha = alphaPlane(img, even) + frame = hevc.Frame{Y: toGray(img, alpha != nil), StrideY: even.Dx()} + default: + alpha = alphaPlane(img, even) + ycc := toYCbCr(img, ratioOf(o.Chroma), alpha != nil) + frame = hevc.Frame{Y: ycc.Y, Cb: ycc.Cb, Cr: ycc.Cr, + StrideY: ycc.YStride, StrideC: ycc.CStride} + } + + stored := image.Point{X: even.Dx(), Y: even.Dy()} + + tile := o.Tile + if tile == 0 && stored.X*stored.Y > hevc.MaxLumaSamples { + tile = defaultTile + } + + if tile != 0 && (tile%sw != 0 || tile%sh != 0 || + tile*tile > hevc.MaxLumaSamples) { + return ErrUnsupported + } + + f := heicFile{size: image.Point{X: width, Y: height}, stored: stored, + gray: o.Chroma == ChromaGray, depth: o.BitDepth, icc: o.ICC} + + src := gridSource{frame: frame, alpha: alpha, alpha6: alpha6, + stored: stored, sw: sw, sh: sh} + + base := hevc.EncoderOptions{Chroma: o.Chroma, BitDepth: o.BitDepth, + QP: qp, Lossless: o.Lossless, SAO: o.SAO} + + if err := f.picture(&src, base, tile); err != nil { + return err + } + + if len(o.Exif) > 0 { + f.meta(encItem{typ: "Exif", name: "exif", data: append(u32(0), o.Exif...)}) + } + + if len(o.XMP) > 0 { + f.meta(encItem{typ: "mime", name: "xmp", mime: xmpContentType, data: o.XMP}) + } + + file, err := f.marshal() + if err != nil { + return err + } + + _, err = w.Write(file) + + return err +} + +// codeItem codes one picture as a complete access unit, and returns the sample +// data its item carries with the parameter sets its hvcC repeats. +func codeItem(opts hevc.EncoderOptions, frame hevc.Frame) ([]byte, []hevc.NALUnit, error) { + enc, err := hevc.NewEncoder(opts) + if err != nil { + return nil, nil, ErrUnsupported + } + + nals, err := enc.Encode(frame) + if err != nil || len(nals) != 4 { + return nil, nil, ErrInvalid + } + + sample := hevc.MarshalNAL(nals[3]) + if uint64(len(sample))+maxBoxOverhead > math.MaxUint32 { + return nil, nil, ErrUnsupported + } + + data := make([]byte, 4, 4+len(sample)) + binary.BigEndian.PutUint32(data, uint32(len(sample))) + + return append(data, sample...), nals[:3], nil +} + +// encItem is one item on its way into the file: what iinf declares, what iloc +// points at, which ipco properties ipma ties to it, and what iref ties it to. +type encItem struct { + id uint16 + typ string + name string + mime string + hidden bool + mono bool + data []byte + assoc []byte + ref string + to []uint16 +} + +// heicFile gathers the items and their properties, so that the boxes naming +// them are written once from one list. +type heicFile struct { + size, stored image.Point + gray bool + depth int + icc []byte + grid bool + tile int + items []encItem + props []byte + nProps byte + colr []byte + shared map[string]byte + ispe byte + ispeTile byte + pixi byte + pixiMono byte + clap byte +} + +// prop appends a property to ipco and returns the one based index ipma names +// it by, with the essential bit of 23008-12 set. +func (f *heicFile) prop(b []byte, essential bool) byte { + f.props = append(f.props, b...) + f.nProps++ + + idx := f.nProps + if essential { + idx |= 0x80 + } + + return idx +} + +// once returns the index of a property already written with the same bytes, so +// that the tiles of a grid share one decoder configuration record. +func (f *heicFile) once(b []byte, essential bool) byte { + if f.shared == nil { + f.shared = map[string]byte{} + } + + if idx, ok := f.shared[string(b)]; ok { + return idx + } + + idx := f.prop(b, essential) + f.shared[string(b)] = idx + + return idx +} + +// sizeProp is the ispe of one size, kept so that every tile shares one. +func (f *heicFile) sizeProp(idx *byte, w, h int) byte { + if *idx == 0 { + *idx = f.prop(fullBox("ispe", 0, 0, append(u32(uint32(w)), u32(uint32(h))...)), true) + } + + return *idx +} + +// colorProps is the nclx description of the samples and, beside it, whatever +// profile says what they mean. +func (f *heicFile) colorProps() []byte { + if f.colr == nil { + f.colr = []byte{f.prop(box("colr", + append(append(append([]byte("nclx"), u16(encPrimaries)...), + u16(encTransfer)...), append(u16(encMatrix), 0x80)...)), false)} + + if len(f.icc) > 0 { + f.colr = append(f.colr, + f.prop(box("colr", append([]byte("prof"), f.icc...)), false)) + } + } + + return f.colr +} + +// pixiProp is the sample size of a component count, one property per count. +func (f *heicFile) pixiProp(mono bool) byte { + depth := byte(max(f.depth, 8)) + + idx, body := &f.pixi, []byte{3, depth, depth, depth} + if mono || f.gray { + idx, body = &f.pixiMono, []byte{1, depth} + } + + if *idx == 0 { + *idx = f.prop(fullBox("pixi", 0, 0, body), true) + } + + return *idx +} + +// add appends an item with the properties its part calls for: a grid describes +// the picture, a tile describes only itself, and a lone picture does both. +func (f *heicFile) add(it encItem, params []hevc.NALUnit) { + tile := it.typ == "hvc1" && f.grid + + if tile { + it.assoc = []byte{f.sizeProp(&f.ispeTile, f.tile, f.tile), f.pixiProp(it.mono)} + } else { + it.assoc = []byte{f.sizeProp(&f.ispe, f.size.X, f.size.Y), f.pixiProp(it.mono)} + } + + if config, err := marshalHvcC(params); err == nil { + it.assoc = append(it.assoc, f.once(box("hvcC", config), true)) + } + + switch { + case it.mono: + it.assoc = append(it.assoc, + f.once(fullBox("auxC", 0, 0, append([]byte(alphaURN), 0)), true)) + case !tile: + it.assoc = append(it.assoc, f.colorProps()...) + } + + // A grid names its own output size, so only a lone picture needs the clean + // aperture that takes the repeated edge back off. + if !f.grid && f.clap == 0 && f.stored != f.size { + f.clap = f.prop(box("clap", clapData(f.size)), false) + } + + if f.clap != 0 && !tile { + it.assoc = append(it.assoc, f.clap) + } + + f.items = append(f.items, it) +} + +// meta appends a metadata item, which describes the picture and carries no +// properties of its own. +func (f *heicFile) meta(it encItem) { + it.id = uint16(len(f.items)) + 1 + it.ref, it.to = "cdsc", []uint16{1} + + f.items = append(f.items, it) +} + +func (f *heicFile) marshal() ([]byte, error) { + ftyp := box("ftyp", []byte("heic\x00\x00\x00\x00mif1heic")) + + var data []byte + for _, it := range f.items { + data = append(data, it.data...) + } + + meta, err := f.metaBox(0) + if err != nil { + return nil, err + } + + meta, err = f.metaBox(uint64(len(ftyp) + len(meta) + 8)) + if err != nil { + return nil, err + } + + return append(append(ftyp, meta...), box("mdat", data)...), nil +} + +func (f *heicFile) metaBox(offset uint64) ([]byte, error) { + // ipma names a property by seven bits, so a file wanting more of them than + // that is refused rather than written with the indices wrapped. + if f.nProps > 0x7f { + return nil, ErrUnsupported + } + + hdlr := fullBox("hdlr", 0, 0, append([]byte("\x00\x00\x00\x00pict"), make([]byte, 13)...)) + pitm := fullBox("pitm", 0, 0, u16(1)) + + var ( + infes []byte + ilocs []byte + irefs []byte + ipmas []byte + assoc int + ) + + for _, it := range f.items { + flags := uint32(0) + if it.hidden { + flags = 1 + } + + entry := append(u16(it.id), 0, 0) + entry = append(entry, it.typ...) + entry = append(entry, it.name...) + entry = append(entry, 0) + + if it.typ == "mime" { + entry = append(entry, it.mime...) + entry = append(entry, 0, 0) + } + + infes = append(infes, fullBox("infe", 2, flags, entry)...) + + if uint64(len(it.data))+offset > math.MaxUint32 { + return nil, ErrUnsupported + } + + ilocs = append(ilocs, u16(it.id)...) + ilocs = append(ilocs, u16(0)...) + ilocs = append(ilocs, u16(1)...) + ilocs = append(ilocs, u32(uint32(offset))...) + ilocs = append(ilocs, u32(uint32(len(it.data)))...) + offset += uint64(len(it.data)) + + if it.ref != "" { + body := append(u16(it.id), u16(uint16(len(it.to)))...) + + for _, to := range it.to { + body = append(body, u16(to)...) + } + + irefs = append(irefs, box(it.ref, body)...) + } + + if len(it.assoc) == 0 { + continue + } + + ipmas = append(ipmas, u16(it.id)...) + ipmas = append(ipmas, byte(len(it.assoc))) + ipmas = append(ipmas, it.assoc...) + assoc++ + } + + iinf := fullBox("iinf", 0, 0, append(u16(uint16(len(f.items))), infes...)) + locs := append(u16(uint16(len(f.items))), ilocs...) + iloc := fullBox("iloc", 0, 0, append([]byte{0x44, 0}, locs...)) + iprp := box("iprp", append(box("ipco", f.props), + fullBox("ipma", 0, 0, append(u32(uint32(assoc)), ipmas...))...)) + + meta := append(hdlr, pitm...) + meta = append(meta, iinf...) + meta = append(meta, iloc...) + + if irefs != nil { + meta = append(meta, fullBox("iref", 0, 0, irefs)...) + } + + meta = append(meta, iprp...) + + return fullBox("meta", 0, 0, meta), nil +} + +// clapData is the CleanApertureBox naming the whole of the image ispe +// describes. It is what makes a reader take the stored picture down to that +// size rather than hand back the repeated edge with it. +func clapData(size image.Point) []byte { + var out []byte + + for _, v := range [8]int32{int32(size.X), 1, int32(size.Y), 1, 0, 1, 0, 1} { + out = append(out, u32(uint32(v))...) + } + + return out +} + +// marshalHvcC writes the HEVCDecoderConfigurationRecord. ISO/IEC 14496-15 +// requires its profile, tier and level to be the sequence parameter set's own. +func marshalHvcC(params []hevc.NALUnit) ([]byte, error) { + var ( + ptl []byte + sps []byte + ) + + for _, nal := range params { + if nal.Type != hevc.NALSPS { + continue + } + + b, ok := hevc.ProfileTierLevel(nal.RBSP) + if !ok { + return nil, ErrInvalid + } + + ptl, sps = b, nal.RBSP + } + + if ptl == nil { + return nil, ErrInvalid + } + + chroma, depthY, depthC, ok := hevc.SPSFormat(sps) + if !ok { + return nil, ErrInvalid + } + + out := append([]byte{1}, ptl...) + out = append(out, 0xf0, 0, 0xfc, + 0xfc|byte(chroma), + 0xf8|byte(depthY-8), + 0xf8|byte(depthC-8), + 0, 0, 0x0f, 3) + + for _, nal := range params { + data := hevc.MarshalNAL(nal) + if len(data) > 0xffff { + return nil, ErrInvalid + } + + out = append(out, 0x80|byte(nal.Type), 0, 1) + out = append(out, u16(uint16(len(data)))...) + out = append(out, data...) + } + + return out, nil +} + +func box(typ string, data []byte) []byte { + out := make([]byte, 8, 8+len(data)) + binary.BigEndian.PutUint32(out, uint32(8+len(data))) + copy(out[4:], typ) + + return append(out, data...) +} + +func fullBox(typ string, version uint8, flags uint32, data []byte) []byte { + return box(typ, append([]byte{version, byte(flags >> 16), byte(flags >> 8), byte(flags)}, data...)) +} + +func u16(v uint16) []byte { + out := make([]byte, 2) + binary.BigEndian.PutUint16(out, v) + + return out +} + +func u32(v uint32) []byte { + out := make([]byte, 4) + binary.BigEndian.PutUint32(out, v) + + return out +} diff --git a/third_party/h265/heic/exif.go b/third_party/h265/heic/exif.go new file mode 100644 index 00000000..efdcaf7a --- /dev/null +++ b/third_party/h265/heic/exif.go @@ -0,0 +1,504 @@ +package heic + +import ( + "encoding/binary" + "errors" + "io" + "slices" +) + +// ErrNoExif is returned when the file carries no Exif item. +var ErrNoExif = errors.New("heic: no exif data") + +// ErrNoXMP is returned when the file carries no XMP item. +var ErrNoXMP = errors.New("heic: no xmp data") + +const xmpContentType = "application/rdf+xml" + +// Exif holds the Exif metadata decoded from an HEIC image. +type Exif struct { + // Orientation is the Exif orientation, 1 to 8, where 1 is upright. + Orientation int + // Width and Height are the dimensions the Exif tags report, which need + // not be the dimensions the image decodes to. + Width int + Height int + + // Make and Model name the camera, Software what wrote the file. + Make string + Model string + Software string + + // DateTime and DateTimeOriginal are "YYYY:MM:DD HH:MM:SS", the first the + // file's own time and the second the time the photo was taken. + DateTime string + DateTimeOriginal string + + // ExposureTime is in seconds and FocalLength in millimetres. + ExposureTime float64 + FNumber float64 + ISOSpeed int + FocalLength float64 + Flash int + + // GPSLatitude and GPSLongitude are decimal degrees, positive north and + // east. GPSAltitude is metres above sea level. + GPSLatitude float64 + GPSLongitude float64 + GPSAltitude float64 + + Copyright string + Artist string +} + +// DecodeExif reads the Exif metadata of an HEIC image. Tags the file omits +// stay zero. It returns ErrNoExif when the file carries no Exif item. +func DecodeExif(r io.Reader) (*Exif, error) { + tiff, err := RawExif(r) + if err != nil { + return nil, err + } + + exif := &Exif{Orientation: 1} + if err := parseExifData(tiff, exif); err != nil { + return nil, err + } + + return exif, nil +} + +// RawExif returns the TIFF payload of the Exif item, without the +// exif_tiff_header_offset the HEIC container puts in front of it. It aliases a +// buffered input rather than copying it, so it must not be written to. +func RawExif(r io.Reader) ([]byte, error) { + f, err := readFile(r) + if err != nil { + return nil, err + } + + it := f.descItem("Exif", "") + if it == nil { + return nil, ErrNoExif + } + b, err := f.meta.data(it, f.src) + if err != nil { + return nil, err + } + tiff := exifTIFF(b) + if tiff == nil { + return nil, ErrNoExif + } + + return tiff, nil +} + +// RawXMP returns the XMP packet of the file. It aliases a buffered input +// rather than copying it, so it must not be written to. +func RawXMP(r io.Reader) ([]byte, error) { + f, err := readFile(r) + if err != nil { + return nil, err + } + + it := f.descItem("mime", xmpContentType) + if it == nil { + return nil, ErrNoXMP + } + + return f.meta.data(it, f.src) +} + +func readFile(r io.Reader) (*file, error) { + src, err := srcFor(r) + if err != nil { + return nil, err + } + + return parse(src) +} + +// descItem finds the metadata item describing the primary image. A file with +// no image item at all still gives up its metadata. +func (f *file) descItem(typ, contentType string) *item { + m := f.meta + if m == nil { + return nil + } + primary, _ := f.primary() + + for _, id := range m.order { + it := m.items[id] + if it == nil || it.typ != typ || it.unsupported { + continue + } + if it.contentType != contentType { + continue + } + if primary != nil && !slices.Contains(m.refsTo("cdsc", it.id), primary.id) { + continue + } + + return it + } + + return nil +} + +func isTIFFHeader(b []byte) bool { + if len(b) < 4 { + return false + } + + return (b[0] == 'I' && b[1] == 'I' && b[2] == 42 && b[3] == 0) || + (b[0] == 'M' && b[1] == 'M' && b[2] == 0 && b[3] == 42) +} + +// exifTIFF strips the exif_tiff_header_offset of ISO/IEC 23008-12 Annex A.2.1. +// A file that gets the offset wrong still parses, from the header found by +// scanning, which is the offset the writer should have signalled. +func exifTIFF(b []byte) []byte { + if len(b) < 4 { + return nil + } + off := uint64(binary.BigEndian.Uint32(b)) + b = b[4:] + + if off <= uint64(len(b)) && isTIFFHeader(b[off:]) { + return b[off:] + } + for i := range b { + if isTIFFHeader(b[i:]) { + return b[i:] + } + } + + return nil +} + +const ( + tagOrientation = 0x0112 + tagImageWidth = 0x0100 + tagImageLength = 0x0101 + tagMake = 0x010F + tagModel = 0x0110 + tagSoftware = 0x0131 + tagDateTime = 0x0132 + tagArtist = 0x013B + tagCopyright = 0x8298 + tagExifIFDPointer = 0x8769 + tagGPSIFDPointer = 0x8825 + + tagExposureTime = 0x829A + tagFNumber = 0x829D + tagISOSpeedRatings = 0x8827 + tagDateTimeOriginal = 0x9003 + tagFlash = 0x9209 + tagFocalLength = 0x920A + + tagGPSLatitudeRef = 0x0001 + tagGPSLatitude = 0x0002 + tagGPSLongitudeRef = 0x0003 + tagGPSLongitude = 0x0004 + tagGPSAltitudeRef = 0x0005 + tagGPSAltitude = 0x0006 +) + +const ( + typeUnsignedByte = 1 + typeASCIIString = 2 + typeUnsignedShort = 3 + typeUnsignedLong = 4 + typeUnsignedRational = 5 + typeSignedByte = 6 + typeUndefined = 7 + typeSignedShort = 8 + typeSignedLong = 9 + typeSignedRational = 10 + typeSingleFloat = 11 + typeDoubleFloat = 12 +) + +type exifReader struct { + data []byte + littleEndian bool +} + +func (r *exifReader) uint16(offset int) uint16 { + if offset < 0 || offset+1 >= len(r.data) { + return 0 + } + if r.littleEndian { + return uint16(r.data[offset]) | uint16(r.data[offset+1])<<8 + } + + return uint16(r.data[offset])<<8 | uint16(r.data[offset+1]) +} + +func (r *exifReader) uint32(offset int) uint32 { + if offset < 0 || offset+3 >= len(r.data) { + return 0 + } + if r.littleEndian { + return uint32(r.data[offset]) | uint32(r.data[offset+1])<<8 | + uint32(r.data[offset+2])<<16 | uint32(r.data[offset+3])<<24 + } + + return uint32(r.data[offset])<<24 | uint32(r.data[offset+1])<<16 | + uint32(r.data[offset+2])<<8 | uint32(r.data[offset+3]) +} + +func (r *exifReader) readString(offset, maxLen int) string { + if offset < 0 || offset >= len(r.data) { + return "" + } + end := offset + for end < len(r.data) && end < offset+maxLen && r.data[end] != 0 { + end++ + } + + return string(r.data[offset:end]) +} + +func (r *exifReader) readRational(offset int) float64 { + if offset < 0 || offset+7 >= len(r.data) { + return 0 + } + num, den := r.uint32(offset), r.uint32(offset+4) + if den == 0 { + return 0 + } + + return float64(num) / float64(den) +} + +func parseExifData(data []byte, exif *Exif) error { + if len(data) < 8 { + return errors.New("heic: exif data too short") + } + + r := &exifReader{data: data} + switch { + case data[0] == 'I' && data[1] == 'I': + r.littleEndian = true + case data[0] == 'M' && data[1] == 'M': + default: + return errors.New("heic: invalid exif byte order marker") + } + + if r.uint16(2) != 42 { + return errors.New("heic: invalid exif magic number") + } + + ifdOffset := r.uint32(4) + if ifdOffset < 8 || int(ifdOffset) >= len(data) { + return errors.New("heic: invalid exif ifd offset") + } + + exifIFDOffset, gpsIFDOffset := parseIFD(r, int(ifdOffset), exif) + if exifIFDOffset > 0 { + parseExifSubIFD(r, exifIFDOffset, exif) + } + if gpsIFDOffset > 0 { + parseGPSSubIFD(r, gpsIFDOffset, exif) + } + + return nil +} + +// eachEntry walks the entries of an IFD, resolving each value to its offset. +func eachEntry(r *exifReader, offset int, fn func(tag, dataType uint16, count uint32, valueOffset int)) { + if offset < 0 || offset+1 >= len(r.data) { + return + } + + n := int(r.uint16(offset)) + offset += 2 + + for i := range n { + entry := offset + i*12 + if entry+11 >= len(r.data) { + break + } + + tag := r.uint16(entry) + dataType := r.uint16(entry + 2) + count := r.uint32(entry + 4) + valueOffset := entry + 8 + + if getDataSize(dataType, count) > 4 { + valueOffset = int(r.uint32(valueOffset)) + if valueOffset >= len(r.data) { + continue + } + } + + fn(tag, dataType, count, valueOffset) + } +} + +func parseIFD(r *exifReader, offset int, exif *Exif) (exifIFDOffset, gpsIFDOffset int) { + eachEntry(r, offset, func(tag, dataType uint16, count uint32, valueOffset int) { + switch tag { + case tagOrientation: + if dataType == typeUnsignedShort { + exif.Orientation = int(r.uint16(valueOffset)) + } + case tagImageWidth: + switch dataType { + case typeUnsignedShort: + exif.Width = int(r.uint16(valueOffset)) + case typeUnsignedLong: + exif.Width = int(r.uint32(valueOffset)) + } + case tagImageLength: + switch dataType { + case typeUnsignedShort: + exif.Height = int(r.uint16(valueOffset)) + case typeUnsignedLong: + exif.Height = int(r.uint32(valueOffset)) + } + case tagMake: + if dataType == typeASCIIString { + exif.Make = r.readString(valueOffset, int(count)) + } + case tagModel: + if dataType == typeASCIIString { + exif.Model = r.readString(valueOffset, int(count)) + } + case tagSoftware: + if dataType == typeASCIIString { + exif.Software = r.readString(valueOffset, int(count)) + } + case tagDateTime: + if dataType == typeASCIIString { + exif.DateTime = r.readString(valueOffset, int(count)) + } + case tagArtist: + if dataType == typeASCIIString { + exif.Artist = r.readString(valueOffset, int(count)) + } + case tagCopyright: + if dataType == typeASCIIString { + exif.Copyright = r.readString(valueOffset, int(count)) + } + case tagExifIFDPointer: + if dataType == typeUnsignedLong { + exifIFDOffset = int(r.uint32(valueOffset)) + } + case tagGPSIFDPointer: + if dataType == typeUnsignedLong { + gpsIFDOffset = int(r.uint32(valueOffset)) + } + } + }) + + return exifIFDOffset, gpsIFDOffset +} + +func parseExifSubIFD(r *exifReader, offset int, exif *Exif) { + eachEntry(r, offset, func(tag, dataType uint16, count uint32, valueOffset int) { + switch tag { + case tagExposureTime: + if dataType == typeUnsignedRational { + exif.ExposureTime = r.readRational(valueOffset) + } + case tagFNumber: + if dataType == typeUnsignedRational { + exif.FNumber = r.readRational(valueOffset) + } + case tagISOSpeedRatings: + if dataType == typeUnsignedShort { + exif.ISOSpeed = int(r.uint16(valueOffset)) + } + case tagDateTimeOriginal: + if dataType == typeASCIIString { + exif.DateTimeOriginal = r.readString(valueOffset, int(count)) + } + case tagFlash: + if dataType == typeUnsignedShort { + exif.Flash = int(r.uint16(valueOffset)) + } + case tagFocalLength: + if dataType == typeUnsignedRational { + exif.FocalLength = r.readRational(valueOffset) + } + } + }) +} + +func parseGPSSubIFD(r *exifReader, offset int, exif *Exif) { + var latRef, lonRef string + var lat, lon []float64 + var altRef uint8 + + eachEntry(r, offset, func(tag, dataType uint16, count uint32, valueOffset int) { + switch tag { + case tagGPSLatitudeRef: + if dataType == typeASCIIString { + latRef = r.readString(valueOffset, 2) + } + case tagGPSLatitude: + if dataType == typeUnsignedRational && count == 3 { + lat = []float64{ + r.readRational(valueOffset), + r.readRational(valueOffset + 8), + r.readRational(valueOffset + 16), + } + } + case tagGPSLongitudeRef: + if dataType == typeASCIIString { + lonRef = r.readString(valueOffset, 2) + } + case tagGPSLongitude: + if dataType == typeUnsignedRational && count == 3 { + lon = []float64{ + r.readRational(valueOffset), + r.readRational(valueOffset + 8), + r.readRational(valueOffset + 16), + } + } + case tagGPSAltitudeRef: + if dataType == typeUnsignedByte && valueOffset < len(r.data) { + altRef = r.data[valueOffset] + } + case tagGPSAltitude: + if dataType == typeUnsignedRational { + exif.GPSAltitude = r.readRational(valueOffset) + } + } + }) + + if altRef == 1 { + exif.GPSAltitude = -exif.GPSAltitude + } + if len(lat) == 3 { + exif.GPSLatitude = lat[0] + lat[1]/60 + lat[2]/3600 + if latRef == "S" { + exif.GPSLatitude = -exif.GPSLatitude + } + } + if len(lon) == 3 { + exif.GPSLongitude = lon[0] + lon[1]/60 + lon[2]/3600 + if lonRef == "W" { + exif.GPSLongitude = -exif.GPSLongitude + } + } +} + +func getDataSize(dataType uint16, count uint32) int { + var size int + switch dataType { + case typeUnsignedByte, typeSignedByte, typeASCIIString, typeUndefined: + size = 1 + case typeUnsignedShort, typeSignedShort: + size = 2 + case typeUnsignedLong, typeSignedLong, typeSingleFloat: + size = 4 + case typeUnsignedRational, typeSignedRational, typeDoubleFloat: + size = 8 + default: + size = 1 + } + + return size * int(count) +} diff --git a/third_party/h265/heic/grid.go b/third_party/h265/heic/grid.go new file mode 100644 index 00000000..ffd00e8b --- /dev/null +++ b/third_party/h265/heic/grid.go @@ -0,0 +1,358 @@ +package heic + +import ( + "sync" + "sync/atomic" + + "github.com/gen2brain/h265/hevc" +) + +type gridInfo struct { + rows, cols int + w, h int +} + +func parseGrid(b []byte) (gridInfo, error) { + r := &reader{b: b} + r.u8() + + flags := r.u8() + + var g gridInfo + + g.rows = int(r.u8()) + 1 + g.cols = int(r.u8()) + 1 + + if flags&1 != 0 { + g.w, g.h = int(r.u32()), int(r.u32()) + } else { + g.w, g.h = int(r.u16()), int(r.u16()) + } + + if r.err || g.w <= 0 || g.h <= 0 { + return g, ErrInvalid + } + + return g, nil +} + +func (f *file) gridOf(it *item) (gridInfo, []uint32, error) { + data, err := f.meta.data(it, f.src) + if err != nil { + return gridInfo{}, nil, err + } + + g, err := parseGrid(data) + if err != nil { + return gridInfo{}, nil, err + } + + tiles := f.meta.refsTo("dimg", it.id) + if len(tiles) != g.rows*g.cols { + return gridInfo{}, nil, ErrInvalid + } + + return g, tiles, nil +} + +// decodeImage decodes an item, stitching the tiles first when it is a grid. +func (f *file) decodeImage(it *item) (*hevc.Picture, error) { + if it.unsupported { + return nil, ErrUnsupported + } + + if it.typ != "grid" { + var dec itemDecoder + + return f.decodeItem(dec.use(f.workers(0)), it) + } + + g, tiles, err := f.gridOf(it) + if err != nil { + return nil, err + } + + if n := f.limit(); n > 0 && uint64(g.w)*uint64(g.h) > uint64(n) { + return nil, ErrUnsupported + } + + return f.decodeTiles(g, tiles) +} + +// decodeTiles decodes the tiles and copies each into its place in the output. +func (f *file) decodeTiles(g gridInfo, tiles []uint32) (*hevc.Picture, error) { + if len(tiles) == 0 { + return nil, ErrInvalid + } + + var ( + out *hevc.Picture + tw, th int + ready = make(chan struct{}) + next atomic.Int64 + fail atomic.Pointer[error] + wg sync.WaitGroup + ) + + setErr := func(err error) { fail.CompareAndSwap(nil, &err) } + + next.Store(1) + + // The tiles already spread across the budget, so each one's wavefront + // takes only what is left over rather than multiplying it. + tileWorkers := f.workers(len(tiles)) + perTile := max(f.workers(0)/tileWorkers, 1) + + for range tileWorkers - 1 { + wg.Add(1) + + go func() { + defer wg.Done() + + var dec itemDecoder + + dec.use(perTile) + + for { + i := int(next.Add(1)) - 1 + if i >= len(tiles) || fail.Load() != nil { + return + } + + p, err := f.decodeTile(&dec, tiles[i]) + if err != nil { + setErr(err) + + return + } + + <-ready + + if fail.Load() != nil { + p.Release() + + return + } + + if p.CropW != tw || p.CropH != th || + p.ChromaFormat != out.ChromaFormat || p.BitDepth != out.BitDepth { + p.Release() + setErr(ErrInvalid) + + return + } + + blit(out, p, g, i, tw, th) + p.Release() + } + }() + } + + var dec itemDecoder + + dec.use(perTile) + + func() { + defer close(ready) + + p, err := f.decodeTile(&dec, tiles[0]) + if err != nil { + setErr(err) + + return + } + + defer p.Release() + + tw, th = p.CropW, p.CropH + if tw*g.cols < g.w || th*g.rows < g.h || + tw*(g.cols-1) >= g.w || th*(g.rows-1) >= g.h { + setErr(ErrInvalid) + + return + } + + out = newGrid(p, g) + + blit(out, p, g, 0, tw, th) + }() + + for fail.Load() == nil { + i := int(next.Add(1)) - 1 + if i >= len(tiles) { + break + } + + p, err := f.decodeTile(&dec, tiles[i]) + if err != nil { + setErr(err) + + break + } + + if p.CropW != tw || p.CropH != th || + p.ChromaFormat != out.ChromaFormat || p.BitDepth != out.BitDepth { + p.Release() + setErr(ErrInvalid) + + break + } + + blit(out, p, g, i, tw, th) + p.Release() + } + + wg.Wait() + + if err := fail.Load(); err != nil { + return nil, *err + } + + return out, nil +} + +func (f *file) decodeTile(dec *itemDecoder, id uint32) (*hevc.Picture, error) { + t := f.meta.items[id] + if t == nil { + return nil, ErrInvalid + } + + return f.decodeItem(dec, t) +} + +// newGrid allocates the stitched picture, which may be smaller than the tiles +// cover. +func newGrid(first *hevc.Picture, g gridInfo) *hevc.Picture { + sw, sh := subsampling(first.ChromaFormat) + + out := &hevc.Picture{ + Width: g.w, + Height: g.h, + CropW: g.w, + CropH: g.h, + ChromaFormat: first.ChromaFormat, + BitDepth: first.BitDepth, + StrideY: g.w, + } + + if first.ChromaFormat != 0 { + out.WidthC = (g.w + sw - 1) / sw + out.HeightC = (g.h + sh - 1) / sh + out.StrideC = out.WidthC + } + + if first.BitDepth > 8 { + out.Y16 = make([]uint16, out.StrideY*g.h) + out.Cb16 = make([]uint16, out.StrideC*out.HeightC) + out.Cr16 = make([]uint16, out.StrideC*out.HeightC) + + return out + } + + out.Y = make([]uint8, out.StrideY*g.h) + out.Cb = make([]uint8, out.StrideC*out.HeightC) + out.Cr = make([]uint8, out.StrideC*out.HeightC) + + return out +} + +func subsampling(chromaFormat int) (int, int) { + switch chromaFormat { + case 1: + return 2, 2 + case 2: + return 2, 1 + } + + return 1, 1 +} + +// blit copies tile i of the grid into its place in out. +func blit(out, p *hevc.Picture, g gridInfo, i, tw, th int) { + sw, sh := subsampling(out.ChromaFormat) + row, col := i/g.cols, i%g.cols + + for pl := range 3 { + sx, sy := col*tw, row*th + cw, ch := tw, th + ow, oh := g.w, g.h + ss, ds := p.StrideY, out.StrideY + sox, soy := p.CropX, p.CropY + + if pl != 0 { + if out.ChromaFormat == 0 { + return + } + + sx, sy = sx/sw, sy/sh + cw, ch = cw/sw, ch/sh + ow, oh = out.WidthC, out.HeightC + ss, ds = p.StrideC, out.StrideC + sox, soy = sox/sw, soy/sh + } + + cw = min(cw, ow-sx) + ch = min(ch, oh-sy) + + if cw <= 0 || ch <= 0 { + continue + } + + if out.BitDepth > 8 { + src, dst := planes16(p, out, pl) + for y := range ch { + copy(dst[(sy+y)*ds+sx:][:cw], src[(soy+y)*ss+sox:][:cw]) + } + + continue + } + + src, dst := planes8(p, out, pl) + for y := range ch { + copy(dst[(sy+y)*ds+sx:][:cw], src[(soy+y)*ss+sox:][:cw]) + } + } +} + +func planes8(src, dst *hevc.Picture, pl int) ([]uint8, []uint8) { + switch pl { + case 0: + return src.Y, dst.Y + case 1: + return src.Cb, dst.Cb + default: + return src.Cr, dst.Cr + } +} + +func planes16(src, dst *hevc.Picture, pl int) ([]uint16, []uint16) { + switch pl { + case 0: + return src.Y16, dst.Y16 + case 1: + return src.Cb16, dst.Cb16 + default: + return src.Cr16, dst.Cr16 + } +} + +// gridAlpha assembles a grid's alpha from the auxiliary items on its tiles. +func (f *file) gridAlpha(it *item) (*hevc.Picture, error) { + g, tiles, err := f.gridOf(it) + if err != nil { + return nil, err + } + + alpha := make([]uint32, len(tiles)) + + for i, id := range tiles { + a := f.alphaOf(id) + if a == nil { + return nil, nil + } + + alpha[i] = a.id + } + + return f.decodeTiles(g, alpha) +} diff --git a/third_party/h265/heic/gridenc.go b/third_party/h265/heic/gridenc.go new file mode 100644 index 00000000..c2b40e29 --- /dev/null +++ b/third_party/h265/heic/gridenc.go @@ -0,0 +1,176 @@ +package heic + +import ( + "image" + + "github.com/gen2brain/h265/hevc" +) + +// samples is what a plane holds either side of eight bits. +type samples interface{ ~uint8 | ~uint16 } + +// gridSource is the converted picture a grid is cut from. +type gridSource struct { + frame hevc.Frame + alpha []uint8 + alpha6 []uint16 + stored image.Point + sw, sh int +} + +// picture writes one item, or a grid of tiles and the item assembling them. +func (f *heicFile) picture(src *gridSource, base hevc.EncoderOptions, tile int) error { + if tile == 0 { + return f.single(src, base) + } + + cols := (src.stored.X + tile - 1) / tile + rows := (src.stored.Y + tile - 1) / tile + + if rows > 256 || cols > 256 || rows*cols*2+2 > 0xffff { + return ErrUnsupported + } + + // 6.6.2.3.1 clips the tiles to the size the grid names, so all are equal. + desc := []byte{0, 1, byte(rows - 1), byte(cols - 1)} + desc = append(desc, u32(uint32(f.size.X))...) + desc = append(desc, u32(uint32(f.size.Y))...) + + f.grid, f.tile = true, tile + f.add(encItem{id: 1, typ: "grid", name: "grid", data: desc, ref: "dimg"}, nil) + + base.Width, base.Height = tile, tile + + for i := range rows * cols { + x0, y0 := i%cols*tile, i/cols*tile + + sample, params, err := codeItem(base, src.tile(x0, y0, tile, false)) + if err != nil { + return err + } + + id := uint16(2 + i) + f.items[0].to = append(f.items[0].to, id) + f.add(encItem{id: id, typ: "hvc1", name: "tile", hidden: true, + data: sample}, params) + } + + if src.alpha == nil && src.alpha6 == nil { + return nil + } + + // Annex F ties the alpha to each tile rather than to the grid. + alpha := base + alpha.Chroma = hevc.ChromaMono + + for i := range rows * cols { + x0, y0 := i%cols*tile, i/cols*tile + + sample, params, err := codeItem(alpha, src.tile(x0, y0, tile, true)) + if err != nil { + return err + } + + f.add(encItem{id: uint16(2 + rows*cols + i), typ: "hvc1", name: "alpha", + hidden: true, mono: true, data: sample, + ref: "auxl", to: []uint16{uint16(2 + i)}}, params) + } + + return nil +} + +// single writes the one item a picture that fits a level takes. +func (f *heicFile) single(src *gridSource, base hevc.EncoderOptions) error { + base.Width, base.Height = src.stored.X, src.stored.Y + + sample, params, err := codeItem(base, src.frame) + if err != nil { + return err + } + + f.add(encItem{id: 1, typ: "hvc1", name: "image", data: sample}, params) + + if src.alpha == nil && src.alpha6 == nil { + return nil + } + + alpha := base + alpha.Chroma = hevc.ChromaMono + + sample, params, err = codeItem(alpha, hevc.Frame{ + Y: src.alpha, Y16: src.alpha6, StrideY: src.stored.X, + }) + if err != nil { + return err + } + + f.add(encItem{id: 2, typ: "hvc1", name: "alpha", hidden: true, mono: true, + data: sample, ref: "auxl", to: []uint16{1}}, params) + + return nil +} + +// tile cuts one tile out, repeating the edge where the grid reaches past it. +func (g *gridSource) tile(x0, y0, n int, alpha bool) hevc.Frame { + if alpha { + if g.alpha6 != nil { + return hevc.Frame{StrideY: n, + Y16: cutTile(g.alpha6, g.stored.X, g.stored.X, g.stored.Y, x0, y0, n, n)} + } + + return hevc.Frame{StrideY: n, + Y: cutTile(g.alpha, g.stored.X, g.stored.X, g.stored.Y, x0, y0, n, n)} + } + + f := g.frame + w, h := g.stored.X, g.stored.Y + cw, ch := w/g.sw, h/g.sh + cn := n / g.sw + cnh := n / g.sh + + if f.Y16 != nil { + out := hevc.Frame{StrideY: n, + Y16: cutTile(f.Y16, f.StrideY, w, h, x0, y0, n, n)} + + if f.Cb16 != nil { + out.StrideC = cn + out.Cb16 = cutTile(f.Cb16, f.StrideC, cw, ch, x0/g.sw, y0/g.sh, cn, cnh) + out.Cr16 = cutTile(f.Cr16, f.StrideC, cw, ch, x0/g.sw, y0/g.sh, cn, cnh) + } + + return out + } + + out := hevc.Frame{StrideY: n, Y: cutTile(f.Y, f.StrideY, w, h, x0, y0, n, n)} + + if f.Cb != nil { + out.StrideC = cn + out.Cb = cutTile(f.Cb, f.StrideC, cw, ch, x0/g.sw, y0/g.sh, cn, cnh) + out.Cr = cutTile(f.Cr, f.StrideC, cw, ch, x0/g.sw, y0/g.sh, cn, cnh) + } + + return out +} + +// cutTile copies a tw by th rectangle out of a plane, clamping to its edge. +func cutTile[P samples](src []P, stride, w, h, x0, y0, tw, th int) []P { + out := make([]P, tw*th) + + for y := range th { + row := src[min(y0+y, h-1)*stride:] + dst := out[y*tw:] + + n := min(tw, w-x0) + if n > 0 { + copy(dst[:n], row[x0:x0+n]) + } else { + n = 0 + } + + for x := n; x < tw; x++ { + dst[x] = row[w-1] + } + } + + return out +} diff --git a/third_party/h265/heic/heic.go b/third_party/h265/heic/heic.go new file mode 100644 index 00000000..07d3a21c --- /dev/null +++ b/third_party/h265/heic/heic.go @@ -0,0 +1,748 @@ +/* +Package heic decodes HEIF images that carry HEVC-coded item data, the format +commonly called HEIC, and writes them. + +# Color + +[Decode] returns RGB, converted with the matrix and range the file declares in +its nclx color description. [Options.ToYCbCr] skips that and hands back the +planes the bitstream carries: *[image.YCbCr], *[image.NYCbCrA] with alpha, or +*[image.Gray] for monochrome. Above 8 bits there is no such image type, so +*[image.NRGBA64] is returned anyway. + +[image.YCbCr] reads its planes as full-range BT.601 whatever the file signals, +which is rarely what a HEIC file means. [DecodeColor] reports what they +actually are, so ToYCbCr is for reaching the samples rather than for display: + + img, ci, err := heic.DecodeColor(r, heic.Options{ToYCbCr: true}) + +[ColorInfo] carries the CICP code points and the range flag, plus the ICC +profile when the file has one. Matrix and FullRange are what the conversion to +RGB uses. Primaries and Transfer are reported but not applied, so RGB output +stays in the file's own color space. + +# Encoding + +[Encode] writes any image as a HEIC still. One that is not already an 8-bit +*[image.YCbCr] at [EncodeOptions.Chroma] is converted, and the file carries an +nclx description of what [image.YCbCr] holds: full-range BT.601 carrying sRGB +primaries and transfer. [EncodeOptions.Quality] selects the quantiser and +[EncodeOptions.Lossless] codes the samples as PCM instead. [Chroma420], +[Chroma422], [Chroma444] and [ChromaGray] are the samplings it writes. + +An image that carries alpha keeps it. The channel becomes a monochrome +auxiliary item coded at the same quality, and the colour is written +un-composited so that a reader gets both back. An opaque image writes no such +item. [EncodeOptions.Exif] and [EncodeOptions.XMP] add metadata items +describing the picture, and [EncodeOptions.ICC] a colour profile beside the +nclx description rather than instead of it. [EncodeOptions.SAO] fits an +offset per coding tree block to the error left in it, which costs about 2.2x +the encoding time. + +Chroma that cannot resolve a dimension leaves a picture with one stored with +its edge repeated, carrying a clean aperture that takes the repetition back +off. 4:4:4 and monochrome resolve any size and never need it. + +# Grids + +A picture larger than [hevc.MaxLumaSamples] has no level to be coded at, so it +is written as a grid of items instead and reassembled on the way out. +[EncodeOptions.Tile] asks for one at any size. + +# Metadata + +[DecodeExif] reads the Exif item a file describes its image with, and +[RawExif] and [RawXMP] return the payloads unparsed. +*/ +package heic + +import ( + "errors" + "image" + "image/color" + "io" + "math" + "runtime" + + "github.com/gen2brain/h265/hevc" +) + +// ErrUnsupported is returned for a file this package cannot render but which +// is otherwise well formed: an essential property it does not implement, or a +// sample format it has no conversion for. A caller that has another decoder to +// fall back on should test for this one rather than [ErrInvalid]. +var ErrUnsupported = errors.New("heic: unsupported image") + +// DefaultFrameSizeLimit bounds the pixel area a header may ask to allocate. +const DefaultFrameSizeLimit = min(16384*16384, math.MaxInt>>6) + +// Resource ceilings apply to every entry point, including metadata readers. +// The per-item ceiling also bounds one HEVC access unit or metadata payload. +const ( + maxInputBytes = 512 * 1024 * 1024 + maxItemBytes = 64 * 1024 * 1024 +) + +// ColorInfo describes the color space an image was decoded from. +type ColorInfo struct { + Primaries uint16 + Transfer uint16 + Matrix uint16 + FullRange bool + // ICCP is the embedded ICC profile, for files that carry one in place of + // an nclx description. It aliases the input, so it is not a copy. + ICCP []byte +} + +// Options controls decoding. +type Options struct { + // AutoRotate applies the clap/irot/imir transforms, forcing NRGBA output + // when it transforms. + AutoRotate bool + // FrameSizeLimit bounds a frame's area in pixels. Zero means + // DefaultFrameSizeLimit; a negative value removes the limit. + FrameSizeLimit int + // ToYCbCr forces the image's native color space instead of NRGBA: + // *image.YCbCr, *image.NYCbCrA when there is alpha, or *image.Gray when + // the image is monochrome. Above 8 bits NRGBA64 is returned anyway. + // image.YCbCr reads the planes as full-range BT.601 whatever the file + // signals, so this is for reaching the samples, not for display. + // DecodeColor reports what the samples actually are. + ToYCbCr bool + // Threads bounds the goroutines a decode may use, over the tiles of a grid + // and the wavefront rows within each. Zero means GOMAXPROCS; one decodes + // serially. + Threads int +} + +func options(opts []Options) Options { + if len(opts) == 0 { + return Options{} + } + + return opts[0] +} + +type file struct { + src *source + meta *metaBox + movie *movie + frameSizeLimit int + threads int + budget *hevc.DecodeBudget +} + +func (f *file) setOptions(o Options) { + f.frameSizeLimit = o.FrameSizeLimit + f.threads = o.Threads + if n := f.limit(); n > 0 { + // Covers edge tiles and alpha without allowing repeated cropped + // pictures or non-output sequence frames to multiply decode work. + f.budget = hevc.NewDecodeBudget(int64(n) * 4) + } +} + +// workers is how many goroutines a grid may use, never more than it has tiles. +func (f *file) workers(n int) int { + w := f.threads + if w == 0 { + w = runtime.GOMAXPROCS(0) + } + + if n <= 0 { + return max(w, 1) + } + + return max(min(w, n), 1) +} + +// HEIC holds the images of a file, which may be an image sequence. +type HEIC struct { + // Image holds the decoded frames, *image.NRGBA or *image.NRGBA64. + Image []image.Image + // Delay holds each frame's duration in seconds. + Delay []float64 + // LoopCount controls how many times the animation restarts, following + // image/gif: zero loops forever, -1 shows each frame once, and any other + // value plays the animation LoopCount+1 times. + LoopCount int + // Color describes the color space the frames were decoded from. + Color ColorInfo +} + +func parse(src *source) (*file, error) { + f := &file{src: src} + + seen := false + + err := src.eachBox(func(typ string, off, n uint64) error { + // Only these carry anything parse needs, so the media data is never + // read here: the items that reference it are read on demand. + switch typ { + case "ftyp", "meta", "moov": + case "mini": + // The MinimizedImageBox of the low overhead profile carries the + // whole description in place of meta, so a file built on it is one + // we can read nothing from rather than a malformed one. + return ErrUnsupported + default: + return nil + } + + if n > maxHeaderBox { + return ErrUnsupported + } + b, err := src.at(off, n) + if err != nil { + return err + } + + switch typ { + case "ftyp": + seen = true + case "meta": + if f.meta != nil { + return nil + } + + m, err := parseMeta(b) + if err != nil { + return err + } + + f.meta = m + + case "moov": + if f.movie != nil { + return nil + } + + mv, err := parseMoov(b) + if err != nil { + return err + } + + f.movie = mv + } + + return nil + }) + if err != nil { + return nil, err + } + + if !seen || (f.meta == nil && f.movie == nil) { + return nil, ErrInvalid + } + + return f, nil +} + +// srcFor addresses the file by range when the reader allows it, so only the +// items a decode reaches are read. Anything else is buffered whole, which is +// what image.Decode leaves us with: it hands the decoder a bufio.Reader. +func srcFor(r io.Reader) (*source, error) { + ra, raOK := r.(io.ReaderAt) + sk, skOK := r.(io.Seeker) + + if raOK && skOK { + cur, err1 := sk.Seek(0, io.SeekCurrent) + end, err2 := sk.Seek(0, io.SeekEnd) + + if err1 == nil && err2 == nil && end > cur { + n := end - cur + if n > maxInputBytes { + return nil, ErrUnsupported + } + + return &source{r: io.NewSectionReader(ra, cur, n), size: uint64(n)}, nil + } + } + + data, err := io.ReadAll(io.LimitReader(r, maxInputBytes+1)) + if err != nil { + return nil, err + } + if len(data) > maxInputBytes { + return nil, ErrUnsupported + } + + return memSource(data), nil +} + +// parseHeader reads only the boxes a configuration needs and stops as soon as +// one can be derived, so a stream that cannot be addressed by range still +// costs no more than its header. +func parseHeader(r io.Reader) (*file, error) { + f := &file{} + + seen := false + + err := eachBoxReader(r, func(typ string, n int64, body io.Reader) error { + switch typ { + case "ftyp": + seen = true + + case "meta": + if f.meta != nil { + return nil + } + + b, err := boxBytes(body, n) + if err != nil { + return err + } + + m, err := parseMeta(b) + if err != nil { + return err + } + + f.meta = m + + case "moov": + if f.movie != nil { + return nil + } + + b, err := boxBytes(body, n) + if err != nil { + return err + } + + mv, err := parseMoov(b) + if err != nil { + return err + } + + f.movie = mv + + return nil + + default: + return nil + } + + // Only a configuration from the primary item ends the walk. A picture + // track is the fallback for a file that has no usable image item, and + // a meta box after moov would still outrank it. + if seen { + if _, err := f.config(); err == nil { + return errStop + } + } + + return nil + }) + if err != nil && !errors.Is(err, errStop) { + return nil, err + } + + if !seen || (f.meta == nil && f.movie == nil) { + return nil, ErrInvalid + } + + return f, nil +} + +// config is the image configuration of the primary item, or of the picture +// track when a file carries no image item. +func (f *file) config() (image.Config, error) { + it, err := f.primary() + if err != nil { + if f.movie == nil { + return image.Config{}, err + } + + return f.sequenceConfig() + } + + w, h, err := f.size(it) + if err != nil { + return image.Config{}, err + } + + return image.Config{Width: w, Height: h, ColorModel: colorModelFor(f, it)}, nil +} + +func (f *file) limit() int { + switch { + case f.frameSizeLimit < 0: + return 0 + case f.frameSizeLimit == 0: + return DefaultFrameSizeLimit + } + + return f.frameSizeLimit +} + +// primary is the item a file describes itself with. +func (f *file) primary() (*item, error) { + if f.meta == nil { + return nil, ErrInvalid + } + + it := f.meta.items[f.meta.primary] + if it == nil { + return nil, ErrInvalid + } + + if it.unsupported { + return nil, ErrUnsupported + } + + return it, nil +} + +// alphaOf finds the auxiliary item that carries this item's alpha channel. +func (f *file) alphaOf(id uint32) *item { + for _, r := range f.meta.refs { + if r.typ != "auxl" || len(r.to) == 0 || r.to[0] != id { + continue + } + + it := f.meta.items[r.from] + if it == nil || it.unsupported { + continue + } + + if p := f.meta.prop(it, "auxC"); p != nil && isAlphaURN(p.auxC) { + return it + } + } + + return nil +} + +func isAlphaURN(s string) bool { + return s == "urn:mpeg:mpegB:cicp:systems:auxiliary:alpha" || + s == "urn:mpeg:hevc:2015:auxid:1" +} + +// itemDecoder carries the decoder across the tiles of a grid, which keeps the +// per-picture buffers allocated once, together with the configuration already +// fed to it so the tiles after the first skip the parameter sets they share. +type itemDecoder struct { + d hevc.Decoder + cfg *hevcConfig +} + +// use sets how many goroutines this decoder's wavefront may take. +func (dec *itemDecoder) use(threads int) *itemDecoder { + dec.d.Threads(threads) + + return dec +} + +func (f *file) decodeItem(dec *itemDecoder, it *item) (*hevc.Picture, error) { + // The coded SPS may disagree with the container's ispe. Enforce the + // budget in HEVC before it allocates pictures, including tiles and alpha. + dec.d.FrameSizeLimit(f.limit()) + dec.d.Budget(f.budget) + if it.typ == "grid" { + return nil, ErrUnsupported + } + + if it.typ != "hvc1" && it.typ != "hvc2" { + return nil, ErrUnsupported + } + + cfg := f.meta.prop(it, "hvcC") + if cfg == nil || cfg.hvcC == nil { + return nil, ErrInvalid + } + + data, err := f.meta.data(it, f.src) + if err != nil { + return nil, err + } + + if n := f.limit(); n > 0 { + if p := f.meta.prop(it, "ispe"); p != nil && uint64(p.w)*uint64(p.h) > uint64(n) { + return nil, ErrUnsupported + } + } + + if dec.cfg != cfg.hvcC { + for _, nal := range cfg.hvcC.paramSets { + u, ok := hevc.ParseNAL(nal) + if !ok || u.Type.IsVCL() { + return nil, ErrInvalid + } + + if _, err := dec.d.DecodeNAL(u); err != nil { + return nil, wrap(err) + } + } + + dec.cfg = cfg.hvcC + } + + var out []*hevc.Picture + complete := false + defer func() { + if !complete { + releasePictures(out) + } + }() + frames := 0 + err = eachNAL(data, cfg.hvcC.lengthSize, func(u hevc.NALUnit) error { + if u.Type.IsVCL() && len(u.RBSP) > 0 && u.RBSP[0]&0x80 != 0 { + frames++ + // An image item is one picture; refuse an appended sequence before + // allocating its second frame. + if frames > 1 { + return ErrInvalid + } + } + pics, err := dec.d.DecodeNAL(u) + out = append(out, pics...) + if err != nil { + return wrap(err) + } + return nil + }) + if err != nil { + return nil, err + } + + out = append(out, dec.d.Flush()...) + + if len(out) != 1 { + return nil, ErrInvalid + } + complete = true + return out[0], nil +} + +func wrap(err error) error { + if errors.Is(err, hevc.ErrUnsupported) { + return ErrUnsupported + } + + return ErrInvalid +} + +// decodeStill decodes the primary item, its alpha, and any grid it derives +// from, and converts the result. +func (f *file) decodeStill(o Options) (image.Image, ColorInfo, error) { + it, err := f.primary() + if err != nil { + if f.movie == nil { + return nil, ColorInfo{}, err + } + + seq, serr := f.decodeSequence(o, 1) + if serr != nil { + return nil, ColorInfo{}, serr + } + + if seq == nil { + return nil, ColorInfo{}, err + } + + return seq.Image[0], seq.Color, nil + } + + pic, err := f.decodeImage(it) + if err != nil { + return nil, ColorInfo{}, err + } + + // ISO/IEC 23008-12 7.2.1: ispe is the displayed size. + f.clampToISPE(it, pic) + + var alpha *hevc.Picture + + // A grid carries no alpha of its own; its tiles do. + if it.typ == "grid" { + alpha, err = f.gridAlpha(it) + } else if a := f.alphaOf(it.id); a != nil { + alpha, err = f.decodeImage(a) + } + + if err != nil { + return nil, ColorInfo{}, err + } + + if alpha != nil { + f.clampToISPE(it, alpha) + } + + ci := f.colorInfo(it, pic) + + img, err := toImage(pic, alpha, ci, o.ToYCbCr) + if err != nil { + return nil, ci, err + } + + if o.AutoRotate { + img, err = f.transform(it, img) + if err != nil { + return nil, ci, err + } + } + + return img, ci, nil +} + +// Decode reads a HEIC image as *image.NRGBA, or *image.NRGBA64 above 8 bits. +func Decode(r io.Reader, opts ...Options) (image.Image, error) { + img, _, err := decode(r, opts...) + + return img, err +} + +// DecodeColor is Decode, and also reports the color space the image was +// decoded from. +func DecodeColor(r io.Reader, opts ...Options) (image.Image, ColorInfo, error) { + return decode(r, opts...) +} + +func decode(r io.Reader, opts ...Options) (image.Image, ColorInfo, error) { + src, err := srcFor(r) + if err != nil { + return nil, ColorInfo{}, err + } + + f, err := parse(src) + if err != nil { + return nil, ColorInfo{}, err + } + + o := options(opts) + f.setOptions(o) + + return f.decodeStill(o) +} + +// DecodeAll returns every frame of an image sequence with its duration, and +// how many times the animation repeats. A still image gives one frame. +// FrameSizeLimit also bounds the aggregate coded pixel area of each track. +func DecodeAll(r io.Reader, opts ...Options) (*HEIC, error) { + src, err := srcFor(r) + if err != nil { + return nil, err + } + + f, err := parse(src) + if err != nil { + return nil, err + } + + o := options(opts) + f.setOptions(o) + + if f.movie != nil { + seq, err := f.decodeSequence(o, 0) + if err != nil { + return nil, err + } + + if seq != nil { + return seq, nil + } + } + + img, ci, err := f.decodeStill(o) + if err != nil { + return nil, err + } + + return &HEIC{Image: []image.Image{img}, Delay: []float64{0}, Color: ci}, nil +} + +// DecodeConfig returns the dimensions and color model without decoding the +// image data. +func DecodeConfig(r io.Reader) (image.Config, error) { + return DecodeConfigWithOptions(r, Options{}) +} + +// DecodeConfigWithOptions returns the display dimensions after the requested +// container transforms and applies the same pixel budget as Decode. Coded SPS +// dimensions are independently checked when Decode reaches the image data. +func DecodeConfigWithOptions(r io.Reader, o Options) (image.Config, error) { + f, err := parseHeader(r) + if err != nil { + return image.Config{}, err + } + + f.frameSizeLimit = o.FrameSizeLimit + cfg, err := f.config() + if err != nil { + return image.Config{}, err + } + if n := f.limit(); n > 0 && uint64(cfg.Width)*uint64(cfg.Height) > uint64(n) { + return image.Config{}, ErrUnsupported + } + if o.AutoRotate { + if it, err := f.primary(); err == nil { + if clap := f.meta.prop(it, "clap"); clap != nil { + _, _, w, h, ok := cropRect(&clap.clap, cfg.Width, cfg.Height) + if !ok { + return image.Config{}, ErrInvalid + } + cfg.Width, cfg.Height = w, h + } + if rot := f.meta.prop(it, "irot"); rot != nil && rot.angle&1 != 0 { + cfg.Width, cfg.Height = cfg.Height, cfg.Width + } + } + } + return cfg, nil +} + +// sequenceConfig reads the dimensions from the sample entry of the picture +// track, for a file that carries no image item. +func (f *file) sequenceConfig() (image.Config, error) { + t := f.movie.pictTrack() + if t == nil || t.hvcC == nil || t.width == 0 || t.height == 0 { + return image.Config{}, ErrInvalid + } + + model := color.NRGBAModel + if t.hvcC.bitDepthLuma > 8 { + model = color.NRGBA64Model + } + + return image.Config{Width: t.width, Height: t.height, ColorModel: model}, nil +} + +// clampToISPE trims a decoded picture to the size the item declares. +func (f *file) clampToISPE(it *item, pic *hevc.Picture) { + p := f.meta.prop(it, "ispe") + if p == nil { + return + } + + pic.CropW = min(pic.CropW, int(p.w)) + pic.CropH = min(pic.CropH, int(p.h)) +} + +// size is the stored size of an item, which is what Decode returns unless +// AutoRotate transforms it. +func (f *file) size(it *item) (int, int, error) { + p := f.meta.prop(it, "ispe") + if p == nil { + return 0, 0, ErrInvalid + } + + if p.w == 0 || p.h == 0 || p.w > 1<<20 || p.h > 1<<20 { + return 0, 0, ErrInvalid + } + + return int(p.w), int(p.h), nil +} + +func decodeWrapper(r io.Reader) (image.Image, error) { + return Decode(r) +} + +func init() { + for _, brand := range []string{ + "heic", "heix", "heim", "heis", "hevc", "hevx", "hevm", "hevs", + "mif1", "msf1", + } { + image.RegisterFormat("heic", "????ftyp"+brand, decodeWrapper, DecodeConfig) + } +} diff --git a/third_party/h265/heic/image.go b/third_party/h265/heic/image.go new file mode 100644 index 00000000..b018f83d --- /dev/null +++ b/third_party/h265/heic/image.go @@ -0,0 +1,276 @@ +package heic + +import ( + "image" + "image/color" + + "github.com/gen2brain/h265/hevc" +) + +func colorModelFor(f *file, it *item) color.Model { + if p := f.meta.prop(it, "hvcC"); p != nil && p.hvcC != nil && p.hvcC.bitDepthLuma > 8 { + return color.NRGBA64Model + } + + return color.NRGBAModel +} + +// colorInfo is the color description of an item. The sequence declares one in +// its video usability information and a colr box replaces it whole, which is +// how ISO/IEC 23008-12 resolves the two. +func (f *file) colorInfo(it *item, pic *hevc.Picture) ColorInfo { + ci := ColorInfo{Matrix: mcUnspec, Primaries: 2, Transfer: 2} + + if pic != nil { + ci = ColorInfo{ + Primaries: pic.ColorPrimaries, + Transfer: pic.ColorTransfer, + Matrix: pic.ColorMatrix, + FullRange: pic.FullRange, + } + } + + if f.meta == nil || it == nil { + return ci + } + + // 23008-12 lets a picture carry an nclx description and an ICC profile at + // once, in a colr box each, so both are read rather than the first. + for _, ip := range it.props { + if ip.idx >= len(f.meta.props) { + continue + } + + c := f.meta.props[ip.idx].colr + if c == nil { + continue + } + + if c.hasNCLX { + ci.Primaries = c.primaries + ci.Transfer = c.transfer + ci.Matrix = c.matrix + ci.FullRange = c.fullRange + } + + if c.icc != nil { + ci.ICCP = c.icc + } + } + + return ci +} + +// aliasesPicture reports whether img shares memory with the decoded planes. +func aliasesPicture(img image.Image) bool { + switch img.(type) { + case *image.YCbCr, *image.NYCbCrA, *image.Gray: + return true + } + + return false +} + +// planar wraps the decoded planes without converting them, which is what the +// bitstream already holds. +func planar(pic, alpha *hevc.Picture) (image.Image, bool) { + if pic.BitDepth != 8 { + return nil, false + } + + y, cb, cr := views(pic) + rect := image.Rect(0, 0, pic.CropW, pic.CropH) + + if pic.ChromaFormat == 0 { + if alpha != nil { + return nil, false + } + + return &image.Gray{Pix: y.p8, Stride: y.stride, Rect: rect}, true + } + + var ratio image.YCbCrSubsampleRatio + + switch pic.ChromaFormat { + case 1: + ratio = image.YCbCrSubsampleRatio420 + case 2: + ratio = image.YCbCrSubsampleRatio422 + case 3: + ratio = image.YCbCrSubsampleRatio444 + default: + return nil, false + } + + yc := image.YCbCr{ + Y: y.p8, Cb: cb.p8, Cr: cr.p8, + YStride: y.stride, CStride: cb.stride, + SubsampleRatio: ratio, Rect: rect, + } + + if alpha == nil { + return &yc, true + } + + if alpha.BitDepth != 8 || alpha.ChromaFormat != 0 { + return nil, false + } + + ay, _, _ := views(alpha) + if ay.w != pic.CropW || ay.h != pic.CropH { + return nil, false + } + + return &image.NYCbCrA{YCbCr: yc, A: ay.p8, AStride: ay.stride}, true +} + +func toImage(pic, alpha *hevc.Picture, ci ColorInfo, ycbcr bool) (image.Image, error) { + if ycbcr { + if img, ok := planar(pic, alpha); ok { + return img, nil + } + } + + outDepth := 8 + if pic.BitDepth > 8 { + outDepth = 16 + } + + cs := newColorState(pic, ci, outDepth) + if cs.unsupported { + return nil, ErrUnsupported + } + + y, cb, cr := views(pic) + if !y.valid() { + return nil, ErrInvalid + } + + w, h := y.w, y.h + rect := image.Rect(0, 0, w, h) + + cs.prepare(w) + + if cs.fastRow(outDepth) { + cs.consts = cs.rowConsts() + + if outDepth == 8 { + cs.row = rowFn(cs.ssHor) + } else { + cs.row16 = rowFn16(cs.ssHor) + } + } + + var ( + as *colorState + av planeView + ) + + if alpha != nil { + // An auxiliary alpha image carries its values in the luma channel, so + // chroma planes are padding whatever the file codes them as. + av, _, _ = views(alpha) + if av.w != w || av.h != h { + return nil, ErrUnsupported + } + + as = alphaState(alpha, outDepth, true) + as.prepare(w) + } + + rgb := make([]uint16, 3*w) + aRow := make([]uint16, w) + + if as == nil { + for x := range aRow { + aRow[x] = uint16(cs.outMax) + } + } + + if outDepth == 8 { + dst := image.NewNRGBA(rect) + + if cs.row != nil { + ab := make([]uint8, w) + for i := range ab { + ab[i] = 0xff + } + + for row := range h { + if as != nil { + as.alphaRow(av, row, w, aRow) + + for i, v := range aRow { + ab[i] = uint8(v) + } + } + + u0, u1, v0, v1 := cs.rowPlanes(cb, cr, row, w, h) + cs.row(dst.Pix[row*dst.Stride:], cs.lumaRowF(y, row, w), + u0, u1, v0, v1, ab, w, &cs.consts) + } + + return dst, nil + } + + for row := range h { + cs.rgbRow(y, cb, cr, row, rgb) + + if as != nil { + as.alphaRow(av, row, w, aRow) + } + + o := row * dst.Stride + for x := range w { + dst.Pix[o] = uint8(rgb[3*x]) + dst.Pix[o+1] = uint8(rgb[3*x+1]) + dst.Pix[o+2] = uint8(rgb[3*x+2]) + dst.Pix[o+3] = uint8(aRow[x]) + o += 4 + } + } + + return dst, nil + } + + dst := image.NewNRGBA64(rect) + + if cs.row16 != nil { + for row := range h { + if as != nil { + as.alphaRow(av, row, w, aRow) + } + + u0, u1, v0, v1 := cs.rowPlanes(cb, cr, row, w, h) + cs.row16(dst.Pix[row*dst.Stride:], cs.lumaRowF(y, row, w), + u0, u1, v0, v1, aRow, w, &cs.consts) + } + + return dst, nil + } + + for row := range h { + cs.rgbRow(y, cb, cr, row, rgb) + + if as != nil { + as.alphaRow(av, row, w, aRow) + } + + o := row * dst.Stride + for x := range w { + r, g, b, a := rgb[3*x], rgb[3*x+1], rgb[3*x+2], aRow[x] + + dst.Pix[o] = uint8(r >> 8) + dst.Pix[o+1] = uint8(r) + dst.Pix[o+2] = uint8(g >> 8) + dst.Pix[o+3] = uint8(g) + dst.Pix[o+4] = uint8(b >> 8) + dst.Pix[o+5] = uint8(b) + dst.Pix[o+6] = uint8(a >> 8) + dst.Pix[o+7] = uint8(a) + o += 8 + } + } + + return dst, nil +} diff --git a/third_party/h265/heic/isobmff.go b/third_party/h265/heic/isobmff.go new file mode 100644 index 00000000..a36fc6cc --- /dev/null +++ b/third_party/h265/heic/isobmff.go @@ -0,0 +1,917 @@ +package heic + +import ( + "errors" + "io" + "slices" +) + +// ErrInvalid is returned when a file is not a HEIF, or is malformed past the +// point where anything can be decoded from it. +var ErrInvalid = errors.New("heic: invalid file") + +type reader struct { + b []byte + i int + err bool +} + +func (r *reader) remaining() int { + if r.err { + return 0 + } + + return len(r.b) - r.i +} + +func (r *reader) fail() { + r.err = true + r.i = len(r.b) +} + +func (r *reader) bytes(n int) []byte { + if n < 0 || r.remaining() < n { + r.fail() + + return nil + } + v := r.b[r.i : r.i+n] + r.i += n + + return v +} + +func (r *reader) skip(n int) { + r.bytes(n) +} + +func (r *reader) u8() uint8 { + b := r.bytes(1) + if b == nil { + return 0 + } + + return b[0] +} + +func (r *reader) uint(n int) uint64 { + b := r.bytes(n) + var v uint64 + for _, c := range b { + v = v<<8 | uint64(c) + } + + return v +} + +func (r *reader) u16() uint16 { return uint16(r.uint(2)) } +func (r *reader) u32() uint32 { return uint32(r.uint(4)) } +func (r *reader) u64() uint64 { return r.uint(8) } + +// boxNames interns the four-character codes the parser compares against, so +// reading a header allocates nothing. An unknown box is skipped on its size +// alone, and its name never leaves this function. +var boxNames = func() map[[4]byte]string { + m := make(map[[4]byte]string) + + for _, s := range []string{ + "ftyp", "meta", "hdlr", "pitm", "iinf", "infe", "iloc", "iref", "iprp", + "ipco", "ipma", "hvcC", "ispe", "colr", "pixi", "irot", "imir", "clap", + "auxC", "idat", "mdat", "moov", "trak", "mdia", "minf", "stbl", "stsd", + "stts", "stsc", "stsz", "stco", "co64", "tkhd", "mvhd", "hvc1", "hvc2", + "grid", "Exif", "mime", "iprd", "free", "skip", "url ", "dinf", "dref", + } { + m[[4]byte([]byte(s))] = s + } + + return m +}() + +func (r *reader) str4() string { + b := r.bytes(4) + if b == nil { + return "" + } + + if s, ok := boxNames[[4]byte(b)]; ok { + return s + } + + return string(b) +} + +func (r *reader) cstr() string { + for j := r.i; j < len(r.b); j++ { + if r.b[j] == 0 { + s := string(r.b[r.i:j]) + r.i = j + 1 + + return s + } + } + r.fail() + + return "" +} + +func (r *reader) fullBox() (uint8, uint32) { + v := r.u32() + + return uint8(v >> 24), v & 0xffffff +} + +// eachBox calls fn for every box in b. +func eachBox(b []byte, fn func(typ string, payload []byte) error) error { + r := &reader{b: b} + + for r.remaining() >= 8 { + start := r.i + size := uint64(r.u32()) + typ := r.str4() + hdr := 8 + + switch size { + case 1: + size = r.u64() + hdr = 16 + case 0: + size = uint64(len(b) - start) + } + if r.err || size < uint64(hdr) || size > uint64(len(b)-start) { + return ErrInvalid + } + + payload := b[start+hdr : start+int(size)] + if typ == "uuid" { + r.i = start + int(size) + + continue + } + if err := fn(typ, payload); err != nil { + return err + } + r.i = start + int(size) + } + + if r.err { + return ErrInvalid + } + + return nil +} + +type extent struct { + off, len uint64 +} + +type item struct { + id uint32 + typ string + contentType string + extents []extent + + method uint8 + baseOffset uint64 + + props []itemProp + + unsupported bool +} + +type itemProp struct { + idx int + essential bool +} + +type itemRef struct { + typ string + from uint32 + to []uint32 +} + +type property struct { + typ string + + w, h uint32 + hvcC *hevcConfig + pixi []uint8 + auxC string + angle uint8 + axis uint8 + clap [8]uint32 + colr *colorInfo +} + +// hevcConfig is the HEVCDecoderConfigurationRecord of ISO/IEC 14496-15. The +// parameter sets travel in it rather than in the item data. +type hevcConfig struct { + chromaFormat uint8 + bitDepthLuma uint8 + bitDepthChroma uint8 + lengthSize int + paramSets [][]byte +} + +type colorInfo struct { + icc []byte + primaries uint16 + transfer uint16 + matrix uint16 + fullRange bool + hasNCLX bool +} + +type metaBox struct { + primary uint32 + items map[uint32]*item + order []uint32 + props []property + refs []itemRef + idat []byte + entries int +} + +// reserve accounts for parsed objects, including zero-width iloc extents. +// Payload size alone cannot bound the number of such objects. +func (m *metaBox) reserve(n int) bool { + if n < 0 || n > maxSamples-m.entries { + return false + } + m.entries += n + return true +} + +// source is the file, addressed by range. b carries it when it is buffered, +// and r reads it a range at a time when it is not. +type source struct { + b []byte + r io.ReaderAt + size uint64 +} + +func memSource(b []byte) *source { + return &source{b: b, size: uint64(len(b))} +} + +// at returns n bytes at off. It aliases a buffered file, so the result must +// not be written to. +func (s *source) at(off, n uint64) ([]byte, error) { + if off > s.size || n > s.size-off { + return nil, ErrInvalid + } + + if s.r == nil { + return s.b[off : off+n], nil + } + + buf := make([]byte, n) + + _, err := io.ReadFull(io.NewSectionReader(s.r, int64(off), int64(n)), buf) + + switch { + case err == nil: + return buf, nil + case errors.Is(err, io.EOF), errors.Is(err, io.ErrUnexpectedEOF): + return nil, ErrInvalid + } + + return nil, err +} + +// eachBox reports where each top level payload lives without reading it. +func (s *source) eachBox(fn func(typ string, off, n uint64) error) error { + for off := uint64(0); off+8 <= s.size; { + b, err := s.at(off, 8) + if err != nil { + return err + } + + r := &reader{b: b} + size := uint64(r.u32()) + typ := r.str4() + head := uint64(8) + + switch size { + case 1: + if off+16 > s.size { + return ErrInvalid + } + + b, err = s.at(off+8, 8) + if err != nil { + return err + } + + rr := &reader{b: b} + size = rr.u64() + head = 16 + r.err = r.err || rr.err + + case 0: + size = s.size - off + } + + if r.err || size < head || size > s.size-off { + return ErrInvalid + } + + if typ != "uuid" { + if err := fn(typ, off+head, size-head); err != nil { + return err + } + } + + off += size + } + + return nil +} + +// maxHeaderBox bounds a box eachBoxReader buffers, since its size comes from +// the file being read. +const maxHeaderBox = 64 << 20 + +// errStop ends a walk from inside the callback. +var errStop = errors.New("heic: stop") + +// eachBoxReader walks the top-level boxes of a stream. fn reads as much of a +// box as it wants from body, whose length is n, or -1 when the box runs to the +// end of the file; the rest is discarded before the next box. Boxes fn does +// not read are never buffered, so a caller after the header alone does not pay +// for the media data behind it. +func eachBoxReader(r io.Reader, fn func(typ string, n int64, body io.Reader) error) error { + var hdr [16]byte + + for { + // A short read at a box boundary is the end of the walk, which is + // what the in-memory form does with a trailing partial header. + if _, err := io.ReadFull(r, hdr[:8]); err != nil { + if err == io.EOF || err == io.ErrUnexpectedEOF { + return nil + } + + return ErrInvalid + } + + size := uint64(hdr[0])<<24 | uint64(hdr[1])<<16 | uint64(hdr[2])<<8 | uint64(hdr[3]) + typ := string(hdr[4:8]) + head := uint64(8) + + if size == 1 { + if _, err := io.ReadFull(r, hdr[8:16]); err != nil { + return ErrInvalid + } + + size = 0 + for _, b := range hdr[8:16] { + size = size<<8 | uint64(b) + } + + head = 16 + } + + n := int64(-1) + if size != 0 { + if size < head { + return ErrInvalid + } + + n = int64(size - head) + } + + body := &io.LimitedReader{R: r, N: maxHeaderBox} + if n >= 0 { + body.N = n + } + + err := fn(typ, n, body) + + if _, derr := io.Copy(io.Discard, body); derr != nil { + return ErrInvalid + } + + if err != nil { + return err + } + + if n < 0 { + return nil + } + } +} + +// boxBytes reads a whole box payload, refusing one too large to be a header. +func boxBytes(body io.Reader, n int64) ([]byte, error) { + if n < 0 { + return io.ReadAll(body) + } + + if n > maxHeaderBox { + return nil, ErrInvalid + } + + b := make([]byte, n) + if _, err := io.ReadFull(body, b); err != nil { + return nil, ErrInvalid + } + + return b, nil +} + +func parseMeta(payload []byte) (*metaBox, error) { + r := &reader{b: payload} + r.fullBox() + if r.err { + return nil, ErrInvalid + } + + m := &metaBox{items: map[uint32]*item{}} + + err := eachBox(payload[r.i:], func(typ string, b []byte) error { + switch typ { + case "pitm": + rr := &reader{b: b} + v, _ := rr.fullBox() + if v == 0 { + m.primary = uint32(rr.u16()) + } else { + m.primary = rr.u32() + } + if rr.err { + return ErrInvalid + } + + case "iloc": + return m.parseIloc(b) + + case "iinf": + return m.parseIinf(b) + + case "iref": + return m.parseIref(b) + + case "idat": + m.idat = b + + case "iprp": + return eachBox(b, func(typ string, b []byte) error { + switch typ { + case "ipco": + return m.parseIpco(b) + case "ipma": + return m.parseIpma(b) + } + + return nil + }) + } + + return nil + }) + if err != nil { + return nil, err + } + + m.markUnsupported() + + return m, nil +} + +// knownProps are the property types this reader implements. An item with an +// essential association to anything else must not be processed. +var knownProps = map[string]bool{ + "ispe": true, "hvcC": true, "pixi": true, "auxC": true, + "irot": true, "imir": true, "clap": true, "colr": true, +} + +func (m *metaBox) markUnsupported() { + for _, it := range m.items { + for _, ip := range it.props { + if !ip.essential { + continue + } + if ip.idx >= len(m.props) || !knownProps[m.props[ip.idx].typ] { + it.unsupported = true + + break + } + } + } +} + +func (m *metaBox) item(id uint32) *item { + it := m.items[id] + if it == nil { + it = &item{id: id} + m.items[id] = it + m.order = append(m.order, id) + } + + return it +} + +func (m *metaBox) parseIloc(b []byte) error { + r := &reader{b: b} + v, _ := r.fullBox() + + sizes := r.u8() + offSize, lenSize := int(sizes>>4), int(sizes&0xf) + sizes = r.u8() + baseSize, idxSize := int(sizes>>4), int(sizes&0xf) + if v < 1 { + idxSize = 0 + } + for _, n := range []int{offSize, lenSize, baseSize, idxSize} { + if n != 0 && n != 4 && n != 8 { + return ErrInvalid + } + } + + count := int(r.u16()) + if v == 2 { + r.i -= 2 + count = int(r.u32()) + } + + for range count { + if !m.reserve(1) { + return ErrUnsupported + } + var id uint32 + if v < 2 { + id = uint32(r.u16()) + } else { + id = r.u32() + } + it := m.item(id) + if v >= 1 { + it.method = uint8(r.u16() & 0xf) + } + r.skip(2) + it.baseOffset = r.uint(baseSize) + + n := int(r.u16()) + if !m.reserve(n) { + return ErrUnsupported + } + for range n { + r.skip(idxSize) + off := r.uint(offSize) + l := r.uint(lenSize) + if r.err { + return ErrInvalid + } + it.extents = append(it.extents, extent{off: off, len: l}) + } + if r.err { + return ErrInvalid + } + } + + return nil +} + +func (m *metaBox) parseIinf(b []byte) error { + r := &reader{b: b} + v, _ := r.fullBox() + if v == 0 { + r.u16() + } else { + r.u32() + } + if r.err { + return ErrInvalid + } + + return eachBox(b[r.i:], func(typ string, b []byte) error { + if typ != "infe" { + return nil + } + if !m.reserve(1) { + return ErrUnsupported + } + rr := &reader{b: b} + v, _ := rr.fullBox() + if v < 2 { + return nil + } + + var id uint32 + if v == 2 { + id = uint32(rr.u16()) + } else { + id = rr.u32() + } + rr.skip(2) + itemType := rr.str4() + if rr.err { + return ErrInvalid + } + + it := m.item(id) + it.typ = itemType + + if itemType == "mime" { + rr.cstr() + if ct := rr.cstr(); !rr.err { + it.contentType = ct + } + } + + return nil + }) +} + +func (m *metaBox) parseIref(b []byte) error { + r := &reader{b: b} + v, _ := r.fullBox() + if r.err { + return ErrInvalid + } + + return eachBox(b[r.i:], func(typ string, b []byte) error { + rr := &reader{b: b} + ref := itemRef{typ: typ} + if v == 0 { + ref.from = uint32(rr.u16()) + } else { + ref.from = rr.u32() + } + n := int(rr.u16()) + if !m.reserve(n + 1) { + return ErrUnsupported + } + for range n { + var to uint32 + if v == 0 { + to = uint32(rr.u16()) + } else { + to = rr.u32() + } + ref.to = append(ref.to, to) + } + if rr.err { + return ErrInvalid + } + m.refs = append(m.refs, ref) + + return nil + }) +} + +func (m *metaBox) parseIpco(b []byte) error { + return eachBox(b, func(typ string, b []byte) error { + if !m.reserve(1) { + return ErrUnsupported + } + p := property{typ: typ} + r := &reader{b: b} + + switch typ { + case "ispe": + r.fullBox() + p.w = r.u32() + p.h = r.u32() + + case "hvcC": + c, err := parseHvcC(r) + if err != nil { + return err + } + p.hvcC = c + + case "pixi": + r.fullBox() + n := int(r.u8()) + for range n { + p.pixi = append(p.pixi, r.u8()) + } + + case "auxC": + r.fullBox() + p.auxC = r.cstr() + + case "irot": + p.angle = r.u8() & 3 + + case "imir": + p.axis = r.u8() & 1 + + case "clap": + for i := range 8 { + p.clap[i] = r.u32() + } + + case "colr": + switch r.str4() { + case "nclx": + p.colr = &colorInfo{hasNCLX: true} + p.colr.primaries = r.u16() + p.colr.transfer = r.u16() + p.colr.matrix = r.u16() + p.colr.fullRange = r.u8()>>7 != 0 + case "rICC", "prof": + p.colr = &colorInfo{icc: b[4:]} + } + } + + if r.err { + return ErrInvalid + } + m.props = append(m.props, p) + + return nil + }) +} + +func (m *metaBox) parseIpma(b []byte) error { + r := &reader{b: b} + v, flags := r.fullBox() + count := int(r.u32()) + if r.err { + return ErrInvalid + } + + for range count { + var id uint32 + if v < 1 { + id = uint32(r.u16()) + } else { + id = r.u32() + } + it := m.item(id) + + n := int(r.u8()) + if !m.reserve(n + 1) { + return ErrUnsupported + } + it.props = slices.Grow(it.props, n) + + for range n { + var idx int + var essential bool + if flags&1 != 0 { + v := r.u16() + idx, essential = int(v&0x7fff), v&0x8000 != 0 + } else { + v := r.u8() + idx, essential = int(v&0x7f), v&0x80 != 0 + } + if r.err { + return ErrInvalid + } + if idx == 0 { + continue + } + it.props = append(it.props, itemProp{idx: idx - 1, essential: essential}) + } + if r.err { + return ErrInvalid + } + } + + return nil +} + +// data gathers an item's extents out of the file or, for construction method 1, out of idat. +func (m *metaBox) data(it *item, src *source) ([]byte, error) { + if it.method == 1 { + src = memSource(m.idat) + } else if it.method != 0 { + return nil, ErrInvalid + } + + span := func(e extent) (uint64, uint64, error) { + if it.baseOffset > src.size || e.off > src.size-it.baseOffset { + return 0, 0, ErrInvalid + } + off, n := it.baseOffset+e.off, e.len + if n == 0 { + if off > src.size { + return 0, 0, ErrInvalid + } + + n = src.size - off + } + + if off > src.size || n > src.size-off || n > maxItemBytes { + return 0, 0, ErrInvalid + } + + return off, n, nil + } + + if len(it.extents) == 1 { + off, n, err := span(it.extents[0]) + if err != nil { + return nil, err + } + + return src.at(off, n) + } + + // Validate all spans before reading or allocating. Repeated extents must + // not amplify a small source into an arbitrarily large allocation. + var total uint64 + for _, e := range it.extents { + _, n, err := span(e) + if err != nil { + return nil, err + } + if n > min(src.size, maxItemBytes)-total { + return nil, ErrInvalid + } + total += n + } + if total == 0 { + return nil, ErrInvalid + } + out := make([]byte, 0, int(total)) + for _, e := range it.extents { + off, n, _ := span(e) // Validated above against the same immutable source. + + b, err := src.at(off, n) + if err != nil { + return nil, err + } + + out = append(out, b...) + } + + return out, nil +} + +func (m *metaBox) prop(it *item, typ string) *property { + if it == nil { + return nil + } + for _, ip := range it.props { + if ip.idx < len(m.props) && m.props[ip.idx].typ == typ { + return &m.props[ip.idx] + } + } + + return nil +} + +func (m *metaBox) refsTo(typ string, from uint32) []uint32 { + for _, r := range m.refs { + if r.typ == typ && r.from == from { + return r.to + } + } + + return nil +} + +// parseHvcC reads the HEVCDecoderConfigurationRecord. Everything before the +// arrays is fixed width, and the profile and level fields are not needed once +// the parameter sets themselves are in hand. +func parseHvcC(r *reader) (*hevcConfig, error) { + if r.u8() != 1 { + return nil, ErrInvalid + } + + r.skip(12) + + c := &hevcConfig{} + + r.skip(2) + r.skip(1) + c.chromaFormat = r.u8() & 3 + c.bitDepthLuma = r.u8()&7 + 8 + c.bitDepthChroma = r.u8()&7 + 8 + + r.skip(2) + c.lengthSize = int(r.u8()&3) + 1 + + arrays := int(r.u8()) + if r.err { + return nil, ErrInvalid + } + + for range arrays { + r.skip(1) + + n := int(r.u16()) + if r.err || n > r.remaining()/4 || n > 1024-len(c.paramSets) { + return nil, ErrInvalid + } + c.paramSets = slices.Grow(c.paramSets, n) + + for range n { + nal := r.bytes(int(r.u16())) + if r.err { + return nil, ErrInvalid + } + + c.paramSets = append(c.paramSets, nal) + } + } + + if r.err { + return nil, ErrInvalid + } + + return c, nil +} diff --git a/third_party/h265/heic/limits.go b/third_party/h265/heic/limits.go new file mode 100644 index 00000000..ee5a2ee2 --- /dev/null +++ b/third_party/h265/heic/limits.go @@ -0,0 +1,39 @@ +package heic + +import "github.com/gen2brain/h265/hevc" + +// eachNAL validates framing and visits one unit at a time. Splitting the whole +// item first retains every unescaped payload and a slice entry per tiny NAL. +func eachNAL(data []byte, lengthSize int, visit func(hevc.NALUnit) error) error { + if lengthSize < 1 || lengthSize > 4 { + return ErrInvalid + } + for count := 0; len(data) > 0; count++ { + if count >= maxSamples || len(data) < lengthSize { + return ErrInvalid + } + var n uint64 + for _, b := range data[:lengthSize] { + n = n*256 + uint64(b) + } + data = data[lengthSize:] + if n < 2 || n > uint64(len(data)) { + return ErrInvalid + } + u, ok := hevc.ParseNAL(data[:int(n)]) + if !ok { + return ErrInvalid + } + if err := visit(u); err != nil { + return err + } + data = data[int(n):] + } + return nil +} + +func releasePictures(pics []*hevc.Picture) { + for _, pic := range pics { + pic.Release() + } +} diff --git a/third_party/h265/heic/sequence.go b/third_party/h265/heic/sequence.go new file mode 100644 index 00000000..07b31878 --- /dev/null +++ b/third_party/h265/heic/sequence.go @@ -0,0 +1,601 @@ +package heic + +import ( + "errors" + "math" + + "github.com/gen2brain/h265/hevc" +) + +type track struct { + id uint32 + handler string + timescale uint32 + samples []extent + deltas []uint32 + auxl []uint32 + auxType string + hvcC *hevcConfig + width int + height int + + duration uint64 + hasEdits bool + repeating bool + segmentDur uint64 +} + +const indefiniteDuration = ^uint64(0) + +// loopCount is zero for forever, -1 to show each frame once, otherwise the +// number of extra plays. ISO/IEC 23008-12 section 9.6.1. +func (t *track) loopCount() int { + if !t.hasEdits { + return 0 + } + if !t.repeating { + return -1 + } + if t.duration == indefiniteDuration || t.duration == 0 || t.segmentDur == 0 { + return 0 + } + + n := t.duration / t.segmentDur + if t.duration%t.segmentDur != 0 { + n++ + } + if n <= 1 { + return -1 + } + if n-1 > math.MaxInt32 { + return 0 + } + + return int(n - 1) +} + +type movie struct { + tracks []track +} + +// A movie's expanded sample tables share this budget. HEIC sequences longer +// than this need a streaming API rather than eager table/frame allocation. +const maxSamples = 65536 + +func parseMoov(b []byte) (*movie, error) { + m := &movie{} + samples := 0 + + err := eachBox(b, func(typ string, b []byte) error { + if typ != "trak" { + return nil + } + t, err := parseTrak(b) + if err != nil { + return err + } + samples += len(t.samples) + if samples > maxSamples || len(m.tracks) >= 1024 { + return ErrUnsupported + } + m.tracks = append(m.tracks, t) + + return nil + }) + if err != nil { + return nil, err + } + + return m, nil +} + +func parseTrak(b []byte) (track, error) { + var t track + + err := eachBox(b, func(typ string, b []byte) error { + switch typ { + case "tkhd": + r := &reader{b: b} + v, _ := r.fullBox() + if v == 1 { + r.skip(16) + } else { + r.skip(8) + } + t.id = r.u32() + r.skip(4) + if v == 1 { + t.duration = r.u64() + } else { + t.duration = uint64(r.u32()) + if t.duration == 1<<32-1 { + t.duration = indefiniteDuration + } + } + if r.err { + return ErrInvalid + } + + case "edts": + t.hasEdits = true + + return eachBox(b, func(typ string, b []byte) error { + if typ != "elst" { + return nil + } + r := &reader{b: b} + v, flags := r.fullBox() + if flags&1 == 0 { + return nil + } + t.repeating = true + if r.u32() != 1 { + return nil + } + if v == 1 { + t.segmentDur = r.u64() + } else { + t.segmentDur = uint64(r.u32()) + } + if r.err { + return ErrInvalid + } + + return nil + }) + + case "tref": + return eachBox(b, func(typ string, b []byte) error { + if typ != "auxl" { + return nil + } + r := &reader{b: b} + for r.remaining() >= 4 { + t.auxl = append(t.auxl, r.u32()) + } + + return nil + }) + + case "mdia": + return eachBox(b, func(typ string, b []byte) error { + switch typ { + case "mdhd": + r := &reader{b: b} + v, _ := r.fullBox() + if v == 1 { + r.skip(16) + } else { + r.skip(8) + } + t.timescale = r.u32() + if r.err { + return ErrInvalid + } + + case "hdlr": + r := &reader{b: b} + r.fullBox() + r.u32() + t.handler = r.str4() + + case "minf": + return eachBox(b, func(typ string, b []byte) error { + if typ != "stbl" { + return nil + } + + return t.parseStbl(b) + }) + } + + return nil + }) + } + + return nil + }) + + return t, err +} + +type chunkRun struct { + firstChunk, perChunk uint32 +} + +func (t *track) parseStbl(b []byte) error { + var sizes []uint32 + var offsets []uint64 + var runs []chunkRun + type timingRun struct{ count, delta uint32 } + var timing []timingRun + timingCount := 0 + seen := make(map[string]bool) + + err := eachBox(b, func(typ string, b []byte) error { + r := &reader{b: b} + // Multiple copies could otherwise repeatedly replace or expand a + // table, and make its final size conceal the parsing cost. + switch typ { + case "stts", "stsz", "stsd", "stsc", "stco", "co64": + key := typ + if key == "co64" { + key = "stco" + } + if seen[key] { + return ErrInvalid + } + seen[key] = true + } + + switch typ { + case "stts": + r.fullBox() + n := int(r.u32()) + if r.err || n < 0 || n > maxSamples || n > r.remaining()/8 { + return ErrInvalid + } + for range n { + count := r.u32() + delta := r.u32() + if r.err || count == 0 || uint64(count) > uint64(maxSamples-timingCount) { + return ErrInvalid + } + timingCount += int(count) + timing = append(timing, timingRun{count, delta}) + } + + case "stsz": + r.fullBox() + uniform := r.u32() + n := int(r.u32()) + if r.err || n < 0 || n > maxSamples || (uniform == 0 && n > r.remaining()/4) { + return ErrInvalid + } + sizes = make([]uint32, n) + for i := range n { + if uniform != 0 { + sizes[i] = uniform + } else { + sizes[i] = r.u32() + } + } + + case "stsd": + r.fullBox() + r.u32() + + return eachBox(b[r.i:], func(typ string, b []byte) error { + if len(b) < 78 { + return nil + } + + rr := &reader{b: b} + rr.skip(24) + t.width = int(rr.u16()) + t.height = int(rr.u16()) + + return eachBox(b[78:], func(typ string, b []byte) error { + switch typ { + case "auxi": + rr := &reader{b: b} + rr.fullBox() + t.auxType = rr.cstr() + + case "hvcC": + c, err := parseHvcC(&reader{b: b}) + if err != nil { + return err + } + t.hvcC = c + } + + return nil + }) + }) + + case "stsc": + r.fullBox() + n := int(r.u32()) + if r.err || n < 0 || n > maxSamples || n > r.remaining()/12 { + return ErrInvalid + } + for range n { + first := r.u32() + per := r.u32() + r.u32() + if r.err || first == 0 || per == 0 || per > maxSamples || + (len(runs) > 0 && first <= runs[len(runs)-1].firstChunk) { + return ErrInvalid + } + runs = append(runs, chunkRun{first, per}) + } + + case "stco", "co64": + r.fullBox() + n := int(r.u32()) + entrySize := 4 + if typ == "co64" { + entrySize = 8 + } + if r.err || n < 0 || n > maxSamples || n > r.remaining()/entrySize { + return ErrInvalid + } + offsets = make([]uint64, n) + for i := range n { + if typ == "stco" { + offsets[i] = uint64(r.u32()) + } else { + offsets[i] = r.u64() + } + } + } + + if r.err { + return ErrInvalid + } + + return nil + }) + if err != nil { + return err + } + + if timingCount != len(sizes) { + return ErrInvalid + } + t.samples, err = layoutSamples(sizes, offsets, runs) + if err != nil { + return err + } + t.deltas = make([]uint32, 0, timingCount) + for _, run := range timing { + for range int(run.count) { + t.deltas = append(t.deltas, run.delta) + } + } + return nil +} + +// layoutSamples walks the chunk table to give every sample a file offset. +func layoutSamples(sizes []uint32, offsets []uint64, runs []chunkRun) ([]extent, error) { + if len(sizes) == 0 || len(offsets) == 0 || len(runs) == 0 { + if len(sizes) != 0 || len(offsets) != 0 || len(runs) != 0 { + return nil, ErrInvalid + } + return nil, nil + } + if runs[0].firstChunk != 1 || uint64(runs[len(runs)-1].firstChunk) > uint64(len(offsets)) { + return nil, ErrInvalid + } + + out := make([]extent, 0, len(sizes)) + s := 0 + runIndex := 0 + + for c := range offsets { + for runIndex+1 < len(runs) && uint32(c+1) >= runs[runIndex+1].firstChunk { + runIndex++ + } + per := runs[runIndex].perChunk + + off := offsets[c] + for range int(per) { + if s >= len(sizes) { + return nil, ErrInvalid + } + if sizes[s] == 0 || uint64(sizes[s]) > math.MaxUint64-off { + return nil, ErrInvalid + } + out = append(out, extent{off: off, len: uint64(sizes[s])}) + off += uint64(sizes[s]) + s++ + } + } + + if s != len(sizes) { + return nil, ErrInvalid + } + return out, nil +} + +func (m *movie) pictTrack() *track { + for i := range m.tracks { + if m.tracks[i].handler == "pict" && len(m.tracks[i].samples) > 0 { + return &m.tracks[i] + } + } + + return nil +} + +func (m *movie) alphaTrack(id uint32) *track { + for i := range m.tracks { + t := &m.tracks[i] + if t.handler != "auxv" || len(t.samples) == 0 || !isAlphaURN(t.auxType) { + continue + } + for _, to := range t.auxl { + if to == id { + return t + } + } + } + + return nil +} + +// decodeSequence decodes an image sequence track. +func (f *file) decodeSequence(o Options, maxFrames int) (*HEIC, error) { + t := f.movie.pictTrack() + if t == nil || t.timescale == 0 { + return nil, nil + } + + colors, err := f.decodeTrack(t, maxFrames) + if err != nil { + return nil, err + } + // Planar output aliases these pictures; RGB output owns its pixels. + if !o.ToYCbCr { + defer releasePictures(colors) + } + + var alphas []*hevc.Picture + + if at := f.movie.alphaTrack(t.id); at != nil { + if alphas, err = f.decodeTrack(at, maxFrames); err != nil { + return nil, err + } + if !o.ToYCbCr { + defer releasePictures(alphas) + } + } + + var first *hevc.Picture + if len(colors) > 0 { + first = colors[0] + } + + out := &HEIC{LoopCount: t.loopCount(), Color: f.sequenceColor(first)} + + for i, pic := range colors { + var alpha *hevc.Picture + if i < len(alphas) { + alpha = alphas[i] + } + + img, err := toImage(pic, alpha, out.Color, o.ToYCbCr) + if err != nil { + return nil, err + } + + if o.AutoRotate && f.meta != nil { + if it, err := f.primary(); err == nil { + if img, err = f.transform(it, img); err != nil { + return nil, err + } + } + } + + out.Image = append(out.Image, img) + + d := 0.0 + if i < len(t.deltas) { + d = float64(t.deltas[i]) / float64(t.timescale) + } + + out.Delay = append(out.Delay, d) + } + + if len(out.Image) == 0 { + return nil, nil + } + + return out, nil +} + +// sequenceColor takes the color description from the primary item when the +// file carries one, and otherwise from what the sequence itself declares. +func (f *file) sequenceColor(pic *hevc.Picture) ColorInfo { + var it *item + + if f.meta != nil { + it, _ = f.primary() + } + + return f.colorInfo(it, pic) +} + +func (f *file) decodeTrack(t *track, maxFrames int) ([]*hevc.Picture, error) { + if t.hvcC == nil { + return nil, ErrInvalid + } + + // A sample needs at least one byte, so a table claiming more samples than + // the file has bytes is describing data that cannot exist. + if uint64(len(t.samples)) > f.src.size { + return nil, ErrInvalid + } + + var d hevc.Decoder + d.FrameSizeLimit(f.limit()) + d.Budget(f.budget) + d.Threads(f.workers(0)) + + for _, nal := range t.hvcC.paramSets { + u, ok := hevc.ParseNAL(nal) + if !ok || u.Type.IsVCL() { + return nil, ErrInvalid + } + + if _, err := d.DecodeNAL(u); err != nil { + return nil, wrap(err) + } + } + + var out []*hevc.Picture + complete := false + defer func() { + if !complete { + releasePictures(out) + } + }() + var pixels uint64 + take := func(pics []*hevc.Picture) error { + for i, pic := range pics { + if maxFrames > 0 && len(out) >= maxFrames { + releasePictures(pics[i:]) + return errStop + } + n := uint64(pic.Width) * uint64(pic.Height) + if limit := f.limit(); limit > 0 && n > uint64(limit)-pixels { + releasePictures(pics[i:]) + return ErrUnsupported + } + pixels += n + out = append(out, pic) + } + if maxFrames > 0 && len(out) >= maxFrames { + return errStop + } + return nil + } + + for sampleIndex, s := range t.samples { + // A first-frame request must not scan a long run of non-output + // samples. The DPB itself is independently bounded by the SPS parser. + if maxFrames == 1 && sampleIndex >= 64 { + return nil, ErrUnsupported + } + if s.len == 0 || s.len > maxItemBytes { + return nil, ErrInvalid + } + + b, err := f.src.at(s.off, s.len) + if err != nil { + return nil, err + } + + err = eachNAL(b, t.hvcC.lengthSize, func(u hevc.NALUnit) error { + pics, err := d.DecodeNAL(u) + if err != nil { + releasePictures(pics) + return wrap(err) + } + return take(pics) + }) + if errors.Is(err, errStop) { + complete = true + return out, nil + } + if err != nil { + return nil, err + } + } + if err := take(d.Flush()); err != nil && !errors.Is(err, errStop) { + return nil, err + } + complete = true + return out, nil +} diff --git a/third_party/h265/heic/transform.go b/third_party/h265/heic/transform.go new file mode 100644 index 00000000..573ea721 --- /dev/null +++ b/third_party/h265/heic/transform.go @@ -0,0 +1,189 @@ +package heic + +import "image" + +type raw struct { + pix []byte + stride int + w, h int + psize int +} + +func toRaw(img image.Image) (*raw, bool) { + switch m := img.(type) { + case *image.NRGBA: + return &raw{m.Pix, m.Stride, m.Rect.Dx(), m.Rect.Dy(), 4}, true + case *image.NRGBA64: + return &raw{m.Pix, m.Stride, m.Rect.Dx(), m.Rect.Dy(), 8}, true + } + + return nil, false +} + +func (r *raw) toImage() image.Image { + rect := image.Rect(0, 0, r.w, r.h) + if r.psize == 4 { + return &image.NRGBA{Pix: r.pix, Stride: r.stride, Rect: rect} + } + + return &image.NRGBA64{Pix: r.pix, Stride: r.stride, Rect: rect} +} + +func (r *raw) alloc(w, h int) *raw { + return &raw{pix: make([]byte, w*h*r.psize), stride: w * r.psize, w: w, h: h, psize: r.psize} +} + +func (r *raw) at(x, y int) []byte { + o := y*r.stride + x*r.psize + + return r.pix[o : o+r.psize] +} + +func (r *raw) crop(x, y, w, h int) *raw { + out := r.alloc(w, h) + for j := range h { + copy(out.pix[j*out.stride:][:w*r.psize], r.at(x, y+j)[:w*r.psize]) + } + + return out +} + +// rotate turns the image anti-clockwise by angle quarter turns. +func (r *raw) rotate(angle int) *raw { + if angle == 0 { + return r + } + w, h := r.w, r.h + if angle&1 != 0 { + w, h = h, w + } + out := r.alloc(w, h) + + for j := range r.h { + for i := range r.w { + var dx, dy int + switch angle { + case 1: + dx, dy = j, r.w-1-i + case 2: + dx, dy = r.w-1-i, r.h-1-j + default: + dx, dy = r.h-1-j, i + } + copy(out.at(dx, dy), r.at(i, j)) + } + } + + return out +} + +func (r *raw) mirror(axis int) *raw { + if axis == 0 { + for y := range r.h / 2 { + a := r.pix[y*r.stride:][:r.w*r.psize] + b := r.pix[(r.h-1-y)*r.stride:][:r.w*r.psize] + for i := range a { + a[i], b[i] = b[i], a[i] + } + } + + return r + } + + for y := range r.h { + for x := range r.w / 2 { + a, b := r.at(x, y), r.at(r.w-1-x, y) + for i := range a { + a[i], b[i] = b[i], a[i] + } + } + } + + return r +} + +// cropRect derives the clean aperture rectangle, in fractions throughout. +func cropRect(clap *[8]uint32, w, h int) (int, int, int, int, bool) { + widthN, widthD := int64(int32(clap[0])), int64(int32(clap[1])) + heightN, heightD := int64(int32(clap[2])), int64(int32(clap[3])) + horizN, horizD := int64(int32(clap[4])), int64(int32(clap[5])) + vertN, vertD := int64(int32(clap[6])), int64(int32(clap[7])) + + if widthD <= 0 || heightD <= 0 || horizD <= 0 || vertD <= 0 || + widthN < 0 || heightN < 0 { + return 0, 0, 0, 0, false + } + if widthN%widthD != 0 || heightN%heightD != 0 { + return 0, 0, 0, 0, false + } + clapW, clapH := widthN/widthD, heightN/heightD + + numX := int64(w)*horizD + 2*horizN - clapW*horizD + denX := 2 * horizD + numY := int64(h)*vertD + 2*vertN - clapH*vertD + denY := 2 * vertD + if numX%denX != 0 || numY%denY != 0 { + return 0, 0, 0, 0, false + } + x, y := numX/denX, numY/denY + + if x < 0 || y < 0 || clapW <= 0 || clapH <= 0 || + x+clapW > int64(w) || y+clapH > int64(h) { + return 0, 0, 0, 0, false + } + + return int(x), int(y), int(clapW), int(clapH), true +} + +// applyTransforms runs clean aperture, then rotation, then mirroring, per MIAF 7.3.6.7. +// hasTransform reports whether AutoRotate would have to touch the pixels. +func (f *file) hasTransform(it *item) bool { + return f.meta.prop(it, "clap") != nil || f.meta.prop(it, "irot") != nil || + f.meta.prop(it, "imir") != nil +} + +func (f *file) applyTransforms(img image.Image, it *item) (image.Image, error) { + clap := f.meta.prop(it, "clap") + irot := f.meta.prop(it, "irot") + imir := f.meta.prop(it, "imir") + if clap == nil && irot == nil && imir == nil { + return img, nil + } + + r, ok := toRaw(img) + if !ok { + return nil, ErrUnsupported + } + + if clap != nil { + x, y, w, h, ok := cropRect(&clap.clap, r.w, r.h) + if !ok { + return nil, ErrInvalid + } + if x != 0 || y != 0 || w != r.w || h != r.h { + r = r.crop(x, y, w, h) + } + } + if irot != nil { + r = r.rotate(int(irot.angle)) + } + if imir != nil { + r = r.mirror(int(imir.axis)) + } + + return r.toImage(), nil +} + +// transform applies the clap, irot and imir properties. The planar images the +// ToYCbCr path returns alias the decoded planes, so they are converted first. +func (f *file) transform(it *item, img image.Image) (image.Image, error) { + if !f.hasTransform(it) { + return img, nil + } + + if aliasesPicture(img) { + return nil, ErrUnsupported + } + + return f.applyTransforms(img, it) +} diff --git a/third_party/h265/heic/ycbcr.go b/third_party/h265/heic/ycbcr.go new file mode 100644 index 00000000..218a7246 --- /dev/null +++ b/third_party/h265/heic/ycbcr.go @@ -0,0 +1,404 @@ +package heic + +import "image" + +// toYCbCr converts img to the 8-bit planes the encoder codes, at a size the +// sampling resolves: a dimension the chroma cannot divide repeats its edge, +// which ispe then hides. keepAlpha leaves the color un-composited. +func toYCbCr(img image.Image, sub image.YCbCrSubsampleRatio, keepAlpha bool) *image.YCbCr { + b := img.Bounds() + sw, sh := ratioSub(sub) + even := image.Rect(0, 0, roundUp(b.Dx(), sw), roundUp(b.Dy(), sh)) + + if ycc, ok := img.(*image.YCbCr); ok { + if ycc.SubsampleRatio == sub && ycc.Rect == even { + return ycc + } + + return ycbcrTo(ycc, even, sub) + } + + if a, ok := img.(*image.NYCbCrA); ok { + return ycbcrTo(&a.YCbCr, even, sub) + } + + return rgbTo(img, even, sub, keepAlpha) +} + +// toYCbCr420 is toYCbCr at the sampling this package has always written. +func toYCbCr420(img image.Image, keepAlpha bool) *image.YCbCr { + return toYCbCr(img, image.YCbCrSubsampleRatio420, keepAlpha) +} + +// toGray is the luma plane on its own, for a picture coded without chroma. +func toGray(img image.Image, keepAlpha bool) []byte { + b := img.Bounds() + w, h := b.Dx(), b.Dy() + out := make([]byte, w*h) + + switch src := img.(type) { + case *image.Gray: + for y := range h { + copy(out[y*w:], src.Pix[src.PixOffset(b.Min.X, b.Min.Y+y):][:w]) + } + + return out + case *image.YCbCr: + for y := range h { + copy(out[y*w:], src.Y[src.YOffset(b.Min.X, b.Min.Y+y):][:w]) + } + + return out + } + + buf := make([]byte, 4*w) + + for y := range h { + px := readRow(img, b, y, buf, keepAlpha) + + for x := range w { + p := px[4*x:] + out[y*w+x], _, _ = rgbToYCbCr(p[0], p[1], p[2]) + } + } + + return out +} + +// toDeep converts img to sixteen bit planes at a sampling and a sample size, +// the way toYCbCr does at eight. The color comes through image.Color, which +// hands back sixteen bits whatever the source holds. +func toDeep(img image.Image, sub image.YCbCrSubsampleRatio, depth int, + keepAlpha bool, +) (even image.Rectangle, y, cb, cr []uint16) { + b := img.Bounds() + w, h := b.Dx(), b.Dy() + sw, sh := ratioSub(sub) + even = image.Rect(0, 0, roundUp(w, sw), roundUp(h, sh)) + + y = make([]uint16, even.Dx()*even.Dy()) + cb = make([]uint16, even.Dx()/sw*even.Dy()/sh) + cr = make([]uint16, len(cb)) + + for cy := range even.Dy() / sh { + for cx := range even.Dx() / sw { + var sumCb, sumCr, n int32 + + for j := range sh { + for i := range sw { + px, py := min(cx*sw+i, w-1), min(cy*sh+j, h-1) + r, g, bl, a := img.At(b.Min.X+px, b.Min.Y+py).RGBA() + + if keepAlpha { + r, g, bl = unpremul16(r, a), unpremul16(g, a), unpremul16(bl, a) + } + + l, pcb, pcr := rgbToYCbCrDeep(r, g, bl, depth) + y[(cy*sh+j)*even.Dx()+cx*sw+i] = l + sumCb, sumCr, n = sumCb+int32(pcb), sumCr+int32(pcr), n+1 + } + } + + cb[cy*(even.Dx()/sw)+cx] = uint16((sumCb + n/2) / n) + cr[cy*(even.Dx()/sw)+cx] = uint16((sumCr + n/2) / n) + } + } + + return even, y, cb, cr +} + +// alphaDeep is alphaPlane at a sample size above eight. +func alphaDeep(img image.Image, even image.Rectangle, depth int) []uint16 { + if o, ok := img.(interface{ Opaque() bool }); ok && o.Opaque() { + return nil + } + + b := img.Bounds() + w, h := b.Dx(), b.Dy() + out := make([]uint16, even.Dx()*even.Dy()) + top := uint16(1<> (16 - depth)) + out[j*even.Dx()+i] = v + + if v != top { + opaque = false + } + } + } + + if opaque { + return nil + } + + return out +} + +// rgbToYCbCrDeep is the full range BT.601 matrix at a sample size, taking the +// sixteen bit components image.Color hands back. +func rgbToYCbCrDeep(r, g, b uint32, depth int) (uint16, uint16, uint16) { + sh := 16 - depth + top := int32(1)<>sh), int32(g>>sh), int32(b>>sh) + + y := (19595*r1 + 38470*g1 + 7471*b1 + 1<<15) >> 16 + cb := (-11056*r1-21712*g1+32768*b1+1<<15)>>16 + half + cr := (32768*r1-27440*g1-5328*b1+1<<15)>>16 + half + + return uint16(min(max(y, 0), top)), uint16(min(max(cb, 0), top)), + uint16(min(max(cr, 0), top)) +} + +func unpremul16(c, a uint32) uint32 { + if a == 0 { + return 0 + } + + return min(c*0xffff/a, 0xffff) +} + +// ratioSub is SubWidthC and SubHeightC for the samplings this package writes. +func ratioSub(sub image.YCbCrSubsampleRatio) (int, int) { + switch sub { + case image.YCbCrSubsampleRatio444: + return 1, 1 + case image.YCbCrSubsampleRatio422: + return 2, 1 + default: + return 2, 2 + } +} + +func roundUp(n, to int) int { + return (n + to - 1) / to * to +} + +// alphaPlane is the alpha of img at luma resolution, padded the way the luma +// is, or nil for a picture that is opaque and needs no auxiliary item. +func alphaPlane(img image.Image, even image.Rectangle) []uint8 { + if o, ok := img.(interface{ Opaque() bool }); ok && o.Opaque() { + return nil + } + + b := img.Bounds() + w, h := b.Dx(), b.Dy() + out := make([]uint8, even.Dx()*even.Dy()) + opaque := true + + for y := range even.Dy() { + sy := b.Min.Y + min(y, h-1) + row := out[y*even.Dx():][:even.Dx()] + + alphaRow(img, b.Min.X, sy, w, row) + fillEdge(row, w) + + for _, a := range row { + if a != 0xff { + opaque = false + + break + } + } + } + + if opaque { + return nil + } + + return out +} + +func alphaRow(img image.Image, x, y, w int, dst []uint8) { + switch src := img.(type) { + case *image.NRGBA: + p := src.Pix[src.PixOffset(x, y):] + + for i := range w { + dst[i] = p[4*i+3] + } + case *image.RGBA: + p := src.Pix[src.PixOffset(x, y):] + + for i := range w { + dst[i] = p[4*i+3] + } + case *image.NYCbCrA: + p := src.A[src.AOffset(x, y):] + + copy(dst[:w], p[:w]) + default: + for i := range w { + _, _, _, a := img.At(x+i, y).RGBA() + dst[i] = uint8(a >> 8) + } + } +} + +// ycbcrTo resamples any subsampling to another through COffset, which resolves +// the source ratio for us, so a matching one copies and the rest average. +func ycbcrTo(src *image.YCbCr, even image.Rectangle, sub image.YCbCrSubsampleRatio) *image.YCbCr { + out := image.NewYCbCr(even, sub) + sw, sh := ratioSub(sub) + b := src.Rect + w, h := b.Dx(), b.Dy() + + for y := range even.Dy() { + row := out.Y[y*out.YStride:][:even.Dx()] + copy(row, src.Y[src.YOffset(b.Min.X, b.Min.Y+min(y, h-1)):][:w]) + fillEdge(row, w) + } + + for cy := range even.Dy() / sh { + for cx := range even.Dx() / sw { + var cb, cr, n int32 + + for j := range sh { + y := b.Min.Y + min(cy*sh+j, h-1) + + for i := range sw { + o := src.COffset(b.Min.X+min(cx*sw+i, w-1), y) + cb, cr, n = cb+int32(src.Cb[o]), cr+int32(src.Cr[o]), n+1 + } + } + + out.Cb[cy*out.CStride+cx] = byte((cb + n/2) / n) + out.Cr[cy*out.CStride+cx] = byte((cr + n/2) / n) + } + } + + return out +} + +func rgbTo(img image.Image, even image.Rectangle, sub image.YCbCrSubsampleRatio, + keepAlpha bool, +) *image.YCbCr { + out := image.NewYCbCr(even, sub) + sw, sh := ratioSub(sub) + b := img.Bounds() + w, h := b.Dx(), b.Dy() + + rows := make([][]byte, sh) + px := make([][]byte, sh) + + for j := range rows { + rows[j] = make([]byte, 4*w) + } + + for y := 0; y < even.Dy(); y += sh { + for j := range sh { + px[j] = readRow(img, b, min(y+j, h-1), rows[j], keepAlpha) + } + + for x := 0; x < even.Dx(); x += sw { + var cb, cr, n int32 + + for j := range sh { + row := out.Y[(y+j)*out.YStride:] + + for i := range sw { + p := px[j][4*min(x+i, w-1):] + l, pcb, pcr := rgbToYCbCr(p[0], p[1], p[2]) + row[x+i] = l + cb, cr, n = cb+int32(pcb), cr+int32(pcr), n+1 + } + } + + out.Cb[y/sh*out.CStride+x/sw] = byte((cb + n/2) / n) + out.Cr[y/sh*out.CStride+x/sw] = byte((cr + n/2) / n) + } + } + + return out +} + +func fillEdge(row []byte, w int) { + for i := w; i < len(row); i++ { + row[i] = row[w-1] + } +} + +// readRow returns row y of img as RGB samples with a four byte pixel stride, +// composited over black unless keepAlpha asks for the color an alpha item +// will be read against, which a non-premultiplied source already holds. +func readRow(img image.Image, b image.Rectangle, y int, dst []byte, keepAlpha bool) []byte { + w := b.Dx() + + switch src := img.(type) { + case *image.RGBA: + p := src.Pix[src.PixOffset(b.Min.X, b.Min.Y+y):] + + if !keepAlpha { + return p + } + + for x, o := 0, 0; x < w; x, o = x+1, o+4 { + a := uint32(p[o+3]) + + for c := range 3 { + dst[o+c] = unpremul(uint32(p[o+c]), a) + } + } + case *image.NRGBA: + p := src.Pix[src.PixOffset(b.Min.X, b.Min.Y+y):] + + if keepAlpha { + return p + } + + for x, o := 0, 0; x < w; x, o = x+1, o+4 { + a := uint32(p[o+3]) + dst[o] = byte(uint32(p[o]) * a / 255) + dst[o+1] = byte(uint32(p[o+1]) * a / 255) + dst[o+2] = byte(uint32(p[o+2]) * a / 255) + } + case *image.Gray: + p := src.Pix[src.PixOffset(b.Min.X, b.Min.Y+y):] + + for x, o := 0, 0; x < w; x, o = x+1, o+4 { + v := p[x] + dst[o], dst[o+1], dst[o+2] = v, v, v + } + default: + for x, o := 0, 0; x < w; x, o = x+1, o+4 { + r, g, bl, a := img.At(b.Min.X+x, b.Min.Y+y).RGBA() + + if keepAlpha { + dst[o], dst[o+1], dst[o+2] = unpremul(r, a), unpremul(g, a), unpremul(bl, a) + + continue + } + + dst[o], dst[o+1], dst[o+2] = byte(r>>8), byte(g>>8), byte(bl>>8) + } + } + + return dst +} + +// unpremul takes a premultiplied component back to the color an alpha channel +// is read against. Where nothing is left of the alpha, neither is the color. +func unpremul(c, a uint32) byte { + if a == 0 { + return 0 + } + + return byte(min(c*0xffff/a, 0xffff) >> 8) +} + +// rgbToYCbCr is the full range BT.601 matrix, which is what image.YCbCr holds +// and what the nclx description this package writes declares. +func rgbToYCbCr(r, g, b byte) (byte, byte, byte) { + r1, g1, b1 := int32(r), int32(g), int32(b) + + y := (19595*r1 + 38470*g1 + 7471*b1 + 1<<15) >> 16 + cb := (-11056*r1 - 21712*g1 + 32768*b1 + 257<<15) >> 16 + cr := (32768*r1 - 27440*g1 - 5328*b1 + 257<<15) >> 16 + + return byte(y), byte(min(max(cb, 0), 255)), byte(min(max(cr, 0), 255)) +} diff --git a/third_party/h265/hevc/budget.go b/third_party/h265/hevc/budget.go new file mode 100644 index 00000000..d5eda011 --- /dev/null +++ b/third_party/h265/hevc/budget.go @@ -0,0 +1,36 @@ +package hevc + +import "sync/atomic" + +// DecodeBudget bounds the total coded pixel area processed by decoders sharing +// it, including pictures that are cropped, discarded or never output. A budget +// may be shared across concurrent grid/alpha decoders. A nil budget is unlimited. +type DecodeBudget struct { + remaining atomic.Int64 +} + +// NewDecodeBudget creates a budget of n coded pixels. Negative n permits none. +func NewDecodeBudget(n int64) *DecodeBudget { + b := &DecodeBudget{} + b.remaining.Store(max(n, 0)) + return b +} + +func (b *DecodeBudget) take(n int64) bool { + if b == nil { + return true + } + for { + left := b.remaining.Load() + if n > left || n <= 0 { + return false + } + if b.remaining.CompareAndSwap(left, left-n) { + return true + } + } +} + +// Budget attaches an aggregate work budget without resetting its consumption. +// Configure the decoder before calling DecodeNAL. +func (d *Decoder) Budget(b *DecodeBudget) { d.budget = b } diff --git a/third_party/h265/hevc/cabac.go b/third_party/h265/hevc/cabac.go new file mode 100644 index 00000000..26b5116c --- /dev/null +++ b/third_party/h265/hevc/cabac.go @@ -0,0 +1,237 @@ +package hevc + +import ( + "math" + "math/bits" +) + +const nContexts = 179 + +const ( + cabacBits = 16 + cabacMask = 1<= 62 { + mps = st + } + + lps := int(transIdxLPS[st]) + + for v := range 2 { + s := 2*st + v + + transState[128+s] = uint8(2*mps + v) + + flipped := v + if st == 0 { + flipped = 1 - v + } + + transState[127-s] = uint8(2*lps + flipped) + } + } +} + +func normShift(r uint32) uint { + return uint(9 - bits.Len32(r)) +} + +type cabac struct { + low uint32 + rng uint32 + data []byte + pos int + state [nContexts]uint8 +} + +func (c *cabac) init(data []byte, off int) error { + if off < 0 || off+2 > len(data) { + return ErrInvalid + } + + c.data = data + c.low = uint32(data[off])<<18 + uint32(data[off+1])<<10 + 1<<9 + c.rng = 0x1fe + c.pos = off + 2 + + return nil +} + +func (c *cabac) initContexts(qp int32, t sliceType, cabacInit bool) { + row := &initValues[initType(t, cabacInit)] + + for i := range c.state { + c.state[i] = initState(row[i], qp) + } +} + +func initType(t sliceType, cabacInit bool) int { + n := 2 - int(t) + if cabacInit && t != sliceI { + n ^= 3 + } + + return n +} + +func initState(v uint8, qp int32) uint8 { + m := int32(v>>4)*5 - 45 + n := int32(v&0x0f)<<3 - 16 + + if qp < 0 { + qp = 0 + } else if qp > 51 { + qp = 51 + } + + s := 2*((m*qp>>4)+n) - 127 + s ^= s >> 31 + + if s > 124 { + s = 124 + s&1 + } + + return uint8(s) +} + +func (c *cabac) byteAt(i int) uint32 { + if i < len(c.data) { + return uint32(c.data[i]) + } + + return 0 +} + +func (c *cabac) refill() { + c.low += c.byteAt(c.pos)<<9 + c.byteAt(c.pos+1)<<1 - cabacMask + c.pos += 2 +} + +func (c *cabac) refillShifted() { + i := uint(bits.TrailingZeros32(c.low) - cabacBits) + x := c.byteAt(c.pos)<<9 + c.byteAt(c.pos+1)<<1 - cabacMask + c.low += x << i + c.pos += 2 +} + +func (c *cabac) decodeBin(ctx int) uint32 { + s := c.state[ctx] + lps := uint32(lpsRange[c.rng>>6&3][s]) + + c.rng -= lps + + mask := uint32(int32(c.rng<<(cabacBits+1)-c.low) >> 31) + + c.low -= c.rng << (cabacBits + 1) & mask + c.rng += (lps - c.rng) & mask + + signed := int32(s) ^ int32(mask) + c.state[ctx] = transState[128+signed] + + shift := normShift(c.rng) + c.rng <<= shift + c.low <<= shift + + if c.low&cabacMask == 0 { + c.refillShifted() + } + + return uint32(signed & 1) +} + +func (c *cabac) decodeBypass() uint32 { + c.low += c.low + if c.low&cabacMask == 0 { + c.refill() + } + + r := c.rng << (cabacBits + 1) + if c.low < r { + return 0 + } + + c.low -= r + + return 1 +} + +func (c *cabac) decodeBypassBits(n int) uint32 { + var v uint32 + + for range n { + v = v<<1 | c.decodeBypass() + } + + return v +} + +func (c *cabac) decodeTerminate() uint32 { + c.rng -= 2 + + if c.low >= c.rng<<(cabacBits+1) { + return 1 + } + + if c.rng < 256 { + c.rng <<= 1 + c.low <<= 1 + + if c.low&cabacMask == 0 { + c.refill() + } + } + + return 0 +} + +func (c *cabac) pcmOffset() int { + n := c.pos + + if c.low&0x1 != 0 { + n-- + } + + if c.low&0x1ff != 0 { + n-- + } + + return n +} diff --git a/third_party/h265/hevc/cabac_tables.go b/third_party/h265/hevc/cabac_tables.go new file mode 100644 index 00000000..30ef327f --- /dev/null +++ b/third_party/h265/hevc/cabac_tables.go @@ -0,0 +1,294 @@ +package hevc + +// Table 9-46. +var rangeTabLPS = [64][4]uint8{ + {128, 176, 208, 240}, + {128, 167, 197, 227}, + {128, 158, 187, 216}, + {123, 150, 178, 205}, + {116, 142, 169, 195}, + {111, 135, 160, 185}, + {105, 128, 152, 175}, + {100, 122, 144, 166}, + {95, 116, 137, 158}, + {90, 110, 130, 150}, + {85, 104, 123, 142}, + {81, 99, 117, 135}, + {77, 94, 111, 128}, + {73, 89, 105, 122}, + {69, 85, 100, 116}, + {66, 80, 95, 110}, + {62, 76, 90, 104}, + {59, 72, 86, 99}, + {56, 69, 81, 94}, + {53, 65, 77, 89}, + {51, 62, 73, 85}, + {48, 59, 69, 80}, + {46, 56, 66, 76}, + {43, 53, 63, 72}, + {41, 50, 59, 69}, + {39, 48, 56, 65}, + {37, 45, 54, 62}, + {35, 43, 51, 59}, + {33, 41, 48, 56}, + {32, 39, 46, 53}, + {30, 37, 43, 50}, + {29, 35, 41, 48}, + {27, 33, 39, 45}, + {26, 31, 37, 43}, + {24, 30, 35, 41}, + {23, 28, 33, 39}, + {22, 27, 32, 37}, + {21, 26, 30, 35}, + {20, 24, 29, 33}, + {19, 23, 27, 31}, + {18, 22, 26, 30}, + {17, 21, 25, 28}, + {16, 20, 23, 27}, + {15, 19, 22, 25}, + {14, 18, 21, 24}, + {14, 17, 20, 23}, + {13, 16, 19, 22}, + {12, 15, 18, 21}, + {12, 14, 17, 20}, + {11, 14, 16, 19}, + {11, 13, 15, 18}, + {10, 12, 15, 17}, + {10, 12, 14, 16}, + {9, 11, 13, 15}, + {9, 11, 12, 14}, + {8, 10, 12, 14}, + {8, 9, 11, 13}, + {7, 9, 11, 12}, + {7, 9, 10, 12}, + {7, 8, 10, 11}, + {6, 8, 9, 11}, + {6, 7, 9, 10}, + {6, 7, 8, 9}, + {2, 2, 2, 2}, +} + +// Table 9-47. +var transIdxLPS = [64]uint8{ + 0, 0, 1, 2, 2, 4, 4, 5, 6, 7, 8, 9, 9, 11, 11, 12, + 13, 13, 15, 15, 16, 16, 18, 18, 19, 19, 21, 21, 22, 22, 23, 24, + 24, 25, 26, 26, 27, 27, 28, 29, 29, 30, 30, 30, 31, 32, 32, 33, + 33, 33, 34, 34, 35, 35, 35, 36, 36, 36, 37, 37, 37, 38, 38, 63, +} + +// Table 9-4. +var initValues = [3][nContexts]uint8{ + { + 153, 200, 139, 141, 157, 154, 154, 154, 154, 154, 154, 154, 154, 184, 154, 154, + 154, 184, 63, 139, 154, 154, 154, 154, 154, 154, 154, 154, 154, 154, 154, 154, + 154, 154, 154, 154, 154, 153, 138, 138, 111, 141, 94, 138, 182, 154, 154, 139, + 139, 139, 139, 139, 139, 110, 110, 124, 125, 140, 153, 125, 127, 140, 109, 111, + 143, 127, 111, 79, 108, 123, 63, 110, 110, 124, 125, 140, 153, 125, 127, 140, + 109, 111, 143, 127, 111, 79, 108, 123, 63, 91, 171, 134, 141, 111, 111, 125, + 110, 110, 94, 124, 108, 124, 107, 125, 141, 179, 153, 125, 107, 125, 141, 179, + 153, 125, 107, 125, 141, 179, 153, 125, 140, 139, 182, 182, 152, 136, 152, 136, + 153, 136, 139, 111, 136, 139, 111, 141, 111, 140, 92, 137, 138, 140, 152, 138, + 139, 153, 74, 149, 92, 139, 107, 122, 152, 140, 179, 166, 182, 140, 227, 122, + 197, 138, 153, 136, 167, 152, 152, 154, 154, 154, 154, 154, 154, 154, 154, 154, + 154, 154, 154, + }, + { + 153, 185, 107, 139, 126, 154, 197, 185, 201, 154, 154, 154, 149, 154, 139, 154, + 154, 154, 152, 139, 110, 122, 95, 79, 63, 31, 31, 153, 153, 153, 153, 140, + 198, 140, 198, 168, 79, 124, 138, 94, 153, 111, 149, 107, 167, 154, 154, 139, + 139, 139, 139, 139, 139, 125, 110, 94, 110, 95, 79, 125, 111, 110, 78, 110, + 111, 111, 95, 94, 108, 123, 108, 125, 110, 94, 110, 95, 79, 125, 111, 110, + 78, 110, 111, 111, 95, 94, 108, 123, 108, 121, 140, 61, 154, 155, 154, 139, + 153, 139, 123, 123, 63, 153, 166, 183, 140, 136, 153, 154, 166, 183, 140, 136, + 153, 154, 166, 183, 140, 136, 153, 154, 170, 153, 123, 123, 107, 121, 107, 121, + 167, 151, 183, 140, 151, 183, 140, 140, 140, 154, 196, 196, 167, 154, 152, 167, + 182, 182, 134, 149, 136, 153, 121, 136, 137, 169, 194, 166, 167, 154, 167, 137, + 182, 107, 167, 91, 122, 107, 167, 154, 154, 154, 154, 154, 154, 154, 154, 154, + 154, 154, 154, + }, + { + 153, 160, 107, 139, 126, 154, 197, 185, 201, 154, 154, 154, 134, 154, 139, 154, + 154, 183, 152, 139, 154, 137, 95, 79, 63, 31, 31, 153, 153, 153, 153, 169, + 198, 169, 198, 168, 79, 224, 167, 122, 153, 111, 149, 92, 167, 154, 154, 139, + 139, 139, 139, 139, 139, 125, 110, 124, 110, 95, 94, 125, 111, 111, 79, 125, + 126, 111, 111, 79, 108, 123, 93, 125, 110, 124, 110, 95, 94, 125, 111, 111, + 79, 125, 126, 111, 111, 79, 108, 123, 93, 121, 140, 61, 154, 170, 154, 139, + 153, 139, 123, 123, 63, 124, 166, 183, 140, 136, 153, 154, 166, 183, 140, 136, + 153, 154, 166, 183, 140, 136, 153, 154, 170, 153, 138, 138, 122, 121, 122, 121, + 167, 151, 183, 140, 151, 183, 140, 140, 140, 154, 196, 167, 167, 154, 152, 167, + 182, 182, 134, 149, 136, 153, 121, 136, 122, 169, 208, 166, 167, 154, 152, 167, + 182, 107, 167, 91, 107, 107, 167, 154, 154, 154, 154, 154, 154, 154, 154, 154, + 154, 154, 154, + }, +} + +// Offsets of each syntax element's first context, 9.3.2.2. +const ( + ctxSAOMergeFlag = 0 + ctxSAOTypeIDX = 1 + ctxSplitCodingUnitFlag = 2 + ctxCUTransquantBypassFlag = 5 + ctxSkipFlag = 6 + ctxCUQPDelta = 9 + ctxPredModeFlag = 12 + ctxPartMode = 13 + ctxPrevIntraLumaPredFlag = 17 + ctxIntraChromaPredMode = 18 + ctxMergeFlag = 20 + ctxMergeIDX = 21 + ctxInterPredIDC = 22 + ctxRefIDXL0 = 27 + ctxRefIDXL1 = 29 + ctxAbsMVDGreater0Flag = 31 + ctxAbsMVDGreater1Flag = 33 + ctxMVPLXFlag = 35 + ctxNoResidualDataFlag = 36 + ctxSplitTransformFlag = 37 + ctxCBFLuma = 40 + ctxCBFCBCR = 42 + ctxTransformSkipFlag = 47 + ctxExplicitRDPCMFlag = 49 + ctxExplicitRDPCMDirFlag = 51 + ctxLastSignificantCoeffXPrefix = 53 + ctxLastSignificantCoeffYPrefix = 71 + ctxSignificantCoeffGroupFlag = 89 + ctxSignificantCoeffFlag = 93 + ctxCoeffAbsLevelGreater1Flag = 137 + ctxCoeffAbsLevelGreater2Flag = 161 + ctxLog2ResScaleAbs = 167 + ctxResScaleSignFlag = 175 + ctxCUChromaQPOffsetFlag = 177 + ctxCUChromaQPOffsetIDX = 178 +) + +// The 32-point inverse transform matrix. +var transMatrix = [32][32]int8{ + { + 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, + 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, + }, + { + 90, 90, 88, 85, 82, 78, 73, 67, 61, 54, 46, 38, 31, 22, 13, 4, + -4, -13, -22, -31, -38, -46, -54, -61, -67, -73, -78, -82, -85, -88, -90, -90, + }, + { + 90, 87, 80, 70, 57, 43, 25, 9, -9, -25, -43, -57, -70, -80, -87, -90, + -90, -87, -80, -70, -57, -43, -25, -9, 9, 25, 43, 57, 70, 80, 87, 90, + }, + { + 90, 82, 67, 46, 22, -4, -31, -54, -73, -85, -90, -88, -78, -61, -38, -13, + 13, 38, 61, 78, 88, 90, 85, 73, 54, 31, 4, -22, -46, -67, -82, -90, + }, + { + 89, 75, 50, 18, -18, -50, -75, -89, -89, -75, -50, -18, 18, 50, 75, 89, + 89, 75, 50, 18, -18, -50, -75, -89, -89, -75, -50, -18, 18, 50, 75, 89, + }, + { + 88, 67, 31, -13, -54, -82, -90, -78, -46, -4, 38, 73, 90, 85, 61, 22, + -22, -61, -85, -90, -73, -38, 4, 46, 78, 90, 82, 54, 13, -31, -67, -88, + }, + { + 87, 57, 9, -43, -80, -90, -70, -25, 25, 70, 90, 80, 43, -9, -57, -87, + -87, -57, -9, 43, 80, 90, 70, 25, -25, -70, -90, -80, -43, 9, 57, 87, + }, + { + 85, 46, -13, -67, -90, -73, -22, 38, 82, 88, 54, -4, -61, -90, -78, -31, + 31, 78, 90, 61, 4, -54, -88, -82, -38, 22, 73, 90, 67, 13, -46, -85, + }, + { + 83, 36, -36, -83, -83, -36, 36, 83, 83, 36, -36, -83, -83, -36, 36, 83, + 83, 36, -36, -83, -83, -36, 36, 83, 83, 36, -36, -83, -83, -36, 36, 83, + }, + { + 82, 22, -54, -90, -61, 13, 78, 85, 31, -46, -90, -67, 4, 73, 88, 38, + -38, -88, -73, -4, 67, 90, 46, -31, -85, -78, -13, 61, 90, 54, -22, -82, + }, + { + 80, 9, -70, -87, -25, 57, 90, 43, -43, -90, -57, 25, 87, 70, -9, -80, + -80, -9, 70, 87, 25, -57, -90, -43, 43, 90, 57, -25, -87, -70, 9, 80, + }, + { + 78, -4, -82, -73, 13, 85, 67, -22, -88, -61, 31, 90, 54, -38, -90, -46, + 46, 90, 38, -54, -90, -31, 61, 88, 22, -67, -85, -13, 73, 82, 4, -78, + }, + { + 75, -18, -89, -50, 50, 89, 18, -75, -75, 18, 89, 50, -50, -89, -18, 75, + 75, -18, -89, -50, 50, 89, 18, -75, -75, 18, 89, 50, -50, -89, -18, 75, + }, + { + 73, -31, -90, -22, 78, 67, -38, -90, -13, 82, 61, -46, -88, -4, 85, 54, + -54, -85, 4, 88, 46, -61, -82, 13, 90, 38, -67, -78, 22, 90, 31, -73, + }, + { + 70, -43, -87, 9, 90, 25, -80, -57, 57, 80, -25, -90, -9, 87, 43, -70, + -70, 43, 87, -9, -90, -25, 80, 57, -57, -80, 25, 90, 9, -87, -43, 70, + }, + { + 67, -54, -78, 38, 85, -22, -90, 4, 90, 13, -88, -31, 82, 46, -73, -61, + 61, 73, -46, -82, 31, 88, -13, -90, -4, 90, 22, -85, -38, 78, 54, -67, + }, + { + 64, -64, -64, 64, 64, -64, -64, 64, 64, -64, -64, 64, 64, -64, -64, 64, + 64, -64, -64, 64, 64, -64, -64, 64, 64, -64, -64, 64, 64, -64, -64, 64, + }, + { + 61, -73, -46, 82, 31, -88, -13, 90, -4, -90, 22, 85, -38, -78, 54, 67, + -67, -54, 78, 38, -85, -22, 90, 4, -90, 13, 88, -31, -82, 46, 73, -61, + }, + { + 57, -80, -25, 90, -9, -87, 43, 70, -70, -43, 87, 9, -90, 25, 80, -57, + -57, 80, 25, -90, 9, 87, -43, -70, 70, 43, -87, -9, 90, -25, -80, 57, + }, + { + 54, -85, -4, 88, -46, -61, 82, 13, -90, 38, 67, -78, -22, 90, -31, -73, + 73, 31, -90, 22, 78, -67, -38, 90, -13, -82, 61, 46, -88, 4, 85, -54, + }, + { + 50, -89, 18, 75, -75, -18, 89, -50, -50, 89, -18, -75, 75, 18, -89, 50, + 50, -89, 18, 75, -75, -18, 89, -50, -50, 89, -18, -75, 75, 18, -89, 50, + }, + { + 46, -90, 38, 54, -90, 31, 61, -88, 22, 67, -85, 13, 73, -82, 4, 78, + -78, -4, 82, -73, -13, 85, -67, -22, 88, -61, -31, 90, -54, -38, 90, -46, + }, + { + 43, -90, 57, 25, -87, 70, 9, -80, 80, -9, -70, 87, -25, -57, 90, -43, + -43, 90, -57, -25, 87, -70, -9, 80, -80, 9, 70, -87, 25, 57, -90, 43, + }, + { + 38, -88, 73, -4, -67, 90, -46, -31, 85, -78, 13, 61, -90, 54, 22, -82, + 82, -22, -54, 90, -61, -13, 78, -85, 31, 46, -90, 67, 4, -73, 88, -38, + }, + { + 36, -83, 83, -36, -36, 83, -83, 36, 36, -83, 83, -36, -36, 83, -83, 36, + 36, -83, 83, -36, -36, 83, -83, 36, 36, -83, 83, -36, -36, 83, -83, 36, + }, + { + 31, -78, 90, -61, 4, 54, -88, 82, -38, -22, 73, -90, 67, -13, -46, 85, + -85, 46, 13, -67, 90, -73, 22, 38, -82, 88, -54, -4, 61, -90, 78, -31, + }, + { + 25, -70, 90, -80, 43, 9, -57, 87, -87, 57, -9, -43, 80, -90, 70, -25, + -25, 70, -90, 80, -43, -9, 57, -87, 87, -57, 9, 43, -80, 90, -70, 25, + }, + { + 22, -61, 85, -90, 73, -38, -4, 46, -78, 90, -82, 54, -13, -31, 67, -88, + 88, -67, 31, 13, -54, 82, -90, 78, -46, 4, 38, -73, 90, -85, 61, -22, + }, + { + 18, -50, 75, -89, 89, -75, 50, -18, -18, 50, -75, 89, -89, 75, -50, 18, + 18, -50, 75, -89, 89, -75, 50, -18, -18, 50, -75, 89, -89, 75, -50, 18, + }, + { + 13, -38, 61, -78, 88, -90, 85, -73, 54, -31, 4, 22, -46, 67, -82, 90, + -90, 82, -67, 46, -22, -4, 31, -54, 73, -85, 90, -88, 78, -61, 38, -13, + }, + { + 9, -25, 43, -57, 70, -80, 87, -90, 90, -87, 80, -70, 57, -43, 25, -9, + -9, 25, -43, 57, -70, 80, -87, 90, -90, 87, -80, 70, -57, 43, -25, 9, + }, + { + 4, -13, 22, -31, 38, -46, 54, -61, 67, -73, 78, -82, 85, -88, 90, -90, + 90, -90, 88, -85, 82, -78, 73, -67, 61, -54, 46, -38, 31, -22, 13, -4, + }, +} diff --git a/third_party/h265/hevc/cabacenc.go b/third_party/h265/hevc/cabacenc.go new file mode 100644 index 00000000..170de908 --- /dev/null +++ b/third_party/h265/hevc/cabacenc.go @@ -0,0 +1,165 @@ +package hevc + +// cabacWriter is the arithmetic encoder of 9.3.4. It always counts what it +// writes in rate, and one with no bits to write to only counts. +type cabacWriter struct { + low uint32 + rng uint32 + outstanding int + firstBit bool + bits *putBits + rate int64 + state [nContexts]uint8 +} + +func (w *cabacWriter) init(bits *putBits, qp int32, t sliceType, cabacInit bool) { + w.bits = bits + w.reinit() + + row := &initValues[initType(t, cabacInit)] + for i := range w.state { + w.state[i] = initState(row[i], qp) + } +} + +// counter is a copy of w that writes nothing. +func (w *cabacWriter) counter() cabacWriter { + c := *w + c.bits = nil + c.rate = 0 + + return c +} + +func (w *cabacWriter) reinit() { + w.low = 0 + w.rng = 510 + w.outstanding = 0 + w.firstBit = true +} + +func (w *cabacWriter) putBit(v uint32) { + if w.firstBit { + w.firstBit = false + } else { + w.bits.bit(v) + } + + for w.outstanding > 0 { + w.bits.bit(1 - v) + w.outstanding-- + } +} + +func (w *cabacWriter) renorm() { + for w.rng < 256 { + switch { + case w.low < 256: + w.putBit(0) + case w.low >= 512: + w.low -= 512 + w.putBit(1) + default: + w.low -= 256 + w.outstanding++ + } + + w.rng <<= 1 + w.low <<= 1 + } +} + +func (w *cabacWriter) encodeBin(ctx int, bin uint32) { + s := w.state[ctx] + mps := bin == uint32(s&1) + + signed := int32(s) + if !mps { + signed = -signed - 1 + } + + w.state[ctx] = transState[128+signed] + w.rate += int64(entropyBits[s^uint8(bin&1)]) + + if w.bits == nil { + return + } + + lps := uint32(lpsRange[w.rng>>6&3][s]) + w.rng -= lps + + if !mps { + w.low += w.rng + w.rng = lps + } + + w.renorm() +} + +func (w *cabacWriter) encodeBypass(bin uint32) { + w.rate += 1 << rateShift + + if w.bits == nil { + return + } + + w.low <<= 1 + if bin != 0 { + w.low += w.rng + } + + switch { + case w.low >= 1024: + w.low -= 1024 + w.putBit(1) + case w.low < 512: + w.putBit(0) + default: + w.low -= 512 + w.outstanding++ + } +} + +func (w *cabacWriter) encodeBypassBits(v uint32, n int) { + if w.bits == nil { + w.rate += int64(n) << rateShift + + return + } + + for i := n - 1; i >= 0; i-- { + w.encodeBypass(v >> uint(i) & 1) + } +} + +// encodeTerminate codes a bin against the fixed range of two, which is the +// probability state 63 holds. +func (w *cabacWriter) encodeTerminate(bin uint32) { + w.rate += int64(entropyBits[126+(bin&1)]) + + if w.bits == nil { + return + } + + w.rng -= 2 + if bin == 0 { + w.renorm() + + return + } + + w.low += w.rng + w.rng = 2 + w.renorm() + w.putBit(w.low >> 9 & 1) + w.bits.bit(w.low >> 8 & 1) + w.bits.bit(1) +} + +func (w *cabacWriter) bytes() []byte { + for w.bits.nbits != 0 { + w.bits.bit(0) + } + + return w.bits.bytes() +} diff --git a/third_party/h265/hevc/cpu_amd64.s b/third_party/h265/hevc/cpu_amd64.s new file mode 100644 index 00000000..49109e7d --- /dev/null +++ b/third_party/h265/hevc/cpu_amd64.s @@ -0,0 +1,77 @@ +//go:build amd64 && !noasm + +#include "textflag.h" + +// cpuidAVX2 reports whether the CPU has AVX2 and the OS saves the YMM state. +TEXT ·cpuidAVX2(SB), NOSPLIT, $0-1 + MOVL $0, AX + CPUID + CMPL AX, $7 + JL no + + MOVL $1, AX + MOVL $0, CX + CPUID + BTL $27, CX // OSXSAVE + JNC no + + MOVL $0, CX + XGETBV + ANDL $6, AX // XMM and YMM state + CMPL AX, $6 + JNE no + + MOVL $7, AX + MOVL $0, CX + CPUID + BTL $5, BX // AVX2 + JNC no + + MOVB $1, ret+0(FP) + RET + +no: + MOVB $0, ret+0(FP) + RET + +// cpuidAVX512ICL reports whether the CPU has the Ice Lake AVX-512 feature set +// and the OS saves the ZMM state. +TEXT ·cpuidAVX512ICL(SB), NOSPLIT, $0-1 + MOVL $0, AX + CPUID + CMPL AX, $7 + JL noicl + + MOVL $1, AX + MOVL $0, CX + CPUID + ANDL $0x18000000, CX // OSXSAVE and AVX + CMPL CX, $0x18000000 + JNE noicl + + MOVL $0, CX + XGETBV + MOVL AX, DX + ANDL $6, AX // XMM and YMM state + CMPL AX, $6 + JNE noicl + ANDL $0xe0, DX // opmask, ZMM_Hi256 and Hi16_ZMM + CMPL DX, $0xe0 + JNE noicl + + MOVL $7, AX + MOVL $0, CX + CPUID + ANDL $0xd0230000, BX // F, DQ, IFMA, CD, BW, VL + CMPL BX, $0xd0230000 + JNE noicl + ANDL $0x00005f42, CX // VBMI, VBMI2, GFNI, VAES, VPCLMULQDQ, VNNI, BITALG, VPOPCNTDQ + CMPL CX, $0x00005f42 + JNE noicl + + MOVB $1, ret+0(FP) + RET + +noicl: + MOVB $0, ret+0(FP) + RET diff --git a/third_party/h265/hevc/ctu.go b/third_party/h265/hevc/ctu.go new file mode 100644 index 00000000..81715dc5 --- /dev/null +++ b/third_party/h265/hevc/ctu.go @@ -0,0 +1,1334 @@ +package hevc + +import "slices" + +const ( + partMode2Nx2N = iota + partMode2NxN + partModeNx2N + partModeNxN + partMode2NxnU + partMode2NxnD + partModenLx2N + partModenRx2N +) + +// Table 8-10 for ChromaArrayType 1, indexed by qPi - 30. +var chromaQPTable = [14]int8{29, 30, 31, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37} + +func chromaQP(qPi int32, chromaArrayType uint32) int32 { + if chromaArrayType != 1 { + return min(qPi, 51) + } + + switch { + case qPi < 30: + return qPi + case qPi > 43: + return qPi - 6 + default: + return int32(chromaQPTable[qPi-30]) + } +} + +type ctuDecoder struct { + c cabac + s *sps + p *pps + sh *sliceHeader + pic *Picture + + minTbLog2 int + minTbWidth int + minTbAddr []int32 + + intraMode []uint8 + intraModeC []uint8 + cuDepth []uint8 + qpY []int8 + skipped []bool + noFilter []bool + bypass bool + + qpYPrev int32 + qpYCur int32 + qpYPred int32 + qgX, qgY int + qpCoded bool + qpDelta int32 + + // 8.6.1's CuQpOffsetCb and CuQpOffsetCr, coded once per chroma + // quantisation group when the picture carries an offset list. + cuOffCoded bool + cuOffCb int32 + cuOffCr int32 + statCoef [4]uint8 + scaling [maxScalingListSizes][maxScalingListMats][]uint8 + + // scalingFrom is the list d.scaling was derived from. Parsing a parameter + // set yields a new one, so the pointer changing is the only invalidation. + scalingFrom *scalingList + saoType [3]int + eoClass [2]int + sao [][3]saoParams + + blk []blockInfo + mvField []mvInfo + mvPoc [][2]int32 + mvLong [][2]bool + mvValid []bool + mvWidth int + curX int + curY int + cuX int + cuY int + cuSize int + puW int + puH int + partIdx int + curIntra bool + lastMerge bool + + refPOC [2][]int32 + refLong [2][]bool + refPics [2][]*Picture + poc int32 + colPic *Picture + noBackwardPred bool + + rsToTs []int32 + tsToRs []int32 + tileID []int32 + scanFor scanGeometry + built bool + + saved [nContexts]uint8 + hasSaved bool + + depSaved [nContexts]uint8 + hasDepSaved bool + + threads int + sliceAddrRs int + simpleAvail bool + ctbSliceAddr []int32 + sliceLF []bool + ctbSlice []int32 + slices []*sliceHeader + depSliceAddrRs int + + coef [32 * 32]int32 + scratch transformScratch + + // Scratch for one prediction unit, the largest being 64x64 luma. Motion + // compensation runs for every unit, so none of this may be allocated. + saoSrc8 []uint8 + saoSrc16 []uint16 + + mcBuf [2][64 * 64]int16 + mcTmp [(64 + 7) * 64]int32 + mcTmp16 [(64 + 7) * 64]int16 + mcPad8 [(64 + 7) * (64 + 7)]uint8 + mcPad16 [(64 + 7) * (64 + 7)]uint16 + mergeCd [8]mvInfo + ref refSamples + avail [4*32 + 1]bool +} + +// scanGeometry is everything the tile scan and the z-scan address table are +// built from. Every picture of a sequence shares it, and the tables cost more +// than the rest of the per-picture setup put together. +type scanGeometry struct { + widthInCtbs uint32 + heightInCtbs uint32 + ctbLog2SizeY uint8 + minTbLog2 uint8 + cols []uint32 + rows []uint32 +} + +func geometryOf(s *sps, p *pps) scanGeometry { + return scanGeometry{ + widthInCtbs: s.picWidthInCtbs, + heightInCtbs: s.picHeightInCtbs, + ctbLog2SizeY: s.ctbLog2SizeY, + minTbLog2: s.minTbLog2SizeY, + cols: p.colWidthsInCtbs, + rows: p.rowHeightsInCtbs, + } +} + +func (g scanGeometry) same(o scanGeometry) bool { + return g.widthInCtbs == o.widthInCtbs && g.heightInCtbs == o.heightInCtbs && + g.ctbLog2SizeY == o.ctbLog2SizeY && g.minTbLog2 == o.minTbLog2 && + slices.Equal(g.cols, o.cols) && slices.Equal(g.rows, o.rows) +} + +// newCTUDecoder prepares the per-picture state, reusing prev's buffers. They +// are sized from the sequence and grow when a larger one arrives. +func newCTUDecoder(prev *ctuDecoder, s *sps, p *pps, sh *sliceHeader, pic *Picture) *ctuDecoder { + d := prev + if d == nil { + d = &ctuDecoder{} + } + + minTbLog2 := int(s.minTbLog2SizeY) + tbW := (int(s.picWidthInLumaSamples) + 1<> minTbLog2 + tbH := (int(s.picHeightInLumaSamples) + 1<> minTbLog2 + mvW := (int(s.picWidthInLumaSamples) + 3) / 4 + mvH := (int(s.picHeightInLumaSamples) + 3) / 4 + ctbs := int(s.picWidthInCtbs) * int(s.picHeightInCtbs) + + saoSrc8, saoSrc16 := d.saoSrc8, d.saoSrc16 + scaling, scalingFrom := d.scaling, d.scalingFrom + threads := d.threads + + *d = ctuDecoder{ + s: s, p: p, sh: sh, pic: pic, + + minTbLog2: minTbLog2, + minTbWidth: tbW, + mvWidth: mvW, + + minTbAddr: keep(d.minTbAddr, tbW*tbH), + intraMode: reuse(d.intraMode, tbW*tbH), + intraModeC: reuse(d.intraModeC, tbW*tbH), + cuDepth: reuse(d.cuDepth, tbW*tbH), + qpY: reuse(d.qpY, tbW*tbH), + skipped: reuse(d.skipped, tbW*tbH), + noFilter: reuse(d.noFilter, tbW*tbH), + + // Only mvValid says whether the three beside it mean anything, and + // every read of them is behind it, so they carry over unzeroed. + mvField: keep(d.mvField, mvW*mvH), + mvPoc: keep(d.mvPoc, mvW*mvH), + mvLong: keep(d.mvLong, mvW*mvH), + mvValid: reuse(d.mvValid, mvW*mvH), + blk: reuse(d.blk, mvW*mvH), + + ctbSliceAddr: reuse(d.ctbSliceAddr, ctbs), + ctbSlice: reuse(d.ctbSlice, ctbs), + sao: reuse(d.sao, ctbs), + sliceLF: reuse(d.sliceLF, ctbs), + + rsToTs: d.rsToTs, + tsToRs: d.tsToRs, + tileID: d.tileID, + scanFor: d.scanFor, + built: d.built, + + saoSrc8: saoSrc8, + saoSrc16: saoSrc16, + + scaling: scaling, + scalingFrom: scalingFrom, + threads: threads, + + qpYPrev: sh.qpY, + qpYCur: sh.qpY, + } + + if g := geometryOf(s, p); !g.same(d.scanFor) { + d.scanFor = g + d.built = false + + d.buildTileScan() + } + + if !d.built { + d.built = true + + d.buildMinTbAddr(tbH) + } + + sl := (*scalingList)(nil) + + if s.scalingListEnabled { + sl = &s.scalingList + if p.scalingListPresent { + sl = &p.scalingList + } + } + + if sl != d.scalingFrom { + d.scalingFrom = sl + d.scaling = [maxScalingListSizes][maxScalingListMats][]uint8{} + + if sl != nil { + d.scaling = sl.factors() + } + } + + return d +} + +// keep returns a buffer of n elements without clearing it, for a table that is +// either rewritten in full or carried over. +func keep[T any](b []T, n int) []T { + if cap(b) < n { + return make([]T, n) + } + + return b[:n] +} + +// reuse returns a zeroed buffer of n elements, keeping b's memory when it fits. +func reuse[T any](b []T, n int) []T { + if cap(b) < n { + return make([]T, n) + } + + b = b[:n] + clear(b) + + return b +} + +// buildMinTbAddr is the MinTbAddrZs derivation of 6.5.2, which the +// availability process in 6.4.1 compares against. +func (d *ctuDecoder) buildMinTbAddr(h int) { + shift := int(d.s.ctbLog2SizeY) - d.minTbLog2 + + for y := range h { + for x := range d.minTbWidth { + tbX := (x << d.minTbLog2) >> d.s.ctbLog2SizeY + tbY := (y << d.minTbLog2) >> d.s.ctbLog2SizeY + + ctb := int(d.rsToTs[int(d.s.picWidthInCtbs)*tbY+tbX]) + + v := ctb << (shift * 2) + + for i := range shift { + m := 1 << i + + if m&x != 0 { + v += m * m + } + + if m&y != 0 { + v += 2 * m * m + } + } + + d.minTbAddr[y*d.minTbWidth+x] = int32(v) + } + } +} + +func (d *ctuDecoder) tbIndex(x, y int) int { + return (y>>d.minTbLog2)*d.minTbWidth + x>>d.minTbLog2 +} + +// available is the z-scan availability of 6.4.1. +func (d *ctuDecoder) available(xCurr, yCurr, xN, yN int) bool { + if xN < 0 || yN < 0 || + xN >= int(d.s.picWidthInLumaSamples) || yN >= int(d.s.picHeightInLumaSamples) { + return false + } + + if d.minTbAddr[d.tbIndex(xN, yN)] >= d.minTbAddr[d.tbIndex(xCurr, yCurr)] { + return false + } + + // With one tile and a slice starting at the first block, everything + // earlier in z-scan order is in the same slice and the same tile. + if d.simpleAvail { + return true + } + + cw := int(d.s.picWidthInCtbs) + curRs := (yCurr>>d.s.ctbLog2SizeY)*cw + xCurr>>d.s.ctbLog2SizeY + nbRs := (yN>>d.s.ctbLog2SizeY)*cw + xN>>d.s.ctbLog2SizeY + + if d.tileID[d.rsToTs[curRs]] != d.tileID[d.rsToTs[nbRs]] { + return false + } + + // 6.4.1 wants the neighbour in the same slice. Slices follow the tile scan, + // so a raster comparison would admit one from a slice already finished. + return d.ctbSliceAddr[nbRs] == int32(d.sliceAddrRs) +} + +// intraChromaMode is Table 8-2 and, for 4:2:2, Table 8-3. +func (d *ctuDecoder) intraChromaMode(luma int) int { + mode := luma + + if d.c.decodeBin(ctxIntraChromaPredMode) != 0 { + idx := int(d.c.decodeBypassBits(2)) + + mode = [4]int{intraPlanar, intraVer, intraHor, intraDC}[idx] + if mode == luma { + mode = 34 + } + } + + if d.s.chromaFormatIDC == 2 { + mode = int(chroma422Map[mode]) + } + + return mode +} + +// Table 8-3. +var chroma422Map = [35]uint8{ + 0, 1, 2, 2, 2, 2, 3, 5, 7, 8, 10, 12, 13, 15, 17, 18, 19, 20, + 21, 22, 23, 23, 24, 24, 25, 25, 26, 27, 27, 28, 28, 29, 29, 30, 31, +} + +func (d *ctuDecoder) codingQuadtree(x, y, log2Size, depth int) error { + split := log2Size > int(d.s.minCbLog2SizeY) + + if log2Size > int(d.s.minCbLog2SizeY) && + x+1< depth { + ctx++ + } + + if d.available(x, y, x, y-1) && int(d.cuDepth[d.tbIndex(x, y-1)]) > depth { + ctx++ + } + + split = d.c.decodeBin(ctxSplitCodingUnitFlag+ctx) != 0 + } + + if d.p.cuQPDeltaEnabled && + log2Size >= int(d.s.ctbLog2SizeY)-int(d.p.diffCuQPDeltaDepth) { + d.qpCoded = false + d.qpDelta = 0 + d.qgX, d.qgY = x, y + d.qpYPred = d.predictQP(x, y) + d.qpYCur = d.qpYPred + } + + // 7.3.8.4 restarts the chroma offset at its own depth, which need not be + // the one the luma delta uses. + if d.sh.cuChromaQPOffset && + log2Size >= int(d.s.ctbLog2SizeY)-int(d.p.diffCuChromaQPOffsetDep) { + d.cuOffCoded = false + d.cuOffCb, d.cuOffCr = 0, 0 + } + + if !split { + return d.codingUnit(x, y, log2Size, depth) + } + + half := 1 << (log2Size - 1) + + for i := range 4 { + nx, ny := x+i&1*half, y+i>>1*half + + if nx >= int(d.s.picWidthInLumaSamples) || ny >= int(d.s.picHeightInLumaSamples) { + continue + } + + if err := d.codingQuadtree(nx, ny, log2Size-1, depth+1); err != nil { + return err + } + } + + return nil +} + +// predictQP is qPY_PRED of 8.6.1. +func (d *ctuDecoder) predictQP(x, y int) int32 { + ctbMask := ^(1<> 1 +} + +// parseCuQPDelta reads cu_qp_delta_abs and its sign, 9.3.3.10. +func (d *ctuDecoder) parseCuQPDelta() { + prefix := 0 + for prefix < 5 { + ctx := 1 + if prefix == 0 { + ctx = 0 + } + + if d.c.decodeBin(ctxCUQPDelta+ctx) == 0 { + break + } + + prefix++ + } + + v := int32(prefix) + + if prefix > 4 { + // 7.4.9.14 caps cu_qp_delta_abs at 26 + QpBdOffsetY/2, well inside this. + k := 0 + for k < 6 && d.c.decodeBypass() != 0 { + k++ + } + + v = int32(5 + (1<>d.minTbLog2+j)*d.minTbWidth+x>>d.minTbLog2:][:nw] + + for i := range row { + row[i] = v + } + } +} + +// codingUnit is 7.3.8.5. QpY of 8.6.1 covers the whole coding unit, including +// one that carries no residual at all, since deblocking reads it back. +func (d *ctuDecoder) codingUnit(x, y, log2Size, depth int) error { + if err := d.codingUnitData(x, y, log2Size, depth); err != nil { + return err + } + + // 8.7.2.2 marks the coding block boundary even with no transform tree. + d.markTU(x, y, 1<= int(d.s.log2MinPcmCbSize) && + log2Size <= int(d.s.log2MaxPcmCbSize) && partMode == partMode2Nx2N && + d.c.decodeTerminate() != 0 { + return d.pcmSample(x, y, log2Size) + } + + parts := 1 + if partMode == partModeNxN { + parts = 4 + } + + pbSize := size + if parts == 4 { + pbSize = size / 2 + } + + var lumaModes [4]int + + prevFlags := make([]bool, parts) + for i := range parts { + prevFlags[i] = d.c.decodeBin(ctxPrevIntraLumaPredFlag) != 0 + } + + for i := range parts { + px, py := x+i&1*pbSize, y+i>>1*pbSize + lumaModes[i] = d.intraLumaModeWithFlag(px, py, prevFlags[i]) + fill(d, d.intraMode, px, py, pbSize, uint8(lumaModes[i])) + } + + // 7.3.8.5 codes intra_chroma_pred_mode once per prediction block when + // ChromaArrayType is 3, and once per coding unit otherwise. + switch { + case d.s.chromaArrayType() == 3: + for i := range parts { + px, py := x+i&1*pbSize, y+i>>1*pbSize + fill(d, d.intraModeC, px, py, pbSize, uint8(d.intraChromaMode(lumaModes[i]))) + } + case d.s.chromaArrayType() != 0: + fill(d, d.intraModeC, x, y, size, uint8(d.intraChromaMode(lumaModes[0]))) + default: + fill(d, d.intraModeC, x, y, size, uint8(lumaModes[0])) + } + + return d.transformTree(x, y, x, y, log2Size, 0, 0, lumaModes, partMode, [2]bool{}, [2]bool{}) +} + +// intraLumaModeWithFlag completes 8.4.2 once prev_intra_luma_pred_flag has been +// read; the flags for all partitions precede the indices in the syntax. +func (d *ctuDecoder) intraLumaModeWithFlag(x, y int, prev bool) int { + candA, candB := intraDC, intraDC + + // 8.4.2: a neighbour that is not intra coded contributes INTRA_DC, as does + // one above the current coding tree block row. + if d.available(x, y, x-1, y) && d.blk[d.blkIndex(x-1, y)].intra { + candA = int(d.intraMode[d.tbIndex(x-1, y)]) + } + + ctbMask := ^(1<= y&ctbMask && d.blk[d.blkIndex(x, y-1)].intra { + candB = int(d.intraMode[d.tbIndex(x, y-1)]) + } + + var cand [3]int + + switch { + case candA == candB && candA < 2: + cand = [3]int{intraPlanar, intraDC, intraVer} + case candA == candB: + cand = [3]int{candA, 2 + (candA+29)%32, 2 + (candA-2+1)%32} + default: + cand[0], cand[1] = candA, candB + + switch { + case candA != intraPlanar && candB != intraPlanar: + cand[2] = intraPlanar + case candA != intraDC && candB != intraDC: + cand[2] = intraDC + default: + cand[2] = intraVer + } + } + + if prev { + idx := 0 + if d.c.decodeBypass() != 0 { + idx = 1 + if d.c.decodeBypass() != 0 { + idx = 2 + } + } + + return cand[idx] + } + + if cand[0] > cand[1] { + cand[0], cand[1] = cand[1], cand[0] + } + + if cand[0] > cand[2] { + cand[0], cand[2] = cand[2], cand[0] + } + + if cand[1] > cand[2] { + cand[1], cand[2] = cand[2], cand[1] + } + + mode := int(d.c.decodeBypassBits(5)) + for i := range 3 { + if mode >= cand[i] { + mode++ + } + } + + return mode +} + +func (d *ctuDecoder) transformTree(x, y, xBase, yBase, log2Size, depth, blkIdx int, + lumaModes [4]int, partMode int, cbfCbUp, cbfCrUp [2]bool, +) error { + intraSplit := d.curIntra && partMode == partModeNxN + + maxDepth := int(d.s.maxTrHierInter) + if d.curIntra { + maxDepth = int(d.s.maxTrHierIntra) + } + + if intraSplit { + maxDepth++ + } + + // 7.3.8.8: with no inter hierarchy an inter unit that is not 2Nx2N still + // splits once. + interSplit := !d.curIntra && d.s.maxTrHierInter == 0 && + partMode != partMode2Nx2N && depth == 0 + + split := log2Size > int(d.s.maxTbLog2SizeY) || (intraSplit && depth == 0) || interSplit + + if log2Size <= int(d.s.maxTbLog2SizeY) && log2Size > int(d.s.minTbLog2SizeY) && + depth < maxDepth && !(intraSplit && depth == 0) && !interSplit { + split = d.c.decodeBin(ctxSplitTransformFlag+5-log2Size) != 0 + } + + cbfCb, cbfCr := cbfCbUp, cbfCrUp + + if d.s.chromaArrayType() != 0 && (log2Size > 2 || d.s.chromaArrayType() == 3) { + // 7.3.8.8: 4:2:2 stacks two chroma transform blocks per luma block, each + // with its own cbf, once the tree stops splitting them apart. + second := d.s.chromaArrayType() == 2 && (!split || log2Size == 3) + + cbfCb = [2]bool{} + if depth == 0 || cbfCbUp[0] { + cbfCb[0] = d.c.decodeBin(ctxCBFCBCR+depth) != 0 + if second { + cbfCb[1] = d.c.decodeBin(ctxCBFCBCR+depth) != 0 + } + } + + cbfCr = [2]bool{} + if depth == 0 || cbfCrUp[0] { + cbfCr[0] = d.c.decodeBin(ctxCBFCBCR+depth) != 0 + if second { + cbfCr[1] = d.c.decodeBin(ctxCBFCBCR+depth) != 0 + } + } + } + + if split { + half := 1 << (log2Size - 1) + + for i := range 4 { + if err := d.transformTree(x+i&1*half, y+i>>1*half, x, y, log2Size-1, + depth+1, i, lumaModes, partMode, cbfCb, cbfCr); err != nil { + return err + } + } + + return nil + } + + // 7.3.8.8 only codes cbf_luma when the block is intra, nested, or has + // chroma coefficients; otherwise an inter root block is inferred to have + // luma residual. + cbfLuma := true + if depth != 0 || cbfCb[0] || cbfCb[1] || cbfCr[0] || cbfCr[1] || d.curIntra { + cbfLuma = d.c.decodeBin(ctxCBFLuma+boolToInt(depth == 0)) != 0 + } + + return d.transformUnit(x, y, xBase, yBase, log2Size, depth, blkIdx, + lumaModes, partMode, cbfLuma, cbfCb, cbfCr) +} + +func (d *ctuDecoder) setQP(x, y, size int) { + fill(d, d.qpY, x, y, size, int8(d.qpYCur)) + + d.qpYPrev = d.qpYCur +} + +func (d *ctuDecoder) transformUnit(x, y, xBase, yBase, log2Size, _, blkIdx int, + lumaModes [4]int, _ int, cbfLuma bool, cbfCb, cbfCr [2]bool, +) error { + // 8.4.4.2 takes the mode from IntraPredModeY at the block's own position, + // which is not blkIdx once the transform tree splits below the partition. + mode := lumaModes[0] + if d.curIntra { + mode = int(d.intraMode[d.tbIndex(x, y)]) + } + + cbfChroma := cbfCb[0] || cbfCb[1] || cbfCr[0] || cbfCr[1] + + if (cbfLuma || cbfChroma) && d.p.cuQPDeltaEnabled && !d.qpCoded { + d.parseCuQPDelta() + } + + if cbfChroma && !d.bypass && d.sh.cuChromaQPOffset && !d.cuOffCoded { + d.parseCuChromaQPOffset() + } + + d.markTU(x, y, 1< 2 || !sub { + c := log2Size + cx, cy := x, y + + if sub { + c-- + cx, cy = x/d.s.subWidthC, y/d.s.subHeightC + } + + return d.chromaTBs(cx, cy, c, d.chromaMode(x, y), cbfCb, cbfCr) + } + + if blkIdx != 3 { + return nil + } + + cx, cy := xBase/d.s.subWidthC, yBase/d.s.subHeightC + + return d.chromaTBs(cx, cy, 2, d.chromaMode(xBase, yBase), cbfCb, cbfCr) +} + +// chromaTBs reconstructs the chroma transform blocks of one transform unit. +// 4:2:2 stacks two of them per component, and 7.3.8.10 codes both Cb blocks +// before either Cr block. +func (d *ctuDecoder) chromaTBs(cx, cy, c, mode int, cbfCb, cbfCr [2]bool) error { + n := 1 + if d.s.chromaArrayType() == 2 { + n = 2 + } + + for cIdx := 1; cIdx <= 2; cIdx++ { + cbf := cbfCb + if cIdx == 2 { + cbf = cbfCr + } + + for t := range n { + if err := d.reconstruct(cx, cy+t< 0 { + off := d.p.cbQPOffset + d.sh.cbQPOffset + d.cuOffCb + if cIdx == 2 { + off = d.p.crQPOffset + d.sh.crQPOffset + d.cuOffCr + } + + qp = chromaQP(clip3(d.qpYCur+off, -offC, 57), d.s.chromaArrayType()) + offC + } + + // 8.6.2 turns a four by four intra block end for end, whether it reaches + // the residual by skipping the transform or by bypassing it. + rotate := d.s.transformSkipRotation && n == 4 && d.curIntra + + // 8.6.2: a bypassed unit takes the coefficients as the residual, with no + // scaling and no transform. + shift := 0 + + if d.bypass && rotate { + for i, j := 0, len(coef)-1; i < j; i, j = i+1, j-1 { + coef[i], coef[j] = coef[j], coef[i] + } + } + + if !d.bypass { + k := dsp + + k.dequant(coef, d.scalingFactor(log2Size, cIdx, skip), n, int(qp), bitDepth, + d.s.extendedPrecision) + + shift = residualShiftBits(bitDepth, d.s.extendedPrecision) + dst := d.curIntra && cIdx == 0 && log2Size == 2 + + switch { + case wideTransform(bitDepth, d.s.extendedPrecision): + if skip { + transformSkipWide(coef, n, rotate, shift) + } else { + inverseTransformWide(coef, n, dst, bitDepth, shift, &d.scratch) + } + + shift = 0 + case skip: + k.transformSkip(coef, n, rotate) + default: + k.inverseTransform(coef, n, dst, bitDepth, d.s.extendedPrecision, &d.scratch) + } + } + + addResidual(plane, stride, x, y, n, shift, coef, bitDepth) + + return nil +} + +// addResidual is bdShift of 8.6.2 and the sum of 8.6.6, in one pass. +func addResidual[P pixel](plane []P, stride, x, y, n, shift int, coef []int32, bitDepth int) { + if n >= 8 { + if p, ok := any(plane).([]uint8); ok && bitDepth == 8 { + if k := dsp.addResidual8; k != nil { + k(p[y*stride+x:], stride, coef[:n*n], n, shift) + + return + } + } else if p, ok := any(plane).([]uint16); ok { + if k := dsp.addResidual16; k != nil { + k(p[y*stride+x:], stride, coef[:n*n], n, shift, int32(1)<>shift), 0, maxV)) + } + } +} + +func gatherRef[P pixel](d *ctuDecoder, plane []P, stride, x, y, n, cIdx int) { + sw, sh := 1, 1 + if cIdx > 0 { + sw, sh = d.s.subWidthC, d.s.subHeightC + } + + w, h := d.pic.Width/sw, d.pic.Height/sh + + d.ref.n = n + + // Availability is uniform across a minimum transform block, so it is + // derived once per block rather than once per reference sample. + lastTb, lastOK := -1, false + + for i := range 4*n + 1 { + var nx, ny int + + switch { + case i < 2*n: + nx, ny = x-1, y+2*n-1-i + case i == 2*n: + nx, ny = x-1, y-1 + default: + nx, ny = x+i-2*n-1, y-1 + } + + ok := nx >= 0 && ny >= 0 && nx < w && ny < h + + if ok { + tb := d.tbIndex(nx*sw, ny*sh) + + if tb != lastTb { + lastTb = tb + lastOK = d.available(x*sw, y*sh, nx*sw, ny*sh) + + // 8.4.4.2.2: with constrained intra prediction, samples from + // inter-coded neighbours count as unavailable. + if lastOK && d.p.constrainedIntraPred && + !d.blk[d.blkIndex(nx*sw, ny*sh)].intra { + lastOK = false + } + } + + ok = lastOK + } + + d.avail[i] = ok + if ok { + d.ref.s[i] = int32(plane[ny*stride+nx]) + } + } + + d.ref.substitute(d.avail[:4*n+1], d.pic.depth(cIdx)) +} + +// buildTileScan is 6.5.1, the raster to tile scan conversion and the tile +// each coding tree block belongs to. +func (d *ctuDecoder) buildTileScan() { + w, h := int(d.s.picWidthInCtbs), int(d.s.picHeightInCtbs) + + colBd := make([]int, len(d.p.colWidthsInCtbs)+1) + for i, v := range d.p.colWidthsInCtbs { + colBd[i+1] = colBd[i] + int(v) + } + + rowBd := make([]int, len(d.p.rowHeightsInCtbs)+1) + for i, v := range d.p.rowHeightsInCtbs { + rowBd[i+1] = rowBd[i] + int(v) + } + + d.rsToTs = make([]int32, w*h) + d.tsToRs = make([]int32, w*h) + d.tileID = make([]int32, w*h) + + for rs := range w * h { + x, y := rs%w, rs/w + + var tbX, tbY int + + for i := range len(colBd) - 1 { + if x >= colBd[i] { + tbX = i + } + } + + for i := range len(rowBd) - 1 { + if y >= rowBd[i] { + tbY = i + } + } + + ts := 0 + + for i := range tbX { + ts += int(d.p.rowHeightsInCtbs[tbY]) * int(d.p.colWidthsInCtbs[i]) + } + + for i := range tbY { + ts += w * int(d.p.rowHeightsInCtbs[i]) + } + + ts += (y-rowBd[tbY])*int(d.p.colWidthsInCtbs[tbX]) + x - colBd[tbX] + + d.rsToTs[rs] = int32(ts) + d.tsToRs[ts] = int32(rs) + d.tileID[ts] = int32(tbY*len(d.p.colWidthsInCtbs) + tbX) + } +} + +// partMode is 7.3.8.5's part_mode, whose binarization depends on the +// prediction mode, the coding block size and whether asymmetric splits are on. +func (d *ctuDecoder) partMode(intra bool, log2Size int) int { + if intra { + if d.c.decodeBin(ctxPartMode) != 0 { + return partMode2Nx2N + } + + return partModeNxN + } + + if d.c.decodeBin(ctxPartMode) != 0 { + return partMode2Nx2N + } + + minCb := log2Size == int(d.s.minCbLog2SizeY) + + // Table 9-34. At the smallest coding block size the third bin separates + // Nx2N from NxN, and the asymmetric modes are not available at all. + if d.c.decodeBin(ctxPartMode+1) != 0 { + if minCb || !d.s.ampEnabled { + return partMode2NxN + } + + if d.c.decodeBin(ctxPartMode+3) != 0 { + return partMode2NxN + } + + if d.c.decodeBypass() != 0 { + return partMode2NxnD + } + + return partMode2NxnU + } + + if minCb { + if log2Size == 3 { + return partModeNx2N + } + + if d.c.decodeBin(ctxPartMode+2) != 0 { + return partModeNx2N + } + + return partModeNxN + } + + if !d.s.ampEnabled { + return partModeNx2N + } + + if d.c.decodeBin(ctxPartMode+3) != 0 { + return partModeNx2N + } + + if d.c.decodeBypass() != 0 { + return partModenRx2N + } + + return partModenLx2N +} + +var partShape = [8][4][4]int{ + partMode2Nx2N: {{0, 0, 4, 4}}, + partMode2NxN: {{0, 0, 4, 2}, {0, 2, 4, 2}}, + partModeNx2N: {{0, 0, 2, 4}, {2, 0, 2, 4}}, + partModeNxN: {{0, 0, 2, 2}, {2, 0, 2, 2}, {0, 2, 2, 2}, {2, 2, 2, 2}}, + partMode2NxnU: {{0, 0, 4, 1}, {0, 1, 4, 3}}, + partMode2NxnD: {{0, 0, 4, 3}, {0, 3, 4, 1}}, + partModenLx2N: {{0, 0, 1, 4}, {1, 0, 3, 4}}, + partModenRx2N: {{0, 0, 3, 4}, {3, 0, 1, 4}}, +} + +var partCount = [8]int{1, 2, 2, 4, 2, 2, 2, 2} + +func (d *ctuDecoder) interCU(x, y, log2Size, depth, partMode int) error { + size := 1 << log2Size + q := size / 4 + + for i := range partCount[partMode] { + s := partShape[partMode][i] + + if err := d.predictionUnit(x+s[0]*q, y+s[1]*q, s[2]*q, s[3]*q, + i, partMode, depth, size, x, y, false); err != nil { + return err + } + } + + // The bin is rqt_root_cbf: one means residual data follows. It is + // inferred to one for a merged 2Nx2N unit. + if !(partMode == partMode2Nx2N && d.lastMerge) { + if d.c.decodeBin(ctxNoResidualDataFlag) == 0 { + return nil + } + } + + var modes [4]int + + return d.transformTree(x, y, x, y, log2Size, 0, 0, modes, partMode, [2]bool{}, [2]bool{}) +} + +// scalingFactor picks the matrix of 8.6.3, which is flat when scaling lists +// are off and when a large block skips the transform. +func (d *ctuDecoder) scalingFactor(log2Size, cIdx int, skip bool) []uint8 { + if !d.s.scalingListEnabled || (skip && log2Size > 2) { + return nil + } + + sizeID := log2Size - 2 + + matrixID := cIdx + if !d.curIntra { + matrixID += 3 + } + + if sizeID == 3 && cIdx > 0 { + matrixID = 0 + if !d.curIntra { + matrixID = 3 + } + } + + return d.scaling[sizeID][matrixID] +} + +func readPCM[P pixel](g *getBits, plane []P, stride, x, y, w, h, depth, bitDepth int) { + shift := bitDepth - depth + + for j := range h { + row := (y + j) * stride + for i := range w { + plane[row+x+i] = P(g.bits(depth) << shift) + } + } +} + +// pcmSample is 7.3.8.7 and 8.4.4.1: the samples are read from the bitstream at +// the byte boundary the arithmetic decoder left off, which then restarts. +func (d *ctuDecoder) pcmSample(x, y, log2Size int) error { + off := d.c.pcmOffset() + if off < 0 || off >= len(d.c.data) { + return ErrInvalid + } + + size := 1 << log2Size + + fill(d, d.intraMode, x, y, size, uint8(intraDC)) + d.markPU(x, y, size, size, true) + + if d.s.pcmLoopFilterDisabled { + fill(d, d.noFilter, x, y, size, true) + } + + var g getBits + + g.init(d.c.data[off:]) + + depth := int(d.s.pcmBitDepthLuma) + + if d.pic.deep() { + plane, stride := d.pic.plane16(0) + readPCM(&g, plane, stride, x, y, size, size, depth, d.pic.BitDepth) + } else { + plane, stride := d.pic.plane8(0) + readPCM(&g, plane, stride, x, y, size, size, depth, d.pic.BitDepth) + } + + if d.s.chromaArrayType() != 0 { + sw, sh := d.s.subWidthC, d.s.subHeightC + depth = int(d.s.pcmBitDepthChroma) + + for cIdx := 1; cIdx <= 2; cIdx++ { + if d.pic.deep() { + plane, stride := d.pic.plane16(cIdx) + readPCM(&g, plane, stride, x/sw, y/sh, size/sw, size/sh, depth, d.pic.BitDepthC) + } else { + plane, stride := d.pic.plane8(cIdx) + readPCM(&g, plane, stride, x/sw, y/sh, size/sw, size/sh, depth, d.pic.BitDepthC) + } + } + } + + if g.err { + return ErrInvalid + } + + return d.c.init(d.c.data, off+(g.pos()+7)/8) +} diff --git a/third_party/h265/hevc/deblock.go b/third_party/h265/hevc/deblock.go new file mode 100644 index 00000000..6cfaeabc --- /dev/null +++ b/third_party/h265/hevc/deblock.go @@ -0,0 +1,663 @@ +package hevc + +// Table 8-12. +var betaTable = [52]uint8{ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 20, 22, 24, + 26, 28, 30, 32, 34, 36, 38, 40, 42, 44, 46, 48, 50, 52, 54, 56, + 58, 60, 62, 64, +} + +var tcTable = [54]uint8{ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 3, + 3, 3, 3, 4, 4, 4, 5, 5, 6, 6, 7, 8, 9, 10, 11, 13, + 14, 16, 18, 20, 22, 24, +} + +// blockInfo is what boundary strength derivation reads back after decoding. +type blockInfo struct { + intra bool + cbf bool + tuV bool + tuH bool + puV bool + puH bool +} + +func (d *ctuDecoder) blkIndex(x, y int) int { + return (y>>2)*d.mvWidth + x>>2 +} + +// blocksIn is how many blocks of a region fall inside the picture, so the +// loops that walk one need no bound on each block. +func (d *ctuDecoder) blocksIn(x, y, w, h, log2 int) (int, int) { + last := 1<> log2, max(nh+last, 0) >> log2 +} + +func (d *ctuDecoder) markTU(x, y, w, h int, cbf bool) { + nw, nh := d.blocksIn(x, y, w, h, 2) + + for j := range nh { + row := d.blk[(y>>2+j)*d.mvWidth+x>>2:][:nw] + + if cbf { + for i := range row { + row[i].cbf = true + } + } + + // The edges of 8.7.2 are the first column and the first row of the + // block, so only those carry a flag. + if j == 0 { + for i := range row { + row[i].tuH = true + } + } + + row[0].tuV = true + } +} + +func (d *ctuDecoder) markPU(x, y, w, h int, intra bool) { + nw, nh := d.blocksIn(x, y, w, h, 2) + + for j := range nh { + row := d.blk[(y>>2+j)*d.mvWidth+x>>2:][:nw] + + for i := range row { + row[i].intra = intra + } + + if j == 0 { + for i := range row { + row[i].puH = true + } + } + + row[0].puV = true + } +} + +// boundaryStrength is 8.7.2.4. +func (d *ctuDecoder) boundaryStrength(xP, yP, xQ, yQ int, vertical bool) int { + qi := d.blkIndex(xQ, yQ) + q := &d.blk[qi] + + tu := q.tuV + pu := q.puV + + if !vertical { + tu, pu = q.tuH, q.puH + } + + // 8.7.2 filters transform and prediction block edges only, not every + // edge on the eight-sample grid, and most of them are neither. + if !tu && !pu { + return 0 + } + + pi := d.blkIndex(xP, yP) + p := &d.blk[pi] + + if p.intra || q.intra { + return 2 + } + + if tu && (p.cbf || q.cbf) { + return 1 + } + + if !d.mvValid[pi] || !d.mvValid[qi] { + return 1 + } + + if differentMotion(&d.mvField[pi], &d.mvField[qi], d.mvPoc[pi], d.mvPoc[qi]) { + return 1 + } + + return 0 +} + +func mvFar(a, b mv) bool { + return absI32(int32(a.x)-int32(b.x)) >= 4 || absI32(int32(a.y)-int32(b.y)) >= 4 +} + +// differentMotion is the motion part of 8.7.2.4, comparing the pictures each +// block recorded when it was decoded. +func differentMotion(p, q *mvInfo, pPoc, qPoc [2]int32) bool { + var ( + pp, qp [2]int32 + pv, qv [2]mv + np, nq int + ) + + for l := range 2 { + if p.pred[l] { + pp[np], pv[np] = pPoc[l], p.mv[l] + np++ + } + + if q.pred[l] { + qp[nq], qv[nq] = qPoc[l], q.mv[l] + nq++ + } + } + + if np != nq { + return true + } + + if np == 1 { + return pp[0] != qp[0] || mvFar(pv[0], qv[0]) + } + + if np == 0 { + return false + } + + if pp[0] == qp[0] && pp[1] == qp[1] { + if pp[0] == pp[1] { + return (mvFar(pv[0], qv[0]) || mvFar(pv[1], qv[1])) && + (mvFar(pv[0], qv[1]) || mvFar(pv[1], qv[0])) + } + + return mvFar(pv[0], qv[0]) || mvFar(pv[1], qv[1]) + } + + if pp[0] == qp[1] && pp[1] == qp[0] { + return mvFar(pv[0], qv[1]) || mvFar(pv[1], qv[0]) + } + + return true +} + +// sliceAt is the slice header governing a coding tree block. The filtering +// parameters of 7.4.7.1 are per slice. +func (d *ctuDecoder) sliceAt(x, y int) *sliceHeader { + rs := (y>>d.s.ctbLog2SizeY)*int(d.s.picWidthInCtbs) + x>>d.s.ctbLog2SizeY + if rs < 0 || rs >= len(d.ctbSlice) || int(d.ctbSlice[rs]) >= len(d.slices) { + return d.sh + } + + return d.slices[d.ctbSlice[rs]] +} + +// deblock is 8.7.2: every vertical edge in the picture, then every horizontal +// one, both on the eight-sample grid. Within a pass each edge writes its own +// band of eight lines, so the bands split across workers. +func (d *ctuDecoder) deblock() { + w, h := int(d.s.picWidthInLumaSamples), int(d.s.picHeightInLumaSamples) + + for _, vertical := range []bool{true, false} { + d.overRows((h+7)/8, func(r0, r1 int) { + for r := r0; r < r1; r++ { + y := r * 8 + + if !vertical && y == 0 { + continue + } + + for x := 0; x < w; x += 8 { + if vertical && x == 0 { + continue + } + + d.deblockEdge(x, y, vertical) + } + } + }) + } +} + +func (d *ctuDecoder) deblockEdge(x, y int, vertical bool) { + // The two sides share a coding tree block unless the edge sits on one of + // its boundaries, and 8.7.2 only asks about tiles and slices when they do. + ctb := 1<= int(d.s.picHeightInLumaSamples) || qx >= int(d.s.picWidthInLumaSamples) { + continue + } + + if across && !d.filterEdge(px, py, qx, qy) { + continue + } + + bs := d.boundaryStrength(px, py, qx, qy, vertical) + if bs == 0 { + continue + } + + pt, qt := d.tbIndex(px, py), d.tbIndex(qx, qy) + + qp := (int32(d.qpY[pt]) + int32(d.qpY[qt]) + 1) >> 1 + + // 8.7.2.5.3 leaves a side untouched when its coding unit bypassed the + // transform, or is pulse code modulated with filtering turned off. + noP[k>>2], noQ[k>>2] = d.noFilter[pt], d.noFilter[qt] + + if beta, tc := betaTc(qp, bs, sl, d.pic.BitDepth); beta != 0 && tc != 0 { + if deep { + plan[k>>2] = deblockLumaPlan(luma16, base+k*line, line, step, beta, tc) + } else { + plan[k>>2] = deblockLumaPlan(luma8, base+k*line, line, step, beta, tc) + } + } + + if bs != 2 || d.s.chromaArrayType() == 0 { + continue + } + + if (vertical && x%(8*d.s.subWidthC) != 0) || (!vertical && y%(8*d.s.subHeightC) != 0) { + continue + } + + onC[k>>2], qpC[k>>2] = true, qp + } + + d.deblockChromaEdge(x, y, vertical, sl, &onC, &qpC, &noP, &noQ) + + if deep { + deblockLumaEdge(luma16, base, line, step, plan, d.pic.BitDepth, noP, noQ) + } else { + deblockLumaEdge(luma8, base, line, step, plan, d.pic.BitDepth, noP, noQ) + } +} + +// deblockChromaEdge is 8.7.2.5.5 over one edge. The two groups of four luma +// lines are adjacent in the chroma planes, so they share a call when their +// coding units agree. +func (d *ctuDecoder) deblockChromaEdge(x, y int, vertical bool, sl *sliceHeader, + on *[2]bool, qp *[2]int32, noP, noQ *[2]bool, +) { + if !on[0] && !on[1] { + return + } + + sw, sh := d.s.subWidthC, d.s.subHeightC + + // The four-sample luma segment spans 4/subHeightC chroma rows on a + // vertical edge, and 4/subWidthC columns on a horizontal one. + n := 4 / sh + if !vertical { + n = 4 / sw + } + + both := on[0] && on[1] && qp[0] == qp[1] && noP[0] == noP[1] && noQ[0] == noQ[1] + + deep := d.pic.deep() + cb8, cs := d.pic.plane8(1) + cr8, _ := d.pic.plane8(2) + cb16, cs16 := d.pic.plane16(1) + cr16, _ := d.pic.plane16(2) + + if deep { + cs = cs16 + } + + for g := range 2 { + if !on[g] { + continue + } + + cx, cy := x, y+4*g + if !vertical { + cx, cy = x+4*g, y + } + + lines := n + if both { + if g != 0 { + continue + } + + lines = 2 * n + } + + _, tcCb := betaTc(chromaQP(clip3(qp[g]+d.p.cbQPOffset, 0, 57), d.s.chromaArrayType()), + 2, sl, d.pic.BitDepthC) + _, tcCr := betaTc(chromaQP(clip3(qp[g]+d.p.crQPOffset, 0, 57), d.s.chromaArrayType()), + 2, sl, d.pic.BitDepthC) + + if tcCb == 0 && tcCr == 0 { + continue + } + + if deep { + deblockChromaPair(cb16, cr16, cs, cx/sw, cy/sh, lines, vertical, + tcCb, tcCr, d.pic.BitDepthC, noP[g], noQ[g]) + } else { + deblockChromaPair(cb8, cr8, cs, cx/sw, cy/sh, lines, vertical, + tcCb, tcCr, d.pic.BitDepthC, noP[g], noQ[g]) + } + } +} + +func betaTc(qp int32, bs int, sh *sliceHeader, bitDepth int) (int32, int32) { + qb := clip3(qp+int32(sh.betaOffsetDiv2)*2, 0, 51) + beta := int32(betaTable[qb]) * (1 << (bitDepth - 8)) + + qt := clip3(qp+2*int32(bs-1)+int32(sh.tcOffsetDiv2)*2, 0, 53) + tc := int32(tcTable[qt]) * (1 << (bitDepth - 8)) + + return beta, tc +} + +// lumaPlan is what 8.7.2.5.3 decides for one group of four lines. +type lumaPlan struct { + tc int32 + mode uint8 + nDp, nDq uint8 +} + +const ( + lumaNone = iota + lumaStrong + lumaNormal +) + +// deblockLumaPlan is 8.7.2.5.3 over the outer two lines of a group. +func deblockLumaPlan[P pixel](plane []P, base, line, step int, beta, tc int32) lumaPlan { + at := func(l, i int) int32 { + return int32(plane[base+l*line+i*step]) + } + + dp0 := absI32(at(0, -3) - 2*at(0, -2) + at(0, -1)) + dq0 := absI32(at(0, 2) - 2*at(0, 1) + at(0, 0)) + dp3 := absI32(at(3, -3) - 2*at(3, -2) + at(3, -1)) + dq3 := absI32(at(3, 2) - 2*at(3, 1) + at(3, 0)) + + dp, dq := dp0+dp3, dq0+dq3 + if dp+dq >= beta { + return lumaPlan{} + } + + strong := func(l int, d2 int32) bool { + return 2*d2 < beta>>2 && + absI32(at(l, -4)-at(l, -1))+absI32(at(l, 0)-at(l, 3)) < beta>>3 && + absI32(at(l, -1)-at(l, 0)) < (5*tc+1)>>1 + } + + if strong(0, dp0+dq0) && strong(3, dp3+dq3) { + return lumaPlan{tc: tc, mode: lumaStrong} + } + + pl := lumaPlan{tc: tc, mode: lumaNormal} + + if dp < (beta+beta>>1)>>3 { + pl.nDp = 1 + } + + if dq < (beta+beta>>1)>>3 { + pl.nDq = 1 + } + + return pl +} + +// deblockLumaApply is 8.7.2.5.7 over the four lines its plan was taken from. +func deblockLumaApply[P pixel](plane []P, base, line, step int, pl lumaPlan, + bitDepth int, noP, noQ bool, +) { + tc := pl.tc + + at := func(l, i int) int32 { + return int32(plane[base+l*line+i*step]) + } + + set := func(l, i int, v int32) { + if (i < 0 && noP) || (i >= 0 && noQ) { + return + } + + plane[base+l*line+i*step] = P(clip3(v, 0, 1<>3, p0-c, p0+c)) + set(l, -2, clip3((p2+p1+p0+q0+2)>>2, p1-c, p1+c)) + set(l, -3, clip3((2*p3+3*p2+p1+p0+q0+4)>>3, p2-c, p2+c)) + set(l, 0, clip3((p1+2*p0+2*q0+2*q1+q2+4)>>3, q0-c, q0+c)) + set(l, 1, clip3((p0+q0+q1+q2+2)>>2, q1-c, q1+c)) + set(l, 2, clip3((p0+q0+q1+3*q2+2*q3+4)>>3, q2-c, q2+c)) + } + + return + } + + for l := range 4 { + p0, p1, p2 := at(l, -1), at(l, -2), at(l, -3) + q0, q1, q2 := at(l, 0), at(l, 1), at(l, 2) + + delta := (9*(q0-p0) - 3*(q1-p1) + 8) >> 4 + if absI32(delta) >= tc*10 { + continue + } + + delta = clip3(delta, -tc, tc) + + set(l, -1, p0+delta) + set(l, 0, q0-delta) + + if pl.nDp != 0 { + set(l, -2, p1+clip3(((p2+p0+1)>>1-p1+delta)>>1, -(tc>>1), tc>>1)) + } + + if pl.nDq != 0 { + set(l, 1, q1+clip3(((q2+q0+1)>>1-q1-delta)>>1, -(tc>>1), tc>>1)) + } + } +} + +// deblockLumaEdge filters the eight lines of one edge, both groups at once. +func deblockLumaEdge[P pixel](plane []P, base, line, step int, pl [2]lumaPlan, + bitDepth int, noP, noQ [2]bool, +) { + if pl[0].mode == lumaNone && pl[1].mode == lumaNone { + return + } + + if bitDepth == 8 { + if p8, ok := any(plane).([]uint8); ok { + mode, ok := lumaBoth(&pl, &noP, &noQ) + if ok { + flags := int32(0) + if noP[0] { + flags |= 1 + } + + if noQ[0] { + flags |= 2 + } + + if deblockLuma8(p8, base, line, step, mode, pl, flags) { + return + } + } + } + } + + for g := range 2 { + if pl[g].mode == lumaNone { + continue + } + + deblockLumaApply(plane, base+g*4*line, line, step, pl[g], bitDepth, noP[g], noQ[g]) + } +} + +// lumaBoth gives a group without a filter the other's, at a tc of zero. +func lumaBoth(pl *[2]lumaPlan, noP, noQ *[2]bool) (uint8, bool) { + a, b := 0, 1 + if pl[0].mode == lumaNone { + a, b = 1, 0 + } + + if pl[b].mode == lumaNone { + pl[b].mode = pl[a].mode + noP[b], noQ[b] = noP[a], noQ[a] + } + + return pl[0].mode, pl[0].mode == pl[1].mode && noP[0] == noP[1] && noQ[0] == noQ[1] +} + +// deblockLuma8 hands one edge to a kernel, turning it first when it runs down. +func deblockLuma8(p []uint8, base, line, step int, mode uint8, pl [2]lumaPlan, flags int32) bool { + strong, normal := deblockStrongAsm, deblockNormalAsm + if strong == nil || normal == nil { + return false + } + + nd := int32(pl[0].nDp) | int32(pl[0].nDq)<<1 | + int32(pl[1].nDp)<<2 | int32(pl[1].nDq)<<3 + + if step != 1 { + q := p[base-4*step:] + + if mode == lumaStrong { + strong(q, step, pl[0].tc, pl[1].tc, flags) + } else { + normal(q, step, pl[0].tc, pl[1].tc, nd, flags) + } + + return true + } + + if deblockTurnIn == nil { + return false + } + + var buf [8 * 8]uint8 + + deblockTurnIn(buf[:], p[base-4:], line) + + if mode == lumaStrong { + strong(buf[:], 8, pl[0].tc, pl[1].tc, flags) + } else { + normal(buf[:], 8, pl[0].tc, pl[1].tc, nd, flags) + } + + deblockTurnOut(p[base-4:], line, buf[:]) + + return true +} + +// deblockChromaPair is 8.7.2.5.5 over both components at once, which is what +// the edge derivation of 8.7.2.4 gives them in common. +func deblockChromaPair[P pixel](cb, cr []P, stride, x, y, n int, vertical bool, + tcCb, tcCr int32, bitDepth int, noP, noQ bool, +) { + step, line := 1, stride + if !vertical { + step, line = stride, 1 + } + + base := y*stride + x + maxV := int32(1<>3, -tc, tc) + + if !noP { + plane[off-step] = P(clip3(p0+delta, 0, maxV)) + } + + if !noQ { + plane[off] = P(clip3(q0-delta, 0, maxV)) + } +} + +// filterEdge is the filterEdgeFlag derivation of 8.7.2, which suppresses +// filtering across a tile or slice boundary the parameter sets close off. +func (d *ctuDecoder) filterEdge(xP, yP, xQ, yQ int) bool { + w := int(d.s.picWidthInCtbs) + + pRs := (yP>>d.s.ctbLog2SizeY)*w + xP>>d.s.ctbLog2SizeY + qRs := (yQ>>d.s.ctbLog2SizeY)*w + xQ>>d.s.ctbLog2SizeY + + if pRs == qRs { + return true + } + + if !d.p.loopFilterAcrossTiles && d.tileID[d.rsToTs[pRs]] != d.tileID[d.rsToTs[qRs]] { + return false + } + + if d.ctbSliceAddr[pRs] != d.ctbSliceAddr[qRs] { + if !d.sliceLF[d.ctbSliceAddr[qRs]] { + return false + } + } + + return true +} diff --git a/third_party/h265/hevc/deblock_amd64.s b/third_party/h265/hevc/deblock_amd64.s new file mode 100644 index 00000000..b350d711 --- /dev/null +++ b/third_party/h265/hevc/deblock_amd64.s @@ -0,0 +1,378 @@ +//go:build amd64 && !noasm + +#include "textflag.h" + +// The two luma filters of 8.7.2.5.7 over the eight lines of one edge, with +// position i of line l at p[i*pitch+l]. + +DATA deblkW<>+0(SB)/2, $1 +DATA deblkW<>+2(SB)/2, $2 +DATA deblkW<>+4(SB)/2, $3 +DATA deblkW<>+6(SB)/2, $4 +DATA deblkW<>+8(SB)/2, $8 +DATA deblkW<>+10(SB)/2, $9 +DATA deblkW<>+12(SB)/2, $10 +DATA deblkW<>+14(SB)/2, $0 +GLOBL deblkW<>(SB), RODATA|NOPTR, $16 + +#define ONE deblkW<>+0(SB) +#define TWO deblkW<>+2(SB) +#define THREE deblkW<>+4(SB) +#define FOUR deblkW<>+6(SB) +#define EIGHT deblkW<>+8(SB) +#define NINE deblkW<>+10(SB) +#define TEN deblkW<>+12(SB) + +// ndMask is the four ways 8.7.2.5.7's dEp or dEq can fall over the two halves. +DATA ndMask<>+0(SB)/8, $0x0000000000000000 +DATA ndMask<>+8(SB)/8, $0x0000000000000000 +DATA ndMask<>+16(SB)/8, $0xffffffffffffffff +DATA ndMask<>+24(SB)/8, $0x0000000000000000 +DATA ndMask<>+32(SB)/8, $0x0000000000000000 +DATA ndMask<>+40(SB)/8, $0xffffffffffffffff +DATA ndMask<>+48(SB)/8, $0xffffffffffffffff +DATA ndMask<>+56(SB)/8, $0xffffffffffffffff +GLOBL ndMask<>(SB), RODATA|NOPTR, $64 + +// TCV spreads the thresholds in R9 and R10 over the lanes each governs. +#define TCV(v, t) \ + MOVQ $0x0001000100010001, R11 \ + IMULQ R11, R9 \ + IMULQ R11, R10 \ + MOVQ R9, v \ + MOVQ R10, t \ + VPUNPCKLQDQ t, v, v + +// CLIPC puts v inside base-c .. base+c. +#define CLIPC(v, base, c, t0, t1) \ + VPSUBW c, base, t0 \ + VPADDW c, base, t1 \ + VPMAXSW t0, v, v \ + VPMINSW t1, v, v + +// STORE8 saturates a lane to a byte and writes the eight of them. +#define STORE8(v, t, dst) \ + VPACKUSWB v, v, t \ + MOVQ t, dst + +// func deblockStrong8AVX2(p *uint8, pitch int, tc0, tc1, flags int32) +TEXT ·deblockStrong8AVX2(SB), NOSPLIT, $0-28 + MOVQ p+0(FP), AX + MOVQ pitch+8(FP), R8 + + VPMOVZXBW (AX), X0 + LEAQ (AX)(R8*1), CX + VPMOVZXBW (CX), X1 + LEAQ (CX)(R8*1), CX + VPMOVZXBW (CX), X2 + LEAQ (CX)(R8*1), CX + VPMOVZXBW (CX), X3 + LEAQ (CX)(R8*1), CX + VPMOVZXBW (CX), X4 + LEAQ (CX)(R8*1), CX + VPMOVZXBW (CX), X5 + LEAQ (CX)(R8*1), CX + VPMOVZXBW (CX), X6 + LEAQ (CX)(R8*1), CX + VPMOVZXBW (CX), X7 + + MOVL tc0+16(FP), R9 + MOVL tc1+20(FP), R10 + TCV(X8, X15) + VPADDW X8, X8, X9 + + VPBROADCASTW FOUR, X12 + VPBROADCASTW TWO, X13 + + // p0' from p2 + 2*p1 + 2*p0 + 2*q0 + q1 + 4 + VPADDW X2, X3, X10 + VPADDW X4, X10, X10 + VPADDW X10, X10, X10 + VPADDW X1, X10, X10 + VPADDW X5, X10, X10 + VPADDW X12, X10, X10 + VPSRAW $3, X10, X10 + CLIPC(X10, X3, X9, X14, X15) + + // p1' from p2 + p1 + p0 + q0 + 2 + VPADDW X1, X2, X11 + VPADDW X3, X11, X11 + VPADDW X4, X11, X11 + VPADDW X13, X11, X11 + VPSRAW $2, X11, X11 + CLIPC(X11, X2, X9, X14, X15) + + MOVL flags+24(FP), DX + TESTL $1, DX + JNZ qside + + // p2' from 2*p3 + 3*p2 + p1 + p0 + q0 + 4 + VPADDW X0, X0, X14 + VPADDW X1, X14, X14 + VPADDW X1, X14, X14 + VPADDW X1, X14, X14 + VPADDW X2, X14, X14 + VPADDW X3, X14, X14 + VPADDW X4, X14, X14 + VPADDW X12, X14, X14 + VPSRAW $3, X14, X14 + CLIPC(X14, X1, X9, X8, X15) + + LEAQ (AX)(R8*1), CX + STORE8(X14, X15, (CX)) + LEAQ (CX)(R8*1), CX + STORE8(X11, X15, (CX)) + LEAQ (CX)(R8*1), CX + STORE8(X10, X15, (CX)) + +qside: + TESTL $2, DX + JNZ done + + // q0' from p1 + 2*p0 + 2*q0 + 2*q1 + q2 + 4 + VPADDW X3, X4, X10 + VPADDW X5, X10, X10 + VPADDW X10, X10, X10 + VPADDW X2, X10, X10 + VPADDW X6, X10, X10 + VPADDW X12, X10, X10 + VPSRAW $3, X10, X10 + CLIPC(X10, X4, X9, X14, X15) + + // q1' from p0 + q0 + q1 + q2 + 2 + VPADDW X3, X4, X11 + VPADDW X5, X11, X11 + VPADDW X6, X11, X11 + VPADDW X13, X11, X11 + VPSRAW $2, X11, X11 + CLIPC(X11, X5, X9, X14, X15) + + // q2' from p0 + q0 + q1 + 3*q2 + 2*q3 + 4 + VPADDW X6, X6, X14 + VPADDW X6, X14, X14 + VPADDW X7, X14, X14 + VPADDW X7, X14, X14 + VPADDW X3, X14, X14 + VPADDW X4, X14, X14 + VPADDW X5, X14, X14 + VPADDW X12, X14, X14 + VPSRAW $3, X14, X14 + CLIPC(X14, X6, X9, X8, X15) + + LEAQ (AX)(R8*4), CX + STORE8(X10, X15, (CX)) + LEAQ (CX)(R8*1), CX + STORE8(X11, X15, (CX)) + LEAQ (CX)(R8*1), CX + STORE8(X14, X15, (CX)) + +done: + VZEROUPPER + RET + +// func deblockNormal8AVX2(p *uint8, pitch int, tc0, tc1, nd, flags int32) +TEXT ·deblockNormal8AVX2(SB), NOSPLIT, $0-32 + MOVQ p+0(FP), AX + MOVQ pitch+8(FP), R8 + + LEAQ (AX)(R8*1), CX + VPMOVZXBW (CX), X1 // p2 + LEAQ (CX)(R8*1), CX + VPMOVZXBW (CX), X2 // p1 + LEAQ (CX)(R8*1), CX + VPMOVZXBW (CX), X3 // p0 + LEAQ (CX)(R8*1), CX + VPMOVZXBW (CX), X4 // q0 + LEAQ (CX)(R8*1), CX + VPMOVZXBW (CX), X5 // q1 + LEAQ (CX)(R8*1), CX + VPMOVZXBW (CX), X6 // q2 + + MOVL tc0+16(FP), R9 + MOVL tc1+20(FP), R10 + TCV(X8, X15) + + // delta = (9*(q0-p0) - 3*(q1-p1) + 8) >> 4 + VPSUBW X3, X4, X10 + VPBROADCASTW NINE, X11 + VPMULLW X11, X10, X10 + VPSUBW X2, X5, X12 + VPBROADCASTW THREE, X11 + VPMULLW X11, X12, X12 + VPSUBW X12, X10, X10 + VPBROADCASTW EIGHT, X11 + VPADDW X11, X10, X10 + VPSRAW $4, X10, X10 + + // keep only the lanes 8.7.2.5.7 leaves inside ten times tc + VPABSW X10, X11 + VPBROADCASTW TEN, X12 + VPMULLW X12, X8, X12 + VPCMPGTW X11, X12, X15 // mask = tc*10 > |delta| + + VPXOR X0, X0, X0 + VPSUBW X8, X0, X13 + VPMAXSW X13, X10, X10 + VPMINSW X8, X10, X10 // delta clipped to +-tc + + VPSRAW $1, X8, X14 // tc>>1 + VPSUBW X14, X0, X7 // -(tc>>1) + + // the dEp and dEq of each half become the lanes their sample may move in + MOVL nd+24(FP), BX + LEAQ ndMask<>(SB), R12 + + MOVL BX, R9 + ANDL $1, R9 + MOVL BX, R10 + SHRL $1, R10 + ANDL $2, R10 + ORL R10, R9 + SHLQ $4, R9 + VMOVDQU (R12)(R9*1), X9 + VPAND X15, X9, X9 + + MOVL BX, R10 + SHRL $1, R10 + ANDL $1, R10 + MOVL BX, R11 + SHRL $2, R11 + ANDL $2, R11 + ORL R11, R10 + SHLQ $4, R10 + VMOVDQU (R12)(R10*1), X13 + VPAND X15, X13, X13 + + MOVL flags+28(FP), DX + TESTL $1, DX + JNZ nqside + + VPADDW X10, X3, X11 + VPBLENDVB X15, X11, X3, X11 + LEAQ (AX)(R8*2), CX + LEAQ (CX)(R8*1), CX + STORE8(X11, X12, (CX)) // p0 + + // p1' from ((p2+p0+1)>>1 - p1 + delta) >> 1 + VPADDW X1, X3, X11 + VPBROADCASTW ONE, X12 + VPADDW X12, X11, X11 + VPSRAW $1, X11, X11 + VPSUBW X2, X11, X11 + VPADDW X10, X11, X11 + VPSRAW $1, X11, X11 + VPMAXSW X7, X11, X11 + VPMINSW X14, X11, X11 + VPADDW X2, X11, X11 + VPBLENDVB X9, X11, X2, X11 + LEAQ (AX)(R8*2), CX + STORE8(X11, X12, (CX)) // p1 + +nqside: + TESTL $2, DX + JNZ ndone + + VPSUBW X10, X4, X11 + VPBLENDVB X15, X11, X4, X11 + LEAQ (AX)(R8*4), CX + STORE8(X11, X12, (CX)) // q0 + + // q1' from ((q2+q0+1)>>1 - q1 - delta) >> 1 + VPADDW X6, X4, X11 + VPBROADCASTW ONE, X12 + VPADDW X12, X11, X11 + VPSRAW $1, X11, X11 + VPSUBW X5, X11, X11 + VPSUBW X10, X11, X11 + VPSRAW $1, X11, X11 + VPMAXSW X7, X11, X11 + VPMINSW X14, X11, X11 + VPADDW X5, X11, X11 + VPBLENDVB X13, X11, X5, X11 + LEAQ (AX)(R8*4), CX + LEAQ (CX)(R8*1), CX + STORE8(X11, X12, (CX)) // q1 + +ndone: + VZEROUPPER + RET + +// TRANSPOSE8 turns eight rows of eight bytes in the low half of X0 through X7 +// into eight columns, two to a register, in X8 through X11. +#define TRANSPOSE8() \ + VPUNPCKLBW X1, X0, X8 \ + VPUNPCKLBW X3, X2, X9 \ + VPUNPCKLBW X5, X4, X10 \ + VPUNPCKLBW X7, X6, X11 \ + VPUNPCKLWD X9, X8, X12 \ + VPUNPCKHWD X9, X8, X13 \ + VPUNPCKLWD X11, X10, X14 \ + VPUNPCKHWD X11, X10, X15 \ + VPUNPCKLDQ X14, X12, X8 \ + VPUNPCKHDQ X14, X12, X9 \ + VPUNPCKLDQ X15, X13, X10 \ + VPUNPCKHDQ X15, X13, X11 + +// func turnIn8AVX2(dst *uint8, src *uint8, stride int) +TEXT ·turnIn8AVX2(SB), NOSPLIT, $0-24 + MOVQ dst+0(FP), DI + MOVQ src+8(FP), SI + MOVQ stride+16(FP), R8 + + MOVQ (SI), X0 + MOVQ (SI)(R8*1), X1 + LEAQ (SI)(R8*2), CX + MOVQ (CX), X2 + MOVQ (CX)(R8*1), X3 + LEAQ (CX)(R8*2), CX + MOVQ (CX), X4 + MOVQ (CX)(R8*1), X5 + LEAQ (CX)(R8*2), CX + MOVQ (CX), X6 + MOVQ (CX)(R8*1), X7 + + TRANSPOSE8() + + VMOVDQU X8, (DI) + VMOVDQU X9, 16(DI) + VMOVDQU X10, 32(DI) + VMOVDQU X11, 48(DI) + + VZEROUPPER + RET + +// func turnOut8AVX2(dst *uint8, stride int, src *uint8) +TEXT ·turnOut8AVX2(SB), NOSPLIT, $0-24 + MOVQ dst+0(FP), DI + MOVQ stride+8(FP), R8 + MOVQ src+16(FP), SI + + MOVQ (SI), X0 + MOVQ 8(SI), X1 + MOVQ 16(SI), X2 + MOVQ 24(SI), X3 + MOVQ 32(SI), X4 + MOVQ 40(SI), X5 + MOVQ 48(SI), X6 + MOVQ 56(SI), X7 + + TRANSPOSE8() + + MOVQ X8, (DI) + VPEXTRQ $1, X8, R9 + MOVQ R9, (DI)(R8*1) + LEAQ (DI)(R8*2), CX + MOVQ X9, (CX) + VPEXTRQ $1, X9, R9 + MOVQ R9, (CX)(R8*1) + LEAQ (CX)(R8*2), CX + MOVQ X10, (CX) + VPEXTRQ $1, X10, R9 + MOVQ R9, (CX)(R8*1) + LEAQ (CX)(R8*2), CX + MOVQ X11, (CX) + VPEXTRQ $1, X11, R9 + MOVQ R9, (CX)(R8*1) + + VZEROUPPER + RET diff --git a/third_party/h265/hevc/deblock_arm64.s b/third_party/h265/hevc/deblock_arm64.s new file mode 100644 index 00000000..139ee534 --- /dev/null +++ b/third_party/h265/hevc/deblock_arm64.s @@ -0,0 +1,404 @@ +//go:build arm64 && !noasm + +#include "textflag.h" + +// The two luma filters of 8.7.2.5.7 over the eight lines of one edge. A lane is +// one line, and the store saturates to the byte range the spec clips to anyway. + +// Every sum 8.7.2.5.7 forms stays inside a signed halfword: the widest is +// 2*p3 + 3*p2 + p1 + p0 + q0 + 4, which eight bit samples cap at 2044, and the +// normal filter's 9*(q0-p0) - 3*(q1-p1) at 3060. Halfwords are what the loads +// and the stores want anyway, which is a widening step each way fewer. +#define SMAX8H(Vd, Vn, Vm) WORD $(0x4e606400 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define SMIN8H(Vd, Vn, Vm) WORD $(0x4e606c00 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define SSHR8H(Vd, Vn, n) WORD $(0x4f000400 | ((32 - (n)) << 16) | ((Vn) << 5) | (Vd)) +#define SQXTUN8B(Vd, Vn) WORD $(0x2e212800 | ((Vn) << 5) | (Vd)) +#define ABS8H(Vd, Vn) WORD $(0x4e60b800 | ((Vn) << 5) | (Vd)) +#define MUL8H(Vd, Vn, Vm) WORD $(0x4e609c00 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define CMGT8H(Vd, Vn, Vm) WORD $(0x4e603400 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define BSL16B(Vd, Vn, Vm) WORD $(0x6e601c00 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define ADD8H(Vd, Vn, Vm) WORD $(0x4e608400 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define SUB8H(Vd, Vn, Vm) WORD $(0x6e608400 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define UXTL8H(Vd, Vn) WORD $(0x2f08a400 | ((Vn) << 5) | (Vd)) +#define DUP8H(Vd, Rn) WORD $(0x4e020c00 | ((Rn) << 5) | (Vd)) +#define EOR16B(Vd, Vn, Vm) WORD $(0x6e201c00 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define AND16B(Vd, Vn, Vm) WORD $(0x4e201c00 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define MOV16B(Vd, Vn) WORD $(0x4ea01c00 | ((Vn) << 16) | ((Vn) << 5) | (Vd)) + +// LOAD8 widens the eight bytes of one position into eight halfwords. +#define LOAD8(addr, Vd) \ + FMOVD (addr), F20 \ + UXTL8H(Vd, 20) + +// CLIPC puts v inside base-c .. base+c. +#define CLIPC(v, base, c, t0, t1) \ + SUB8H(t0, base, c) \ + ADD8H(t1, base, c) \ + SMAX8H(v, v, t0) \ + SMIN8H(v, v, t1) + +// STORE8 narrows eight halfwords to bytes and writes them. +#define STORE8(v, addr) \ + SQXTUN8B(19, v) \ + FMOVD F19, (addr) + +// TCV spreads the two thresholds over the lanes each governs. +#define TCV(Vd) \ + MOVD $0x0001000100010001, R5 \ + MUL R5, R2, R2 \ + MUL R5, R6, R6 \ + VMOV R2, Vd.D[0] \ + VMOV R6, Vd.D[1] + +// func deblockStrong8NEON(p *uint8, pitch int, tc0, tc1, flags int32) +TEXT ·deblockStrong8NEON(SB), NOSPLIT, $0-28 + MOVD p+0(FP), R0 + MOVD pitch+8(FP), R1 + MOVW tc0+16(FP), R2 + MOVW tc1+20(FP), R6 + MOVW flags+24(FP), R3 + + MOVD R0, R4 + LOAD8(R4, 0) + ADD R1, R4 + LOAD8(R4, 1) + ADD R1, R4 + LOAD8(R4, 2) + ADD R1, R4 + LOAD8(R4, 3) + ADD R1, R4 + LOAD8(R4, 4) + ADD R1, R4 + LOAD8(R4, 5) + ADD R1, R4 + LOAD8(R4, 6) + ADD R1, R4 + LOAD8(R4, 7) + + TCV(V8) + ADD8H(9, 8, 8) + + MOVD $4, R5 + DUP8H(10, 5) + MOVD $2, R5 + DUP8H(11, 5) + + // p0' from p2 + 2*p1 + 2*p0 + 2*q0 + q1 + 4 + ADD8H(12, 3, 2) + ADD8H(12, 12, 4) + ADD8H(12, 12, 12) + ADD8H(12, 12, 1) + ADD8H(12, 12, 5) + ADD8H(12, 12, 10) + SSHR8H(12, 12, 3) + CLIPC(12, 3, 9, 17, 18) + + // p1' from p2 + p1 + p0 + q0 + 2 + ADD8H(13, 2, 1) + ADD8H(13, 13, 3) + ADD8H(13, 13, 4) + ADD8H(13, 13, 11) + SSHR8H(13, 13, 2) + CLIPC(13, 2, 9, 17, 18) + + TSTW $1, R3 + BNE qside + + // p2' from 2*p3 + 3*p2 + p1 + p0 + q0 + 4 + ADD8H(14, 0, 0) + ADD8H(14, 14, 1) + ADD8H(14, 14, 1) + ADD8H(14, 14, 1) + ADD8H(14, 14, 2) + ADD8H(14, 14, 3) + ADD8H(14, 14, 4) + ADD8H(14, 14, 10) + SSHR8H(14, 14, 3) + CLIPC(14, 1, 9, 17, 18) + + MOVD R0, R4 + ADD R1, R4 + STORE8(14, R4) + ADD R1, R4 + STORE8(13, R4) + ADD R1, R4 + STORE8(12, R4) + +qside: + TSTW $2, R3 + BNE done + + // q0' from p1 + 2*p0 + 2*q0 + 2*q1 + q2 + 4 + ADD8H(12, 4, 3) + ADD8H(12, 12, 5) + ADD8H(12, 12, 12) + ADD8H(12, 12, 2) + ADD8H(12, 12, 6) + ADD8H(12, 12, 10) + SSHR8H(12, 12, 3) + CLIPC(12, 4, 9, 17, 18) + + // q1' from p0 + q0 + q1 + q2 + 2 + ADD8H(13, 4, 3) + ADD8H(13, 13, 5) + ADD8H(13, 13, 6) + ADD8H(13, 13, 11) + SSHR8H(13, 13, 2) + CLIPC(13, 5, 9, 17, 18) + + // q2' from p0 + q0 + q1 + 3*q2 + 2*q3 + 4 + ADD8H(14, 6, 6) + ADD8H(14, 14, 6) + ADD8H(14, 14, 7) + ADD8H(14, 14, 7) + ADD8H(14, 14, 3) + ADD8H(14, 14, 4) + ADD8H(14, 14, 5) + ADD8H(14, 14, 10) + SSHR8H(14, 14, 3) + CLIPC(14, 6, 9, 17, 18) + + MOVD R0, R4 + ADD R1, R4 + ADD R1, R4 + ADD R1, R4 + ADD R1, R4 + STORE8(12, R4) + ADD R1, R4 + STORE8(13, R4) + ADD R1, R4 + STORE8(14, R4) + +done: + RET + +// func deblockNormal8NEON(p *uint8, pitch int, tc0, tc1, nd, flags int32) +TEXT ·deblockNormal8NEON(SB), NOSPLIT, $0-32 + MOVD p+0(FP), R0 + MOVD pitch+8(FP), R1 + MOVW tc0+16(FP), R2 + MOVW tc1+20(FP), R6 + MOVW nd+24(FP), R7 + MOVW flags+28(FP), R3 + + MOVD R0, R4 + ADD R1, R4 + LOAD8(R4, 1) + ADD R1, R4 + LOAD8(R4, 2) + ADD R1, R4 + LOAD8(R4, 3) + ADD R1, R4 + LOAD8(R4, 4) + ADD R1, R4 + LOAD8(R4, 5) + ADD R1, R4 + LOAD8(R4, 6) + + TCV(V8) + + // delta = (9*(q0-p0) - 3*(q1-p1) + 8) >> 4 + SUB8H(12, 4, 3) + MOVD $9, R5 + DUP8H(15, 5) + MUL8H(12, 12, 15) + SUB8H(13, 5, 2) + MOVD $3, R5 + DUP8H(15, 5) + MUL8H(13, 13, 15) + SUB8H(12, 12, 13) + MOVD $8, R5 + DUP8H(15, 5) + ADD8H(12, 12, 15) + SSHR8H(12, 12, 4) + + // keep only the lanes 8.7.2.5.7 leaves inside ten times tc + ABS8H(21, 12) + MOVD $10, R5 + DUP8H(15, 5) + MUL8H(15, 8, 15) + CMGT8H(16, 15, 21) + + EOR16B(15, 15, 15) + SUB8H(17, 15, 8) + SMAX8H(12, 12, 17) + SMIN8H(12, 12, 8) + + SSHR8H(18, 8, 1) + EOR16B(15, 15, 15) + SUB8H(17, 15, 18) + + // the dEp and dEq of each half become the lanes their sample may move in + AND $1, R7, R5 + NEG R5, R5 + VMOV R5, V9.D[0] + LSR $2, R7, R5 + AND $1, R5, R5 + NEG R5, R5 + VMOV R5, V9.D[1] + AND16B(9, 9, 16) + + LSR $1, R7, R5 + AND $1, R5, R5 + NEG R5, R5 + VMOV R5, V10.D[0] + LSR $3, R7, R5 + AND $1, R5, R5 + NEG R5, R5 + VMOV R5, V10.D[1] + AND16B(10, 10, 16) + + TSTW $1, R3 + BNE nqside + + ADD8H(14, 3, 12) + MOV16B(22, 16) + BSL16B(22, 14, 3) + MOVD R0, R4 + ADD R1, R4 + ADD R1, R4 + ADD R1, R4 + STORE8(22, R4) + + // p1' from ((p2+p0+1)>>1 - p1 + delta) >> 1 + ADD8H(14, 1, 3) + MOVD $1, R5 + DUP8H(15, 5) + ADD8H(14, 14, 15) + SSHR8H(14, 14, 1) + SUB8H(14, 14, 2) + ADD8H(14, 14, 12) + SSHR8H(14, 14, 1) + SMAX8H(14, 14, 17) + SMIN8H(14, 14, 18) + ADD8H(14, 14, 2) + MOV16B(22, 9) + BSL16B(22, 14, 2) + MOVD R0, R4 + ADD R1, R4 + ADD R1, R4 + STORE8(22, R4) + +nqside: + TSTW $2, R3 + BNE ndone + + SUB8H(14, 4, 12) + MOV16B(22, 16) + BSL16B(22, 14, 4) + MOVD R0, R4 + ADD R1, R4 + ADD R1, R4 + ADD R1, R4 + ADD R1, R4 + STORE8(22, R4) + + // q1' from ((q2+q0+1)>>1 - q1 - delta) >> 1 + ADD8H(14, 6, 4) + MOVD $1, R5 + DUP8H(15, 5) + ADD8H(14, 14, 15) + SSHR8H(14, 14, 1) + SUB8H(14, 14, 5) + SUB8H(14, 14, 12) + SSHR8H(14, 14, 1) + SMAX8H(14, 14, 17) + SMIN8H(14, 14, 18) + ADD8H(14, 14, 5) + MOV16B(22, 10) + BSL16B(22, 14, 5) + MOVD R0, R4 + ADD R1, R4 + ADD R1, R4 + ADD R1, R4 + ADD R1, R4 + ADD R1, R4 + STORE8(22, R4) + +ndone: + RET + +// TRANSPOSE8 turns eight rows of eight bytes in the low half of V0 through V7 +// into eight columns, two to a register, in V8 through V11. +#define TRANSPOSE8() \ + VZIP1 V1.B16, V0.B16, V12.B16 \ + VZIP1 V3.B16, V2.B16, V13.B16 \ + VZIP1 V5.B16, V4.B16, V14.B16 \ + VZIP1 V7.B16, V6.B16, V15.B16 \ + VZIP1 V13.H8, V12.H8, V8.H8 \ + VZIP2 V13.H8, V12.H8, V9.H8 \ + VZIP1 V15.H8, V14.H8, V10.H8 \ + VZIP2 V15.H8, V14.H8, V11.H8 \ + VZIP1 V10.S4, V8.S4, V12.S4 \ + VZIP2 V10.S4, V8.S4, V13.S4 \ + VZIP1 V11.S4, V9.S4, V14.S4 \ + VZIP2 V11.S4, V9.S4, V15.S4 \ + VMOV V12.B16, V8.B16 \ + VMOV V13.B16, V9.B16 \ + VMOV V14.B16, V10.B16 \ + VMOV V15.B16, V11.B16 + +// func turnIn8NEON(dst *uint8, src *uint8, stride int) +TEXT ·turnIn8NEON(SB), NOSPLIT, $0-24 + MOVD dst+0(FP), R0 + MOVD src+8(FP), R1 + MOVD stride+16(FP), R2 + + VLD1 (R1), [V0.B8] + ADD R2, R1 + VLD1 (R1), [V1.B8] + ADD R2, R1 + VLD1 (R1), [V2.B8] + ADD R2, R1 + VLD1 (R1), [V3.B8] + ADD R2, R1 + VLD1 (R1), [V4.B8] + ADD R2, R1 + VLD1 (R1), [V5.B8] + ADD R2, R1 + VLD1 (R1), [V6.B8] + ADD R2, R1 + VLD1 (R1), [V7.B8] + + TRANSPOSE8() + + VST1 [V8.B16, V9.B16, V10.B16, V11.B16], (R0) + RET + +// func turnOut8NEON(dst *uint8, stride int, src *uint8) +TEXT ·turnOut8NEON(SB), NOSPLIT, $0-24 + MOVD dst+0(FP), R0 + MOVD stride+8(FP), R1 + MOVD src+16(FP), R2 + + VLD1.P 8(R2), [V0.B8] + VLD1.P 8(R2), [V1.B8] + VLD1.P 8(R2), [V2.B8] + VLD1.P 8(R2), [V3.B8] + VLD1.P 8(R2), [V4.B8] + VLD1.P 8(R2), [V5.B8] + VLD1.P 8(R2), [V6.B8] + VLD1 (R2), [V7.B8] + + TRANSPOSE8() + + VST1 [V8.B8], (R0) + VMOV V8.D[1], R3 + ADD R1, R0 + MOVD R3, (R0) + ADD R1, R0 + VST1 [V9.B8], (R0) + VMOV V9.D[1], R3 + ADD R1, R0 + MOVD R3, (R0) + ADD R1, R0 + VST1 [V10.B8], (R0) + VMOV V10.D[1], R3 + ADD R1, R0 + MOVD R3, (R0) + ADD R1, R0 + VST1 [V11.B8], (R0) + VMOV V11.D[1], R3 + ADD R1, R0 + MOVD R3, (R0) + RET diff --git a/third_party/h265/hevc/deblock_riscv64.s b/third_party/h265/hevc/deblock_riscv64.s new file mode 100644 index 00000000..7034b19c --- /dev/null +++ b/third_party/h265/hevc/deblock_riscv64.s @@ -0,0 +1,363 @@ +//go:build riscv64 && riscv64.rva23u64 && !noasm + +#include "textflag.h" + +// The two luma filters of 8.7.2.5.7 over the eight lines of one edge, with +// position i of line l at p[i*pitch+l]. + +// LOAD8 widens the eight bytes of one position into eight halfwords. +#define LOAD8(addr, t, Vd) \ + VLE8V (addr), t \ + VZEXTVF2 t, Vd + +// CLIPC puts v inside base-c .. base+c. +#define CLIPC(v, base, c, t0, t1) \ + VSUBVV c, base, t0 \ + VADDVV c, base, t1 \ + VMAXVV t0, v, v \ + VMINVV t1, v, v + +// CLAMP holds v to what a byte can carry. +#define CLAMP(v, hi) \ + VMAXVX X0, v, v \ + VMINVX hi, v, v + +// func deblockStrong8RVV(p *uint8, pitch int, tc0, tc1, flags int32) +TEXT ·deblockStrong8RVV(SB), NOSPLIT, $0-28 + MOV p+0(FP), X10 + MOV pitch+8(FP), X11 + MOVW tc0+16(FP), X12 + MOVW tc1+20(FP), X13 + MOVW flags+24(FP), X14 + + MOV $255, X17 + + VSETIVLI $8, E16, M1, TA, MA, X0 + + MOV X10, X15 + LOAD8(X15, V16, V1) + ADD X11, X15 + LOAD8(X15, V16, V2) + ADD X11, X15 + LOAD8(X15, V16, V3) + ADD X11, X15 + LOAD8(X15, V16, V4) + ADD X11, X15 + LOAD8(X15, V16, V5) + ADD X11, X15 + LOAD8(X15, V16, V6) + ADD X11, X15 + LOAD8(X15, V16, V7) + ADD X11, X15 + LOAD8(X15, V16, V8) + + // the first four lines take tc0 and the rest tc1 + SUB X12, X13, X16 + VIDV V9 + VSRLVI $2, V9, V9 + VMULVX X16, V9, V9 + VADDVX X12, V9, V9 + VADDVV V9, V9, V10 + + // p0' from p2 + 2*p1 + 2*p0 + 2*q0 + q1 + 4 + VADDVV V3, V4, V11 + VADDVV V5, V11, V11 + VADDVV V11, V11, V11 + VADDVV V2, V11, V11 + VADDVV V6, V11, V11 + VADDVI $4, V11, V11 + VSRAVI $3, V11, V11 + CLIPC(V11, V4, V10, V15, V16) + CLAMP(V11, X17) + + // p1' from p2 + p1 + p0 + q0 + 2 + VADDVV V2, V3, V12 + VADDVV V4, V12, V12 + VADDVV V5, V12, V12 + VADDVI $2, V12, V12 + VSRAVI $2, V12, V12 + CLIPC(V12, V3, V10, V15, V16) + CLAMP(V12, X17) + + AND $1, X14, X16 + BNE X0, X16, qside + + // p2' from 2*p3 + 3*p2 + p1 + p0 + q0 + 4 + VADDVV V1, V1, V13 + VADDVV V2, V13, V13 + VADDVV V2, V13, V13 + VADDVV V2, V13, V13 + VADDVV V3, V13, V13 + VADDVV V4, V13, V13 + VADDVV V5, V13, V13 + VADDVI $4, V13, V13 + VSRAVI $3, V13, V13 + CLIPC(V13, V2, V10, V15, V16) + CLAMP(V13, X17) + + MOV X10, X15 + ADD X11, X15 + + VSETIVLI $8, E8, MF2, TA, MA, X0 + VNSRLWI $0, V13, V14 + VSE8V V14, (X15) + ADD X11, X15 + VNSRLWI $0, V12, V14 + VSE8V V14, (X15) + ADD X11, X15 + VNSRLWI $0, V11, V14 + VSE8V V14, (X15) + VSETIVLI $8, E16, M1, TA, MA, X0 + +qside: + AND $2, X14, X16 + BNE X0, X16, done + + // q0' from p1 + 2*p0 + 2*q0 + 2*q1 + q2 + 4 + VADDVV V4, V5, V11 + VADDVV V6, V11, V11 + VADDVV V11, V11, V11 + VADDVV V3, V11, V11 + VADDVV V7, V11, V11 + VADDVI $4, V11, V11 + VSRAVI $3, V11, V11 + CLIPC(V11, V5, V10, V15, V16) + CLAMP(V11, X17) + + // q1' from p0 + q0 + q1 + q2 + 2 + VADDVV V4, V5, V12 + VADDVV V6, V12, V12 + VADDVV V7, V12, V12 + VADDVI $2, V12, V12 + VSRAVI $2, V12, V12 + CLIPC(V12, V6, V10, V15, V16) + CLAMP(V12, X17) + + // q2' from p0 + q0 + q1 + 3*q2 + 2*q3 + 4 + VADDVV V7, V7, V13 + VADDVV V7, V13, V13 + VADDVV V8, V13, V13 + VADDVV V8, V13, V13 + VADDVV V4, V13, V13 + VADDVV V5, V13, V13 + VADDVV V6, V13, V13 + VADDVI $4, V13, V13 + VSRAVI $3, V13, V13 + CLIPC(V13, V7, V10, V15, V16) + CLAMP(V13, X17) + + MOV X10, X15 + ADD X11, X15 + ADD X11, X15 + ADD X11, X15 + ADD X11, X15 + + VSETIVLI $8, E8, MF2, TA, MA, X0 + VNSRLWI $0, V11, V14 + VSE8V V14, (X15) + ADD X11, X15 + VNSRLWI $0, V12, V14 + VSE8V V14, (X15) + ADD X11, X15 + VNSRLWI $0, V13, V14 + VSE8V V14, (X15) + +done: + RET + +// func deblockNormal8RVV(p *uint8, pitch int, tc0, tc1, nd, flags int32) +TEXT ·deblockNormal8RVV(SB), NOSPLIT, $0-32 + MOV p+0(FP), X10 + MOV pitch+8(FP), X11 + MOVW tc0+16(FP), X12 + MOVW tc1+20(FP), X13 + MOVW nd+24(FP), X18 + MOVW flags+28(FP), X14 + + MOV $255, X17 + + VSETIVLI $8, E16, M1, TA, MA, X0 + + MOV X10, X15 + ADD X11, X15 + LOAD8(X15, V16, V2) + ADD X11, X15 + LOAD8(X15, V16, V3) + ADD X11, X15 + LOAD8(X15, V16, V4) + ADD X11, X15 + LOAD8(X15, V16, V5) + ADD X11, X15 + LOAD8(X15, V16, V6) + ADD X11, X15 + LOAD8(X15, V16, V7) + + SUB X12, X13, X16 + VIDV V9 + VSRLVI $2, V9, V9 + VMULVX X16, V9, V9 + VADDVX X12, V9, V9 + + // delta = (9*(q0-p0) - 3*(q1-p1) + 8) >> 4 + VSUBVV V4, V5, V11 + MOV $9, X16 + VMULVX X16, V11, V11 + VSUBVV V3, V6, V12 + MOV $3, X16 + VMULVX X16, V12, V12 + VSUBVV V12, V11, V11 + VADDVI $8, V11, V11 + VSRAVI $4, V11, V11 + + // keep only the lanes 8.7.2.5.7 leaves inside ten times tc + VRSUBVX X0, V11, V13 + VMAXVV V13, V11, V13 + MOV $10, X16 + VMULVX X16, V9, V14 + VMSLTVV V14, V13, V19 + + VRSUBVX X0, V9, V15 + VMAXVV V15, V11, V11 + VMINVV V9, V11, V11 + + // the dEp and dEq of each half become the lanes their sample may move in + AND $1, X18, X19 + SRL $2, X18, X20 + AND $1, X20, X20 + SUB X19, X20, X21 + VIDV V14 + VSRLVI $2, V14, V14 + VMULVX X21, V14, V14 + VADDVX X19, V14, V14 + VMSGTVX X0, V14, V20 + + SRL $1, X18, X19 + AND $1, X19, X19 + SRL $3, X18, X20 + AND $1, X20, X20 + SUB X19, X20, X21 + VIDV V14 + VSRLVI $2, V14, V14 + VMULVX X21, V14, V14 + VADDVX X19, V14, V14 + VMSGTVX X0, V14, V21 + + VSRAVI $1, V9, V15 + VRSUBVX X0, V15, V16 + + AND $1, X14, X16 + BNE X0, X16, nqside + + VMV1RV V19, V0 + VADDVV V11, V4, V13 + VMERGEVVM V13, V4, V0, V13 + CLAMP(V13, X17) + + // p1' from ((p2+p0+1)>>1 - p1 + delta) >> 1 + VMANDMM V19, V20, V0 + VADDVV V2, V4, V14 + VADDVI $1, V14, V14 + VSRAVI $1, V14, V14 + VSUBVV V3, V14, V14 + VADDVV V11, V14, V14 + VSRAVI $1, V14, V14 + VMAXVV V16, V14, V14 + VMINVV V15, V14, V14 + VADDVV V3, V14, V14 + VMERGEVVM V14, V3, V0, V14 + CLAMP(V14, X17) + + MOV X10, X15 + ADD X11, X15 + ADD X11, X15 + + VSETIVLI $8, E8, MF2, TA, MA, X0 + VNSRLWI $0, V14, V17 + VSE8V V17, (X15) + ADD X11, X15 + VNSRLWI $0, V13, V17 + VSE8V V17, (X15) + VSETIVLI $8, E16, M1, TA, MA, X0 + +nqside: + AND $2, X14, X16 + BNE X0, X16, ndone + + VMV1RV V19, V0 + VSUBVV V11, V5, V13 + VMERGEVVM V13, V5, V0, V13 + CLAMP(V13, X17) + + // q1' from ((q2+q0+1)>>1 - q1 - delta) >> 1 + VMANDMM V19, V21, V0 + VADDVV V7, V5, V14 + VADDVI $1, V14, V14 + VSRAVI $1, V14, V14 + VSUBVV V6, V14, V14 + VSUBVV V11, V14, V14 + VSRAVI $1, V14, V14 + VMAXVV V16, V14, V14 + VMINVV V15, V14, V14 + VADDVV V6, V14, V14 + VMERGEVVM V14, V6, V0, V14 + CLAMP(V14, X17) + + MOV X10, X15 + ADD X11, X15 + ADD X11, X15 + ADD X11, X15 + ADD X11, X15 + + VSETIVLI $8, E8, MF2, TA, MA, X0 + VNSRLWI $0, V13, V17 + VSE8V V17, (X15) + ADD X11, X15 + VNSRLWI $0, V14, V17 + VSE8V V17, (X15) + +ndone: + RET + +// func turnIn8RVV(dst *uint8, src *uint8, stride int) +TEXT ·turnIn8RVV(SB), NOSPLIT, $0-24 + MOV dst+0(FP), X10 + MOV src+8(FP), X11 + MOV stride+16(FP), X12 + + MOV $8, X13 + MOV $8, X14 + + VSETIVLI $8, E8, M1, TA, MA, X0 + +turnin: + VLE8V (X11), V1 + VSSE8V V1, X13, (X10) + + ADD X12, X11 + ADD $1, X10 + ADD $-1, X14 + BNE X0, X14, turnin + + RET + +// func turnOut8RVV(dst *uint8, stride int, src *uint8) +TEXT ·turnOut8RVV(SB), NOSPLIT, $0-24 + MOV dst+0(FP), X10 + MOV stride+8(FP), X11 + MOV src+16(FP), X12 + + MOV $8, X13 + MOV $8, X14 + + VSETIVLI $8, E8, M1, TA, MA, X0 + +turnout: + VLSE8V (X12), X13, V1 + VSE8V V1, (X10) + + ADD X11, X10 + ADD $1, X12 + ADD $-1, X14 + BNE X0, X14, turnout + + RET diff --git a/third_party/h265/hevc/deblockenc.go b/third_party/h265/hevc/deblockenc.go new file mode 100644 index 00000000..e20a6bb9 --- /dev/null +++ b/third_party/h265/hevc/deblockenc.go @@ -0,0 +1,112 @@ +package hevc + +// edgeV and edgeH mark a 4x4 block as the first column, or the first row, of a +// transform block, which a coding unit's own edges are always among. +const ( + edgeV = 1 << iota + edgeH +) + +// markTU records where a transform block begins, at the granularity 8.7.2 +// reads its edges in. +func (e *intraEncoder[P]) markTU(x, y, n int) { + bw := e.width / 4 + + for j := range n / 4 { + row := e.edges[(y/4+j)*bw+x/4:][:n/4] + + if j == 0 { + for i := range row { + row[i] |= edgeH + } + } + + row[0] |= edgeV + } +} + +// deblockRecon is 8.7.2 over the reconstruction: every vertical edge and then +// every horizontal one, on the eight-sample grid. It runs once the picture is +// whole, which 8.4.4.2.2 predicts from the samples before. +func (e *intraEncoder[P]) deblockRecon() { + var hdr sliceHeader + + for _, vertical := range []bool{true, false} { + for y := 0; y < e.height; y += 8 { + if !vertical && y == 0 { + continue + } + + for x := 0; x < e.width; x += 8 { + if vertical && x == 0 { + continue + } + + e.deblockEdge(x, y, vertical, &hdr) + } + } + } +} + +// deblockEdge filters the two four-sample segments of one edge. Every coding +// unit here is intra, so 8.7.2.4 gives each of them strength two. +func (e *intraEncoder[P]) deblockEdge(x, y int, vertical bool, hdr *sliceHeader) { + bw := e.width / 4 + sw, sh := e.shiftW, e.shiftH + + step, line := 1, e.width + if !vertical { + step, line = e.width, 1 + } + + base := y*e.width + x + + beta, tc := betaTc(int32(e.qp), 2, hdr, e.bitDepth) + + var plan [2]lumaPlan + + for k := 0; k < 8; k += 4 { + qx, qy := x, y+k + if !vertical { + qx, qy = x+k, y + } + + if qx >= e.width || qy >= e.height { + continue + } + + mask := uint8(edgeV) + if !vertical { + mask = edgeH + } + + if e.edges[qy/4*bw+qx/4]&mask == 0 { + continue + } + + if beta != 0 && tc != 0 { + plan[k>>2] = deblockLumaPlan(e.recon[0], base+k*line, line, step, beta, tc) + } + + if e.s.chromaArrayType() == 0 { + continue + } + + if (vertical && x&(8<> sh + if !vertical { + n = 4 >> sw + } + + _, tc := betaTc(int32(e.qpDeblockC), 2, hdr, e.bitDepth) + + deblockChromaPair(e.recon[1], e.recon[2], e.strideC, qx>>sw, qy>>sh, n, vertical, + tc, tc, e.bitDepth, false, false) + } + + deblockLumaEdge(e.recon[0], base, line, step, plan, e.bitDepth, + [2]bool{}, [2]bool{}) +} diff --git a/third_party/h265/hevc/decoder.go b/third_party/h265/hevc/decoder.go new file mode 100644 index 00000000..a4cc8957 --- /dev/null +++ b/third_party/h265/hevc/decoder.go @@ -0,0 +1,605 @@ +/* +Package hevc decodes an HEVC (H.265) bitstream, and encodes an intra-only one. + +[Decoder.DecodeNAL] takes one NAL unit at a time and returns the pictures that +are ready, which is not the same as the pictures it just decoded: a stream that +codes out of display order is held back by sps_max_num_reorder_pics and released +by picture order count. [Decoder.Flush] drains what is left at the end. + + var d hevc.Decoder + + for _, nal := range hevc.SplitAnnexB(data) { + pics, err := d.DecodeNAL(nal) + if err != nil { + return err + } + + for _, p := range pics { + p.Release() + } + } + +[SplitAnnexB] frames a start-code delimited stream and [SplitHVCC] a +length-prefixed one. + +# Pictures + +A [Picture] holds its planes as either 8-bit or 16-bit samples, in Y/Cb/Cr or +Y16/Cb16/Cr16, chosen by the sequence rather than by the plane: both are 16-bit +if either [Picture.BitDepth] or [Picture.BitDepthC] exceeds 8, which 7.4.3.2 +allows to differ. Width and Height stay as decoded because prediction reads the +whole plane; CropX, CropY, CropW and CropH are what a caller should display. + +[Picture.Release] hands the planes back to the decoder to be reused by a later +picture. It is optional, since a picture that is never released is collected +like any other value, but it keeps a long sequence from allocating a fresh set +of planes per frame. Reading the planes afterwards is a mistake; releasing twice +is not. + +# Threading + +[Decoder.Threads] bounds the goroutines a picture may be spread over, across +wavefront rows and the loop filter row bands. Zero means GOMAXPROCS and one +decodes serially. A picture without entropy_coding_sync_enabled_flag, or one a +single block wide, is serial whatever the bound. + +# Encoding + +[Encoder] writes self-contained intra IDR access units from 8-bit 4:2:0 frames +whose dimensions are non-zero and even. A picture that does not fill the coding +grid is padded to it and cropped back by a conformance window. Every frame is +coded on its own, so [Encoder.Flush] never has anything left to return. + + enc, err := hevc.NewEncoder(hevc.EncoderOptions{Width: 1920, Height: 1080, QP: 26}) + if err != nil { + return err + } + + nals, err := enc.Encode(hevc.Frame{Y: y, Cb: cb, Cr: cr, StrideY: ys, StrideC: cs}) + +[MarshalAnnexB] frames the result for a file and [MarshalNAL] writes one unit +for a length-prefixed container, whose configuration record repeats the +[ProfileTierLevel] of the sequence parameter set. + +A picture is one slice of 64x64 coding tree blocks, coded as 32x32 units and as +16x16 ones along an edge a 32x32 does not fit. Prediction searches all 35 intra +modes and the 8x8 transform blocks choose between one transform and four. +[EncoderOptions.Lossless] codes the samples as PCM instead and ignores QP. + +# Errors + +[ErrInvalid] means the bitstream is malformed. [ErrUnsupported] means it is +valid and declares a coding tool this decoder does not implement, which is +refused rather than decoded into a picture that merely looks plausible. Those +tools are cross-component prediction, implicit and explicit RDPCM, and CABAC +bypass alignment; everything else in the range extensions is applied. +*/ +package hevc + +// Decoder decodes a HEVC bitstream one NAL unit at a time. +type Decoder struct { + vps map[uint8]*vps + sps map[uint32]*sps + pps map[uint32]*pps + + cur *Picture + curOut bool + ctu *ctuDecoder + ctuPrev *ctuDecoder + prevSlic *sliceHeader + + dpb []dpbPicture + pool picPool + threads int + frameSizeLimit int + poc pocState + curRPS refPicSet + + // 8.1.3: leading pictures associated with an intra random access point + // that starts decoding reference pictures that were never decoded, and + // are discarded rather than decoded. + seenPicture bool + skipRASL bool + + maxReorder int + maxLatency int + maxDecPicBuf int + budget *DecodeBudget +} + +// FrameSizeLimit refuses a sequence whose pictures are larger than n samples, +// with ErrUnsupported. Zero, the default, accepts anything the level allows. +func (d *Decoder) FrameSizeLimit(n int) { d.frameSizeLimit = n } + +// DecodeNAL consumes one NAL unit and returns whatever pictures that completes, +// in output order. Reordering means a picture may surface several NAL units +// after the one that finished it. +func (d *Decoder) DecodeNAL(nal NALUnit) ([]*Picture, error) { + if d.sps == nil { + d.vps = make(map[uint8]*vps) + d.sps = make(map[uint32]*sps) + d.pps = make(map[uint32]*pps) + } + + switch nal.Type { + case NALVPS: + v, err := parseVPS(nal.RBSP) + if err != nil { + return nil, err + } + + d.vps[v.id] = v + + return nil, nil + + case NALSPS: + s, err := parseSPS(nal.RBSP) + if err != nil { + return nil, err + } + + d.sps[s.id] = s + + return nil, nil + + case NALPPS: + p, err := parsePPS(nal.RBSP) + if err != nil { + return nil, err + } + + d.pps[p.id] = p + + return nil, nil + } + + if nal.Type == NALEOS || nal.Type == NALEOB { + d.seenPicture = false + + return nil, nil + } + + if !nal.Type.IsVCL() { + return nil, nil + } + + if nal.Type.IsIRAP() { + d.skipRASL = !d.seenPicture || nal.Type.IsIDR() || + (nal.Type >= NALBlaWLP && nal.Type <= NALBlaNLP) + d.seenPicture = true + } + + if d.skipRASL && (nal.Type == NALRaslN || nal.Type == NALRaslR) { + return nil, nil + } + + d.seenPicture = true + + return d.decodeSlice(nal) +} + +// Flush ends the sequence and returns every picture still held back for +// reordering, in output order. +func (d *Decoder) Flush() []*Picture { + out := d.finishPicture() + + for { + p := d.dpbBump() + if p == nil { + break + } + + out = append(out, p) + } + + d.cur, d.ctu, d.prevSlic = nil, nil, nil + + for i := range d.dpb { + d.dpb[i].pic.release() + } + + clear(d.dpb) + d.dpb = d.dpb[:0] + + return out +} + +// finishPicture runs the loop filters over the picture the decoder has been +// filling, files it in the buffer and applies the additional bumping of +// C.5.2.3. +func (d *Decoder) finishPicture() []*Picture { + if d.cur == nil { + return nil + } + + if d.ctu != nil { + d.ctu.storeColMotion() + d.ctu.deblock() + d.ctu.applySAO() + } + + d.dpbStore(d.cur, d.curOut) + + // ctu is the signal that a picture is in progress; ctuPrev keeps its + // buffers and its scan tables for the next one. + d.cur, d.ctu = nil, nil + + return d.dpbDrain(false) +} + +func (d *Decoder) decodeSlice(nal NALUnit) ([]*Picture, error) { + p, first, err := d.ppsForSlice(nal) + if err != nil { + return nil, err + } + + s, ok := d.sps[p.spsID] + if !ok { + return nil, ErrInvalid + } + + // 7.4.3.2.1 activates one pair of parameter sets for a whole picture, so a + // set re-sent between its slice segments describes the next one. + if !first && d.ctu != nil { + if p.id != d.ctu.p.id { + return nil, ErrInvalid + } + + s, p = d.ctu.s, d.ctu.p + } + + if n := d.frameSizeLimit; n > 0 && + int(s.picWidthInLumaSamples)*int(s.picHeightInLumaSamples) > n { + return nil, ErrUnsupported + } + + sh, err := parseSliceHeader(nal.RBSP, nal.Type, s, p) + if err != nil { + return nil, err + } + + if sh.dependentSliceSegment { + if d.prevSlic == nil { + return nil, ErrInvalid + } + + sh.inherit(d.prevSlic) + } else { + indep := *sh + d.prevSlic = &indep + } + + if err := p.resolveTileGeometry(s); err != nil { + return nil, err + } + + var done []*Picture + + if sh.firstSliceSegmentInPic { + if !d.budget.take(int64(s.picWidthInLumaSamples) * int64(s.picHeightInLumaSamples)) { + return nil, ErrUnsupported + } + prior := d.cur != nil + + done = append(done, d.finishPicture()...) + + d.maxReorder = int(s.maxNumReorderPics) + d.maxLatency = int(s.maxLatencyIncrease) + d.maxDecPicBuf = int(s.maxDecPicBuffering) + 1 + + // 8.1.3 and 8.3.1: only a random access point that starts decoding + // restarts the count; one in mid-stream keeps it. + noRaslOutput := nal.Type.IsIRAP() && d.skipRASL + + if noRaslOutput { + d.poc = pocState{} + } + + poc := derivePOC(&d.poc, nal.Type, int32(sh.picOrderCntLsb), + s.log2MaxPocLsb, noRaslOutput) + d.poc.update(nal.Type, nal.TemporalID, poc, int32(sh.picOrderCntLsb), s.log2MaxPocLsb) + + rps := deriveRefPicSet(poc, s.log2MaxPocLsb, &sh.stRPS, &sh.ltRPS, sh.ltRPS.numSps) + d.resolveLongTerm(&rps, s.log2MaxPocLsb) + + // C.5.2.2: a random access point that starts decoding either discards + // the buffer or releases all of it, and a clean one always discards. + // Otherwise the reference picture set decides what stays. + switch { + case noRaslOutput && prior: + if !sh.noOutputOfPriorPics && nal.Type != NALCra { + for { + q := d.dpbBump() + if q == nil { + break + } + + done = append(done, q) + } + } + + d.dpb = nil + default: + d.dpbMark(&rps) + d.dpbRemoveUnused() + done = append(done, d.dpbDrain(true)...) + } + + if sh.sliceType != sliceI { + d.generateUnavailable(&rps, s) + } + + d.curRPS = rps + + d.cur = newPicture(&d.pool, s) + d.cur.POC = int(poc) + d.curOut = sh.picOutputFlag + d.ctu = newCTUDecoder(d.ctuPrev, s, p, sh, d.cur) + d.ctu.threads = d.waveThreads() + d.ctuPrev = d.ctu + d.ctu.poc = poc + } + + if d.cur == nil || d.ctu == nil { + return nil, ErrInvalid + } + + d.ctu.sh = sh + + // 7.4.7.1 signals the active reference counts and the list modification in + // every slice header, so the lists belong to the slice, not the picture. + d.buildRefLists(sh) + + if err := d.ctu.decodeSliceData(nal, sh); err != nil { + return nil, err + } + + return done, nil +} + +// ppsForSlice reads far enough into a slice segment header to find the picture +// parameter set it names, and whether it starts a picture. +func (d *Decoder) ppsForSlice(nal NALUnit) (*pps, bool, error) { + var c getBits + c.init(nal.RBSP) + + first := c.bit() != 0 + + if nal.Type >= NALBlaWLP && nal.Type <= 23 { + c.bit() + } + + id := c.ue() + if c.err { + return nil, false, ErrInvalid + } + + p, ok := d.pps[id] + if !ok { + return nil, false, ErrInvalid + } + + return p, first, nil +} + +// decodeSliceData is 7.3.8.1. Tiles and wavefronts split the slice segment into +// substreams that each restart the arithmetic decoder at an entry point. +func (d *ctuDecoder) decodeSliceData(nal NALUnit, sh *sliceHeader) error { + w := int(d.s.picWidthInCtbs) + total := w * int(d.s.picHeightInCtbs) + + starts := d.substreamStarts(nal, sh) + + d.sliceAddrRs = int(sh.sliceSegmentAddress) + if sh.dependentSliceSegment { + d.sliceAddrRs = d.depSliceAddrRs + } else { + d.depSliceAddrRs = int(sh.sliceSegmentAddress) + } + + d.simpleAvail = d.sliceAddrRs == 0 && !d.p.tilesEnabled + + sub := 0 + + start := int(d.rsToTs[sh.sliceSegmentAddress]) + tileStart := start == 0 || d.tileID[start] != d.tileID[start-1] + + if err := d.startSubstream(nal, sh, starts, sub, tileStart); err != nil { + return err + } + + wpp := d.p.entropyCodingSync + + d.sliceLF[d.sliceAddrRs] = sh.loopFilterAcross + + d.slices = append(d.slices, sh) + cur := int32(len(d.slices) - 1) + + if n := d.waveWorkers(sh, starts, wpp); n > 1 { + return d.decodeWavefront(nal, sh, starts, cur, n) + } + + for ts := start; ts < total; ts++ { + rs := int(d.tsToRs[ts]) + + d.ctbSliceAddr[rs] = int32(d.sliceAddrRs) + d.ctbSlice[rs] = cur + x := rs % w << d.s.ctbLog2SizeY + y := rs / w << d.s.ctbLog2SizeY + + if wpp && rs%w == 0 { + // 9.3.1 syncs from the block above-right, but only when 6.4.1 + // makes it available. A new slice starting on a row boundary has + // to initialise instead, and so does every row of a picture one + // block wide, which never has that neighbour at all. + top := rs - w + 1 + availT := w >= 2 && rs >= w && top >= d.sliceAddrRs && + d.tileID[d.rsToTs[rs]] == d.tileID[d.rsToTs[top]] + + oneWide := w < 2 && rs > 0 + + switch { + case availT && d.hasSaved: + d.c.state = d.saved + case ts > start || oneWide: + d.c.initContexts(sh.qpY, sh.sliceType, sh.cabacInit) + } + + d.qpYPrev = sh.qpY + } + + if err := d.codingTreeUnit(x, y); err != nil { + return err + } + + if wpp && rs%w == 1 { + d.saved = d.c.state + d.hasSaved = true + } + + if d.c.decodeTerminate() != 0 { + if d.p.dependentSliceSegmentsEnabled { + d.depSaved = d.c.state + d.hasDepSaved = true + } + + return nil + } + + endOfRow := wpp && rs%w == w-1 + endOfTile := ts+1 < total && d.tileID[ts+1] != d.tileID[ts] + + if endOfRow || endOfTile { + if d.c.decodeTerminate() == 0 { + return ErrInvalid + } + + sub++ + + if err := d.startSubstream(nal, sh, starts, sub, false); err != nil { + return err + } + + if endOfTile { + d.c.initContexts(sh.qpY, sh.sliceType, sh.cabacInit) + d.hasSaved = false + } + + d.qpYPrev = sh.qpY + } + } + + return nil +} + +// substreamStarts converts the entry point offsets, which count bytes of the +// NAL payload, into indices into the RBSP. +func (d *ctuDecoder) substreamStarts(nal NALUnit, sh *sliceHeader) []int { + starts := make([]int, 0, len(sh.entryPointOffsets)+1) + starts = append(starts, sh.dataOffset) + + base := nal.NALOffset(sh.dataOffset) + + for _, off := range sh.entryPointOffsets { + starts = append(starts, nal.RBSPOffset(base+int(off))) + } + + return starts +} + +// startSubstream is 9.3.1. A dependent slice segment carries on with the +// contexts the previous segment ended with, unless it opens a tile, which +// always initialises them. +func (d *ctuDecoder) startSubstream(nal NALUnit, sh *sliceHeader, starts []int, i int, + tileStart bool, +) error { + if i >= len(starts) { + return ErrInvalid + } + + if err := d.c.init(nal.RBSP, starts[i]); err != nil { + return err + } + + // 8.6.1 restarts qPY_PREV at a slice or a tile, so a dependent segment + // carrying on inside a tile keeps the quantisation parameter it inherited. + carry := i == 0 && sh.dependentSliceSegment && d.hasDepSaved && !tileStart + + if i == 0 && !carry { + d.c.initContexts(sh.qpY, sh.sliceType, sh.cabacInit) + } + + if carry { + d.c.state = d.depSaved + + return nil + } + + d.qpYCur = sh.qpY + d.qpYPrev = sh.qpY + + return nil +} + +// codingTreeUnit is 7.3.8.2. +func (d *ctuDecoder) codingTreeUnit(x, y int) error { + if d.sh.saoLuma || d.sh.saoChroma { + d.parseSAO(x, y) + } + + return d.codingQuadtree(x, y, int(d.s.ctbLog2SizeY), 0) +} + +func (d *Decoder) buildRefLists(sh *sliceHeader) { + for l := range 2 { + d.ctu.refPOC[l], d.ctu.refPics[l], d.ctu.refLong[l] = nil, nil, nil + + if sh.sliceType == sliceI || (l == 1 && sh.sliceType != sliceB) { + continue + } + + active := int(sh.numRefIdxL0Active) + entries := sh.listModification.listEntryL0 + modify := sh.listModification.flagL0 + + if l == 1 { + active = int(sh.numRefIdxL1Active) + entries = sh.listModification.listEntryL1 + modify = sh.listModification.flagL1 + } + + pocs := buildRefPicList(&d.curRPS, active, modify, entries, l == 1) + + d.ctu.refPOC[l] = pocs + d.ctu.refPics[l] = make([]*Picture, len(pocs)) + d.ctu.refLong[l] = make([]bool, len(pocs)) + + for i, p := range pocs { + d.ctu.refPics[l][i] = d.dpbFind(p) + d.ctu.refLong[l][i] = d.dpbLongTerm(p) + } + } + + d.ctu.noBackwardPred = true + + for l := range 2 { + for _, p := range d.ctu.refPOC[l] { + if p > int32(d.ctu.poc) { + d.ctu.noBackwardPred = false + } + } + } + + d.ctu.colPic = nil + + if sh.temporalMvp { + l := 1 + if sh.collocatedFromL0 { + l = 0 + } + + if int(sh.collocatedRefIdx) < len(d.ctu.refPics[l]) { + d.ctu.colPic = d.ctu.refPics[l][sh.collocatedRefIdx] + } + } +} diff --git a/third_party/h265/hevc/dequant_amd64.s b/third_party/h265/hevc/dequant_amd64.s new file mode 100644 index 00000000..e3b0afdd --- /dev/null +++ b/third_party/h265/hevc/dequant_amd64.s @@ -0,0 +1,132 @@ +//go:build amd64 && !noasm + +#include "textflag.h" + +// func dequant32AVX2(coef *int32, m *uint8, n int, ls, rnd, sh, lo, hi int32) +// +// 8.6.3 once the left shift has been folded into the right one, so the product +// fits 32 bits. m may be nil, which means a flat matrix of sixteen. n is the +// coefficient count, always a multiple of eight. +TEXT ·dequant32AVX2(SB), NOSPLIT, $0-44 + MOVQ coef+0(FP), DI + MOVQ m+8(FP), SI + MOVQ n+16(FP), CX + + MOVL ls+24(FP), AX + MOVQ AX, X1 + VPBROADCASTD X1, Y1 + MOVL rnd+28(FP), AX + MOVQ AX, X2 + VPBROADCASTD X2, Y2 + MOVL lo+36(FP), AX + MOVQ AX, X4 + VPBROADCASTD X4, Y4 + MOVL hi+40(FP), AX + MOVQ AX, X5 + VPBROADCASTD X5, Y5 + + MOVL sh+32(FP), AX + MOVQ AX, X3 + + MOVL $16, AX + MOVQ AX, X6 + VPBROADCASTD X6, Y6 + + TESTQ SI, SI + JZ flat + +scaled: + VMOVDQU (DI), Y0 + VPMOVZXBD (SI), Y7 + VPMULLD Y7, Y0, Y0 + VPMULLD Y1, Y0, Y0 + VPADDD Y2, Y0, Y0 + VPSRAD X3, Y0, Y0 + VPMAXSD Y4, Y0, Y0 + VPMINSD Y5, Y0, Y0 + VMOVDQU Y0, (DI) + + ADDQ $32, DI + ADDQ $8, SI + SUBQ $8, CX + JNZ scaled + + VZEROUPPER + RET + +flat: + VMOVDQU (DI), Y0 + VPMULLD Y6, Y0, Y0 + VPMULLD Y1, Y0, Y0 + VPADDD Y2, Y0, Y0 + VPSRAD X3, Y0, Y0 + VPMAXSD Y4, Y0, Y0 + VPMINSD Y5, Y0, Y0 + VMOVDQU Y0, (DI) + + ADDQ $32, DI + SUBQ $8, CX + JNZ flat + + VZEROUPPER + RET + +// dequant32AVX512 is dequant32AVX2 over sixteen coefficients at a time. +TEXT ·dequant32AVX512(SB), NOSPLIT, $0-44 + MOVQ coef+0(FP), DI + MOVQ m+8(FP), SI + MOVQ n+16(FP), CX + + MOVL ls+24(FP), AX + VPBROADCASTD AX, Z1 + MOVL rnd+28(FP), AX + VPBROADCASTD AX, Z2 + MOVL lo+36(FP), AX + VPBROADCASTD AX, Z4 + MOVL hi+40(FP), AX + VPBROADCASTD AX, Z5 + + MOVL sh+32(FP), AX + MOVQ AX, X3 + + MOVL $16, AX + VPBROADCASTD AX, Z6 + + TESTQ SI, SI + JZ flat512 + +scaled512: + VMOVDQU32 (DI), Z0 + VPMOVZXBD (SI), Z7 + VPMULLD Z7, Z0, Z0 + VPMULLD Z1, Z0, Z0 + VPADDD Z2, Z0, Z0 + VPSRAD X3, Z0, Z0 + VPMAXSD Z4, Z0, Z0 + VPMINSD Z5, Z0, Z0 + VMOVDQU32 Z0, (DI) + + ADDQ $64, DI + ADDQ $16, SI + SUBQ $16, CX + JNZ scaled512 + + VZEROUPPER + RET + +flat512: + VMOVDQU32 (DI), Z0 + VPMULLD Z6, Z0, Z0 + VPMULLD Z1, Z0, Z0 + VPADDD Z2, Z0, Z0 + VPSRAD X3, Z0, Z0 + VPMAXSD Z4, Z0, Z0 + VPMINSD Z5, Z0, Z0 + VMOVDQU32 Z0, (DI) + + ADDQ $64, DI + SUBQ $16, CX + JNZ flat512 + + VZEROUPPER + RET diff --git a/third_party/h265/hevc/dequant_arm64.s b/third_party/h265/hevc/dequant_arm64.s new file mode 100644 index 00000000..b3ceece8 --- /dev/null +++ b/third_party/h265/hevc/dequant_arm64.s @@ -0,0 +1,88 @@ +//go:build arm64 && !noasm + +#include "textflag.h" + +#define MUL4S_(Vd, Vn, Vm) WORD $(0x4ea09c00 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define SSHL4S3(Vd, Vn, Vm) WORD $(0x4ea04400 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define SMAX4S(Vd, Vn, Vm) WORD $(0x4ea06400 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define SMIN4S(Vd, Vn, Vm) WORD $(0x4ea06c00 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) + +// func dequant32NEON(coef *int32, m *uint8, n int, ls, rnd, sh, lo, hi int32) +// +// 8.6.3 once the left shift has been folded into the right one. m may be nil, +// which means a flat matrix of sixteen. +TEXT ·dequant32NEON(SB), NOSPLIT, $0-44 + MOVD coef+0(FP), R0 + MOVD m+8(FP), R1 + MOVD n+16(FP), R2 + MOVW ls+24(FP), R3 + MOVW rnd+28(FP), R4 + MOVW sh+32(FP), R5 + MOVW lo+36(FP), R6 + MOVW hi+40(FP), R7 + + VDUP R3, V1.S4 + VDUP R4, V2.S4 + NEG R5, R8 + VDUP R8, V3.S4 + VDUP R6, V4.S4 + VDUP R7, V5.S4 + + MOVD $16, R9 + VDUP R9, V6.S4 + + CBZ R1, flat + +scaled: + VLD1 (R0), [V16.S4, V17.S4] + VLD1.P 8(R1), [V7.B8] + VUXTL V7.B8, V7.H8 + VUXTL V7.H4, V18.S4 + VUXTL2 V7.H8, V19.S4 + + MUL4S_(16, 16, 18) + MUL4S_(17, 17, 19) + MUL4S_(16, 16, 1) + MUL4S_(17, 17, 1) + + VADD V2.S4, V16.S4, V16.S4 + VADD V2.S4, V17.S4, V17.S4 + + SSHL4S3(16, 16, 3) + SSHL4S3(17, 17, 3) + SMAX4S(16, 16, 4) + SMAX4S(17, 17, 4) + SMIN4S(16, 16, 5) + SMIN4S(17, 17, 5) + + VST1.P [V16.S4, V17.S4], 32(R0) + + SUB $8, R2 + CBNZ R2, scaled + + RET + +flat: + VLD1 (R0), [V16.S4, V17.S4] + + MUL4S_(16, 16, 6) + MUL4S_(17, 17, 6) + MUL4S_(16, 16, 1) + MUL4S_(17, 17, 1) + + VADD V2.S4, V16.S4, V16.S4 + VADD V2.S4, V17.S4, V17.S4 + + SSHL4S3(16, 16, 3) + SSHL4S3(17, 17, 3) + SMAX4S(16, 16, 4) + SMAX4S(17, 17, 4) + SMIN4S(16, 16, 5) + SMIN4S(17, 17, 5) + + VST1.P [V16.S4, V17.S4], 32(R0) + + SUB $8, R2 + CBNZ R2, flat + + RET diff --git a/third_party/h265/hevc/dequant_riscv64.s b/third_party/h265/hevc/dequant_riscv64.s new file mode 100644 index 00000000..3c237030 --- /dev/null +++ b/third_party/h265/hevc/dequant_riscv64.s @@ -0,0 +1,60 @@ +//go:build riscv64 && riscv64.rva23u64 && !noasm + +#include "textflag.h" + +// func dequant32RVV(coef *int32, m *uint8, n int, ls, rnd, sh, lo, hi int32) +// +// 8.6.3 once the left shift has been folded into the right one. m may be nil, +// which means a flat matrix of sixteen. +TEXT ·dequant32RVV(SB), NOSPLIT, $0-44 + MOV coef+0(FP), X10 + MOV m+8(FP), X11 + MOV n+16(FP), X12 + MOVW ls+24(FP), X13 + MOVW rnd+28(FP), X14 + MOVW sh+32(FP), X15 + MOVW lo+36(FP), X16 + MOVW hi+40(FP), X17 + + BEQZ X11, flat + +scaled: + VSETVLI X12, E32, M1, TA, MA, X18 + + VLE32V (X10), V1 + VLE8V (X11), V2 + VZEXTVF4 V2, V3 + VMULVV V3, V1, V1 + VMULVX X13, V1, V1 + VADDVX X14, V1, V1 + VSRAVX X15, V1, V1 + VMAXVX X16, V1, V1 + VMINVX X17, V1, V1 + VSE32V V1, (X10) + + SLLI $2, X18, X19 + ADD X19, X10 + ADD X18, X11 + SUB X18, X12 + BNEZ X12, scaled + + RET + +flat: + VSETVLI X12, E32, M1, TA, MA, X18 + + VLE32V (X10), V1 + VSLLVI $4, V1, V1 + VMULVX X13, V1, V1 + VADDVX X14, V1, V1 + VSRAVX X15, V1, V1 + VMAXVX X16, V1, V1 + VMINVX X17, V1, V1 + VSE32V V1, (X10) + + SLLI $2, X18, X19 + ADD X19, X10 + SUB X18, X12 + BNEZ X12, flat + + RET diff --git a/third_party/h265/hevc/dpb.go b/third_party/h265/hevc/dpb.go new file mode 100644 index 00000000..4320aab7 --- /dev/null +++ b/third_party/h265/hevc/dpb.go @@ -0,0 +1,414 @@ +package hevc + +// pocState carries what 8.3.1 needs from the previous picture with TemporalId +// zero that was neither RASL, RADL nor a sub-layer non-reference picture. +type pocState struct { + prevLsb int32 + prevMsb int32 +} + +// derivePOC is the picture order count derivation of 8.3.1. +func derivePOC(st *pocState, nalType NALType, lsb int32, log2MaxLsb uint8, noRaslOutput bool) int32 { + maxLsb := int32(1) << log2MaxLsb + + var msb int32 + + switch { + case nalType.IsIRAP() && noRaslOutput: + msb = 0 + case lsb < st.prevLsb && st.prevLsb-lsb >= maxLsb/2: + msb = st.prevMsb + maxLsb + case lsb > st.prevLsb && lsb-st.prevLsb > maxLsb/2: + msb = st.prevMsb - maxLsb + default: + msb = st.prevMsb + } + + return msb + lsb +} + +// isSubLayerNonRef reports the sub-layer non-reference types, which 8.3.1 +// excludes from the previous-picture search along with RASL and RADL. +func isSubLayerNonRef(t NALType) bool { + return t < 16 && t%2 == 0 +} + +// updatePOCState advances the state 8.3.1 reads, which only pictures at +// temporal layer zero that are not RASL, RADL or sub-layer non-reference do. +func (st *pocState) update(nalType NALType, temporalID uint8, poc, lsb int32, _ uint8) { + if temporalID != 0 || + nalType == NALRaslN || nalType == NALRaslR || + nalType == NALRadlN || nalType == NALRadlR || + isSubLayerNonRef(nalType) { + return + } + + st.prevLsb = lsb + st.prevMsb = poc - lsb +} + +// refPicSet is the result of 8.3.2: the reference pictures the current picture +// may use, by POC, split the way list construction consumes them. +type refPicSet struct { + stCurrBefore []int32 + stCurrAfter []int32 + ltCurr []int32 + stFoll []int32 + ltFoll []int32 + + // A long-term entry whose delta_poc_msb_present_flag is zero names its + // picture by the least significant bits alone, so it can only be resolved + // against what the buffer holds. + ltCurrLsbOnly []bool + ltFollLsbOnly []bool +} + +// deriveRefPicSet is 8.3.2. The long-term entries need the current POC to +// rebuild their most significant bits, so it is passed rather than stored. +func deriveRefPicSet(poc int32, log2MaxLsb uint8, st *shortTermRPS, lt *longTermRPS, + numLtSps int, +) refPicSet { + var rps refPicSet + + for i, d := range st.deltaPocS0 { + if st.usedS0[i] { + rps.stCurrBefore = append(rps.stCurrBefore, poc+d) + } else { + rps.stFoll = append(rps.stFoll, poc+d) + } + } + + for i, d := range st.deltaPocS1 { + if st.usedS1[i] { + rps.stCurrAfter = append(rps.stCurrAfter, poc+d) + } else { + rps.stFoll = append(rps.stFoll, poc+d) + } + } + + maxLsb := int32(1) << log2MaxLsb + + var cycle int32 + + for i := range lt.pocLsbLt { + // DeltaPocMsbCycleLt accumulates within each of the two groups the + // syntax splits long-term references into. + if i == 0 || i == numLtSps { + cycle = int32(lt.deltaPocMsbCycle[i]) + } else { + cycle += int32(lt.deltaPocMsbCycle[i]) + } + + v := int32(lt.pocLsbLt[i]) + if lt.deltaPocMsbPresn[i] { + v = poc - cycle*maxLsb - poc&(maxLsb-1) + v + } + + if lt.usedByCurrPicLt[i] { + rps.ltCurr = append(rps.ltCurr, v) + rps.ltCurrLsbOnly = append(rps.ltCurrLsbOnly, !lt.deltaPocMsbPresn[i]) + } else { + rps.ltFoll = append(rps.ltFoll, v) + rps.ltFollLsbOnly = append(rps.ltFollLsbOnly, !lt.deltaPocMsbPresn[i]) + } + } + + return rps +} + +func (r *refPicSet) numPocTotalCurr() int { + return len(r.stCurrBefore) + len(r.stCurrAfter) + len(r.ltCurr) +} + +// buildRefPicList is 8.3.4. The temporary list repeats the three groups until +// it is long enough, then the modification indices select from it. +func buildRefPicList(rps *refPicSet, numActive int, modify bool, entries []uint32, l1 bool) []int32 { + total := rps.numPocTotalCurr() + if total == 0 { + return nil + } + + n := max(numActive, total) + + first, second := rps.stCurrBefore, rps.stCurrAfter + if l1 { + first, second = rps.stCurrAfter, rps.stCurrBefore + } + + temp := make([]int32, 0, n) + for len(temp) < n { + for _, p := range first { + if len(temp) == n { + break + } + + temp = append(temp, p) + } + + for _, p := range second { + if len(temp) == n { + break + } + + temp = append(temp, p) + } + + for _, p := range rps.ltCurr { + if len(temp) == n { + break + } + + temp = append(temp, p) + } + } + + list := make([]int32, numActive) + + for i := range list { + if modify && i < len(entries) { + if int(entries[i]) >= len(temp) { + return nil + } + + list[i] = temp[entries[i]] + + continue + } + + list[i] = temp[i] + } + + return list +} + +// dpbPicture is one decoded picture buffer entry, C.3. +type dpbPicture struct { + pic *Picture + ref bool + longTerm bool + output bool + latency int +} + +func (d *Decoder) dpbFind(poc int32) *Picture { + for i := range d.dpb { + if int32(d.dpb[i].pic.POC) == poc { + return d.dpb[i].pic + } + } + + return nil +} + +// dpbMark is 8.3.2: everything the current picture's reference picture set +// leaves out stops being a reference. +func (d *Decoder) dpbMark(rps *refPicSet) { + keep := make(map[int32]bool, rps.numPocTotalCurr()) + long := make(map[int32]bool, len(rps.ltCurr)+len(rps.ltFoll)) + + for _, l := range [][]int32{rps.stCurrBefore, rps.stCurrAfter, rps.stFoll} { + for _, p := range l { + keep[p] = true + } + } + + for _, l := range [][]int32{rps.ltCurr, rps.ltFoll} { + for _, p := range l { + keep[p] = true + long[p] = true + } + } + + for i := range d.dpb { + poc := int32(d.dpb[i].pic.POC) + d.dpb[i].ref = keep[poc] + d.dpb[i].longTerm = long[poc] + } +} + +// dpbLongTerm reports whether a reference picture is marked long term, which +// suppresses the motion vector scaling of 8.5.3.2.8. +func (d *Decoder) dpbLongTerm(poc int32) bool { + for i := range d.dpb { + if int32(d.dpb[i].pic.POC) == poc { + return d.dpb[i].longTerm + } + } + + return false +} + +// dpbRemoveUnused drops the entries C.5.2.2 empties, those neither held for +// reference nor waiting to be output. +func (d *Decoder) dpbRemoveUnused() { + kept := d.dpb[:0] + + for _, e := range d.dpb { + if e.ref || e.output { + kept = append(kept, e) + + continue + } + + e.pic.release() + } + + clear(d.dpb[len(kept):]) + d.dpb = kept +} + +func (d *Decoder) dpbNeedOutput() int { + n := 0 + + for _, e := range d.dpb { + if e.output { + n++ + } + } + + return n +} + +// dpbBump is C.5.2.4, releasing the picture with the smallest picture order +// count of those still waiting. +func (d *Decoder) dpbBump() *Picture { + best := -1 + + for i := range d.dpb { + if d.dpb[i].output && (best < 0 || d.dpb[i].pic.POC < d.dpb[best].pic.POC) { + best = i + } + } + + if best < 0 { + return nil + } + + p := d.dpb[best].pic + d.dpb[best].output = false + + // The caller takes a reference of its own; the buffer entry may be the + // last one and drop in dpbRemoveUnused. + p.acquire() + + d.dpbRemoveUnused() + + return p +} + +// dpbFull is the bumping condition shared by C.5.2.2 and C.5.2.3. +func (d *Decoder) dpbFull(includeBuffering bool) bool { + if d.dpbNeedOutput() > d.maxReorder { + return true + } + + if d.maxLatency != 0 { + for _, e := range d.dpb { + if e.output && e.latency >= d.maxReorder+d.maxLatency-1 { + return true + } + } + } + + return includeBuffering && len(d.dpb) >= d.maxDecPicBuf +} + +func (d *Decoder) dpbDrain(includeBuffering bool) []*Picture { + var out []*Picture + + for d.dpbFull(includeBuffering) { + p := d.dpbBump() + if p == nil { + break + } + + out = append(out, p) + } + + return out +} + +// dpbStore is C.5.2.3, which also ages every picture already waiting. +func (d *Decoder) dpbStore(p *Picture, output bool) { + if output { + for i := range d.dpb { + if d.dpb[i].output { + d.dpb[i].latency++ + } + } + } + + d.dpb = append(d.dpb, dpbPicture{pic: p, ref: true, output: output}) +} + +// resolveLongTerm completes the long-term entries of 8.3.2 that carry only the +// least significant bits of their picture order count. +func (d *Decoder) resolveLongTerm(rps *refPicSet, log2MaxLsb uint8) { + mask := int32(1)<= len(lsbOnly) || !lsbOnly[i] { + taken[pocs[i]] = true + + continue + } + + for _, e := range d.dpb { + poc := int32(e.pic.POC) + if e.ref && !taken[poc] && poc&mask == pocs[i]&mask { + pocs[i] = poc + taken[poc] = true + + break + } + } + } + } + + fix(rps.ltCurr, rps.ltCurrLsbOnly) + fix(rps.ltFoll, rps.ltFollLsbOnly) +} + +// generateUnavailable is 8.3.3. A reference the buffer never received is +// replaced by a mid-grey picture so a stream joined at a random access point, +// or one with a broken link, still decodes. +func (d *Decoder) generateUnavailable(rps *refPicSet, s *sps) { + grey := func(poc int32) { + if d.dpbFind(poc) != nil { + return + } + + p := newPicture(&d.pool, s) + p.POC = int(poc) + + for cIdx, plane := range [][]uint8{p.Y, p.Cb, p.Cr} { + mid := uint8(1) << (p.depth(cIdx) - 1) + + for i := range plane { + plane[i] = mid + } + } + + for cIdx, plane := range [][]uint16{p.Y16, p.Cb16, p.Cr16} { + mid := uint16(1) << (p.depth(cIdx) - 1) + + for i := range plane { + plane[i] = mid + } + } + + d.dpb = append(d.dpb, dpbPicture{pic: p, ref: true}) + } + + for _, l := range [][]int32{rps.stCurrBefore, rps.stCurrAfter, rps.ltCurr, + rps.stFoll, rps.ltFoll} { + for _, poc := range l { + grey(poc) + } + } +} diff --git a/third_party/h265/hevc/dsp.go b/third_party/h265/hevc/dsp.go new file mode 100644 index 00000000..ca9aefdc --- /dev/null +++ b/third_party/h265/hevc/dsp.go @@ -0,0 +1,102 @@ +package hevc + +type dspContext struct { + inverseTransform func(coef []int32, n int, dst bool, bitDepth int, extended bool, s *transformScratch) + transformSkip func(coef []int32, n int, rotate bool) + dequant func(coef []int32, m []uint8, n, qp, bitDepth int, extended bool) + + // addResidual8 and addResidual16 are nil unless an implementation is + // compiled in. They handle n of at least eight. + addResidual8 func(dst []uint8, stride int, coef []int32, n, shift int) + addResidual16 func(dst []uint16, stride int, coef []int32, n, shift int, maxV int32) +} + +// oddAsm is the sixteen-wide odd half of 8.6.4.2, nil unless an +// implementation is compiled in. It stands outside dspContext because the +// inverse transform is itself a kernel, so reaching it through dsp would be an +// initialisation cycle. +var oddAsm func(out, in []int32, stride int) + +var forwardTransform8Asm func(dst, src []int32, n int) + +// The luma filters of 8.7.2.5.7 over the eight lines of one edge, nil unless an +// implementation is compiled in. Position i of line l is at p[i*pitch+l], the +// first four lines take tc0 and the rest tc1. +var ( + deblockStrongAsm func(p []uint8, pitch int, tc0, tc1, flags int32) + deblockNormalAsm func(p []uint8, pitch int, tc0, tc1, nd, flags int32) + + // deblockTurnIn and deblockTurnOut put a vertical edge's lines into those + // lanes and back, since only a horizontal one already lies that way. + deblockTurnIn func(dst, src []uint8, stride int) + deblockTurnOut func(dst []uint8, stride int, src []uint8) +) + +// planarAsm is 8.4.4.2.4 for eight-bit output, nil unless an implementation is +// compiled in. It handles n of at least eight. +var planarAsm func(dst []uint8, stride int, r *refSamples, shift int) + +// predUniAsm and predBiAsm are 8.5.3.3.4.2 for eight-bit output, nil unless an +// implementation is compiled in. w is a multiple of eight. +var ( + predUniAsm func(dst []uint8, dstStride int, src []int16, srcStride, w, h, shift int) + predBiAsm func(dst []uint8, dstStride int, a, b []int16, srcStride, w, h, shift int) +) + +// The motion compensation kernels of 8.5.3.3.3, nil unless an implementation +// is compiled in. w is a multiple of eight throughout. +// +// mcTapAsm is one direction over eight-bit samples, with src at the first tap; +// mcTapV16Asm is the vertical half of a two-pass filter, reading the first +// pass at sixteen bits. Both take the tap count, so luma's eight-tap and +// chroma's four-tap share them. mcCopyAsm is the integer-position case. +var ( + mcCopyAsm func(dst []int16, dstStride int, src []uint8, srcStride, w, h, shift int) + + mcTapAsm func(dst []int16, dstStride int, src []uint8, srcStride, tapStride, w, h int, + f []int16) + + mcTapV16Asm func(dst []int16, dstStride int, src []int16, srcStride, w, h, shift int, + f []int16) +) + +// dequant32Asm is 8.6.3 in its 32-bit form, nil unless an implementation is +// compiled in. m may be nil for a flat matrix. +var dequant32Asm func(coef []int32, m []uint8, ls, rnd int32, sh int, lo, hi int32) + +// idctColsAsm is one pass of 8.6.4.2 over eight columns at a time, nil unless +// an implementation is compiled in. n is at least eight. +var idctColsAsm func(dst, src []int32, n int, rnd int32, shift int, lo, hi int32) + +// sse8Asm is the squared error between a plane and a block, nil unless an +// implementation is compiled in. n is a multiple of eight. +var sse8Asm func(src []uint8, srcStride int, block []uint8, blockStride, n int) int64 + +// quantize8Asm is the forward direction of 8.6.3, nil unless an implementation +// is compiled in. +var quantize8Asm func(dst, src []int32, count int, scale, offset int32, qbits int) + +// satd16x8Asm is satd over a sixteen wide and eight tall strip, which is two +// 8x8 blocks, nil unless an implementation is compiled in. +var satd16x8Asm func(src []uint8, srcStride int, pred []uint8, predStride int) int64 + +// transposeAsm transposes an n by n block of int32, nil unless an +// implementation is compiled in. n is a multiple of eight. +var transposeAsm func(dst, src []int32, n int) + +func newDSPGo() *dspContext { + return &dspContext{ + inverseTransform: inverseTransform, + transformSkip: transformSkip, + dequant: dequant, + } +} + +// dsp is resolved at package initialisation, so reading a kernel is a plain +// field load. The inverse transform reads one per basis row. +var dsp = func() *dspContext { + d := newDSPGo() + dspInit(d) + + return d +}() diff --git a/third_party/h265/hevc/dsp_amd64.go b/third_party/h265/hevc/dsp_amd64.go new file mode 100644 index 00000000..9f5119b2 --- /dev/null +++ b/third_party/h265/hevc/dsp_amd64.go @@ -0,0 +1,264 @@ +//go:build amd64 && !noasm + +package hevc + +var ( + hasAVX2 = cpuidAVX2() + hasAVX512 = cpuidAVX512ICL() +) + +func cpuidAVX2() bool + +func cpuidAVX512ICL() bool + +//go:noescape +func addResidual8AVX2(dst *uint8, stride int, coef *int32, n, shift int) + +//go:noescape +func addResidual16AVX2(dst *uint16, stride int, coef *int32, n, shift int, maxV int32) + +//go:noescape +func odd16AVX2(out *int32, in *int32, m *int8, stride int) + +//go:noescape +func predPlanar8AVX2(dst *uint8, stride int, top *int32, left *int32, tr, bl, n, shift int) + +//go:noescape +func predUni8AVX2(dst *uint8, dstStride int, src *int16, srcStride, w, h, shift int) + +//go:noescape +func predUni8AVX512(dst *uint8, dstStride int, src *int16, srcStride, w, h, shift int) + +//go:noescape +func mcTap8AVX2(dst *int16, dstStride int, src *uint8, srcStride, tapStride, w, h int, f *int16) + +//go:noescape +func mcTap4AVX2(dst *int16, dstStride int, src *uint8, srcStride, tapStride, w, h int, f *int16) + +//go:noescape +func mcTapV16x8AVX2(dst *int16, dstStride int, src *int16, srcStride, w, h, shift int, f *int16) + +//go:noescape +func mcTapV16x4AVX2(dst *int16, dstStride int, src *int16, srcStride, w, h, shift int, f *int16) + +//go:noescape +func mcCopy8AVX2(dst *int16, dstStride int, src *uint8, srcStride, w, h, shift int) + +//go:noescape +func mcCopy8AVX512(dst *int16, dstStride int, src *uint8, srcStride, w, h, shift int) + +//go:noescape +func predBi8AVX2(dst *uint8, dstStride int, a, b *int16, srcStride, w, h, shift int) + +//go:noescape +func predBi8AVX512(dst *uint8, dstStride int, a, b *int16, srcStride, w, h, shift int) + +//go:noescape +func idctCols8AVX2(dst, src, m *int32, n, mstride, shift int, rnd, lo, hi int32) + +//go:noescape +func predAngular8AVX2(dst *uint8, stride int, ref *int32, angle, n int) + +//go:noescape +func sse8AVX2(src *uint8, srcStride int, block *uint8, blockStride, n int) int64 + +//go:noescape +func quantize8AVX2(dst, src *int32, count int, scale, offset int32, qbits int) + +//go:noescape +func satd16x8AVX2(src *uint8, srcStride int, pred *uint8, predStride int) int64 + +//go:noescape +func transpose8AVX2(dst, src *int32, n int) + +//go:noescape +func forwardTransform8AVX2(dst, src, m *int32, n, shift1, shift2 int) + +//go:noescape +func forwardTransform8AVX512(dst, src, m *int32, n, shift1, shift2 int) + +var forwardTransformMatrix = func() [4][32 * 32]int32 { + var m [4][32 * 32]int32 + + for p, n := range [4]int{4, 8, 16, 32} { + stride := 32 / n + + for x := range n { + for k := range n { + m[p][x*n+k] = int32(transMatrix[k*stride][x]) + } + } + } + + return m +}() + +//go:noescape +func dequant32AVX2(coef *int32, m *uint8, n int, ls, rnd, sh, lo, hi int32) + +//go:noescape +func dequant32AVX512(coef *int32, m *uint8, n int, ls, rnd, sh, lo, hi int32) + +//go:noescape +func deblockStrong8AVX2(p *uint8, pitch int, tc0, tc1, flags int32) + +//go:noescape +func deblockNormal8AVX2(p *uint8, pitch int, tc0, tc1, nd, flags int32) + +//go:noescape +func turnIn8AVX2(dst *uint8, src *uint8, stride int) + +//go:noescape +func turnOut8AVX2(dst *uint8, stride int, src *uint8) + +func dspInit(d *dspContext) { + if !hasAVX2 { + return + } + + d.addResidual8 = func(dst []uint8, stride int, coef []int32, n, shift int) { + addResidual8AVX2(&dst[0], stride, &coef[0], n, shift) + } + + d.addResidual16 = func(dst []uint16, stride int, coef []int32, n, shift int, maxV int32) { + addResidual16AVX2(&dst[0], stride, &coef[0], n, shift, maxV) + } + + idctColsAsm = func(dst, src []int32, n int, rnd int32, shift int, lo, hi int32) { + idctCols8AVX2(&dst[0], &src[0], &transMatrix32[0][0], n, 32*(32/n), shift, + rnd, lo, hi) + } + + deblockStrongAsm = func(p []uint8, pitch int, tc0, tc1, flags int32) { + deblockStrong8AVX2(&p[0], pitch, tc0, tc1, flags) + } + + deblockNormalAsm = func(p []uint8, pitch int, tc0, tc1, nd, flags int32) { + deblockNormal8AVX2(&p[0], pitch, tc0, tc1, nd, flags) + } + + deblockTurnIn = func(dst, src []uint8, stride int) { + turnIn8AVX2(&dst[0], &src[0], stride) + } + + deblockTurnOut = func(dst []uint8, stride int, src []uint8) { + turnOut8AVX2(&dst[0], stride, &src[0]) + } + + transposeAsm = func(dst, src []int32, n int) { + transpose8AVX2(&dst[0], &src[0], n) + } + + satd16x8Asm = func(src []uint8, srcStride int, pred []uint8, predStride int) int64 { + return satd16x8AVX2(&src[0], srcStride, &pred[0], predStride) + } + + sse8Asm = func(src []uint8, srcStride int, block []uint8, blockStride, n int) int64 { + return sse8AVX2(&src[0], srcStride, &block[0], blockStride, n) + } + + quantize8Asm = func(dst, src []int32, count int, scale, offset int32, qbits int) { + quantize8AVX2(&dst[0], &src[0], count, scale, offset, qbits) + } + + forwardTransform8Asm = func(dst, src []int32, n int) { + if hasAVX512 && n >= 16 { + forwardTransform8AVX512(&dst[0], &src[0], &forwardTransformMatrix[log2(n)-2][0], + n, log2(n)-1, log2(n)+6) + + return + } + + forwardTransform8AVX2(&dst[0], &src[0], &forwardTransformMatrix[log2(n)-2][0], + n, log2(n)-1, log2(n)+6) + } + + oddAsm = func(out, in []int32, stride int) { + odd16AVX2(&out[0], &in[0], &transMatrix[0][0], stride) + } + + mcTapAsm = func(dst []int16, dstStride int, src []uint8, srcStride, tapStride, w, h int, + f []int16, + ) { + if len(f) == 8 { + mcTap8AVX2(&dst[0], dstStride, &src[0], srcStride, tapStride, w, h, &f[0]) + + return + } + + mcTap4AVX2(&dst[0], dstStride, &src[0], srcStride, tapStride, w, h, &f[0]) + } + + mcTapV16Asm = func(dst []int16, dstStride int, src []int16, srcStride, w, h, shift int, + f []int16, + ) { + if len(f) == 8 { + mcTapV16x8AVX2(&dst[0], dstStride, &src[0], srcStride, w, h, shift, &f[0]) + + return + } + + mcTapV16x4AVX2(&dst[0], dstStride, &src[0], srcStride, w, h, shift, &f[0]) + } + + dequant32Asm = func(coef []int32, m []uint8, ls, rnd int32, sh int, lo, hi int32) { + var mp *uint8 + if m != nil { + mp = &m[0] + } + + if hasAVX512 && len(coef) >= 64 { + dequant32AVX512(&coef[0], mp, len(coef), ls, rnd, int32(sh), lo, hi) + + return + } + + dequant32AVX2(&coef[0], mp, len(coef), ls, rnd, int32(sh), lo, hi) + } + + mcCopyAsm = func(dst []int16, dstStride int, src []uint8, srcStride, w, h, shift int) { + if hasAVX512 && w >= 32 { + mcCopy8AVX512(&dst[0], dstStride, &src[0], srcStride, w, h, shift) + + return + } + + mcCopy8AVX2(&dst[0], dstStride, &src[0], srcStride, w, h, shift) + } + + predBiAsm = func(dst []uint8, dstStride int, a, b []int16, srcStride, w, h, shift int) { + if hasAVX512 && w >= 32 { + predBi8AVX512(&dst[0], dstStride, &a[0], &b[0], srcStride, w, h, shift) + + return + } + + predBi8AVX2(&dst[0], dstStride, &a[0], &b[0], srcStride, w, h, shift) + } + + predUniAsm = func(dst []uint8, dstStride int, src []int16, srcStride, w, h, shift int) { + if hasAVX512 && w >= 32 { + predUni8AVX512(&dst[0], dstStride, &src[0], srcStride, w, h, shift) + + return + } + + predUni8AVX2(&dst[0], dstStride, &src[0], srcStride, w, h, shift) + } + + planarAsm = func(dst []uint8, stride int, r *refSamples, shift int) { + n := r.n + predPlanar8AVX2(&dst[0], stride, &r.s[2*n+1], &r.s[2*n-1], + int(r.top(n)), int(r.left(n)), n, shift) + } +} + +func predAngularRows(dst []uint8, stride int, ref []int32, angle, n int) bool { + if !hasAVX2 || n < 8 { + return false + } + + predAngular8AVX2(&dst[0], stride, &ref[0], angle, n) + + return true +} diff --git a/third_party/h265/hevc/dsp_arm64.go b/third_party/h265/hevc/dsp_arm64.go new file mode 100644 index 00000000..e45be935 --- /dev/null +++ b/third_party/h265/hevc/dsp_arm64.go @@ -0,0 +1,205 @@ +//go:build arm64 && !noasm + +package hevc + +//go:noescape +func addResidual8NEON(dst *uint8, stride int, coef *int32, n, shift int) + +//go:noescape +func addResidual16NEON(dst *uint16, stride int, coef *int32, n, shift int, maxV int32) + +//go:noescape +func odd16NEON(out *int32, in *int32, m *int8, stride int) + +//go:noescape +func predPlanar8NEON(dst *uint8, stride int, top *int32, left *int32, tr, bl, n, shift int) + +//go:noescape +func mcTap8NEON(dst *int16, dstStride int, src *uint8, srcStride, tapStride, w, h int, f *int16) + +//go:noescape +func mcTap4NEON(dst *int16, dstStride int, src *uint8, srcStride, tapStride, w, h int, f *int16) + +//go:noescape +func mcTapV16x8NEON(dst *int16, dstStride int, src *int16, srcStride, w, h, shift int, f *int16) + +//go:noescape +func mcTapV16x4NEON(dst *int16, dstStride int, src *int16, srcStride, w, h, shift int, f *int16) + +//go:noescape +func mcCopy8NEON(dst *int16, dstStride int, src *uint8, srcStride, w, h, shift int) + +//go:noescape +func predUni8NEON(dst *uint8, dstStride int, src *int16, srcStride, w, h, shift int) + +//go:noescape +func predBi8NEON(dst *uint8, dstStride int, a, b *int16, srcStride, w, h, shift int) + +//go:noescape +func idctCols4NEON(dst, src, m *int32, n, mstride, shift int, rnd, lo, hi int32) + +//go:noescape +func predAngular8NEON(dst *uint8, stride int, ref *int32, angle, n int) + +//go:noescape +func sse8NEON(src *uint8, srcStride int, block *uint8, blockStride, n int) int64 + +//go:noescape +func quantize8NEON(dst, src *int32, count int, scale, offset int32, qbits int) + +//go:noescape +func satd16x8NEON(src *uint8, srcStride int, pred *uint8, predStride int) int64 + +//go:noescape +func transpose4NEON(dst, src *int32, n int) + +//go:noescape +func forwardTransform8NEON(dst, src, m *int32, n, shift1, shift2 int) + +//go:noescape +func dequant32NEON(coef *int32, m *uint8, n int, ls, rnd, sh, lo, hi int32) + +var forwardTransformMatrixNEON = func() [4][32 * 32]int32 { + var m [4][32 * 32]int32 + + for p, n := range [4]int{4, 8, 16, 32} { + stride := 32 / n + + for x := range n { + for k := range n { + m[p][x*n+k] = int32(transMatrix[k*stride][x]) + } + } + } + + return m +}() + +//go:noescape +func deblockStrong8NEON(p *uint8, pitch int, tc0, tc1, flags int32) + +//go:noescape +func deblockNormal8NEON(p *uint8, pitch int, tc0, tc1, nd, flags int32) + +//go:noescape +func turnIn8NEON(dst *uint8, src *uint8, stride int) + +//go:noescape +func turnOut8NEON(dst *uint8, stride int, src *uint8) + +func dspInit(d *dspContext) { + d.addResidual8 = func(dst []uint8, stride int, coef []int32, n, shift int) { + addResidual8NEON(&dst[0], stride, &coef[0], n, shift) + } + + d.addResidual16 = func(dst []uint16, stride int, coef []int32, n, shift int, maxV int32) { + addResidual16NEON(&dst[0], stride, &coef[0], n, shift, maxV) + } + + idctColsAsm = func(dst, src []int32, n int, rnd int32, shift int, lo, hi int32) { + idctCols4NEON(&dst[0], &src[0], &transMatrix32[0][0], n, 32*(32/n), shift, + rnd, lo, hi) + } + + transposeAsm = func(dst, src []int32, n int) { + transpose4NEON(&dst[0], &src[0], n) + } + + deblockStrongAsm = func(p []uint8, pitch int, tc0, tc1, flags int32) { + deblockStrong8NEON(&p[0], pitch, tc0, tc1, flags) + } + + deblockNormalAsm = func(p []uint8, pitch int, tc0, tc1, nd, flags int32) { + deblockNormal8NEON(&p[0], pitch, tc0, tc1, nd, flags) + } + + deblockTurnIn = func(dst, src []uint8, stride int) { + turnIn8NEON(&dst[0], &src[0], stride) + } + + deblockTurnOut = func(dst []uint8, stride int, src []uint8) { + turnOut8NEON(&dst[0], stride, &src[0]) + } + + satd16x8Asm = func(src []uint8, srcStride int, pred []uint8, predStride int) int64 { + return satd16x8NEON(&src[0], srcStride, &pred[0], predStride) + } + + sse8Asm = func(src []uint8, srcStride int, block []uint8, blockStride, n int) int64 { + return sse8NEON(&src[0], srcStride, &block[0], blockStride, n) + } + + quantize8Asm = func(dst, src []int32, count int, scale, offset int32, qbits int) { + quantize8NEON(&dst[0], &src[0], count, scale, offset, qbits) + } + + forwardTransform8Asm = func(dst, src []int32, n int) { + forwardTransform8NEON(&dst[0], &src[0], &forwardTransformMatrixNEON[log2(n)-2][0], + n, log2(n)-1, log2(n)+6) + } + + oddAsm = func(out, in []int32, stride int) { + odd16NEON(&out[0], &in[0], &transMatrix[0][0], stride) + } + + mcTapAsm = func(dst []int16, dstStride int, src []uint8, srcStride, tapStride, w, h int, + f []int16, + ) { + if len(f) == 8 { + mcTap8NEON(&dst[0], dstStride, &src[0], srcStride, tapStride, w, h, &f[0]) + + return + } + + mcTap4NEON(&dst[0], dstStride, &src[0], srcStride, tapStride, w, h, &f[0]) + } + + mcTapV16Asm = func(dst []int16, dstStride int, src []int16, srcStride, w, h, shift int, + f []int16, + ) { + if len(f) == 8 { + mcTapV16x8NEON(&dst[0], dstStride, &src[0], srcStride, w, h, shift, &f[0]) + + return + } + + mcTapV16x4NEON(&dst[0], dstStride, &src[0], srcStride, w, h, shift, &f[0]) + } + + dequant32Asm = func(coef []int32, m []uint8, ls, rnd int32, sh int, lo, hi int32) { + var mp *uint8 + if m != nil { + mp = &m[0] + } + + dequant32NEON(&coef[0], mp, len(coef), ls, rnd, int32(sh), lo, hi) + } + + mcCopyAsm = func(dst []int16, dstStride int, src []uint8, srcStride, w, h, shift int) { + mcCopy8NEON(&dst[0], dstStride, &src[0], srcStride, w, h, shift) + } + + predBiAsm = func(dst []uint8, dstStride int, a, b []int16, srcStride, w, h, shift int) { + predBi8NEON(&dst[0], dstStride, &a[0], &b[0], srcStride, w, h, shift) + } + + predUniAsm = func(dst []uint8, dstStride int, src []int16, srcStride, w, h, shift int) { + predUni8NEON(&dst[0], dstStride, &src[0], srcStride, w, h, shift) + } + + planarAsm = func(dst []uint8, stride int, r *refSamples, shift int) { + n := r.n + predPlanar8NEON(&dst[0], stride, &r.s[2*n+1], &r.s[2*n-1], + int(r.top(n)), int(r.left(n)), n, shift) + } +} + +func predAngularRows(dst []uint8, stride int, ref []int32, angle, n int) bool { + if n < 8 { + return false + } + + predAngular8NEON(&dst[0], stride, &ref[0], angle, n) + + return true +} diff --git a/third_party/h265/hevc/dsp_noasm.go b/third_party/h265/hevc/dsp_noasm.go new file mode 100644 index 00000000..9bade68f --- /dev/null +++ b/third_party/h265/hevc/dsp_noasm.go @@ -0,0 +1,7 @@ +//go:build noasm || (!amd64 && !arm64 && !(riscv64 && riscv64.rva23u64)) + +package hevc + +func dspInit(*dspContext) {} + +func predAngularRows([]uint8, int, []int32, int, int) bool { return false } diff --git a/third_party/h265/hevc/dsp_riscv64.go b/third_party/h265/hevc/dsp_riscv64.go new file mode 100644 index 00000000..b867ce9e --- /dev/null +++ b/third_party/h265/hevc/dsp_riscv64.go @@ -0,0 +1,187 @@ +//go:build riscv64 && riscv64.rva23u64 && !noasm + +package hevc + +//go:noescape +func addResidual8RVV(dst *uint8, stride int, coef *int32, n, shift int) + +//go:noescape +func addResidual16RVV(dst *uint16, stride int, coef *int32, n, shift int, maxV int32) + +//go:noescape +func odd16RVV(out *int32, in *int32, m *int8, stride int) + +//go:noescape +func predPlanar8RVV(dst *uint8, stride int, top *int32, left *int32, tr, bl, n, shift int) + +//go:noescape +func mcTap8RVV(dst *int16, dstStride int, src *uint8, srcStride, tapStride, w, h, taps int, f *int16) + +//go:noescape +func mcTapV16RVV(dst *int16, dstStride int, src *int16, srcStride, w, h, shift, taps int, f *int16) + +//go:noescape +func mcCopy8RVV(dst *int16, dstStride int, src *uint8, srcStride, w, h, shift int) + +//go:noescape +func predUni8RVV(dst *uint8, dstStride int, src *int16, srcStride, w, h, shift int) + +//go:noescape +func predBi8RVV(dst *uint8, dstStride int, a, b *int16, srcStride, w, h, shift int) + +//go:noescape +func idctColsRVV(dst, src, m *int32, n, mstride, shift int, rnd, lo, hi int32) + +//go:noescape +func predAngular8RVV(dst *uint8, stride int, ref *int32, angle, n int) + +//go:noescape +func sse8RVV(src *uint8, srcStride int, block *uint8, blockStride, n int) int64 + +//go:noescape +func quantize8RVV(dst, src *int32, count int, scale, offset int32, qbits int) + +//go:noescape +func satd16x8RVV(src *uint8, srcStride int, pred *uint8, predStride int) int64 + +//go:noescape +func deblockStrong8RVV(p *uint8, pitch int, tc0, tc1, flags int32) + +//go:noescape +func deblockNormal8RVV(p *uint8, pitch int, tc0, tc1, nd, flags int32) + +//go:noescape +func turnIn8RVV(dst *uint8, src *uint8, stride int) + +//go:noescape +func turnOut8RVV(dst *uint8, stride int, src *uint8) + +//go:noescape +func transposeRVV(dst, src *int32, n int) + +//go:noescape +func forwardTransform8RVV(dst, src, m *int32, n, shift1, shift2 int) + +var forwardTransformMatrixRVV = func() [4][32 * 32]int32 { + var m [4][32 * 32]int32 + + for p, n := range [4]int{4, 8, 16, 32} { + stride := 32 / n + + for x := range n { + for k := range n { + m[p][x*n+k] = int32(transMatrix[k*stride][x]) + } + } + } + + return m +}() + +//go:noescape +func dequant32RVV(coef *int32, m *uint8, n int, ls, rnd, sh, lo, hi int32) + +func dspInit(d *dspContext) { + d.addResidual8 = func(dst []uint8, stride int, coef []int32, n, shift int) { + addResidual8RVV(&dst[0], stride, &coef[0], n, shift) + } + + d.addResidual16 = func(dst []uint16, stride int, coef []int32, n, shift int, maxV int32) { + addResidual16RVV(&dst[0], stride, &coef[0], n, shift, maxV) + } + + idctColsAsm = func(dst, src []int32, n int, rnd int32, shift int, lo, hi int32) { + idctColsRVV(&dst[0], &src[0], &transMatrix32[0][0], n, 32*(32/n), shift, + rnd, lo, hi) + } + + transposeAsm = func(dst, src []int32, n int) { + transposeRVV(&dst[0], &src[0], n) + } + + deblockStrongAsm = func(p []uint8, pitch int, tc0, tc1, flags int32) { + deblockStrong8RVV(&p[0], pitch, tc0, tc1, flags) + } + + deblockNormalAsm = func(p []uint8, pitch int, tc0, tc1, nd, flags int32) { + deblockNormal8RVV(&p[0], pitch, tc0, tc1, nd, flags) + } + + deblockTurnIn = func(dst, src []uint8, stride int) { + turnIn8RVV(&dst[0], &src[0], stride) + } + + deblockTurnOut = func(dst []uint8, stride int, src []uint8) { + turnOut8RVV(&dst[0], stride, &src[0]) + } + + satd16x8Asm = func(src []uint8, srcStride int, pred []uint8, predStride int) int64 { + return satd16x8RVV(&src[0], srcStride, &pred[0], predStride) + } + + sse8Asm = func(src []uint8, srcStride int, block []uint8, blockStride, n int) int64 { + return sse8RVV(&src[0], srcStride, &block[0], blockStride, n) + } + + quantize8Asm = func(dst, src []int32, count int, scale, offset int32, qbits int) { + quantize8RVV(&dst[0], &src[0], count, scale, offset, qbits) + } + + forwardTransform8Asm = func(dst, src []int32, n int) { + forwardTransform8RVV(&dst[0], &src[0], &forwardTransformMatrixRVV[log2(n)-2][0], + n, log2(n)-1, log2(n)+6) + } + + oddAsm = func(out, in []int32, stride int) { + odd16RVV(&out[0], &in[0], &transMatrix[0][0], stride) + } + + mcTapAsm = func(dst []int16, dstStride int, src []uint8, srcStride, tapStride, w, h int, + f []int16, + ) { + mcTap8RVV(&dst[0], dstStride, &src[0], srcStride, tapStride, w, h, len(f), &f[0]) + } + + mcTapV16Asm = func(dst []int16, dstStride int, src []int16, srcStride, w, h, shift int, + f []int16, + ) { + mcTapV16RVV(&dst[0], dstStride, &src[0], srcStride, w, h, shift, len(f), &f[0]) + } + + dequant32Asm = func(coef []int32, m []uint8, ls, rnd int32, sh int, lo, hi int32) { + var mp *uint8 + if m != nil { + mp = &m[0] + } + + dequant32RVV(&coef[0], mp, len(coef), ls, rnd, int32(sh), lo, hi) + } + + mcCopyAsm = func(dst []int16, dstStride int, src []uint8, srcStride, w, h, shift int) { + mcCopy8RVV(&dst[0], dstStride, &src[0], srcStride, w, h, shift) + } + + predBiAsm = func(dst []uint8, dstStride int, a, b []int16, srcStride, w, h, shift int) { + predBi8RVV(&dst[0], dstStride, &a[0], &b[0], srcStride, w, h, shift) + } + + predUniAsm = func(dst []uint8, dstStride int, src []int16, srcStride, w, h, shift int) { + predUni8RVV(&dst[0], dstStride, &src[0], srcStride, w, h, shift) + } + + planarAsm = func(dst []uint8, stride int, r *refSamples, shift int) { + n := r.n + predPlanar8RVV(&dst[0], stride, &r.s[2*n+1], &r.s[2*n-1], + int(r.top(n)), int(r.left(n)), n, shift) + } +} + +func predAngularRows(dst []uint8, stride int, ref []int32, angle, n int) bool { + if n < 8 { + return false + } + + predAngular8RVV(&dst[0], stride, &ref[0], angle, n) + + return true +} diff --git a/third_party/h265/hevc/encoder.go b/third_party/h265/hevc/encoder.go new file mode 100644 index 00000000..d92d3c03 --- /dev/null +++ b/third_party/h265/hevc/encoder.go @@ -0,0 +1,275 @@ +package hevc + +import ( + "errors" + "runtime" +) + +// ErrInvalidEncodeInput means the frame or the options do not describe +// something this encoder can code. +var ErrInvalidEncodeInput = errors.New("hevc: invalid encode input") + +// MaxLumaSamples is MaxLumaPs of Table A.8, which every level from 6.0 up +// shares. A picture larger than this has no level to be coded at, so a caller +// with one splits it over a grid of items instead. +const MaxLumaSamples = 35651584 + +// Frame is one picture in the [EncoderOptions.Chroma] sampling. StrideY and +// StrideC are in samples and may exceed the width, so a frame can be a window +// on a larger buffer. A monochrome frame leaves the chroma planes nil. Above +// eight bits the samples come in Y16, Cb16 and Cr16 instead. +type Frame struct { + Y, Cb, Cr []uint8 + Y16, Cb16, Cr16 []uint16 + StrideY, StrideC int +} + +// ChromaFormat is the chroma sampling a picture is coded in, chroma_format_idc +// of 7.4.3.2 by another name. The zero value is 4:2:0. +type ChromaFormat int + +const ( + Chroma420 ChromaFormat = iota + Chroma422 + Chroma444 + ChromaMono +) + +// idc is chroma_format_idc, and sub the SubWidthC and SubHeightC of Table 6-1. +func (c ChromaFormat) idc() uint32 { + switch c { + case Chroma422: + return 2 + case Chroma444: + return 3 + case ChromaMono: + return 0 + default: + return 1 + } +} + +func (c ChromaFormat) sub() (int, int) { + switch c { + case Chroma422: + return 2, 1 + case Chroma444, ChromaMono: + return 1, 1 + default: + return 2, 2 + } +} + +// EncoderOptions configures an [Encoder]. Width and Height must be non-zero, a +// multiple of what Chroma resolves, and no more than [MaxLumaSamples] between +// them; anything the coding tree cannot fill is padded away behind a +// conformance window. QP runs from 1 through 51 and selects 26 when left at +// zero; Lossless codes the samples as PCM instead and ignores QP. +type EncoderOptions struct { + Width, Height int + QP int + Lossless bool + // Chroma is the sampling to code in. The zero value is 4:2:0. + Chroma ChromaFormat + // BitDepth is the sample size, 8 through 12. The zero value is 8. Above + // eight the samples come in the sixteen bit planes of [Frame]. + BitDepth int + // SAO fits the offsets of 8.7.3 to the error left in each coding tree + // block. It codes the picture twice, for about 2.2x the time, 3.5% of luma + // bitrate and half a decibel of chroma. + SAO bool +} + +// Encoder writes self-contained intra IDR access units. It holds the working +// memory one picture needs and reuses it for the next, so it is not safe for +// concurrent use. +type Encoder struct { + width, height int + qp int + lossless bool + threads int + chroma ChromaFormat + subW, subH int + bitDepth int + sao bool + + intra intraEncoder[uint8] + deep *intraEncoder[uint16] + planes [3][]uint8 + planes16 [3][]uint16 +} + +// Threads bounds the goroutines coding one picture's rows. More than one turns +// on the synchronisation of 9.3.1, which costs about 2% of bitrate; zero and +// one code serially and leave it out of the stream. +func (e *Encoder) Threads(n int) { + if e != nil { + e.threads = n + } +} + +func NewEncoder(opts EncoderOptions) (*Encoder, error) { + if opts.Chroma < Chroma420 || opts.Chroma > ChromaMono { + return nil, ErrInvalidEncodeInput + } + + sw, sh := opts.Chroma.sub() + + if opts.Width <= 0 || opts.Height <= 0 || opts.Width%sw != 0 || opts.Height%sh != 0 || + opts.QP < 0 || opts.QP > 51 { + return nil, ErrInvalidEncodeInput + } + + if codedSize(opts.Width)*codedSize(opts.Height) > MaxLumaSamples { + return nil, ErrInvalidEncodeInput + } + + if opts.QP == 0 { + opts.QP = 26 + } + + if opts.BitDepth == 0 { + opts.BitDepth = 8 + } + + if opts.BitDepth < 8 || opts.BitDepth > 12 { + return nil, ErrInvalidEncodeInput + } + + return &Encoder{width: opts.Width, height: opts.Height, qp: opts.QP, + lossless: opts.Lossless, chroma: opts.Chroma, subW: sw, subH: sh, + bitDepth: opts.BitDepth, sao: opts.SAO}, nil +} + +// Encode codes one frame as a complete access unit: a video, a sequence and a +// picture parameter set followed by the slice. The NAL units it returns own +// their bitstream and outlive the next call. +func (e *Encoder) Encode(frame Frame) ([]NALUnit, error) { + if e == nil { + return nil, ErrInvalidEncodeInput + } + + if e.bitDepth <= 8 { + return encodePicture(e, &e.intra, [3][]uint8{frame.Y, frame.Cb, frame.Cr}, + &e.planes, frame.StrideY, frame.StrideC) + } + + if e.deep == nil { + e.deep = new(intraEncoder[uint16]) + } + + return encodePicture(e, e.deep, [3][]uint16{frame.Y16, frame.Cb16, frame.Cr16}, + &e.planes16, frame.StrideY, frame.StrideC) +} + +// encodePicture gathers the frame into the coding grid and codes it, either as +// a slice of its own or, without loss, as pulse code modulated blocks. +func encodePicture[P pixel](e *Encoder, enc *intraEncoder[P], src [3][]P, + planes *[3][]P, strideY, strideC int, +) ([]NALUnit, error) { + mono := e.chroma == ChromaMono + + if strideY < e.width || (!mono && strideC < e.width/e.subW) { + return nil, ErrInvalidEncodeInput + } + + cw, ch := codedSize(e.width), codedSize(e.height) + sw, sh := e.subW, e.subH + stride := [3]int{strideY, strideC, strideC} + width := [3]int{e.width, e.width / sw, e.width / sw} + height := [3]int{e.height, e.height / sh, e.height / sh} + padded := [3][2]int{{cw, ch}, {cw / sw, ch / sh}, {cw / sw, ch / sh}} + + n := 3 + if mono { + n = 1 + planes[1], planes[2] = nil, nil + } + + for i := range n { + plane, ok := padPlane(planes[i], src[i], stride[i], width[i], height[i], + padded[i][0], padded[i][1]) + if !ok { + return nil, ErrInvalidEncodeInput + } + + planes[i] = plane + } + + if e.lossless { + h := encoderHeaders{ + width: cw, height: ch, cropRight: cw - e.width, cropBottom: ch - e.height, + chromaFormat: e.chroma.idc(), subWidthC: sw, subHeightC: sh, + bitDepth: e.bitDepth, + levelIDC: pcmLevelIDC(cw * ch), pcm: true, + } + + return append(h.parameterSets(), NALUnit{Type: NALIdrNLP, + RBSP: pcmSlice(planes[0], planes[1], planes[2], cw, ch, sw, sh, e.bitDepth)}), nil + } + + enc.bitDepth = e.bitDepth + enc.wantSAO = e.sao + enc.threads = e.waveThreads() + + rbsp, err := enc.slice(planes[0], planes[1], planes[2], cw, ch, e.qp) + if err != nil { + return nil, err + } + + return enc.nals(e.width, e.height, rbsp), nil +} + +// Flush ends the sequence. Every frame is coded on its own, so there is never +// anything held back. +func (e *Encoder) Flush() ([]NALUnit, error) { + if e == nil { + return nil, ErrInvalidEncodeInput + } + + return nil, nil +} + +// padPlane gathers a strided plane into dst at paddedW by paddedH, repeating the +// last column and row to fill what the picture does not reach. dst grows only +// when what it already holds is too small. +func padPlane[P pixel](dst, src []P, stride, width, height, paddedW, paddedH int) ([]P, bool) { + if stride <= 0 || width <= 0 || height <= 0 || stride < width || + paddedW < width || paddedH < height || len(src)/stride < height { + return nil, false + } + + if cap(dst) < paddedW*paddedH { + dst = make([]P, 0, paddedW*paddedH) + } + + out := dst[:0] + + for y := range height { + row := src[y*stride : y*stride+width] + out = append(out, row...) + + for range paddedW - width { + out = append(out, row[width-1]) + } + } + + last := out[(height-1)*paddedW : height*paddedW] + for y := height; y < paddedH; y++ { + out = append(out, last...) + } + + return out, true +} + +func (e *Encoder) waveThreads() int { + if e.threads == 0 { + return 1 + } + + if e.threads < 0 { + return runtime.GOMAXPROCS(0) + } + + return e.threads +} diff --git a/third_party/h265/hevc/getbits.go b/third_party/h265/hevc/getbits.go new file mode 100644 index 00000000..b1c88d8f --- /dev/null +++ b/third_party/h265/hevc/getbits.go @@ -0,0 +1,142 @@ +package hevc + +import "math/bits" + +type getBits struct { + state uint64 + bitsLeft int + err bool + index int + data []byte +} + +func (c *getBits) init(data []byte) { + *c = getBits{data: data} +} + +func (c *getBits) refill(n int) { + var state uint32 + + for { + if c.index >= len(c.data) { + c.err = true + if state != 0 { + break + } + + return + } + + state = state<<8 | uint32(c.data[c.index]) + c.index++ + c.bitsLeft += 8 + + if n <= c.bitsLeft { + break + } + } + + c.state |= uint64(state) << (64 - c.bitsLeft) +} + +func (c *getBits) bit() uint32 { + if c.bitsLeft == 0 { + c.refill(1) + } + + state := c.state + c.bitsLeft-- + c.state = state << 1 + + return uint32(state >> 63) +} + +func (c *getBits) bits(n int) uint32 { + if n == 0 { + return 0 + } + + if uint32(n) > uint32(c.bitsLeft) { + c.refill(n) + } + + state := c.state + c.bitsLeft -= n + c.state = state << n + + return uint32(state >> (64 - n)) +} + +func (c *getBits) skip(n int) { + for n > 32 { + c.bits(32) + n -= 32 + } + + c.bits(n) +} + +func (c *getBits) ue() uint32 { + n := 0 + + for c.bit() == 0 { + n++ + + if n > 31 { + c.err = true + + return 0 + } + } + + if n == 0 { + return 0 + } + + return 1<>3, p&7 + + if byteOff >= len(c.data) { + return false + } + + last := len(c.data) + for last > byteOff && c.data[last-1] == 0 { + last-- + } + + if last <= byteOff { + return false + } + + if byteOff < last-1 { + return true + } + + return bitOff < 7-bits.TrailingZeros8(c.data[byteOff]) +} diff --git a/third_party/h265/hevc/headers.go b/third_party/h265/hevc/headers.go new file mode 100644 index 00000000..6d9083dc --- /dev/null +++ b/third_party/h265/hevc/headers.go @@ -0,0 +1,1095 @@ +package hevc + +import "errors" + +// ErrInvalid is returned for a bitstream that cannot be decoded, and +// ErrUnsupported for one using a feature this decoder does not implement. +var ( + ErrInvalid = errors.New("hevc: invalid bitstream") + ErrUnsupported = errors.New("hevc: unsupported feature") +) + +const ( + maxSubLayers = 7 + maxShortTermRPS = 64 + maxLongTermRefPics = 32 + maxRefPicsPerRPS = 16 + maxTileColumns = 22 + maxTileRows = 20 + maxPicSize = 16384 + maxChromaQPOffsets = 6 + minCtbLog2SizeY = 4 + maxCtbLog2SizeY = 6 + maxScalingListSizes = 4 + maxScalingListMats = 6 +) + +type profileTierLevel struct { + profileSpace uint8 + tierFlag bool + profileIDC uint8 + levelIDC uint8 + compatFlags uint32 +} + +type shortTermRPS struct { + deltaPocS0 []int32 + deltaPocS1 []int32 + usedS0 []bool + usedS1 []bool +} + +func (r *shortTermRPS) numDeltaPocs() int { + return len(r.deltaPocS0) + len(r.deltaPocS1) +} + +type scalingList struct { + sl [maxScalingListSizes][maxScalingListMats][64]uint8 + dc [2][maxScalingListMats]uint8 +} + +type vps struct { + id uint8 + maxLayersMinus1 uint8 + maxSubLayersMinus1 uint8 + temporalIDNesting bool + ptl profileTierLevel + maxDecPicBuffering [maxSubLayers]uint32 + maxNumReorderPics [maxSubLayers]uint32 + maxLatencyIncrease [maxSubLayers]uint32 +} + +type sps struct { + vpsID uint8 + maxSubLayersMinus1 uint8 + temporalIDNesting bool + ptl profileTierLevel + id uint32 + + chromaFormatIDC uint32 + separateColourPlane bool + subWidthC, subHeightC int + picWidthInLumaSamples uint32 + picHeightInLumaSamples uint32 + + confWinLeft, confWinRight uint32 + confWinTop, confWinBottom uint32 + + bitDepthLuma, bitDepthChroma uint8 + + // E.3.1: the color description of the sequence, unspecified unless the + // video usability information says otherwise. + colourPrimaries uint16 + transferChar uint16 + matrixCoeffs uint16 + fullRange bool + log2MaxPocLsb uint8 + + maxDecPicBuffering uint32 + maxNumReorderPics uint32 + maxLatencyIncrease uint32 + + minCbLog2SizeY uint8 + ctbLog2SizeY uint8 + ctbSizeY uint32 + minTbLog2SizeY uint8 + maxTbLog2SizeY uint8 + maxTrHierInter uint32 + maxTrHierIntra uint32 + picWidthInCtbs uint32 + picHeightInCtbs uint32 + + scalingListEnabled bool + scalingList scalingList + + ampEnabled bool + saoEnabled bool + + pcmEnabled bool + pcmBitDepthLuma uint8 + pcmBitDepthChroma uint8 + log2MinPcmCbSize uint8 + log2MaxPcmCbSize uint8 + pcmLoopFilterDisabled bool + + stRPS []shortTermRPS + longTermRefPicsPresent bool + ltRefPicPocLsb []uint32 + usedByCurrPicLt []bool + + temporalMvpEnabled bool + strongIntraSmoothing bool + transformSkipRotation bool + transformSkipContext bool + implicitRdpcm bool + explicitRdpcm bool + extendedPrecision bool + intraSmoothingDisabled bool + highPrecisionOffsets bool + persistentRiceAdaptation bool + cabacBypassAlignment bool +} + +type pps struct { + id uint32 + spsID uint32 + + dependentSliceSegmentsEnabled bool + outputFlagPresent bool + numExtraSliceHeaderBits uint8 + signDataHidingEnabled bool + cabacInitPresent bool + + numRefIdxL0DefaultActive uint32 + numRefIdxL1DefaultActive uint32 + + initQP int32 + constrainedIntraPred bool + transformSkipEnabled bool + cuQPDeltaEnabled bool + diffCuQPDeltaDepth uint32 + cbQPOffset int32 + crQPOffset int32 + sliceChromaQPOffsets bool + weightedPred bool + weightedBipred bool + transquantBypass bool + tilesEnabled bool + entropyCodingSync bool + numTileColumns int + numTileRows int + uniformSpacing bool + columnWidthMinus1 []uint32 + rowHeightMinus1 []uint32 + loopFilterAcrossTiles bool + colWidthsInCtbs []uint32 + rowHeightsInCtbs []uint32 + + loopFilterAcrossSlices bool + deblockingControlPresen bool + deblockingOverride bool + deblockingDisabled bool + betaOffsetDiv2 int32 + tcOffsetDiv2 int32 + + scalingListPresent bool + scalingList scalingList + + listsModificationPresent bool + log2ParallelMergeLevel uint32 + sliceHeaderExtensionPresen bool + + log2MaxTransformSkipSize uint32 + crossComponentPrediction bool + chromaQPOffsetList bool + diffCuChromaQPOffsetDep uint32 + chromaQPOffsetListLen uint32 + cbQPOffsetList [maxChromaQPOffsets]int32 + crQPOffsetList [maxChromaQPOffsets]int32 + log2SaoOffsetScaleLuma uint32 + log2SaoOffsetScaleChroma uint32 +} + +func parseProfileTierLevel(c *getBits, profilePresent bool, maxSubLayersMinus1 uint8) profileTierLevel { + var p profileTierLevel + + if profilePresent { + p.profileSpace = uint8(c.bits(2)) + p.tierFlag = c.bit() != 0 + p.profileIDC = uint8(c.bits(5)) + p.compatFlags = c.bits(32) + c.skip(48) + } + + p.levelIDC = uint8(c.bits(8)) + + if maxSubLayersMinus1 == 0 { + return p + } + + var profilePresentSub, levelPresentSub [maxSubLayers]bool + + for i := range int(maxSubLayersMinus1) { + profilePresentSub[i] = c.bit() != 0 + levelPresentSub[i] = c.bit() != 0 + } + + for range 8 - int(maxSubLayersMinus1) { + c.bits(2) + } + + for i := range int(maxSubLayersMinus1) { + if profilePresentSub[i] { + c.skip(88) + } + + if levelPresentSub[i] { + c.bits(8) + } + } + + return p +} + +func parseSubLayerHRD(c *getBits, cpbCnt int, subPicPresent bool) { + for range cpbCnt { + c.ue() + c.ue() + + if subPicPresent { + c.ue() + c.ue() + } + + c.bit() + } +} + +func parseHRD(c *getBits, commonInfPresent bool, maxSubLayersMinus1 uint8) error { + var nalHRD, vclHRD, subPicPresent bool + + if commonInfPresent { + nalHRD = c.bit() != 0 + vclHRD = c.bit() != 0 + + if nalHRD || vclHRD { + subPicPresent = c.bit() != 0 + if subPicPresent { + c.bits(8) + c.bits(5) + c.bit() + c.bits(5) + } + + c.bits(4) + c.bits(4) + + if subPicPresent { + c.bits(4) + } + + c.bits(5) + c.bits(5) + c.bits(5) + } + } + + for range int(maxSubLayersMinus1) + 1 { + fixedRate := c.bit() != 0 + if !fixedRate { + fixedRate = c.bit() != 0 + } + + lowDelay := false + + if fixedRate { + c.ue() + } else { + lowDelay = c.bit() != 0 + } + + cpbCnt := 1 + if !lowDelay { + n := c.ue() + if n > 31 { + return ErrInvalid + } + + cpbCnt = int(n) + 1 + } + + if nalHRD { + parseSubLayerHRD(c, cpbCnt, subPicPresent) + } + + if vclHRD { + parseSubLayerHRD(c, cpbCnt, subPicPresent) + } + } + + return nil +} + +func parseVUI(c *getBits, s *sps, maxSubLayersMinus1 uint8) error { + if c.bit() != 0 { + if c.bits(8) == 255 { + c.bits(16) + c.bits(16) + } + } + + if c.bit() != 0 { + c.bit() + } + + // E.2.1 video_signal_type. The color description defaults to unspecified, + // which newPicture carries so a container with no description of its own + // can fall back to what the sequence declares. + if c.bit() != 0 { + c.bits(3) + + s.fullRange = c.bit() != 0 + + if c.bit() != 0 { + s.colourPrimaries = uint16(c.bits(8)) + s.transferChar = uint16(c.bits(8)) + s.matrixCoeffs = uint16(c.bits(8)) + } + } + + if c.bit() != 0 { + c.ue() + c.ue() + } + + c.bit() + c.bit() + c.bit() + + if c.bit() != 0 { + c.ue() + c.ue() + c.ue() + c.ue() + } + + if c.bit() != 0 { + c.bits(32) + c.bits(32) + + if c.bit() != 0 { + c.ue() + } + + if c.bit() != 0 { + if err := parseHRD(c, true, maxSubLayersMinus1); err != nil { + return err + } + } + } + + if c.bit() != 0 { + c.bit() + c.bit() + c.bit() + c.ue() + c.ue() + c.ue() + c.ue() + c.ue() + } + + return nil +} + +func parseVPS(rbsp []byte) (*vps, error) { + var c getBits + c.init(rbsp) + + v := &vps{} + v.id = uint8(c.bits(4)) + c.bits(2) + v.maxLayersMinus1 = uint8(c.bits(6)) + v.maxSubLayersMinus1 = uint8(c.bits(3)) + + if v.maxSubLayersMinus1 > maxSubLayers-1 { + return nil, ErrInvalid + } + + v.temporalIDNesting = c.bit() != 0 + + if c.bits(16) != 0xffff { + return nil, ErrInvalid + } + + v.ptl = parseProfileTierLevel(&c, true, v.maxSubLayersMinus1) + + start := int(v.maxSubLayersMinus1) + if c.bit() != 0 { + start = 0 + } + + for i := start; i <= int(v.maxSubLayersMinus1); i++ { + v.maxDecPicBuffering[i] = c.ue() + v.maxNumReorderPics[i] = c.ue() + v.maxLatencyIncrease[i] = c.ue() + if v.maxDecPicBuffering[i] > 15 || v.maxNumReorderPics[i] > v.maxDecPicBuffering[i] { + return nil, ErrInvalid + } + } + + maxLayerID := int(c.bits(6)) + + numLayerSetsMinus1 := c.ue() + if numLayerSetsMinus1 > 1023 { + return nil, ErrInvalid + } + + for range int(numLayerSetsMinus1) { + c.skip(maxLayerID + 1) + } + + if c.bit() != 0 { + c.bits(32) + c.bits(32) + + if c.bit() != 0 { + c.ue() + } + + numHRD := c.ue() + if numHRD > 1024 { + return nil, ErrInvalid + } + + for i := range int(numHRD) { + c.ue() + + commonInfPresent := true + if i > 0 { + commonInfPresent = c.bit() != 0 + } + + if err := parseHRD(&c, commonInfPresent, v.maxSubLayersMinus1); err != nil { + return nil, err + } + } + } + + extension := c.bit() != 0 + + if err := checkTrailing(&c, extension); err != nil { + return nil, err + } + + return v, nil +} + +func checkTrailing(c *getBits, extension bool) error { + if c.err { + return ErrInvalid + } + + if !extension && c.moreRBSPData() { + return ErrInvalid + } + + return nil +} + +func parseSPS(rbsp []byte) (*sps, error) { + var c getBits + c.init(rbsp) + + // E.3.1 infers all three as unspecified when the sequence does not say. + s := &sps{colourPrimaries: 2, transferChar: 2, matrixCoeffs: 2} + s.vpsID = uint8(c.bits(4)) + s.maxSubLayersMinus1 = uint8(c.bits(3)) + + if s.maxSubLayersMinus1 > maxSubLayers-1 { + return nil, ErrInvalid + } + + s.temporalIDNesting = c.bit() != 0 + s.ptl = parseProfileTierLevel(&c, true, s.maxSubLayersMinus1) + + s.id = c.ue() + if s.id > 15 { + return nil, ErrInvalid + } + + s.chromaFormatIDC = c.ue() + if s.chromaFormatIDC > 3 { + return nil, ErrInvalid + } + + if s.chromaFormatIDC == 3 { + s.separateColourPlane = c.bit() != 0 + } + + s.subWidthC, s.subHeightC = 1, 1 + + switch s.chromaFormatIDC { + case 1: + s.subWidthC, s.subHeightC = 2, 2 + case 2: + s.subWidthC = 2 + } + + s.picWidthInLumaSamples = c.ue() + s.picHeightInLumaSamples = c.ue() + + if s.picWidthInLumaSamples == 0 || s.picHeightInLumaSamples == 0 || + s.picWidthInLumaSamples > maxPicSize || s.picHeightInLumaSamples > maxPicSize { + return nil, ErrInvalid + } + + if c.bit() != 0 { + s.confWinLeft = c.ue() + s.confWinRight = c.ue() + s.confWinTop = c.ue() + s.confWinBottom = c.ue() + } + + bitDepthLumaMinus8 := c.ue() + bitDepthChromaMinus8 := c.ue() + + if bitDepthLumaMinus8 > 8 || bitDepthChromaMinus8 > 8 { + return nil, ErrUnsupported + } + + s.bitDepthLuma = 8 + uint8(bitDepthLumaMinus8) + s.bitDepthChroma = 8 + uint8(bitDepthChromaMinus8) + + log2MaxPocLsbMinus4 := c.ue() + if log2MaxPocLsbMinus4 > 12 { + return nil, ErrInvalid + } + + s.log2MaxPocLsb = 4 + uint8(log2MaxPocLsbMinus4) + + start := int(s.maxSubLayersMinus1) + if c.bit() != 0 { + start = 0 + } + + for i := start; i <= int(s.maxSubLayersMinus1); i++ { + s.maxDecPicBuffering = c.ue() + s.maxNumReorderPics = c.ue() + s.maxLatencyIncrease = c.ue() + // HEVC's decoded-picture buffer holds at most 16 pictures; the + // syntax element stores that capacity minus one. + if s.maxDecPicBuffering > 15 || s.maxNumReorderPics > s.maxDecPicBuffering { + return nil, ErrInvalid + } + } + + log2MinCbSizeMinus3 := c.ue() + log2DiffMaxMinCbSize := c.ue() + + if log2MinCbSizeMinus3 > 3 || log2DiffMaxMinCbSize > 3 { + return nil, ErrInvalid + } + + s.minCbLog2SizeY = 3 + uint8(log2MinCbSizeMinus3) + s.ctbLog2SizeY = s.minCbLog2SizeY + uint8(log2DiffMaxMinCbSize) + + if s.ctbLog2SizeY < minCtbLog2SizeY || s.ctbLog2SizeY > maxCtbLog2SizeY { + return nil, ErrInvalid + } + + // 7.4.3.2: both dimensions are an integer multiple of MinCbSizeY. Without + // it a coding unit at the edge extends past the picture, and the block + // bookkeeping is sized for what the picture holds. + if mask := uint32(1)< 3 || log2DiffMaxMinTbSize > 3 { + return nil, ErrInvalid + } + + s.minTbLog2SizeY = 2 + uint8(log2MinTbSizeMinus2) + s.maxTbLog2SizeY = s.minTbLog2SizeY + uint8(log2DiffMaxMinTbSize) + + if s.minTbLog2SizeY >= s.minCbLog2SizeY || s.maxTbLog2SizeY > min(s.ctbLog2SizeY, 5) { + return nil, ErrInvalid + } + + s.maxTrHierInter = c.ue() + s.maxTrHierIntra = c.ue() + + s.scalingList = defaultScalingList() + + s.scalingListEnabled = c.bit() != 0 + if s.scalingListEnabled && c.bit() != 0 { + if err := parseScalingListData(&c, &s.scalingList); err != nil { + return nil, err + } + } + + s.ampEnabled = c.bit() != 0 + s.saoEnabled = c.bit() != 0 + + s.log2MinPcmCbSize, s.log2MaxPcmCbSize = 8, 0 + s.pcmBitDepthLuma, s.pcmBitDepthChroma = s.bitDepthLuma, s.bitDepthChroma + + s.pcmEnabled = c.bit() != 0 + if s.pcmEnabled { + s.pcmBitDepthLuma = 1 + uint8(c.bits(4)) + s.pcmBitDepthChroma = 1 + uint8(c.bits(4)) + + if s.pcmBitDepthLuma > s.bitDepthLuma || s.pcmBitDepthChroma > s.bitDepthChroma { + return nil, ErrInvalid + } + + log2MinPcmCbSizeMinus3 := c.ue() + log2DiffMaxMinPcmCbSize := c.ue() + + if log2MinPcmCbSizeMinus3 > 2 || log2DiffMaxMinPcmCbSize > 2 { + return nil, ErrInvalid + } + + s.log2MinPcmCbSize = 3 + uint8(log2MinPcmCbSizeMinus3) + s.log2MaxPcmCbSize = s.log2MinPcmCbSize + uint8(log2DiffMaxMinPcmCbSize) + s.pcmLoopFilterDisabled = c.bit() != 0 + } + + numStRPS := c.ue() + if numStRPS > maxShortTermRPS { + return nil, ErrInvalid + } + + s.stRPS = make([]shortTermRPS, 0, numStRPS) + + for i := range int(numStRPS) { + rps, err := parseShortTermRPS(&c, i, int(numStRPS), s.stRPS) + if err != nil { + return nil, err + } + + s.stRPS = append(s.stRPS, rps) + } + + s.longTermRefPicsPresent = c.bit() != 0 + if s.longTermRefPicsPresent { + n := c.ue() + if n > maxLongTermRefPics { + return nil, ErrInvalid + } + + s.ltRefPicPocLsb = make([]uint32, n) + s.usedByCurrPicLt = make([]bool, n) + + for i := range int(n) { + s.ltRefPicPocLsb[i] = c.bits(int(s.log2MaxPocLsb)) + s.usedByCurrPicLt[i] = c.bit() != 0 + } + } + + s.temporalMvpEnabled = c.bit() != 0 + s.strongIntraSmoothing = c.bit() != 0 + + if c.bit() != 0 { + if err := parseVUI(&c, s, s.maxSubLayersMinus1); err != nil { + return nil, err + } + } + + unparsed := false + + if c.bit() != 0 { + rangeExtension := c.bit() != 0 + multilayerExtension := c.bit() != 0 + extension3D := c.bit() != 0 + sccExtension := c.bit() != 0 + unparsed = c.bits(4) != 0 + + if rangeExtension { + s.transformSkipRotation = c.bit() != 0 + s.transformSkipContext = c.bit() != 0 + s.implicitRdpcm = c.bit() != 0 + s.explicitRdpcm = c.bit() != 0 + s.extendedPrecision = c.bit() != 0 + s.intraSmoothingDisabled = c.bit() != 0 + s.highPrecisionOffsets = c.bit() != 0 + s.persistentRiceAdaptation = c.bit() != 0 + s.cabacBypassAlignment = c.bit() != 0 + } + + // The tools below are parsed so the extension stays in step, but they + // are not applied. A stream that uses one is refused rather than + // decoded into something that merely looks plausible. + if s.implicitRdpcm || s.explicitRdpcm || s.cabacBypassAlignment { + return nil, ErrUnsupported + } + + if multilayerExtension || extension3D || sccExtension { + return nil, ErrUnsupported + } + } + + if err := checkTrailing(&c, unparsed); err != nil { + return nil, err + } + + return s, nil +} + +func parsePPS(rbsp []byte) (*pps, error) { + var c getBits + c.init(rbsp) + + p := &pps{} + p.id = c.ue() + p.spsID = c.ue() + + if p.id > 63 || p.spsID > 15 { + return nil, ErrInvalid + } + + p.dependentSliceSegmentsEnabled = c.bit() != 0 + p.outputFlagPresent = c.bit() != 0 + p.numExtraSliceHeaderBits = uint8(c.bits(3)) + p.signDataHidingEnabled = c.bit() != 0 + p.cabacInitPresent = c.bit() != 0 + p.numRefIdxL0DefaultActive = c.ue() + 1 + p.numRefIdxL1DefaultActive = c.ue() + 1 + + if p.numRefIdxL0DefaultActive > 16 || p.numRefIdxL1DefaultActive > 16 { + return nil, ErrInvalid + } + + p.initQP = c.se() + 26 + p.constrainedIntraPred = c.bit() != 0 + p.transformSkipEnabled = c.bit() != 0 + + p.cuQPDeltaEnabled = c.bit() != 0 + if p.cuQPDeltaEnabled { + p.diffCuQPDeltaDepth = c.ue() + } + + p.cbQPOffset = c.se() + p.crQPOffset = c.se() + + if p.cbQPOffset < -12 || p.cbQPOffset > 12 || p.crQPOffset < -12 || p.crQPOffset > 12 { + return nil, ErrInvalid + } + + p.sliceChromaQPOffsets = c.bit() != 0 + p.weightedPred = c.bit() != 0 + p.weightedBipred = c.bit() != 0 + p.transquantBypass = c.bit() != 0 + p.tilesEnabled = c.bit() != 0 + p.entropyCodingSync = c.bit() != 0 + + p.log2MaxTransformSkipSize = 2 + p.numTileColumns, p.numTileRows = 1, 1 + p.uniformSpacing = true + p.loopFilterAcrossTiles = true + + if p.tilesEnabled { + p.numTileColumns = int(c.ue()) + 1 + p.numTileRows = int(c.ue()) + 1 + + if p.numTileColumns > maxTileColumns || p.numTileRows > maxTileRows { + return nil, ErrInvalid + } + + p.uniformSpacing = c.bit() != 0 + if !p.uniformSpacing { + p.columnWidthMinus1 = make([]uint32, p.numTileColumns-1) + for i := range p.columnWidthMinus1 { + p.columnWidthMinus1[i] = c.ue() + } + + p.rowHeightMinus1 = make([]uint32, p.numTileRows-1) + for i := range p.rowHeightMinus1 { + p.rowHeightMinus1[i] = c.ue() + } + } + + p.loopFilterAcrossTiles = c.bit() != 0 + } + + p.loopFilterAcrossSlices = c.bit() != 0 + + p.deblockingControlPresen = c.bit() != 0 + if p.deblockingControlPresen { + p.deblockingOverride = c.bit() != 0 + p.deblockingDisabled = c.bit() != 0 + + if !p.deblockingDisabled { + p.betaOffsetDiv2 = c.se() + p.tcOffsetDiv2 = c.se() + + if p.betaOffsetDiv2 < -6 || p.betaOffsetDiv2 > 6 || + p.tcOffsetDiv2 < -6 || p.tcOffsetDiv2 > 6 { + return nil, ErrInvalid + } + } + } + + p.scalingList = defaultScalingList() + + p.scalingListPresent = c.bit() != 0 + if p.scalingListPresent { + if err := parseScalingListData(&c, &p.scalingList); err != nil { + return nil, err + } + } + + p.listsModificationPresent = c.bit() != 0 + p.log2ParallelMergeLevel = c.ue() + 2 + p.sliceHeaderExtensionPresen = c.bit() != 0 + + unparsed := false + + if c.bit() != 0 { + rangeExtension := c.bit() != 0 + multilayerExtension := c.bit() != 0 + extension3D := c.bit() != 0 + sccExtension := c.bit() != 0 + unparsed = c.bits(4) != 0 + + if rangeExtension { + if p.transformSkipEnabled { + p.log2MaxTransformSkipSize = c.ue() + 2 + } + + p.crossComponentPrediction = c.bit() != 0 + + p.chromaQPOffsetList = c.bit() != 0 + if p.chromaQPOffsetList { + p.diffCuChromaQPOffsetDep = c.ue() + p.chromaQPOffsetListLen = c.ue() + 1 + + if p.chromaQPOffsetListLen > maxChromaQPOffsets { + return nil, ErrInvalid + } + + for i := range int(p.chromaQPOffsetListLen) { + p.cbQPOffsetList[i] = c.se() + p.crQPOffsetList[i] = c.se() + } + } + + p.log2SaoOffsetScaleLuma = c.ue() + p.log2SaoOffsetScaleChroma = c.ue() + } + + // Parsed to keep the extension in step, but not applied. + if p.crossComponentPrediction { + return nil, ErrUnsupported + } + + if multilayerExtension || extension3D || sccExtension { + return nil, ErrUnsupported + } + } + + if err := checkTrailing(&c, unparsed); err != nil { + return nil, err + } + + return p, nil +} + +func (p *pps) resolveTileGeometry(s *sps) error { + w, h := int(s.picWidthInCtbs), int(s.picHeightInCtbs) + + if !p.tilesEnabled { + p.colWidthsInCtbs = []uint32{uint32(w)} + p.rowHeightsInCtbs = []uint32{uint32(h)} + + return nil + } + + if p.numTileColumns > w || p.numTileRows > h { + return ErrInvalid + } + + cols := make([]uint32, p.numTileColumns) + rows := make([]uint32, p.numTileRows) + + if p.uniformSpacing { + for i := range cols { + cols[i] = uint32((i+1)*w/p.numTileColumns - i*w/p.numTileColumns) + } + + for i := range rows { + rows[i] = uint32((i+1)*h/p.numTileRows - i*h/p.numTileRows) + } + } else { + var sum uint32 + + for i, v := range p.columnWidthMinus1 { + cols[i] = v + 1 + sum += cols[i] + } + + if sum >= uint32(w) { + return ErrInvalid + } + + cols[len(cols)-1] = uint32(w) - sum + + sum = 0 + + for i, v := range p.rowHeightMinus1 { + rows[i] = v + 1 + sum += rows[i] + } + + if sum >= uint32(h) { + return ErrInvalid + } + + rows[len(rows)-1] = uint32(h) - sum + } + + p.colWidthsInCtbs = cols + p.rowHeightsInCtbs = rows + + return nil +} + +func (s *sps) croppedWidth() uint32 { + crop := uint32(s.subWidthC) * (s.confWinLeft + s.confWinRight) + if crop >= s.picWidthInLumaSamples { + return 0 + } + + return s.picWidthInLumaSamples - crop +} + +func (s *sps) croppedHeight() uint32 { + crop := uint32(s.subHeightC) * (s.confWinTop + s.confWinBottom) + if crop >= s.picHeightInLumaSamples { + return 0 + } + + return s.picHeightInLumaSamples - crop +} + +func parseShortTermRPS(c *getBits, idx, numStRPS int, prev []shortTermRPS) (shortTermRPS, error) { + var rps shortTermRPS + + interPred := false + if idx != 0 { + interPred = c.bit() != 0 + } + + if interPred { + deltaIdx := 1 + if idx == numStRPS { + deltaIdx = int(c.ue()) + 1 + } + + if deltaIdx > idx { + return rps, ErrInvalid + } + + ref := &prev[idx-deltaIdx] + + sign := c.bit() != 0 + + absDeltaRpsMinus1 := c.ue() + if absDeltaRpsMinus1 >= 32768 { + return rps, ErrInvalid + } + + deltaRps := int32(absDeltaRpsMinus1) + 1 + if sign { + deltaRps = -deltaRps + } + + n := ref.numDeltaPocs() + used := make([]bool, n+1) + useDelta := make([]bool, n+1) + + for j := range n + 1 { + used[j] = c.bit() != 0 + + useDelta[j] = true + if !used[j] { + useDelta[j] = c.bit() != 0 + } + } + + numNeg, numPos := len(ref.deltaPocS0), len(ref.deltaPocS1) + + for j := numPos - 1; j >= 0; j-- { + if d := ref.deltaPocS1[j] + deltaRps; d < 0 && useDelta[numNeg+j] { + rps.deltaPocS0 = append(rps.deltaPocS0, d) + rps.usedS0 = append(rps.usedS0, used[numNeg+j]) + } + } + + if deltaRps < 0 && useDelta[n] { + rps.deltaPocS0 = append(rps.deltaPocS0, deltaRps) + rps.usedS0 = append(rps.usedS0, used[n]) + } + + for j := range numNeg { + if d := ref.deltaPocS0[j] + deltaRps; d < 0 && useDelta[j] { + rps.deltaPocS0 = append(rps.deltaPocS0, d) + rps.usedS0 = append(rps.usedS0, used[j]) + } + } + + for j := numNeg - 1; j >= 0; j-- { + if d := ref.deltaPocS0[j] + deltaRps; d > 0 && useDelta[j] { + rps.deltaPocS1 = append(rps.deltaPocS1, d) + rps.usedS1 = append(rps.usedS1, used[j]) + } + } + + if deltaRps > 0 && useDelta[n] { + rps.deltaPocS1 = append(rps.deltaPocS1, deltaRps) + rps.usedS1 = append(rps.usedS1, used[n]) + } + + for j := range numPos { + if d := ref.deltaPocS1[j] + deltaRps; d > 0 && useDelta[numNeg+j] { + rps.deltaPocS1 = append(rps.deltaPocS1, d) + rps.usedS1 = append(rps.usedS1, used[numNeg+j]) + } + } + + return rps, nil + } + + numNeg := c.ue() + numPos := c.ue() + + if numNeg > maxRefPicsPerRPS || numPos > maxRefPicsPerRPS { + return rps, ErrInvalid + } + + rps.deltaPocS0 = make([]int32, numNeg) + rps.usedS0 = make([]bool, numNeg) + + var poc int32 + + for i := range int(numNeg) { + d := c.ue() + if d >= 32768 { + return rps, ErrInvalid + } + + poc -= int32(d) + 1 + rps.deltaPocS0[i] = poc + rps.usedS0[i] = c.bit() != 0 + } + + rps.deltaPocS1 = make([]int32, numPos) + rps.usedS1 = make([]bool, numPos) + + poc = 0 + + for i := range int(numPos) { + d := c.ue() + if d >= 32768 { + return rps, ErrInvalid + } + + poc += int32(d) + 1 + rps.deltaPocS1[i] = poc + rps.usedS1[i] = c.bit() != 0 + } + + return rps, nil +} + +func ceilDiv(a, b uint32) uint32 { + return (a + b - 1) / b +} diff --git a/third_party/h265/hevc/headersenc.go b/third_party/h265/hevc/headersenc.go new file mode 100644 index 00000000..67441c93 --- /dev/null +++ b/third_party/h265/hevc/headersenc.go @@ -0,0 +1,215 @@ +package hevc + +type encoderHeaders struct { + // width and height are the coded picture, which 7.4.3.2 requires to be a + // multiple of the minimum coding block size. cropRight and cropBottom are + // the luma samples the conformance window hides beyond the real picture. + width, height int + cropRight, cropBottom int + // chromaFormat is chroma_format_idc, and subWidthC and subHeightC the + // units 7.4.3.2 counts the conformance window offsets in. + chromaFormat uint32 + subWidthC, subHeightC int + bitDepth int + levelIDC uint8 + pcm bool + deblockingDisabled bool + sao bool + signDataHidingEnabled bool + ctbLog2 uint8 + maxTrHierIntra uint32 + wavefront bool +} + +// parameterSets is the video, sequence and picture parameter sets a slice +// coded with these headers needs in front of it. +func (h encoderHeaders) parameterSets() []NALUnit { + return []NALUnit{ + {Type: NALVPS, RBSP: h.vps()}, + {Type: NALSPS, RBSP: h.sps()}, + {Type: NALPPS, RBSP: h.pps()}, + } +} + +// profileTier is 7.3.3: Main tier, progressive frames only. The High tier is +// not defined for the levels this encoder reaches. A.3.2 gives Main eight bit +// 4:2:0 alone and A.3.3 gives Main 10 ten bits of it; anything else is the +// range extension of A.3.5, which spells its limits out in constraint flags. +func (h encoderHeaders) profileTier(w *putBits) { + chroma, depth := h.chromaFormat, max(h.bitDepth, 8) + idc, compat := profileIDC(chroma, depth) + + w.bits(0, 2) + w.bit(0) + w.bits(uint64(idc), 5) + w.bits(uint64(compat), 32) + w.bit(1) + w.bit(0) + w.bit(1) + w.bit(1) + + if idc != 4 { + w.bits(0, 44) + w.bits(uint64(h.levelIDC), 8) + + return + } + + for _, set := range [9]bool{ + depth <= 12, depth <= 10, depth <= 8, + chroma <= 2, chroma <= 1, chroma == 0, + true, false, true, + } { + w.bit(boolToBit(set)) + } + + w.bits(0, 35) + w.bits(uint64(h.levelIDC), 8) +} + +// profileIDC is general_profile_idc and the compatibility flags that go with +// it, the flag for profile j sitting j bits down from the top. +func profileIDC(chromaFormat uint32, bitDepth int) (uint32, uint32) { + switch { + case chromaFormat != 1 || bitDepth > 10: + return 4, 1 << (31 - 4) + case bitDepth > 8: + return 2, 1 << (31 - 2) + default: + return 1, 1<<(31-1) | 1<<(31-2) + } +} + +func (h encoderHeaders) vps() []byte { + var w putBits + w.bits(0, 4) + w.bit(1) + w.bit(1) + w.bits(0, 6) + w.bits(0, 3) + w.bit(1) + w.bits(0xffff, 16) + h.profileTier(&w) + w.bit(1) + w.ue(1) + w.ue(0) + w.ue(0) + w.bits(0, 6) + w.ue(0) + w.bit(0) + w.bit(0) + w.rbspTrailingBits() + + return w.bytes() +} + +func (h encoderHeaders) sps() []byte { + var w putBits + w.bits(0, 4) + w.bits(0, 3) + w.bit(1) + h.profileTier(&w) + w.ue(0) + w.ue(h.chromaFormat) + if h.chromaFormat == 3 { + w.bit(0) + } + w.ue(uint32(h.width)) + w.ue(uint32(h.height)) + if h.cropRight|h.cropBottom != 0 { + w.bit(1) + w.ue(0) + w.ue(uint32(h.cropRight / max(h.subWidthC, 1))) + w.ue(0) + w.ue(uint32(h.cropBottom / max(h.subHeightC, 1))) + } else { + w.bit(0) + } + w.ue(uint32(max(h.bitDepth, 8) - 8)) + w.ue(uint32(max(h.bitDepth, 8) - 8)) + w.ue(4) + w.bit(1) + w.ue(1) + w.ue(0) + w.ue(0) + w.ue(1) + ctbLog2 := h.ctbLog2 + if ctbLog2 == 0 { + ctbLog2 = 4 + } + w.ue(uint32(ctbLog2 - 4)) + w.ue(0) + // 7.4.3.2 caps the transform at the coding tree block, and at 32. + w.ue(uint32(min(ctbLog2, 5) - 2)) + w.ue(0) + w.ue(h.maxTrHierIntra) + w.bit(0) + w.bit(0) + w.bit(boolToBit(h.sao)) + w.bit(boolToBit(h.pcm)) + if h.pcm { + w.bits(uint64(max(h.bitDepth, 8)-1), 4) + w.bits(uint64(max(h.bitDepth, 8)-1), 4) + w.ue(1) + w.ue(0) + w.bit(1) + } + w.ue(0) + w.bit(0) + w.bit(0) + w.bit(0) + w.bit(0) + w.bit(0) + w.rbspTrailingBits() + + return w.bytes() +} + +func (h encoderHeaders) pps() []byte { + var w putBits + w.ue(0) + w.ue(0) + w.bit(0) + w.bit(0) + w.bits(0, 3) + w.bit(boolToBit(h.signDataHidingEnabled)) + w.bit(0) + w.ue(0) + w.ue(0) + w.se(0) + w.bit(0) + w.bit(0) + w.bit(0) + w.se(0) + w.se(0) + w.bit(0) + w.bit(0) + w.bit(0) + w.bit(0) + w.bit(0) + w.bit(boolToBit(h.wavefront)) + w.bit(1) + w.bit(1) + w.bit(0) + w.bit(boolToBit(h.deblockingDisabled)) + if !h.deblockingDisabled { + w.se(0) + w.se(0) + } + w.bit(0) + w.bit(0) + w.ue(0) + w.bit(0) + w.bit(0) + w.rbspTrailingBits() + + return w.bytes() +} + +func boolToBit(v bool) uint32 { + if v { + return 1 + } + + return 0 +} diff --git a/third_party/h265/hevc/idct_amd64.s b/third_party/h265/hevc/idct_amd64.s new file mode 100644 index 00000000..8bbb9c9b --- /dev/null +++ b/third_party/h265/hevc/idct_amd64.s @@ -0,0 +1,147 @@ +//go:build amd64 && !noasm + +#include "textflag.h" + +// func idctCols8AVX2(dst, src, m *int32, n, mstride, shift int, rnd, lo, hi int32) +// +// One pass of 8.6.4.2 over eight columns at a time. Outputs are produced four +// at a time so the even and odd accumulators for the tile stay in registers, +// and j is unrolled by two so the parity is static. +TEXT ·idctCols8AVX2(SB), NOSPLIT, $0-60 + MOVQ dst+0(FP), DI + MOVQ src+8(FP), SI + MOVQ m+16(FP), R14 + MOVQ n+24(FP), R8 + MOVQ mstride+32(FP), R10 + MOVQ shift+40(FP), CX + + SHLQ $2, R10 + + MOVL rnd+48(FP), AX + MOVQ AX, X10 + VPBROADCASTD X10, Y10 + MOVL lo+52(FP), AX + MOVQ AX, X11 + VPBROADCASTD X11, Y11 + MOVL hi+56(FP), AX + MOVQ AX, X12 + VPBROADCASTD X12, Y12 + MOVQ CX, X13 + + MOVQ R8, R9 + SHRQ $1, R9 + + MOVQ R8, R15 + SHLQ $2, R15 + + XORQ BX, BX + +xloop: + XORQ R11, R11 + +iloop: + VPXOR Y0, Y0, Y0 + VPXOR Y1, Y1, Y1 + VPXOR Y2, Y2, Y2 + VPXOR Y3, Y3, Y3 + VPXOR Y4, Y4, Y4 + VPXOR Y5, Y5, Y5 + VPXOR Y6, Y6, Y6 + VPXOR Y7, Y7, Y7 + + LEAQ (SI)(BX*4), DX + LEAQ (R14)(R11*4), AX + + MOVQ R8, R12 + +jloop: + VMOVDQU (DX), Y8 + VPTEST Y8, Y8 + JZ skipeven + + VPBROADCASTD (AX), Y9 + VPMULLD Y8, Y9, Y9 + VPADDD Y9, Y0, Y0 + VPBROADCASTD 4(AX), Y9 + VPMULLD Y8, Y9, Y9 + VPADDD Y9, Y1, Y1 + VPBROADCASTD 8(AX), Y9 + VPMULLD Y8, Y9, Y9 + VPADDD Y9, Y2, Y2 + VPBROADCASTD 12(AX), Y9 + VPMULLD Y8, Y9, Y9 + VPADDD Y9, Y3, Y3 + +skipeven: + ADDQ R15, DX + ADDQ R10, AX + + VMOVDQU (DX), Y8 + VPTEST Y8, Y8 + JZ skipodd + + VPBROADCASTD (AX), Y9 + VPMULLD Y8, Y9, Y9 + VPADDD Y9, Y4, Y4 + VPBROADCASTD 4(AX), Y9 + VPMULLD Y8, Y9, Y9 + VPADDD Y9, Y5, Y5 + VPBROADCASTD 8(AX), Y9 + VPMULLD Y8, Y9, Y9 + VPADDD Y9, Y6, Y6 + VPBROADCASTD 12(AX), Y9 + VPMULLD Y8, Y9, Y9 + VPADDD Y9, Y7, Y7 + +skipodd: + ADDQ R15, DX + ADDQ R10, AX + + SUBQ $2, R12 + JNZ jloop + + // The low output rows run forward from i0, the high ones backward from + // n-1-i0, which is the M[j][n-1-i] symmetry. + MOVQ R11, AX + IMULQ R15, AX + LEAQ (DI)(AX*1), R13 + LEAQ (R13)(BX*4), R13 + + MOVQ R8, AX + DECQ AX + SUBQ R11, AX + IMULQ R15, AX + LEAQ (DI)(AX*1), R12 + LEAQ (R12)(BX*4), R12 + +#define EMIT(Ye, Yo) \ + VPADDD Yo, Ye, Y8 \ + VPSUBD Yo, Ye, Y9 \ + VPADDD Y10, Y8, Y8 \ + VPADDD Y10, Y9, Y9 \ + VPSRAD X13, Y8, Y8 \ + VPSRAD X13, Y9, Y9 \ + VPMAXSD Y11, Y8, Y8 \ + VPMINSD Y12, Y8, Y8 \ + VPMAXSD Y11, Y9, Y9 \ + VPMINSD Y12, Y9, Y9 \ + VMOVDQU Y8, (R13) \ + VMOVDQU Y9, (R12) \ + ADDQ R15, R13 \ + SUBQ R15, R12 + + EMIT(Y0, Y4) + EMIT(Y1, Y5) + EMIT(Y2, Y6) + EMIT(Y3, Y7) + + ADDQ $4, R11 + CMPQ R11, R9 + JLT iloop + + ADDQ $8, BX + CMPQ BX, R8 + JLT xloop + + VZEROUPPER + RET diff --git a/third_party/h265/hevc/idct_arm64.s b/third_party/h265/hevc/idct_arm64.s new file mode 100644 index 00000000..66b256f0 --- /dev/null +++ b/third_party/h265/hevc/idct_arm64.s @@ -0,0 +1,152 @@ +//go:build arm64 && !noasm + +#include "textflag.h" + +#define MUL4S(Vd, Vn, Vm) WORD $(0x4ea09c00 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define SSHL4S(Vd, Vn, Vm) WORD $(0x4ea04400 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define SMAX4S(Vd, Vn, Vm) WORD $(0x4ea06400 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define SMIN4S(Vd, Vn, Vm) WORD $(0x4ea06c00 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) + +// func idctCols4NEON(dst, src, m *int32, n, mstride, shift int, rnd, lo, hi int32) +// +// One pass of 8.6.4.2 over four columns at a time. Outputs come four at a +// time so the even and odd accumulators stay in registers, and j is unrolled +// by two so the parity is static. +TEXT ·idctCols4NEON(SB), NOSPLIT, $0-60 + MOVD dst+0(FP), R0 + MOVD src+8(FP), R1 + MOVD m+16(FP), R2 + MOVD n+24(FP), R3 + MOVD mstride+32(FP), R4 + MOVD shift+40(FP), R5 + MOVW rnd+48(FP), R6 + MOVW lo+52(FP), R7 + MOVW hi+56(FP), R19 + + LSL $2, R4 + + VDUP R6, V10.S4 + VDUP R7, V11.S4 + VDUP R19, V12.S4 + NEG R5, R8 + VDUP R8, V13.S4 + + LSR $1, R3, R9 + LSL $2, R3, R10 + + MOVD $0, R11 + +xloop: + MOVD $0, R12 + +iloop: + VMOVI $0, V0.B16 + VMOVI $0, V1.B16 + VMOVI $0, V2.B16 + VMOVI $0, V3.B16 + VMOVI $0, V4.B16 + VMOVI $0, V5.B16 + VMOVI $0, V6.B16 + VMOVI $0, V7.B16 + + ADD R11<<2, R1, R13 + ADD R12<<2, R2, R14 + + MOVD R3, R15 + +jloop: + VLD1 (R13), [V8.S4] + VMOV V8.D[0], R16 + VMOV V8.D[1], R17 + ORR R17, R16, R16 + CBZ R16, skipeven + + VLD1 (R14), [V9.S4] + + VDUP V9.S[0], V14.S4 + MUL4S(14, 14, 8) + VADD V14.S4, V0.S4, V0.S4 + VDUP V9.S[1], V14.S4 + MUL4S(14, 14, 8) + VADD V14.S4, V1.S4, V1.S4 + VDUP V9.S[2], V14.S4 + MUL4S(14, 14, 8) + VADD V14.S4, V2.S4, V2.S4 + VDUP V9.S[3], V14.S4 + MUL4S(14, 14, 8) + VADD V14.S4, V3.S4, V3.S4 + +skipeven: + ADD R10, R13 + ADD R4, R14 + + VLD1 (R13), [V8.S4] + VMOV V8.D[0], R16 + VMOV V8.D[1], R17 + ORR R17, R16, R16 + CBZ R16, skipodd + + VLD1 (R14), [V9.S4] + + VDUP V9.S[0], V14.S4 + MUL4S(14, 14, 8) + VADD V14.S4, V4.S4, V4.S4 + VDUP V9.S[1], V14.S4 + MUL4S(14, 14, 8) + VADD V14.S4, V5.S4, V5.S4 + VDUP V9.S[2], V14.S4 + MUL4S(14, 14, 8) + VADD V14.S4, V6.S4, V6.S4 + VDUP V9.S[3], V14.S4 + MUL4S(14, 14, 8) + VADD V14.S4, V7.S4, V7.S4 + +skipodd: + ADD R10, R13 + ADD R4, R14 + + SUB $2, R15 + CBNZ R15, jloop + + // The low output rows run forward from i0, the high ones backward from + // n-1-i0, which is the M[j][n-1-i] symmetry. + MUL R10, R12, R16 + ADD R0, R16 + ADD R11<<2, R16, R16 + + SUB R12, R3, R17 + SUB $1, R17 + MUL R10, R17, R17 + ADD R0, R17 + ADD R11<<2, R17, R17 + +#define EMIT(Ve, Vo) \ + VADD Vo.S4, Ve.S4, V14.S4; \ + VSUB Vo.S4, Ve.S4, V15.S4; \ + VADD V10.S4, V14.S4, V14.S4; \ + VADD V10.S4, V15.S4, V15.S4; \ + SSHL4S(14, 14, 13); \ + SSHL4S(15, 15, 13); \ + SMAX4S(14, 14, 11); \ + SMIN4S(14, 14, 12); \ + SMAX4S(15, 15, 11); \ + SMIN4S(15, 15, 12); \ + VST1 [V14.S4], (R16); \ + VST1 [V15.S4], (R17); \ + ADD R10, R16; \ + SUB R10, R17 + + EMIT(V0, V4) + EMIT(V1, V5) + EMIT(V2, V6) + EMIT(V3, V7) + + ADD $4, R12 + CMP R9, R12 + BLT iloop + + ADD $4, R11 + CMP R3, R11 + BLT xloop + + RET diff --git a/third_party/h265/hevc/idct_riscv64.s b/third_party/h265/hevc/idct_riscv64.s new file mode 100644 index 00000000..602f4f91 --- /dev/null +++ b/third_party/h265/hevc/idct_riscv64.s @@ -0,0 +1,133 @@ +//go:build riscv64 && riscv64.rva23u64 && !noasm + +#include "textflag.h" + +// func idctColsRVV(dst, src, m *int32, n, mstride, shift int, rnd, lo, hi int32) +// +// One pass of 8.6.4.2 over as many columns as the vector length takes. Outputs +// come four at a time so the even and odd accumulators stay in registers, and +// j is unrolled by two so the parity is static. +TEXT ·idctColsRVV(SB), NOSPLIT, $0-60 + MOV dst+0(FP), X10 + MOV src+8(FP), X11 + MOV m+16(FP), X12 + MOV n+24(FP), X13 + MOV mstride+32(FP), X14 + MOV shift+40(FP), X15 + MOVW rnd+48(FP), X16 + MOVW lo+52(FP), X17 + MOVW hi+56(FP), X18 + + SLLI $2, X14 + SRLI $3, X13, X19 + SLLI $2, X13, X20 + + MOV $0, X21 + MOV X13, X22 + +xloop: + VSETVLI X22, E32, M1, TA, MA, X23 + + MOV $0, X24 + MOV X19, X8 + +iloop: + VMVVI $0, V1 + VMVVI $0, V2 + VMVVI $0, V3 + VMVVI $0, V4 + VMVVI $0, V5 + VMVVI $0, V6 + VMVVI $0, V7 + VMVVI $0, V8 + + SLLI $2, X21, X30 + ADD X11, X30, X25 + + SLLI $2, X24, X28 + ADD X12, X28 + + MOV X13, X29 + +jloop: + VLE32V (X25), V9 + + MOVW (X28), X7 + VMULVX X7, V9, V10 + VADDVV V10, V1, V1 + MOVW 4(X28), X7 + VMULVX X7, V9, V10 + VADDVV V10, V2, V2 + MOVW 8(X28), X7 + VMULVX X7, V9, V10 + VADDVV V10, V3, V3 + MOVW 12(X28), X7 + VMULVX X7, V9, V10 + VADDVV V10, V4, V4 + + ADD X20, X25 + ADD X14, X28 + + VLE32V (X25), V9 + + MOVW (X28), X7 + VMULVX X7, V9, V10 + VADDVV V10, V5, V5 + MOVW 4(X28), X7 + VMULVX X7, V9, V10 + VADDVV V10, V6, V6 + MOVW 8(X28), X7 + VMULVX X7, V9, V10 + VADDVV V10, V7, V7 + MOVW 12(X28), X7 + VMULVX X7, V9, V10 + VADDVV V10, V8, V8 + + ADD X20, X25 + ADD X14, X28 + + ADD $-2, X29 + BNEZ X29, jloop + + // The low output rows run forward from i0, the high ones backward from + // n-1-i0, which is the M[j][n-1-i] symmetry. + MUL X20, X24, X5 + ADD X10, X5 + ADD X30, X5 + + SUB X24, X13, X6 + ADD $-1, X6 + MUL X20, X6, X6 + ADD X10, X6 + ADD X30, X6 + +#define EMIT(Ve, Vo) \ + VADDVV Vo, Ve, V11; \ + VSUBVV Vo, Ve, V12; \ + VADDVX X16, V11, V11; \ + VADDVX X16, V12, V12; \ + VSRAVX X15, V11, V11; \ + VSRAVX X15, V12, V12; \ + VMAXVX X17, V11, V11; \ + VMINVX X18, V11, V11; \ + VMAXVX X17, V12, V12; \ + VMINVX X18, V12, V12; \ + VSE32V V11, (X5); \ + VSE32V V12, (X6); \ + ADD X20, X5; \ + SUB X20, X6 + + EMIT(V1, V5) + EMIT(V2, V6) + EMIT(V3, V7) + EMIT(V4, V8) + + ADD $4, X24 + ADD $-1, X8 + BNEZ X8, iloop + + ADD X23, X21 + SUB X23, X22 + BNEZ X22, xloop + + RET diff --git a/third_party/h265/hevc/inter.go b/third_party/h265/hevc/inter.go new file mode 100644 index 00000000..1af36298 --- /dev/null +++ b/third_party/h265/hevc/inter.go @@ -0,0 +1,633 @@ +package hevc + +func (d *ctuDecoder) mvIndex(x, y int) int { + return (y>>2)*d.mvWidth + x>>2 +} + +// availablePB is 6.4.2. A neighbour inside the current coding block is +// available without consulting the z-scan order of 6.4.1, apart from the one +// partition the clause excludes. +func (d *ctuDecoder) availablePB(xN, yN int) bool { + if xN < 0 || yN < 0 || + xN >= int(d.s.picWidthInLumaSamples) || yN >= int(d.s.picHeightInLumaSamples) { + return false + } + + sameCb := xN >= d.cuX && xN < d.cuX+d.cuSize && yN >= d.cuY && yN < d.cuY+d.cuSize + + if !sameCb { + return d.available(d.curX, d.curY, xN, yN) + } + + return !(d.puW<<1 == d.cuSize && d.puH<<1 == d.cuSize && d.partIdx == 1 && + d.cuY+d.puH <= yN && d.cuX+d.puW > xN) +} + +func (d *ctuDecoder) neighbourMV() neighbour { + return func(x, y int) (*mvInfo, bool) { + if !d.availablePB(x, y) { + return nil, false + } + + i := d.mvIndex(x, y) + if i < 0 || i >= len(d.mvField) || !d.mvValid[i] { + return nil, false + } + + return &d.mvField[i], true + } +} + +// setMV records the motion of a prediction unit and the pictures its reference +// indices name, which 8.5.3.2.8 reads back from a later picture. +func (d *ctuDecoder) setMV(x, y, w, h int, m mvInfo) { + var poc [2]int32 + + var long [2]bool + + for l := range 2 { + if m.pred[l] && int(m.refIdx[l]) < len(d.refPOC[l]) { + poc[l] = d.refPOC[l][m.refIdx[l]] + long[l] = d.refLong[l][m.refIdx[l]] + } + } + + nw, nh := d.blocksIn(x, y, w, h, 2) + + for j := range nh { + k := (y>>2+j)*d.mvWidth + x>>2 + + field := d.mvField[k:][:nw] + pocs := d.mvPoc[k:][:nw] + longs := d.mvLong[k:][:nw] + valid := d.mvValid[k:][:nw] + + for i := range field { + field[i] = m + pocs[i] = poc + longs[i] = long + valid[i] = true + } + } +} + +// mvdCoding is 7.3.8.9. +func (d *ctuDecoder) mvdCoding() mv { + g0x := d.c.decodeBin(ctxAbsMVDGreater0Flag) != 0 + g0y := d.c.decodeBin(ctxAbsMVDGreater0Flag) != 0 + + g1x, g1y := false, false + + if g0x { + g1x = d.c.decodeBin(ctxAbsMVDGreater1Flag+1) != 0 + } + + if g0y { + g1y = d.c.decodeBin(ctxAbsMVDGreater1Flag+1) != 0 + } + + comp := func(g0, g1 bool) int16 { + if !g0 { + return 0 + } + + v := int32(1) + if g1 { + v = d.expGolombBypass(1) + 2 + } + + if d.c.decodeBypass() != 0 { + v = -v + } + + return int16(v) + } + + return mv{comp(g0x, g1x), comp(g0y, g1y)} +} + +// expGolombBypass reads a bypass-coded order-k exponential Golomb value. +func (d *ctuDecoder) expGolombBypass(k int) int32 { + n := 0 + for d.c.decodeBypass() != 0 { + n++ + + if n > 30 { + return 0 + } + } + + v := int32(0) + for i := 0; i < n+k; i++ { + v = v<<1 | int32(d.c.decodeBypass()) + } + + if n == 0 { + return v + } + + return v + int32((1<<(n+k))-(1< 2 && nCbS == 8 { + mx, my, mw, mh, mIdx = xCb, yCb, nCbS, nCbS, 0 + } + + nb := d.neighbourMV() + + lists := [2][]int32{d.refPOC[0], d.refPOC[1]} + + var m mvInfo + + merge := skip + if !skip { + merge = d.c.decodeBin(ctxMergeFlag) != 0 + } + + if partIdx == 0 { + d.lastMerge = merge + } + + if merge { + idx := 0 + + if d.sh.maxNumMergeCand > 1 { + for idx < int(d.sh.maxNumMergeCand)-1 { + var bit uint32 + + if idx == 0 { + bit = d.c.decodeBin(ctxMergeIDX) + } else { + bit = d.c.decodeBypass() + } + + if bit == 0 { + break + } + + idx++ + } + } + + d.curX, d.curY = mx, my + d.puW, d.puH, d.partIdx = mw, mh, mIdx + + cand := mergeCandidates(d.mergeCd[:], nb, mx, my, mw, mh, mIdx, partMode, + int(d.sh.maxNumMergeCand), int(d.p.log2ParallelMergeLevel)) + + d.curX, d.curY = x, y + d.puW, d.puH, d.partIdx = w, h, partIdx + + if len(cand) < int(d.sh.maxNumMergeCand) { + if t, ok := d.temporalMerge(mx, my, mw, mh); ok { + cand = append(cand, t) + } + } + + if d.sh.sliceType == sliceB { + cand = combineBiCandidates(cand, int(d.sh.maxNumMergeCand), lists) + } + + numRef := int(d.sh.numRefIdxL0Active) + if d.sh.sliceType == sliceB { + numRef = min(numRef, int(d.sh.numRefIdxL1Active)) + } + + cand = zeroCandidates(cand, int(d.sh.maxNumMergeCand), numRef, + d.sh.sliceType == sliceB) + + if idx >= len(cand) { + return ErrInvalid + } + + m = cand[idx] + + // 8.5.3.2.1: the smallest prediction units may not be bi-predicted, so + // a merge candidate that is drops to list zero. + if w+h == 12 && m.pred[0] && m.pred[1] { + m.pred[1] = false + m.mv[1] = mv{} + } + } else { + idc := predL0 + if d.sh.sliceType == sliceB { + idc = d.interPredIdc(w, h, depth) + } + + for l := range 2 { + if (l == 0 && idc == predL1) || (l == 1 && idc == predL0) { + continue + } + + active := int(d.sh.numRefIdxL0Active) + if l == 1 { + active = int(d.sh.numRefIdxL1Active) + } + + ref := d.refIdx(active) + + var mvd mv + + if l == 1 && d.sh.mvdL1Zero && idc == predBI { + mvd = mv{} + } else { + mvd = d.mvdCoding() + } + + flag := d.c.decodeBin(ctxMVPLXFlag) + + if int(ref) >= len(lists[l]) { + return ErrInvalid + } + + ab, avail := amvpCandidates(nb, x, y, w, h, l, lists[l][ref], d.poc, lists, + d.refLong, d.refLong[l][ref]) + + var cand [2]mv + + n := 0 + + if avail[0] { + cand[n] = ab[0] + n++ + } + + if avail[1] && !(avail[0] && ab[0] == ab[1]) { + cand[n] = ab[1] + n++ + } + + if n < 2 { + if t, ok := d.temporalMV(x, y, w, h, l, lists[l][ref], d.refLong[l][ref]); ok { + cand[n] = t + n++ + } + } + + p := cand[flag] + + m.mv[l] = mv{p.x + mvd.x, p.y + mvd.y} + m.refIdx[l] = ref + m.pred[l] = true + } + } + + if !m.pred[0] { + m.refIdx[0] = -1 + } + + if !m.pred[1] { + m.refIdx[1] = -1 + } + + d.setMV(x, y, w, h, m) + d.markPU(x, y, w, h, false) + + return d.motionCompensate(x, y, w, h, &m) +} + +func (d *ctuDecoder) motionCompensate(x, y, w, h int, m *mvInfo) error { + var buf [2][]int16 + + for l := range 2 { + if !m.pred[l] { + continue + } + + if int(m.refIdx[l]) >= len(d.refPics[l]) { + return ErrInvalid + } + + ref := d.refPics[l][m.refIdx[l]] + if ref == nil { + return ErrInvalid + } + + buf[l] = d.mcBuf[l][:w*h] + + if d.pic.deep() { + src, stride := ref.plane16(0) + mcLuma(buf[l], w, src, stride, ref.Width, ref.Height, + x+int(m.mv[l].x>>2), y+int(m.mv[l].y>>2), + int(m.mv[l].x&3), int(m.mv[l].y&3), w, h, d.pic.BitDepth, + d.mcTmp[:], d.mcTmp16[:], d.mcPad16[:]) + } else { + src, stride := ref.plane8(0) + mcLuma(buf[l], w, src, stride, ref.Width, ref.Height, + x+int(m.mv[l].x>>2), y+int(m.mv[l].y>>2), + int(m.mv[l].x&3), int(m.mv[l].y&3), w, h, d.pic.BitDepth, + d.mcTmp[:], d.mcTmp16[:], d.mcPad8[:]) + } + } + + if err := d.combine(x, y, w, h, 0, buf, m); err != nil { + return err + } + + if d.s.chromaArrayType() == 0 { + return nil + } + + sw, sh := d.s.subWidthC, d.s.subHeightC + cw, ch := w/sw, h/sh + + if cw == 0 || ch == 0 { + return nil + } + + for cIdx := 1; cIdx <= 2; cIdx++ { + var cbuf [2][]int16 + + for l := range 2 { + if !m.pred[l] { + continue + } + + ref := d.refPics[l][m.refIdx[l]] + cbuf[l] = d.mcBuf[l][:cw*ch] + + mvx := int(m.mv[l].x) * 2 / sw + mvy := int(m.mv[l].y) * 2 / sh + + px, py := x/sw+mvx>>3, y/sh+mvy>>3 + fx, fy := mvx&7, mvy&7 + + if d.pic.deep() { + src, stride := ref.plane16(cIdx) + mcChroma(cbuf[l], cw, src, stride, ref.WidthC, ref.HeightC, + px, py, fx, fy, cw, ch, d.pic.BitDepthC, d.mcTmp[:], d.mcTmp16[:], + d.mcPad16[:]) + } else { + src, stride := ref.plane8(cIdx) + mcChroma(cbuf[l], cw, src, stride, ref.WidthC, ref.HeightC, + px, py, fx, fy, cw, ch, d.pic.BitDepthC, d.mcTmp[:], d.mcTmp16[:], + d.mcPad8[:]) + } + } + + if err := d.combine(x/sw, y/sh, cw, ch, cIdx, cbuf, m); err != nil { + return err + } + } + + return nil +} + +func (d *ctuDecoder) weighted() bool { + return (d.p.weightedPred && d.sh.sliceType == sliceP) || + (d.p.weightedBipred && d.sh.sliceType == sliceB) +} + +func (d *ctuDecoder) combine(x, y, w, h, cIdx int, buf [2][]int16, m *mvInfo) error { + bd := d.pic.depth(cIdx) + + if d.pic.deep() { + plane, stride := d.pic.plane16(cIdx) + + return combineInto(d, plane, y*stride+x, stride, w, h, cIdx, bd, buf, m) + } + + plane, stride := d.pic.plane8(cIdx) + + return combineInto(d, plane, y*stride+x, stride, w, h, cIdx, bd, buf, m) +} + +// combineInto applies 8.5.3.3.4, choosing the default or the explicitly +// weighted process as the picture parameter set requires. +func combineInto[P pixel](d *ctuDecoder, dst []P, off, stride, w, h, cIdx, bd int, + buf [2][]int16, m *mvInfo, +) error { + if !d.weighted() { + return combinePred(dst, off, stride, w, h, bd, buf, m) + } + + wt := &d.sh.weights + + denom := int(wt.lumaLog2Denom) + if cIdx > 0 { + denom = int(wt.chromaLog2Denom) + } + + weight := func(l int) (int, int) { + i := int(m.refIdx[l]) + + if cIdx == 0 { + return int(wt.lumaWeight[l][i]), int(wt.lumaOffset[l][i]) + } + + return int(wt.chromaWeight[l][i][cIdx-1]), int(wt.chromaOffset[l][i][cIdx-1]) + } + + switch { + case m.pred[0] && m.pred[1]: + w0, o0 := weight(0) + w1, o1 := weight(1) + weightBi(dst, off, stride, buf[0], buf[1], w, w, h, w0, w1, o0, o1, denom, bd, + d.s.highPrecisionOffsets) + case m.pred[0]: + w0, o0 := weight(0) + weightUni(dst, off, stride, buf[0], w, w, h, w0, o0, denom, bd, + d.s.highPrecisionOffsets) + case m.pred[1]: + w1, o1 := weight(1) + weightUni(dst, off, stride, buf[1], w, w, h, w1, o1, denom, bd, + d.s.highPrecisionOffsets) + default: + return ErrInvalid + } + + return nil +} + +func combinePred[P pixel](dst []P, off, stride, w, h, bd int, buf [2][]int16, m *mvInfo) error { + switch { + case m.pred[0] && m.pred[1]: + predBi(dst, off, stride, buf[0], buf[1], w, w, h, bd) + case m.pred[0]: + predUni(dst, off, stride, buf[0], w, w, h, bd) + case m.pred[1]: + predUni(dst, off, stride, buf[1], w, w, h, bd) + default: + return ErrInvalid + } + + return nil +} + +// storeColMotion subsamples the picture's motion field to the 16x16 grid the +// temporal predictor reads, recording reference pictures by POC. +func (d *ctuDecoder) storeColMotion() { + for y := 0; y < int(d.s.picHeightInLumaSamples); y += 16 { + for x := 0; x < int(d.s.picWidthInLumaSamples); x += 16 { + c := &d.pic.Col[d.pic.colIndex(x, y)] + + k := d.blkIndex(x, y) + c.intra = d.blk[k].intra + + if c.intra || !d.mvValid[k] { + c.intra = true + + continue + } + + c.info = d.mvField[k] + c.refPoc = d.mvPoc[k] + c.refLong = d.mvLong[k] + } + } +} + +// temporalMV is 8.5.3.2.8 and 8.5.3.2.9: the collocated block at the +// bottom-right of the prediction unit, falling back to its centre. +func (d *ctuDecoder) temporalMV(xPb, yPb, nPbW, nPbH, list int, refPoc int32, refLong bool) (mv, bool) { + if !d.sh.temporalMvp || d.colPic == nil { + return mv{}, false + } + + col := d.colPic + + try := func(x, y int) (*colMotion, bool) { + if x < 0 || y < 0 || x >= col.Width || y >= col.Height { + return nil, false + } + + c := &col.Col[col.colIndex(x, y)] + if c.intra { + return nil, false + } + + return c, true + } + + // 8.5.3.2.9, which the caller falls back from when it yields nothing. + derive := func(c *colMotion) (mv, bool) { + l := 0 + + switch { + case !c.info.pred[0]: + l = 1 + case !c.info.pred[1]: + l = 0 + case d.noBackwardPred: + l = list + case d.sh.collocatedFromL0: + l = 1 + default: + l = 0 + } + + if !c.info.pred[l] { + return mv{}, false + } + + // The collocated reference must match the target in long term status, + // and a long term one is never scaled. + if c.refLong[l] != refLong { + return mv{}, false + } + + if c.refPoc[l] == d.poc || refPoc == d.poc { + return mv{}, false + } + + if refLong || int32(col.POC)-c.refPoc[l] == d.poc-refPoc { + return c.info.mv[l], true + } + + return scaleMV(c.info.mv[l], d.poc, refPoc, int32(col.POC), c.refPoc[l]), true + } + + // The bottom-right candidate is unavailable outside the picture or past the + // coding tree row, and the centre takes over whenever it yields nothing. + if c, ok := try(xPb+nPbW, yPb+nPbH); ok && + yPb>>d.s.ctbLog2SizeY == (yPb+nPbH)>>d.s.ctbLog2SizeY { + if v, ok := derive(c); ok { + return v, true + } + } + + c, ok := try(xPb+nPbW/2, yPb+nPbH/2) + if !ok { + return mv{}, false + } + + return derive(c) +} + +// temporalMerge builds the temporal merge candidate, which uses reference +// index zero in each list the slice has. +func (d *ctuDecoder) temporalMerge(x, y, w, h int) (mvInfo, bool) { + var m mvInfo + + m.refIdx = [2]int8{-1, -1} + + for l := range 2 { + if l == 1 && d.sh.sliceType != sliceB { + continue + } + + if len(d.refPOC[l]) == 0 { + continue + } + + v, ok := d.temporalMV(x, y, w, h, l, d.refPOC[l][0], d.refLong[l][0]) + if !ok { + continue + } + + m.mv[l] = v + m.refIdx[l] = 0 + m.pred[l] = true + } + + return m, m.pred[0] || m.pred[1] +} diff --git a/third_party/h265/hevc/interpred.go b/third_party/h265/hevc/interpred.go new file mode 100644 index 00000000..e5f7e171 --- /dev/null +++ b/third_party/h265/hevc/interpred.go @@ -0,0 +1,482 @@ +package hevc + +// Table 8-11. +// lumaFilter16 is the same table at the width the kernels multiply in. +var lumaFilter16 = func() [4][8]int16 { + var f [4][8]int16 + + for i, row := range lumaFilter { + for j, v := range row { + f[i][j] = int16(v) + } + } + + return f +}() + +var lumaFilter = [4][8]int32{ + {0, 0, 0, 64, 0, 0, 0, 0}, + {-1, 4, -10, 58, 17, -5, 1, 0}, + {-1, 4, -11, 40, 40, -11, 4, -1}, + {0, 1, -5, 17, 58, -10, 4, -1}, +} + +// Table 8-12. +// chromaFilter16 is the same table at the width the kernels multiply in. +var chromaFilter16 = func() [8][4]int16 { + var f [8][4]int16 + + for i, row := range chromaFilter { + for j, v := range row { + f[i][j] = int16(v) + } + } + + return f +}() + +var chromaFilter = [8][4]int32{ + {0, 64, 0, 0}, + {-2, 58, 10, -2}, + {-4, 54, 16, -2}, + {-6, 46, 28, -4}, + {-4, 36, 36, -4}, + {-4, 28, 46, -6}, + {-2, 16, 54, -4}, + {-2, 10, 58, -2}, +} + +func clampInt(v, lo, hi int) int { + if v < lo { + return lo + } + + if v > hi { + return hi + } + + return v +} + +// mcLuma is the luma sample interpolation of 8.5.3.3.3.1. It writes the +// 14-bit intermediate the weighted prediction process consumes. +func mcLuma[P pixel](dst []int16, dstStride int, src []P, srcStride, picW, picH, + x, y, xFrac, yFrac, w, h, bitDepth int, scratch []int32, tmp16 []int16, pad []P, +) { + shift1 := min(4, bitDepth-8) + shift2 := 6 + shift3 := max(2, 14-bitDepth) + + // The eight-tap support reaches three samples back and four forward. When + // it leaves the picture the region is copied out once with the edge + // clamping of 8.5.3.3.3, so the filters below index the source directly. + ox, ew := 0, w + if xFrac != 0 { + ox, ew = -3, w+7 + } + + oy, eh := 0, h + if yFrac != 0 { + oy, eh = -3, h+7 + } + + if x+ox < 0 || y+oy < 0 || x+ox+ew > picW || y+oy+eh > picH { + emulate(pad, src, srcStride, picW, picH, x+ox, y+oy, ew, eh) + + src, srcStride = pad, ew + x, y = -ox, -oy + } + + at := func(px, py int) int32 { + return int32(src[py*srcStride+px]) + } + + // The kernels cover eight-bit samples at widths they can step through + // whole; anything else falls to the loops below. + asm := mcTapAsm + if bitDepth != 8 || w%8 != 0 { + asm = nil + } + + p8, _ := any(src).([]uint8) + if p8 == nil { + asm = nil + } + + switch { + case xFrac == 0 && yFrac == 0: + if p8 != nil && w%8 == 0 && mcCopyAsm != nil { + mcCopyAsm(dst, dstStride, p8[y*srcStride+x:], srcStride, w, h, shift3) + + return + } + + for j := range h { + for i := range w { + dst[j*dstStride+i] = int16(at(x+i, y+j) << shift3) + } + } + + case yFrac == 0: + if asm != nil { + asm(dst, dstStride, p8[y*srcStride+x-3:], srcStride, 1, w, h, + lumaFilter16[xFrac][:]) + + return + } + + f := &lumaFilter[xFrac] + + for j := range h { + for i := range w { + var v int32 + for k := range 8 { + v += f[k] * at(x+i+k-3, y+j) + } + + dst[j*dstStride+i] = int16(v >> shift1) + } + } + + case xFrac == 0: + if asm != nil { + asm(dst, dstStride, p8[(y-3)*srcStride+x:], srcStride, srcStride, w, h, + lumaFilter16[yFrac][:]) + + return + } + + f := &lumaFilter[yFrac] + + for j := range h { + for i := range w { + var v int32 + for k := range 8 { + v += f[k] * at(x+i, y+j+k-3) + } + + dst[j*dstStride+i] = int16(v >> shift1) + } + } + + default: + if asm != nil && mcTapV16Asm != nil { + t := tmp16[:(h+7)*w] + + asm(t, w, p8[(y-3)*srcStride+x-3:], srcStride, 1, w, h+7, + lumaFilter16[xFrac][:]) + mcTapV16Asm(dst, dstStride, t, w, w, h, shift2, lumaFilter16[yFrac][:]) + + return + } + + fx, fy := &lumaFilter[xFrac], &lumaFilter[yFrac] + + tmp := scratch[:(h+7)*w] + + for j := range h + 7 { + for i := range w { + var v int32 + for k := range 8 { + v += fx[k] * at(x+i+k-3, y+j-3) + } + + tmp[j*w+i] = v >> shift1 + } + } + + for j := range h { + for i := range w { + var v int32 + for k := range 8 { + v += fy[k] * tmp[(j+k)*w+i] + } + + dst[j*dstStride+i] = int16(v >> shift2) + } + } + } +} + +// mcChroma is the chroma sample interpolation of 8.5.3.3.3.2. +func mcChroma[P pixel](dst []int16, dstStride int, src []P, srcStride, picW, picH, + x, y, xFrac, yFrac, w, h, bitDepth int, scratch []int32, tmp16 []int16, pad []P, +) { + shift1 := min(4, bitDepth-8) + shift2 := 6 + shift3 := max(2, 14-bitDepth) + + // The four-tap support reaches one sample back and two forward. + ox, ew := 0, w + if xFrac != 0 { + ox, ew = -1, w+3 + } + + oy, eh := 0, h + if yFrac != 0 { + oy, eh = -1, h+3 + } + + if x+ox < 0 || y+oy < 0 || x+ox+ew > picW || y+oy+eh > picH { + emulate(pad, src, srcStride, picW, picH, x+ox, y+oy, ew, eh) + + src, srcStride = pad, ew + x, y = -ox, -oy + } + + at := func(px, py int) int32 { + return int32(src[py*srcStride+px]) + } + + p8, _ := any(src).([]uint8) + + asm := mcTapAsm + if bitDepth != 8 || w%8 != 0 || p8 == nil { + asm = nil + } + + switch { + case xFrac == 0 && yFrac == 0: + if p8 != nil && w%8 == 0 && bitDepth == 8 && mcCopyAsm != nil { + mcCopyAsm(dst, dstStride, p8[y*srcStride+x:], srcStride, w, h, shift3) + + return + } + + for j := range h { + for i := range w { + dst[j*dstStride+i] = int16(at(x+i, y+j) << shift3) + } + } + + case yFrac == 0: + if asm != nil { + asm(dst, dstStride, p8[y*srcStride+x-1:], srcStride, 1, w, h, + chromaFilter16[xFrac][:]) + + return + } + + f := &chromaFilter[xFrac] + + for j := range h { + for i := range w { + var v int32 + for k := range 4 { + v += f[k] * at(x+i+k-1, y+j) + } + + dst[j*dstStride+i] = int16(v >> shift1) + } + } + + case xFrac == 0: + if asm != nil { + asm(dst, dstStride, p8[(y-1)*srcStride+x:], srcStride, srcStride, w, h, + chromaFilter16[yFrac][:]) + + return + } + + f := &chromaFilter[yFrac] + + for j := range h { + for i := range w { + var v int32 + for k := range 4 { + v += f[k] * at(x+i, y+j+k-1) + } + + dst[j*dstStride+i] = int16(v >> shift1) + } + } + + default: + if asm != nil && mcTapV16Asm != nil { + t := tmp16[:(h+3)*w] + + asm(t, w, p8[(y-1)*srcStride+x-1:], srcStride, 1, w, h+3, + chromaFilter16[xFrac][:]) + mcTapV16Asm(dst, dstStride, t, w, w, h, shift2, chromaFilter16[yFrac][:]) + + return + } + + fx, fy := &chromaFilter[xFrac], &chromaFilter[yFrac] + + tmp := scratch[:(h+3)*w] + + for j := range h + 3 { + for i := range w { + var v int32 + for k := range 4 { + v += fx[k] * at(x+i+k-1, y+j-1) + } + + tmp[j*w+i] = v >> shift1 + } + } + + for j := range h { + for i := range w { + var v int32 + for k := range 4 { + v += fy[k] * tmp[(j+k)*w+i] + } + + dst[j*dstStride+i] = int16(v >> shift2) + } + } + } +} + +// emulate copies a region out of a reference picture, repeating the edge +// samples where it falls outside, so a filter reading it needs no bounds +// check. +func emulate[P pixel](dst []P, src []P, srcStride, picW, picH, x, y, w, h int) { + for j := range h { + sy := clampInt(y+j, 0, picH-1) + row := src[sy*srcStride:] + out := dst[j*w : j*w+w] + + // The inside part is a copy; only the overhang repeats. + lo := clampInt(-x, 0, w) + hi := clampInt(picW-x, 0, w) + + for i := range lo { + out[i] = row[0] + } + + if hi > lo { + copy(out[lo:hi], row[x+lo:x+hi]) + } + + for i := hi; i < w; i++ { + out[i] = row[picW-1] + } + } +} + +// predUni is the default uni-prediction of 8.5.3.3.4.2. +func predUni[P pixel](dst []P, dstOff, dstStride int, src []int16, srcStride, w, h, bitDepth int) { + shift := max(2, 14-bitDepth) + + if k := predUniAsm; k != nil && bitDepth == 8 { + if p, ok := any(dst).([]uint8); ok { + if n := w &^ 7; n != 0 { + k(p[dstOff:], dstStride, src, srcStride, n, h, shift) + + if n == w { + return + } + + dstOff += n + src = src[n:] + w -= n + } + } + } + + predUniGo(dst, dstOff, dstStride, src, srcStride, w, h, bitDepth, shift) +} + +func predUniGo[P pixel](dst []P, dstOff, dstStride int, src []int16, srcStride, w, h, bitDepth, shift int) { + off := int32(1) << (shift - 1) + maxV := int32(1)<> shift + dst[dstOff+j*dstStride+i] = P(clip3(v, 0, maxV)) + } + } +} + +// predBi is the default bi-prediction of 8.5.3.3.4.2. +func predBi[P pixel](dst []P, dstOff, dstStride int, a, b []int16, srcStride, w, h, bitDepth int) { + shift := max(2, 14-bitDepth) + 1 + + if k := predBiAsm; k != nil && bitDepth == 8 { + if p, ok := any(dst).([]uint8); ok { + if n := w &^ 7; n != 0 { + k(p[dstOff:], dstStride, a, b, srcStride, n, h, shift) + + if n == w { + return + } + + dstOff += n + a, b = a[n:], b[n:] + w -= n + } + } + } + + predBiGo(dst, dstOff, dstStride, a, b, srcStride, w, h, bitDepth, shift) +} + +func predBiGo[P pixel](dst []P, dstOff, dstStride int, a, b []int16, srcStride, w, h, + bitDepth, shift int, +) { + off := int32(1) << (shift - 1) + maxV := int32(1)<> shift + dst[dstOff+j*dstStride+i] = P(clip3(v, 0, maxV)) + } + } +} + +// weightUni is the explicit weighted uni-prediction of 8.5.3.3.4.3. +func weightUni[P pixel](dst []P, dstOff, dstStride int, src []int16, srcStride, + w, h, weight, offset, denom, bitDepth int, highPrecision bool, +) { + shift1 := max(2, 14-bitDepth) + log2Wd := denom + shift1 + maxV := int32(1)<= 1 { + v = ((v*int32(weight) + 1<<(log2Wd-1)) >> log2Wd) + o + } else { + v = v*int32(weight) + o + } + + dst[dstOff+j*dstStride+i] = P(clip3(v, 0, maxV)) + } + } +} + +// weightBi is the explicit weighted bi-prediction of 8.5.3.3.4.3. +func weightBi[P pixel](dst []P, dstOff, dstStride int, a, b []int16, srcStride, + w, h, w0, w1, o0, o1, denom, bitDepth int, highPrecision bool, +) { + shift1 := max(2, 14-bitDepth) + log2Wd := denom + shift1 + maxV := int32(1)<>(log2Wd+1), 0, maxV)) + } + } +} diff --git a/third_party/h265/hevc/interpred_amd64.s b/third_party/h265/hevc/interpred_amd64.s new file mode 100644 index 00000000..22a171d0 --- /dev/null +++ b/third_party/h265/hevc/interpred_amd64.s @@ -0,0 +1,160 @@ +//go:build amd64 && !noasm + +#include "textflag.h" + +// func predUni8AVX2(dst *uint8, dstStride int, src *int16, srcStride, w, h, shift int) +// +// 8.5.3.3.4.2 without weights. The add saturates because the Go path sums at +// 32 bits, and a sum that saturates shifts to more than the maximum sample and +// would clip there anyway. The pack then saturates unsigned, which is the clip +// itself. w is a multiple of eight; the caller does any remainder. +TEXT ·predUni8AVX2(SB), NOSPLIT, $0-56 + MOVQ dst+0(FP), DI + MOVQ dstStride+8(FP), SI + MOVQ src+16(FP), DX + MOVQ w+32(FP), R8 + MOVQ h+40(FP), R9 + MOVQ shift+48(FP), R10 + + // off = 1 << (shift-1), while CL is still free + MOVQ $1, AX + MOVQ R10, CX + DECQ CX + SHLQ CX, AX + MOVQ AX, X1 + + VPBROADCASTW X1, Y1 + MOVQ R10, X2 + + MOVQ srcStride+24(FP), CX + SHLQ $1, CX + +rows: + MOVQ DI, R11 + MOVQ DX, R12 + MOVQ R8, R13 + +wide: + CMPQ R13, $16 + JLT narrow + + VMOVDQU (R12), Y0 + VPADDSW Y1, Y0, Y0 + VPSRAW X2, Y0, Y0 + VPACKUSWB Y0, Y0, Y0 + VPERMQ $0xd8, Y0, Y0 + VMOVDQU X0, (R11) + + ADDQ $32, R12 + ADDQ $16, R11 + SUBQ $16, R13 + JMP wide + +narrow: + TESTQ R13, R13 + JZ next + + VMOVDQU (R12), X0 + VPADDSW X1, X0, X0 + VPSRAW X2, X0, X0 + VPACKUSWB X0, X0, X0 + MOVQ X0, (R11) + + ADDQ $16, R12 + ADDQ $8, R11 + SUBQ $8, R13 + JMP narrow + +next: + ADDQ SI, DI + ADDQ CX, DX + DECQ R9 + JNZ rows + + VZEROUPPER + RET + +// func predUni8AVX512(dst *uint8, dstStride int, src *int16, srcStride, w, h, shift int) +// +// The 512-bit down-convert saturates unsigned only, so the negative half of the +// clip is a max against zero rather than a signed pack. +TEXT ·predUni8AVX512(SB), NOSPLIT, $0-56 + MOVQ dst+0(FP), DI + MOVQ dstStride+8(FP), SI + MOVQ src+16(FP), DX + MOVQ w+32(FP), R8 + MOVQ h+40(FP), R9 + MOVQ shift+48(FP), R10 + + MOVQ $1, AX + MOVQ R10, CX + DECQ CX + SHLQ CX, AX + MOVQ AX, X1 + + VPBROADCASTW X1, Z1 + MOVQ R10, X2 + VPXORD Z3, Z3, Z3 + + MOVQ srcStride+24(FP), CX + SHLQ $1, CX + +rows512: + MOVQ DI, R11 + MOVQ DX, R12 + MOVQ R8, R13 + +wide512: + CMPQ R13, $32 + JLT half512 + + VMOVDQU32 (R12), Z0 + VPADDSW Z1, Z0, Z0 + VPSRAW X2, Z0, Z0 + VPMAXSW Z3, Z0, Z0 + VPMOVUSWB Z0, (R11) + + ADDQ $64, R12 + ADDQ $32, R11 + SUBQ $32, R13 + JMP wide512 + +half512: + CMPQ R13, $16 + JLT narrow512 + + VMOVDQU (R12), Y0 + VPADDSW Y1, Y0, Y0 + VPSRAW X2, Y0, Y0 + VPACKUSWB Y0, Y0, Y0 + VPERMQ $0xd8, Y0, Y0 + VMOVDQU X0, (R11) + + ADDQ $32, R12 + ADDQ $16, R11 + SUBQ $16, R13 + JMP half512 + +narrow512: + TESTQ R13, R13 + JZ next512 + + VMOVDQU (R12), X0 + VPADDSW X1, X0, X0 + VPSRAW X2, X0, X0 + VPACKUSWB X0, X0, X0 + MOVQ X0, (R11) + + ADDQ $16, R12 + ADDQ $8, R11 + SUBQ $8, R13 + JMP narrow512 + +next512: + ADDQ SI, DI + ADDQ CX, DX + DECQ R9 + JNZ rows512 + + VZEROUPPER + RET diff --git a/third_party/h265/hevc/interpred_arm64.s b/third_party/h265/hevc/interpred_arm64.s new file mode 100644 index 00000000..fd970b05 --- /dev/null +++ b/third_party/h265/hevc/interpred_arm64.s @@ -0,0 +1,54 @@ +//go:build arm64 && !noasm + +#include "textflag.h" + +#define SQADD8H(Vd, Vn, Vm) WORD $(0x4e600c00 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define SSHL8H(Vd, Vn, Vm) WORD $(0x4e604400 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define SQXTUN8B2(Vd, Vn) WORD $(0x2e212800 | ((Vn) << 5) | (Vd)) + +// func predUni8NEON(dst *uint8, dstStride int, src *int16, srcStride, w, h, shift int) +// +// 8.5.3.3.4.2 without weights. The add saturates because the Go path sums at +// 32 bits, and a sum that saturates shifts to more than the maximum sample and +// would clip there anyway. w is a multiple of eight. +TEXT ·predUni8NEON(SB), NOSPLIT, $0-56 + MOVD dst+0(FP), R0 + MOVD dstStride+8(FP), R1 + MOVD src+16(FP), R2 + MOVD srcStride+24(FP), R3 + MOVD w+32(FP), R4 + MOVD h+40(FP), R5 + MOVD shift+48(FP), R6 + + LSL $1, R3 + + // off = 1 << (shift-1) + SUB $1, R6, R7 + MOVD $1, R8 + LSL R7, R8, R8 + VDUP R8, V1.H8 + + NEG R6, R9 + VDUP R9, V2.H8 + +rows: + MOVD R0, R10 + MOVD R2, R11 + MOVD R4, R12 + +cols: + VLD1.P 16(R11), [V0.H8] + SQADD8H(0, 0, 1) + SSHL8H(0, 0, 2) + SQXTUN8B2(0, 0) + VST1.P [V0.B8], 8(R10) + + SUB $8, R12 + CBNZ R12, cols + + ADD R1, R0 + ADD R3, R2 + SUB $1, R5 + CBNZ R5, rows + + RET diff --git a/third_party/h265/hevc/interpred_bi_amd64.s b/third_party/h265/hevc/interpred_bi_amd64.s new file mode 100644 index 00000000..56ed62ea --- /dev/null +++ b/third_party/h265/hevc/interpred_bi_amd64.s @@ -0,0 +1,172 @@ +//go:build amd64 && !noasm + +#include "textflag.h" + +// func predBi8AVX2(dst *uint8, dstStride int, a, b *int16, srcStride, w, h, shift int) +// +// 8.5.3.3.4.2 for two lists. Both adds saturate: the Go path sums at 32 bits, +// and a sum that saturates shifts to more than the maximum sample and would +// clip there anyway. w is a multiple of eight. +TEXT ·predBi8AVX2(SB), NOSPLIT, $0-64 + MOVQ dst+0(FP), DI + MOVQ dstStride+8(FP), SI + MOVQ a+16(FP), DX + MOVQ b+24(FP), R11 + MOVQ w+40(FP), R8 + MOVQ h+48(FP), R9 + MOVQ shift+56(FP), R10 + + // off = 1 << (shift-1), while CL is still free + MOVQ $1, AX + MOVQ R10, CX + DECQ CX + SHLQ CX, AX + MOVQ AX, X1 + + VPBROADCASTW X1, Y1 + MOVQ R10, X2 + + MOVQ srcStride+32(FP), CX + SHLQ $1, CX + +rows: + MOVQ DI, R12 + MOVQ DX, R13 + MOVQ R11, R14 + MOVQ R8, R15 + +wide: + CMPQ R15, $16 + JLT narrow + + VMOVDQU (R13), Y0 + VPADDSW (R14), Y0, Y0 + VPADDSW Y1, Y0, Y0 + VPSRAW X2, Y0, Y0 + VPACKUSWB Y0, Y0, Y0 + VPERMQ $0xd8, Y0, Y0 + VMOVDQU X0, (R12) + + ADDQ $32, R13 + ADDQ $32, R14 + ADDQ $16, R12 + SUBQ $16, R15 + JMP wide + +narrow: + TESTQ R15, R15 + JZ next + + VMOVDQU (R13), X0 + VPADDSW (R14), X0, X0 + VPADDSW X1, X0, X0 + VPSRAW X2, X0, X0 + VPACKUSWB X0, X0, X0 + MOVQ X0, (R12) + + ADDQ $16, R13 + ADDQ $16, R14 + ADDQ $8, R12 + SUBQ $8, R15 + JMP narrow + +next: + ADDQ SI, DI + ADDQ CX, DX + ADDQ CX, R11 + DECQ R9 + JNZ rows + + VZEROUPPER + RET + +// func predBi8AVX512(dst *uint8, dstStride int, a, b *int16, srcStride, w, h, shift int) +TEXT ·predBi8AVX512(SB), NOSPLIT, $0-64 + MOVQ dst+0(FP), DI + MOVQ dstStride+8(FP), SI + MOVQ a+16(FP), DX + MOVQ b+24(FP), R11 + MOVQ w+40(FP), R8 + MOVQ h+48(FP), R9 + MOVQ shift+56(FP), R10 + + MOVQ $1, AX + MOVQ R10, CX + DECQ CX + SHLQ CX, AX + MOVQ AX, X1 + + VPBROADCASTW X1, Z1 + MOVQ R10, X2 + VPXORD Z3, Z3, Z3 + + MOVQ srcStride+32(FP), CX + SHLQ $1, CX + +rows512: + MOVQ DI, R12 + MOVQ DX, R13 + MOVQ R11, R14 + MOVQ R8, R15 + +wide512: + CMPQ R15, $32 + JLT half512 + + VMOVDQU32 (R13), Z0 + VPADDSW (R14), Z0, Z0 + VPADDSW Z1, Z0, Z0 + VPSRAW X2, Z0, Z0 + VPMAXSW Z3, Z0, Z0 + VPMOVUSWB Z0, (R12) + + ADDQ $64, R13 + ADDQ $64, R14 + ADDQ $32, R12 + SUBQ $32, R15 + JMP wide512 + +half512: + CMPQ R15, $16 + JLT narrow512 + + VMOVDQU (R13), Y0 + VPADDSW (R14), Y0, Y0 + VPADDSW Y1, Y0, Y0 + VPSRAW X2, Y0, Y0 + VPACKUSWB Y0, Y0, Y0 + VPERMQ $0xd8, Y0, Y0 + VMOVDQU X0, (R12) + + ADDQ $32, R13 + ADDQ $32, R14 + ADDQ $16, R12 + SUBQ $16, R15 + JMP half512 + +narrow512: + TESTQ R15, R15 + JZ next512 + + VMOVDQU (R13), X0 + VPADDSW (R14), X0, X0 + VPADDSW X1, X0, X0 + VPSRAW X2, X0, X0 + VPACKUSWB X0, X0, X0 + MOVQ X0, (R12) + + ADDQ $16, R13 + ADDQ $16, R14 + ADDQ $8, R12 + SUBQ $8, R15 + JMP narrow512 + +next512: + ADDQ SI, DI + ADDQ CX, DX + ADDQ CX, R11 + DECQ R9 + JNZ rows512 + + VZEROUPPER + RET diff --git a/third_party/h265/hevc/interpred_bi_arm64.s b/third_party/h265/hevc/interpred_bi_arm64.s new file mode 100644 index 00000000..09a481a3 --- /dev/null +++ b/third_party/h265/hevc/interpred_bi_arm64.s @@ -0,0 +1,57 @@ +//go:build arm64 && !noasm + +#include "textflag.h" + +#define SQADD8H_(Vd, Vn, Vm) WORD $(0x4e600c00 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define SSHL8H_(Vd, Vn, Vm) WORD $(0x4e604400 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define SQXTUN8B3(Vd, Vn) WORD $(0x2e212800 | ((Vn) << 5) | (Vd)) + +// func predBi8NEON(dst *uint8, dstStride int, a, b *int16, srcStride, w, h, shift int) +// +// 8.5.3.3.4.2 for two lists. Both adds saturate, which matches the 32-bit sum +// the Go path takes once the shift and clip are applied. +TEXT ·predBi8NEON(SB), NOSPLIT, $0-64 + MOVD dst+0(FP), R0 + MOVD dstStride+8(FP), R1 + MOVD a+16(FP), R2 + MOVD b+24(FP), R3 + MOVD srcStride+32(FP), R4 + MOVD w+40(FP), R5 + MOVD h+48(FP), R6 + MOVD shift+56(FP), R7 + + LSL $1, R4 + + SUB $1, R7, R8 + MOVD $1, R9 + LSL R8, R9, R9 + VDUP R9, V1.H8 + + NEG R7, R10 + VDUP R10, V2.H8 + +rows: + MOVD R0, R11 + MOVD R2, R12 + MOVD R3, R13 + MOVD R5, R14 + +cols: + VLD1.P 16(R12), [V0.H8] + VLD1.P 16(R13), [V3.H8] + SQADD8H_(0, 0, 3) + SQADD8H_(0, 0, 1) + SSHL8H_(0, 0, 2) + SQXTUN8B3(0, 0) + VST1.P [V0.B8], 8(R11) + + SUB $8, R14 + CBNZ R14, cols + + ADD R1, R0 + ADD R4, R2 + ADD R4, R3 + SUB $1, R6 + CBNZ R6, rows + + RET diff --git a/third_party/h265/hevc/interpred_bi_riscv64.s b/third_party/h265/hevc/interpred_bi_riscv64.s new file mode 100644 index 00000000..b1ef1678 --- /dev/null +++ b/third_party/h265/hevc/interpred_bi_riscv64.s @@ -0,0 +1,67 @@ +//go:build riscv64 && riscv64.rva23u64 && !noasm + +#include "textflag.h" + +// func predBi8RVV(dst *uint8, dstStride int, a, b *int16, srcStride, w, h, shift int) +// +// 8.5.3.3.4.2 for two lists, summed at 32 bits as the Go path does, then +// clamped and narrowed. +TEXT ·predBi8RVV(SB), NOSPLIT, $0-64 + MOV dst+0(FP), X10 + MOV dstStride+8(FP), X11 + MOV a+16(FP), X12 + MOV b+24(FP), X13 + MOV srcStride+32(FP), X14 + MOV w+40(FP), X15 + MOV h+48(FP), X16 + MOV shift+56(FP), X17 + + SLLI $1, X14 + + ADD $-1, X17, X18 + MOV $1, X19 + SLL X18, X19, X19 + + MOV $255, X20 + +rows: + MOV X10, X21 + MOV X12, X22 + MOV X13, X23 + MOV X15, X24 + +cols: + VSETVLI X24, E16, MF2, TA, MA, X25 + + VLE16V (X22), V1 + VLE16V (X23), V2 + + VSETVLI X24, E32, M1, TA, MA, X25 + VSEXTVF2 V1, V3 + VSEXTVF2 V2, V4 + VADDVV V4, V3, V3 + VADDVX X19, V3, V3 + VSRAVX X17, V3, V3 + VMAXVX X0, V3, V3 + VMINVX X20, V3, V3 + + VSETVLI X24, E16, MF2, TA, MA, X25 + VNSRLWI $0, V3, V5 + VSETVLI X24, E8, MF4, TA, MA, X25 + VNSRLWI $0, V5, V6 + VSE8V V6, (X21) + + SLLI $1, X25, X26 + ADD X26, X22 + ADD X26, X23 + ADD X25, X21 + SUB X25, X24 + BNEZ X24, cols + + ADD X11, X10 + ADD X14, X12 + ADD X14, X13 + ADD $-1, X16 + BNEZ X16, rows + + RET diff --git a/third_party/h265/hevc/interpred_copy_amd64.s b/third_party/h265/hevc/interpred_copy_amd64.s new file mode 100644 index 00000000..9539f534 --- /dev/null +++ b/third_party/h265/hevc/interpred_copy_amd64.s @@ -0,0 +1,125 @@ +//go:build amd64 && !noasm + +#include "textflag.h" + +// func mcCopy8AVX2(dst *int16, dstStride int, src *uint8, srcStride, w, h, shift int) +// +// The integer-position case of 8.5.3.3.3, which is a widening copy into the +// 14-bit intermediate. w is a multiple of eight. +TEXT ·mcCopy8AVX2(SB), NOSPLIT, $0-56 + MOVQ dst+0(FP), DI + MOVQ dstStride+8(FP), SI + MOVQ src+16(FP), DX + MOVQ srcStride+24(FP), CX + MOVQ w+32(FP), R8 + MOVQ h+40(FP), R9 + MOVQ shift+48(FP), R10 + + SHLQ $1, SI + MOVQ R10, X2 + +rows: + MOVQ DI, R11 + MOVQ DX, R12 + MOVQ R8, R13 + +wide: + CMPQ R13, $16 + JLT narrow + + VPMOVZXBW (R12), Y0 + VPSLLW X2, Y0, Y0 + VMOVDQU Y0, (R11) + + ADDQ $16, R12 + ADDQ $32, R11 + SUBQ $16, R13 + JMP wide + +narrow: + TESTQ R13, R13 + JZ next + + VPMOVZXBW (R12), X0 + VPSLLW X2, X0, X0 + VMOVDQU X0, (R11) + + ADDQ $8, R12 + ADDQ $16, R11 + SUBQ $8, R13 + JMP narrow + +next: + ADDQ SI, DI + ADDQ CX, DX + DECQ R9 + JNZ rows + + VZEROUPPER + RET + +// func mcCopy8AVX512(dst *int16, dstStride int, src *uint8, srcStride, w, h, shift int) +TEXT ·mcCopy8AVX512(SB), NOSPLIT, $0-56 + MOVQ dst+0(FP), DI + MOVQ dstStride+8(FP), SI + MOVQ src+16(FP), DX + MOVQ srcStride+24(FP), CX + MOVQ w+32(FP), R8 + MOVQ h+40(FP), R9 + MOVQ shift+48(FP), R10 + + SHLQ $1, SI + MOVQ R10, X2 + +rows512: + MOVQ DI, R11 + MOVQ DX, R12 + MOVQ R8, R13 + +wide512: + CMPQ R13, $32 + JLT half512 + + VPMOVZXBW (R12), Z0 + VPSLLW X2, Z0, Z0 + VMOVDQU32 Z0, (R11) + + ADDQ $32, R12 + ADDQ $64, R11 + SUBQ $32, R13 + JMP wide512 + +half512: + CMPQ R13, $16 + JLT narrow512 + + VPMOVZXBW (R12), Y0 + VPSLLW X2, Y0, Y0 + VMOVDQU Y0, (R11) + + ADDQ $16, R12 + ADDQ $32, R11 + SUBQ $16, R13 + JMP half512 + +narrow512: + TESTQ R13, R13 + JZ next512 + + VPMOVZXBW (R12), X0 + VPSLLW X2, X0, X0 + VMOVDQU X0, (R11) + + ADDQ $8, R12 + ADDQ $16, R11 + SUBQ $8, R13 + JMP narrow512 + +next512: + ADDQ SI, DI + ADDQ CX, DX + DECQ R9 + JNZ rows512 + + VZEROUPPER + RET diff --git a/third_party/h265/hevc/interpred_copy_arm64.s b/third_party/h265/hevc/interpred_copy_arm64.s new file mode 100644 index 00000000..88646862 --- /dev/null +++ b/third_party/h265/hevc/interpred_copy_arm64.s @@ -0,0 +1,42 @@ +//go:build arm64 && !noasm + +#include "textflag.h" + +#define USHL8H(Vd, Vn, Vm) WORD $(0x6e604400 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) + +// func mcCopy8NEON(dst *int16, dstStride int, src *uint8, srcStride, w, h, shift int) +// +// The integer-position case of 8.5.3.3.3, a widening copy into the 14-bit +// intermediate. w is a multiple of eight. +TEXT ·mcCopy8NEON(SB), NOSPLIT, $0-56 + MOVD dst+0(FP), R0 + MOVD dstStride+8(FP), R1 + MOVD src+16(FP), R2 + MOVD srcStride+24(FP), R3 + MOVD w+32(FP), R4 + MOVD h+40(FP), R5 + MOVD shift+48(FP), R6 + + LSL $1, R1 + VDUP R6, V1.H8 + +rows: + MOVD R0, R7 + MOVD R2, R8 + MOVD R4, R9 + +cols: + VLD1.P 8(R8), [V0.B8] + VUXTL V0.B8, V0.H8 + USHL8H(0, 0, 1) + VST1.P [V0.H8], 16(R7) + + SUB $8, R9 + CBNZ R9, cols + + ADD R1, R0 + ADD R3, R2 + SUB $1, R5 + CBNZ R5, rows + + RET diff --git a/third_party/h265/hevc/interpred_copy_riscv64.s b/third_party/h265/hevc/interpred_copy_riscv64.s new file mode 100644 index 00000000..87d5e8b0 --- /dev/null +++ b/third_party/h265/hevc/interpred_copy_riscv64.s @@ -0,0 +1,44 @@ +//go:build riscv64 && riscv64.rva23u64 && !noasm + +#include "textflag.h" + +// func mcCopy8RVV(dst *int16, dstStride int, src *uint8, srcStride, w, h, shift int) +// +// The integer-position case of 8.5.3.3.3, a widening copy into the 14-bit +// intermediate. w is a multiple of eight. +TEXT ·mcCopy8RVV(SB), NOSPLIT, $0-56 + MOV dst+0(FP), X10 + MOV dstStride+8(FP), X11 + MOV src+16(FP), X12 + MOV srcStride+24(FP), X13 + MOV w+32(FP), X14 + MOV h+40(FP), X15 + MOV shift+48(FP), X16 + + SLLI $1, X11 + +rows: + MOV X10, X17 + MOV X12, X18 + MOV X14, X19 + +cols: + VSETVLI X19, E16, M1, TA, MA, X20 + + VLE8V (X18), V1 + VZEXTVF2 V1, V2 + VSLLVX X16, V2, V2 + VSE16V V2, (X17) + + SLLI $1, X20, X21 + ADD X21, X17 + ADD X20, X18 + SUB X20, X19 + BNEZ X19, cols + + ADD X11, X10 + ADD X13, X12 + ADD $-1, X15 + BNEZ X15, rows + + RET diff --git a/third_party/h265/hevc/interpred_riscv64.s b/third_party/h265/hevc/interpred_riscv64.s new file mode 100644 index 00000000..9222308c --- /dev/null +++ b/third_party/h265/hevc/interpred_riscv64.s @@ -0,0 +1,63 @@ +//go:build riscv64 && riscv64.rva23u64 && !noasm + +#include "textflag.h" + +// func predUni8RVV(dst *uint8, dstStride int, src *int16, srcStride, w, h, shift int) +// +// 8.5.3.3.4.2 without weights. The sum is taken at 32 bits, as the Go path +// does, then clamped and narrowed. The narrowing shift is logical, so the +// clamp has to come first: the unsigned saturating form would turn a negative +// sample into the maximum rather than zero. +TEXT ·predUni8RVV(SB), NOSPLIT, $0-56 + MOV dst+0(FP), X10 + MOV dstStride+8(FP), X11 + MOV src+16(FP), X12 + MOV srcStride+24(FP), X13 + MOV w+32(FP), X14 + MOV h+40(FP), X15 + MOV shift+48(FP), X16 + + SLLI $1, X13 + + // off = 1 << (shift-1) + ADD $-1, X16, X17 + MOV $1, X18 + SLL X17, X18, X18 + + MOV $255, X24 + +rows: + MOV X10, X19 + MOV X12, X20 + MOV X14, X21 + +cols: + VSETVLI X21, E16, MF2, TA, MA, X22 + + VLE16V (X20), V1 + VSETVLI X21, E32, M1, TA, MA, X22 + VSEXTVF2 V1, V2 + VADDVX X18, V2, V2 + VSRAVX X16, V2, V2 + + VMAXVX X0, V2, V2 + VMINVX X24, V2, V2 + + VSETVLI X21, E16, MF2, TA, MA, X22 + VNSRLWI $0, V2, V3 + VSETVLI X21, E8, MF4, TA, MA, X22 + VNSRLWI $0, V3, V4 + VSE8V V4, (X19) + + SLLI $1, X22, X23 + ADD X23, X20 + ADD X22, X19 + SUB X22, X21 + BNEZ X21, cols + + ADD X11, X10 + ADD X13, X12 + ADD $-1, X15 + BNEZ X15, rows + + RET diff --git a/third_party/h265/hevc/interpred_tap_amd64.s b/third_party/h265/hevc/interpred_tap_amd64.s new file mode 100644 index 00000000..630ed0e2 --- /dev/null +++ b/third_party/h265/hevc/interpred_tap_amd64.s @@ -0,0 +1,253 @@ +//go:build amd64 && !noasm + +#include "textflag.h" + +// func mcTap8AVX2(dst *int16, dstStride int, src *uint8, srcStride, tapStride, w, h int, f *int16) +// +// 8-tap interpolation of 8.5.3.3.3 for eight-bit samples, in one direction. +// tapStride is one for the horizontal pass and the source stride for the +// vertical one; src already points at the first tap. shift1 is zero at eight +// bits and every partial sum stays inside int16. w is a multiple of eight. +TEXT ·mcTap8AVX2(SB), NOSPLIT, $0-64 + MOVQ dst+0(FP), DI + MOVQ dstStride+8(FP), SI + MOVQ src+16(FP), DX + MOVQ srcStride+24(FP), CX + MOVQ tapStride+32(FP), R8 + MOVQ w+40(FP), R9 + MOVQ h+48(FP), R10 + MOVQ f+56(FP), R11 + + SHLQ $1, SI + + VPBROADCASTW 0(R11), Y8 + VPBROADCASTW 2(R11), Y9 + VPBROADCASTW 4(R11), Y10 + VPBROADCASTW 6(R11), Y11 + VPBROADCASTW 8(R11), Y12 + VPBROADCASTW 10(R11), Y13 + VPBROADCASTW 12(R11), Y14 + VPBROADCASTW 14(R11), Y15 + +rows8: + MOVQ DI, R12 + MOVQ DX, R13 + MOVQ R9, R14 + +wide8: + CMPQ R14, $16 + JLT narrow8 + + MOVQ R13, R15 + + VPMOVZXBW (R15), Y1 + VPMULLW Y8, Y1, Y1 + VMOVDQU Y1, Y0 + ADDQ R8, R15 + + VPMOVZXBW (R15), Y1 + VPMULLW Y9, Y1, Y1 + VPADDW Y1, Y0, Y0 + ADDQ R8, R15 + + VPMOVZXBW (R15), Y1 + VPMULLW Y10, Y1, Y1 + VPADDW Y1, Y0, Y0 + ADDQ R8, R15 + + VPMOVZXBW (R15), Y1 + VPMULLW Y11, Y1, Y1 + VPADDW Y1, Y0, Y0 + ADDQ R8, R15 + + VPMOVZXBW (R15), Y1 + VPMULLW Y12, Y1, Y1 + VPADDW Y1, Y0, Y0 + ADDQ R8, R15 + + VPMOVZXBW (R15), Y1 + VPMULLW Y13, Y1, Y1 + VPADDW Y1, Y0, Y0 + ADDQ R8, R15 + + VPMOVZXBW (R15), Y1 + VPMULLW Y14, Y1, Y1 + VPADDW Y1, Y0, Y0 + ADDQ R8, R15 + + VPMOVZXBW (R15), Y1 + VPMULLW Y15, Y1, Y1 + VPADDW Y1, Y0, Y0 + ADDQ R8, R15 + + VMOVDQU Y0, (R12) + + ADDQ $16, R13 + ADDQ $32, R12 + SUBQ $16, R14 + JMP wide8 + +narrow8: + TESTQ R14, R14 + JZ next8 + + MOVQ R13, R15 + + VPMOVZXBW (R15), X1 + VPMULLW X8, X1, X1 + VMOVDQU X1, X0 + ADDQ R8, R15 + + VPMOVZXBW (R15), X1 + VPMULLW X9, X1, X1 + VPADDW X1, X0, X0 + ADDQ R8, R15 + + VPMOVZXBW (R15), X1 + VPMULLW X10, X1, X1 + VPADDW X1, X0, X0 + ADDQ R8, R15 + + VPMOVZXBW (R15), X1 + VPMULLW X11, X1, X1 + VPADDW X1, X0, X0 + ADDQ R8, R15 + + VPMOVZXBW (R15), X1 + VPMULLW X12, X1, X1 + VPADDW X1, X0, X0 + ADDQ R8, R15 + + VPMOVZXBW (R15), X1 + VPMULLW X13, X1, X1 + VPADDW X1, X0, X0 + ADDQ R8, R15 + + VPMOVZXBW (R15), X1 + VPMULLW X14, X1, X1 + VPADDW X1, X0, X0 + ADDQ R8, R15 + + VPMOVZXBW (R15), X1 + VPMULLW X15, X1, X1 + VPADDW X1, X0, X0 + ADDQ R8, R15 + + VMOVDQU X0, (R12) + + ADDQ $8, R13 + ADDQ $16, R12 + SUBQ $8, R14 + JMP narrow8 + +next8: + ADDQ SI, DI + ADDQ CX, DX + DECQ R10 + JNZ rows8 + + VZEROUPPER + RET + +// func mcTap4AVX2(dst *int16, dstStride int, src *uint8, srcStride, tapStride, w, h int, f *int16) +// +// 4-tap interpolation of 8.5.3.3.3 for eight-bit samples, in one direction. +// tapStride is one for the horizontal pass and the source stride for the +// vertical one; src already points at the first tap. shift1 is zero at eight +// bits and every partial sum stays inside int16. w is a multiple of eight. +TEXT ·mcTap4AVX2(SB), NOSPLIT, $0-64 + MOVQ dst+0(FP), DI + MOVQ dstStride+8(FP), SI + MOVQ src+16(FP), DX + MOVQ srcStride+24(FP), CX + MOVQ tapStride+32(FP), R8 + MOVQ w+40(FP), R9 + MOVQ h+48(FP), R10 + MOVQ f+56(FP), R11 + + SHLQ $1, SI + + VPBROADCASTW 0(R11), Y8 + VPBROADCASTW 2(R11), Y9 + VPBROADCASTW 4(R11), Y10 + VPBROADCASTW 6(R11), Y11 + +rows4: + MOVQ DI, R12 + MOVQ DX, R13 + MOVQ R9, R14 + +wide4: + CMPQ R14, $16 + JLT narrow4 + + MOVQ R13, R15 + + VPMOVZXBW (R15), Y1 + VPMULLW Y8, Y1, Y1 + VMOVDQU Y1, Y0 + ADDQ R8, R15 + + VPMOVZXBW (R15), Y1 + VPMULLW Y9, Y1, Y1 + VPADDW Y1, Y0, Y0 + ADDQ R8, R15 + + VPMOVZXBW (R15), Y1 + VPMULLW Y10, Y1, Y1 + VPADDW Y1, Y0, Y0 + ADDQ R8, R15 + + VPMOVZXBW (R15), Y1 + VPMULLW Y11, Y1, Y1 + VPADDW Y1, Y0, Y0 + ADDQ R8, R15 + + VMOVDQU Y0, (R12) + + ADDQ $16, R13 + ADDQ $32, R12 + SUBQ $16, R14 + JMP wide4 + +narrow4: + TESTQ R14, R14 + JZ next4 + + MOVQ R13, R15 + + VPMOVZXBW (R15), X1 + VPMULLW X8, X1, X1 + VMOVDQU X1, X0 + ADDQ R8, R15 + + VPMOVZXBW (R15), X1 + VPMULLW X9, X1, X1 + VPADDW X1, X0, X0 + ADDQ R8, R15 + + VPMOVZXBW (R15), X1 + VPMULLW X10, X1, X1 + VPADDW X1, X0, X0 + ADDQ R8, R15 + + VPMOVZXBW (R15), X1 + VPMULLW X11, X1, X1 + VPADDW X1, X0, X0 + ADDQ R8, R15 + + VMOVDQU X0, (R12) + + ADDQ $8, R13 + ADDQ $16, R12 + SUBQ $8, R14 + JMP narrow4 + +next4: + ADDQ SI, DI + ADDQ CX, DX + DECQ R10 + JNZ rows4 + + VZEROUPPER + RET diff --git a/third_party/h265/hevc/interpred_tap_arm64.s b/third_party/h265/hevc/interpred_tap_arm64.s new file mode 100644 index 00000000..b55e5e9b --- /dev/null +++ b/third_party/h265/hevc/interpred_tap_arm64.s @@ -0,0 +1,152 @@ +//go:build arm64 && !noasm + +#include "textflag.h" + +#define MUL8H(Vd, Vn, Vm) WORD $(0x4e609c00 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define MLA8H(Vd, Vn, Vm) WORD $(0x4e609400 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) + +// func mcTap8NEON(dst *int16, dstStride int, src *uint8, srcStride, tapStride, w, h int, f *int16) +// +// 8-tap interpolation of 8.5.3.3.3 for eight-bit samples, in one direction. +TEXT ·mcTap8NEON(SB), NOSPLIT, $0-64 + MOVD dst+0(FP), R0 + MOVD dstStride+8(FP), R1 + MOVD src+16(FP), R2 + MOVD srcStride+24(FP), R3 + MOVD tapStride+32(FP), R8 + MOVD w+40(FP), R9 + MOVD h+48(FP), R10 + MOVD f+56(FP), R11 + + LSL $1, R1 + + VLD1R.P 2(R11), [V16.H8] + VLD1R.P 2(R11), [V17.H8] + VLD1R.P 2(R11), [V18.H8] + VLD1R.P 2(R11), [V19.H8] + VLD1R.P 2(R11), [V20.H8] + VLD1R.P 2(R11), [V21.H8] + VLD1R.P 2(R11), [V22.H8] + VLD1R.P 2(R11), [V23.H8] + +rows8: + MOVD R0, R12 + MOVD R2, R13 + MOVD R9, R14 + +cols8: + MOVD R13, R15 + + VLD1 (R15), [V0.B8] + VUXTL V0.B8, V0.H8 + MUL8H(2, 0, 16) + ADD R8, R15 + + VLD1 (R15), [V0.B8] + VUXTL V0.B8, V0.H8 + MLA8H(2, 0, 17) + ADD R8, R15 + + VLD1 (R15), [V0.B8] + VUXTL V0.B8, V0.H8 + MLA8H(2, 0, 18) + ADD R8, R15 + + VLD1 (R15), [V0.B8] + VUXTL V0.B8, V0.H8 + MLA8H(2, 0, 19) + ADD R8, R15 + + VLD1 (R15), [V0.B8] + VUXTL V0.B8, V0.H8 + MLA8H(2, 0, 20) + ADD R8, R15 + + VLD1 (R15), [V0.B8] + VUXTL V0.B8, V0.H8 + MLA8H(2, 0, 21) + ADD R8, R15 + + VLD1 (R15), [V0.B8] + VUXTL V0.B8, V0.H8 + MLA8H(2, 0, 22) + ADD R8, R15 + + VLD1 (R15), [V0.B8] + VUXTL V0.B8, V0.H8 + MLA8H(2, 0, 23) + ADD R8, R15 + + VST1.P [V2.H8], 16(R12) + + ADD $8, R13 + SUB $8, R14 + CBNZ R14, cols8 + + ADD R1, R0 + ADD R3, R2 + SUB $1, R10 + CBNZ R10, rows8 + + RET + +// func mcTap4NEON(dst *int16, dstStride int, src *uint8, srcStride, tapStride, w, h int, f *int16) +// +// 4-tap interpolation of 8.5.3.3.3 for eight-bit samples, in one direction. +TEXT ·mcTap4NEON(SB), NOSPLIT, $0-64 + MOVD dst+0(FP), R0 + MOVD dstStride+8(FP), R1 + MOVD src+16(FP), R2 + MOVD srcStride+24(FP), R3 + MOVD tapStride+32(FP), R8 + MOVD w+40(FP), R9 + MOVD h+48(FP), R10 + MOVD f+56(FP), R11 + + LSL $1, R1 + + VLD1R.P 2(R11), [V16.H8] + VLD1R.P 2(R11), [V17.H8] + VLD1R.P 2(R11), [V18.H8] + VLD1R.P 2(R11), [V19.H8] + +rows4: + MOVD R0, R12 + MOVD R2, R13 + MOVD R9, R14 + +cols4: + MOVD R13, R15 + + VLD1 (R15), [V0.B8] + VUXTL V0.B8, V0.H8 + MUL8H(2, 0, 16) + ADD R8, R15 + + VLD1 (R15), [V0.B8] + VUXTL V0.B8, V0.H8 + MLA8H(2, 0, 17) + ADD R8, R15 + + VLD1 (R15), [V0.B8] + VUXTL V0.B8, V0.H8 + MLA8H(2, 0, 18) + ADD R8, R15 + + VLD1 (R15), [V0.B8] + VUXTL V0.B8, V0.H8 + MLA8H(2, 0, 19) + ADD R8, R15 + + VST1.P [V2.H8], 16(R12) + + ADD $8, R13 + SUB $8, R14 + CBNZ R14, cols4 + + ADD R1, R0 + ADD R3, R2 + SUB $1, R10 + CBNZ R10, rows4 + + RET diff --git a/third_party/h265/hevc/interpred_tap_riscv64.s b/third_party/h265/hevc/interpred_tap_riscv64.s new file mode 100644 index 00000000..102b6bb6 --- /dev/null +++ b/third_party/h265/hevc/interpred_tap_riscv64.s @@ -0,0 +1,62 @@ +//go:build riscv64 && riscv64.rva23u64 && !noasm + +#include "textflag.h" + +// func mcTap8RVV(dst *int16, dstStride int, src *uint8, srcStride, tapStride, w, h, taps int, f *int16) +// +// The eight-tap interpolation of 8.5.3.3.3.1 for eight-bit samples, in one +// direction. tapStride is one for the horizontal pass and the source stride +// for the vertical one; src already points at the first tap. shift1 is zero at +// eight bits and every partial sum stays inside int16. +TEXT ·mcTap8RVV(SB), NOSPLIT, $0-72 + MOV dst+0(FP), X10 + MOV dstStride+8(FP), X11 + MOV src+16(FP), X12 + MOV srcStride+24(FP), X13 + MOV tapStride+32(FP), X14 + MOV w+40(FP), X15 + MOV h+48(FP), X16 + MOV taps+56(FP), X28 + MOV f+64(FP), X17 + + SLLI $1, X11 + +rows: + MOV X10, X18 + MOV X12, X19 + MOV X15, X20 + +cols: + VSETVLI X20, E16, M1, TA, MA, X21 + + MOV X19, X22 + MOV X17, X23 + MOV X28, X24 + + VMVVI $0, V4 + +taps: + VLE8V (X22), V1 + VZEXTVF2 V1, V2 + MOVH (X23), X25 + VMACCVX V2, X25, V4 + + ADD X14, X22 + ADD $2, X23 + ADD $-1, X24 + BNEZ X24, taps + + VSE16V V4, (X18) + + SLLI $1, X21, X26 + ADD X26, X18 + ADD X21, X19 + SUB X21, X20 + BNEZ X20, cols + + ADD X11, X10 + ADD X13, X12 + ADD $-1, X16 + BNEZ X16, rows + + RET diff --git a/third_party/h265/hevc/interpred_tapv_amd64.s b/third_party/h265/hevc/interpred_tapv_amd64.s new file mode 100644 index 00000000..0af1166c --- /dev/null +++ b/third_party/h265/hevc/interpred_tapv_amd64.s @@ -0,0 +1,193 @@ +//go:build amd64 && !noasm + +#include "textflag.h" + +// func mcTapV16x8AVX2(dst *int16, dstStride int, src *int16, srcStride, w, h, shift int, f *int16) +// +// The vertical half of the two-pass 8-tap interpolation, reading the +// horizontal pass at sixteen bits. The products need 32 bits, so this widens +// rather than staying in int16 as the first pass does. w is a multiple of +// eight. +TEXT ·mcTapV16x8AVX2(SB), NOSPLIT, $0-64 + MOVQ dst+0(FP), DI + MOVQ dstStride+8(FP), SI + MOVQ src+16(FP), DX + MOVQ srcStride+24(FP), R8 + MOVQ w+32(FP), R9 + MOVQ h+40(FP), R10 + MOVQ shift+48(FP), R14 + MOVQ f+56(FP), R11 + + SHLQ $1, SI + SHLQ $1, R8 + MOVQ R14, X2 + + MOVWQSX 0(R11), AX + MOVQ AX, X3 + VPBROADCASTD X3, Y8 + MOVWQSX 2(R11), AX + MOVQ AX, X3 + VPBROADCASTD X3, Y9 + MOVWQSX 4(R11), AX + MOVQ AX, X3 + VPBROADCASTD X3, Y10 + MOVWQSX 6(R11), AX + MOVQ AX, X3 + VPBROADCASTD X3, Y11 + MOVWQSX 8(R11), AX + MOVQ AX, X3 + VPBROADCASTD X3, Y12 + MOVWQSX 10(R11), AX + MOVQ AX, X3 + VPBROADCASTD X3, Y13 + MOVWQSX 12(R11), AX + MOVQ AX, X3 + VPBROADCASTD X3, Y14 + MOVWQSX 14(R11), AX + MOVQ AX, X3 + VPBROADCASTD X3, Y15 + +rows8: + MOVQ DI, R12 + MOVQ DX, R13 + MOVQ R9, R14 + +cols8: + MOVQ R13, R15 + + VPMOVSXWD (R15), Y1 + VPMULLD Y8, Y1, Y1 + VMOVDQU Y1, Y0 + ADDQ R8, R15 + + VPMOVSXWD (R15), Y1 + VPMULLD Y9, Y1, Y1 + VPADDD Y1, Y0, Y0 + ADDQ R8, R15 + + VPMOVSXWD (R15), Y1 + VPMULLD Y10, Y1, Y1 + VPADDD Y1, Y0, Y0 + ADDQ R8, R15 + + VPMOVSXWD (R15), Y1 + VPMULLD Y11, Y1, Y1 + VPADDD Y1, Y0, Y0 + ADDQ R8, R15 + + VPMOVSXWD (R15), Y1 + VPMULLD Y12, Y1, Y1 + VPADDD Y1, Y0, Y0 + ADDQ R8, R15 + + VPMOVSXWD (R15), Y1 + VPMULLD Y13, Y1, Y1 + VPADDD Y1, Y0, Y0 + ADDQ R8, R15 + + VPMOVSXWD (R15), Y1 + VPMULLD Y14, Y1, Y1 + VPADDD Y1, Y0, Y0 + ADDQ R8, R15 + + VPMOVSXWD (R15), Y1 + VPMULLD Y15, Y1, Y1 + VPADDD Y1, Y0, Y0 + ADDQ R8, R15 + + VPSRAD X2, Y0, Y0 + VPACKSSDW Y0, Y0, Y0 + VPERMQ $0xd8, Y0, Y0 + VMOVDQU X0, (R12) + + ADDQ $16, R13 + ADDQ $16, R12 + SUBQ $8, R14 + JNZ cols8 + + ADDQ SI, DI + ADDQ R8, DX + DECQ R10 + JNZ rows8 + + VZEROUPPER + RET + +// func mcTapV16x4AVX2(dst *int16, dstStride int, src *int16, srcStride, w, h, shift int, f *int16) +// +// The vertical half of the two-pass 4-tap interpolation, reading the +// horizontal pass at sixteen bits. The products need 32 bits, so this widens +// rather than staying in int16 as the first pass does. w is a multiple of +// eight. +TEXT ·mcTapV16x4AVX2(SB), NOSPLIT, $0-64 + MOVQ dst+0(FP), DI + MOVQ dstStride+8(FP), SI + MOVQ src+16(FP), DX + MOVQ srcStride+24(FP), R8 + MOVQ w+32(FP), R9 + MOVQ h+40(FP), R10 + MOVQ shift+48(FP), R14 + MOVQ f+56(FP), R11 + + SHLQ $1, SI + SHLQ $1, R8 + MOVQ R14, X2 + + MOVWQSX 0(R11), AX + MOVQ AX, X3 + VPBROADCASTD X3, Y8 + MOVWQSX 2(R11), AX + MOVQ AX, X3 + VPBROADCASTD X3, Y9 + MOVWQSX 4(R11), AX + MOVQ AX, X3 + VPBROADCASTD X3, Y10 + MOVWQSX 6(R11), AX + MOVQ AX, X3 + VPBROADCASTD X3, Y11 + +rows4: + MOVQ DI, R12 + MOVQ DX, R13 + MOVQ R9, R14 + +cols4: + MOVQ R13, R15 + + VPMOVSXWD (R15), Y1 + VPMULLD Y8, Y1, Y1 + VMOVDQU Y1, Y0 + ADDQ R8, R15 + + VPMOVSXWD (R15), Y1 + VPMULLD Y9, Y1, Y1 + VPADDD Y1, Y0, Y0 + ADDQ R8, R15 + + VPMOVSXWD (R15), Y1 + VPMULLD Y10, Y1, Y1 + VPADDD Y1, Y0, Y0 + ADDQ R8, R15 + + VPMOVSXWD (R15), Y1 + VPMULLD Y11, Y1, Y1 + VPADDD Y1, Y0, Y0 + ADDQ R8, R15 + + VPSRAD X2, Y0, Y0 + VPACKSSDW Y0, Y0, Y0 + VPERMQ $0xd8, Y0, Y0 + VMOVDQU X0, (R12) + + ADDQ $16, R13 + ADDQ $16, R12 + SUBQ $8, R14 + JNZ cols4 + + ADDQ SI, DI + ADDQ R8, DX + DECQ R10 + JNZ rows4 + + VZEROUPPER + RET diff --git a/third_party/h265/hevc/interpred_tapv_arm64.s b/third_party/h265/hevc/interpred_tapv_arm64.s new file mode 100644 index 00000000..5f6d35b4 --- /dev/null +++ b/third_party/h265/hevc/interpred_tapv_arm64.s @@ -0,0 +1,173 @@ +//go:build arm64 && !noasm + +#include "textflag.h" + +#define SMULL4S(Vd, Vn, Vm) WORD $(0x0e60c000 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define SMULL2_4S(Vd, Vn, Vm) WORD $(0x4e60c000 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define SMLAL4S_(Vd, Vn, Vm) WORD $(0x0e608000 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define SMLAL2_4S_(Vd, Vn, Vm) WORD $(0x4e608000 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define SSHL4S__(Vd, Vn, Vm) WORD $(0x4ea04400 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define SQXTN4H__(Vd, Vn) WORD $(0x0e614800 | ((Vn) << 5) | (Vd)) +#define SQXTN2_8H__(Vd, Vn) WORD $(0x4e614800 | ((Vn) << 5) | (Vd)) + +// func mcTapV16x8NEON(dst *int16, dstStride int, src *int16, srcStride, w, h, shift int, f *int16) +// +// The vertical half of the two-pass 8-tap interpolation, at 32 bits. +TEXT ·mcTapV16x8NEON(SB), NOSPLIT, $0-64 + MOVD dst+0(FP), R0 + MOVD dstStride+8(FP), R1 + MOVD src+16(FP), R2 + MOVD srcStride+24(FP), R8 + MOVD w+32(FP), R9 + MOVD h+40(FP), R10 + MOVD shift+48(FP), R6 + MOVD f+56(FP), R11 + + LSL $1, R1 + LSL $1, R8 + + NEG R6, R7 + VDUP R7, V4.S4 + + VLD1R.P 2(R11), [V16.H8] + VLD1R.P 2(R11), [V17.H8] + VLD1R.P 2(R11), [V18.H8] + VLD1R.P 2(R11), [V19.H8] + VLD1R.P 2(R11), [V20.H8] + VLD1R.P 2(R11), [V21.H8] + VLD1R.P 2(R11), [V22.H8] + VLD1R.P 2(R11), [V23.H8] + +rows8: + MOVD R0, R12 + MOVD R2, R13 + MOVD R9, R14 + +cols8: + MOVD R13, R15 + + VLD1 (R15), [V0.H8] + SMULL4S(2, 0, 16) + SMULL2_4S(3, 0, 16) + ADD R8, R15 + + VLD1 (R15), [V0.H8] + SMLAL4S_(2, 0, 17) + SMLAL2_4S_(3, 0, 17) + ADD R8, R15 + + VLD1 (R15), [V0.H8] + SMLAL4S_(2, 0, 18) + SMLAL2_4S_(3, 0, 18) + ADD R8, R15 + + VLD1 (R15), [V0.H8] + SMLAL4S_(2, 0, 19) + SMLAL2_4S_(3, 0, 19) + ADD R8, R15 + + VLD1 (R15), [V0.H8] + SMLAL4S_(2, 0, 20) + SMLAL2_4S_(3, 0, 20) + ADD R8, R15 + + VLD1 (R15), [V0.H8] + SMLAL4S_(2, 0, 21) + SMLAL2_4S_(3, 0, 21) + ADD R8, R15 + + VLD1 (R15), [V0.H8] + SMLAL4S_(2, 0, 22) + SMLAL2_4S_(3, 0, 22) + ADD R8, R15 + + VLD1 (R15), [V0.H8] + SMLAL4S_(2, 0, 23) + SMLAL2_4S_(3, 0, 23) + ADD R8, R15 + + SSHL4S__(2, 2, 4) + SSHL4S__(3, 3, 4) + SQXTN4H__(5, 2) + SQXTN2_8H__(5, 3) + VST1.P [V5.H8], 16(R12) + + ADD $16, R13 + SUB $8, R14 + CBNZ R14, cols8 + + ADD R1, R0 + ADD R8, R2 + SUB $1, R10 + CBNZ R10, rows8 + + RET + +// func mcTapV16x4NEON(dst *int16, dstStride int, src *int16, srcStride, w, h, shift int, f *int16) +// +// The vertical half of the two-pass 4-tap interpolation, at 32 bits. +TEXT ·mcTapV16x4NEON(SB), NOSPLIT, $0-64 + MOVD dst+0(FP), R0 + MOVD dstStride+8(FP), R1 + MOVD src+16(FP), R2 + MOVD srcStride+24(FP), R8 + MOVD w+32(FP), R9 + MOVD h+40(FP), R10 + MOVD shift+48(FP), R6 + MOVD f+56(FP), R11 + + LSL $1, R1 + LSL $1, R8 + + NEG R6, R7 + VDUP R7, V4.S4 + + VLD1R.P 2(R11), [V16.H8] + VLD1R.P 2(R11), [V17.H8] + VLD1R.P 2(R11), [V18.H8] + VLD1R.P 2(R11), [V19.H8] + +rows4: + MOVD R0, R12 + MOVD R2, R13 + MOVD R9, R14 + +cols4: + MOVD R13, R15 + + VLD1 (R15), [V0.H8] + SMULL4S(2, 0, 16) + SMULL2_4S(3, 0, 16) + ADD R8, R15 + + VLD1 (R15), [V0.H8] + SMLAL4S_(2, 0, 17) + SMLAL2_4S_(3, 0, 17) + ADD R8, R15 + + VLD1 (R15), [V0.H8] + SMLAL4S_(2, 0, 18) + SMLAL2_4S_(3, 0, 18) + ADD R8, R15 + + VLD1 (R15), [V0.H8] + SMLAL4S_(2, 0, 19) + SMLAL2_4S_(3, 0, 19) + ADD R8, R15 + + SSHL4S__(2, 2, 4) + SSHL4S__(3, 3, 4) + SQXTN4H__(5, 2) + SQXTN2_8H__(5, 3) + VST1.P [V5.H8], 16(R12) + + ADD $16, R13 + SUB $8, R14 + CBNZ R14, cols4 + + ADD R1, R0 + ADD R8, R2 + SUB $1, R10 + CBNZ R10, rows4 + + RET diff --git a/third_party/h265/hevc/interpred_tapv_riscv64.s b/third_party/h265/hevc/interpred_tapv_riscv64.s new file mode 100644 index 00000000..7a662593 --- /dev/null +++ b/third_party/h265/hevc/interpred_tapv_riscv64.s @@ -0,0 +1,69 @@ +//go:build riscv64 && riscv64.rva23u64 && !noasm + +#include "textflag.h" + +// func mcTapV16RVV(dst *int16, dstStride int, src *int16, srcStride, w, h, shift, taps int, f *int16) +// +// The vertical half of the two-pass eight-tap interpolation, reading the +// horizontal pass at sixteen bits. The products need 32 bits, so this widens +// rather than staying in int16 as the first pass does. +TEXT ·mcTapV16RVV(SB), NOSPLIT, $0-72 + MOV dst+0(FP), X10 + MOV dstStride+8(FP), X11 + MOV src+16(FP), X12 + MOV srcStride+24(FP), X13 + MOV w+32(FP), X14 + MOV h+40(FP), X15 + MOV shift+48(FP), X16 + MOV taps+56(FP), X28 + MOV f+64(FP), X17 + + SLLI $1, X11 + SLLI $1, X13 + +rows: + MOV X10, X18 + MOV X12, X19 + MOV X14, X20 + +cols: + VSETVLI X20, E32, M1, TA, MA, X21 + + MOV X19, X22 + MOV X17, X23 + MOV X28, X24 + + VMVVI $0, V4 + +taps: + VSETVLI X20, E16, MF2, TA, MA, X21 + VLE16V (X22), V1 + VSETVLI X20, E32, M1, TA, MA, X21 + VSEXTVF2 V1, V2 + MOVH (X23), X25 + VMACCVX V2, X25, V4 + + ADD X13, X22 + ADD $2, X23 + ADD $-1, X24 + BNEZ X24, taps + + VSRAVX X16, V4, V4 + + VSETVLI X20, E16, MF2, TA, MA, X21 + VNSRLWI $0, V4, V5 + VSE16V V5, (X18) + VSETVLI X20, E32, M1, TA, MA, X21 + + SLLI $1, X21, X26 + ADD X26, X18 + ADD X26, X19 + SUB X21, X20 + BNEZ X20, cols + + ADD X11, X10 + ADD X13, X12 + ADD $-1, X15 + BNEZ X15, rows + + RET diff --git a/third_party/h265/hevc/intrapred.go b/third_party/h265/hevc/intrapred.go new file mode 100644 index 00000000..e0dfd7d6 --- /dev/null +++ b/third_party/h265/hevc/intrapred.go @@ -0,0 +1,356 @@ +package hevc + +type pixel interface { + ~uint8 | ~uint16 +} + +const ( + intraPlanar = 0 + intraDC = 1 + intraHor = 10 + intraVer = 26 +) + +// Table 8-4, indexed by predModeIntra - 2. +var intraPredAngle = [33]int32{ + 32, 26, 21, 17, 13, 9, 5, 2, 0, -2, -5, -9, -13, -17, -21, -26, -32, + -26, -21, -17, -13, -9, -5, -2, 0, 2, 5, 9, 13, 17, 21, 26, 32, +} + +// Table 8-5, indexed by predModeIntra - 11. +var intraInvAngle = [15]int32{ + -4096, -1638, -910, -630, -482, -390, -315, -256, -315, -390, -482, -630, -910, -1638, -4096, +} + +// refSamples holds p[][] flattened in the order 8.4.4.2.2 scans it: the left +// column bottom to top, the corner, then the top row left to right. +type refSamples struct { + s [4*32 + 1]int32 + n int +} + +func (r *refSamples) corner() int32 { return r.s[2*r.n] } +func (r *refSamples) top(x int) int32 { return r.s[2*r.n+1+x] } +func (r *refSamples) left(y int) int32 { return r.s[2*r.n-1-y] } +func (r *refSamples) setTop(x int, v int32) { r.s[2*r.n+1+x] = v } +func (r *refSamples) setLeft(y int, v int32) { r.s[2*r.n-1-y] = v } + +func (r *refSamples) copyFrom(src *refSamples) { + r.n = src.n + copy(r.s[:4*src.n+1], src.s[:4*src.n+1]) +} + +// substitute is 8.4.4.2.2. avail runs in the same order as s. +func (r *refSamples) substitute(avail []bool, bitDepth int) { + n := 4*r.n + 1 + + first := -1 + + for i := range n { + if avail[i] { + first = i + + break + } + } + + if first < 0 { + v := int32(1) << (bitDepth - 1) + for i := range n { + r.s[i] = v + } + + return + } + + r.s[0] = r.s[first] + + for i := 1; i < n; i++ { + if !avail[i] { + r.s[i] = r.s[i-1] + } + } +} + +// filterFlag is the derivation in 8.4.4.2.3. +func filterFlag(mode, n, cIdx int, sps *sps) bool { + if cIdx != 0 && sps.chromaArrayType() != 3 { + return false + } + + if mode == intraDC || n == 4 { + return false + } + + var thres int + + switch n { + case 8: + thres = 7 + case 16: + thres = 1 + case 32: + thres = 0 + default: + return false + } + + return min(abs(mode-intraVer), abs(mode-intraHor)) > thres +} + +func abs(v int) int { + if v < 0 { + return -v + } + + return v +} + +// filterRef is 8.4.4.2.3. +func filterRef(r *refSamples, mode, cIdx, bitDepth int, sps *sps) { + if sps.intraSmoothingDisabled || !filterFlag(mode, r.n, cIdx, sps) { + return + } + + n := r.n + + if sps.strongIntraSmoothing && cIdx == 0 && n == 32 { + lim := int32(1) << (bitDepth - 5) + + if absI32(r.corner()+r.top(2*n-1)-2*r.top(n-1)) < lim && + absI32(r.corner()+r.left(2*n-1)-2*r.left(n-1)) < lim { + c, tr, bl := r.corner(), r.top(2*n-1), r.left(2*n-1) + + for i := range 2*n - 1 { + r.setLeft(i, ((63-int32(i))*c+int32(i+1)*bl+32)>>6) + r.setTop(i, ((63-int32(i))*c+int32(i+1)*tr+32)>>6) + } + + return + } + } + + var f [4*32 + 1]int32 + + f[0] = r.s[0] + f[4*n] = r.s[4*n] + + for i := 1; i < 4*n; i++ { + f[i] = (r.s[i-1] + 2*r.s[i] + r.s[i+1] + 2) >> 2 + } + + copy(r.s[:4*n+1], f[:4*n+1]) +} + +func absI32(v int32) int32 { + if v < 0 { + return -v + } + + return v +} + +func clip1[P pixel](v int32, bitDepth int) P { + return P(clip3(v, 0, 1<= 8 { + if p, ok := any(dst).([]uint8); ok { + k(p[off:], stride, r, shift) + + return + } + } + + predPlanarGo(dst, off, stride, r, n, shift) +} + +func predPlanarGo[P pixel](dst []P, off, stride int, r *refSamples, n, shift int) { + tr, bl := r.top(n), r.left(n) + + for y := range n { + l := r.left(y) + + for x := range n { + v := (int32(n-1-x)*l + int32(x+1)*tr + + int32(n-1-y)*r.top(x) + int32(y+1)*bl + int32(n)) >> shift + dst[off+y*stride+x] = P(v) + } + } +} + +func predDC[P pixel](dst []P, off, stride int, r *refSamples, cIdx int) { + n := r.n + + var sum int32 + for i := range n { + sum += r.top(i) + r.left(i) + } + + dc := (sum + int32(n)) >> (log2(n) + 1) + + for y := range n { + for x := range n { + dst[off+y*stride+x] = P(dc) + } + } + + if cIdx != 0 || n >= 32 { + return + } + + dst[off] = P((r.left(0) + 2*dc + r.top(0) + 2) >> 2) + + for x := 1; x < n; x++ { + dst[off+x] = P((r.top(x) + 3*dc + 2) >> 2) + } + + for y := 1; y < n; y++ { + dst[off+y*stride] = P((r.left(y) + 3*dc + 2) >> 2) + } +} + +func predAngular[P pixel](dst []P, off, stride int, r *refSamples, mode, cIdx, bitDepth int) { + n := r.n + angle := intraPredAngle[mode-2] + + var ref [3 * 32 * 2]int32 + + // The corner sits at 2n in both r.s and ref. + base := 2 * n + + vertical := mode >= 18 + + m := n + if angle >= 0 { + m = 2 * n + } + + if vertical { + copy(ref[base:base+m+1], r.s[base:base+m+1]) + } else { + ref[base] = r.s[base] + + for x := 1; x <= m; x++ { + ref[base+x] = r.s[base-x] + } + } + + if angle < 0 { + if lim := int(int32(n) * angle >> 5); lim < -1 { + inv := intraInvAngle[mode-11] + + for x := -1; x >= lim; x-- { + i := int((int32(x)*inv + 128) >> 8) + + switch { + case i == 0: + ref[base+x] = r.s[base] + case vertical: + ref[base+x] = r.s[base-i] + default: + ref[base+x] = r.s[base+i] + } + } + } + } + + if p, ok := any(dst).([]uint8); ok && predAngular8(p[off:], stride, ref[base:], int(angle), n, vertical) { + finishAngular(dst, off, stride, r, mode, cIdx, bitDepth) + + return + } + + for b := range n { + idx := int(int32(b+1) * angle >> 5) + fact := int32(b+1) * angle & 31 + row := ref[base+idx+1 : base+idx+2+n] + + switch { + case fact == 0 && vertical: + out := dst[off+b*stride : off+b*stride+n] + + for a := range out { + out[a] = P(row[a]) + } + case fact == 0: + for a := range n { + dst[off+a*stride+b] = P(row[a]) + } + case vertical: + out := dst[off+b*stride : off+b*stride+n] + + for a := range out { + out[a] = P(((32-fact)*row[a] + fact*row[a+1] + 16) >> 5) + } + default: + for a := range n { + dst[off+a*stride+b] = P(((32-fact)*row[a] + fact*row[a+1] + 16) >> 5) + } + } + } + + finishAngular(dst, off, stride, r, mode, cIdx, bitDepth) +} + +// predAngular8 runs the kernel. A horizontal mode is the same prediction +// transposed, so it is taken that way and turned back. +func predAngular8(dst []uint8, stride int, ref []int32, angle, n int, vertical bool) bool { + if vertical { + return predAngularRows(dst, stride, ref, angle, n) + } + + var tmp [32 * 32]uint8 + + if !predAngularRows(tmp[:], n, ref, angle, n) { + return false + } + + for y := range n { + row := dst[y*stride : y*stride+n] + + for x := range row { + row[x] = tmp[x*n+y] + } + } + + return true +} + +// finishAngular is the boundary smoothing of 8.4.4.2.6. +func finishAngular[P pixel](dst []P, off, stride int, r *refSamples, mode, cIdx, bitDepth int) { + n := r.n + if cIdx != 0 || n >= 32 { + return + } + + if mode == intraVer { + for y := range n { + dst[off+y*stride] = clip1[P](r.top(0)+(r.left(y)-r.corner())>>1, bitDepth) + } + + return + } + + if mode == intraHor { + for x := range n { + dst[off+x] = clip1[P](r.left(0)+(r.top(x)-r.corner())>>1, bitDepth) + } + } +} diff --git a/third_party/h265/hevc/intrapred_amd64.s b/third_party/h265/hevc/intrapred_amd64.s new file mode 100644 index 00000000..1de4d527 --- /dev/null +++ b/third_party/h265/hevc/intrapred_amd64.s @@ -0,0 +1,168 @@ +//go:build amd64 && !noasm + +#include "textflag.h" + +DATA lane<>+0x00(SB)/4, $0 +DATA lane<>+0x04(SB)/4, $1 +DATA lane<>+0x08(SB)/4, $2 +DATA lane<>+0x0c(SB)/4, $3 +DATA lane<>+0x10(SB)/4, $4 +DATA lane<>+0x14(SB)/4, $5 +DATA lane<>+0x18(SB)/4, $6 +DATA lane<>+0x1c(SB)/4, $7 +GLOBL lane<>(SB), RODATA|NOPTR, $32 + +DATA pack<>+0x00(SB)/8, $0x808080800c080400 +DATA pack<>+0x08(SB)/8, $0x8080808080808080 +DATA pack<>+0x10(SB)/8, $0x808080800c080400 +DATA pack<>+0x18(SB)/8, $0x8080808080808080 +GLOBL pack<>(SB), RODATA|NOPTR, $32 + +// func predPlanar8AVX2(dst *uint8, stride int, top *int32, left *int32, tr, bl, n, shift int) +// +// 8.4.4.2.4. left is read backwards, the order 8.4.4.2.2 stores it in, so +// left[-y] is the sample for row y. The result needs no clip. +TEXT ·predPlanar8AVX2(SB), NOSPLIT, $0-64 + MOVQ dst+0(FP), DI + MOVQ stride+8(FP), SI + MOVQ top+16(FP), DX + MOVQ left+24(FP), CX + MOVQ n+48(FP), R8 + MOVQ shift+56(FP), R9 + + VPBROADCASTD tr+32(FP), Y10 + VPBROADCASTD n+48(FP), Y12 + VMOVDQU lane<>(SB), Y11 + VMOVDQU pack<>(SB), Y13 + MOVQ R9, X14 + + XORQ R10, R10 + +rows: + // l = left[-y], w = n-1-y, c = (y+1)*bl + n + MOVQ R10, AX + SHLQ $2, AX + SUBQ AX, CX + VPBROADCASTD (CX), Y0 + ADDQ AX, CX + + MOVQ R8, AX + SUBQ R10, AX + DECQ AX + MOVQ AX, X1 + VPBROADCASTD X1, Y1 + + MOVQ R10, AX + INCQ AX + IMULQ bl+40(FP), AX + ADDQ R8, AX + MOVQ AX, X2 + VPBROADCASTD X2, Y2 + + MOVQ DI, R11 + MOVQ DX, R12 + XORQ R13, R13 + +cols: + // xv = lane + base + 1, and n - xv is n-1-x. + MOVQ R13, AX + INCQ AX + MOVQ AX, X3 + VPBROADCASTD X3, Y3 + VPADDD Y11, Y3, Y3 + VPSUBD Y3, Y12, Y4 + + VPMULLD Y0, Y4, Y4 + VPMULLD Y10, Y3, Y3 + VPADDD Y3, Y4, Y4 + + VMOVDQU (R12), Y5 + VPMULLD Y1, Y5, Y5 + VPADDD Y5, Y4, Y4 + + VPADDD Y2, Y4, Y4 + VPSRAD X14, Y4, Y4 + + VPSHUFB Y13, Y4, Y4 + VEXTRACTI128 $1, Y4, X6 + MOVL X4, (R11) + MOVL X6, 4(R11) + + ADDQ $32, R12 + ADDQ $8, R11 + ADDQ $8, R13 + CMPQ R13, R8 + JLT cols + + ADDQ SI, DI + INCQ R10 + CMPQ R10, R8 + JLT rows + + VZEROUPPER + RET + +// func predAngular8AVX2(dst *uint8, stride int, ref *int32, angle, n int) +// +// The interpolation of 8.4.4.2.6 for a vertical mode, eight outputs at a time. +// ref is the corner, and a negative angle reads the n entries before it. The +// weights sum to 32, so the result is a sample again and needs no clamp, and a +// zero second weight lands on the sample itself. +TEXT ·predAngular8AVX2(SB), NOSPLIT, $0-40 + MOVQ dst+0(FP), DI + MOVQ stride+8(FP), R8 + MOVQ ref+16(FP), SI + MOVQ angle+24(FP), R9 + MOVQ n+32(FP), CX + + MOVL $16, AX + VMOVD AX, X15 + VPBROADCASTD X15, Y15 + + XORQ BX, BX + MOVQ DI, R12 + +rowloop: + LEAQ 1(BX), AX + IMULQ R9, AX + MOVQ AX, DX + SARQ $5, DX + ANDQ $31, AX + + LEAQ 4(SI)(DX*4), R10 + + MOVQ $32, R11 + SUBQ AX, R11 + VMOVD R11, X0 + VPBROADCASTD X0, Y0 + VMOVD AX, X1 + VPBROADCASTD X1, Y1 + + XORQ R13, R13 + +mixloop: + VMOVDQU (R10)(R13*4), Y2 + VMOVDQU 4(R10)(R13*4), Y3 + VPMULLD Y0, Y2, Y2 + VPMULLD Y1, Y3, Y3 + VPADDD Y3, Y2, Y2 + VPADDD Y15, Y2, Y2 + VPSRAD $5, Y2, Y2 + + VPACKSSDW Y2, Y2, Y2 + VPERMQ $0x08, Y2, Y2 + VPACKUSWB X2, X2, X2 + VMOVQ X2, (R12)(R13*1) + + ADDQ $8, R13 + CMPQ R13, CX + JLT mixloop + +nextrow: + ADDQ R8, R12 + INCQ BX + CMPQ BX, CX + JLT rowloop + + VZEROUPPER + RET diff --git a/third_party/h265/hevc/intrapred_arm64.s b/third_party/h265/hevc/intrapred_arm64.s new file mode 100644 index 00000000..152c65c1 --- /dev/null +++ b/third_party/h265/hevc/intrapred_arm64.s @@ -0,0 +1,184 @@ +//go:build arm64 && !noasm + +#include "textflag.h" + +#define MUL4S(Vd, Vn, Vm) WORD $(0x4ea09c00 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define SSHL4S_(Vd, Vn, Vm) WORD $(0x4ea04400 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define SQXTN4H_(Vd, Vn) WORD $(0x0e614800 | ((Vn) << 5) | (Vd)) +#define SQXTN2_8H_(Vd, Vn) WORD $(0x4e614800 | ((Vn) << 5) | (Vd)) +#define SQXTUN8B_(Vd, Vn) WORD $(0x2e212800 | ((Vn) << 5) | (Vd)) + +DATA lanes<>+0x00(SB)/4, $0 +DATA lanes<>+0x04(SB)/4, $1 +DATA lanes<>+0x08(SB)/4, $2 +DATA lanes<>+0x0c(SB)/4, $3 +DATA lanes<>+0x10(SB)/4, $4 +DATA lanes<>+0x14(SB)/4, $5 +DATA lanes<>+0x18(SB)/4, $6 +DATA lanes<>+0x1c(SB)/4, $7 +GLOBL lanes<>(SB), RODATA|NOPTR, $32 + +// func predPlanar8NEON(dst *uint8, stride int, top *int32, left *int32, tr, bl, n, shift int) +// +// 8.4.4.2.4. left is read backwards, the order 8.4.4.2.2 stores it in. +TEXT ·predPlanar8NEON(SB), NOSPLIT, $0-64 + MOVD dst+0(FP), R0 + MOVD stride+8(FP), R1 + MOVD top+16(FP), R2 + MOVD left+24(FP), R3 + MOVD tr+32(FP), R4 + MOVD bl+40(FP), R5 + MOVD n+48(FP), R6 + MOVD shift+56(FP), R7 + + MOVD $lanes<>(SB), R8 + VLD1 (R8), [V16.S4, V17.S4] + + VDUP R6, V18.S4 + VDUP R4, V19.S4 + + NEG R7, R9 + VDUP R9, V20.S4 + + MOVD $0, R10 + +rows: + // l = left[-y], w = n-1-y, c = (y+1)*bl + n + LSL $2, R10, R11 + SUB R11, R3, R12 + MOVW (R12), R13 + VDUP R13, V21.S4 + + SUB R10, R6, R14 + SUB $1, R14 + VDUP R14, V22.S4 + + ADD $1, R10, R15 + MUL R5, R15, R15 + ADD R6, R15 + VDUP R15, V23.S4 + + MOVD R0, R16 + MOVD R2, R17 + MOVD $0, R19 + +cols: + // xv = lanes + base + 1, and n - xv is n-1-x. + ADD $1, R19, R20 + VDUP R20, V0.S4 + VADD V16.S4, V0.S4, V1.S4 + VADD V17.S4, V0.S4, V2.S4 + VSUB V1.S4, V18.S4, V3.S4 + VSUB V2.S4, V18.S4, V4.S4 + + MUL4S(3, 3, 21) + MUL4S(4, 4, 21) + MUL4S(1, 1, 19) + MUL4S(2, 2, 19) + VADD V1.S4, V3.S4, V3.S4 + VADD V2.S4, V4.S4, V4.S4 + + VLD1.P 32(R17), [V5.S4, V6.S4] + MUL4S(5, 5, 22) + MUL4S(6, 6, 22) + VADD V5.S4, V3.S4, V3.S4 + VADD V6.S4, V4.S4, V4.S4 + + VADD V23.S4, V3.S4, V3.S4 + VADD V23.S4, V4.S4, V4.S4 + SSHL4S_(3, 3, 20) + SSHL4S_(4, 4, 20) + + SQXTN4H_(7, 3) + SQXTN2_8H_(7, 4) + SQXTUN8B_(7, 7) + VST1.P [V7.B8], 8(R16) + + ADD $8, R19 + CMP R6, R19 + BLT cols + + ADD R1, R0 + ADD $1, R10 + CMP R6, R10 + BLT rows + + RET + +// func predAngular8NEON(dst *uint8, stride int, ref *int32, angle, n int) +// +// The interpolation of 8.4.4.2.6 for a vertical mode, eight outputs at a time. +// ref is the corner, and a negative angle reads the n entries before it. The +// weights sum to 32, so the result is a sample again and needs no clamp, and a +// zero second weight lands on the sample itself. +TEXT ·predAngular8NEON(SB), NOSPLIT, $0-40 + MOVD dst+0(FP), R0 + MOVD stride+8(FP), R1 + MOVD ref+16(FP), R2 + MOVD angle+24(FP), R3 + MOVD n+32(FP), R4 + + MOVD $16, R5 + VDUP R5, V16.S4 + + MOVD $0, R6 + MOVD R0, R7 + +rowloop: + ADD $1, R6, R8 + MUL R3, R8, R8 + ASR $5, R8, R9 + AND $31, R8, R8 + + LSL $2, R9, R10 + ADD R2, R10 + ADD $4, R10 + ADD $4, R10, R11 + + MOVD $32, R12 + SUB R8, R12, R12 + VDUP R12, V17.S4 + VDUP R8, V18.S4 + + MOVD $0, R13 + MOVD R10, R14 + MOVD R11, R15 + MOVD R7, R16 + +mixloop: + VLD1 (R14), [V2.S4, V3.S4] + VLD1 (R15), [V4.S4, V5.S4] + + MUL4S(2, 2, 17) + MUL4S(3, 3, 17) + MUL4S(4, 4, 18) + MUL4S(5, 5, 18) + + VADD V4.S4, V2.S4, V2.S4 + VADD V5.S4, V3.S4, V3.S4 + VADD V16.S4, V2.S4, V2.S4 + VADD V16.S4, V3.S4, V3.S4 + + VUSHR $5, V2.S4, V2.S4 + VUSHR $5, V3.S4, V3.S4 + + SQXTN4H_(6, 2) + SQXTN2_8H_(6, 3) + SQXTUN8B_(7, 6) + + VST1 [V7.B8], (R16) + + ADD $32, R14 + ADD $32, R15 + ADD $8, R16 + ADD $8, R13 + CMP R4, R13 + BLT mixloop + +nextrow: + ADD $1, R6 + ADD R1, R7 + CMP R4, R6 + BLT rowloop + + RET diff --git a/third_party/h265/hevc/intrapred_riscv64.s b/third_party/h265/hevc/intrapred_riscv64.s new file mode 100644 index 00000000..10a547fb --- /dev/null +++ b/third_party/h265/hevc/intrapred_riscv64.s @@ -0,0 +1,137 @@ +//go:build riscv64 && riscv64.rva23u64 && !noasm + +#include "textflag.h" + +// func predPlanar8RVV(dst *uint8, stride int, top *int32, left *int32, tr, bl, n, shift int) +// +// 8.4.4.2.4. left is read backwards, the order 8.4.4.2.2 stores it in. +TEXT ·predPlanar8RVV(SB), NOSPLIT, $0-64 + MOV dst+0(FP), X10 + MOV stride+8(FP), X11 + MOV top+16(FP), X12 + MOV left+24(FP), X13 + MOV tr+32(FP), X14 + MOV bl+40(FP), X15 + MOV n+48(FP), X16 + MOV shift+56(FP), X17 + + MOV $0, X18 + +rows: + // l = left[-y], w = n-1-y, c = (y+1)*bl + n + SLLI $2, X18, X19 + SUB X19, X13, X20 + MOVW (X20), X21 + + SUB X18, X16, X22 + ADD $-1, X22 + + ADD $1, X18, X23 + MUL X15, X23, X23 + ADD X16, X23 + + MOV X10, X24 + MOV X12, X25 + MOV X16, X26 + MOV $0, X28 + +cols: + VSETVLI X26, E32, M1, TA, MA, X30 + + // v = l*(n-1-x) + tr*(x+1) + w*top[x] + c + VIDV V1 + VADDVX X28, V1, V1 + VADDVI $1, V1, V1 + VRSUBVX X16, V1, V2 + VMULVX X21, V2, V2 + VMULVX X14, V1, V1 + VADDVV V1, V2, V2 + + VLE32V (X25), V3 + VMACCVX V3, X22, V2 + + VADDVX X23, V2, V2 + VSRAVX X17, V2, V2 + + VSETVLI X26, E16, MF2, TA, MA, X30 + VNSRLWI $0, V2, V4 + VSETVLI X26, E8, MF4, TA, MA, X30 + VNSRLWI $0, V4, V5 + VSE8V V5, (X24) + + SLLI $2, X30, X31 + ADD X31, X25 + ADD X30, X24 + ADD X30, X28 + SUB X30, X26 + BNEZ X26, cols + + ADD X11, X10 + ADD $1, X18 + BLT X18, X16, rows + + RET + +// func predAngular8RVV(dst *uint8, stride int, ref *int32, angle, n int) +// +// The interpolation of 8.4.4.2.6 for a vertical mode. ref is the corner, and a +// negative angle reads the n entries before it. The weights sum to 32, so the +// result is a sample again, the narrowing shifts need no clamp, and a zero +// second weight lands on the sample itself. +TEXT ·predAngular8RVV(SB), NOSPLIT, $0-40 + MOV dst+0(FP), X10 + MOV stride+8(FP), X11 + MOV ref+16(FP), X12 + MOV angle+24(FP), X13 + MOV n+32(FP), X14 + + MOV $16, X15 + MOV $0, X16 + MOV X10, X17 + +rowloop: + ADD $1, X16, X18 + MUL X13, X18, X18 + SRAI $5, X18, X19 + ANDI $31, X18, X18 + + SLLI $2, X19, X20 + ADD X12, X20 + ADD $4, X20 + ADD $4, X20, X21 + + MOV $32, X22 + SUB X18, X22, X22 + + MOV X14, X23 + MOV X17, X24 + +cols: + VSETVLI X23, E32, M1, TA, MA, X25 + + VLE32V (X20), V1 + VLE32V (X21), V2 + VMULVX X22, V1, V1 + VMULVX X18, V2, V2 + VADDVV V2, V1, V1 + VADDVX X15, V1, V1 + + VSETVLI X23, E16, MF2, TA, MA, X26 + VNSRLWI $5, V1, V3 + + VSETVLI X23, E8, MF4, TA, MA, X26 + VNSRLWI $0, V3, V4 + VSE8V V4, (X24) + + SLLI $2, X25, X26 + ADD X26, X20 + ADD X26, X21 + ADD X25, X24 + SUB X25, X23 + BNEZ X23, cols + + ADD X11, X17 + ADD $1, X16 + BNE X14, X16, rowloop + + RET diff --git a/third_party/h265/hevc/itransform.go b/third_party/h265/hevc/itransform.go new file mode 100644 index 00000000..aec68d53 --- /dev/null +++ b/third_party/h265/hevc/itransform.go @@ -0,0 +1,545 @@ +package hevc + +import "math" + +var levelScale = [6]int32{40, 45, 51, 57, 64, 72} + +var dstMatrix = [4][4]int8{ + {29, 55, 74, 84}, + {74, 74, 0, -74}, + {84, -29, -74, 55}, + {55, -84, 74, -29}, +} + +func clip3(v, lo, hi int32) int32 { + if v < lo { + return lo + } + + if v > hi { + return hi + } + + return v +} + +func boolToInt(v bool) int { + if v { + return 1 + } + + return 0 +} + +func log2(n int) int { + k := 0 + for n > 1 { + n >>= 1 + k++ + } + + return k +} + +// 8.6.3, log2TransformRange. +func transformRange(bitDepth int, extended bool) int { + if !extended { + return 15 + } + + return max(15, bitDepth+6) +} + +// 8.6.3. m is nil when the factors are flat 16. +func dequant(coef []int32, m []uint8, n, qp, bitDepth int, extended bool) { + rng := transformRange(bitDepth, extended) + shift := bitDepth + log2(n) + 10 - rng + lo, hi := int32(-1<= 1 && rng <= 15 { + dequant32(coef, m, int32(levelScale[qp%6]), sh, lo, hi) + + return + } + + scale := int64(levelScale[qp%6]) << (qp / 6) + rnd := int64(1) << (shift - 1) + + clip := func(v int64) int32 { + if v < int64(lo) { + return lo + } + + if v > int64(hi) { + return hi + } + + return int32(v) + } + + // A zero coefficient scales to zero, and most of a block is zero. + if m == nil { + for i, c := range coef { + if c == 0 { + continue + } + + coef[i] = clip((int64(c)*16*scale + rnd) >> shift) + } + + return + } + + for i, c := range coef { + if c == 0 { + continue + } + + coef[i] = clip((int64(c)*int64(m[i])*scale + rnd) >> shift) + } +} + +func dequant32(coef []int32, m []uint8, ls int32, sh int, lo, hi int32) { + rnd := int32(1) << (sh - 1) + + if k := dequant32Asm; k != nil && len(coef)%8 == 0 { + k(coef, m, ls, rnd, sh, lo, hi) + + return + } + + if m == nil { + for i, c := range coef { + if c == 0 { + continue + } + + coef[i] = clip3((c*16*ls+rnd)>>sh, lo, hi) + } + + return + } + + for i, c := range coef { + if c == 0 { + continue + } + + coef[i] = clip3((c*int32(m[i])*ls+rnd)>>sh, lo, hi) + } +} + +// idct is 8.6.4.2, split by size so the even half unrolls instead of +// recursing. Each level transforms the even coefficients at half the size and +// combines them with an odd half computed from the basis rows. +func idct(out, in []int32, n int, s *transformScratch) { + switch n { + case 4: + idct4(out, in) + case 8: + idct8(out, in) + case 16: + idct16(out, in) + default: + idct32(out, in, s) + } +} + +func idct4(out, in []int32) { + _ = out[3] + + e0 := 64 * (in[0] + in[2]) + e1 := 64 * (in[0] - in[2]) + + o0 := 83*in[1] + 36*in[3] + o1 := 36*in[1] - 83*in[3] + + out[0] = e0 + o0 + out[1] = e1 + o1 + out[2] = e1 - o1 + out[3] = e0 - o0 +} + +func idct8(out, in []int32) { + var ev, e, o [4]int32 + + ev[0], ev[1], ev[2], ev[3] = in[0], in[2], in[4], in[6] + idct4(e[:], ev[:]) + + oddGo(o[:], in, 4) + + for i, v := range e { + out[i] = v + o[i] + out[7-i] = v - o[i] + } +} + +func idct16(out, in []int32) { + var ev, e, o [8]int32 + + for i := range ev { + ev[i] = in[2*i] + } + + idct8(e[:], ev[:]) + oddGo(o[:], in, 2) + + for i, v := range e { + out[i] = v + o[i] + out[15-i] = v - o[i] + } +} + +func idct32(out, in []int32, s *transformScratch) { + var ev, e [16]int32 + + for i := range ev { + ev[i] = in[2*i] + } + + idct16(e[:], ev[:]) + + o := s.odd[:] + + // Only the widest butterfly is worth an assembly call; below sixteen the + // call costs more than the vectors save. + if k := oddAsm; k != nil { + k(o, in, 1) + } else { + oddGo(o, in, 1) + } + + for i, v := range e { + out[i] = v + o[i] + out[31-i] = v - o[i] + } +} + +// oddGo accumulates the odd half of one level, a basis row at a time so the +// matrix is indexed once per coefficient and a zero one costs nothing. +func oddGo(out, in []int32, stride int) { + clear(out) + + for j := range out { + c := in[2*j+1] + if c == 0 { + continue + } + + row := transMatrix[(2*j+1)*stride][:len(out)] + acc := out[:len(row)] + + for i, v := range row { + acc[i] += int32(v) * c + } + } +} + +func idst1D(out, in []int32) { + for i := range 4 { + var v int32 + + for j := range 4 { + v += int32(dstMatrix[j][i]) * in[j] + } + + out[i] = v + } +} + +type transformScratch struct { + // odd is the sixteen-wide butterfly accumulator. It lives here rather + // than on the stack because passing a local array to a kernel through a + // function value makes it escape. + odd [16]int32 + col [32]int32 + out [32]int32 + block [32 * 32]int32 + block2 [32 * 32]int32 +} + +// idctColsGo is 8.6.4.2 over eight columns at a time, dense but for the +// even/odd split: M[j][n-1-i] is M[j][i] for even j and its negation for odd j. +func idctColsGo(dst, src []int32, n int, rnd int32, shift int, lo, hi int32) { + half := n / 2 + step := 32 / n + + var acc [2][16 * 8]int32 + + for x0 := 0; x0 < n; x0 += 8 { + clear(acc[0][:half*8]) + clear(acc[1][:half*8]) + + for j := range n { + c := src[j*n+x0:][:8] + + var any int32 + for _, v := range c { + any |= v + } + + if any == 0 { + continue + } + + row := transMatrix[j*step][:half] + a := acc[j&1][:half*8] + + for i, m := range row { + w := int32(m) + + for lane, v := range c { + a[i*8+lane] += w * v + } + } + } + + for i := range half { + e := acc[0][i*8:][:8] + o := acc[1][i*8:][:8] + + lowRow := dst[i*n+x0:][:8] + highRow := dst[(n-1-i)*n+x0:][:8] + + for lane := range 8 { + lowRow[lane] = clip3((e[lane]+o[lane]+rnd)>>shift, lo, hi) + highRow[lane] = clip3((e[lane]-o[lane]+rnd)>>shift, lo, hi) + } + } + } +} + +// transMatrix32 is the basis matrix at the width the kernels broadcast from. +var transMatrix32 = func() [32][32]int32 { + var m [32][32]int32 + + for j, row := range transMatrix { + for i, v := range row { + m[j][i] = int32(v) + } + } + + return m +}() + +// transposeBlock writes the transpose of an n by n block. +func transposeBlock(dst, src []int32, n int) { + if k := transposeAsm; k != nil { + k(dst, src, n) + + return + } + + for y := range n { + row := src[y*n : y*n+n] + + for x, v := range row { + dst[x*n+y] = v + } + } +} + +// 8.6.4.1. +func inverseTransform(coef []int32, n int, dst bool, bitDepth int, extended bool, s *transformScratch) { + rng := transformRange(bitDepth, extended) + lo, hi := int32(-1<= 8 { + block, block2 := s.block[:n*n], s.block2[:n*n] + + k(block, coef[:n*n], n, 64, 7, lo, hi) + transposeBlock(block2, block, n) + k(block, block2, n, 0, 0, math.MinInt32, math.MaxInt32) + transposeBlock(coef[:n*n], block, n) + + return + } + + // A column of zeros transforms to zeros, and residual blocks are mostly + // zero above the last significant coefficient. + col, out := s.col[:n], s.out[:n] + + for x := range n { + var acc int32 + + for y := range col { + v := coef[y*n+x] + col[y] = v + acc |= v + } + + if acc == 0 { + for y := range n { + s.block[y*n+x] = 0 + } + + continue + } + + if dst { + idst1D(out, col) + } else { + idct(out, col, n, s) + } + + for y, v := range out { + s.block[y*n+x] = clip3((v+64)>>7, lo, hi) + } + } + + for y := range n { + row := s.block[y*n : y*n+n] + + if dst { + idst1D(out, row) + } else { + idct(out, row, n, s) + } + + copy(coef[y*n:y*n+n], out) + } +} + +// coeffRange is the transform range of a component, or zero when extended +// precision is off. +func (s *sps) coeffRange(cIdx int) int { + if !s.extendedPrecision { + return 0 + } + + bitDepth := int(s.bitDepthLuma) + if cIdx > 0 { + bitDepth = int(s.bitDepthChroma) + } + + return transformRange(bitDepth, true) +} + +// wideTransform reports whether the basis row sums of 8.6.4.2 can leave +// thirty-two bits. +func wideTransform(bitDepth int, extended bool) bool { + return transformRange(bitDepth, extended) > 15 +} + +func transform1DWide(out, in []int64, n int, dst bool) { + if dst { + for i := range 4 { + var v int64 + + for j := range 4 { + v += int64(dstMatrix[j][i]) * in[j] + } + + out[i] = v + } + + return + } + + step := 32 / n + + for i := range n { + var v int64 + + for j := range n { + v += int64(transMatrix[j*step][i]) * in[j] + } + + out[i] = v + } +} + +// inverseTransformWide is 8.6.4.1 in sixty-four bits, with bdShift folded into +// the row stage. +func inverseTransformWide(coef []int32, n int, dst bool, bitDepth int, shift int, s *transformScratch) { + rng := transformRange(bitDepth, true) + lo, hi := int64(-1<> 7 + + s.block[y*n+x] = int32(min(max(v, lo), hi)) + } + } + + rnd := int64(1) << (shift - 1) + + for y := range n { + for x, v := range s.block[y*n : y*n+n] { + col[x] = int64(v) + } + + transform1DWide(out[:n], col[:n], n, dst) + + for x := range n { + coef[y*n+x] = int32((out[x] + rnd) >> shift) + } + } +} + +// transformSkipWide is 8.6.2 with bdShift folded into tsShift. Extended +// precision keeps bdShift the larger, so the net shift is to the right. +func transformSkipWide(coef []int32, n int, rotate bool, shift int) { + sh := shift - (5 + log2(n)) + rnd := int32(1) << (sh - 1) + + if rotate { + for i, j := 0, len(coef)-1; i < j; i, j = i+1, j-1 { + coef[i], coef[j] = (coef[j]+rnd)>>sh, (coef[i]+rnd)>>sh + } + + if len(coef)%2 == 1 { + coef[len(coef)/2] = (coef[len(coef)/2] + rnd) >> sh + } + + return + } + + for i, v := range coef { + coef[i] = (v + rnd) >> sh + } +} + +// residualShiftBits is bdShift of 8.6.2. +func residualShiftBits(bitDepth int, extended bool) int { + shift := 20 - bitDepth + if extended { + return max(shift, 11) + } + + return shift +} + +// 8.6.2, transform_skip_flag. Not clipped; bdShift follows. +func transformSkip(coef []int32, n int, rotate bool) { + shift := 5 + log2(n) + + if rotate { + for i, j := 0, len(coef)-1; i < j; i, j = i+1, j-1 { + coef[i], coef[j] = coef[j]<+0x00(SB)/8, $0x808080800c080400 +DATA packLow<>+0x08(SB)/8, $0x8080808080808080 +DATA packLow<>+0x10(SB)/8, $0x808080800c080400 +DATA packLow<>+0x18(SB)/8, $0x8080808080808080 +GLOBL packLow<>(SB), RODATA|NOPTR, $32 + +// func addResidual8AVX2(dst *uint8, stride int, coef *int32, n, shift int) +TEXT ·addResidual8AVX2(SB), NOSPLIT, $0-40 + MOVQ dst+0(FP), DI + MOVQ stride+8(FP), SI + MOVQ coef+16(FP), DX + MOVQ n+24(FP), BX + MOVQ shift+32(FP), R8 + + // rnd is 1<<(shift-1), and zero when nothing is shifted. + XORQ R9, R9 + TESTQ R8, R8 + JLE havernd + MOVQ R8, CX + DECQ CX + MOVQ $1, R9 + SHLQ CX, R9 + +havernd: + MOVQ R9, X0 + VPBROADCASTD X0, Y0 + MOVQ R8, X1 + VPXOR Y2, Y2, Y2 + MOVL $255, AX + MOVQ AX, X3 + VPBROADCASTD X3, Y3 + VMOVDQU packLow<>(SB), Y6 + + MOVQ BX, R10 + +rows: + MOVQ DI, R12 + MOVQ BX, R11 + +cols: + VMOVDQU (DX), Y4 + VPADDD Y0, Y4, Y4 + VPSRAD X1, Y4, Y4 + VPMOVZXBD (R12), Y5 + VPADDD Y5, Y4, Y4 + VPMAXSD Y2, Y4, Y4 + VPMINSD Y3, Y4, Y4 + + VPSHUFB Y6, Y4, Y4 + VEXTRACTI128 $1, Y4, X7 + MOVL X4, (R12) + MOVL X7, 4(R12) + + ADDQ $32, DX + ADDQ $8, R12 + SUBQ $8, R11 + JNZ cols + + ADDQ SI, DI + DECQ R10 + JNZ rows + + VZEROUPPER + RET + +// func addResidual16AVX2(dst *uint16, stride int, coef *int32, n, shift int, maxV int32) +TEXT ·addResidual16AVX2(SB), NOSPLIT, $0-44 + MOVQ dst+0(FP), DI + MOVQ stride+8(FP), SI + MOVQ coef+16(FP), DX + MOVQ n+24(FP), BX + MOVQ shift+32(FP), R8 + + SHLQ $1, SI + + XORQ R9, R9 + TESTQ R8, R8 + JLE havernd16 + MOVQ R8, CX + DECQ CX + MOVQ $1, R9 + SHLQ CX, R9 + +havernd16: + MOVQ R9, X0 + VPBROADCASTD X0, Y0 + MOVQ R8, X1 + VPXOR Y2, Y2, Y2 + MOVL maxV+40(FP), AX + MOVQ AX, X3 + VPBROADCASTD X3, Y3 + + MOVQ BX, R10 + +rows16: + MOVQ DI, R12 + MOVQ BX, R11 + +cols16: + VMOVDQU (DX), Y4 + VPADDD Y0, Y4, Y4 + VPSRAD X1, Y4, Y4 + VPMOVZXWD (R12), Y5 + VPADDD Y5, Y4, Y4 + VPMAXSD Y2, Y4, Y4 + VPMINSD Y3, Y4, Y4 + + VPACKUSDW Y4, Y4, Y4 + VEXTRACTI128 $1, Y4, X7 + MOVQ X4, (R12) + MOVQ X7, 8(R12) + + ADDQ $32, DX + ADDQ $16, R12 + SUBQ $8, R11 + JNZ cols16 + + ADDQ SI, DI + DECQ R10 + JNZ rows16 + + VZEROUPPER + RET + +// func odd16AVX2(out *int32, in *int32, m *int8, stride int) +// +// out[i] = sum over j of m[(2j+1)*stride][i] * in[2j+1], sixteen wide. The +// accumulators stay in registers, and a zero coefficient skips a row. +TEXT ·odd16AVX2(SB), NOSPLIT, $0-32 + MOVQ out+0(FP), DI + MOVQ in+8(FP), SI + MOVQ m+16(FP), DX + MOVQ stride+24(FP), R8 + + LEAQ 4(SI), R10 + + // The first basis row is stride rows in, and each step advances by two. + MOVQ R8, R11 + SHLQ $5, R11 + ADDQ R11, DX + SHLQ $1, R11 + + VPXOR Y0, Y0, Y0 + VPXOR Y1, Y1, Y1 + + MOVQ $16, R12 + +loop: + MOVL (R10), AX + TESTL AX, AX + JZ skip + + VPBROADCASTD (R10), Y4 + VPMOVSXBD (DX), Y2 + VPMOVSXBD 8(DX), Y3 + VPMULLD Y4, Y2, Y2 + VPMULLD Y4, Y3, Y3 + VPADDD Y2, Y0, Y0 + VPADDD Y3, Y1, Y1 + +skip: + ADDQ $8, R10 + ADDQ R11, DX + DECQ R12 + JNZ loop + + VMOVDQU Y0, (DI) + VMOVDQU Y1, 32(DI) + VZEROUPPER + RET diff --git a/third_party/h265/hevc/itransform_arm64.s b/third_party/h265/hevc/itransform_arm64.s new file mode 100644 index 00000000..4e39a9b5 --- /dev/null +++ b/third_party/h265/hevc/itransform_arm64.s @@ -0,0 +1,187 @@ +//go:build arm64 && !noasm + +#include "textflag.h" + +// Instructions the Go assembler does not know, encoded as gav1d does it. The +// bases came from assembling each form on the board and reading objdump. +#define SSHL4S(Vd, Vn, Vm) WORD $(0x4ea04400 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define SQXTN4H(Vd, Vn) WORD $(0x0e614800 | ((Vn) << 5) | (Vd)) +#define SQXTN2_8H(Vd, Vn) WORD $(0x4e614800 | ((Vn) << 5) | (Vd)) +#define SQXTUN8B(Vd, Vn) WORD $(0x2e212800 | ((Vn) << 5) | (Vd)) +#define SXTL8H(Vd, Vn) WORD $(0x0f08a400 | ((Vn) << 5) | (Vd)) +#define SXTL2_8H(Vd, Vn) WORD $(0x4f08a400 | ((Vn) << 5) | (Vd)) +#define SMLAL4S(Vd, Vn, Vm) WORD $(0x0e608000 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define SMLAL2_4S(Vd, Vn, Vm) WORD $(0x4e608000 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define SMAX4S(Vd, Vn, Vm) WORD $(0x4ea06400 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define SMIN4S(Vd, Vn, Vm) WORD $(0x4ea06c00 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) + +// func addResidual8NEON(dst *uint8, stride int, coef *int32, n, shift int) +TEXT ·addResidual8NEON(SB), NOSPLIT, $0-40 + MOVD dst+0(FP), R0 + MOVD stride+8(FP), R1 + MOVD coef+16(FP), R2 + MOVD n+24(FP), R3 + MOVD shift+32(FP), R4 + + // rnd is 1<<(shift-1), and zero when nothing is shifted. + MOVD $0, R5 + CMP $0, R4 + BLE havernd + MOVD $1, R5 + SUB $1, R4, R6 + LSL R6, R5, R5 + +havernd: + VDUP R5, V0.S4 + NEG R4, R7 + VDUP R7, V1.S4 + + MOVD R3, R8 + +rows: + MOVD R0, R9 + MOVD R3, R10 + +cols: + VLD1.P 32(R2), [V4.S4, V5.S4] + + VADD V0.S4, V4.S4, V4.S4 + VADD V0.S4, V5.S4, V5.S4 + SSHL4S(4, 4, 1) + SSHL4S(5, 5, 1) + + VLD1 (R9), [V6.B8] + VUXTL V6.B8, V6.H8 + VUXTL V6.H4, V7.S4 + VUXTL2 V6.H8, V8.S4 + + VADD V7.S4, V4.S4, V4.S4 + VADD V8.S4, V5.S4, V5.S4 + + SQXTN4H(9, 4) + SQXTN2_8H(9, 5) + SQXTUN8B(9, 9) + + VST1.P [V9.B8], 8(R9) + + SUB $8, R10 + CBNZ R10, cols + + ADD R1, R0 + SUB $1, R8 + CBNZ R8, rows + + RET + +// func addResidual16NEON(dst *uint16, stride int, coef *int32, n, shift int, maxV int32) +TEXT ·addResidual16NEON(SB), NOSPLIT, $0-44 + MOVD dst+0(FP), R0 + MOVD stride+8(FP), R1 + MOVD coef+16(FP), R2 + MOVD n+24(FP), R3 + MOVD shift+32(FP), R4 + MOVW maxV+40(FP), R11 + + LSL $1, R1 + + MOVD $0, R5 + CMP $0, R4 + BLE havernd16 + MOVD $1, R5 + SUB $1, R4, R6 + LSL R6, R5, R5 + +havernd16: + VDUP R5, V0.S4 + NEG R4, R7 + VDUP R7, V1.S4 + VMOVI $0, V2.B16 + VDUP R11, V3.S4 + + MOVD R3, R8 + +rows16: + MOVD R0, R9 + MOVD R3, R10 + +cols16: + VLD1.P 32(R2), [V4.S4, V5.S4] + + VADD V0.S4, V4.S4, V4.S4 + VADD V0.S4, V5.S4, V5.S4 + SSHL4S(4, 4, 1) + SSHL4S(5, 5, 1) + + VLD1 (R9), [V6.H8] + VUXTL V6.H4, V7.S4 + VUXTL2 V6.H8, V8.S4 + + VADD V7.S4, V4.S4, V4.S4 + VADD V8.S4, V5.S4, V5.S4 + + SMAX4S(4, 4, 2) + SMAX4S(5, 5, 2) + SMIN4S(4, 4, 3) + SMIN4S(5, 5, 3) + + SQXTN4H(9, 4) + SQXTN2_8H(9, 5) + + VST1.P [V9.H8], 16(R9) + + SUB $8, R10 + CBNZ R10, cols16 + + ADD R1, R0 + SUB $1, R8 + CBNZ R8, rows16 + + RET + +// func odd16NEON(out *int32, in *int32, m *int8, stride int) +// +// The sixteen-wide butterfly of 8.6.4.2. Coefficients fit int16 after 8.6.3 +// clips them, so the basis row widens to int16 and SMLAL does the widening +// multiply-accumulate into the four int32 accumulators. +TEXT ·odd16NEON(SB), NOSPLIT, $0-32 + MOVD out+0(FP), R0 + MOVD in+8(FP), R1 + MOVD m+16(FP), R2 + MOVD stride+24(FP), R3 + + ADD $4, R1 + + LSL $5, R3, R4 + ADD R4, R2 + LSL $1, R4 + + VMOVI $0, V0.B16 + VMOVI $0, V1.B16 + VMOVI $0, V2.B16 + VMOVI $0, V3.B16 + + MOVD $16, R5 + +loop: + MOVWU (R1), R6 + CBZ R6, skip + + VDUP R6, V4.H8 + + VLD1 (R2), [V5.B16] + SXTL8H(6, 5) + SXTL2_8H(7, 5) + + SMLAL4S(0, 6, 4) + SMLAL2_4S(1, 6, 4) + SMLAL4S(2, 7, 4) + SMLAL2_4S(3, 7, 4) + +skip: + ADD $8, R1 + ADD R4, R2 + SUB $1, R5 + CBNZ R5, loop + + VST1 [V0.S4, V1.S4, V2.S4, V3.S4], (R0) + RET diff --git a/third_party/h265/hevc/itransform_riscv64.s b/third_party/h265/hevc/itransform_riscv64.s new file mode 100644 index 00000000..d178c32c --- /dev/null +++ b/third_party/h265/hevc/itransform_riscv64.s @@ -0,0 +1,161 @@ +//go:build riscv64 && riscv64.rva23u64 && !noasm + +#include "textflag.h" + +// func addResidual8RVV(dst *uint8, stride int, coef *int32, n, shift int) +TEXT ·addResidual8RVV(SB), NOSPLIT, $0-40 + MOV dst+0(FP), X10 + MOV stride+8(FP), X11 + MOV coef+16(FP), X12 + MOV n+24(FP), X13 + MOV shift+32(FP), X14 + + // rnd is 1<<(shift-1), and zero when nothing is shifted. + MOV $0, X15 + BLEZ X14, havernd + MOV $1, X15 + ADD $-1, X14, X16 + SLL X16, X15, X15 + +havernd: + MOV $255, X17 + MOV X13, X18 + +rows: + MOV X10, X19 + MOV X13, X20 + +cols: + VSETVLI X20, E32, M1, TA, MA, X21 + + VLE32V (X12), V1 + VADDVX X15, V1, V1 + VSRAVX X14, V1, V1 + VLE8V (X19), V2 + VZEXTVF4 V2, V3 + VADDVV V3, V1, V1 + VMAXVX X0, V1, V1 + VMINVX X17, V1, V1 + + VSETVLI X20, E16, MF2, TA, MA, X21 + VNSRLWI $0, V1, V4 + VSETVLI X20, E8, MF4, TA, MA, X21 + VNSRLWI $0, V4, V5 + VSE8V V5, (X19) + + SLLI $2, X21, X22 + ADD X22, X12 + ADD X21, X19 + SUB X21, X20 + BNEZ X20, cols + + ADD X11, X10 + ADD $-1, X18 + BNEZ X18, rows + + RET + +// func addResidual16RVV(dst *uint16, stride int, coef *int32, n, shift int, maxV int32) +TEXT ·addResidual16RVV(SB), NOSPLIT, $0-44 + MOV dst+0(FP), X10 + MOV stride+8(FP), X11 + MOV coef+16(FP), X12 + MOV n+24(FP), X13 + MOV shift+32(FP), X14 + MOVW maxV+40(FP), X17 + + SLLI $1, X11 + + MOV $0, X15 + BLEZ X14, havernd16 + MOV $1, X15 + ADD $-1, X14, X16 + SLL X16, X15, X15 + +havernd16: + MOV X13, X18 + +rows16: + MOV X10, X19 + MOV X13, X20 + +cols16: + VSETVLI X20, E32, M1, TA, MA, X21 + + VLE32V (X12), V1 + VADDVX X15, V1, V1 + VSRAVX X14, V1, V1 + VLE16V (X19), V2 + VZEXTVF2 V2, V3 + VADDVV V3, V1, V1 + VMAXVX X0, V1, V1 + VMINVX X17, V1, V1 + + VSETVLI X20, E16, MF2, TA, MA, X21 + VNSRLWI $0, V1, V4 + VSE16V V4, (X19) + + SLLI $2, X21, X22 + ADD X22, X12 + SLLI $1, X21, X22 + ADD X22, X19 + SUB X21, X20 + BNEZ X20, cols16 + + ADD X11, X10 + ADD $-1, X18 + BNEZ X18, rows16 + + RET + +// func odd16RVV(out *int32, in *int32, m *int8, stride int) +// +// The sixteen-wide butterfly of 8.6.4.2, chunked by the vector length so it +// does not assume VLEN. +TEXT ·odd16RVV(SB), NOSPLIT, $0-32 + MOV out+0(FP), X10 + MOV in+8(FP), X11 + MOV m+16(FP), X12 + MOV stride+24(FP), X13 + + ADD $4, X11 + + // The first basis row is stride rows in, and each step advances by two. + SLLI $5, X13, X14 + ADD X14, X12 + SLLI $1, X14 + + MOV $16, X15 + +chunk: + VSETVLI X15, E32, M1, TA, MA, X16 + + VMVVI $0, V1 + + MOV X11, X17 + MOV X12, X18 + MOV $16, X19 + +rows2: + MOV (X17), X20 + BEQZ X20, skip + + VLE8V (X18), V2 + VSEXTVF4 V2, V3 + VMACCVX V3, X20, V1 + +skip: + ADD $8, X17 + ADD X14, X18 + ADD $-1, X19 + BNEZ X19, rows2 + + VSE32V V1, (X10) + + SLLI $2, X16, X21 + ADD X21, X10 + ADD X16, X12 + SUB X16, X15 + BNEZ X15, chunk + + RET diff --git a/third_party/h265/hevc/lossyenc.go b/third_party/h265/hevc/lossyenc.go new file mode 100644 index 00000000..b8738f27 --- /dev/null +++ b/third_party/h265/hevc/lossyenc.go @@ -0,0 +1,1559 @@ +package hevc + +import ( + "math" + "slices" +) + +// codedSize rounds a picture dimension up to the minimum coding block size, +// which 7.4.3.2 requires the coded picture to be a multiple of. +func codedSize(n int) int { + return (n + 15) &^ 15 +} + +// nals is the parameter sets a slice coded by this encoder needs, and the +// slice behind them. +func (e *intraEncoder[P]) nals(width, height int, rbsp []byte) []NALUnit { + cw, ch := codedSize(width), codedSize(height) + h := encoderHeaders{ + width: cw, height: ch, cropRight: cw - width, cropBottom: ch - height, + chromaFormat: e.s.chromaFormatIDC, + subWidthC: int(e.s.subWidthC), subHeightC: int(e.s.subHeightC), + bitDepth: e.bitDepth, + levelIDC: pcmLevelIDC(cw * ch), + ctbLog2: 6, maxTrHierIntra: 2, + wavefront: e.wavefront, sao: e.saoOn, + } + + return append(h.parameterSets(), NALUnit{Type: NALIdrNLP, RBSP: rbsp}) +} + +// intraEncoder codes one picture as a single intra slice of 64x64 coding tree +// blocks, at any sampling of 6.2 and any sample size the transforms reach. A +// picture allocates only the bitstream it returns. +type intraEncoder[P pixel] struct { + width, height int + + // qp is SliceQpY; qpY and qpC are the Qp' of 8.6.1 behind it, and + // qpDeblockC the chroma QP 8.7.2 filters by, which takes no offset. + qp, qpY, qpC int + qpDeblockC int + bitDepth int + lambda int64 + // lambdaBase is the same weight at eight bits, which is what rdoq weighs by. + lambdaBase int64 + + src [3][]P + recon [3][]P + + // shiftW and shiftH are SubWidthC and SubHeightC of 6.2 as the shifts they + // only ever are, and strideC the chroma stride they leave. + shiftW, shiftH int + strideC int + + // modes and depth are per 16x16 block, for 8.4.2 and 9.3.4.2.2. coded is + // which 4x4 of each plane has been reconstructed, luma then chroma, which + // is what the reference samples of 8.4.4.2.2 may be read from. edges is + // where a transform block begins, which is what 8.7.2 filters. + modes []int + depth []uint8 + coded [2][]uint8 + edges []uint8 + + bits putBits + cabac cabacWriter + s sps + p pps + + // hint is the last slice's length, so the next one is allocated once + // rather than grown into. + hint int + + // threads is what the rows may spread over, wavefront whether they may. + threads int + wavefront bool + + // sao is 8.7.3's parameters per coding tree block, decided from the + // deblocked picture and written in front of each block on a second pass. + sao [][3]saoParams + saoSrc []P + saoStat saoStats + // wantSAO asks for the second pass; saoOn, saoLuma and saoChroma are what + // it found worth carrying. + wantSAO bool + saoOn, saoLuma, saoChroma bool + + scratch lossyBlockScratch[P] + before cuState[P] + kept cuState[P] + tuBase cuState[P] + tuKept cuState[P] + tu cuTransform +} + +// cuTransform holds both ways of coding a 32x32 coding unit's transform tree: +// one unit of its own size, or four 16x16 ones. +type cuTransform struct { + split bool + y32 [32 * 32]int32 + y [4][16 * 16]int32 + + // c32 and c are the chroma levels, Cb then Cr, with the two blocks 4:2:2 + // stacks laid one after the other. + c32 [2][32 * 32]int32 + c [2][4][16 * 16]int32 + + // whole and quad are the coded block flags, taken where the levels are made. + whole bool + quad [4]bool + wholeC [2][2]bool + quadC [4][2][2]bool +} + +// flat reports whether the unit took one transform and left no residual in it. +func (t *cuTransform) flat() bool { + return !t.split && !t.whole && !anyCBF(t.wholeC) +} + +// anyCBF reports whether either component of either stacked block is coded. +func anyCBF(f [2][2]bool) bool { + return f[0][0] || f[0][1] || f[1][0] || f[1][1] +} + +// cuState is everything coding one 32x32 block changes, so that an arm of the +// size decision can be taken back or put back. +type cuState[P pixel] struct { + cabac cabacWriter + bits []byte + cur, nbits uint8 + y [32 * 32]P + cb, cr [32 * 32]P + modes [4]int + depth [4]uint8 + codedY [64]uint8 + codedC [64]uint8 + edges [64]uint8 +} + +// lossyBlock names one transform block and how it is coded. +type lossyBlock[P pixel] struct { + cIdx int + x, y, n int + mode int + dst bool + + // pred is a prediction already made, which the mode search keeps from its sweep. + pred []P +} + +// lossyBlockScratch is the working memory one block needs. No two blocks are +// ever in flight at once, and the largest is 32x32. +type lossyBlockScratch[P pixel] struct { + pred [32 * 32]P + modePred [modeShortlist][16 * 16]P + avail [4*32 + 1]bool + residual [32 * 32]int32 + coef [32 * 32]int32 + num [32 * 32]int64 + cost [32 * 32]int64 + costZero [32 * 32]int64 + costSig [32 * 32]int64 + reconCoef [32 * 32]int32 + base, ref refSamples + transform transformScratch +} + +type lossyTU8Plan struct { + split bool + y [4][4 * 4]int32 + y8 [8 * 8]int32 + cbfY [4]bool + cbfY8 bool + + // c8 is the chroma of the whole 8x8 block and c that of its four 4x4 ones, + // which only 4:4:4 splits chroma down to. + c8 [2][8 * 8]int32 + c [2][4][4 * 4]int32 + cbfC8 [2][2]bool + cbfC [4][2][2]bool +} + +// slice codes the whole picture. The planes are the coded picture, so a caller +// with one that does not fill the coding grid pads it first. 7.3.8.2 wants the +// offsets of 8.7.3 in front of the block they are fitted to, so asking for them +// codes the picture twice. +func (e *intraEncoder[P]) slice(y, cb, cr []P, width, height, qp int) ([]byte, error) { + e.saoOn = false + + out, err := e.codeSlice(y, cb, cr, width, height, qp) + if err != nil || !e.wantSAO { + return out, err + } + + e.decideSAO() + + if !e.saoOn { + return out, nil + } + + if out, err = e.codeSlice(y, cb, cr, width, height, qp); err != nil { + return nil, err + } + + e.applySAO() + + return out, nil +} + +func (e *intraEncoder[P]) codeSlice(y, cb, cr []P, width, height, qp int) ([]byte, error) { + e.reset(y, cb, cr, width, height, qp) + + rows, cols := ctbCount(height), ctbCount(width) + e.wavefront = e.threads > 1 && rows > 1 && cols > 1 + + if e.wavefront { + subs, err := e.encodeWavefront(rows, cols, min(e.threads, rows)) + if err != nil { + return nil, err + } + + e.deblockRecon() + + out := e.sliceRBSP(subs) + e.hint = len(out) + len(out)/8 + + return out, nil + } + + subs := make([][]byte, rows) + + var sync [nContexts]uint8 + + for k := range rows { + e.startRow(k, &sync) + + if err := e.ctbRow(k, rows, cols, &sync); err != nil { + return nil, err + } + + if e.wavefront { + subs[k] = e.cabac.bytes() + } + } + + if !e.wavefront { + subs = [][]byte{e.cabac.bytes()} + } + + e.deblockRecon() + + out := e.sliceRBSP(subs) + e.hint = len(out) + len(out)/8 + + return out, nil +} + +// sliceRBSP puts the segment header in front of the substreams. 7.4.7.1 counts +// the entry points in the slice data bytes a decoder sees. +func (e *intraEncoder[P]) sliceRBSP(subs [][]byte) []byte { + var w putBits + + w.bit(1) + w.bit(0) + w.ue(0) + w.ue(uint32(sliceI)) + + if e.saoOn { + w.bit(boolToBit(e.saoLuma)) + + if e.s.chromaArrayType() != 0 { + w.bit(boolToBit(e.saoChroma)) + } + } + + w.se(int32(e.qp - 26)) + + // 7.3.6.1 carries slice_loop_filter_across_slices_enabled_flag only while + // a loop filter is on. + w.bit(1) + + if e.wavefront { + sizes := escapedSizes(subs) + + w.ue(uint32(len(sizes))) + + if len(sizes) > 0 { + bits := 1 + for max := slices.Max(sizes); max>>bits != 0; bits++ { + } + + w.ue(uint32(bits - 1)) + + for _, n := range sizes { + w.bits(uint64(n-1), bits) + } + } + } + + w.rbspTrailingBits() + + out := w.bytes() + for _, s := range subs { + out = append(out, s...) + } + + return out +} + +// escapedSizes is the length of every substream but the last, in the bytes the +// emulation prevention of 7.3.1.1 leaves. A header ends on a set bit, so the +// run of zeros never carries into the data. +func escapedSizes(subs [][]byte) []uint32 { + if len(subs) < 2 { + return nil + } + + out := make([]uint32, len(subs)-1) + zeros := 0 + + for i, sub := range subs { + var n uint32 + + for _, b := range sub { + if zeros == 2 && b <= 3 { + n++ + + zeros = 0 + } + + n++ + + if b == 0 { + zeros++ + } else { + zeros = 0 + } + } + + if i < len(out) { + out[i] = n + } + } + + return out +} + +// ctbCount is how many coding tree blocks of 64 samples a dimension takes. +func ctbCount(n int) int { return (n + 63) / 64 } + +// startRow begins a substream. Without the wavefront the rows are one stream. +func (e *intraEncoder[P]) startRow(k int, sync *[nContexts]uint8) { + if !e.wavefront { + if k == 0 { + e.bits = putBits{data: make([]byte, 0, e.hint)} + e.cabac.init(&e.bits, int32(e.qp), sliceI, false) + } + + return + } + + e.bits = putBits{} + e.cabac.init(&e.bits, int32(e.qp), sliceI, false) + + // 9.3.1 hands a row the contexts the row above left after its second block. + if k > 0 { + e.cabac.state = *sync + } +} + +// ctbRow codes one row. The end_of_subset_one_bit of 7.3.8.1 closes every row +// but the last. +func (e *intraEncoder[P]) ctbRow(k, rows, cols int, sync *[nContexts]uint8) error { + for x := range cols { + e.writeSAO(&e.cabac, k*cols+x, cols) + + if err := e.tree(x*64, k*64, 6, 0); err != nil { + return err + } + + last := k == rows-1 && x == cols-1 + e.cabac.encodeTerminate(boolToBit(last)) + + if e.wavefront && x == min(1, cols-1) { + *sync = e.cabac.state + } + + if e.wavefront && !last && x == cols-1 { + e.cabac.encodeTerminate(1) + } + } + + return nil +} + +func (e *intraEncoder[P]) reset(y, cb, cr []P, width, height, qp int) { + e.width, e.height = width, height + e.s = chromaSPS(len(cb), width*height) + e.shiftW, e.shiftH = int(e.s.subWidthC)-1, int(e.s.subHeightC)-1 + e.strideC = width >> e.shiftW + + e.bitDepth = max(e.bitDepth, 8) + if _, ok := any(y).([]uint8); ok { + e.bitDepth = 8 + } + + e.s.bitDepthLuma, e.s.bitDepthChroma = uint8(e.bitDepth), uint8(e.bitDepth) + + off := 6 * (e.bitDepth - 8) + cat := e.s.chromaArrayType() + + e.qp = qp + e.qpY = qp + off + e.qpDeblockC = int(chromaQP(clip3(int32(qp), 0, 57), cat)) + e.qpC = int(chromaQP(clip3(int32(qp), int32(-off), 57), cat)) + off + e.lambda = lossyLambda(e.qpY) + e.lambdaBase = lossyLambda(qp) + e.src = [3][]P{y, cb, cr} + e.p = pps{} + + e.recon[0] = regrow(e.recon[0], len(y)) + e.recon[1] = regrow(e.recon[1], len(cb)) + e.recon[2] = regrow(e.recon[2], len(cr)) + e.modes = regrow(e.modes, width/16*height/16) + e.depth = regrow(e.depth, len(e.modes)) + e.coded[0] = regrow(e.coded[0], width/4*height/4) + e.coded[1] = regrow(e.coded[1], len(cb)/16) + e.edges = regrow(e.edges, width/4*height/4) + + e.bits = putBits{} + e.cabac.init(&e.bits, int32(qp), sliceI, false) +} + +func regrow[T any](s []T, n int) []T { + if cap(s) < n { + return make([]T, n) + } + + s = s[:n] + clear(s) + + return s +} + +func (e *intraEncoder[P]) stride(cIdx int) int { + if cIdx == 0 { + return e.width + } + + return e.strideC +} + +// chromaSPS is the sampling the chroma planes describe by their size, which +// 6.2 fixes: a quarter of the luma is 4:2:0, a half 4:2:2, all of it 4:4:4 and +// none of it monochrome. +func chromaSPS(chroma, luma int) sps { + switch { + case chroma == luma: + return sps{chromaFormatIDC: 3, subWidthC: 1, subHeightC: 1} + case chroma*2 == luma: + return sps{chromaFormatIDC: 2, subWidthC: 2, subHeightC: 1} + case chroma*4 == luma: + return sps{chromaFormatIDC: 1, subWidthC: 2, subHeightC: 2} + default: + return sps{chromaFormatIDC: 0, subWidthC: 1, subHeightC: 1} + } +} + +// chromaTBs is how many transform blocks a component takes over one luma +// transform block. 7.3.8.8 stacks two of them in 4:2:2. +func (e *intraEncoder[P]) chromaTBs() int { + switch e.s.chromaArrayType() { + case 0: + return 0 + case 2: + return 2 + default: + return 1 + } +} + +func (e *intraEncoder[P]) blockQP(cIdx int) int { + if cIdx == 0 { + return e.qpY + } + + return e.qpC +} + +// tree is the coding quadtree of 7.3.8.4. A full 32x32 chooses its own size; a +// block at the picture edge splits without a flag. +func (e *intraEncoder[P]) tree(x0, y0, log2Size, d int) error { + if log2Size == 4 { + return e.leaf(x0, y0, d, -1) + } + + size := 1 << log2Size + + if x0+size <= e.width && y0+size <= e.height { + if log2Size == 5 { + return e.cuSize(x0, y0, d) + } + + e.cabac.encodeBin(ctxSplitCodingUnitFlag+e.splitCtx(x0, y0, d), 1) + } + + half := size / 2 + + for i := range 4 { + x, y := x0+i&1*half, y0+i>>1*half + if x >= e.width || y >= e.height { + continue + } + + if err := e.tree(x, y, log2Size-1, d+1); err != nil { + return err + } + } + + return nil +} + +// cuSize codes a full 32x32 block both ways and keeps the cheaper: one coding +// unit of its own, or four 16x16 ones. +func (e *intraEncoder[P]) cuSize(x0, y0, d int) error { + base, start := len(e.bits.data), e.cabac.rate + ctx := ctxSplitCodingUnitFlag + e.splitCtx(x0, y0, d) + + // Both arms pick their first mode by searching the same top-left 16x16 + // against the same reconstruction, so the search is done once for both. + mode := e.lumaMode(x0, y0, lossyMPM(e.modes, e.width/16, x0/16, y0/16, 4)) + + e.save(&e.before, x0, y0, base) + + e.cabac.encodeBin(ctx, 0) + + if err := e.cu32(x0, y0, d, mode); err != nil { + return err + } + + if e.tu.flat() { + return nil + } + + whole := e.rdCost(e.cuDistortion(x0, y0, 32), e.cabac.rate-start) + + e.save(&e.kept, x0, y0, base) + e.load(&e.before, x0, y0, base) + + e.cabac.encodeBin(ctx, 1) + + // Both halves of the cost only grow with each quadrant, so a split that has + // already lost is abandoned where it stands. + split := int64(0) + + for i := range 4 { + x, y := x0+i&1*16, y0+i>>1*16 + first := -1 + + if i == 0 { + first = mode + } + + if err := e.leaf(x, y, d+1, first); err != nil { + return err + } + + split += e.cuDistortion(x, y, 16) + if e.rdCost(split, e.cabac.rate-start) > whole { + e.load(&e.kept, x0, y0, base) + + return nil + } + } + + if whole <= e.rdCost(split, e.cabac.rate-start) { + e.load(&e.kept, x0, y0, base) + } + + return nil +} + +// cuDistortion is the squared error of a block, chroma included, which is what +// the two arms of the size decision differ over. +func (e *intraEncoder[P]) cuDistortion(x0, y0, n int) int64 { + dist := e.distortion(0, x0, y0, n, e.recon[0][y0*e.width+x0:], e.width) + + cs := e.strideC + cx, cy, cn := x0>>e.shiftW, y0>>e.shiftH, n>>e.shiftW + + for c := 1; c < 3; c++ { + for t := range e.chromaTBs() { + y := cy + t*cn + + dist += e.distortion(c, cx, y, cn, e.recon[c][y*cs+cx:], cs) + } + } + + return dist +} + +func (e *intraEncoder[P]) save(s *cuState[P], x0, y0, base int) { + s.cabac = e.cabac + s.bits = append(s.bits[:0], e.bits.data[base:]...) + s.cur, s.nbits = e.bits.cur, e.bits.nbits + + bw := e.width / 16 + saveRect(s.y[:], e.recon[0], e.width, x0, y0, 32, 32) + saveRect(s.modes[:], e.modes, bw, x0/16, y0/16, 2, 2) + saveRect(s.depth[:], e.depth, bw, x0/16, y0/16, 2, 2) + saveRect(s.codedY[:], e.coded[0], e.width/4, x0/4, y0/4, 8, 8) + saveRect(s.edges[:], e.edges, e.width/4, x0/4, y0/4, 8, 8) + + if e.s.chromaArrayType() == 0 { + return + } + + cs, cw, ch := e.strideC, 32>>e.shiftW, 32>>e.shiftH + cx, cy := x0>>e.shiftW, y0>>e.shiftH + saveRect(s.cb[:], e.recon[1], cs, cx, cy, cw, ch) + saveRect(s.cr[:], e.recon[2], cs, cx, cy, cw, ch) + saveRect(s.codedC[:], e.coded[1], cs/4, cx/4, cy/4, cw/4, ch/4) +} + +func (e *intraEncoder[P]) load(s *cuState[P], x0, y0, base int) { + e.cabac = s.cabac + e.bits.data = append(e.bits.data[:base], s.bits...) + e.bits.cur, e.bits.nbits = s.cur, s.nbits + + bw := e.width / 16 + loadRect(e.recon[0], s.y[:], e.width, x0, y0, 32, 32) + loadRect(e.modes, s.modes[:], bw, x0/16, y0/16, 2, 2) + loadRect(e.depth, s.depth[:], bw, x0/16, y0/16, 2, 2) + loadRect(e.coded[0], s.codedY[:], e.width/4, x0/4, y0/4, 8, 8) + loadRect(e.edges, s.edges[:], e.width/4, x0/4, y0/4, 8, 8) + + if e.s.chromaArrayType() == 0 { + return + } + + cs, cw, ch := e.strideC, 32>>e.shiftW, 32>>e.shiftH + cx, cy := x0>>e.shiftW, y0>>e.shiftH + loadRect(e.recon[1], s.cb[:], cs, cx, cy, cw, ch) + loadRect(e.recon[2], s.cr[:], cs, cx, cy, cw, ch) + loadRect(e.coded[1], s.codedC[:], cs/4, cx/4, cy/4, cw/4, ch/4) +} + +// saveRect lifts a w by h rectangle out of a picture-wide map, loadRect puts +// one back. +func saveRect[T any](dst, src []T, stride, x, y, w, h int) { + for j := range h { + copy(dst[j*w:(j+1)*w], src[(y+j)*stride+x:]) + } +} + +func loadRect[T any](dst, src []T, stride, x, y, w, h int) { + for j := range h { + copy(dst[(y+j)*stride+x:][:w], src[j*w:]) + } +} + +func (e *intraEncoder[P]) splitCtx(x0, y0, d int) int { + blocksWide := e.width / 16 + ctx := 0 + + if x0 > 0 && int(e.depth[y0/16*blocksWide+(x0-1)/16]) > d { + ctx++ + } + + if y0 > 0 && int(e.depth[(y0-1)/16*blocksWide+x0/16]) > d { + ctx++ + } + + return ctx +} + +// leaf codes a 16x16 coding unit, whose transform tree splits to 8x8 and then +// to 4x4 where that is cheaper. A negative mode is searched for. +func (e *intraEncoder[P]) leaf(x0, y0, d, mode int) error { + blocksWide := e.width / 16 + cand := lossyMPM(e.modes, blocksWide, x0/16, y0/16, 4) + + if mode < 0 { + mode = e.lumaMode(x0, y0, cand) + } + + var tus [4]lossyTU8Plan + + for i := range 4 { + tus[i] = e.tu8(x0+i&1*8, y0+i>>1*8, mode) + } + + var root [2][2]bool + + for i := range 4 { + x, y := x0+i&1*8, y0+i>>1*8 + + for c := range 2 { + root[c][0] = root[c][0] || tus[i].cbfC8[c][0] || tus[i].cbfC8[c][1] + } + + if !tus[i].split { + e.markTU(x, y, 8) + + continue + } + + for j := range 4 { + e.markTU(x+j&1*4, y+j>>1*4, 4) + } + } + + e.cabac.encodeBin(ctxPartMode, 1) + lossyIntraLumaMode(&e.cabac, mode, cand) + e.chromaPredMode(&e.cabac) + e.cabac.encodeBin(ctxSplitTransformFlag+1, 1) + e.chromaCBF(&e.cabac, 0, root, root, e.chromaSecond(16, true)) + + for i := range 4 { + if err := e.tu8Code(&e.cabac, &tus[i], mode, root); err != nil { + return err + } + } + + idx := y0/16*blocksWide + x0/16 + e.modes[idx] = mode + e.depth[idx] = uint8(d) + + return nil +} + +// cu32 codes a 32x32 coding unit, choosing between one transform unit of its +// own size and four 16x16 ones. A negative mode is searched for. +func (e *intraEncoder[P]) cu32(x0, y0, d, mode int) error { + blocksWide := e.width / 16 + cand := lossyMPM(e.modes, blocksWide, x0/16, y0/16, 4) + + if mode < 0 { + mode = e.lumaMode(x0, y0, cand) + } + + t := &e.tu + base := len(e.bits.data) + + e.save(&e.tuBase, x0, y0, base) + + coef, cbf := e.codeBlock(lossyBlock[P]{x: x0, y: y0, n: 32, mode: mode}, true) + copy(t.y32[:], coef) + t.whole = cbf + t.wholeC = e.codeChroma(x0, y0, 5, mode, t.c32[0][:], t.c32[1][:]) + + t.split = false + whole := e.rdCost(e.cuDistortion(x0, y0, 32), e.tu32Rate(mode)) + + e.save(&e.tuKept, x0, y0, base) + e.load(&e.tuBase, x0, y0, base) + + for i := range 4 { + x, y := x0+i&1*16, y0+i>>1*16 + + coef, cbf = e.codeBlock(lossyBlock[P]{x: x, y: y, n: 16, mode: mode}, true) + copy(t.y[i][:], coef) + t.quad[i] = cbf + t.quadC[i] = e.codeChroma(x, y, 4, mode, t.c[0][i][:], t.c[1][i][:]) + } + + t.split = true + if whole <= e.rdCost(e.cuDistortion(x0, y0, 32), e.tu32Rate(mode)) { + t.split = false + + e.load(&e.tuKept, x0, y0, base) + } + + if t.split { + for i := range 4 { + e.markTU(x0+i&1*16, y0+i>>1*16, 16) + } + } else { + e.markTU(x0, y0, 32) + } + + lossyIntraLumaMode(&e.cabac, mode, cand) + e.chromaPredMode(&e.cabac) + + if err := e.tu32Tree(&e.cabac, mode); err != nil { + return err + } + + for j := range 2 { + for i := range 2 { + idx := (y0/16+j)*blocksWide + x0/16 + i + e.modes[idx] = mode + e.depth[idx] = uint8(d) + } + } + + return nil +} + +// chromaPredMode is intra_chroma_pred_mode of 7.3.8.5, which Table 8-2 reads +// as the luma mode itself. Monochrome codes none. +func (e *intraEncoder[P]) chromaPredMode(w *cabacWriter) { + if e.chromaTBs() > 0 { + w.encodeBin(ctxIntraChromaPredMode, 0) + } +} + +// chromaMode is the mode the chroma blocks predict with, which Table 8-3 +// remaps for 4:2:2. +func (e *intraEncoder[P]) chromaMode(luma int) int { + if e.s.chromaFormatIDC == 2 { + return int(chroma422Map[luma]) + } + + return luma +} + +// chromaLog2 is the size of one chroma transform block under a luma block of +// log2 samples, which only 4:4:4 leaves alone. +func (e *intraEncoder[P]) chromaLog2(log2 int) int { + return log2 - e.shiftW +} + +// chromaAt reports whether 7.3.8.8 carries chroma at a luma block of n, which +// below eight samples only 4:4:4 does. +func (e *intraEncoder[P]) chromaAt(n int) bool { + return e.chromaTBs() > 0 && (n > 4 || e.s.chromaArrayType() == 3) +} + +// chromaSecond is the condition of 7.3.8.8 under which 4:2:2 gives the second +// of its two stacked blocks a coded block flag of its own. +func (e *intraEncoder[P]) chromaSecond(n int, split bool) bool { + return e.s.chromaArrayType() == 2 && (!split || n == 8) +} + +// codeChroma codes the chroma transform blocks under a luma block of log2 +// samples at (x, y), and returns their coded block flags. +func (e *intraEncoder[P]) codeChroma(x, y, log2, mode int, cb, cr []int32) [2][2]bool { + var cbf [2][2]bool + + cn := 1 << e.chromaLog2(log2) + cx, cy := x>>e.shiftW, y>>e.shiftH + cmode := e.chromaMode(mode) + dst := [2][]int32{cb, cr} + + for c := range 2 { + for t := range e.chromaTBs() { + coef, f := e.codeBlock(lossyBlock[P]{cIdx: c + 1, x: cx, y: cy + t*cn, + n: cn, mode: cmode}, true) + + copy(dst[c][t*cn*cn:], coef) + cbf[c][t] = f + } + } + + return cbf +} + +// chromaCBF writes cbf_cb and cbf_cr of 7.3.8.8, which up gates below the root. +func (e *intraEncoder[P]) chromaCBF(w *cabacWriter, depth int, cbf, up [2][2]bool, second bool) { + if e.chromaTBs() == 0 { + return + } + + for c := range 2 { + if depth != 0 && !up[c][0] { + continue + } + + w.encodeBin(ctxCBFCBCR+depth, boolToBit(cbf[c][0])) + + if second { + w.encodeBin(ctxCBFCBCR+depth, boolToBit(cbf[c][1])) + } + } +} + +// chromaResidual writes the levels of the chroma blocks under one luma block. +// 7.3.8.10 codes both Cb blocks before either Cr one. +func (e *intraEncoder[P]) chromaResidual(w *cabacWriter, log2, mode int, cb, cr []int32, + cbf [2][2]bool, +) error { + clog2 := e.chromaLog2(log2) + n := 1 << (2 * clog2) + cmode := e.chromaMode(mode) + src := [2][]int32{cb, cr} + + for c := range 2 { + for t := range e.chromaTBs() { + if !cbf[c][t] { + continue + } + + if err := e.residual(w, src[c][t*n:(t+1)*n], clog2, c+1, cmode); err != nil { + return err + } + } + } + + return nil +} + +// tu32Rate is what the chosen transform tree costs, coded into a writer that +// only counts. +func (e *intraEncoder[P]) tu32Rate(mode int) int64 { + w := e.cabac.counter() + _ = e.tu32Tree(&w, mode) + + return w.rate +} + +// tu32Tree is the transform_tree of 7.3.8.8 for a 32x32 coding unit. +func (e *intraEncoder[P]) tu32Tree(w *cabacWriter, mode int) error { + t := &e.tu + + root := t.wholeC + if t.split { + root = [2][2]bool{} + + for i := range 4 { + for c := range 2 { + root[c][0] = root[c][0] || t.quadC[i][c][0] || t.quadC[i][c][1] + } + } + } + + w.encodeBin(ctxSplitTransformFlag, boolToBit(t.split)) + e.chromaCBF(w, 0, root, root, e.chromaSecond(32, t.split)) + + if !t.split { + if err := e.codedResidual(w, t.y32[:], t.whole, 5, 0, mode, 0); err != nil { + return err + } + + return e.chromaResidual(w, 5, mode, t.c32[0][:], t.c32[1][:], t.wholeC) + } + + for i := range 4 { + w.encodeBin(ctxSplitTransformFlag+1, 0) + e.chromaCBF(w, 1, t.quadC[i], root, e.chromaSecond(16, false)) + + if err := e.codedResidual(w, t.y[i][:], t.quad[i], 4, 0, mode, 1); err != nil { + return err + } + + if err := e.chromaResidual(w, 4, mode, t.c[0][i][:], t.c[1][i][:], t.quadC[i]); err != nil { + return err + } + } + + return nil +} + +// codedResidual writes cbf_luma and the levels behind it. Table 9-49 gives the +// flag its own context at the root of a transform tree. +func (e *intraEncoder[P]) codedResidual(w *cabacWriter, coef []int32, cbf bool, + log2Size, cIdx, mode, trafoDepth int, +) error { + w.encodeBin(ctxCBFLuma+boolToInt(trafoDepth == 0), boolToBit(cbf)) + + if !cbf { + return nil + } + + return e.residual(w, coef, log2Size, cIdx, mode) +} + +func (e *intraEncoder[P]) residual(w *cabacWriter, coef []int32, log2Size, cIdx, mode int) error { + return encodeResidual(w, &e.s, &e.p, coef, + residualBlock{log2Size: log2Size, cIdx: cIdx, predModeIntra: mode, intra: true}) +} + +// tu8 codes one 8x8 quadrant of a 16x16 coding unit, choosing between one 8x8 +// transform and four 4x4 ones on their coded cost. +func (e *intraEncoder[P]) tu8(x, y, mode int) lossyTU8Plan { + w := e.width + + var before, kept tu8State[P] + + e.saveTU8(&before, x, y) + + quarters := [4]lossyBlock[P]{} + for j := range 4 { + quarters[j] = lossyBlock[P]{x: x + j&1*4, y: y + j>>1*4, n: 4, mode: mode, dst: true} + } + + whole := lossyBlock[P]{x: x, y: y, n: 8, mode: mode} + + split := lossyTU8Plan{split: true} + + for j := range 4 { + coef, cbf := e.codeBlock(quarters[j], true) + copy(split.y[j][:], coef) + split.cbfY[j] = cbf + } + + splitCost := e.rdCost(e.distortion(0, x, y, 8, e.recon[0][y*w+x:], w), + e.tu8Rate(&split, mode)) + + e.saveTU8(&kept, x, y) + e.loadTU8(&before, x, y) + + var unsplit lossyTU8Plan + + coef, cbf := e.codeBlock(whole, true) + copy(unsplit.y8[:], coef) + unsplit.cbfY8 = cbf + + chosen := unsplit + if splitCost < e.rdCost(e.distortion(0, x, y, 8, e.recon[0][y*w+x:], w), + e.tu8Rate(&unsplit, mode)) { + chosen = split + + e.loadTU8(&kept, x, y) + } + + if e.chromaTBs() == 0 { + return chosen + } + + // The luma arms are weighed on luma alone, so the chroma is coded once the + // split it has to follow is settled. + if !chosen.split || !e.chromaAt(4) { + chosen.cbfC8 = e.codeChroma(x, y, 3, mode, chosen.c8[0][:], chosen.c8[1][:]) + + return chosen + } + + for j := range 4 { + bx, by := x+j&1*4, y+j>>1*4 + chosen.cbfC[j] = e.codeChroma(bx, by, 2, mode, chosen.c[0][j][:], chosen.c[1][j][:]) + + for c := range 2 { + chosen.cbfC8[c][0] = chosen.cbfC8[c][0] || chosen.cbfC[j][c][0] + } + } + + return chosen +} + +// tu8Code writes one 8x8 quadrant of a 16x16 coding unit: its split flag, the +// chroma flags 7.3.8.8 puts at this depth, and the levels behind them. +func (e *intraEncoder[P]) tu8Code(w *cabacWriter, p *lossyTU8Plan, mode int, up [2][2]bool) error { + w.encodeBin(ctxSplitTransformFlag+2, boolToBit(p.split)) + e.chromaCBF(w, 1, p.cbfC8, up, e.chromaSecond(8, p.split)) + + if !p.split { + if err := e.codedResidual(w, p.y8[:], p.cbfY8, 3, 0, mode, 1); err != nil { + return err + } + + return e.chromaResidual(w, 3, mode, p.c8[0][:], p.c8[1][:], p.cbfC8) + } + + deep := e.chromaAt(4) + + for j := range 4 { + if deep { + e.chromaCBF(w, 2, p.cbfC[j], p.cbfC8, false) + } + + if err := e.codedResidual(w, p.y[j][:], p.cbfY[j], 2, 0, mode, 2); err != nil { + return err + } + + if !deep { + continue + } + + if err := e.chromaResidual(w, 2, mode, p.c[0][j][:], p.c[1][j][:], p.cbfC[j]); err != nil { + return err + } + } + + if deep { + return nil + } + + // 7.3.8.10 codes the one chroma block of the whole quadrant behind the last + // of its luma blocks. + return e.chromaResidual(w, 3, mode, p.c8[0][:], p.c8[1][:], p.cbfC8) +} + +// tu8State is the reconstruction one 8x8 transform unit leaves behind, so the +// arm that wins does not have to be coded a second time. +type tu8State[P pixel] struct { + y [8 * 8]P + coded [4]uint8 +} + +func (e *intraEncoder[P]) saveTU8(s *tu8State[P], x, y int) { + saveRect(s.y[:], e.recon[0], e.width, x, y, 8, 8) + saveRect(s.coded[:], e.coded[0], e.width/4, x/4, y/4, 2, 2) +} + +func (e *intraEncoder[P]) loadTU8(s *tu8State[P], x, y int) { + loadRect(e.recon[0], s.y[:], e.width, x, y, 8, 8) + loadRect(e.coded[0], s.coded[:], e.width/4, x/4, y/4, 2, 2) +} + +func (e *intraEncoder[P]) lumaMode(x, y int, cand [3]int) int { + b := lossyBlock[P]{x: x, y: y, n: 16} + e.prepareRef(b) + + var ( + short [modeShortlist]int + score [modeShortlist]int64 + slot [modeShortlist]int + ) + + for i := range short { + short[i], score[i], slot[i] = intraPlanar, 1<<62, i + } + + pred := e.scratch.pred[:16*16] + + // The smoothing has one outcome at a fixed size, so it is built once. + filtered := false + + for mode := intraPlanar; mode <= 34; mode++ { + ref := &e.scratch.base + + if filterFlag(mode, 16, 0, &e.s) && !e.s.intraSmoothingDisabled { + if !filtered { + e.scratch.ref.copyFrom(&e.scratch.base) + filterRef(&e.scratch.ref, mode, 0, e.bitDepth, &e.s) + + filtered = true + } + + ref = &e.scratch.ref + } + + intraPredict(pred, 0, 16, ref, mode, 0, e.bitDepth) + + s := e.satd(x, y, pred, 16, score[modeShortlist-1]) + + for i := range short { + if s >= score[i] { + continue + } + + // The prediction goes in the slot the evicted mode gives up. + free := slot[modeShortlist-1] + + copy(short[i+1:], short[i:len(short)-1]) + copy(score[i+1:], score[i:len(score)-1]) + copy(slot[i+1:], slot[i:len(slot)-1]) + short[i], score[i], slot[i] = mode, s, free + copy(e.scratch.modePred[free][:], pred) + + break + } + } + + bestMode, bestCost := short[0], int64(-1) + + for i, mode := range short { + b.mode = mode + b.pred = e.scratch.modePred[slot[i]][:] + coef, trial, cbf := e.blockData(b, false) + + // The rate only adds, so a mode whose distortion alone has lost is not + // coded to find out what it costs. + dist := e.distortion(0, x, y, 16, trial, 16) + if bestCost >= 0 && e.rdCost(dist, 0) >= bestCost { + continue + } + + cost := e.rdCost(dist, e.modeRate(cand, mode, coef, cbf)) + if bestCost < 0 || cost < bestCost { + bestMode, bestCost = mode, cost + } + } + + return bestMode +} + +// satd is the Hadamard transformed absolute difference between the source and a +// prediction, over 8x8 at a time. It stands in for what the residual will cost +// far better than the plain difference does, and for a fraction of coding it. +// The sum only grows, so one that has reached limit is returned short. +func (e *intraEncoder[P]) satd(x, y int, pred []P, n int, limit int64) int64 { + src := e.src[0] + stride := e.width + + var sum int64 + + // The kernels are eight bit; anything deeper takes the Go path. + s8, _ := any(src).([]uint8) + p8, _ := any(pred).([]uint8) + + for by := 0; by < n; by += 8 { + for bx := 0; bx < n; bx += 8 { + if sum >= limit { + return sum + } + + so, po := (y+by)*stride+x+bx, by*n+bx + + if k := satd16x8Asm; k != nil && s8 != nil && bx+16 <= n { + sum += k(s8[so:], stride, p8[po:], n) + bx += 8 + + continue + } + + sum += satd8x8Go(src[so:], stride, pred[po:], n) + } + } + + return sum +} + +// satd8x8Go is one 8x8 of satd, the rows transformed and then the columns. +func satd8x8Go[P pixel](src []P, srcStride int, pred []P, predStride int) int64 { + var d [64]int32 + + for j := range 8 { + row := src[j*srcStride:] + p := pred[j*predStride:] + + for i := range 8 { + d[j*8+i] = int32(row[i]) - int32(p[i]) + } + } + + for j := range 8 { + hadamard8((*[8]int32)(d[j*8 : j*8+8])) + } + + var sum int64 + + var col [8]int32 + + for i := range 8 { + for j := range 8 { + col[j] = d[j*8+i] + } + + hadamard8(&col) + + for j := range 8 { + sum += int64(absLevel(col[j])) + } + } + + return sum +} + +func hadamard8(v *[8]int32) { + var t [8]int32 + + for i := range 4 { + t[i] = v[i] + v[i+4] + t[i+4] = v[i] - v[i+4] + } + + var u [8]int32 + + for _, o := range [2]int{0, 4} { + u[o] = t[o] + t[o+2] + u[o+1] = t[o+1] + t[o+3] + u[o+2] = t[o] - t[o+2] + u[o+3] = t[o+1] - t[o+3] + } + + for _, o := range [4]int{0, 2, 4, 6} { + v[o] = u[o] + u[o+1] + v[o+1] = u[o] - u[o+1] + } +} + +// modeRate is what one mode costs, at rateShift. +func (e *intraEncoder[P]) modeRate(cand [3]int, mode int, coef []int32, cbf bool) int64 { + w := e.cabac.counter() + + lossyIntraLumaMode(&w, mode, cand) + + w.encodeBin(ctxCBFLuma, boolToBit(cbf)) + + if cbf { + _ = encodeResidual(&w, &e.s, &e.p, coef, + residualBlock{log2Size: 4, predModeIntra: mode, intra: true}) + } + + return w.rate +} + +func (e *intraEncoder[P]) tu8Rate(plan *lossyTU8Plan, mode int) int64 { + w := e.cabac.counter() + + w.encodeBin(ctxSplitTransformFlag+2, boolToBit(plan.split)) + + if plan.split { + for i := range plan.y { + e.rateResidual(&w, plan.y[i][:], plan.cbfY[i], 2, mode) + } + } else { + e.rateResidual(&w, plan.y8[:], plan.cbfY8, 3, mode) + } + + return w.rate +} + +// modeShortlist is how many of the 35 intra modes are coded in full. Six and +// eight measure the same; four is worse on a large picture. +const modeShortlist = 6 + +// lambdaShift is the fraction lossyLambda carries. +const lambdaShift = 8 + +// rdCost weighs squared error against bits, both at rateShift. +func (e *intraEncoder[P]) rdCost(dist, rate int64) int64 { + return dist<<(rateShift+lambdaShift) + e.lambda*rate +} + +func (e *intraEncoder[P]) rateResidual(w *cabacWriter, coef []int32, cbf bool, log2Size, mode int) { + w.encodeBin(ctxCBFLuma, boolToBit(cbf)) + + if cbf { + _ = encodeResidual(w, &e.s, &e.p, coef, + residualBlock{log2Size: log2Size, predModeIntra: mode, intra: true}) + } +} + +// distortion is the squared error between the source and block, which is a +// trial reconstruction or a window on the picture reconstruction. +func (e *intraEncoder[P]) distortion(cIdx, x, y, n int, block []P, blockStride int) int64 { + stride := e.stride(cIdx) + src := e.src[cIdx] + + if k := sse8Asm; k != nil { + if s8, ok := any(src).([]uint8); ok { + b8, _ := any(block).([]uint8) + + return k(s8[y*stride+x:], stride, b8, blockStride, n) + } + } + + return sse8Go(src[y*stride+x:], stride, block, blockStride, n) +} + +// sse8Go is the squared error of an n by n block. +func sse8Go[P pixel](src []P, srcStride int, block []P, blockStride, n int) int64 { + var dist int64 + + for j := range n { + for i := range n { + d := int64(src[j*srcStride+i]) - int64(block[j*blockStride+i]) + dist += d * d + } + } + + return dist +} + +// codeBlock codes one transform block and writes its reconstruction back. +func (e *intraEncoder[P]) codeBlock(b lossyBlock[P], rdoq bool) ([]int32, bool) { + e.prepareRef(b) + coef, block, cbf := e.blockData(b, rdoq) + + stride := e.stride(b.cIdx) + for j := range b.n { + copy(e.recon[b.cIdx][(b.y+j)*stride+b.x:], block[j*b.n:(j+1)*b.n]) + } + + e.markCoded(b.cIdx, b.x, b.y, b.n) + + return coef, cbf +} + +// markCoded records that a block of the plane has been reconstructed, at the +// 4x4 granularity the reference samples are looked up in. +func (e *intraEncoder[P]) markCoded(cIdx, x, y, n int) { + coded := e.coded[min(cIdx, 1)] + blocksWide := e.stride(cIdx) / 4 + + for j := range n / 4 { + for i := range n / 4 { + coded[(y/4+j)*blocksWide+x/4+i] = 1 + } + } +} + +// blockData predicts, transforms, quantises and reconstructs one block against +// the reference samples prepareRef has already built. +func (e *intraEncoder[P]) blockData(b lossyBlock[P], rdoq bool) ([]int32, []P, bool) { + n := b.n + count := n * n + stride := e.stride(b.cIdx) + qp := e.blockQP(b.cIdx) + + pred := e.scratch.pred[:count] + residual := e.scratch.residual[:count] + coef := e.scratch.coef[:count] + reconCoef := e.scratch.reconCoef[:count] + + if b.pred != nil { + copy(pred, b.pred) + } else { + e.scratch.ref.copyFrom(&e.scratch.base) + filterRef(&e.scratch.ref, b.mode, b.cIdx, e.bitDepth, &e.s) + intraPredict(pred, 0, n, &e.scratch.ref, b.mode, b.cIdx, e.bitDepth) + } + + src := e.src[b.cIdx] + + for j := range n { + for i := range n { + residual[j*n+i] = int32(src[(b.y+j)*stride+b.x+i]) - int32(pred[j*n+i]) + } + } + + if b.dst { + forwardTransformDST4(reconCoef, residual, e.bitDepth) + } else { + forwardTransform(reconCoef, residual, n, e.bitDepth) + } + + if rdoq { + e.rdoq(coef, reconCoef, n, qp, b.cIdx, b.mode) + } else { + quantize(coef, reconCoef, n, qp, e.bitDepth) + } + + if !hasCoefficients(coef) { + return coef, pred, false + } + + copy(reconCoef, coef) + dequant(reconCoef, nil, n, qp, e.bitDepth, false) + inverseTransform(reconCoef, n, b.dst, e.bitDepth, false, &e.scratch.transform) + addResidual(pred, n, 0, 0, n, residualShiftBits(e.bitDepth, false), reconCoef, e.bitDepth) + + return coef, pred, true +} + +// prepareRef builds the reference samples of 8.4.4.2.2, which do not depend on +// the prediction mode. +func (e *intraEncoder[P]) prepareRef(b lossyBlock[P]) { + n, stride := b.n, e.stride(b.cIdx) + recon := e.recon[b.cIdx] + avail := e.scratch.avail[:4*n+1] + clear(avail) + + e.scratch.base.n = n + coded := e.coded[min(b.cIdx, 1)] + blocksWide := stride / 4 + rows := len(recon) / stride + + for i := range 4*n + 1 { + var nx, ny int + + switch { + case i < 2*n: + nx, ny = b.x-1, b.y+2*n-1-i + case i == 2*n: + nx, ny = b.x-1, b.y-1 + default: + nx, ny = b.x+i-2*n-1, b.y-1 + } + + if nx < 0 || ny < 0 || nx >= stride || ny >= rows || + coded[ny/4*blocksWide+nx/4] == 0 { + continue + } + + e.scratch.base.s[i] = int32(recon[ny*stride+nx]) + avail[i] = true + } + + e.scratch.base.substitute(avail, e.bitDepth) +} + +// lambdaScale is the constant in front of the quantiser's own curve, at Q8, +// which is the usual 0.57. +const lambdaScale = 146 + +// lossyLambda weights bits against squared error at Q8, doubling every three +// QP the way the quantiser's step does. +func lossyLambda(qp int) int64 { + return max(1, int64(math.Round(float64(lambdaScale)*math.Exp2(float64(qp-12)/3)))) +} + +// lossyMPM is the candidate list of 8.4.2. A block on the top edge of its +// coding tree block has no candidate above it. +func lossyMPM(modes []int, blocksWide, x, y, ctbBlocks int) [3]int { + candA, candB := intraDC, intraDC + + if x > 0 { + candA = modes[y*blocksWide+x-1] + } + + if y > 0 && y%ctbBlocks != 0 { + candB = modes[(y-1)*blocksWide+x] + } + + var cand [3]int + + switch { + case candA == candB && candA < 2: + cand = [3]int{intraPlanar, intraDC, intraVer} + case candA == candB: + cand = [3]int{candA, 2 + (candA+29)%32, 2 + (candA-2+1)%32} + default: + cand[0], cand[1] = candA, candB + + switch { + case candA != intraPlanar && candB != intraPlanar: + cand[2] = intraPlanar + case candA != intraDC && candB != intraDC: + cand[2] = intraDC + default: + cand[2] = intraVer + } + } + + return cand +} + +func lossyIntraLumaMode(w *cabacWriter, mode int, cand [3]int) { + for i, m := range cand { + if mode != m { + continue + } + + w.encodeBin(ctxPrevIntraLumaPredFlag, 1) + + if i == 0 { + w.encodeBypass(0) + } else { + w.encodeBypass(1) + w.encodeBypass(uint32(i - 1)) + } + + return + } + + w.encodeBin(ctxPrevIntraLumaPredFlag, 0) + + rem := mode + + for _, m := range cand { + if mode > m { + rem-- + } + } + + w.encodeBypassBits(uint32(rem), 5) +} + +func hasCoefficients(coef []int32) bool { + for _, v := range coef { + if v != 0 { + return true + } + } + + return false +} diff --git a/third_party/h265/hevc/mvpred.go b/third_party/h265/hevc/mvpred.go new file mode 100644 index 00000000..5a1ed7f2 --- /dev/null +++ b/third_party/h265/hevc/mvpred.go @@ -0,0 +1,276 @@ +package hevc + +type mv struct { + x, y int16 +} + +// predFlag records which lists a prediction unit uses. +type mvInfo struct { + mv [2]mv + refIdx [2]int8 + pred [2]bool +} + +func (m *mvInfo) sameMotion(o *mvInfo) bool { + return m.pred == o.pred && m.mv == o.mv && m.refIdx == o.refIdx +} + +// neighbour supplies the motion of an already-decoded block, or reports it +// unavailable. It stands in for the picture-wide motion field. +type neighbour func(x, y int) (*mvInfo, bool) + +// mergeCandidates is the spatial part of 8.5.3.2.2 and 8.5.3.2.3: A1, B1, B0, +// A0 and B2 in that order, with the pruning comparisons the clause specifies +// and the partition rules that stop a 2NxN or Nx2N unit from collapsing onto +// its sibling. +func mergeCandidates(dst []mvInfo, nb neighbour, xPb, yPb, nPbW, nPbH, partIdx, partMode, + maxCand int, parMrgLevel int, +) []mvInfo { + inSameRegion := func(xN, yN int) bool { + return xPb>>parMrgLevel == xN>>parMrgLevel && yPb>>parMrgLevel == yN>>parMrgLevel + } + + get := func(xN, yN int) (*mvInfo, bool) { + if inSameRegion(xN, yN) { + return nil, false + } + + return nb(xN, yN) + } + + cand := dst[:0] + + // A partition rule that excludes A1 or B1 also removes it from the pruning + // comparisons; being pruned as a duplicate does not. + a1, availA1 := get(xPb-1, yPb+nPbH-1) + if partIdx == 1 && (partMode == partModeNx2N || partMode == partModenLx2N || + partMode == partModenRx2N) { + availA1 = false + } + + if availA1 { + cand = append(cand, *a1) + } + + b1, availB1 := get(xPb+nPbW-1, yPb-1) + if partIdx == 1 && (partMode == partMode2NxN || partMode == partMode2NxnU || + partMode == partMode2NxnD) { + availB1 = false + } + + if availB1 && !(availA1 && a1.sameMotion(b1)) { + cand = append(cand, *b1) + } + + if b0, ok := get(xPb+nPbW, yPb-1); ok && !(availB1 && b1.sameMotion(b0)) { + cand = append(cand, *b0) + } + + if a0, ok := get(xPb-1, yPb+nPbH); ok && !(availA1 && a1.sameMotion(a0)) { + cand = append(cand, *a0) + } + + if len(cand) < 4 { + if b2, ok := get(xPb-1, yPb-1); ok && + !(availA1 && a1.sameMotion(b2)) && !(availB1 && b1.sameMotion(b2)) { + cand = append(cand, *b2) + } + } + + if len(cand) > maxCand { + cand = cand[:maxCand] + } + + return cand +} + +// combineBiCandidates is 8.5.3.2.4, which pairs the L0 of one candidate with +// the L1 of another until the list is full. It applies to B slices only. +func combineBiCandidates(cand []mvInfo, maxCand int, lists [2][]int32) []mvInfo { + // Table 8-6, the order the pairs are tried in. + l0Idx := [12]int{0, 1, 0, 2, 1, 2, 0, 3, 1, 3, 2, 3} + l1Idx := [12]int{1, 0, 2, 0, 2, 1, 3, 0, 3, 1, 3, 2} + + orig := len(cand) + if orig < 2 { + return cand + } + + for k := 0; k < orig*(orig-1) && len(cand) < maxCand; k++ { + if k >= len(l0Idx) { + break + } + + a, b := &cand[l0Idx[k]], &cand[l1Idx[k]] + + if !a.pred[0] || !b.pred[1] { + continue + } + + if int(a.refIdx[0]) >= len(lists[0]) || int(b.refIdx[1]) >= len(lists[1]) { + continue + } + + if lists[0][a.refIdx[0]] == lists[1][b.refIdx[1]] && a.mv[0] == b.mv[1] { + continue + } + + cand = append(cand, mvInfo{ + mv: [2]mv{a.mv[0], b.mv[1]}, + refIdx: [2]int8{a.refIdx[0], b.refIdx[1]}, + pred: [2]bool{true, true}, + }) + } + + return cand +} + +// zeroCandidates is 8.5.3.2.5, the zero-motion fill that guarantees the list +// reaches its full length. +func zeroCandidates(cand []mvInfo, maxCand, numRefIdx int, biPred bool) []mvInfo { + for i := 0; len(cand) < maxCand; i++ { + r := int8(0) + if i < numRefIdx { + r = int8(i) + } + + c := mvInfo{refIdx: [2]int8{r, r}, pred: [2]bool{true, biPred}} + if !biPred { + c.refIdx[1] = -1 + } + + cand = append(cand, c) + } + + return cand +} + +// scaleMV is the temporal distance scaling of 8.5.3.2.8, shared by the +// temporal merge candidate and the spatial predictors that cross references. +func scaleMV(v mv, currPoc, refPoc, colPoc, colRefPoc int32) mv { + td := clip3(colPoc-colRefPoc, -128, 127) + tb := clip3(currPoc-refPoc, -128, 127) + + if td == 0 { + return v + } + + tx := (16384 + absI32(td)/2) / td + scale := clip3((tb*tx+32)>>6, -4096, 4095) + + sx := scale * int32(v.x) + sy := scale * int32(v.y) + + return mv{ + x: int16(clip3(sign(sx)*((absI32(sx)+127)>>8), -32768, 32767)), + y: int16(clip3(sign(sy)*((absI32(sy)+127)>>8), -32768, 32767)), + } +} + +func sign(v int32) int32 { + switch { + case v < 0: + return -1 + case v > 0: + return 1 + } + + return 0 +} + +const ( + predL0 = iota + predL1 + predBI +) + +// refPic pairs a reference picture with the POC the lists resolved it to. +type refPic struct { + poc int32 + pic *Picture +} + +// amvpCandidates is 8.5.3.2.7. It reports the left and above predictors and +// whether each was found, leaving 8.5.3.2.6 to assemble the list. +func amvpCandidates(nb neighbour, xPb, yPb, nPbW, nPbH int, list int, refPoc int32, + currPoc int32, lists [2][]int32, long [2][]bool, refLong bool, +) (out [2]mv, avail [2]bool) { + direct := func(m *mvInfo) (mv, bool) { + for k := range 2 { + l := list ^ k + + if m.pred[l] && int(m.refIdx[l]) < len(lists[l]) && + lists[l][m.refIdx[l]] == refPoc { + return m.mv[l], true + } + } + + return mv{}, false + } + + // 8.5.3.2.7 takes a neighbour only when its reference matches the target in + // long term status, and never scales a long term vector. + scaled := func(m *mvInfo) (mv, bool) { + for k := range 2 { + l := list ^ k + + if !m.pred[l] || int(m.refIdx[l]) >= len(lists[l]) { + continue + } + + if long[l][m.refIdx[l]] != refLong { + continue + } + + if refLong { + return m.mv[l], true + } + + return scaleMV(m.mv[l], currPoc, refPoc, currPoc, lists[l][m.refIdx[l]]), true + } + + return mv{}, false + } + + sweep := func(pos [][2]int, pick func(*mvInfo) (mv, bool)) (mv, bool) { + for _, p := range pos { + m, ok := nb(p[0], p[1]) + if !ok { + continue + } + + if v, ok := pick(m); ok { + return v, true + } + } + + return mv{}, false + } + + left := [][2]int{{xPb - 1, yPb + nPbH}, {xPb - 1, yPb + nPbH - 1}} + above := [][2]int{{xPb + nPbW, yPb - 1}, {xPb + nPbW - 1, yPb - 1}, {xPb - 1, yPb - 1}} + + isScaled := false + + for _, p := range left { + if _, ok := nb(p[0], p[1]); ok { + isScaled = true + } + } + + out[0], avail[0] = sweep(left, direct) + if !avail[0] { + out[0], avail[0] = sweep(left, scaled) + } + + out[1], avail[1] = sweep(above, direct) + + // With no left neighbour at all the above predictor becomes the first + // candidate and the second is derived again, this time with scaling. + if !isScaled { + out[0], avail[0] = out[1], avail[1] + out[1], avail[1] = sweep(above, scaled) + } + + return out, avail +} diff --git a/third_party/h265/hevc/nal.go b/third_party/h265/hevc/nal.go new file mode 100644 index 00000000..6c355945 --- /dev/null +++ b/third_party/h265/hevc/nal.go @@ -0,0 +1,331 @@ +package hevc + +import "bytes" + +// NALType is the nal_unit_type field of a NAL unit header. +type NALType uint8 + +const ( + NALTrailN NALType = iota + NALTrailR + NALTsaN + NALTsaR + NALStsaN + NALStsaR + NALRadlN + NALRadlR + NALRaslN + NALRaslR +) + +const ( + NALBlaWLP NALType = iota + 16 + NALBlaWRadl + NALBlaNLP + NALIdrWRadl + NALIdrNLP + NALCra +) + +const ( + NALVPS NALType = iota + 32 + NALSPS + NALPPS + NALAUD + NALEOS + NALEOB + NALFD + NALPrefixSEI + NALSuffixSEI +) + +// IsVCL reports whether the unit carries a coded slice segment. +func (t NALType) IsVCL() bool { + return t < 32 +} + +// IsIDR reports whether the unit starts an instantaneous decoding refresh picture. +func (t NALType) IsIDR() bool { + return t == NALIdrWRadl || t == NALIdrNLP +} + +// IsIRAP reports whether the unit starts an intra random access point picture. +func (t NALType) IsIRAP() bool { + return t >= NALBlaWLP && t <= NALCra +} + +// NALUnit is one parsed NAL unit. +type NALUnit struct { + Type NALType + LayerID uint8 + TemporalID uint8 + RBSP []byte + + EPB []uint32 +} + +// ParseNAL parses a single NAL unit with a two-byte header and no framing. +func ParseNAL(data []byte) (NALUnit, bool) { + if len(data) < 2 { + return NALUnit{}, false + } + + b0, b1 := data[0], data[1] + if b0&0x80 != 0 { + return NALUnit{}, false + } + + tidPlus1 := b1 & 0x07 + if tidPlus1 == 0 { + return NALUnit{}, false + } + + rbsp, epb := unescape(data[2:]) + + return NALUnit{ + Type: NALType(b0 >> 1 & 0x3f), + LayerID: b0&0x01<<5 | b1>>3, + TemporalID: tidPlus1 - 1, + RBSP: rbsp, + EPB: epb, + }, true +} + +// SplitAnnexB splits a start-code delimited byte stream into NAL units. +func SplitAnnexB(data []byte) []NALUnit { + var nals []NALUnit + + i, _, ok := startCode(data, 0) + if !ok { + return nals + } + + for i < len(data) { + next, scStart, found := startCode(data, i) + + end := len(data) + if found { + end = scStart + for end > i && data[end-1] == 0 { + end-- + } + } + + if nal, ok := ParseNAL(data[i:end]); ok { + nals = append(nals, nal) + } + + if !found { + break + } + + i = next + } + + return nals +} + +// SplitHVCC splits length-prefixed NAL units, as framed inside hvcC. +func SplitHVCC(data []byte, lengthSize int) []NALUnit { + var nals []NALUnit + + if lengthSize < 1 || lengthSize > 4 { + return nals + } + + for i := 0; i+lengthSize <= len(data); { + n := 0 + for j := range lengthSize { + n = n<<8 | int(data[i+j]) + } + + i += lengthSize + if n < 2 || n > len(data)-i { + break + } + + if nal, ok := ParseNAL(data[i : i+n]); ok { + nals = append(nals, nal) + } + + i += n + } + + return nals +} + +func startCode(data []byte, off int) (next, start int, ok bool) { + for i := off; i+2 < len(data); i++ { + if data[i] != 0 || data[i+1] != 0 { + continue + } + + if data[i+2] == 1 { + return i + 3, i, true + } + + if i+3 < len(data) && data[i+2] == 0 && data[i+3] == 1 { + return i + 4, i, true + } + } + + return 0, 0, false +} + +func unescape(data []byte) ([]byte, []uint32) { + if !bytes.Contains(data, []byte{0, 0, 3}) { + return data, nil + } + + rbsp := make([]byte, 0, len(data)) + + var epb []uint32 + + for i := 0; i < len(data); { + if i+2 < len(data) && data[i] == 0 && data[i+1] == 0 && data[i+2] == 3 { + rbsp = append(rbsp, 0, 0) + epb = append(epb, uint32(i+2)) + i += 3 + + continue + } + + rbsp = append(rbsp, data[i]) + i++ + } + + return rbsp, epb +} + +// RBSPOffset converts a payload-relative byte position, as entry point offsets +// count them, into an index into RBSP. +func (n NALUnit) RBSPOffset(off int) int { + rbsp := off + + for _, p := range n.EPB { + if int(p) >= off { + break + } + + rbsp-- + } + + return rbsp +} + +// NALOffset is the inverse of RBSPOffset. +func (n NALUnit) NALOffset(off int) int { + for _, p := range n.EPB { + if int(p) <= off { + off++ + + continue + } + + break + } + + return off +} + +func marshalNAL(typ NALType, layerID, temporalID uint8, rbsp []byte) []byte { + out := make([]byte, 0, len(rbsp)+2) + out = append(out, + byte(typ)<<1|layerID>>5, + layerID<<3|(temporalID+1)&7, + ) + + return append(out, escapeRBSP(rbsp)...) +} + +func MarshalNAL(nal NALUnit) []byte { + return marshalNAL(nal.Type, nal.LayerID, nal.TemporalID, nal.RBSP) +} + +func marshalAnnexB(nals [][]byte) []byte { + var out []byte + + for _, nal := range nals { + out = append(out, 0, 0, 0, 1) + out = append(out, nal...) + } + + return out +} + +func MarshalAnnexB(nals []NALUnit) []byte { + data := make([][]byte, len(nals)) + for i := range nals { + data[i] = MarshalNAL(nals[i]) + } + + return marshalAnnexB(data) +} + +func marshalHVCC(nals [][]byte, lengthSize int) []byte { + if lengthSize < 1 || lengthSize > 4 { + return nil + } + + var out []byte + + for _, nal := range nals { + if len(nal) == 0 || uint64(len(nal)) >= 1<= 0; i-- { + out = append(out, byte(len(nal)>>uint(i*8))) + } + + out = append(out, nal...) + } + + return out +} + +func escapeRBSP(rbsp []byte) []byte { + out := make([]byte, 0, len(rbsp)) + zeros := 0 + + for _, b := range rbsp { + if zeros == 2 && b <= 3 { + out = append(out, 3) + zeros = 0 + } + + out = append(out, b) + + if b == 0 { + zeros++ + } else { + zeros = 0 + } + } + + return out +} + +// ProfileTierLevel returns the twelve bytes of profile_tier_level, from +// general_profile_space through general_level_idc, out of a sequence parameter +// set RBSP. A container's decoder configuration record repeats them. +func ProfileTierLevel(sps []byte) ([]byte, bool) { + if len(sps) < 13 { + return nil, false + } + + return sps[1:13], true +} + +// SPSFormat is the chroma_format_idc and the two sample sizes a sequence +// parameter set declares. A container's decoder configuration record repeats +// them, and a reader that trusts it over the bitstream has to be told the +// truth. +func SPSFormat(sps []byte) (chromaFormat, bitDepthLuma, bitDepthChroma int, ok bool) { + s, err := parseSPS(sps) + if err != nil { + return 0, 0, 0, false + } + + return int(s.chromaFormatIDC), int(s.bitDepthLuma), int(s.bitDepthChroma), true +} diff --git a/third_party/h265/hevc/pcmenc.go b/third_party/h265/hevc/pcmenc.go new file mode 100644 index 00000000..860688a4 --- /dev/null +++ b/third_party/h265/hevc/pcmenc.go @@ -0,0 +1,64 @@ +package hevc + +func pcmLevelIDC(samples int) uint8 { + for _, level := range []struct { + samples int + idc uint8 + }{ + {36864, 30}, + {122880, 60}, + {245760, 63}, + {552960, 90}, + {983040, 93}, + {2228224, 120}, + {8912896, 150}, + {35651584, 180}, + } { + if samples <= level.samples { + return level.idc + } + } + + return 186 +} + +func pcmSlice[P pixel](y, cb, cr []P, width, height, subW, subH, bitDepth int) []byte { + var bits putBits + bits.bit(1) + bits.bit(0) + bits.ue(0) + bits.ue(uint32(sliceI)) + bits.se(0) + bits.bit(1) + bits.rbspTrailingBits() + + var cabac cabacWriter + cabac.init(&bits, 26, sliceI, false) + + for y0 := 0; y0 < height; y0 += 16 { + for x0 := 0; x0 < width; x0 += 16 { + cabac.encodeBin(ctxPartMode, 1) + cabac.encodeTerminate(1) + cabac.bytes() + + writePCMPlane(&bits, y, width, x0, y0, 16, 16, bitDepth) + + if cb != nil { + writePCMPlane(&bits, cb, width/subW, x0/subW, y0/subH, 16/subW, 16/subH, bitDepth) + writePCMPlane(&bits, cr, width/subW, x0/subW, y0/subH, 16/subW, 16/subH, bitDepth) + } + cabac.reinit() + cabac.encodeTerminate(boolToBit(x0 == width-16 && y0 == height-16)) + } + } + + return cabac.bytes() +} + +func writePCMPlane[P pixel](bits *putBits, plane []P, stride, x, y, w, h, bitDepth int) { + for j := range h { + for i := range w { + bits.bits(uint64(plane[(y+j)*stride+x+i]), bitDepth) + } + } +} diff --git a/third_party/h265/hevc/picture.go b/third_party/h265/hevc/picture.go new file mode 100644 index 00000000..16711d44 --- /dev/null +++ b/third_party/h265/hevc/picture.go @@ -0,0 +1,278 @@ +package hevc + +// Picture is one decoded picture. Planes hold either 8-bit or 16-bit samples +// depending on the bit depth, with Stride in samples. +type Picture struct { + Width, Height int + + // CropW and CropH are the dimensions after the conformance window, which + // is what a caller should display. Width and Height stay as decoded, + // since prediction reads the whole plane. + CropX, CropY int + CropW, CropH int + + ChromaFormat int + + // BitDepth and BitDepthC are the luma and chroma sample depths, which + // 7.4.3.2 lets differ. Both planes are stored 16-bit if either exceeds 8. + BitDepth int + BitDepthC int + + // ColorPrimaries, ColorTransfer, ColorMatrix and FullRange are what the + // sequence declares in its video usability information, as the code points + // of ISO/IEC 23091-2. All three are 2, unspecified, when it declares none. + ColorPrimaries uint16 + ColorTransfer uint16 + ColorMatrix uint16 + FullRange bool + + POC int + + Y, Cb, Cr []uint8 + Y16, Cb16, Cr16 []uint16 + + StrideY, StrideC int + WidthC, HeightC int + + Col []colMotion + ColW int + + pool *picPool + refs int32 +} + +// Release hands the picture's memory back to the decoder that produced it, to +// be reused by a later picture. It is optional: one that is never released is +// collected as any other value would be. Reading the planes afterwards is a +// mistake; releasing twice is not, and does nothing. +func (p *Picture) Release() { + p.release() +} + +func (p *Picture) acquire() { + if p != nil { + p.refs++ + } +} + +func (p *Picture) release() { + if p == nil || p.refs == 0 { + return + } + + p.refs-- + if p.refs > 0 { + return + } + + g := p.geom() + + b := picBufs{ + col: p.Col, + y: p.Y, cb: p.Cb, cr: p.Cr, + y16: p.Y16, cb16: p.Cb16, cr16: p.Cr16, + } + + pool := p.pool + p.pool = nil + + // Dropping the planes turns a read after release into a panic rather than + // a later picture's samples. + p.Col = nil + p.Y, p.Cb, p.Cr = nil, nil, nil + p.Y16, p.Cb16, p.Cr16 = nil, nil, nil + + pool.put(g, b) +} + +// picBufs is the sample memory of one picture, the only part worth recycling. +type picBufs struct { + col []colMotion + y, cb, cr []uint8 + y16, cb16, cr16 []uint16 +} + +// picGeom is everything that fixes a picture's buffer sizes, so a recycled one +// needs no reallocation. +type picGeom struct { + strideY, height int + strideC, heightC int + colLen int + deep bool +} + +func (p *Picture) geom() picGeom { + return picGeom{ + strideY: p.StrideY, height: p.Height, + strideC: p.StrideC, heightC: p.HeightC, + colLen: len(p.Col), + deep: p.Y16 != nil, + } +} + +// picPoolDepth bounds how many pictures of one shape are kept, so a sequence +// that changes resolution cannot make the pool grow without end. +const picPoolDepth = 8 + +// picPool holds the pictures nobody references any more. A decoder keeps one, +// so everything it recycles came from the same sequence. +type picPool struct { + free map[picGeom][]picBufs +} + +func (pl *picPool) get(g picGeom) (picBufs, bool) { + if pl == nil { + return picBufs{}, false + } + + free := pl.free[g] + if len(free) == 0 { + return picBufs{}, false + } + + b := free[len(free)-1] + free[len(free)-1] = picBufs{} + pl.free[g] = free[:len(free)-1] + + return b, true +} + +func (pl *picPool) put(g picGeom, b picBufs) { + if pl == nil { + return + } + + if pl.free == nil { + pl.free = make(map[picGeom][]picBufs) + } + + if len(pl.free[g]) < picPoolDepth { + pl.free[g] = append(pl.free[g], b) + } +} + +func newPicture(pool *picPool, s *sps) *Picture { + p := &Picture{ + Width: int(s.picWidthInLumaSamples), + Height: int(s.picHeightInLumaSamples), + ChromaFormat: int(s.chromaFormatIDC), + BitDepth: int(s.bitDepthLuma), + BitDepthC: int(s.bitDepthChroma), + + ColorPrimaries: s.colourPrimaries, + ColorTransfer: s.transferChar, + ColorMatrix: s.matrixCoeffs, + FullRange: s.fullRange, + } + + p.CropX = int(s.confWinLeft) * s.subWidthC + p.CropY = int(s.confWinTop) * s.subHeightC + p.CropW = int(s.croppedWidth()) + p.CropH = int(s.croppedHeight()) + + p.StrideY = p.Width + p.WidthC = p.Width / s.subWidthC + p.HeightC = p.Height / s.subHeightC + p.StrideC = p.WidthC + + if s.chromaFormatIDC == 0 { + p.WidthC, p.HeightC, p.StrideC = 0, 0, 0 + } + + p.ColW = (p.Width + 15) / 16 + + g := picGeom{ + strideY: p.StrideY, height: p.Height, + strideC: p.StrideC, heightC: p.HeightC, + colLen: p.ColW * ((p.Height + 15) / 16), + deep: max(s.bitDepthLuma, s.bitDepthChroma) > 8, + } + + // A recycled picture keeps only its buffers; everything derived from the + // sequence has already been filled in above, and the samples are zeroed so + // a reused one is indistinguishable from a fresh one. + if r, ok := pool.get(g); ok { + p.Col, p.Y, p.Cb, p.Cr = r.col, r.y, r.cb, r.cr + p.Y16, p.Cb16, p.Cr16 = r.y16, r.cb16, r.cr16 + + clear(p.Col) + clear(p.Y) + clear(p.Cb) + clear(p.Cr) + clear(p.Y16) + clear(p.Cb16) + clear(p.Cr16) + + p.pool, p.refs = pool, 1 + + return p + } + + p.pool, p.refs = pool, 1 + + p.Col = make([]colMotion, g.colLen) + + if g.deep { + p.Y16 = make([]uint16, p.StrideY*p.Height) + p.Cb16 = make([]uint16, p.StrideC*p.HeightC) + p.Cr16 = make([]uint16, p.StrideC*p.HeightC) + + return p + } + + p.Y = make([]uint8, p.StrideY*p.Height) + p.Cb = make([]uint8, p.StrideC*p.HeightC) + p.Cr = make([]uint8, p.StrideC*p.HeightC) + + return p +} + +// deep reports whether the planes hold 16-bit samples. +func (p *Picture) deep() bool { + return p.Y16 != nil +} + +// depth is the sample depth of one component. +func (p *Picture) depth(cIdx int) int { + if cIdx == 0 { + return p.BitDepth + } + + return p.BitDepthC +} + +func (p *Picture) plane8(cIdx int) ([]uint8, int) { + switch cIdx { + case 0: + return p.Y, p.StrideY + case 1: + return p.Cb, p.StrideC + default: + return p.Cr, p.StrideC + } +} + +func (p *Picture) plane16(cIdx int) ([]uint16, int) { + switch cIdx { + case 0: + return p.Y16, p.StrideY + case 1: + return p.Cb16, p.StrideC + default: + return p.Cr16, p.StrideC + } +} + +// The collocated motion field, subsampled to 16x16 as 8.5.3.2.9 allows. +// Reference pictures are held by POC so the collocated picture's own lists are +// not needed later. +type colMotion struct { + info mvInfo + refPoc [2]int32 + refLong [2]bool + intra bool +} + +func (p *Picture) colIndex(x, y int) int { + return (y>>4)*p.ColW + x>>4 +} diff --git a/third_party/h265/hevc/putbits.go b/third_party/h265/hevc/putbits.go new file mode 100644 index 00000000..a22c3ebe --- /dev/null +++ b/third_party/h265/hevc/putbits.go @@ -0,0 +1,66 @@ +package hevc + +type putBits struct { + data []byte + cur uint8 + nbits uint8 +} + +func (w *putBits) bit(v uint32) { + w.cur = w.cur<<1 | uint8(v&1) + w.nbits++ + + if w.nbits == 8 { + w.data = append(w.data, w.cur) + w.cur, w.nbits = 0, 0 + } +} + +func (w *putBits) bits(v uint64, n int) { + for n > 0 { + n-- + w.bit(uint32(v >> n & 1)) + } +} + +func (w *putBits) ue(v uint32) { + n := 0 + + for x := v + 1; x > 1; x >>= 1 { + n++ + } + + for range n { + w.bit(0) + } + + w.bits(uint64(v)+1, n+1) +} + +func (w *putBits) se(v int32) { + var u uint32 + if v <= 0 { + u = uint32(-v) << 1 + } else { + u = uint32(v)<<1 - 1 + } + + w.ue(u) +} + +func (w *putBits) rbspTrailingBits() { + w.bit(1) + + for w.nbits != 0 { + w.bit(0) + } +} + +// count is the number of bits written, which the rate estimates measure. +func (w *putBits) count() int { + return len(w.data)*8 + int(w.nbits) +} + +func (w *putBits) bytes() []byte { + return w.data +} diff --git a/third_party/h265/hevc/rdoq.go b/third_party/h265/hevc/rdoq.go new file mode 100644 index 00000000..6ff227f2 --- /dev/null +++ b/third_party/h265/hevc/rdoq.go @@ -0,0 +1,384 @@ +package hevc + +// Rate distortion optimised quantisation. Rounding a coefficient to the nearest +// level is only right when bits are free; 8.6.3 leaves the encoder to weigh the +// error a coarser level makes against the bits it saves, which is what this +// does, one coefficient at a time and in the order 7.3.8.11 codes them. + +// quantScale is the multiplier of 8.6.3 and the shift that follows it. +func quantScale(n, qp, bitDepth int) (scale int64, qbits uint) { + qbits = uint(14 + qp/6 + 15 - bitDepth - log2(n)) + scale = (int64(1)<<20 + int64(levelScale[qp%6])/2) / int64(levelScale[qp%6]) + + return scale, qbits +} + +// levelDist is the squared picture error one level of the quantiser makes, +// scaled so that levelError's shifted square lands in rdCost's units. A level +// is levelScale[qp%6] << (qp/6) over 64 in the picture, which is the square +// root of this. +func levelDist(qp int) int64 { + return int64(levelScale[qp%6]) * int64(levelScale[qp%6]) << (2 * (qp / 6)) +} + +// levelError is the squared error a level leaves, in the units rdCost weighs +// against bits. A level of the quantiser is levelScale[qp%6] << (qp/6) over 64 +// in the picture, which is where the constant comes from; the shift by 15 keeps +// the square inside 64 bits, and the 19 it is short is carried by the rate. +func levelError(err int64, shift uint, dist int64) int64 { + e := err >> shift + + return e * e * dist +} + +// rdoqState carries what the level passes of 7.3.8.11 make the next coefficient +// cost: which context set the greater-than-one flags are in, how far the +// greater-than-one run has got, and the Rice parameter. +type rdoqState struct { + ctxSet int + greater1Ctx int + lastGreater1 int + numSig int + rice int + started bool + last bool +} + +// binBits is what one context coded bin costs against the contexts as they +// stand, which is the estimate 9.3.4.3 would give it. +func (e *intraEncoder[P]) binBits(ctx int, bin uint32) int64 { + return int64(entropyBits[e.cabac.state[ctx]^uint8(bin&1)]) +} + +// remainingBits is the length of the 9.3.3.11 binarisation, all of it bypass. +func remainingBits(v int32, rice int) int64 { + if v < 3<>rice+1+int32(rice)) << rateShift + } + + k := 0 + for v >= ((1<<(k+1))+2)< 0 { + ctx += 16 + } + + bits += e.binBits(ctxCoeffAbsLevelGreater1Flag+ctx, boolToBit(level > 1)) + want = 2 + + if level > 1 { + base = 2 + + if st.lastGreater1 < 0 { + ctx := st.ctxSet + if cIdx > 0 { + ctx += 4 + } + + bits += e.binBits(ctxCoeffAbsLevelGreater2Flag+ctx, boolToBit(level > 2)) + want = 3 + + if level > 2 { + base = 3 + } + } + } + } + + if base == want { + bits += remainingBits(level-base, st.rice) + } + + return bits +} + +// advance moves the level passes on by one coded coefficient. +func (st *rdoqState) advance(level int32) { + if st.numSig < 8 { + if level > 1 { + if st.lastGreater1 < 0 { + st.lastGreater1 = st.numSig + } + + st.greater1Ctx = 0 + } else if st.greater1Ctx > 0 { + st.greater1Ctx++ + } + } + + if level > 3<> 2 + + num := e.scratch.num[:n*n] + for i, v := range raw[:n*n] { + t := int64(v) + if t < 0 { + t = -t + } + + num[i] = t * scale + } + + // The significance of a sub-block feeds the contexts of the ones before it, + // so it is taken from where the levels round to rather than from decisions + // not yet made. + var csbf [8 * 8]bool + + lastSB, lastPos := -1, -1 + + for i, sb := range sbScan { + for k, pos := range coeffScan { + idx := (int(sb.y)<<2+int(pos.y))*n + int(sb.x)<<2 + int(pos.x) + if (num[idx]+int64(1)<<(qbits-1))>>qbits == 0 { + continue + } + + csbf[int(sb.y)*sbWidth+int(sb.x)] = true + lastSB, lastPos = i, k + } + } + + clear(coef[:n*n]) + + if lastSB < 0 { + return + } + + // Each coefficient's cost is kept so that the last significant position can + // be reconsidered once every level is known. + cost := e.scratch.cost[:n*n] + costZero := e.scratch.costZero[:n*n] + costSig := e.scratch.costSig[:n*n] + + clear(cost) + clear(costZero) + clear(costSig) + + var st rdoqState + + for i := lastSB; i >= 0; i-- { + sb := sbScan[i] + xS, yS := int(sb.x), int(sb.y) + + if !csbf[yS*sbWidth+xS] { + continue + } + + prevCsbf := 0 + if xS+1 < sbWidth && csbf[yS*sbWidth+xS+1] { + prevCsbf++ + } + + if yS+1 < sbWidth && csbf[(yS+1)*sbWidth+xS] { + prevCsbf += 2 + } + + sigSet := newSigCtxSet(xS, yS, log2(n), cIdx, scanIdx, prevCsbf, false) + + st.ctxSet = 0 + if i > 0 && cIdx == 0 { + st.ctxSet = 2 + } + + if st.started && st.greater1Ctx == 0 { + st.ctxSet++ + } + + st.started = true + st.greater1Ctx = 1 + st.lastGreater1 = -1 + st.numSig = 0 + + // A sub-block whose levels cost more than the error of dropping them + // is dropped, which is a saving the flag itself pays for. + coded, dropped := int64(0), int64(0) + optional := i > 0 && i < lastSB + + start := numSbCoeff - 1 + if i == lastSB { + start = lastPos + } + + for k := start; k >= 0; k-- { + pos := coeffScan[k] + idx := (yS<<2+int(pos.y))*n + xS<<2 + int(pos.x) + scan := i*numSbCoeff + k + t := num[idx] + + sig := ctxSignificantCoeffFlag + sigSet.at(int(pos.x), int(pos.y)) + nearest := int32((t + int64(1)<<(qbits-1)) >> qbits) + zero := levelError(t, shift, dist) + + best := int32(0) + chosen := zero + lambda*e.binBits(sig, 0) + sigBits := lambda * e.binBits(sig, 0) + + for level := nearest; level >= max(1, nearest-1); level-- { + c := levelError(t-int64(level)< 0 { + ctx += 2 + } + + if dropped+lambda*e.binBits(ctx, 0) >= coded+lambda*e.binBits(ctx, 1) { + continue + } + + csbf[yS*sbWidth+xS] = false + + for k, pos := range coeffScan { + coef[(yS<<2+int(pos.y))*n+xS<<2+int(pos.x)] = 0 + + scan := i*numSbCoeff + k + cost[scan], costSig[scan] = costZero[scan], 0 + } + } + + e.truncate(coef, cost, costZero, costSig, sbScan, coeffScan, + lastSB*numSbCoeff+lastPos, n, cIdx, scanIdx, lambda) +} + +// truncate reconsiders where the block ends. Coding a nearer coefficient as the +// last one drops everything past it, which pays for itself when what is dropped +// costs more in bits than it is worth in error. +func (e *intraEncoder[P]) truncate(coef []int32, cost, costZero, costSig []int64, + sbScan, coeffScan []scanPos, last, n, cIdx, scanIdx int, lambda int64, +) { + at := func(scan int) int { + sb, pos := sbScan[scan/numSbCoeff], coeffScan[scan%numSbCoeff] + + return (int(sb.y)<<2+int(pos.y))*n + int(sb.x)<<2 + int(pos.x) + } + + base := int64(0) + for scan := 0; scan <= last; scan++ { + base += cost[scan] + } + + best, bestLast := int64(1)<<62, last + + for scan := last; scan >= 0; scan-- { + idx := at(scan) + + level := absLevel(coef[idx]) + if level == 0 { + base -= costSig[scan] + + continue + } + + if total := base - costSig[scan] + lambda*e.lastBits(idx, n, cIdx, scanIdx); total < best { + best, bestLast = total, scan + } + + // Past a level above one there is nothing left worth dropping. + if level > 1 { + break + } + + base += costZero[scan] - cost[scan] + } + + for scan := bestLast + 1; scan <= last; scan++ { + coef[at(scan)] = 0 + } +} + +// lastBits is what last_sig_coeff_x and last_sig_coeff_y cost for a position, +// prefix contexts and bypass suffix together. +func (e *intraEncoder[P]) lastBits(idx, n, cIdx, scanIdx int) int64 { + x, y := idx%n, idx/n + if scanIdx == scanVer { + x, y = y, x + } + + return e.lastCoordBits(ctxLastSignificantCoeffXPrefix, x, log2(n), cIdx) + + e.lastCoordBits(ctxLastSignificantCoeffYPrefix, y, log2(n), cIdx) +} + +func (e *intraEncoder[P]) lastCoordBits(base, v, log2Size, cIdx int) int64 { + prefix := lastSigCoeffPrefix(v) + cMax := log2Size<<1 - 1 + + var bits int64 + + for i := range prefix { + bits += e.binBits(base+lastSigCoeffCtx(log2Size, cIdx, i), 1) + } + + if prefix < cMax { + bits += e.binBits(base+lastSigCoeffCtx(log2Size, cIdx, prefix), 0) + } + + if prefix > 3 { + bits += int64(prefix>>1-1) << rateShift + } + + return bits +} diff --git a/third_party/h265/hevc/residual.go b/third_party/h265/hevc/residual.go new file mode 100644 index 00000000..de70666e --- /dev/null +++ b/third_party/h265/hevc/residual.go @@ -0,0 +1,610 @@ +package hevc + +const numSbCoeff = 16 + +// Table 9-43, the sig_coeff_flag context map for 4x4 blocks. +var sigCtxMap4x4 = [16]uint8{0, 1, 4, 5, 2, 3, 4, 5, 6, 6, 8, 8, 7, 7, 8, 8} + +// scanIndex is the derivation in 7.4.9.11: only small intra blocks depart from +// the diagonal scan, and then only for near-horizontal or near-vertical modes. +func scanIndex(log2Size, cIdx, predModeIntra int, intra bool, chromaArrayType uint32) int { + if !intra { + return scanDiag + } + + if log2Size != 2 && !(log2Size == 3 && (cIdx == 0 || chromaArrayType == 3)) { + return scanDiag + } + + switch { + case predModeIntra >= 6 && predModeIntra <= 14: + return scanVer + case predModeIntra >= 22 && predModeIntra <= 30: + return scanHor + default: + return scanDiag + } +} + +// lastSigCoeffPrefix is the truncated Rice prefix of 9.3.3.2 with the context +// derivation of 9.3.4.2.3. +func lastSigCoeffCtx(log2Size, cIdx, binIdx int) int { + if cIdx == 0 { + return binIdx>>((log2Size+1)>>2) + 3*(log2Size-2) + (log2Size-1)>>2 + } + + return binIdx>>(log2Size-2) + 15 +} + +func (c *cabac) lastSigCoeffPrefix(base, log2Size, cIdx int) int { + cMax := log2Size<<1 - 1 + + v := 0 + for v < cMax && c.decodeBin(base+lastSigCoeffCtx(log2Size, cIdx, v)) != 0 { + v++ + } + + return v +} + +func (c *cabac) lastSigCoeffSuffix(prefix int) int { + if prefix <= 3 { + return prefix + } + + n := prefix>>1 - 1 + suffix := int(c.decodeBypassBits(n)) + + return (1< 0 { + limit = 32 - rng + } + + prefix := 0 + for prefix < limit && c.decodeBypass() != 0 { + prefix++ + } + + if prefix < 3 { + return int32(prefix< 0 && prefix == limit { + return int32((1< 0 { + c.base = 3 + } + + switch { + case log2Size == 3 && scanIdx == scanDiag: + c.base += 9 + case log2Size == 3: + c.base += 15 + default: + c.base += 21 + } + + return c +} + +func (c sigCtxSet) at(x, y int) int { + switch { + case c.flat: + return c.base + case c.is4x4: + return c.small + int(sigCtxMap4x4[y<<2+x]) + case c.sbDC && x|y == 0: + return c.dc + } + + return c.base + int(sigCtxByCsbf[c.prevCsbf][y&3<<2|x&3]) +} + +// sigCoeffCtx is 9.3.4.2.5 transcribed, which sigCtxSet is held to by +// TestSigCtxSetMatchesDerivation. +func sigCoeffCtx(xC, yC, log2Size, cIdx, scanIdx int, prevCsbf int) int { + var sig int + + switch { + case log2Size == 2: + sig = int(sigCtxMap4x4[yC<<2+xC]) + case xC+yC == 0: + sig = 0 + default: + sig = int(sigCtxByCsbf[prevCsbf][yC&3<<2|xC&3]) + + if cIdx == 0 { + if xC>>2+yC>>2 > 0 { + sig += 3 + } + + if log2Size == 3 { + if scanIdx == scanDiag { + sig += 9 + } else { + sig += 15 + } + } else { + sig += 21 + } + } else { + if log2Size == 3 { + sig += 9 + } else { + sig += 12 + } + } + } + + if cIdx == 0 { + return sig + } + + return 27 + sig +} + +type residualBlock struct { + log2Size int + cIdx int + predModeIntra int + intra bool + transquantBypass bool +} + +// decodeResidual is the residual_coding syntax of 7.3.8.11. coef receives the +// transform coefficient levels in raster order and must be zeroed by the +// caller. +func decodeResidual(c *cabac, s *sps, p *pps, _ *sliceHeader, coef []int32, + b residualBlock, statCoeff *[4]uint8, +) (transformSkip bool, err error) { + n := 1 << b.log2Size + + if p.transformSkipEnabled && !b.transquantBypass && + b.log2Size <= int(p.log2MaxTransformSkipSize) { + transformSkip = c.decodeBin(ctxTransformSkipFlag+min(b.cIdx, 1)) != 0 + } + + // 9.3.4.2.5 gives a block that skips the transform one significance + // context throughout, rather than one derived from the position. + flatCtx := s.transformSkipContext && (transformSkip || b.transquantBypass) + + scanIdx := scanIndex(b.log2Size, b.cIdx, b.predModeIntra, b.intra, s.chromaArrayType()) + + xPrefix := c.lastSigCoeffPrefix(ctxLastSignificantCoeffXPrefix, b.log2Size, b.cIdx) + yPrefix := c.lastSigCoeffPrefix(ctxLastSignificantCoeffYPrefix, b.log2Size, b.cIdx) + + lastX := c.lastSigCoeffSuffix(xPrefix) + lastY := c.lastSigCoeffSuffix(yPrefix) + + if scanIdx == scanVer { + lastX, lastY = lastY, lastX + } + + if lastX >= n || lastY >= n { + return transformSkip, ErrInvalid + } + + sbLog2 := b.log2Size - 2 + sbScan := scanOrder[sbLog2][scanIdx] + coeffScan := scanOrder[2][scanIdx] + + lastSubBlock := len(sbScan) - 1 + lastScanPos := numSbCoeff + + for { + if lastScanPos == 0 { + lastScanPos = numSbCoeff + lastSubBlock-- + + if lastSubBlock < 0 { + return transformSkip, ErrInvalid + } + } + + lastScanPos-- + + sb := sbScan[lastSubBlock] + pos := coeffScan[lastScanPos] + + if int(sb.x)<<2+int(pos.x) == lastX && int(sb.y)<<2+int(pos.y) == lastY { + break + } + } + + var ( + csbf [8 * 8]bool + st residualState + ) + + sbWidth := 1 << sbLog2 + + var sig [numSbCoeff]bool + + for i := lastSubBlock; i >= 0; i-- { + sb := sbScan[i] + xS, yS := int(sb.x), int(sb.y) + + inferSbDcSig := false + + if i < lastSubBlock && i > 0 { + ctx := 0 + if xS+1 < sbWidth && csbf[yS*sbWidth+xS+1] { + ctx++ + } + + if yS+1 < sbWidth && csbf[(yS+1)*sbWidth+xS] { + ctx++ + } + + base := ctxSignificantCoeffGroupFlag + if b.cIdx > 0 { + base += 2 + } + + csbf[yS*sbWidth+xS] = c.decodeBin(base+min(ctx, 1)) != 0 + inferSbDcSig = true + } else { + csbf[yS*sbWidth+xS] = true + } + + if !csbf[yS*sbWidth+xS] { + continue + } + + prevCsbf := 0 + if xS+1 < sbWidth && csbf[yS*sbWidth+xS+1] { + prevCsbf++ + } + + if yS+1 < sbWidth && csbf[(yS+1)*sbWidth+xS] { + prevCsbf += 2 + } + + start := numSbCoeff - 1 + if i == lastSubBlock { + start = lastScanPos - 1 + } + + for k := range sig { + sig[k] = false + } + + if i == lastSubBlock { + sig[lastScanPos] = true + } + + // 9.3.4.2.5 varies with the coefficient only through its place inside + // the sub-block; everything else is fixed for the whole of it. + sigSet := newSigCtxSet(xS, yS, b.log2Size, b.cIdx, scanIdx, prevCsbf, flatCtx) + + for k := start; k >= 0; k-- { + if k == 0 && inferSbDcSig && !anyTrue(sig[1:]) { + sig[0] = true + + continue + } + + pos := coeffScan[k] + + ctx := sigSet.at(int(pos.x), int(pos.y)) + sig[k] = c.decodeBin(ctxSignificantCoeffFlag+ctx) != 0 + } + + decodeSubBlockLevels(c, s, p, coef, b, sbScan[i], coeffScan, + &sig, i, n, statCoeff, &st) + } + + return transformSkip, nil +} + +func anyTrue(v []bool) bool { + for _, b := range v { + if b { + return true + } + } + + return false +} + +// residualState carries the greater1 context across the sub-blocks of one +// transform block, as 9.3.4.2.6 requires. +type residualState struct { + started bool + greater1Ctx int + lastGreater1 bool +} + +func decodeSubBlockLevels(c *cabac, s *sps, p *pps, coef []int32, + b residualBlock, sb scanPos, coeffScan []scanPos, sig *[numSbCoeff]bool, + subBlock, n int, statCoeff *[4]uint8, st *residualState, +) { + xS, yS := int(sb.x), int(sb.y) + + ctxSet := 0 + if subBlock > 0 && b.cIdx == 0 { + ctxSet = 2 + } + + lastCtx := 1 + + if st.started { + lastCtx = st.greater1Ctx + if lastCtx > 0 { + if st.lastGreater1 { + lastCtx = 0 + } else { + lastCtx++ + } + } + } + + if lastCtx == 0 { + ctxSet++ + } + + st.started = true + + // The three passes below visit the significant coefficients only, so the + // positions are gathered once instead of rescanning all sixteen. + var posBuf [numSbCoeff]int8 + + npos := 0 + + for k := numSbCoeff - 1; k >= 0; k-- { + if sig[k] { + posBuf[npos] = int8(k) + npos++ + } + } + + if npos == 0 { + return + } + + pos := posBuf[:npos] + + firstSig, lastSig := int(pos[len(pos)-1]), int(pos[0]) + lastGreater1 := -1 + + var greater1 [numSbCoeff]bool + + numGreater1 := 0 + greater1Ctx := 1 + + for _, p8 := range pos { + k := int(p8) + + if numGreater1 < 8 { + ctx := ctxSet*4 + min(3, greater1Ctx) + if b.cIdx > 0 { + ctx += 16 + } + + greater1[k] = c.decodeBin(ctxCoeffAbsLevelGreater1Flag+ctx) != 0 + numGreater1++ + + st.greater1Ctx = greater1Ctx + st.lastGreater1 = greater1[k] + + if greater1[k] { + greater1Ctx = 0 + + if lastGreater1 < 0 { + lastGreater1 = k + } + } else if greater1Ctx > 0 { + greater1Ctx++ + } + } + } + + var greater2 bool + + if lastGreater1 >= 0 { + ctx := ctxSet + if b.cIdx > 0 { + ctx += 4 + } + + greater2 = c.decodeBin(ctxCoeffAbsLevelGreater2Flag+ctx) != 0 + } + + signHidden := lastSig-firstSig > 3 && !b.transquantBypass + + var signs [numSbCoeff]bool + + for _, p8 := range pos { + k := int(p8) + + if p.signDataHidingEnabled && signHidden && k == firstSig { + continue + } + + signs[k] = c.decodeBypass() != 0 + } + + rice := 0 + if s.persistentRiceAdaptation { + rice = int(statCoeff[riceStatIndex(b)] / 4) + } + + rng := s.coeffRange(b.cIdx) + + numSig := 0 + + var sumAbs int32 + + firstRemaining := true + + for _, p8 := range pos { + k := int(p8) + + base := int32(1) + if greater1[k] { + base++ + } + + if k == lastGreater1 && greater2 { + base++ + } + + want := int32(1) + + if numSig < 8 { + want = 2 + if k == lastGreater1 { + want = 3 + } + } + + level := base + + if base == want { + rem := c.coeffAbsLevelRemaining(rice, rng) + level = base + rem + + if s.persistentRiceAdaptation && firstRemaining { + updateStatCoeff(statCoeff, riceStatIndex(b), rem) + firstRemaining = false + } + + if level > 3<= 3<<(stat[i]/4): + stat[i]++ + case 2*rem < 1<<(stat[i]/4) && stat[i] > 0: + stat[i]-- + } +} + +// sigCtxByCsbf is the position part of 9.3.4.2.5, which depends only on the +// coefficient's place within its sub-block and on the two neighbouring +// sub-block flags. Built from the derivation rather than transcribed. +var sigCtxByCsbf = func() [4][numSbCoeff]uint8 { + var t [4][numSbCoeff]uint8 + + for csbf := range 4 { + for yP := range 4 { + for xP := range 4 { + var sig uint8 + + switch csbf { + case 0: + switch { + case xP+yP == 0: + sig = 2 + case xP+yP < 3: + sig = 1 + } + case 1: + switch { + case yP == 0: + sig = 2 + case yP == 1: + sig = 1 + } + case 2: + switch { + case xP == 0: + sig = 2 + case xP == 1: + sig = 1 + } + default: + sig = 2 + } + + t[csbf][yP<<2|xP] = sig + } + } + } + + return t +}() diff --git a/third_party/h265/hevc/residualenc.go b/third_party/h265/hevc/residualenc.go new file mode 100644 index 00000000..64dcc42f --- /dev/null +++ b/third_party/h265/hevc/residualenc.go @@ -0,0 +1,331 @@ +package hevc + +// encodeResidual is the residual_coding syntax of 7.3.8.11. coef holds the +// levels in raster order and must already carry the parity of 7.4.9.11. +func encodeResidual(w *cabacWriter, s *sps, p *pps, coef []int32, b residualBlock) error { + if b.log2Size < 2 || b.log2Size > 5 || b.cIdx < 0 || b.cIdx > 2 || + len(coef) != 1<<(b.log2Size<<1) { + return ErrInvalid + } + + if p.transformSkipEnabled || s.persistentRiceAdaptation || + b.transquantBypass || p.transquantBypass || s.transformSkipContext || s.extendedPrecision { + return ErrUnsupported + } + + n := 1 << b.log2Size + scanIdx := scanIndex(b.log2Size, b.cIdx, b.predModeIntra, b.intra, s.chromaArrayType()) + sbLog2 := b.log2Size - 2 + sbScan := scanOrder[sbLog2][scanIdx] + coeffScan := scanOrder[2][scanIdx] + + lastSubBlock, lastScanPos := -1, -1 + var csbf [8 * 8]bool + + for i, sb := range sbScan { + xS, yS := int(sb.x), int(sb.y) + for k, pos := range coeffScan { + level := coef[(yS<<2+int(pos.y))*n+xS<<2+int(pos.x)] + if level == -1<<31 { + return ErrInvalid + } + if level != 0 { + csbf[yS*(n>>2)+xS] = true + lastSubBlock, lastScanPos = i, k + } + } + } + + if lastSubBlock < 0 { + return ErrInvalid + } + + lastSB := sbScan[lastSubBlock] + lastPos := coeffScan[lastScanPos] + lastX, lastY := int(lastSB.x)<<2+int(lastPos.x), int(lastSB.y)<<2+int(lastPos.y) + if scanIdx == scanVer { + lastX, lastY = lastY, lastX + } + + xPrefix := encodeLastSigCoeffPrefix(w, ctxLastSignificantCoeffXPrefix, lastX, b.log2Size, b.cIdx) + yPrefix := encodeLastSigCoeffPrefix(w, ctxLastSignificantCoeffYPrefix, lastY, b.log2Size, b.cIdx) + encodeLastSigCoeffSuffix(w, lastX, xPrefix) + encodeLastSigCoeffSuffix(w, lastY, yPrefix) + + sbWidth := n >> 2 + var st residualState + + for i := lastSubBlock; i >= 0; i-- { + sb := sbScan[i] + xS, yS := int(sb.x), int(sb.y) + + inferSbDcSig := false + if i < lastSubBlock && i > 0 { + ctx := 0 + if xS+1 < sbWidth && csbf[yS*sbWidth+xS+1] { + ctx++ + } + if yS+1 < sbWidth && csbf[(yS+1)*sbWidth+xS] { + ctx++ + } + + base := ctxSignificantCoeffGroupFlag + if b.cIdx > 0 { + base += 2 + } + w.encodeBin(base+min(ctx, 1), boolToBit(csbf[yS*sbWidth+xS])) + inferSbDcSig = true + } + + if !csbf[yS*sbWidth+xS] && i != 0 { + continue + } + + prevCsbf := 0 + if xS+1 < sbWidth && csbf[yS*sbWidth+xS+1] { + prevCsbf++ + } + if yS+1 < sbWidth && csbf[(yS+1)*sbWidth+xS] { + prevCsbf += 2 + } + + start := numSbCoeff - 1 + if i == lastSubBlock { + start = lastScanPos - 1 + } + + var sig [numSbCoeff]bool + if i == lastSubBlock { + sig[lastScanPos] = true + } + + sigSet := newSigCtxSet(xS, yS, b.log2Size, b.cIdx, scanIdx, prevCsbf, false) + for k := start; k >= 0; k-- { + if k == 0 && inferSbDcSig && !anyTrue(sig[1:]) { + sig[0] = true + continue + } + + pos := coeffScan[k] + level := coef[(yS<<2+int(pos.y))*n+xS<<2+int(pos.x)] + sig[k] = level != 0 + w.encodeBin(ctxSignificantCoeffFlag+sigSet.at(int(pos.x), int(pos.y)), boolToBit(sig[k])) + } + + encodeSubBlockLevels(w, s, p, coef, b, sb, coeffScan, &sig, i, n, &st) + } + + return nil +} + +func encodeLastSigCoeffPrefix(w *cabacWriter, base, v, log2Size, cIdx int) int { + prefix := lastSigCoeffPrefix(v) + cMax := log2Size<<1 - 1 + for i := 0; i < prefix; i++ { + w.encodeBin(base+lastSigCoeffCtx(log2Size, cIdx, i), 1) + } + if prefix < cMax { + w.encodeBin(base+lastSigCoeffCtx(log2Size, cIdx, prefix), 0) + } + + return prefix +} + +func encodeLastSigCoeffSuffix(w *cabacWriter, v, prefix int) { + if prefix > 3 { + n := prefix>>1 - 1 + w.encodeBypassBits(uint32(v-((1<>uint(n))&1 +} + +func (w *cabacWriter) encodeCoeffAbsLevelRemaining(v int32, rice, rng int) { + if v < 3<> rice) { + w.encodeBypass(1) + } + w.encodeBypass(0) + w.encodeBypassBits(uint32(v)&((1<= ((1<<(k+1))+2)< 0 { + limit = 32 - rng + k = min(k, limit-3) + } + + prefix := k + 3 + for range prefix { + w.encodeBypass(1) + } + if prefix < limit { + w.encodeBypass(0) + } + + n := k + rice + if rng > 0 && prefix == limit { + n = rng + } + w.encodeBypassBits(uint32(v-((1< 0 && b.cIdx == 0 { + ctxSet = 2 + } + + lastCtx := 1 + + if st.started { + lastCtx = st.greater1Ctx + if lastCtx > 0 { + if st.lastGreater1 { + lastCtx = 0 + } else { + lastCtx++ + } + } + } + + if lastCtx == 0 { + ctxSet++ + } + + st.started = true + + // The passes visit the significant levels in the same order, so they are + // gathered once and then addressed by rank rather than by scan position. + var lev [numSbCoeff]int32 + + npos := 0 + origin := (int(sb.y)*n + int(sb.x)) << 2 + firstSig, lastSig := -1, -1 + + for k := numSbCoeff - 1; k >= 0; k-- { + if !sig[k] { + continue + } + + if lastSig < 0 { + lastSig = k + } + + firstSig = k + + sp := coeffScan[k] + lev[npos] = coef[origin+int(sp.y)*n+int(sp.x)] + npos++ + } + + if npos == 0 { + return + } + + signHidden := lastSig-firstSig > 3 && !b.transquantBypass + + var greater1 [numSbCoeff]bool + + lastGreater1, greater1Ctx := -1, 1 + + for i := range min(npos, 8) { + greater1[i] = absLevel(lev[i]) > 1 + + ctx := ctxSet*4 + min(3, greater1Ctx) + if b.cIdx > 0 { + ctx += 16 + } + + w.encodeBin(ctxCoeffAbsLevelGreater1Flag+ctx, boolToBit(greater1[i])) + st.greater1Ctx, st.lastGreater1 = greater1Ctx, greater1[i] + + if greater1[i] { + greater1Ctx = 0 + + if lastGreater1 < 0 { + lastGreater1 = i + } + } else if greater1Ctx > 0 { + greater1Ctx++ + } + } + + if lastGreater1 >= 0 { + ctx := ctxSet + if b.cIdx > 0 { + ctx += 4 + } + + w.encodeBin(ctxCoeffAbsLevelGreater2Flag+ctx, boolToBit(absLevel(lev[lastGreater1]) > 2)) + } + + for i := range npos { + if p.signDataHidingEnabled && signHidden && i == npos-1 { + continue + } + + w.encodeBypass(boolToBit(lev[i] < 0)) + } + + rice, rng := 0, s.coeffRange(b.cIdx) + + for i := range npos { + level := absLevel(lev[i]) + + base := int32(1) + if greater1[i] { + base++ + } + + if i == lastGreater1 && level > 2 { + base++ + } + + want := int32(1) + + if i < 8 { + want = 2 + if i == lastGreater1 { + want = 3 + } + } + + if base == want { + w.encodeCoeffAbsLevelRemaining(level-base, rice, rng) + + if level > 3<>d.s.ctbLog2SizeY)*w + x>>d.s.ctbLog2SizeY + ts := int(d.rsToTs[rs]) + + mergeLeft, mergeUp := false, false + + if rs%w > 0 && rs-1 >= d.sliceAddrRs && d.tileID[ts] == d.tileID[d.rsToTs[rs-1]] { + mergeLeft = d.c.decodeBin(ctxSAOMergeFlag) != 0 + } + + if !mergeLeft && rs >= w && rs-w >= d.sliceAddrRs && + d.tileID[ts] == d.tileID[d.rsToTs[rs-w]] { + mergeUp = d.c.decodeBin(ctxSAOMergeFlag) != 0 + } + + ctb := rs + + if mergeLeft { + d.sao[ctb] = d.sao[rs-1] + + return + } + + if mergeUp { + d.sao[ctb] = d.sao[rs-w] + + return + } + + nComp := 1 + if d.s.chromaArrayType() != 0 { + nComp = 3 + } + + for cIdx := range nComp { + if cIdx == 0 && !d.sh.saoLuma { + continue + } + + if cIdx > 0 && !d.sh.saoChroma { + continue + } + + cMax := 1<<(min(d.pic.depth(cIdx), 10)-5) - 1 + + typeIdx := 0 + + if cIdx != 2 { + if d.c.decodeBin(ctxSAOTypeIDX) != 0 { + typeIdx = saoBand + if d.c.decodeBypass() != 0 { + typeIdx = saoEdge + } + } + + d.saoType[cIdx] = typeIdx + } else { + typeIdx = d.saoType[1] + } + + p := &d.sao[ctb][cIdx] + p.typeIdx = typeIdx + + if typeIdx == saoOff { + continue + } + + var offAbs [4]int + + for i := range 4 { + v := 0 + for v < cMax && d.c.decodeBypass() != 0 { + v++ + } + + offAbs[i] = v + } + + // 7.4.9.3 scales every offset by the shift the picture carries. + scale := d.p.log2SaoOffsetScaleLuma + if cIdx > 0 { + scale = d.p.log2SaoOffsetScaleChroma + } + + if typeIdx == saoBand { + for i := range 4 { + p.offset[i] = int32(offAbs[i]) << scale + + if offAbs[i] != 0 && d.c.decodeBypass() != 0 { + p.offset[i] = -p.offset[i] + } + } + + p.band = int(d.c.decodeBypassBits(5)) + + continue + } + + // Edge offsets are signed by position: the first two are positive, + // the last two negative. + for i := range 4 { + p.offset[i] = int32(offAbs[i]) << scale + if i >= 2 { + p.offset[i] = -p.offset[i] + } + } + + if cIdx != 2 { + d.eoClass[cIdx] = int(d.c.decodeBypassBits(2)) + } + + p.class = d.eoClass[min(cIdx, 1)] + } +} + +// grow returns a buffer of at least n elements, reusing b when it is big +// enough. The loop filters run once per picture over the whole plane. +func grow[T any](b []T, n int) []T { + if cap(b) < n { + return make([]T, n) + } + + return b[:n] +} + +// applySAO is 8.7.3. It reads the picture as deblocking left it, so the source +// is copied first and every offset is taken from the unfiltered neighbourhood. +func (d *ctuDecoder) applySAO() { + nComp := 1 + if d.s.chromaArrayType() != 0 { + nComp = 3 + } + + for cIdx := range nComp { + if d.pic.deep() { + plane, stride := d.pic.plane16(cIdx) + d.saoSrc16 = grow(d.saoSrc16, len(plane)) + saoPlane(d, plane, stride, cIdx, d.saoSrc16) + } else { + plane, stride := d.pic.plane8(cIdx) + d.saoSrc8 = grow(d.saoSrc8, len(plane)) + saoPlane(d, plane, stride, cIdx, d.saoSrc8) + } + } +} + +func saoPlane[P pixel](d *ctuDecoder, plane []P, stride, cIdx int, src []P) { + sw, sh := 1, 1 + if cIdx > 0 { + sw, sh = d.s.subWidthC, d.s.subHeightC + } + + w, h := d.pic.Width/sw, d.pic.Height/sh + if w == 0 || h == 0 { + return + } + + maxV := int32(1)<= h || y+b[1] < 0 || y+b[1] >= h { + continue + } + + xlo = max(xlo, max(-a[0], -b[0])) + xhi = min(min(x0+ctbW, w), w-max(a[0], b[0])) + } + + // Only the first and last row and column of a coding tree block + // can reach across one, so the interior needs no availability test. + ilo, ihi := xlo, xhi + + if !band { + if a[1] != 0 && (y == y0 || y == min(y0+ctbH, h)-1) { + ihi = ilo + } else if a[0] != 0 { + ilo = min(max(xlo, x0+1), xhi) + ihi = max(min(xhi, x0+ctbW-1), ilo) + } + } + + for x := xlo; x < ilo; x++ { + saoSample(d, plane, src, stride, x, y, w, h, sw, sh, p, a, b, shift, maxV) + } + + for x := ihi; x < xhi; x++ { + saoSample(d, plane, src, stride, x, y, w, h, sw, sh, p, a, b, shift, maxV) + } + + // The next sample whose transform block may differ. + next := func(x int) int { + return min((x*sw>>d.minTbLog2+1)<>shift) - p.band) & 31; k < 4 { + plane[y*stride+x] = P(clip3(v+p.offset[k], 0, maxV)) + } + + return + } + + if x+a[0] < 0 || x+a[0] >= w || y+a[1] < 0 || y+a[1] >= h || + x+b[0] < 0 || x+b[0] >= w || y+b[1] < 0 || y+b[1] >= h { + return + } + + if !d.saoNeighbour(x, y, a, sw, sh) || !d.saoNeighbour(x, y, b, sw, sh) { + return + } + + va := int32(src[(y+a[1])*stride+x+a[0]]) + vb := int32(src[(y+b[1])*stride+x+b[0]]) + + if cat := sign(v-va) + sign(v-vb); cat != 0 { + k := cat + 2 + if cat > 0 { + k = cat + 1 + } + + plane[y*stride+x] = P(clip3(v+p.offset[k], 0, maxV)) + } +} + +// saoBandRow is the band offset of 8.7.3 over a run of samples. +func saoBandRow[P pixel](dst, src []P, n, band int, offset *[4]int32, shift int, maxV int32) { + for i, s := range src[:n] { + v := int32(s) + + if k := (int(v>>shift) - band) & 31; k < 4 { + dst[i] = P(clip3(v+offset[k], 0, maxV)) + } + } +} + +// saoEdgeRow is the edge offset of 8.7.3 over a run of samples, with the two +// neighbours the class compares against passed as their own rows. +func saoEdgeRow[P pixel](dst, src, na, nb []P, n int, offset *[4]int32, maxV int32) { + for i, s := range src[:n] { + v := int32(s) + + cat := sign(v-int32(na[i])) + sign(v-int32(nb[i])) + if cat == 0 { + continue + } + + k := cat + 2 + if cat > 0 { + k = cat + 1 + } + + dst[i] = P(clip3(v+offset[k], 0, maxV)) + } +} + +// saoNeighbour is the availability part of 8.7.3: an edge offset sample is +// left alone when the neighbour it compares against sits across a tile or +// slice boundary that filtering may not cross. +func (d *ctuDecoder) saoNeighbour(x, y int, off [2]int, sw, sh int) bool { + return d.filterEdge(x*sw, y*sh, (x+off[0])*sw, (y+off[1])*sh) +} diff --git a/third_party/h265/hevc/saoenc.go b/third_party/h265/hevc/saoenc.go new file mode 100644 index 00000000..3becac31 --- /dev/null +++ b/third_party/h265/hevc/saoenc.go @@ -0,0 +1,449 @@ +package hevc + +// saoCand is one candidate: the squared error it saves against the bins it takes. +type saoCand struct { + p saoParams + gain int64 + bins int64 +} + +// saoMaxOffset is cMax of 7.3.8.3. +func saoMaxOffset(bitDepth int) int32 { + return int32(1)<<(min(bitDepth, 10)-5) - 1 +} + +// decideSAO fits every coding tree block against the picture deblocking left, +// which the second coding pass then writes in front of each of them. +func (e *intraEncoder[P]) decideSAO() { + rows, cols := ctbCount(e.height), ctbCount(e.width) + e.sao = regrow(e.sao, rows*cols) + e.saoLuma, e.saoChroma = false, false + + nComp := 1 + if e.s.chromaArrayType() != 0 { + nComp = 3 + } + + for ctb := range e.sao { + x0, y0 := ctb%cols*64, ctb/cols*64 + + for cIdx := range nComp { + e.sao[ctb][cIdx] = saoParams{} + } + + e.sao[ctb][0] = e.saoFor(0, x0, y0).p + e.saoLuma = e.saoLuma || e.sao[ctb][0].typeIdx != saoOff + + if nComp == 1 { + continue + } + + // 7.3.8.3 gives Cb and Cr one type and one class between them. + cb, cr := e.saoChromaPair(x0, y0) + e.sao[ctb][1], e.sao[ctb][2] = cb, cr + e.saoChroma = e.saoChroma || cb.typeIdx != saoOff + } + + // A component nothing uses leaves the slice header, and its syntax with it. + if !e.saoLuma { + for ctb := range e.sao { + e.sao[ctb][0] = saoParams{} + } + } + + if !e.saoChroma { + for ctb := range e.sao { + e.sao[ctb][1], e.sao[ctb][2] = saoParams{}, saoParams{} + } + } + + e.saoOn = e.saoLuma || e.saoChroma +} + +// saoBin is one category's error, summed and counted. +type saoBin struct{ sum, cnt int64 } + +// saoStats is what one walk of a coding tree block tells all thirty six +// candidates. +type saoStats struct { + band [32]saoBin + edge [4][4]saoBin +} + +// saoFor is the best parameters for one component of one coding tree block. +func (e *intraEncoder[P]) saoFor(cIdx, x0, y0 int) saoCand { + e.saoGather(cIdx, x0, y0) + + var best saoCand + + for band := range 32 { + if c := e.saoBandCand(cIdx, band); e.saoBetter(c, best) { + best = c + } + } + + for class := range 4 { + if c := e.saoEdgeCand(cIdx, class); e.saoBetter(c, best) { + best = c + } + } + + return best +} + +// saoChromaPair picks one type and class for both chroma components on their +// combined cost, with the offsets each of them wants. +func (e *intraEncoder[P]) saoChromaPair(x0, y0 int) (saoParams, saoParams) { + var ( + best saoCand + bestCb, bestCr saoParams + cb [32]saoCand + cbEdge [4]saoCand + ) + + e.saoGather(1, x0, y0) + + for band := range 32 { + cb[band] = e.saoBandCand(1, band) + } + + for class := range 4 { + cbEdge[class] = e.saoEdgeCand(1, class) + } + + e.saoGather(2, x0, y0) + + try := func(a, b saoCand) { + joint := saoCand{p: a.p, gain: a.gain + b.gain, bins: a.bins + b.bins} + + if e.saoBetter(joint, best) { + best, bestCb, bestCr = joint, a.p, b.p + } + } + + for band := range 32 { + try(cb[band], e.saoBandCand(2, band)) + } + + for class := range 4 { + try(cbEdge[class], e.saoEdgeCand(2, class)) + } + + return bestCb, bestCr +} + +// saoBetter weighs a candidate against the one held and against doing nothing. +func (e *intraEncoder[P]) saoBetter(c, best saoCand) bool { + if c.gain <= 0 { + return false + } + + return c.gain< + best.gain< 0 { + sw, sh = e.shiftW, e.shiftH + } + + cx0, cy0 := x0>>sw, y0>>sh + cx1, cy1 := min(cx0+64>>sw, w), min(cy0+64>>sh, h) + + src, rec := e.src[cIdx], e.recon[cIdx] + + for y := cy0; y < cy1; y++ { + row := y * stride + + // Only the outermost row and column reach outside the plane. + inner := y > 0 && y < h-1 + + for x := cx0; x < cx1; x++ { + v := int32(rec[row+x]) + d := int64(src[row+x]) - int64(v) + + b := &st.band[v>>shift] + b.sum += d + b.cnt++ + + if !inner || x == 0 || x == w-1 { + continue + } + + for class := range 4 { + a, o := eoOffsets[class][0], eoOffsets[class][1] + + va := int32(rec[(y+a[1])*stride+x+a[0]]) + vb := int32(rec[(y+o[1])*stride+x+o[0]]) + + cat := sign(v-va) + sign(v-vb) + if cat == 0 { + continue + } + + k := cat + 2 + if cat > 0 { + k = cat + 1 + } + + c := &st.edge[class][k] + c.sum += d + c.cnt++ + } + } + } +} + +// planeSize is the sample count of a component either way. +func (e *intraEncoder[P]) planeSize(cIdx int) (int, int) { + if cIdx == 0 { + return e.width, e.height + } + + return e.width >> e.shiftW, e.height >> e.shiftH +} + +// writeSAO is the sao syntax of 7.3.8.3, in front of one coding tree block. +func (e *intraEncoder[P]) writeSAO(w *cabacWriter, ctb, cols int) { + if !e.saoOn { + return + } + + if ctb%cols > 0 { + w.encodeBin(ctxSAOMergeFlag, 0) + } + + if ctb >= cols { + w.encodeBin(ctxSAOMergeFlag, 0) + } + + nComp := 1 + if e.s.chromaArrayType() != 0 { + nComp = 3 + } + + top := saoMaxOffset(e.bitDepth) + + for cIdx := range nComp { + if cIdx == 0 && !e.saoLuma { + continue + } + + if cIdx > 0 && !e.saoChroma { + continue + } + + p := &e.sao[ctb][cIdx] + + if cIdx != 2 { + w.encodeBin(ctxSAOTypeIDX, boolToBit(p.typeIdx != saoOff)) + + if p.typeIdx != saoOff { + w.encodeBypass(boolToBit(p.typeIdx == saoEdge)) + } + } + + if p.typeIdx == saoOff { + continue + } + + for k := range 4 { + v := p.offset[k] + if v < 0 { + v = -v + } + + for i := int32(0); i < v; i++ { + w.encodeBypass(1) + } + + if v < top { + w.encodeBypass(0) + } + } + + if p.typeIdx == saoBand { + for k := range 4 { + if p.offset[k] != 0 { + w.encodeBypass(boolToBit(p.offset[k] < 0)) + } + } + + w.encodeBypassBits(uint32(p.band), 5) + + continue + } + + if cIdx != 2 { + w.encodeBypassBits(uint32(p.class), 2) + } + } +} + +// applySAO is 8.7.3 over the reconstruction, so it stays what a decoder makes. +func (e *intraEncoder[P]) applySAO() { + if !e.saoOn { + return + } + + cols := ctbCount(e.width) + + nComp := 1 + if e.s.chromaArrayType() != 0 { + nComp = 3 + } + + for cIdx := range nComp { + if cIdx == 0 && !e.saoLuma || cIdx > 0 && !e.saoChroma { + continue + } + + stride := e.stride(cIdx) + w, h := e.planeSize(cIdx) + + e.saoSrc = regrow(e.saoSrc, len(e.recon[cIdx])) + copy(e.saoSrc, e.recon[cIdx]) + + maxV := int32(1)< 0 { + sw, sh = e.shiftW, e.shiftH + } + + for ctb := range e.sao { + p := &e.sao[ctb][cIdx] + if p.typeIdx == saoOff { + continue + } + + x0, y0 := ctb%cols*64>>sw, ctb/cols*64>>sh + a, b := eoOffsets[p.class][0], eoOffsets[p.class][1] + + for y := y0; y < min(y0+64>>sh, h); y++ { + for x := x0; x < min(x0+64>>sw, w); x++ { + v := int32(e.saoSrc[y*stride+x]) + + if p.typeIdx == saoBand { + if k := (int(v>>shift) - p.band) & 31; k < 4 { + e.recon[cIdx][y*stride+x] = P(clip3(v+p.offset[k], 0, maxV)) + } + + continue + } + + if x+a[0] < 0 || x+a[0] >= w || y+a[1] < 0 || y+a[1] >= h || + x+b[0] < 0 || x+b[0] >= w || y+b[1] < 0 || y+b[1] >= h { + continue + } + + va := int32(e.saoSrc[(y+a[1])*stride+x+a[0]]) + vb := int32(e.saoSrc[(y+b[1])*stride+x+b[0]]) + + cat := sign(v-va) + sign(v-vb) + if cat == 0 { + continue + } + + k := cat + 2 + if cat > 0 { + k = cat + 1 + } + + e.recon[cIdx][y*stride+x] = P(clip3(v+p.offset[k], 0, maxV)) + } + } + } + } +} diff --git a/third_party/h265/hevc/satd_amd64.s b/third_party/h265/hevc/satd_amd64.s new file mode 100644 index 00000000..0c1763aa --- /dev/null +++ b/third_party/h265/hevc/satd_amd64.s @@ -0,0 +1,193 @@ +//go:build amd64 && !noasm + +#include "textflag.h" + +// func satd16x8AVX2(src *uint8, srcStride int, pred *uint8, predStride int) int64 +// +// satd over a sixteen wide and eight tall strip. Y0 to Y7 hold the rows as +// sixteen int16, one 8x8 per lane, and every butterfly and unpack is lane local +// so the two ride through together. Differences reach 255 and each pass scales +// by eight, which int16 holds. +TEXT ·satd16x8AVX2(SB), NOSPLIT, $0-40 + MOVQ src+0(FP), SI + MOVQ srcStride+8(FP), R8 + MOVQ pred+16(FP), DI + MOVQ predStride+24(FP), R9 + + VPMOVZXBW (SI), Y0 + VPMOVZXBW (DI), Y8 + VPSUBW Y8, Y0, Y0 + + ADDQ R8, SI + ADDQ R9, DI + VPMOVZXBW (SI), Y1 + VPMOVZXBW (DI), Y8 + VPSUBW Y8, Y1, Y1 + + ADDQ R8, SI + ADDQ R9, DI + VPMOVZXBW (SI), Y2 + VPMOVZXBW (DI), Y8 + VPSUBW Y8, Y2, Y2 + + ADDQ R8, SI + ADDQ R9, DI + VPMOVZXBW (SI), Y3 + VPMOVZXBW (DI), Y8 + VPSUBW Y8, Y3, Y3 + + ADDQ R8, SI + ADDQ R9, DI + VPMOVZXBW (SI), Y4 + VPMOVZXBW (DI), Y8 + VPSUBW Y8, Y4, Y4 + + ADDQ R8, SI + ADDQ R9, DI + VPMOVZXBW (SI), Y5 + VPMOVZXBW (DI), Y8 + VPSUBW Y8, Y5, Y5 + + ADDQ R8, SI + ADDQ R9, DI + VPMOVZXBW (SI), Y6 + VPMOVZXBW (DI), Y8 + VPSUBW Y8, Y6, Y6 + + ADDQ R8, SI + ADDQ R9, DI + VPMOVZXBW (SI), Y7 + VPMOVZXBW (DI), Y8 + VPSUBW Y8, Y7, Y7 + + VPADDW Y4, Y0, Y8 + VPSUBW Y4, Y0, Y12 + VPADDW Y5, Y1, Y9 + VPSUBW Y5, Y1, Y13 + VPADDW Y6, Y2, Y10 + VPSUBW Y6, Y2, Y14 + VPADDW Y7, Y3, Y11 + VPSUBW Y7, Y3, Y15 + + VPADDW Y10, Y8, Y0 + VPADDW Y11, Y9, Y1 + VPSUBW Y10, Y8, Y2 + VPSUBW Y11, Y9, Y3 + VPADDW Y14, Y12, Y4 + VPADDW Y15, Y13, Y5 + VPSUBW Y14, Y12, Y6 + VPSUBW Y15, Y13, Y7 + + VPADDW Y1, Y0, Y8 + VPSUBW Y1, Y0, Y9 + VPADDW Y3, Y2, Y10 + VPSUBW Y3, Y2, Y11 + VPADDW Y5, Y4, Y12 + VPSUBW Y5, Y4, Y13 + VPADDW Y7, Y6, Y14 + VPSUBW Y7, Y6, Y15 + + // Transpose within each lane, so the second pass lands on the rows. + VPUNPCKLWD Y9, Y8, Y0 + VPUNPCKHWD Y9, Y8, Y1 + VPUNPCKLWD Y11, Y10, Y2 + VPUNPCKHWD Y11, Y10, Y3 + VPUNPCKLWD Y13, Y12, Y4 + VPUNPCKHWD Y13, Y12, Y5 + VPUNPCKLWD Y15, Y14, Y6 + VPUNPCKHWD Y15, Y14, Y7 + + VPUNPCKLDQ Y2, Y0, Y8 + VPUNPCKHDQ Y2, Y0, Y9 + VPUNPCKLDQ Y3, Y1, Y10 + VPUNPCKHDQ Y3, Y1, Y11 + VPUNPCKLDQ Y6, Y4, Y12 + VPUNPCKHDQ Y6, Y4, Y13 + VPUNPCKLDQ Y7, Y5, Y14 + VPUNPCKHDQ Y7, Y5, Y15 + + VPUNPCKLQDQ Y12, Y8, Y0 + VPUNPCKHQDQ Y12, Y8, Y1 + VPUNPCKLQDQ Y13, Y9, Y2 + VPUNPCKHQDQ Y13, Y9, Y3 + VPUNPCKLQDQ Y14, Y10, Y4 + VPUNPCKHQDQ Y14, Y10, Y5 + VPUNPCKLQDQ Y15, Y11, Y6 + VPUNPCKHQDQ Y15, Y11, Y7 + + VPADDW Y4, Y0, Y8 + VPSUBW Y4, Y0, Y12 + VPADDW Y5, Y1, Y9 + VPSUBW Y5, Y1, Y13 + VPADDW Y6, Y2, Y10 + VPSUBW Y6, Y2, Y14 + VPADDW Y7, Y3, Y11 + VPSUBW Y7, Y3, Y15 + + VPADDW Y10, Y8, Y0 + VPADDW Y11, Y9, Y1 + VPSUBW Y10, Y8, Y2 + VPSUBW Y11, Y9, Y3 + VPADDW Y14, Y12, Y4 + VPADDW Y15, Y13, Y5 + VPSUBW Y14, Y12, Y6 + VPSUBW Y15, Y13, Y7 + + VPADDW Y1, Y0, Y8 + VPSUBW Y1, Y0, Y9 + VPADDW Y3, Y2, Y10 + VPSUBW Y3, Y2, Y11 + VPADDW Y5, Y4, Y12 + VPSUBW Y5, Y4, Y13 + VPADDW Y7, Y6, Y14 + VPSUBW Y7, Y6, Y15 + + // The pairwise multiply by one widens as it adds. + MOVL $0x00010001, AX + VMOVD AX, X1 + VPBROADCASTD X1, Y1 + + VPABSW Y8, Y8 + VPMADDWD Y1, Y8, Y0 + + VPABSW Y9, Y9 + VPMADDWD Y1, Y9, Y9 + VPADDD Y9, Y0, Y0 + + VPABSW Y10, Y10 + VPMADDWD Y1, Y10, Y10 + VPADDD Y10, Y0, Y0 + + VPABSW Y11, Y11 + VPMADDWD Y1, Y11, Y11 + VPADDD Y11, Y0, Y0 + + VPABSW Y12, Y12 + VPMADDWD Y1, Y12, Y12 + VPADDD Y12, Y0, Y0 + + VPABSW Y13, Y13 + VPMADDWD Y1, Y13, Y13 + VPADDD Y13, Y0, Y0 + + VPABSW Y14, Y14 + VPMADDWD Y1, Y14, Y14 + VPADDD Y14, Y0, Y0 + + VPABSW Y15, Y15 + VPMADDWD Y1, Y15, Y15 + VPADDD Y15, Y0, Y0 + + VEXTRACTI128 $1, Y0, X1 + VPADDD X1, X0, X0 + VPSHUFD $0x4e, X0, X1 + VPADDD X1, X0, X0 + VPSHUFD $0xb1, X0, X1 + VPADDD X1, X0, X0 + + VMOVD X0, AX + MOVLQZX AX, AX + MOVQ AX, ret+32(FP) + + VZEROUPPER + RET diff --git a/third_party/h265/hevc/satd_arm64.s b/third_party/h265/hevc/satd_arm64.s new file mode 100644 index 00000000..8ea26786 --- /dev/null +++ b/third_party/h265/hevc/satd_arm64.s @@ -0,0 +1,199 @@ +//go:build arm64 && !noasm + +#include "textflag.h" + +#define ABS8H(Vd, Vn) WORD $(0x4e60b800 | ((Vn) << 5) | (Vd)) +#define UADALP4S(Vd, Vn) WORD $(0x6e606800 | ((Vn) << 5) | (Vd)) + +// func satd16x8NEON(src *uint8, srcStride int, pred *uint8, predStride int) int64 +// +// satd over a sixteen wide and eight tall strip, one 8x8 at a time: a row of +// eight int16 is exactly one register, and both halves accumulate into the same +// pairwise sum. Differences reach 255 and each pass scales by eight, which +// int16 holds. +TEXT ·satd16x8NEON(SB), NOSPLIT, $0-40 + MOVD src+0(FP), R0 + MOVD srcStride+8(FP), R1 + MOVD pred+16(FP), R2 + MOVD predStride+24(FP), R3 + + VMOVI $0, V16.B16 + + MOVD $2, R4 + +half: + MOVD R0, R5 + MOVD R2, R6 + + VLD1 (R5), [V17.B8] + VUXTL V17.B8, V0.H8 + VLD1 (R6), [V17.B8] + VUXTL V17.B8, V17.H8 + VSUB V17.H8, V0.H8, V0.H8 + ADD R1, R5 + ADD R3, R6 + + VLD1 (R5), [V17.B8] + VUXTL V17.B8, V1.H8 + VLD1 (R6), [V17.B8] + VUXTL V17.B8, V17.H8 + VSUB V17.H8, V1.H8, V1.H8 + ADD R1, R5 + ADD R3, R6 + + VLD1 (R5), [V17.B8] + VUXTL V17.B8, V2.H8 + VLD1 (R6), [V17.B8] + VUXTL V17.B8, V17.H8 + VSUB V17.H8, V2.H8, V2.H8 + ADD R1, R5 + ADD R3, R6 + + VLD1 (R5), [V17.B8] + VUXTL V17.B8, V3.H8 + VLD1 (R6), [V17.B8] + VUXTL V17.B8, V17.H8 + VSUB V17.H8, V3.H8, V3.H8 + ADD R1, R5 + ADD R3, R6 + + VLD1 (R5), [V17.B8] + VUXTL V17.B8, V4.H8 + VLD1 (R6), [V17.B8] + VUXTL V17.B8, V17.H8 + VSUB V17.H8, V4.H8, V4.H8 + ADD R1, R5 + ADD R3, R6 + + VLD1 (R5), [V17.B8] + VUXTL V17.B8, V5.H8 + VLD1 (R6), [V17.B8] + VUXTL V17.B8, V17.H8 + VSUB V17.H8, V5.H8, V5.H8 + ADD R1, R5 + ADD R3, R6 + + VLD1 (R5), [V17.B8] + VUXTL V17.B8, V6.H8 + VLD1 (R6), [V17.B8] + VUXTL V17.B8, V17.H8 + VSUB V17.H8, V6.H8, V6.H8 + ADD R1, R5 + ADD R3, R6 + + VLD1 (R5), [V17.B8] + VUXTL V17.B8, V7.H8 + VLD1 (R6), [V17.B8] + VUXTL V17.B8, V17.H8 + VSUB V17.H8, V7.H8, V7.H8 + + VADD V4.H8, V0.H8, V8.H8 + VSUB V4.H8, V0.H8, V12.H8 + VADD V5.H8, V1.H8, V9.H8 + VSUB V5.H8, V1.H8, V13.H8 + VADD V6.H8, V2.H8, V10.H8 + VSUB V6.H8, V2.H8, V14.H8 + VADD V7.H8, V3.H8, V11.H8 + VSUB V7.H8, V3.H8, V15.H8 + + VADD V10.H8, V8.H8, V0.H8 + VADD V11.H8, V9.H8, V1.H8 + VSUB V10.H8, V8.H8, V2.H8 + VSUB V11.H8, V9.H8, V3.H8 + VADD V14.H8, V12.H8, V4.H8 + VADD V15.H8, V13.H8, V5.H8 + VSUB V14.H8, V12.H8, V6.H8 + VSUB V15.H8, V13.H8, V7.H8 + + VADD V1.H8, V0.H8, V8.H8 + VSUB V1.H8, V0.H8, V9.H8 + VADD V3.H8, V2.H8, V10.H8 + VSUB V3.H8, V2.H8, V11.H8 + VADD V5.H8, V4.H8, V12.H8 + VSUB V5.H8, V4.H8, V13.H8 + VADD V7.H8, V6.H8, V14.H8 + VSUB V7.H8, V6.H8, V15.H8 + + // Transpose, so the second pass lands on the rows. + VTRN1 V9.H8, V8.H8, V0.H8 + VTRN2 V9.H8, V8.H8, V1.H8 + VTRN1 V11.H8, V10.H8, V2.H8 + VTRN2 V11.H8, V10.H8, V3.H8 + VTRN1 V13.H8, V12.H8, V4.H8 + VTRN2 V13.H8, V12.H8, V5.H8 + VTRN1 V15.H8, V14.H8, V6.H8 + VTRN2 V15.H8, V14.H8, V7.H8 + + VTRN1 V2.S4, V0.S4, V8.S4 + VTRN2 V2.S4, V0.S4, V10.S4 + VTRN1 V3.S4, V1.S4, V9.S4 + VTRN2 V3.S4, V1.S4, V11.S4 + VTRN1 V6.S4, V4.S4, V12.S4 + VTRN2 V6.S4, V4.S4, V14.S4 + VTRN1 V7.S4, V5.S4, V13.S4 + VTRN2 V7.S4, V5.S4, V15.S4 + + VTRN1 V12.D2, V8.D2, V0.D2 + VTRN2 V12.D2, V8.D2, V4.D2 + VTRN1 V13.D2, V9.D2, V1.D2 + VTRN2 V13.D2, V9.D2, V5.D2 + VTRN1 V14.D2, V10.D2, V2.D2 + VTRN2 V14.D2, V10.D2, V6.D2 + VTRN1 V15.D2, V11.D2, V3.D2 + VTRN2 V15.D2, V11.D2, V7.D2 + + VADD V4.H8, V0.H8, V8.H8 + VSUB V4.H8, V0.H8, V12.H8 + VADD V5.H8, V1.H8, V9.H8 + VSUB V5.H8, V1.H8, V13.H8 + VADD V6.H8, V2.H8, V10.H8 + VSUB V6.H8, V2.H8, V14.H8 + VADD V7.H8, V3.H8, V11.H8 + VSUB V7.H8, V3.H8, V15.H8 + + VADD V10.H8, V8.H8, V0.H8 + VADD V11.H8, V9.H8, V1.H8 + VSUB V10.H8, V8.H8, V2.H8 + VSUB V11.H8, V9.H8, V3.H8 + VADD V14.H8, V12.H8, V4.H8 + VADD V15.H8, V13.H8, V5.H8 + VSUB V14.H8, V12.H8, V6.H8 + VSUB V15.H8, V13.H8, V7.H8 + + VADD V1.H8, V0.H8, V8.H8 + VSUB V1.H8, V0.H8, V9.H8 + VADD V3.H8, V2.H8, V10.H8 + VSUB V3.H8, V2.H8, V11.H8 + VADD V5.H8, V4.H8, V12.H8 + VSUB V5.H8, V4.H8, V13.H8 + VADD V7.H8, V6.H8, V14.H8 + VSUB V7.H8, V6.H8, V15.H8 + + ABS8H(8, 8) + UADALP4S(16, 8) + ABS8H(9, 9) + UADALP4S(16, 9) + ABS8H(10, 10) + UADALP4S(16, 10) + ABS8H(11, 11) + UADALP4S(16, 11) + ABS8H(12, 12) + UADALP4S(16, 12) + ABS8H(13, 13) + UADALP4S(16, 13) + ABS8H(14, 14) + UADALP4S(16, 14) + ABS8H(15, 15) + UADALP4S(16, 15) + + ADD $8, R0 + ADD $8, R2 + + SUB $1, R4 + CBNZ R4, half + + VADDV V16.S4, V17 + VMOV V17.S[0], R5 + + MOVD R5, ret+32(FP) + RET diff --git a/third_party/h265/hevc/satd_riscv64.s b/third_party/h265/hevc/satd_riscv64.s new file mode 100644 index 00000000..d10d6a3e --- /dev/null +++ b/third_party/h265/hevc/satd_riscv64.s @@ -0,0 +1,236 @@ +//go:build riscv64 && riscv64.rva23u64 && !noasm + +#include "textflag.h" + +// func satd16x8RVV(src *uint8, srcStride int, pred *uint8, predStride int) int64 +// +// satd over a sixteen wide and eight tall strip, one 8x8 at a time. There is no +// shuffle network: the rows go back out with the strided store and come in +// again contiguously, which is the transpose. Differences reach 255 and each +// pass scales by eight, which int16 holds. +TEXT ·satd16x8RVV(SB), NOSPLIT, $128-40 + MOV src+0(FP), X10 + MOV srcStride+8(FP), X11 + MOV pred+16(FP), X12 + MOV predStride+24(FP), X13 + + MOV $buf-128(SP), X14 + MOV $16, X15 + + VSETIVLI $8, E32, M1, TA, MA, X0 + VMVVI $0, V20 + + MOV $2, X16 + +half: + MOV X10, X5 + MOV X12, X6 + + VSETIVLI $8, E16, M1, TA, MA, X0 + + VLE8V (X5), V16 + VLE8V (X6), V17 + VZEXTVF2 V16, V18 + VZEXTVF2 V17, V19 + VSUBVV V19, V18, V0 + ADD X11, X5 + ADD X13, X6 + + VLE8V (X5), V16 + VLE8V (X6), V17 + VZEXTVF2 V16, V18 + VZEXTVF2 V17, V19 + VSUBVV V19, V18, V1 + ADD X11, X5 + ADD X13, X6 + + VLE8V (X5), V16 + VLE8V (X6), V17 + VZEXTVF2 V16, V18 + VZEXTVF2 V17, V19 + VSUBVV V19, V18, V2 + ADD X11, X5 + ADD X13, X6 + + VLE8V (X5), V16 + VLE8V (X6), V17 + VZEXTVF2 V16, V18 + VZEXTVF2 V17, V19 + VSUBVV V19, V18, V3 + ADD X11, X5 + ADD X13, X6 + + VLE8V (X5), V16 + VLE8V (X6), V17 + VZEXTVF2 V16, V18 + VZEXTVF2 V17, V19 + VSUBVV V19, V18, V4 + ADD X11, X5 + ADD X13, X6 + + VLE8V (X5), V16 + VLE8V (X6), V17 + VZEXTVF2 V16, V18 + VZEXTVF2 V17, V19 + VSUBVV V19, V18, V5 + ADD X11, X5 + ADD X13, X6 + + VLE8V (X5), V16 + VLE8V (X6), V17 + VZEXTVF2 V16, V18 + VZEXTVF2 V17, V19 + VSUBVV V19, V18, V6 + ADD X11, X5 + ADD X13, X6 + + VLE8V (X5), V16 + VLE8V (X6), V17 + VZEXTVF2 V16, V18 + VZEXTVF2 V17, V19 + VSUBVV V19, V18, V7 + + VADDVV V4, V0, V8 + VSUBVV V4, V0, V12 + VADDVV V5, V1, V9 + VSUBVV V5, V1, V13 + VADDVV V6, V2, V10 + VSUBVV V6, V2, V14 + VADDVV V7, V3, V11 + VSUBVV V7, V3, V15 + + VADDVV V10, V8, V0 + VADDVV V11, V9, V1 + VSUBVV V10, V8, V2 + VSUBVV V11, V9, V3 + VADDVV V14, V12, V4 + VADDVV V15, V13, V5 + VSUBVV V14, V12, V6 + VSUBVV V15, V13, V7 + + VADDVV V1, V0, V8 + VSUBVV V1, V0, V9 + VADDVV V3, V2, V10 + VSUBVV V3, V2, V11 + VADDVV V5, V4, V12 + VSUBVV V5, V4, V13 + VADDVV V7, V6, V14 + VSUBVV V7, V6, V15 + + // Scatter down the buffer's columns, then read the rows back. + MOV X14, X18 + VSSE16V V8, X15, (X18) + ADD $2, X18 + VSSE16V V9, X15, (X18) + ADD $2, X18 + VSSE16V V10, X15, (X18) + ADD $2, X18 + VSSE16V V11, X15, (X18) + ADD $2, X18 + VSSE16V V12, X15, (X18) + ADD $2, X18 + VSSE16V V13, X15, (X18) + ADD $2, X18 + VSSE16V V14, X15, (X18) + ADD $2, X18 + VSSE16V V15, X15, (X18) + + MOV X14, X18 + VLE16V (X18), V0 + ADD $16, X18 + VLE16V (X18), V1 + ADD $16, X18 + VLE16V (X18), V2 + ADD $16, X18 + VLE16V (X18), V3 + ADD $16, X18 + VLE16V (X18), V4 + ADD $16, X18 + VLE16V (X18), V5 + ADD $16, X18 + VLE16V (X18), V6 + ADD $16, X18 + VLE16V (X18), V7 + + VADDVV V4, V0, V8 + VSUBVV V4, V0, V12 + VADDVV V5, V1, V9 + VSUBVV V5, V1, V13 + VADDVV V6, V2, V10 + VSUBVV V6, V2, V14 + VADDVV V7, V3, V11 + VSUBVV V7, V3, V15 + + VADDVV V10, V8, V0 + VADDVV V11, V9, V1 + VSUBVV V10, V8, V2 + VSUBVV V11, V9, V3 + VADDVV V14, V12, V4 + VADDVV V15, V13, V5 + VSUBVV V14, V12, V6 + VSUBVV V15, V13, V7 + + VADDVV V1, V0, V8 + VSUBVV V1, V0, V9 + VADDVV V3, V2, V10 + VSUBVV V3, V2, V11 + VADDVV V5, V4, V12 + VSUBVV V5, V4, V13 + VADDVV V7, V6, V14 + VSUBVV V7, V6, V15 + + // The widening comes before the absolute value: the sums leave int16. + VSETIVLI $8, E32, M1, TA, MA, X0 + + VSEXTVF2 V8, V18 + VRSUBVX X0, V18, V19 + VMAXVV V18, V19, V19 + VADDVV V19, V20, V20 + + VSEXTVF2 V9, V18 + VRSUBVX X0, V18, V19 + VMAXVV V18, V19, V19 + VADDVV V19, V20, V20 + + VSEXTVF2 V10, V18 + VRSUBVX X0, V18, V19 + VMAXVV V18, V19, V19 + VADDVV V19, V20, V20 + + VSEXTVF2 V11, V18 + VRSUBVX X0, V18, V19 + VMAXVV V18, V19, V19 + VADDVV V19, V20, V20 + + VSEXTVF2 V12, V18 + VRSUBVX X0, V18, V19 + VMAXVV V18, V19, V19 + VADDVV V19, V20, V20 + + VSEXTVF2 V13, V18 + VRSUBVX X0, V18, V19 + VMAXVV V18, V19, V19 + VADDVV V19, V20, V20 + + VSEXTVF2 V14, V18 + VRSUBVX X0, V18, V19 + VMAXVV V18, V19, V19 + VADDVV V19, V20, V20 + + VSEXTVF2 V15, V18 + VRSUBVX X0, V18, V19 + VMAXVV V18, V19, V19 + VADDVV V19, V20, V20 + + ADD $8, X10 + ADD $8, X12 + + ADD $-1, X16 + BNEZ X16, half + + VMVVI $0, V21 + VREDSUMVS V21, V20, V22 + VMVXS V22, X17 + + MOV X17, ret+32(FP) + RET diff --git a/third_party/h265/hevc/scaling.go b/third_party/h265/hevc/scaling.go new file mode 100644 index 00000000..1757bc3d --- /dev/null +++ b/third_party/h265/hevc/scaling.go @@ -0,0 +1,151 @@ +package hevc + +var defaultScalingListIntra = [64]uint8{ + 16, 16, 16, 16, 17, 18, 21, 24, + 16, 16, 16, 16, 17, 19, 22, 25, + 16, 16, 17, 18, 20, 22, 25, 29, + 16, 16, 18, 21, 24, 27, 31, 36, + 17, 17, 20, 24, 30, 35, 41, 47, + 18, 19, 22, 27, 35, 44, 54, 65, + 21, 22, 25, 31, 41, 54, 70, 88, + 24, 25, 29, 36, 47, 65, 88, 115, +} + +var defaultScalingListInter = [64]uint8{ + 16, 16, 16, 16, 17, 18, 20, 24, + 16, 16, 16, 17, 18, 20, 24, 25, + 16, 16, 17, 18, 20, 24, 25, 28, + 16, 17, 18, 20, 24, 25, 28, 33, + 17, 18, 20, 24, 25, 28, 33, 41, + 18, 20, 24, 25, 28, 33, 41, 54, + 20, 24, 25, 28, 33, 41, 54, 71, + 24, 25, 28, 33, 41, 54, 71, 91, +} + +func defaultScalingList() scalingList { + var sl scalingList + + for m := range sl.sl[0] { + for i := range 16 { + sl.sl[0][m][i] = 16 + } + } + + for s := 1; s < maxScalingListSizes; s++ { + for m := range sl.sl[s] { + if m < 3 { + sl.sl[s][m] = defaultScalingListIntra + } else { + sl.sl[s][m] = defaultScalingListInter + } + } + } + + for s := range sl.dc { + for m := range sl.dc[s] { + sl.dc[s][m] = 16 + } + } + + return sl +} + +func parseScalingListData(c *getBits, sl *scalingList) error { + for sizeID := range maxScalingListSizes { + step := 1 + if sizeID == 3 { + step = 3 + } + + for matrixID := 0; matrixID < maxScalingListMats; matrixID += step { + if c.bit() == 0 { + delta := int(c.ue()) * step + if delta > matrixID { + return ErrInvalid + } + + if delta == 0 { + continue + } + + ref := matrixID - delta + sl.sl[sizeID][matrixID] = sl.sl[sizeID][ref] + + if sizeID > 1 { + sl.dc[sizeID-2][matrixID] = sl.dc[sizeID-2][ref] + } + + continue + } + + next := int32(8) + + if sizeID > 1 { + dc := c.se() + if dc < -7 || dc > 247 { + return ErrInvalid + } + + next = dc + 8 + sl.dc[sizeID-2][matrixID] = uint8(next) + } + + scan, size := scanOrder[3][scanDiag], 8 + if sizeID == 0 { + scan, size = scanOrder[2][scanDiag], 4 + } + + for _, p := range scan { + d := c.se() + if d < -128 || d > 127 { + return ErrInvalid + } + + next = (next + d + 256) % 256 + sl.sl[sizeID][matrixID][int(p.y)*size+int(p.x)] = uint8(next) + } + } + } + + if c.err { + return ErrInvalid + } + + return nil +} + +// factors is the ScalingFactor derivation of 7.4.5, replicating the eight by +// eight lists across the larger transform sizes and overriding the DC term. +func (sl *scalingList) factors() [maxScalingListSizes][maxScalingListMats][]uint8 { + var out [maxScalingListSizes][maxScalingListMats][]uint8 + + for sizeID := range maxScalingListSizes { + n := 4 << sizeID + rep := n / 8 + + for matrixID := range maxScalingListMats { + f := make([]uint8, n*n) + + if sizeID == 0 { + copy(f, sl.sl[0][matrixID][:16]) + out[sizeID][matrixID] = f + + continue + } + + for y := range n { + for x := range n { + f[y*n+x] = sl.sl[sizeID][matrixID][y/rep*8+x/rep] + } + } + + if sizeID > 1 { + f[0] = sl.dc[sizeID-2][matrixID] + } + + out[sizeID][matrixID] = f + } + } + + return out +} diff --git a/third_party/h265/hevc/scan.go b/third_party/h265/hevc/scan.go new file mode 100644 index 00000000..63cf9b4c --- /dev/null +++ b/third_party/h265/hevc/scan.go @@ -0,0 +1,63 @@ +package hevc + +type scanPos struct { + x, y uint8 +} + +const ( + scanDiag = iota + scanHor + scanVer + nScanOrders +) + +const maxScanLog2 = 5 + +func buildDiagScan(size int) []scanPos { + scan := make([]scanPos, 0, size*size) + + for d := range 2*size - 1 { + for y := min(d, size-1); y >= max(0, d-size+1); y-- { + scan = append(scan, scanPos{uint8(d - y), uint8(y)}) + } + } + + return scan +} + +func buildHorScan(size int) []scanPos { + scan := make([]scanPos, 0, size*size) + + for y := range size { + for x := range size { + scan = append(scan, scanPos{uint8(x), uint8(y)}) + } + } + + return scan +} + +func buildVerScan(size int) []scanPos { + scan := make([]scanPos, 0, size*size) + + for x := range size { + for y := range size { + scan = append(scan, scanPos{uint8(x), uint8(y)}) + } + } + + return scan +} + +// scanOrder is ScanOrder[log2BlockSize][scanIdx] of 6.5. +var scanOrder [maxScanLog2 + 1][nScanOrders][]scanPos + +func init() { + for k := range scanOrder { + size := 1 << k + + scanOrder[k][scanDiag] = buildDiagScan(size) + scanOrder[k][scanHor] = buildHorScan(size) + scanOrder[k][scanVer] = buildVerScan(size) + } +} diff --git a/third_party/h265/hevc/slice.go b/third_party/h265/hevc/slice.go new file mode 100644 index 00000000..90f02c2f --- /dev/null +++ b/third_party/h265/hevc/slice.go @@ -0,0 +1,575 @@ +package hevc + +import "math/bits" + +type sliceType uint8 + +const ( + sliceB sliceType = iota + sliceP + sliceI +) + +const maxRefs = 16 + +type longTermRPS struct { + numSps int + pocLsbLt []uint32 + usedByCurrPicLt []bool + deltaPocMsbPresn []bool + deltaPocMsbCycle []uint32 +} + +type refPicListModification struct { + flagL0 bool + listEntryL0 []uint32 + flagL1 bool + listEntryL1 []uint32 +} + +type predWeightTable struct { + lumaLog2Denom uint8 + chromaLog2Denom uint8 + lumaWeight [2][maxRefs]int16 + lumaOffset [2][maxRefs]int16 + chromaWeight [2][maxRefs][2]int16 + chromaOffset [2][maxRefs][2]int16 +} + +type sliceHeader struct { + nalType NALType + + firstSliceSegmentInPic bool + noOutputOfPriorPics bool + ppsID uint32 + dependentSliceSegment bool + sliceSegmentAddress uint32 + + sliceType sliceType + picOutputFlag bool + colourPlaneID uint8 + + picOrderCntLsb uint32 + stRPSFromSPS bool + stRPSIdx uint32 + stRPS shortTermRPS + ltRPS longTermRPS + temporalMvp bool + numPocTotalCurr uint32 + + saoLuma bool + saoChroma bool + + numRefIdxL0Active uint32 + numRefIdxL1Active uint32 + listModification refPicListModification + mvdL1Zero bool + cabacInit bool + collocatedFromL0 bool + collocatedRefIdx uint32 + maxNumMergeCand uint32 + weights predWeightTable + + qpDelta int32 + qpY int32 + cbQPOffset int32 + crQPOffset int32 + cuChromaQPOffset bool + deblockingDisabled bool + betaOffsetDiv2 int32 + tcOffsetDiv2 int32 + loopFilterAcross bool + + entryPointOffsets []uint32 + + dataOffset int +} + +func ceilLog2(n uint32) int { + if n <= 1 { + return 0 + } + + return bits.Len32(n - 1) +} + +func (s *sps) chromaArrayType() uint32 { + if s.separateColourPlane { + return 0 + } + + return s.chromaFormatIDC +} + +func (h *sliceHeader) inherit(prev *sliceHeader) { + addr := h.sliceSegmentAddress + entry := h.entryPointOffsets + off := h.dataOffset + first := h.firstSliceSegmentInPic + dep := h.dependentSliceSegment + nal := h.nalType + ppsID := h.ppsID + noOutput := h.noOutputOfPriorPics + + *h = *prev + + h.sliceSegmentAddress = addr + h.entryPointOffsets = entry + h.dataOffset = off + h.firstSliceSegmentInPic = first + h.dependentSliceSegment = dep + h.nalType = nal + h.ppsID = ppsID + h.noOutputOfPriorPics = noOutput +} + +func parseSliceHeader(rbsp []byte, nalType NALType, s *sps, p *pps) (*sliceHeader, error) { + var c getBits + c.init(rbsp) + + h := &sliceHeader{nalType: nalType} + h.firstSliceSegmentInPic = c.bit() != 0 + + if nalType >= NALBlaWLP && nalType <= 23 { + h.noOutputOfPriorPics = c.bit() != 0 + } + + h.ppsID = c.ue() + if h.ppsID != p.id { + return nil, ErrInvalid + } + + numCtbs := s.picWidthInCtbs * s.picHeightInCtbs + + if !h.firstSliceSegmentInPic { + if p.dependentSliceSegmentsEnabled { + h.dependentSliceSegment = c.bit() != 0 + } + + h.sliceSegmentAddress = c.bits(ceilLog2(numCtbs)) + if h.sliceSegmentAddress >= numCtbs { + return nil, ErrInvalid + } + } + + h.picOutputFlag = true + h.collocatedFromL0 = true + h.maxNumMergeCand = 5 + h.numRefIdxL0Active = p.numRefIdxL0DefaultActive + h.numRefIdxL1Active = p.numRefIdxL1DefaultActive + h.deblockingDisabled = p.deblockingDisabled + h.betaOffsetDiv2 = p.betaOffsetDiv2 + h.tcOffsetDiv2 = p.tcOffsetDiv2 + h.loopFilterAcross = p.loopFilterAcrossSlices + + if !h.dependentSliceSegment { + c.skip(int(p.numExtraSliceHeaderBits)) + + t := c.ue() + if t > 2 { + return nil, ErrInvalid + } + + h.sliceType = sliceType(t) + + if p.outputFlagPresent { + h.picOutputFlag = c.bit() != 0 + } + + if s.separateColourPlane { + h.colourPlaneID = uint8(c.bits(2)) + } + + if !nalType.IsIDR() { + h.picOrderCntLsb = c.bits(int(s.log2MaxPocLsb)) + + h.stRPSFromSPS = c.bit() != 0 + if !h.stRPSFromSPS { + rps, err := parseShortTermRPS(&c, len(s.stRPS), len(s.stRPS), s.stRPS) + if err != nil { + return nil, err + } + + h.stRPS = rps + } else { + if len(s.stRPS) == 0 { + return nil, ErrInvalid + } + + if len(s.stRPS) > 1 { + h.stRPSIdx = c.bits(ceilLog2(uint32(len(s.stRPS)))) + } + + if int(h.stRPSIdx) >= len(s.stRPS) { + return nil, ErrInvalid + } + + h.stRPS = s.stRPS[h.stRPSIdx] + } + + if s.longTermRefPicsPresent { + lt, err := parseLongTermRPS(&c, s) + if err != nil { + return nil, err + } + + h.ltRPS = lt + } + + if s.temporalMvpEnabled { + h.temporalMvp = c.bit() != 0 + } + } + + h.numPocTotalCurr = numPocTotalCurr(&h.stRPS, &h.ltRPS) + + if s.saoEnabled { + h.saoLuma = c.bit() != 0 + + if s.chromaArrayType() != 0 { + h.saoChroma = c.bit() != 0 + } + } + + if h.sliceType != sliceI { + if c.bit() != 0 { + h.numRefIdxL0Active = c.ue() + 1 + + if h.sliceType == sliceB { + h.numRefIdxL1Active = c.ue() + 1 + } + } + + if h.numRefIdxL0Active > maxRefs || h.numRefIdxL1Active > maxRefs { + return nil, ErrInvalid + } + + if p.listsModificationPresent && h.numPocTotalCurr > 1 { + if err := parseRefPicListModification(&c, h); err != nil { + return nil, err + } + } + + if h.sliceType == sliceB { + h.mvdL1Zero = c.bit() != 0 + } + + if p.cabacInitPresent { + h.cabacInit = c.bit() != 0 + } + + if h.temporalMvp { + if h.sliceType == sliceB { + h.collocatedFromL0 = c.bit() != 0 + } + + active := h.numRefIdxL1Active + if h.collocatedFromL0 { + active = h.numRefIdxL0Active + } + + if active > 1 { + h.collocatedRefIdx = c.ue() + if h.collocatedRefIdx >= active { + return nil, ErrInvalid + } + } + } + + if (p.weightedPred && h.sliceType == sliceP) || + (p.weightedBipred && h.sliceType == sliceB) { + if err := parsePredWeightTable(&c, h, s); err != nil { + return nil, err + } + } + + n := c.ue() + if n > 4 { + return nil, ErrInvalid + } + + h.maxNumMergeCand = 5 - n + } + + h.qpDelta = c.se() + h.qpY = p.initQP + h.qpDelta + + if h.qpY < -(6*int32(s.bitDepthLuma)-48) || h.qpY > 51 { + return nil, ErrInvalid + } + + if p.sliceChromaQPOffsets { + h.cbQPOffset = c.se() + h.crQPOffset = c.se() + + if h.cbQPOffset < -12 || h.cbQPOffset > 12 || + h.crQPOffset < -12 || h.crQPOffset > 12 { + return nil, ErrInvalid + } + } + + if p.chromaQPOffsetList { + h.cuChromaQPOffset = c.bit() != 0 + } + + if p.deblockingOverride && c.bit() != 0 { + h.deblockingDisabled = c.bit() != 0 + + if !h.deblockingDisabled { + h.betaOffsetDiv2 = c.se() + h.tcOffsetDiv2 = c.se() + + if h.betaOffsetDiv2 < -6 || h.betaOffsetDiv2 > 6 || + h.tcOffsetDiv2 < -6 || h.tcOffsetDiv2 > 6 { + return nil, ErrInvalid + } + } + } + + if p.loopFilterAcrossSlices && (h.saoLuma || h.saoChroma || !h.deblockingDisabled) { + h.loopFilterAcross = c.bit() != 0 + } + } + + if p.tilesEnabled || p.entropyCodingSync { + n := c.ue() + if n > numCtbs { + return nil, ErrInvalid + } + + if n > 0 { + lenMinus1 := c.ue() + if lenMinus1 > 31 { + return nil, ErrInvalid + } + + h.entryPointOffsets = make([]uint32, n) + + var cum uint32 + + for i := range h.entryPointOffsets { + v := c.bits(int(lenMinus1) + 1) + + if cum+v+1 < cum { + return nil, ErrInvalid + } + + cum += v + 1 + h.entryPointOffsets[i] = cum + } + } + } + + if p.sliceHeaderExtensionPresen { + n := c.ue() + if n > uint32(len(rbsp)) { + return nil, ErrInvalid + } + + c.skip(int(n) * 8) + } + + if c.bit() != 1 { + return nil, ErrInvalid + } + + c.byteAlign() + + if c.err { + return nil, ErrInvalid + } + + h.dataOffset = c.pos() / 8 + + return h, nil +} + +func numPocTotalCurr(st *shortTermRPS, lt *longTermRPS) uint32 { + var n uint32 + + for _, u := range st.usedS0 { + if u { + n++ + } + } + + for _, u := range st.usedS1 { + if u { + n++ + } + } + + for _, u := range lt.usedByCurrPicLt { + if u { + n++ + } + } + + return n +} + +func parseRefPicListModification(c *getBits, h *sliceHeader) error { + n := ceilLog2(h.numPocTotalCurr) + + h.listModification.flagL0 = c.bit() != 0 + if h.listModification.flagL0 { + h.listModification.listEntryL0 = make([]uint32, h.numRefIdxL0Active) + + for i := range h.listModification.listEntryL0 { + v := c.bits(n) + if v >= h.numPocTotalCurr { + return ErrInvalid + } + + h.listModification.listEntryL0[i] = v + } + } + + if h.sliceType != sliceB { + return nil + } + + h.listModification.flagL1 = c.bit() != 0 + if h.listModification.flagL1 { + h.listModification.listEntryL1 = make([]uint32, h.numRefIdxL1Active) + + for i := range h.listModification.listEntryL1 { + v := c.bits(n) + if v >= h.numPocTotalCurr { + return ErrInvalid + } + + h.listModification.listEntryL1[i] = v + } + } + + return nil +} + +func parseLongTermRPS(c *getBits, s *sps) (longTermRPS, error) { + var lt longTermRPS + + var numSps uint32 + if len(s.ltRefPicPocLsb) > 0 { + numSps = c.ue() + } + + numPics := c.ue() + + if numSps > uint32(len(s.ltRefPicPocLsb)) || numSps+numPics > maxLongTermRefPics { + return lt, ErrInvalid + } + + total := int(numSps + numPics) + lt.numSps = int(numSps) + lt.pocLsbLt = make([]uint32, total) + lt.usedByCurrPicLt = make([]bool, total) + lt.deltaPocMsbPresn = make([]bool, total) + lt.deltaPocMsbCycle = make([]uint32, total) + + for i := range total { + if uint32(i) < numSps { + var idx uint32 + if len(s.ltRefPicPocLsb) > 1 { + idx = c.bits(ceilLog2(uint32(len(s.ltRefPicPocLsb)))) + } + + if int(idx) >= len(s.ltRefPicPocLsb) { + return lt, ErrInvalid + } + + lt.pocLsbLt[i] = s.ltRefPicPocLsb[idx] + lt.usedByCurrPicLt[i] = s.usedByCurrPicLt[idx] + } else { + lt.pocLsbLt[i] = c.bits(int(s.log2MaxPocLsb)) + lt.usedByCurrPicLt[i] = c.bit() != 0 + } + + lt.deltaPocMsbPresn[i] = c.bit() != 0 + if lt.deltaPocMsbPresn[i] { + lt.deltaPocMsbCycle[i] = c.ue() + } + } + + return lt, nil +} + +func parsePredWeightTable(c *getBits, h *sliceHeader, s *sps) error { + w := &h.weights + + denom := c.ue() + if denom > 7 { + return ErrInvalid + } + + w.lumaLog2Denom = uint8(denom) + + chromaDenom := int32(denom) + if s.chromaArrayType() != 0 { + chromaDenom += c.se() + + if chromaDenom < 0 || chromaDenom > 7 { + return ErrInvalid + } + } + + w.chromaLog2Denom = uint8(chromaDenom) + + // 7.4.7.3, WpOffsetHalfRangeC: the sample depth under high precision + // offsets, eight bits otherwise. + half := int32(128) + if s.highPrecisionOffsets { + half = 1 << (int32(s.bitDepthChroma) - 1) + } + + lists := 1 + if h.sliceType == sliceB { + lists = 2 + } + + counts := [2]uint32{h.numRefIdxL0Active, h.numRefIdxL1Active} + + for l := range lists { + n := int(counts[l]) + + var lumaFlag, chromaFlag [maxRefs]bool + + for i := range n { + lumaFlag[i] = c.bit() != 0 + } + + if s.chromaArrayType() != 0 { + for i := range n { + chromaFlag[i] = c.bit() != 0 + } + } + + for i := range n { + if lumaFlag[i] { + w.lumaWeight[l][i] = int16(1<>w.chromaLog2Denom + w.chromaOffset[l][i][j] = int16(clip3(off, -half, half-1)) + } + } + } + + return nil +} diff --git a/third_party/h265/hevc/sse_amd64.s b/third_party/h265/hevc/sse_amd64.s new file mode 100644 index 00000000..bb0f744c --- /dev/null +++ b/third_party/h265/hevc/sse_amd64.s @@ -0,0 +1,69 @@ +//go:build amd64 && !noasm + +#include "textflag.h" + +// func sse8AVX2(src *uint8, srcStride int, block *uint8, blockStride, n int) int64 +// +// The squared error of an n by n block, the pairwise multiply squaring and +// adding in one step. +TEXT ·sse8AVX2(SB), NOSPLIT, $0-48 + MOVQ src+0(FP), SI + MOVQ srcStride+8(FP), R8 + MOVQ block+16(FP), DI + MOVQ blockStride+24(FP), R9 + MOVQ n+32(FP), CX + + VPXOR Y0, Y0, Y0 + + XORQ BX, BX + +rowloop: + XORQ DX, DX + +colloop: + MOVQ CX, AX + SUBQ DX, AX + CMPQ AX, $16 + JLT half + + VPMOVZXBW (SI)(DX*1), Y1 + VPMOVZXBW (DI)(DX*1), Y2 + VPSUBW Y2, Y1, Y1 + VPMADDWD Y1, Y1, Y1 + VPADDD Y1, Y0, Y0 + + ADDQ $16, DX + JMP next + +half: + VPMOVZXBW (SI)(DX*1), X1 + VPMOVZXBW (DI)(DX*1), X2 + VPSUBW X2, X1, X1 + VPMADDWD X1, X1, X1 + VPADDD Y1, Y0, Y0 + + ADDQ $8, DX + +next: + CMPQ DX, CX + JLT colloop + + ADDQ R8, SI + ADDQ R9, DI + INCQ BX + CMPQ BX, CX + JLT rowloop + + VEXTRACTI128 $1, Y0, X1 + VPADDD X1, X0, X0 + VPSHUFD $0x4e, X0, X1 + VPADDD X1, X0, X0 + VPSHUFD $0xb1, X0, X1 + VPADDD X1, X0, X0 + + VMOVD X0, AX + MOVLQZX AX, AX + MOVQ AX, ret+40(FP) + + VZEROUPPER + RET diff --git a/third_party/h265/hevc/sse_arm64.s b/third_party/h265/hevc/sse_arm64.s new file mode 100644 index 00000000..81330af7 --- /dev/null +++ b/third_party/h265/hevc/sse_arm64.s @@ -0,0 +1,82 @@ +//go:build arm64 && !noasm + +#include "textflag.h" + +#define SMLAL4S(Vd, Vn, Vm) WORD $(0x0e608000 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define SMLAL2_4S(Vd, Vn, Vm) WORD $(0x4e608000 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) + +// func sse8NEON(src *uint8, srcStride int, block *uint8, blockStride, n int) int64 +// +// The squared error of an n by n block. +TEXT ·sse8NEON(SB), NOSPLIT, $0-48 + MOVD src+0(FP), R0 + MOVD srcStride+8(FP), R1 + MOVD block+16(FP), R2 + MOVD blockStride+24(FP), R3 + MOVD n+32(FP), R4 + + VMOVI $0, V16.B16 + + MOVD $0, R5 + +rowloop: + MOVD R0, R6 + MOVD R2, R7 + MOVD $0, R8 + +colloop: + SUB R8, R4, R9 + CMP $16, R9 + BLT half + + VLD1 (R6), [V0.B16] + VLD1 (R7), [V1.B16] + + VUXTL V0.B8, V2.H8 + VUXTL V1.B8, V3.H8 + VSUB V3.H8, V2.H8, V2.H8 + VUXTL2 V0.B16, V4.H8 + VUXTL2 V1.B16, V5.H8 + VSUB V5.H8, V4.H8, V4.H8 + + SMLAL4S(16, 2, 2) + SMLAL2_4S(16, 2, 2) + SMLAL4S(16, 4, 4) + SMLAL2_4S(16, 4, 4) + + ADD $16, R6 + ADD $16, R7 + ADD $16, R8 + + B next + +half: + VLD1 (R6), [V0.B8] + VLD1 (R7), [V1.B8] + + VUXTL V0.B8, V2.H8 + VUXTL V1.B8, V3.H8 + VSUB V3.H8, V2.H8, V2.H8 + + SMLAL4S(16, 2, 2) + SMLAL2_4S(16, 2, 2) + + ADD $8, R6 + ADD $8, R7 + ADD $8, R8 + +next: + CMP R4, R8 + BLT colloop + + ADD $1, R5 + ADD R1, R0 + ADD R3, R2 + CMP R4, R5 + BLT rowloop + + VADDV V16.S4, V17 + VMOV V17.S[0], R6 + + MOVD R6, ret+40(FP) + RET diff --git a/third_party/h265/hevc/sse_riscv64.s b/third_party/h265/hevc/sse_riscv64.s new file mode 100644 index 00000000..2e5e5157 --- /dev/null +++ b/third_party/h265/hevc/sse_riscv64.s @@ -0,0 +1,55 @@ +//go:build riscv64 && riscv64.rva23u64 && !noasm + +#include "textflag.h" + +// func sse8RVV(src *uint8, srcStride int, block *uint8, blockStride, n int) int64 +// +// The squared error of an n by n block. n and the vector length are both powers +// of two, so one length serves every chunk. +TEXT ·sse8RVV(SB), NOSPLIT, $0-48 + MOV src+0(FP), X10 + MOV srcStride+8(FP), X11 + MOV block+16(FP), X12 + MOV blockStride+24(FP), X13 + MOV n+32(FP), X14 + + VSETVLI X14, E16, M1, TA, MA, X15 + + VSETVLI X15, E32, M2, TA, MA, X0 + VMVVI $0, V8 + + MOV $0, X16 + +rowloop: + MOV X10, X17 + MOV X12, X18 + MOV $0, X19 + +colloop: + VSETVLI X15, E16, M1, TA, MA, X0 + + VLE8V (X17), V1 + VLE8V (X18), V2 + VZEXTVF2 V1, V3 + VZEXTVF2 V2, V4 + VSUBVV V4, V3, V3 + + VWMACCVV V3, V3, V8 + + ADD X15, X17 + ADD X15, X18 + ADD X15, X19 + BLT X19, X14, colloop + + ADD X11, X10 + ADD X13, X12 + ADD $1, X16 + BLT X16, X14, rowloop + + VSETVLI X15, E32, M2, TA, MA, X0 + VMVVI $0, V12 + VREDSUMVS V12, V8, V14 + VMVXS V14, X20 + + MOV X20, ret+40(FP) + RET diff --git a/third_party/h265/hevc/transformenc.go b/third_party/h265/hevc/transformenc.go new file mode 100644 index 00000000..e5e74dd4 --- /dev/null +++ b/third_party/h265/hevc/transformenc.go @@ -0,0 +1,141 @@ +package hevc + +func forwardTransform(dst, src []int32, n, bitDepth int) { + if bitDepth == 8 && transform8Safe(src[:n*n]) { + forwardTransform8(dst, src, n) + + return + } + + forwardTransformWide(dst, src, n, bitDepth) +} + +func transform8Safe(src []int32) bool { + for _, v := range src { + if v < -255 || v > 255 { + return false + } + } + + return true +} + +func forwardTransform8(dst, src []int32, n int) { + if k := forwardTransform8Asm; k != nil { + k(dst, src, n) + + return + } + + forwardTransform8Go(dst, src, n) +} + +func forwardTransform8Go(dst, src []int32, n int) { + shift1 := log2(n) - 1 + shift2 := log2(n) + 6 + var mid [32 * 32]int32 + stride := 32 / n + + for y := range n { + for k := range n { + var sum int32 + for x := range n { + sum += int32(transMatrix[k*stride][x]) * src[y*n+x] + } + mid[y*n+k] = (sum + 1<> uint(shift1) + } + } + + for k := range n { + for v := range n { + var sum int32 + for y := range n { + sum += int32(transMatrix[v*stride][y]) * mid[y*n+k] + } + dst[v*n+k] = (sum + 1<> uint(shift2) + } + } +} + +func forwardTransformWide(dst, src []int32, n, bitDepth int) { + shift1 := log2(n) + bitDepth - 9 + shift2 := log2(n) + 6 + var mid [32 * 32]int64 + stride := 32 / n + + for y := range n { + for k := range n { + var sum int64 + for x := range n { + sum += int64(transMatrix[k*stride][x]) * int64(src[y*n+x]) + } + mid[y*n+k] = (sum + 1<> uint(shift1) + } + } + + for k := range n { + for v := range n { + var sum int64 + for y := range n { + sum += int64(transMatrix[v*stride][y]) * mid[y*n+k] + } + dst[v*n+k] = int32((sum + 1<> uint(shift2)) + } + } +} + +func forwardTransformDST4(dst, src []int32, bitDepth int) { + shift1 := bitDepth - 7 + shift2 := 8 + var mid [16]int32 + + for y := range 4 { + for k := range 4 { + var sum int32 + for x := range 4 { + sum += int32(dstMatrix[k][x]) * src[y*4+x] + } + mid[y*4+k] = (sum + 1<> uint(shift1) + } + } + + for k := range 4 { + for v := range 4 { + var sum int32 + for y := range 4 { + sum += int32(dstMatrix[v][y]) * mid[y*4+k] + } + dst[v*4+k] = (sum + 1<> uint(shift2) + } + } +} + +// quantize is 8.6.3 run backwards. +func quantize(dst, src []int32, n, qp, bitDepth int) { + scale, qbits := quantScale(n, qp, bitDepth) + offset := int64(1<>qbits, int64(0x7fff)) + if v < 0 { + level = -level + } + + dst[i] = int32(level) + } +} diff --git a/third_party/h265/hevc/transformenc_amd64.s b/third_party/h265/hevc/transformenc_amd64.s new file mode 100644 index 00000000..02d20a75 --- /dev/null +++ b/third_party/h265/hevc/transformenc_amd64.s @@ -0,0 +1,344 @@ +//go:build amd64 && !noasm + +#include "textflag.h" + +TEXT ·forwardTransform8AVX2(SB), $4096-48 + MOVQ dst+0(FP), DI + MOVQ src+8(FP), SI + MOVQ m+16(FP), R14 + MOVQ n+24(FP), R8 + MOVQ R8, R9 + SHLQ $2, R9 + + CMPQ R8, $4 + JEQ four + + MOVQ shift1+32(FP), CX + MOVQ CX, X14 + DECQ CX + MOVQ $1, R10 + SHLQ CL, R10 + MOVQ R10, X15 + VPBROADCASTD X15, Y15 + + MOVQ SI, R10 + LEAQ 0(SP), R11 + MOVQ R8, R12 + +row8: + XORQ BX, BX + +coef8: + VPXOR Y0, Y0, Y0 + MOVQ R10, AX + LEAQ (R14)(BX*1), DX + MOVQ R8, CX + +sum8: + VPBROADCASTD (AX), Y1 + VMOVDQU (DX), Y2 + VPMULLD Y1, Y2, Y2 + VPADDD Y2, Y0, Y0 + ADDQ $4, AX + ADDQ R9, DX + DECQ CX + JNZ sum8 + + VPADDD Y15, Y0, Y0 + VPSRAD X14, Y0, Y0 + VMOVDQU Y0, (R11)(BX*1) + ADDQ $32, BX + CMPQ BX, R9 + JLT coef8 + + ADDQ R9, R10 + ADDQ R9, R11 + DECQ R12 + JNZ row8 + + MOVQ shift2+40(FP), CX + MOVQ CX, X14 + DECQ CX + MOVQ $1, R10 + SHLQ CL, R10 + MOVQ R10, X15 + VPBROADCASTD X15, Y15 + + LEAQ 0(SP), R10 + MOVQ R14, R11 + MOVQ R8, R12 + +out8: + XORQ BX, BX + +outcoef8: + VPXOR Y0, Y0, Y0 + MOVQ R10, AX + MOVQ R11, DX + MOVQ R8, CX + +outsum8: + VMOVDQU (AX)(BX*1), Y1 + VPBROADCASTD (DX), Y2 + VPMULLD Y1, Y2, Y2 + VPADDD Y2, Y0, Y0 + ADDQ R9, AX + ADDQ R9, DX + DECQ CX + JNZ outsum8 + + VPADDD Y15, Y0, Y0 + VPSRAD X14, Y0, Y0 + VMOVDQU Y0, (DI)(BX*1) + ADDQ $32, BX + CMPQ BX, R9 + JLT outcoef8 + + ADDQ R9, DI + ADDQ $4, R11 + DECQ R12 + JNZ out8 + + VZEROUPPER + RET + +four: + MOVQ shift1+32(FP), CX + MOVQ CX, X14 + DECQ CX + MOVQ $1, R10 + SHLQ CL, R10 + MOVQ R10, X15 + VPBROADCASTD X15, X15 + + MOVQ SI, R10 + LEAQ 0(SP), R11 + MOVQ R8, R12 + +row4: + XORQ BX, BX + +coef4: + VPXOR X0, X0, X0 + MOVQ R10, AX + LEAQ (R14)(BX*1), DX + MOVQ R8, CX + +sum4: + VPBROADCASTD (AX), X1 + VMOVDQU (DX), X2 + VPMULLD X1, X2, X2 + VPADDD X2, X0, X0 + ADDQ $4, AX + ADDQ R9, DX + DECQ CX + JNZ sum4 + + VPADDD X15, X0, X0 + VPSRAD X14, X0, X0 + VMOVDQU X0, (R11)(BX*1) + ADDQ $16, BX + CMPQ BX, R9 + JLT coef4 + + ADDQ R9, R10 + ADDQ R9, R11 + DECQ R12 + JNZ row4 + + MOVQ shift2+40(FP), CX + MOVQ CX, X14 + DECQ CX + MOVQ $1, R10 + SHLQ CL, R10 + MOVQ R10, X15 + VPBROADCASTD X15, X15 + + LEAQ 0(SP), R10 + MOVQ R14, R11 + MOVQ R8, R12 + +out4: + XORQ BX, BX + +outcoef4: + VPXOR X0, X0, X0 + MOVQ R10, AX + MOVQ R11, DX + MOVQ R8, CX + +outsum4: + VMOVDQU (AX)(BX*1), X1 + VPBROADCASTD (DX), X2 + VPMULLD X1, X2, X2 + VPADDD X2, X0, X0 + ADDQ R9, AX + ADDQ R9, DX + DECQ CX + JNZ outsum4 + + VPADDD X15, X0, X0 + VPSRAD X14, X0, X0 + VMOVDQU X0, (DI)(BX*1) + ADDQ $16, BX + CMPQ BX, R9 + JLT outcoef4 + + ADDQ R9, DI + ADDQ $4, R11 + DECQ R12 + JNZ out4 + + VZEROUPPER + RET + +TEXT ·forwardTransform8AVX512(SB), $4096-48 + MOVQ dst+0(FP), DI + MOVQ src+8(FP), SI + MOVQ m+16(FP), R14 + MOVQ n+24(FP), R8 + MOVQ R8, R9 + SHLQ $2, R9 + + MOVQ shift1+32(FP), CX + MOVQ CX, X14 + DECQ CX + MOVQ $1, R10 + SHLQ CL, R10 + MOVQ R10, X15 + VPBROADCASTD X15, Z15 + + MOVQ SI, R10 + LEAQ 0(SP), R11 + MOVQ R8, R12 + +row16: + XORQ BX, BX + +coef16: + VPXORD Z0, Z0, Z0 + MOVQ R10, AX + LEAQ (R14)(BX*1), DX + MOVQ R8, CX + +sum16: + VPBROADCASTD (AX), Z1 + VMOVDQU32 (DX), Z2 + VPMULLD Z1, Z2, Z2 + VPADDD Z2, Z0, Z0 + ADDQ $4, AX + ADDQ R9, DX + DECQ CX + JNZ sum16 + + VPADDD Z15, Z0, Z0 + VPSRAD X14, Z0, Z0 + VMOVDQU32 Z0, (R11)(BX*1) + ADDQ $64, BX + CMPQ BX, R9 + JLT coef16 + + ADDQ R9, R10 + ADDQ R9, R11 + DECQ R12 + JNZ row16 + + MOVQ shift2+40(FP), CX + MOVQ CX, X14 + DECQ CX + MOVQ $1, R10 + SHLQ CL, R10 + MOVQ R10, X15 + VPBROADCASTD X15, Z15 + + LEAQ 0(SP), R10 + MOVQ R14, R11 + MOVQ R8, R12 + +out16: + XORQ BX, BX + +outcoef16: + VPXORD Z0, Z0, Z0 + MOVQ R10, AX + MOVQ R11, DX + MOVQ R8, CX + +outsum16: + VMOVDQU32 (AX)(BX*1), Z1 + VPBROADCASTD (DX), Z2 + VPMULLD Z1, Z2, Z2 + VPADDD Z2, Z0, Z0 + ADDQ R9, AX + ADDQ R9, DX + DECQ CX + JNZ outsum16 + + VPADDD Z15, Z0, Z0 + VPSRAD X14, Z0, Z0 + VMOVDQU32 Z0, (DI)(BX*1) + ADDQ $64, BX + CMPQ BX, R9 + JLT outcoef16 + + ADDQ R9, DI + ADDQ $4, R11 + DECQ R12 + JNZ out16 + + VZEROUPPER + RET + +// func quantize8AVX2(dst, src *int32, count int, scale, offset int32, qbits int) +// +// The forward direction of 8.6.3. The magnitude is unsigned, which keeps the +// widening multiply exact over the whole int32 range. +TEXT ·quantize8AVX2(SB), NOSPLIT, $0-40 + MOVQ dst+0(FP), DI + MOVQ src+8(FP), SI + MOVQ count+16(FP), CX + MOVL scale+24(FP), AX + MOVL offset+28(FP), DX + MOVQ qbits+32(FP), R8 + + VMOVD AX, X10 + VPBROADCASTD X10, Y10 + + MOVLQZX DX, DX + VMOVQ DX, X11 + VPBROADCASTQ X11, Y11 + + MOVL $0x7fff, AX + VMOVD AX, X12 + VPBROADCASTD X12, Y12 + + VMOVQ R8, X13 + + XORQ BX, BX + +loop: + VMOVDQU (SI)(BX*4), Y1 + VPABSD Y1, Y2 + + VPMULUDQ Y10, Y2, Y3 + VPSRLQ $32, Y2, Y4 + VPMULUDQ Y10, Y4, Y4 + + VPADDQ Y11, Y3, Y3 + VPADDQ Y11, Y4, Y4 + VPSRLQ X13, Y3, Y3 + VPSRLQ X13, Y4, Y4 + + VPSLLQ $32, Y4, Y4 + VPOR Y4, Y3, Y3 + + VPMINSD Y12, Y3, Y3 + VPSIGND Y1, Y3, Y3 + VMOVDQU Y3, (DI)(BX*4) + + ADDQ $8, BX + CMPQ BX, CX + JLT loop + + VZEROUPPER + RET diff --git a/third_party/h265/hevc/transformenc_arm64.s b/third_party/h265/hevc/transformenc_arm64.s new file mode 100644 index 00000000..68542e2b --- /dev/null +++ b/third_party/h265/hevc/transformenc_arm64.s @@ -0,0 +1,170 @@ +//go:build arm64 && !noasm + +#include "textflag.h" + +#define MUL4S(Vd, Vn, Vm) WORD $(0x4ea09c00 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define SSHL4S(Vd, Vn, Vm) WORD $(0x4ea04400 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) + +// func forwardTransform8NEON(dst, src, m *int32, n, shift1, shift2 int) +TEXT ·forwardTransform8NEON(SB), $4096-48 + MOVD dst+0(FP), R0 + MOVD src+8(FP), R1 + MOVD m+16(FP), R2 + MOVD n+24(FP), R3 + MOVD shift1+32(FP), R4 + + LSL $2, R3, R5 + + MOVD $1, R6 + SUB $1, R4, R7 + LSL R7, R6, R6 + VDUP R6, V3.S4 + NEG R4, R7 + VDUP R7, V4.S4 + + MOVD RSP, R10 + MOVD R3, R8 + +row: + MOVD $0, R11 + +rowcoef: + VMOVI $0, V0.B16 + MOVD R1, R12 + ADD R11, R2, R13 + MOVD R3, R14 + +rowsum: + MOVW (R12), R15 + VDUP R15, V1.S4 + VLD1 (R13), [V2.S4] + MUL4S(2, 2, 1) + VADD V2.S4, V0.S4, V0.S4 + ADD $4, R12 + ADD R5, R13 + SUB $1, R14 + CBNZ R14, rowsum + + VADD V3.S4, V0.S4, V0.S4 + SSHL4S(0, 0, 4) + ADD R11, R10, R15 + VST1 [V0.S4], (R15) + ADD $16, R11 + CMP R5, R11 + BLT rowcoef + + ADD R5, R1 + ADD R5, R10 + SUB $1, R8 + CBNZ R8, row + + MOVD shift2+40(FP), R4 + MOVD $1, R6 + SUB $1, R4, R7 + LSL R7, R6, R6 + VDUP R6, V3.S4 + NEG R4, R7 + VDUP R7, V4.S4 + + MOVD RSP, R10 + MOVD R2, R9 + MOVD R3, R8 + +out: + MOVD $0, R11 + +outcoef: + VMOVI $0, V0.B16 + ADD R11, R10, R12 + MOVD R9, R13 + MOVD R3, R14 + +outsum: + VLD1 (R12), [V1.S4] + MOVW (R13), R15 + VDUP R15, V2.S4 + MUL4S(1, 1, 2) + VADD V1.S4, V0.S4, V0.S4 + ADD R5, R12 + ADD R5, R13 + SUB $1, R14 + CBNZ R14, outsum + + VADD V3.S4, V0.S4, V0.S4 + SSHL4S(0, 0, 4) + ADD R11, R0, R15 + VST1 [V0.S4], (R15) + ADD $16, R11 + CMP R5, R11 + BLT outcoef + + ADD R5, R0 + ADD $4, R9 + SUB $1, R8 + CBNZ R8, out + + RET + +#define ABS4S(Vd, Vn) WORD $(0x4ea0b800 | ((Vn) << 5) | (Vd)) +#define UMULL2D(Vd, Vn, Vm) WORD $(0x2ea0c000 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define UMULL2_2D(Vd, Vn, Vm) WORD $(0x6ea0c000 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define USHL2D(Vd, Vn, Vm) WORD $(0x6ee04400 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define SMIN4S(Vd, Vn, Vm) WORD $(0x4ea06c00 | ((Vm) << 16) | ((Vn) << 5) | (Vd)) +#define SSHR31_4S(Vd, Vn) WORD $(0x4f210400 | ((Vn) << 5) | (Vd)) + +// func quantize8NEON(dst, src *int32, count int, scale, offset int32, qbits int) +// +// The forward direction of 8.6.3. The magnitude is unsigned, which keeps the +// widening multiply exact over the whole int32 range. +TEXT ·quantize8NEON(SB), NOSPLIT, $0-40 + MOVD dst+0(FP), R0 + MOVD src+8(FP), R1 + MOVD count+16(FP), R2 + MOVW scale+24(FP), R3 + MOVW offset+28(FP), R4 + MOVD qbits+32(FP), R5 + + VDUP R3, V20.S4 + + AND $0xffffffff, R4, R4 + VDUP R4, V21.D2 + + NEG R5, R6 + VDUP R6, V22.D2 + + MOVD $0x7fff, R7 + VDUP R7, V23.S4 + + MOVD $0, R8 + +loop: + VLD1 (R1), [V0.S4] + + ABS4S(1, 0) + + UMULL2D(2, 1, 20) + UMULL2_2D(3, 1, 20) + + VADD V21.D2, V2.D2, V2.D2 + VADD V21.D2, V3.D2, V3.D2 + + USHL2D(2, 2, 22) + USHL2D(3, 3, 22) + + VUZP1 V3.S4, V2.S4, V4.S4 + + SMIN4S(4, 4, 23) + + SSHR31_4S(5, 0) + VEOR V5.B16, V4.B16, V4.B16 + VSUB V5.S4, V4.S4, V4.S4 + + VST1 [V4.S4], (R0) + + ADD $16, R1 + ADD $16, R0 + ADD $4, R8 + CMP R2, R8 + BLT loop + + RET diff --git a/third_party/h265/hevc/transformenc_riscv64.s b/third_party/h265/hevc/transformenc_riscv64.s new file mode 100644 index 00000000..5de6a9ac --- /dev/null +++ b/third_party/h265/hevc/transformenc_riscv64.s @@ -0,0 +1,154 @@ +//go:build riscv64 && riscv64.rva23u64 && !noasm + +#include "textflag.h" + +// func forwardTransform8RVV(dst, src, m *int32, n, shift1, shift2 int) +TEXT ·forwardTransform8RVV(SB), $4096-48 + MOV dst+0(FP), X10 + MOV src+8(FP), X11 + MOV m+16(FP), X12 + MOV n+24(FP), X13 + MOV shift1+32(FP), X14 + MOV shift2+40(FP), X15 + + ADD $8, SP, X16 + SLLI $2, X13, X17 + + MOV $1, X18 + ADD $-1, X14, X19 + SLL X19, X18, X18 + MOV $1, X19 + ADD $-1, X15, X20 + SLL X20, X19, X19 + + MOV X11, X20 + MOV X16, X21 + MOV X13, X22 + +rows: + MOV X13, X23 + MOV $0, X24 + +cols: + VSETVLI X23, E32, M1, TA, MA, X26 + VMVVI $0, V1 + + MOV X20, X28 + ADD X12, X24, X29 + MOV X13, X30 + +sum: + MOVW (X28), X31 + VLE32V (X29), V2 + VMACCVX V2, X31, V1 + ADD $4, X28 + ADD X17, X29 + ADD $-1, X30 + BNEZ X30, sum + + VADDVX X18, V1, V1 + VSRAVX X14, V1, V1 + ADD X21, X24, X28 + VSE32V V1, (X28) + + SLLI $2, X26, X29 + ADD X29, X24 + SUB X26, X23 + BNEZ X23, cols + + ADD X17, X20 + ADD X17, X21 + ADD $-1, X22 + BNEZ X22, rows + + MOV X13, X22 + MOV X10, X20 + MOV X12, X21 + +outrows: + MOV X13, X23 + MOV $0, X24 + +outcols: + VSETVLI X23, E32, M1, TA, MA, X26 + VMVVI $0, V1 + + ADD X16, X24, X28 + MOV X21, X29 + MOV X13, X30 + +outsum: + VLE32V (X28), V2 + MOVW (X29), X31 + VMACCVX V2, X31, V1 + ADD X17, X28 + ADD X17, X29 + ADD $-1, X30 + BNEZ X30, outsum + + VADDVX X19, V1, V1 + VSRAVX X15, V1, V1 + ADD X20, X24, X28 + VSE32V V1, (X28) + + SLLI $2, X26, X29 + ADD X29, X24 + SUB X26, X23 + BNEZ X23, outcols + + ADD X17, X20 + ADD $4, X21 + ADD $-1, X22 + BNEZ X22, outrows + + RET + +// func quantize8RVV(dst, src *int32, count int, scale, offset int32, qbits int) +// +// The forward direction of 8.6.3. The magnitude is unsigned, which keeps the +// widening multiply exact over the whole int32 range. +TEXT ·quantize8RVV(SB), NOSPLIT, $0-40 + MOV dst+0(FP), X10 + MOV src+8(FP), X11 + MOV count+16(FP), X12 + MOVW scale+24(FP), X13 + MOVW offset+28(FP), X14 + MOV qbits+32(FP), X15 + + SLLI $32, X14, X14 + SRLI $32, X14, X14 + + MOV $0x7fff, X16 + +loop: + VSETVLI X12, E32, M1, TA, MA, X17 + + VLE32V (X11), V1 + + VRSUBVX X0, V1, V2 + VMAXVV V1, V2, V2 + + VWMULUVX X13, V2, V4 + + VSETVLI X17, E64, M2, TA, MA, X0 + VADDVX X14, V4, V4 + VSRLVX X15, V4, V4 + + VSETVLI X17, E32, M1, TA, MA, X0 + VNSRLWI $0, V4, V6 + + VMINVX X16, V6, V6 + + VSRAVI $31, V1, V7 + VXORVV V7, V6, V6 + VSUBVV V7, V6, V6 + + VSE32V V6, (X10) + + SLLI $2, X17, X18 + ADD X18, X11 + ADD X18, X10 + SUB X17, X12 + BNEZ X12, loop + + RET diff --git a/third_party/h265/hevc/transpose_amd64.s b/third_party/h265/hevc/transpose_amd64.s new file mode 100644 index 00000000..634ce7c6 --- /dev/null +++ b/third_party/h265/hevc/transpose_amd64.s @@ -0,0 +1,101 @@ +//go:build amd64 && !noasm + +#include "textflag.h" + +// func transpose8AVX2(dst, src *int32, n int) +// +// Transposes an n by n block of int32 in eight by eight tiles, the usual +// unpack-then-cross-lane sequence. +TEXT ·transpose8AVX2(SB), NOSPLIT, $0-24 + MOVQ dst+0(FP), DI + MOVQ src+8(FP), SI + MOVQ n+16(FP), CX + + MOVQ CX, R8 + SHLQ $2, R8 + + XORQ R9, R9 + +yloop: + XORQ R10, R10 + +xloop: + MOVQ R9, AX + IMULQ R8, AX + LEAQ (SI)(AX*1), R11 + LEAQ (R11)(R10*4), R11 + + MOVQ R10, AX + IMULQ R8, AX + LEAQ (DI)(AX*1), R12 + LEAQ (R12)(R9*4), R12 + + VMOVDQU (R11), Y0 + ADDQ R8, R11 + VMOVDQU (R11), Y1 + ADDQ R8, R11 + VMOVDQU (R11), Y2 + ADDQ R8, R11 + VMOVDQU (R11), Y3 + ADDQ R8, R11 + VMOVDQU (R11), Y4 + ADDQ R8, R11 + VMOVDQU (R11), Y5 + ADDQ R8, R11 + VMOVDQU (R11), Y6 + ADDQ R8, R11 + VMOVDQU (R11), Y7 + + VPUNPCKLDQ Y1, Y0, Y8 + VPUNPCKHDQ Y1, Y0, Y9 + VPUNPCKLDQ Y3, Y2, Y10 + VPUNPCKHDQ Y3, Y2, Y11 + VPUNPCKLDQ Y5, Y4, Y12 + VPUNPCKHDQ Y5, Y4, Y13 + VPUNPCKLDQ Y7, Y6, Y14 + VPUNPCKHDQ Y7, Y6, Y15 + + VPUNPCKLQDQ Y10, Y8, Y0 + VPUNPCKHQDQ Y10, Y8, Y1 + VPUNPCKLQDQ Y11, Y9, Y2 + VPUNPCKHQDQ Y11, Y9, Y3 + VPUNPCKLQDQ Y14, Y12, Y4 + VPUNPCKHQDQ Y14, Y12, Y5 + VPUNPCKLQDQ Y15, Y13, Y6 + VPUNPCKHQDQ Y15, Y13, Y7 + + VPERM2I128 $0x20, Y4, Y0, Y8 + VPERM2I128 $0x20, Y5, Y1, Y9 + VPERM2I128 $0x20, Y6, Y2, Y10 + VPERM2I128 $0x20, Y7, Y3, Y11 + VPERM2I128 $0x31, Y4, Y0, Y12 + VPERM2I128 $0x31, Y5, Y1, Y13 + VPERM2I128 $0x31, Y6, Y2, Y14 + VPERM2I128 $0x31, Y7, Y3, Y15 + + VMOVDQU Y8, (R12) + ADDQ R8, R12 + VMOVDQU Y9, (R12) + ADDQ R8, R12 + VMOVDQU Y10, (R12) + ADDQ R8, R12 + VMOVDQU Y11, (R12) + ADDQ R8, R12 + VMOVDQU Y12, (R12) + ADDQ R8, R12 + VMOVDQU Y13, (R12) + ADDQ R8, R12 + VMOVDQU Y14, (R12) + ADDQ R8, R12 + VMOVDQU Y15, (R12) + + ADDQ $8, R10 + CMPQ R10, CX + JLT xloop + + ADDQ $8, R9 + CMPQ R9, CX + JLT yloop + + VZEROUPPER + RET diff --git a/third_party/h265/hevc/transpose_arm64.s b/third_party/h265/hevc/transpose_arm64.s new file mode 100644 index 00000000..d5e2cfb8 --- /dev/null +++ b/third_party/h265/hevc/transpose_arm64.s @@ -0,0 +1,64 @@ +//go:build arm64 && !noasm + +#include "textflag.h" + +// func transpose4NEON(dst, src *int32, n int) +// +// Transposes an n by n block of int32 in four by four tiles, the interleave +// twice at element width then at pair width. +TEXT ·transpose4NEON(SB), NOSPLIT, $0-24 + MOVD dst+0(FP), R0 + MOVD src+8(FP), R1 + MOVD n+16(FP), R2 + + LSL $2, R2, R3 + + MOVD $0, R4 + +yloop: + MOVD $0, R5 + +xloop: + MUL R3, R4, R6 + ADD R1, R6 + ADD R5<<2, R6, R6 + + MUL R3, R5, R7 + ADD R0, R7 + ADD R4<<2, R7, R7 + + VLD1 (R6), [V0.S4] + ADD R3, R6 + VLD1 (R6), [V1.S4] + ADD R3, R6 + VLD1 (R6), [V2.S4] + ADD R3, R6 + VLD1 (R6), [V3.S4] + + VTRN1 V1.S4, V0.S4, V4.S4 + VTRN2 V1.S4, V0.S4, V5.S4 + VTRN1 V3.S4, V2.S4, V6.S4 + VTRN2 V3.S4, V2.S4, V7.S4 + + VTRN1 V6.D2, V4.D2, V8.D2 + VTRN1 V7.D2, V5.D2, V9.D2 + VTRN2 V6.D2, V4.D2, V10.D2 + VTRN2 V7.D2, V5.D2, V11.D2 + + VST1 [V8.S4], (R7) + ADD R3, R7 + VST1 [V9.S4], (R7) + ADD R3, R7 + VST1 [V10.S4], (R7) + ADD R3, R7 + VST1 [V11.S4], (R7) + + ADD $4, R5 + CMP R2, R5 + BLT xloop + + ADD $4, R4 + CMP R2, R4 + BLT yloop + + RET diff --git a/third_party/h265/hevc/transpose_riscv64.s b/third_party/h265/hevc/transpose_riscv64.s new file mode 100644 index 00000000..975b7617 --- /dev/null +++ b/third_party/h265/hevc/transpose_riscv64.s @@ -0,0 +1,42 @@ +//go:build riscv64 && riscv64.rva23u64 && !noasm + +#include "textflag.h" + +// func transposeRVV(dst, src *int32, n int) +// +// Transposes an n by n block of int32. A row is read contiguously and written +// with the strided store, so no shuffle network is needed. +TEXT ·transposeRVV(SB), NOSPLIT, $0-24 + MOV dst+0(FP), X10 + MOV src+8(FP), X11 + MOV n+16(FP), X12 + + SLLI $2, X12, X13 + + MOV X12, X14 + MOV X10, X15 + +rows: + MOV X12, X16 + MOV X11, X17 + MOV X15, X18 + +cols: + VSETVLI X16, E32, M1, TA, MA, X19 + + VLE32V (X17), V1 + VSSE32V V1, X13, (X18) + + SLLI $2, X19, X20 + ADD X20, X17 + MUL X13, X19, X20 + ADD X20, X18 + SUB X19, X16 + BNEZ X16, cols + + ADD X13, X11 + ADD $4, X15 + ADD $-1, X14 + BNEZ X14, rows + + RET diff --git a/third_party/h265/hevc/wavefront.go b/third_party/h265/hevc/wavefront.go new file mode 100644 index 00000000..5802b79e --- /dev/null +++ b/third_party/h265/hevc/wavefront.go @@ -0,0 +1,281 @@ +package hevc + +import ( + "runtime" + "sync" +) + +// Threads bounds the goroutines decoding one picture's wavefront rows. Zero +// means GOMAXPROCS, one decodes serially. It is read once per slice segment, +// so changing it mid-stream takes effect at the next one. +func (d *Decoder) Threads(n int) { d.threads = n } + +func (d *Decoder) waveThreads() int { + if d.threads == 0 { + return runtime.GOMAXPROCS(0) + } + + return max(d.threads, 1) +} + +// waveWorkers reports how many goroutines 7.3.8.1 may be spread over. The +// wavefront path takes whole rows of one tile, so anything else — tiles, a +// segment starting mid-row, a single row — stays on the serial loop. +func (d *ctuDecoder) waveWorkers(sh *sliceHeader, starts []int, wpp bool) int { + w := int(d.s.picWidthInCtbs) + + // 9.3.1 hands a row the state of the block above-right, which a picture one + // block wide does not have: every row initialises instead, and each one + // needs the whole row above reconstructed before it starts. + if !wpp || d.p.tilesEnabled || len(starts) < 2 || w < 2 { + return 1 + } + + // A dependent segment carries the contexts the previous one ended with, + // and persistent Rice adaptation carries a running state; neither is + // exercised by anything here, so both stay on the serial loop. + if d.p.dependentSliceSegmentsEnabled || d.s.persistentRiceAdaptation { + return 1 + } + + if int(sh.sliceSegmentAddress)%w != 0 { + return 1 + } + + if int(sh.sliceSegmentAddress)/w+len(starts) > int(d.s.picHeightInCtbs) { + return 1 + } + + return min(d.threads, len(starts)) +} + +// wave is the row progress a wavefront synchronises on. done[k] counts the +// coding tree blocks of row k that are reconstructed, and ctx[k] is the +// context state 9.3.1 hands to the row below, valid once done[k] reaches two. +type wave struct { + mu sync.Mutex + cond *sync.Cond + done []int + ctx [][nContexts]uint8 + err error + bad bool +} + +func newWave(rows, first int) *wave { + v := &wave{ + done: make([]int, rows), + ctx: make([][nContexts]uint8, rows), + } + + v.cond = sync.NewCond(&v.mu) + v.done[0] = first + + return v +} + +// await blocks until row k has reconstructed n blocks, reporting false when +// another row has already failed and there is nothing left to wait for. +func (v *wave) await(k, n int) bool { + v.mu.Lock() + defer v.mu.Unlock() + + for v.done[k] < n && !v.bad { + v.cond.Wait() + } + + return !v.bad +} + +func (v *wave) advance(k, n int) { + v.mu.Lock() + v.done[k] = n + v.mu.Unlock() + + v.cond.Broadcast() +} + +// saveCtx publishes the state of 9.3.1 together with the progress that makes +// it readable, so a waiting row never sees one without the other. +func (v *wave) saveCtx(k int, state [nContexts]uint8, n int) { + v.mu.Lock() + v.ctx[k] = state + v.done[k] = n + v.mu.Unlock() + + v.cond.Broadcast() +} + +func (v *wave) fail(err error) { + v.mu.Lock() + + if v.err == nil { + v.err = err + } + + v.bad = true + v.mu.Unlock() + + v.cond.Broadcast() +} + +func (v *wave) failed() bool { + v.mu.Lock() + defer v.mu.Unlock() + + return v.bad +} + +// decodeWavefront is 7.3.8.1 with the rows of the segment decoded at once. +// Each row runs on its own copy of the block decoder: the tables indexed by +// position are slices and stay shared, while the scratch and the arithmetic +// decoder are values and come out per row. +func (d *ctuDecoder) decodeWavefront(nal NALUnit, sh *sliceHeader, starts []int, + cur int32, workers int, +) error { + w := int(d.s.picWidthInCtbs) + rows := len(starts) + first := int(sh.sliceSegmentAddress) / w + + v := newWave(rows, 0) + + var wg sync.WaitGroup + + for id := range workers { + wg.Add(1) + + go func() { + defer wg.Done() + + r := *d + + for k := id; k < rows; k += workers { + if v.failed() { + return + } + + if err := r.decodeWaveRow(nal, sh, starts, v, k, first+k, cur); err != nil { + v.fail(err) + + return + } + } + }() + } + + wg.Wait() + + if v.err != nil { + return v.err + } + + if rows > 1 { + d.saved = v.ctx[rows-1] + d.hasSaved = true + } + + return nil +} + +// decodeWaveRow decodes one coding tree block row of the segment. +func (d *ctuDecoder) decodeWaveRow(nal NALUnit, sh *sliceHeader, starts []int, v *wave, + k, row int, cur int32, +) error { + w := int(d.s.picWidthInCtbs) + + if err := d.startSubstream(nal, sh, starts, k, k == 0); err != nil { + return err + } + + if k > 0 { + // The row above has to reach its second block before this one can + // start, which is both the context handoff and the samples 6.4.1 needs. + if !v.await(k-1, 2) { + return nil + } + + top := row*w - w + 1 + if top >= d.sliceAddrRs { + d.c.state = v.ctx[k-1] + } else { + d.c.initContexts(sh.qpY, sh.sliceType, sh.cabacInit) + } + } + + d.qpYPrev = sh.qpY + d.qpYCur = sh.qpY + + for col := range w { + if k > 0 && !v.await(k-1, min(col+2, w)) { + return nil + } + + rs := row*w + col + + d.ctbSliceAddr[rs] = int32(d.sliceAddrRs) + d.ctbSlice[rs] = cur + + if err := d.codingTreeUnit(col< 0 { + if !v.await(k-1, min(2, cols)) { + return nil, nil + } + + e.cabac.state = v.ctx[k-1] + } + + for x := range cols { + if k > 0 && !v.await(k-1, min(x+2, cols)) { + return nil, nil + } + + e.writeSAO(&e.cabac, k*cols+x, cols) + + if err := e.tree(x*64, k*64, 6, 0); err != nil { + return nil, err + } + + last := k == rows-1 && x == cols-1 + e.cabac.encodeTerminate(boolToBit(last)) + + if x == min(1, cols-1) { + v.saveCtx(k, e.cabac.state, x+1) + } else { + v.advance(k, x+1) + } + + if !last && x == cols-1 { + e.cabac.encodeTerminate(1) + } + } + + return e.cabac.bytes(), nil +} diff --git a/todos.md b/todos.md index 4d17c001..a558d8d5 100644 --- a/todos.md +++ b/todos.md @@ -8,10 +8,9 @@ All four safeguards are implemented: bounded ICO selection, SVG expansion limits enforced WASM AVIF selection, and GIF memory accounting. Full `make verify` and both Windows internal-package cross-builds pass. Hosted review, finding dispositions and final CI/Qodana/CodeQL evidence are tracked in -[PR #27](https://github.com/frathe/picfetch/pull/27). The live GoLand build-tag -refresh and re-inspection remain pending; the committed Qodana configuration -passed hosted analysis. Reload/apply the local `no_emoji nodynamic` module tags -and inspect the imaging import again before closing this remaining local task. +[PR #27](https://github.com/frathe/picfetch/pull/27). The local `no_emoji nodynamic` module tags were applied in GoLand on +2026-09-16 and the imaging loader re-inspects clear; the committed Qodana +configuration passed hosted analysis. Compatibility limits and evidence: [implementation plan](finished_refactorings/2026-09-15-image-input-hardening.md). @@ -83,12 +82,93 @@ new identifier `frathe.picfetch` before the next WinGet publication; See the ### Reconsider HEIC support after licensing and security qualification -HEIC/HEIF is currently unsupported. A future restoration needs a documented -distribution grant or another qualified decoder, containment and platform -verification. The previous fork-upgrade watch [MA-023](needs_refactoring.md#ma-023) +HEIC/HEIF remains default-off. Experimental activation is being implemented +under the accepted September 16 spec; distribution clearance, containment and +platform verification remain required. The previous fork-upgrade watch [MA-023](needs_refactoring.md#ma-023) is closed by removal. Retained [options and source evidence](docs/find-more-like-this/old-heic-wasm-options.md) do not authorize restoring a decoder. +Restoration work is authorized and tracked in +[the active plan](plans/2026-09-15-isolated-heic-restoration.md) and +[draft PR #28](https://github.com/frathe/picfetch/pull/28). The signed `52ed2df` +checkpoint publishes the maintained-source WASI guest, native helper boundaries, +shared GUI/analysis admission, canonical imaging integration and authenticated +package/update handling. The current opt-in work now constructs an owner only +after an enabled preference and validated installed package. + +- [ ] Complete the [Experimental HEIC opt-in spec](.scratch/experimental-heic-opt-in/spec.md): + default-off, restart-only activation, immutable session admission and verified + private Windows helper staging are integrated. The [handoff](.scratch/experimental-heic-opt-in/handoff.md) + records 48/57 completed checklist items; tickets 01/02/03/04/06/07 are resolved. + At `c1b6890`, all six standalone native platform/architecture targets, all four + Linux race partitions, validation, ordinary Windows and Store input construction + pass on first attempt. New evidence covers uncached duplicate/Spiral pixels, + active-analysis Settings/source replacement, native directory navigation, + foreground cancellation, invalid-package recovery and macOS readiness refusal. + GoLand, local build/provenance/import checks, shard inventory and dependency + scans pass. Fresh Codex code/security reviews are clean; final Qodana has zero + results and CodeQL only its two previously dismissed false positives. + Remaining: Windows application-level private-storage/ACL and failed-query + recovery plus concurrent application lifetimes (08), installed-MSIX activation + (05), and final integrated qualification (09). Both MSIX packages install but + their test processes still fail to start with Access denied. Split fixture + preparation from execution and qualify native x64/ARM64 desktops owned by the + standard account; a loaded profile under alternate credentials is insufficient. + The same-owner fresh-logon experiment at `98d3895` also failed: both hosted + desktops use a protected account that Windows refuses to remove from + Administrators (0xC0000124). Explicit-token launch attempts also stalled before + the standard-user script began, including without console creation. All failed + launcher/account experiments were removed; package-specific Windows events + are retained with the restored original fixture. Both gates remain failing. + Suitable environment access is still unresolved. The separate GitHub AI scanner + still fails before analysis with an unsupported-model HTTP 400. Keep all failing + gates and permission checks. The [active plan](plans/2026-09-16-experimental-heic-opt-in.md) + and [activation record](docs/heic/experimental-opt-in.md) retain exact evidence. + The earlier ARM64 analysis failure remains unexplained despite the fresh pass. + Production signing, licensing/distribution, broader camera/color qualification, + the accepted macOS total-memory limit and the full-reinstallation/qualified- + bridge requirement remain separate release constraints. + +The September 16 [history reconciliation](docs/heic/history-reconciliation.md) +accounts for `fc127b44` and `73cb3c9`: all 107 production/source-license files +were preserved byte for byte. It restores the current app-wide threat model, +Qodana YAML guard, four ordinary fixtures and Fyne metadata ignore rule. The +historical eager WASM allocation option was withdrawn after fresh native Linux +CI failed; existing memory ceilings and sandbox policy remain intact. Focused race/interpreter tests, native Apple Silicon +guards, build/provenance/import checks and GoLand inspections pass. No complete +historical decoder-suite equivalence is claimed. + +Remaining qualification: + +- At code checkpoint `ee5cc67`, all hosted CI passes: every Linux race shard, + Linux and Windows amd64/arm64 helper guards, macOS Intel/Apple Silicon native + guards, ordinary Windows and validation. The signing-job security finding is + fixed in `c77d9bd` and resolved; subsequent code/security rounds are clean. + Qodana and CodeQL checks pass. Final evidence and review of the documentation + follow-up are tracked in [PR #28](https://github.com/frathe/picfetch/pull/28) + and the active plan. No broad local race suite was duplicated. +- Ronin accepted the absent hard total native-memory cap on macOS. WASM, + transport, deadline and sandbox controls remain mandatory; helper-only + executable-memory permission is required. The compiler succeeds on the + ordinary 12MP fixture; the interpreter reaches the finite deadline. +- Complete distribution-package, Windows Authenticode/MSIX and upgrade + qualification remain open. Nonadministrator launch-time loopback queries pass + in standalone Windows native CI on both architectures; installed-MSIX execution + is still unqualified. The released updater drops the new helper and + deletes staging, leaving an invalid macOS enclosing signature. The first + transition requires a complete reinstall or a separately qualified bridge. +- [Compatibility evidence](docs/heic/compatibility-2026-09-16.md) covers ordinary + synthetic metadata/orientation, alpha and ten-bit transport plus one verified + Samsung S23 Ultra photo. Broader camera coverage remains open. Faithful + ICC/wide-gamut/PQ/HLG/gain-map display is absent and those color classes are + not deliberately rejected; more fixtures alone cannot establish it. +- The initial cloud foundation commit is unsigned; the PR's signing prerequisite + remains open. No history rewrite, merge or release is + authorized by this reconciliation. + +See [qualification evidence](docs/heic/qualification.md) and the current +[threat model](THREAT-MODEL.md) for controls and accepted limitations. + The [independent alternatives](docs/image-codec-alternatives-2026-09-15.md#heic-alternatives) include libheif/libde265 with LGPL distribution work and hpvcd with unresolved table provenance and security/platform qualification. Ronin prefers avoiding @@ -96,7 +176,7 @@ gen2brain replacements. The authorized review of h265 v0.2.3 found an invalid result invariant, incomplete translated-source provenance and unresolved HEVC patent obligations. Its still decoder can also fall back to sequence decoding, so a future adapter must reject sequences explicitly. The local fix does not -qualify the library; HEIC remains disabled. +qualify the library; experimental activation is tracked separately above. ### Retire the GitHub-hosted Intel macOS runner before August 2027 diff --git a/translations/de.json b/translations/de.json index 7792662d..620dd51d 100644 --- a/translations/de.json +++ b/translations/de.json @@ -494,5 +494,9 @@ "Usage is incomplete.": "Speicherverbrauch ist unvollständig erfasst.", "Processed %d records (%.1f MB).": "%d Einträge (%.1f MB) verarbeitet.", "Working...": "Wird verarbeitet...", - "Apply cache limit": "Cache-Limit anwenden" + "Apply cache limit": "Cache-Limit anwenden", + "Experimental": "Experimentell", + "Experimental HEIC support": "Experimentelle HEIC-Unterstützung", + "Restart PicFetch to apply. Colors may be inaccurate; HDR is not supported.": "Zum Anwenden PicFetch neu starten. Farben können ungenau sein; HDR wird nicht unterstützt.", + "Experimental HEIC support is unavailable. Check the installed helper package and sandbox permissions.": "Die experimentelle HEIC-Unterstützung ist nicht verfügbar. Prüfe das installierte Hilfsprogramm-Paket und die Sandbox-Berechtigungen." } diff --git a/translations/en.json b/translations/en.json index 0fb38a4b..c78c57db 100644 --- a/translations/en.json +++ b/translations/en.json @@ -494,5 +494,9 @@ "Usage is incomplete.": "Usage is incomplete.", "Processed %d records (%.1f MB).": "Processed %d records (%.1f MB).", "Working...": "Working...", - "Apply cache limit": "Apply cache limit" + "Apply cache limit": "Apply cache limit", + "Experimental": "Experimental", + "Experimental HEIC support": "Experimental HEIC support", + "Restart PicFetch to apply. Colors may be inaccurate; HDR is not supported.": "Restart PicFetch to apply. Colors may be inaccurate; HDR is not supported.", + "Experimental HEIC support is unavailable. Check the installed helper package and sandbox permissions.": "Experimental HEIC support is unavailable. Check the installed helper package and sandbox permissions." }