Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 24 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,21 @@ jobs:
path: Halcyon/build/ci-logs/**
if-no-files-found: warn

# Two macOS legs, one per architecture. BOTH run on macos-14 (Apple silicon):
# the x64 leg is a CROSS-COMPILE (`--macos-arch x86_64`, rendered by
# scripts/ci/targets.py), not an Intel runner. That is deliberate — it matches
# how the capability was first proved locally and avoids depending on the
# retiring macos-13 Intel image — and it has a stated cost: the functional FFI
# probe cannot load an x86_64 dylib from an arm64 process, so the x64 leg's
# assertion list omits it in data and gates on Mach-O header + nm symbol
# checks instead. A green x64 leg therefore does NOT claim runtime loadability
# on real Intel hardware. See the "assertions" comment in targets.py.
build:
name: Build macOS (release)
name: Build ${{ matrix.target }} (release)
strategy:
fail-fast: false # lets the sibling arch leg finish; a leg still fails when it fails
matrix:
target: [macos, macos-x64]
runs-on: macos-14
steps:
- uses: actions/checkout@v4
Expand All @@ -92,20 +105,22 @@ jobs:
channel: 'stable'
# R-8 cache added in F6 after R-7 assertions are green (Spec §6)

- name: ci.py provision --target macos
run: python3 scripts/ci.py provision --target macos
- name: ci.py provision
run: python3 scripts/ci.py provision --target ${{ matrix.target }}

- name: ci.py build --target macos
run: python3 scripts/ci.py build --target macos
- name: ci.py build
run: python3 scripts/ci.py build --target ${{ matrix.target }}

- name: ci.py assert-capabilities --target macos
run: python3 scripts/ci.py assert-capabilities --target macos
- name: ci.py assert-capabilities
run: python3 scripts/ci.py assert-capabilities --target ${{ matrix.target }}

- name: Upload ci-logs (build macos)
# Names must be unique across matrix legs or upload-artifact@v4 rejects the
# collision, hence the matrix value in the name.
- name: Upload ci-logs (build ${{ matrix.target }})
uses: actions/upload-artifact@v4
if: always()
with:
name: ci-logs-build-macos
name: ci-logs-build-${{ matrix.target }}
path: Halcyon/build/ci-logs/**
if-no-files-found: warn

Expand Down
13 changes: 10 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,16 @@ jobs:
fail-fast: false # lets sibling matrix legs finish; a leg still fails when it fails
matrix:
include:
- {os: macos-14, target: macos, archive: 'Halcyon-macos-arm64-'}
- {os: windows-latest, target: windows, archive: 'Halcyon-windows-x64-'}
- {os: ubuntu-latest, target: linux, archive: 'Halcyon-linux-x64-'}
# macos-x64 runs on macos-14 too: it is a CROSS-COMPILE
# (--macos-arch x86_64, rendered from scripts/ci/targets.py), not an
# Intel runner. Its archive prefix must differ from the arm64 leg's or
# the two would overwrite each other on the release; the
# `test_macos_legs_do_not_collide_on_archive_name` selftest asserts
# that mechanically against targets.py.
- {os: macos-14, target: macos, archive: 'Halcyon-macos-arm64-'}
- {os: macos-14, target: macos-x64, archive: 'Halcyon-macos-x64-'}
- {os: windows-latest, target: windows, archive: 'Halcyon-windows-x64-'}
- {os: ubuntu-latest, target: linux, archive: 'Halcyon-linux-x64-'}
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
Expand Down
43 changes: 39 additions & 4 deletions lib/services/image_pipeline/decoded_rgba_image_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,17 @@ Future<ui.Image> decodedRgbaToImage(
// below: a slow or never-granted slot must not be able to leave a ~50MB
// `ui.Image` parked with no owner. AC7: an identity orientation composites
// nothing, so it buys nothing.
if (!_ExifTransform.forOrientation(exifOrientation).isIdentity) {
final residual = residualExifOrientation(
declared: exifOrientation,
applied: rgba.appliedOrientation,
);
if (!_ExifTransform.forOrientation(residual).isIdentity) {
await gate();
}
final raw = await _imageFromPixels(rgba);
late final ui.Image oriented;
try {
oriented = await applyExifOrientation(raw, exifOrientation);
oriented = await applyExifOrientation(raw, residual);
} catch (_) {
raw.dispose();
rethrow;
Expand Down Expand Up @@ -149,7 +153,12 @@ Future<PixelPayload> decodedRgbaToPixelPayload(
CompositeGate gate = immediateCompositeGate,
}) async {
_assertDecodedBufferLength(decoded);
final transform = _ExifTransform.forOrientation(exifOrientation);
final transform = _ExifTransform.forOrientation(
residualExifOrientation(
declared: exifOrientation,
applied: decoded.appliedOrientation,
),
);

// SHORT-CIRCUIT. With an identity transform and no downscale to apply, the
// old code uploaded ~50MB to the GPU, drew nothing new, and read ~50MB back
Expand Down Expand Up @@ -255,7 +264,33 @@ Future<OrientedFullRes> decodedRgbaToOrientedFullRes(
CompositeGate gate = immediateCompositeGate,
}) async {
_assertDecodedBufferLength(decoded);
final transform = _ExifTransform.forOrientation(exifOrientation);
final residual = residualExifOrientation(
declared: exifOrientation,
applied: decoded.appliedOrientation,
);
final transform = _ExifTransform.forOrientation(residual);

// PROBE 1 (jank-rootcause-analysis.md §6): the REAL EXIF orientation and
// whether it forces a GPU pass. `req_end`'s `exifOrientation=` is null on
// every RAW item by construction (photo_source.dart's decode arm reports
// "nothing to carry forward", not "no rotation"), so the log could not tell
// a rotated item from an identity one -- which is exactly the split that
// decides whether this item pays two full-frame on-isolate copies. Logged
// here because this is the single place that knows both facts.
//
// `applied=`/`residual=` (Task 7, native-rotation-spec.md §1.4): appended
// fields, existing field names/meanings frozen. `rotated` still means "a
// GPU pass will run" -- now decided by the RESIDUAL, not the declared
// value, so a natively-oriented RAW frame correctly reports `rotated=false`.
if (PerfLog.enabled) {
PerfLog.log(
'orient|exif=$exifOrientation'
'|applied=${decoded.appliedOrientation}'
'|residual=$residual'
'|rotated=${!transform.isIdentity}'
'|bytes=${decoded.rgba.lengthInBytes}',
);
}

// Same short-circuit as decodedRgbaToPixelPayload's: nothing to rotate and
// nothing to scale means there is nothing for the GPU to do.
Expand Down
29 changes: 29 additions & 0 deletions lib/services/image_pipeline/dng_decode_contract.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class DecodedRgba {
this.nativeAddress = 0,
this.nativeKeepAlive,
this.releaseNative,
this.appliedOrientation = 1,
});

/// RGBA8 interleaved, length == width * height * 4.
Expand Down Expand Up @@ -56,13 +57,41 @@ class DecodedRgba {
/// retained `PixelPayload` IS this buffer (the identity short-circuit in
/// `decodedRgbaToOrientedFullRes`).
final void Function()? releaseNative;

/// The EXIF orientation the DECODER has already applied to [rgba], or 1
/// when it applied none. Never a request; always a report. Halcyon applies
/// only the RESIDUAL (`residualExifOrientation` in `exif_orientation.dart`),
/// so a decoder that ignores the request, a build whose dylib predates the
/// oriented entry, and the pure-Dart TIFF arm are all correct without a
/// feature flag. Defaults to 1 so every existing construction site
/// (production and every fake decoder in the test suite) is unaffected.
final int appliedOrientation;
}

/// Decodes a DNG that carries no embedded full-size JPEG preview.
///
/// Throws on failure; callers treat any throw as "fall back to the old path".
typedef DngFullDecoder = Future<DecodedRgba> Function(String path);

/// Orientation-aware sibling of [DngFullDecoder].
///
/// A SECOND typedef, not a widened [DngFullDecoder] -- Dart's function-type
/// subtyping means adding even an OPTIONAL named parameter to a typedef
/// breaks every existing closure assigned to it (see the erratum recorded at
/// `payload_reencoder.dart:13-22`, where `Enc e = fakeOld;` is a compile
/// error after such a widening). [DngFullDecoder]'s declaration therefore
/// stays byte-identical, and this is a separate, additional seam -- exactly
/// as `PointerPayloadEncoder` is the separate sibling of `PayloadEncoder`.
///
/// [exifOrientation] is the DECLARED orientation (from Halcyon's own IFD0
/// walk); the returned [DecodedRgba.appliedOrientation] reports what the
/// decoder actually did with it, which may be less than requested (or
/// nothing at all, on an older dylib or a non-RAW arm).
typedef DngOrientingFullDecoder = Future<DecodedRgba> Function(
String path, {
required int exifOrientation,
});

/// The app's only defence against an OOM from a container header that claims
/// an absurd extent: refuse when `width * height * 4` exceeds this many bytes.
///
Expand Down
76 changes: 65 additions & 11 deletions lib/services/image_pipeline/dng_decode_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'package:ceyx/ceyx.dart';
import 'package:flutter/foundation.dart';

import '../../perf/perf_log.dart';
import '../platform/working_set_trim.dart';
import 'dng_decode_contract.dart';

/// P2: routes the [DngFullDecoder] seam through ceyx's persistent worker pool
Expand Down Expand Up @@ -59,7 +60,7 @@ bool decodePoolEnabledFor(String raw) =>
raw != '0' && raw != 'false' && raw != 'off';

Future<DecodedRgba> decodeDngFull(String path) async {
_ensurePoolLogger();
ensureHalcyonDecodePoolConfigured();
final image = kDecodePoolEnabled
? await CeyxDecodePool.shared.decode(path)
// LEGACY ARM: one isolate spawn + one dylib load per decode. Kept
Expand Down Expand Up @@ -97,15 +98,68 @@ Future<DecodedRgba> decodeDngFull(String path) async {
/// `image_preload_controller.dart`.
const DngFullDecoder halcyonDngFullDecoder = decodeDngFull;

bool _poolLoggerInstalled = false;
/// Task 8 (native-rotation-spec) production binding for
/// [DngOrientingFullDecoder]. The pinned ceyx package in this tree today has
/// NO oriented decode entry (that arrives with spec Tasks 3-5's pin bump) --
/// so THIS ROUND it is a thin pass-through: run today's unoriented decode and
/// report `appliedOrientation: 1`, exactly like every existing fake decoder
/// default. Halcyon applies the whole declared orientation via the residual
/// (`residualExifOrientation`), so behaviour is byte-identical to the
/// `dngDecoder`-only path -- this only exists so `PhotoSource` has a seam to
/// call, wired end-to-end, ready for the oriented pool entry to drop in here
/// once the pin is bumped.
///
/// ponytail: one function, no branching on decodeIntoBufferOrientedAvailable
/// yet -- there is nothing to branch on until Task 5 lands. Add the guarded
/// lookup then, not before (referencing a not-yet-existing ceyx symbol here
/// would break the build today).
Future<DecodedRgba> decodeDngFullOriented(
String path, {
required int exifOrientation,
}) => decodeDngFull(path);

const DngOrientingFullDecoder halcyonOrientingDngFullDecoder =
decodeDngFullOriented;

bool _poolConfigured = false;

/// One-time process configuration of the ceyx decode pool. Idempotent; called
/// from every entry point below, so no startup ordering has to be maintained.
///
/// Does three things:
///
/// 1. **Wires the native buffer pool (R6, Task #9, user ruling 2026-09-06).**
/// `CeyxDecodePool.nativeBufferPool` defaults to null in the ceyx package,
/// and every pooled-route gate short-circuits on that null
/// (`decode_pool.dart:532-535`). Until this assignment existed the whole
/// WP6/WP10 decode-into route was reachable only from ceyx's own tests:
/// production decodes fell back to the legacy native allocator, and nothing
/// was red anywhere — the route was shipped, tested, and carrying zero
/// traffic. The assignment lives HERE rather than as a default inside ceyx
/// because a library must not decide on its own to hold eight ~100MB
/// resident slots for every consumer; the host app owns that budget.
///
/// 2. **Suppresses the idle working-set trim.** See
/// [WorkingSetTrim.suppressed]: idle trimming pages out exactly the idle
/// pooled slots the pool keeps resident for immediate reuse. The
/// folder-switch trim (`trimNow`) is deliberately left enabled.
///
/// 3. **Routes pool events into the perf log and the console.** A silently
/// narrowed pool is exactly the defect class this loudness exists to
/// prevent, so it is deliberately not gated on `PerfLog.enabled`.
void ensureHalcyonDecodePoolConfigured() {
// RE-ASSERTED on every call, deliberately NOT behind the latch below. These
// two are process invariants held in mutable statics that other code (and
// any test helper) can clear; two stores are free, whereas a latched
// assignment that something else resets afterwards leaves the pooled route
// silently off — which is the exact failure this whole task exists to fix.
// The latch guards only the closure allocations, which is all it was ever
// for.
CeyxDecodePool.nativeBufferPool = CeyxNativeBufferPool.shared;
WorkingSetTrim.suppressed = true;

/// Routes pool events (ready / worker died / respawn / narrowing) into the
/// perf log AND onto the console. A silently narrowed pool is exactly the
/// defect class this loudness exists to prevent, so it is deliberately not
/// gated on `PerfLog.enabled`.
void _ensurePoolLogger() {
if (_poolLoggerInstalled) return;
_poolLoggerInstalled = true;
if (_poolConfigured) return;
_poolConfigured = true;
CeyxDecodePool.logger = (line) {
PerfLog.log(line);
debugPrint('[ceyx-pool] $line');
Expand Down Expand Up @@ -134,7 +188,7 @@ void _ensurePoolLogger() {
/// resulting throw, so a superseded decode can never write a permanent-miss
/// latch into the newly opened folder's state.
void bumpHalcyonDecodePoolGeneration() {
_ensurePoolLogger();
ensureHalcyonDecodePoolConfigured();
CeyxDecodePool.shared.bumpGeneration();
}

Expand Down Expand Up @@ -166,7 +220,7 @@ List<int>? halcyonDecodeWidthRecommendations() {
/// clamps it against the machine's recommended width; that recommendation is
/// displayed in settings and is advisory only.
void setHalcyonDecodePoolWidth(int width) {
_ensurePoolLogger();
ensureHalcyonDecodePoolConfigured();
CeyxDecodePool.shared.width = width;
// Requested, not effective: the effective value arrives asynchronously as a
// worker ack and is logged by the pool logger installed above. Logging both
Expand Down
Loading
Loading