luvix 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- luvix/__init__.py +8 -0
- luvix/__main__.py +6 -0
- luvix/backends/__init__.py +0 -0
- luvix/backends/apple/__init__.py +0 -0
- luvix/backends/apple/bridge.py +43 -0
- luvix/backends/apple/hdr_probe.py +39 -0
- luvix/backends/apple/hdr_probe.swift +121 -0
- luvix/backends/apple/hdr_raster.py +37 -0
- luvix/backends/apple/hdr_raster.swift +99 -0
- luvix/backends/apple/icc_profile.py +78 -0
- luvix/backends/apple/icc_profile.swift +31 -0
- luvix/backends/apple/imageio.swift +143 -0
- luvix/cli.py +208 -0
- luvix/doctor.py +96 -0
- luvix/hdr/__init__.py +1 -0
- luvix/hdr/export.py +108 -0
- luvix/hdr/pq.py +39 -0
- luvix/hdr/pq_icc.py +75 -0
- luvix/hdr/pq_profile.py +50 -0
- luvix/hdr/tiff.py +150 -0
- luvix-0.1.1.dist-info/METADATA +97 -0
- luvix-0.1.1.dist-info/RECORD +25 -0
- luvix-0.1.1.dist-info/WHEEL +4 -0
- luvix-0.1.1.dist-info/entry_points.txt +2 -0
- luvix-0.1.1.dist-info/licenses/LICENSE +201 -0
luvix/__init__.py
ADDED
luvix/__main__.py
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Compile and execute the macOS ImageIO backend on demand."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import platform
|
|
7
|
+
import subprocess
|
|
8
|
+
from importlib.resources import files
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BackendError(RuntimeError):
|
|
13
|
+
"""Native backend failed."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def backend_binary() -> Path:
|
|
17
|
+
if platform.system() != "Darwin":
|
|
18
|
+
raise BackendError("Apple HDR backend requires macOS; no portable backend yet")
|
|
19
|
+
source = Path(str(files("luvix.backends.apple").joinpath("imageio.swift")))
|
|
20
|
+
digest = hashlib.sha256(source.read_bytes()).hexdigest()[:16]
|
|
21
|
+
target = Path.home() / ".cache" / "luvix" / f"imageio-{digest}"
|
|
22
|
+
if not target.exists():
|
|
23
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
result = subprocess.run(
|
|
25
|
+
["swiftc", "-O", str(source), "-o", str(target)],
|
|
26
|
+
capture_output=True,
|
|
27
|
+
text=True,
|
|
28
|
+
check=False,
|
|
29
|
+
)
|
|
30
|
+
if result.returncode:
|
|
31
|
+
raise BackendError(f"Swift compilation failed:\n{result.stderr}")
|
|
32
|
+
return target
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def run_backend(*args: str) -> str:
|
|
36
|
+
result = subprocess.run(
|
|
37
|
+
[str(backend_binary()), *map(str, args)], capture_output=True, text=True, check=False
|
|
38
|
+
)
|
|
39
|
+
if result.returncode:
|
|
40
|
+
raise BackendError(
|
|
41
|
+
result.stderr.strip() or result.stdout.strip() or "Native backend failed"
|
|
42
|
+
)
|
|
43
|
+
return result.stdout.strip()
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Build and run the isolated macOS HDR decode diagnostic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import platform
|
|
7
|
+
import subprocess
|
|
8
|
+
from importlib.resources import files
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from luvix.backends.apple.bridge import BackendError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run_hdr_probe(source: Path, samples: int) -> str:
|
|
15
|
+
"""Return JSON from the native HDR-aware decode probe."""
|
|
16
|
+
if platform.system() != "Darwin":
|
|
17
|
+
raise BackendError("HDR-aware ImageIO decoding requires macOS")
|
|
18
|
+
swift = Path(str(files("luvix.backends.apple").joinpath("hdr_probe.swift")))
|
|
19
|
+
digest = hashlib.sha256(swift.read_bytes()).hexdigest()[:16]
|
|
20
|
+
binary = Path.home() / ".cache" / "luvix" / f"hdr-probe-{digest}"
|
|
21
|
+
if not binary.exists():
|
|
22
|
+
binary.parent.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
result = subprocess.run(
|
|
24
|
+
["swiftc", "-O", str(swift), "-o", str(binary)],
|
|
25
|
+
capture_output=True,
|
|
26
|
+
text=True,
|
|
27
|
+
check=False,
|
|
28
|
+
)
|
|
29
|
+
if result.returncode:
|
|
30
|
+
raise BackendError(f"Swift compilation failed:\n{result.stderr}")
|
|
31
|
+
result = subprocess.run(
|
|
32
|
+
[str(binary), "inspect-hdr", str(source), str(samples)],
|
|
33
|
+
capture_output=True,
|
|
34
|
+
text=True,
|
|
35
|
+
check=False,
|
|
36
|
+
)
|
|
37
|
+
if result.returncode:
|
|
38
|
+
raise BackendError(result.stderr.strip() or "HDR probe failed")
|
|
39
|
+
return result.stdout.strip()
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import CoreGraphics
|
|
3
|
+
import CoreImage
|
|
4
|
+
import ImageIO
|
|
5
|
+
|
|
6
|
+
func fail(_ message: String) -> Never {
|
|
7
|
+
fputs("Error: \(message)\n", stderr)
|
|
8
|
+
exit(1)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
let args = CommandLine.arguments
|
|
12
|
+
guard args.count == 4, let count = Int(args[3]), (1...64).contains(count) else {
|
|
13
|
+
fail("Usage: hdr_probe inspect-hdr SOURCE.HEIC SAMPLES(1..64)")
|
|
14
|
+
}
|
|
15
|
+
let url = URL(fileURLWithPath: args[2])
|
|
16
|
+
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else {
|
|
17
|
+
fail("Cannot open source")
|
|
18
|
+
}
|
|
19
|
+
guard CGImageSourceCopyAuxiliaryDataInfoAtIndex(
|
|
20
|
+
source, 0, kCGImageAuxiliaryDataTypeHDRGainMap
|
|
21
|
+
) != nil else { fail("No Apple HDR gain map found") }
|
|
22
|
+
|
|
23
|
+
guard let linearP3 = CGColorSpace(name: CGColorSpace.extendedLinearDisplayP3) else {
|
|
24
|
+
fail("Extended linear Display P3 unavailable")
|
|
25
|
+
}
|
|
26
|
+
let context = CIContext(options: [
|
|
27
|
+
.workingColorSpace: linearP3,
|
|
28
|
+
.outputColorSpace: linearP3,
|
|
29
|
+
.useSoftwareRenderer: false
|
|
30
|
+
])
|
|
31
|
+
|
|
32
|
+
struct Result {
|
|
33
|
+
let report: [String: Any]
|
|
34
|
+
let sampleValues: [Float]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
func measure(_ hdr: Bool) -> Result {
|
|
38
|
+
// ImageIO requires the decode *request* key, with HDR/SDR as its value.
|
|
39
|
+
// Using kCGImageSourceDecodeToHDR as the dictionary key silently ignored
|
|
40
|
+
// the request and produced identical results in both branches.
|
|
41
|
+
let decodeMode = hdr ? kCGImageSourceDecodeToHDR : kCGImageSourceDecodeToSDR
|
|
42
|
+
let options: [CFString: Any] = [kCGImageSourceDecodeRequest: decodeMode]
|
|
43
|
+
guard let decoded = CGImageSourceCreateImageAtIndex(source, 0, options as CFDictionary) else {
|
|
44
|
+
fail("ImageIO decode failed (HDR=\(hdr))")
|
|
45
|
+
}
|
|
46
|
+
let width = decoded.width
|
|
47
|
+
let height = decoded.height
|
|
48
|
+
let ci = CIImage(cgImage: decoded)
|
|
49
|
+
let rowBytes = width * 4 * MemoryLayout<Float>.size
|
|
50
|
+
var pixels = [Float](repeating: 0, count: width * height * 4)
|
|
51
|
+
pixels.withUnsafeMutableBytes { bytes in
|
|
52
|
+
guard let address = bytes.baseAddress else { fail("No bitmap storage") }
|
|
53
|
+
context.render(
|
|
54
|
+
ci,
|
|
55
|
+
toBitmap: address,
|
|
56
|
+
rowBytes: rowBytes,
|
|
57
|
+
bounds: CGRect(x: 0, y: 0, width: width, height: height),
|
|
58
|
+
format: .RGBAf,
|
|
59
|
+
colorSpace: linearP3
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
var minima = [Double](repeating: .infinity, count: 3)
|
|
63
|
+
var maxima = [Double](repeating: -.infinity, count: 3)
|
|
64
|
+
var aboveOne = 0
|
|
65
|
+
var sampled = 0
|
|
66
|
+
var invalid = 0
|
|
67
|
+
let stridePixels = max(1, width * height / 100_000)
|
|
68
|
+
for i in Swift.stride(from: 0, to: width * height, by: stridePixels) {
|
|
69
|
+
let offset = i * 4
|
|
70
|
+
let rgb = (0..<3).map { Double(pixels[offset + $0]) }
|
|
71
|
+
if !rgb.allSatisfy({ $0.isFinite }) { invalid += 1; continue }
|
|
72
|
+
for c in 0..<3 {
|
|
73
|
+
minima[c] = min(minima[c], rgb[c])
|
|
74
|
+
maxima[c] = max(maxima[c], rgb[c])
|
|
75
|
+
}
|
|
76
|
+
if rgb.contains(where: { $0 > 1 }) { aboveOne += 1 }
|
|
77
|
+
sampled += 1
|
|
78
|
+
}
|
|
79
|
+
var points: [[String: Any]] = []
|
|
80
|
+
var sampleValues: [Float] = []
|
|
81
|
+
for i in 0..<count {
|
|
82
|
+
let x = (2 * i + 1) * width / (2 * count)
|
|
83
|
+
let y = height / 2
|
|
84
|
+
let offset = (y * width + x) * 4
|
|
85
|
+
let rgb = (0..<3).map { pixels[offset + $0] }
|
|
86
|
+
sampleValues.append(contentsOf: rgb)
|
|
87
|
+
points.append(["x": x, "y_raw": y, "rgb_linear_p3": rgb.map(Double.init)])
|
|
88
|
+
}
|
|
89
|
+
let report: [String: Any] = [
|
|
90
|
+
"decoded_bits_per_component": decoded.bitsPerComponent,
|
|
91
|
+
"rendered_format": "RGBAf_float32",
|
|
92
|
+
"min_rgb": sampled > 0 ? minima : [0, 0, 0],
|
|
93
|
+
"max_rgb": sampled > 0 ? maxima : [0, 0, 0],
|
|
94
|
+
"pixels_above_one": aboveOne,
|
|
95
|
+
"sampled_pixels": sampled,
|
|
96
|
+
"invalid_pixels": invalid,
|
|
97
|
+
"samples": points
|
|
98
|
+
]
|
|
99
|
+
return Result(report: report, sampleValues: sampleValues)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let sdr = measure(false)
|
|
103
|
+
let hdr = measure(true)
|
|
104
|
+
let maxSampleDifference = zip(sdr.sampleValues, hdr.sampleValues)
|
|
105
|
+
.map { abs(Double($0) - Double($1)) }.max() ?? 0
|
|
106
|
+
let output: [String: Any] = [
|
|
107
|
+
"source": url.lastPathComponent,
|
|
108
|
+
"working_space": "extendedLinearDisplayP3",
|
|
109
|
+
"decode_option": "kCGImageSourceDecodeRequest",
|
|
110
|
+
"sdr": sdr.report,
|
|
111
|
+
"hdr": hdr.report,
|
|
112
|
+
"max_sample_difference": maxSampleDifference,
|
|
113
|
+
"hdr_decode_distinct_at_sample_points": maxSampleDifference > 1e-6,
|
|
114
|
+
"note": "Explicit SDR/HDR ImageIO decode requests; compare measured values before accepting reconstruction."
|
|
115
|
+
]
|
|
116
|
+
guard JSONSerialization.isValidJSONObject(output),
|
|
117
|
+
let data = try? JSONSerialization.data(withJSONObject: output, options: [.sortedKeys]),
|
|
118
|
+
let json = String(data: data, encoding: .utf8) else {
|
|
119
|
+
fail("JSON serialization failed")
|
|
120
|
+
}
|
|
121
|
+
print(json)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Compile and invoke Apple's full-resolution float HDR raster renderer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import platform
|
|
7
|
+
import subprocess
|
|
8
|
+
from importlib.resources import files
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from luvix.backends.apple.bridge import BackendError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def render_hdr_raster(source: Path, prefix: Path) -> None:
|
|
15
|
+
if platform.system() != "Darwin":
|
|
16
|
+
raise BackendError("Apple HDR raster export requires macOS")
|
|
17
|
+
swift = Path(str(files("luvix.backends.apple").joinpath("hdr_raster.swift")))
|
|
18
|
+
digest = hashlib.sha256(swift.read_bytes()).hexdigest()[:16]
|
|
19
|
+
binary = Path.home() / ".cache" / "luvix" / f"hdr-raster-{digest}"
|
|
20
|
+
if not binary.exists():
|
|
21
|
+
binary.parent.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
compiled = subprocess.run(
|
|
23
|
+
["swiftc", "-O", str(swift), "-o", str(binary)],
|
|
24
|
+
capture_output=True,
|
|
25
|
+
text=True,
|
|
26
|
+
check=False,
|
|
27
|
+
)
|
|
28
|
+
if compiled.returncode:
|
|
29
|
+
raise BackendError(f"Swift compilation failed:\n{compiled.stderr}")
|
|
30
|
+
result = subprocess.run(
|
|
31
|
+
[str(binary), str(source), str(prefix)],
|
|
32
|
+
capture_output=True,
|
|
33
|
+
text=True,
|
|
34
|
+
check=False,
|
|
35
|
+
)
|
|
36
|
+
if result.returncode:
|
|
37
|
+
raise BackendError(result.stderr.strip() or "HDR raster export failed")
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import CoreGraphics
|
|
3
|
+
import CoreImage
|
|
4
|
+
import ImageIO
|
|
5
|
+
|
|
6
|
+
func fail(_ message: String) -> Never {
|
|
7
|
+
fputs("Error: \(message)\n", stderr)
|
|
8
|
+
exit(1)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Write raw interleaved Float32 RGBA in native little-endian order and a JSON sidecar.
|
|
12
|
+
// Raw pixel orientation is retained. Source HEIC is never modified.
|
|
13
|
+
let args = CommandLine.arguments
|
|
14
|
+
guard args.count == 3 else { fail("Usage: hdr_raster SOURCE.HEIC OUTPUT_PREFIX") }
|
|
15
|
+
let sourceURL = URL(fileURLWithPath: args[1])
|
|
16
|
+
let prefix = URL(fileURLWithPath: args[2])
|
|
17
|
+
guard !FileManager.default.fileExists(atPath: prefix.path + ".rgba32f"),
|
|
18
|
+
!FileManager.default.fileExists(atPath: prefix.path + ".json") else {
|
|
19
|
+
fail("Refusing to overwrite existing intermediate")
|
|
20
|
+
}
|
|
21
|
+
guard let source = CGImageSourceCreateWithURL(sourceURL as CFURL, nil) else {
|
|
22
|
+
fail("Cannot open source")
|
|
23
|
+
}
|
|
24
|
+
guard CGImageSourceCopyAuxiliaryDataInfoAtIndex(
|
|
25
|
+
source, 0, kCGImageAuxiliaryDataTypeHDRGainMap
|
|
26
|
+
) != nil else { fail("No Apple HDR gain map found") }
|
|
27
|
+
let options: [CFString: Any] = [kCGImageSourceDecodeRequest: kCGImageSourceDecodeToHDR]
|
|
28
|
+
guard let decoded = CGImageSourceCreateImageAtIndex(source, 0, options as CFDictionary) else {
|
|
29
|
+
fail("HDR decode failed")
|
|
30
|
+
}
|
|
31
|
+
// Only use a linear working space with the same primaries as the input.
|
|
32
|
+
// Reject unsupported profiles rather than silently converting to Display P3.
|
|
33
|
+
let baseOptions: [CFString: Any] = [kCGImageSourceDecodeRequest: kCGImageSourceDecodeToSDR]
|
|
34
|
+
guard let base = CGImageSourceCreateImageAtIndex(source, 0, baseOptions as CFDictionary),
|
|
35
|
+
let sourceSpace = base.colorSpace else { fail("Cannot determine source color space") }
|
|
36
|
+
let sourceName = (sourceSpace.name as String?) ?? ""
|
|
37
|
+
let linearName: CFString
|
|
38
|
+
let sourcePrimaries: String
|
|
39
|
+
if sourceName == (CGColorSpace.displayP3 as String) {
|
|
40
|
+
linearName = CGColorSpace.extendedLinearDisplayP3
|
|
41
|
+
sourcePrimaries = "Display P3"
|
|
42
|
+
} else if sourceName == (CGColorSpace.sRGB as String) {
|
|
43
|
+
linearName = CGColorSpace.extendedLinearSRGB
|
|
44
|
+
sourcePrimaries = "sRGB"
|
|
45
|
+
} else {
|
|
46
|
+
fail("Unsupported source color space: \(sourceName). No implicit gamut conversion permitted")
|
|
47
|
+
}
|
|
48
|
+
guard let linearP3 = CGColorSpace(name: linearName) else {
|
|
49
|
+
fail("Requested linear working color space unavailable")
|
|
50
|
+
}
|
|
51
|
+
let width = decoded.width
|
|
52
|
+
let height = decoded.height
|
|
53
|
+
let rowBytes = width * 4 * MemoryLayout<Float>.size
|
|
54
|
+
let context = CIContext(options: [
|
|
55
|
+
.workingColorSpace: linearP3,
|
|
56
|
+
.outputColorSpace: linearP3
|
|
57
|
+
])
|
|
58
|
+
let image = CIImage(cgImage: decoded)
|
|
59
|
+
var pixels = [Float](repeating: 0, count: width * height * 4)
|
|
60
|
+
pixels.withUnsafeMutableBytes { bytes in
|
|
61
|
+
guard let address = bytes.baseAddress else { fail("Allocation failed") }
|
|
62
|
+
context.render(
|
|
63
|
+
image, toBitmap: address, rowBytes: rowBytes,
|
|
64
|
+
bounds: CGRect(x: 0, y: 0, width: width, height: height),
|
|
65
|
+
format: .RGBAf, colorSpace: linearP3
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
let finite = pixels.allSatisfy { $0.isFinite }
|
|
69
|
+
guard finite else { fail("Non-finite HDR pixel detected") }
|
|
70
|
+
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil)
|
|
71
|
+
as? [String: Any]
|
|
72
|
+
|
|
73
|
+
let orientation = properties?[kCGImagePropertyOrientation as String]
|
|
74
|
+
as? Int ?? 1
|
|
75
|
+
let rawURL = URL(fileURLWithPath: prefix.path + ".rgba32f")
|
|
76
|
+
let jsonURL = URL(fileURLWithPath: prefix.path + ".json")
|
|
77
|
+
let payload: [String: Any] = [
|
|
78
|
+
"width": width, "height": height, "channels": 4,
|
|
79
|
+
"dtype": "<f4", "layout": "RGBA", "orientation": orientation,
|
|
80
|
+
"color_space": sourcePrimaries == "Display P3" ? "extendedLinearDisplayP3" : "extendedLinearSRGB",
|
|
81
|
+
"source_primaries": sourcePrimaries,
|
|
82
|
+
"decode_request": "kCGImageSourceDecodeToHDR",
|
|
83
|
+
"reference_white": "relative SDR white = 1.0",
|
|
84
|
+
"source": sourceURL.lastPathComponent
|
|
85
|
+
]
|
|
86
|
+
guard let metadata = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) else {
|
|
87
|
+
fail("JSON encoding failed")
|
|
88
|
+
}
|
|
89
|
+
do {
|
|
90
|
+
try FileManager.default.createDirectory(at: prefix.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
91
|
+
let rawData = pixels.withUnsafeBytes { Data($0) }
|
|
92
|
+
try rawData.write(to: rawURL, options: .atomic)
|
|
93
|
+
try metadata.write(to: jsonURL, options: .atomic)
|
|
94
|
+
print(String(data: metadata, encoding: .utf8) ?? "{}")
|
|
95
|
+
} catch {
|
|
96
|
+
try? FileManager.default.removeItem(at: rawURL)
|
|
97
|
+
try? FileManager.default.removeItem(at: jsonURL)
|
|
98
|
+
fail("Writing HDR intermediate failed: \(error)")
|
|
99
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Get an Apple-supplied extended-linear Display P3 ICC profile."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import platform
|
|
7
|
+
import subprocess
|
|
8
|
+
from importlib.resources import files
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from tempfile import TemporaryDirectory
|
|
11
|
+
|
|
12
|
+
from luvix.backends.apple.bridge import BackendError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def linear_display_p3_icc() -> bytes:
|
|
16
|
+
if platform.system() != "Darwin":
|
|
17
|
+
raise BackendError("Linear Display P3 ICC extraction requires macOS")
|
|
18
|
+
swift = Path(str(files("luvix.backends.apple").joinpath("icc_profile.swift")))
|
|
19
|
+
digest = hashlib.sha256(swift.read_bytes()).hexdigest()[:16]
|
|
20
|
+
binary = Path.home() / ".cache" / "luvix" / f"icc-profile-{digest}"
|
|
21
|
+
if not binary.exists():
|
|
22
|
+
binary.parent.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
result = subprocess.run(
|
|
24
|
+
["swiftc", "-O", str(swift), "-o", str(binary)],
|
|
25
|
+
capture_output=True,
|
|
26
|
+
text=True,
|
|
27
|
+
check=False,
|
|
28
|
+
)
|
|
29
|
+
if result.returncode:
|
|
30
|
+
raise BackendError(f"ICC Swift compilation failed:\n{result.stderr}")
|
|
31
|
+
with TemporaryDirectory(prefix="luvix-icc-") as directory:
|
|
32
|
+
destination = Path(directory) / "linear-display-p3.icc"
|
|
33
|
+
result = subprocess.run(
|
|
34
|
+
[str(binary), str(destination)],
|
|
35
|
+
capture_output=True,
|
|
36
|
+
text=True,
|
|
37
|
+
check=False,
|
|
38
|
+
)
|
|
39
|
+
if result.returncode:
|
|
40
|
+
raise BackendError(result.stderr.strip() or "ICC extraction failed")
|
|
41
|
+
return destination.read_bytes()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def source_linear_icc(working_space: str) -> bytes:
|
|
45
|
+
"""Return the matching Apple linear ICC; unsupported spaces fail closed."""
|
|
46
|
+
if working_space not in {"extendedLinearDisplayP3", "extendedLinearSRGB"}:
|
|
47
|
+
raise BackendError(f"Unsupported working space: {working_space}")
|
|
48
|
+
return (
|
|
49
|
+
linear_display_p3_icc()
|
|
50
|
+
if working_space == "extendedLinearDisplayP3"
|
|
51
|
+
else _linear_srgb_icc()
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _linear_srgb_icc() -> bytes:
|
|
56
|
+
import hashlib
|
|
57
|
+
import subprocess
|
|
58
|
+
from importlib.resources import files
|
|
59
|
+
from tempfile import TemporaryDirectory
|
|
60
|
+
|
|
61
|
+
swift = Path(str(files("luvix.backends.apple").joinpath("icc_profile.swift")))
|
|
62
|
+
digest = hashlib.sha256(swift.read_bytes()).hexdigest()[:16]
|
|
63
|
+
binary = Path.home() / ".cache" / "luvix" / f"icc-profile-{digest}"
|
|
64
|
+
if not binary.exists():
|
|
65
|
+
binary.parent.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
result = subprocess.run(
|
|
67
|
+
["swiftc", "-O", str(swift), "-o", str(binary)], capture_output=True, text=True
|
|
68
|
+
)
|
|
69
|
+
if result.returncode:
|
|
70
|
+
raise BackendError(result.stderr)
|
|
71
|
+
with TemporaryDirectory(prefix="luvix-icc-") as directory:
|
|
72
|
+
destination = Path(directory) / "linear-srgb.icc"
|
|
73
|
+
result = subprocess.run(
|
|
74
|
+
[str(binary), str(destination), "srgb"], capture_output=True, text=True
|
|
75
|
+
)
|
|
76
|
+
if result.returncode:
|
|
77
|
+
raise BackendError(result.stderr.strip())
|
|
78
|
+
return destination.read_bytes()
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import CoreGraphics
|
|
3
|
+
|
|
4
|
+
func fail(_ message: String) -> Never {
|
|
5
|
+
fputs("Error: \(message)\n", stderr)
|
|
6
|
+
exit(1)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
guard (2...3).contains(CommandLine.arguments.count) else {
|
|
10
|
+
fail("Usage: icc_profile OUTPUT.icc")
|
|
11
|
+
}
|
|
12
|
+
let profileName = CommandLine.arguments.count == 3 ? CommandLine.arguments[2] : "p3"
|
|
13
|
+
let spaceName: CFString = profileName == "srgb"
|
|
14
|
+
? CGColorSpace.extendedLinearSRGB : CGColorSpace.extendedLinearDisplayP3
|
|
15
|
+
guard let space = CGColorSpace(name: spaceName) else {
|
|
16
|
+
fail("Extended linear Display P3 is unavailable")
|
|
17
|
+
}
|
|
18
|
+
guard let profile = space.copyICCData() else {
|
|
19
|
+
fail("No ICC profile data available for extended linear Display P3")
|
|
20
|
+
}
|
|
21
|
+
let data = profile as Data
|
|
22
|
+
guard data.count >= 128 else { fail("ICC data is unexpectedly short") }
|
|
23
|
+
let declaredSize = data.prefix(4).reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
|
|
24
|
+
guard declaredSize == data.count else { fail("ICC header length mismatch") }
|
|
25
|
+
guard data.subdata(in: 36..<40) == Data("acsp".utf8) else {
|
|
26
|
+
fail("ICC signature missing")
|
|
27
|
+
}
|
|
28
|
+
let output = URL(fileURLWithPath: CommandLine.arguments[1])
|
|
29
|
+
do { try data.write(to: output, options: .atomic) }
|
|
30
|
+
catch { fail("Cannot write ICC profile: \(error)") }
|
|
31
|
+
print("Exported extended linear Display P3 ICC (\(data.count) bytes)")
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import CoreGraphics
|
|
3
|
+
import ImageIO
|
|
4
|
+
import UniformTypeIdentifiers
|
|
5
|
+
|
|
6
|
+
func die(_ message: String) -> Never { fputs("Error: \(message)\n", stderr); exit(1) }
|
|
7
|
+
func source(_ url: URL) -> CGImageSource {
|
|
8
|
+
guard let s = CGImageSourceCreateWithURL(url as CFURL, nil) else { die("Cannot open \(url.path)") }
|
|
9
|
+
return s
|
|
10
|
+
}
|
|
11
|
+
func auxiliary(_ s: CGImageSource) -> [String: Any] {
|
|
12
|
+
guard let d = CGImageSourceCopyAuxiliaryDataInfoAtIndex(s, 0, kCGImageAuxiliaryDataTypeHDRGainMap) as? [String: Any] else { die("Missing HDR gain map") }
|
|
13
|
+
return d
|
|
14
|
+
}
|
|
15
|
+
struct MapInfo { let width: Int; let height: Int; let stride: Int; let bytes: Data }
|
|
16
|
+
func mapInfo(_ aux: [String: Any]) -> MapInfo {
|
|
17
|
+
guard let desc = aux[kCGImageAuxiliaryDataInfoDataDescription as String] as? [String: Any],
|
|
18
|
+
let w = desc["Width"] as? Int, let h = desc["Height"] as? Int,
|
|
19
|
+
let stride = desc["BytesPerRow"] as? Int,
|
|
20
|
+
let format = desc["PixelFormat"] as? Int,
|
|
21
|
+
let bytes = aux[kCGImageAuxiliaryDataInfoData as String] as? Data,
|
|
22
|
+
format == 1278226488, w > 0, h > 0, stride >= w, bytes.count == stride*h else {
|
|
23
|
+
die("Expected Apple L008 grayscale gain map; unsupported layout")
|
|
24
|
+
}
|
|
25
|
+
return MapInfo(width: w, height: h, stride: stride, bytes: bytes)
|
|
26
|
+
}
|
|
27
|
+
func grayImage(_ m: MapInfo) -> CGImage {
|
|
28
|
+
guard let provider = CGDataProvider(data: m.bytes as CFData),
|
|
29
|
+
let img = CGImage(width: m.width, height: m.height, bitsPerComponent: 8,
|
|
30
|
+
bitsPerPixel: 8, bytesPerRow: m.stride,
|
|
31
|
+
space: CGColorSpaceCreateDeviceGray(),
|
|
32
|
+
bitmapInfo: CGBitmapInfo(rawValue: 0), provider: provider,
|
|
33
|
+
decode: nil, shouldInterpolate: false, intent: .defaultIntent) else { die("Could not decode gain map") }
|
|
34
|
+
return img
|
|
35
|
+
}
|
|
36
|
+
func writePNG(_ image: CGImage, _ url: URL) {
|
|
37
|
+
guard let d = CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil) else { die("Cannot create PNG") }
|
|
38
|
+
CGImageDestinationAddImage(d, image, nil)
|
|
39
|
+
guard CGImageDestinationFinalize(d) else { die("Could not write PNG") }
|
|
40
|
+
}
|
|
41
|
+
func readEditedMap(_ url: URL, original: MapInfo) -> Data {
|
|
42
|
+
let s = source(url)
|
|
43
|
+
guard let image = CGImageSourceCreateImageAtIndex(s, 0, nil), image.width == original.width, image.height == original.height else { die("Edited map must have dimensions \(original.width)x\(original.height)") }
|
|
44
|
+
// Render as 8-bit DeviceGray; preserve original row padding in output buffer.
|
|
45
|
+
var bytes = [UInt8](original.bytes)
|
|
46
|
+
let success = bytes.withUnsafeMutableBytes { buffer -> Bool in
|
|
47
|
+
guard let ctx = CGContext(data: buffer.baseAddress, width: original.width,
|
|
48
|
+
height: original.height, bitsPerComponent: 8,
|
|
49
|
+
bytesPerRow: original.stride,
|
|
50
|
+
space: CGColorSpaceCreateDeviceGray(),
|
|
51
|
+
bitmapInfo: CGImageAlphaInfo.none.rawValue) else { return false }
|
|
52
|
+
ctx.interpolationQuality = .none
|
|
53
|
+
ctx.draw(image, in: CGRect(x: 0, y: 0, width: original.width, height: original.height))
|
|
54
|
+
return true
|
|
55
|
+
}
|
|
56
|
+
guard success else { die("Failed rendering edited gain map") }
|
|
57
|
+
return Data(bytes)
|
|
58
|
+
}
|
|
59
|
+
func writeHEIC(_ base: CGImage, _ original: CGImageSource, _ aux: [String: Any], _ url: URL) {
|
|
60
|
+
guard let d = CGImageDestinationCreateWithURL(url as CFURL, UTType.heic.identifier as CFString, 1, nil) else { die("Cannot create HEIC") }
|
|
61
|
+
let props = CGImageSourceCopyPropertiesAtIndex(original, 0, nil)
|
|
62
|
+
CGImageDestinationAddImage(d, base, props)
|
|
63
|
+
CGImageDestinationAddAuxiliaryDataInfo(d, kCGImageAuxiliaryDataTypeHDRGainMap, aux as CFDictionary)
|
|
64
|
+
guard CGImageDestinationFinalize(d) else { die("Could not finalize HEIC") }
|
|
65
|
+
let check = source(url)
|
|
66
|
+
_ = mapInfo(auxiliary(check))
|
|
67
|
+
// ImageIO can mark output files hidden; explicitly clear the flag.
|
|
68
|
+
let p = Process(); p.executableURL = URL(fileURLWithPath: "/usr/bin/chflags")
|
|
69
|
+
p.arguments = ["nohidden", url.path]
|
|
70
|
+
do { try p.run(); p.waitUntilExit() } catch { die("chflags failed: \(error)") }
|
|
71
|
+
guard p.terminationStatus == 0 else { die("Could not clear hidden flag") }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
func details(_ s: CGImageSource) -> [String: Any] {
|
|
75
|
+
let image = CGImageSourceCreateImageAtIndex(s, 0, nil)!
|
|
76
|
+
let m = mapInfo(auxiliary(s))
|
|
77
|
+
let props = CGImageSourceCopyPropertiesAtIndex(s, 0, nil) as? [String: Any] ?? [:]
|
|
78
|
+
let meta = auxiliary(s)[kCGImageAuxiliaryDataInfoMetadata as String]
|
|
79
|
+
return ["base_width": image.width, "base_height": image.height,
|
|
80
|
+
"gain_width": m.width, "gain_height": m.height,
|
|
81
|
+
"gain_stride": m.stride, "gain_format": "L008",
|
|
82
|
+
"color_space": image.colorSpace?.name as String? ?? "unknown",
|
|
83
|
+
"orientation": props[kCGImagePropertyOrientation as String] ?? 1,
|
|
84
|
+
"has_gainmap_metadata": meta != nil]
|
|
85
|
+
}
|
|
86
|
+
func emitJSON(_ d: [String: Any]) {
|
|
87
|
+
guard let data = try? JSONSerialization.data(withJSONObject: d, options: [.sortedKeys]),
|
|
88
|
+
let text = String(data: data, encoding: .utf8) else { die("JSON error") }
|
|
89
|
+
print(text)
|
|
90
|
+
}
|
|
91
|
+
let args = CommandLine.arguments
|
|
92
|
+
if args.count < 2 { die("Usage: apple_backend inspect|extract|rebuild|validate ...") }
|
|
93
|
+
switch args[1] {
|
|
94
|
+
case "inspect":
|
|
95
|
+
guard args.count == 3 else { die("inspect source.HEIC") }
|
|
96
|
+
emitJSON(details(source(URL(fileURLWithPath: args[2]))))
|
|
97
|
+
case "extract":
|
|
98
|
+
guard args.count == 4 else { die("extract source.HEIC directory") }
|
|
99
|
+
let input = URL(fileURLWithPath: args[2]); let s = source(input)
|
|
100
|
+
let dir = URL(fileURLWithPath: args[3], isDirectory: true)
|
|
101
|
+
do { try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) } catch { die("Cannot create directory") }
|
|
102
|
+
for name in ["base.png", "gainmap.png", "manifest.json"] {
|
|
103
|
+
guard !FileManager.default.fileExists(atPath: dir.appendingPathComponent(name).path) else { die("Refusing to overwrite " + name) }
|
|
104
|
+
}
|
|
105
|
+
let base = CGImageSourceCreateImageAtIndex(s, 0, nil)!
|
|
106
|
+
writePNG(base, dir.appendingPathComponent("base.png"))
|
|
107
|
+
writePNG(grayImage(mapInfo(auxiliary(s))), dir.appendingPathComponent("gainmap.png"))
|
|
108
|
+
var report = details(s)
|
|
109
|
+
report["source_name"] = input.lastPathComponent
|
|
110
|
+
let data = try! JSONSerialization.data(withJSONObject: report, options: [.prettyPrinted, .sortedKeys])
|
|
111
|
+
try! data.write(to: dir.appendingPathComponent("manifest.json"))
|
|
112
|
+
print("Extracted base.png, gainmap.png, manifest.json")
|
|
113
|
+
case "rebuild":
|
|
114
|
+
guard args.count == 6 else { die("rebuild source.HEIC base.png|- gainmap.png|- output.HEIC") }
|
|
115
|
+
let original = source(URL(fileURLWithPath: args[2]))
|
|
116
|
+
let origBase = CGImageSourceCreateImageAtIndex(original, 0, nil)!
|
|
117
|
+
let base: CGImage
|
|
118
|
+
if args[3] == "-" { base = origBase }
|
|
119
|
+
else {
|
|
120
|
+
let bs = source(URL(fileURLWithPath: args[3]))
|
|
121
|
+
guard let edited = CGImageSourceCreateImageAtIndex(bs, 0, nil) else { die("Invalid edited base") }
|
|
122
|
+
base = edited
|
|
123
|
+
}
|
|
124
|
+
guard base.width == origBase.width, base.height == origBase.height else { die("Base dimensions mismatch") }
|
|
125
|
+
guard base.colorSpace?.name == origBase.colorSpace?.name else { die("Base color profile mismatch") }
|
|
126
|
+
var aux = auxiliary(original)
|
|
127
|
+
if args[4] != "-" {
|
|
128
|
+
aux[kCGImageAuxiliaryDataInfoData as String] = readEditedMap(URL(fileURLWithPath: args[4]), original: mapInfo(aux))
|
|
129
|
+
}
|
|
130
|
+
let output = URL(fileURLWithPath: args[5])
|
|
131
|
+
guard !FileManager.default.fileExists(atPath: output.path) else { die("Refusing to overwrite existing output") }
|
|
132
|
+
writeHEIC(base, original, aux, output)
|
|
133
|
+
print("Rebuilt: \(output.path)")
|
|
134
|
+
case "validate":
|
|
135
|
+
guard args.count == 4 else { die("validate source.HEIC output.HEIC") }
|
|
136
|
+
let a = details(source(URL(fileURLWithPath: args[2])))
|
|
137
|
+
let b = details(source(URL(fileURLWithPath: args[3])))
|
|
138
|
+
let keys = ["base_width", "base_height", "gain_width", "gain_height", "gain_format", "color_space", "orientation"]
|
|
139
|
+
let differences = keys.filter { String(describing: a[$0]) != String(describing: b[$0]) }
|
|
140
|
+
emitJSON(["valid": differences.isEmpty, "mismatched_fields": differences])
|
|
141
|
+
if !differences.isEmpty { exit(2) }
|
|
142
|
+
default: die("Unknown command")
|
|
143
|
+
}
|