@octane-xplat/ui 0.10.0 → 0.11.1
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.
- package/dist/native/Image.tsrx.js +151 -16
- package/dist/native/hash-image.js +285 -0
- package/dist/native/image-content-binding.js +158 -0
- package/dist/native/image-content.js +206 -0
- package/dist/native/image-sizing.js +102 -0
- package/dist/native/octane-client-build.json +1 -1
- package/dist/native/vendor/ui-svg/index.android.js +1 -1
- package/dist/web/Image.web.tsrx.js +112 -19
- package/dist/web/hash-image.js +285 -0
- package/dist/web/image-content.js +133 -0
- package/dist/web/octane-client-build.json +1 -1
- package/package.json +5 -4
- package/src/Image.macos.tsrx +11 -2
- package/src/Image.tsrx +203 -17
- package/src/Image.web.tsrx +91 -5
- package/src/Tabs.macos.tsrx +2 -0
- package/src/Timestamp.macos.tsrx +1 -1
- package/src/colorScheme.macos.ts +18 -5
- package/src/hash-image.test.ts +72 -0
- package/src/hash-image.ts +310 -0
- package/src/hosted-unsupported.macos.tsrx +1 -2
- package/src/image-content-binding.mobile.test.ts +174 -0
- package/src/image-content-binding.ts +231 -0
- package/src/image-content.mobile.test.tsrx +84 -0
- package/src/image-content.test.ts +219 -0
- package/src/image-content.ts +309 -0
- package/src/image-decoding.web.test.tsrx +26 -0
- package/src/image-placeholder.mobile.test.tsrx +113 -0
- package/src/image-placeholder.web.test.tsrx +48 -0
- package/src/image-recycling.mobile.test.ts +61 -0
- package/src/image-sizing.mobile.test.ts +158 -0
- package/src/image-sizing.ts +117 -0
- package/src/image.mobile.test.tsrx +81 -0
- package/src/index.macos.ts +1 -0
- package/src/index.shared.ts +7 -0
- package/src/index.shared.web.ts +7 -0
- package/src/list-viewability-observer.ios.ts +1 -0
- package/src/list-viewability.mobile.test.ts +1 -0
- package/src/props.ts +100 -1
- package/src/safeAreaInsets.macos.tsrx +6 -1
- package/src/styled.macos.tsrx +1 -1
- package/src/text-controls.macos.tsrx +3 -1
- package/src/vendor/ui-svg/src/index.android.ts +5 -1
- package/types/generated/.tsrx-typegen-manifest.json +170 -0
- package/types/generated/Accordion.macos.d.ts +5 -0
- package/types/generated/Alert.macos.d.ts +3 -0
- package/types/generated/AlertDialog.macos.d.ts +4 -0
- package/types/generated/AppShell.macos.d.ts +7 -0
- package/types/generated/AspectRatio.macos.d.ts +5 -0
- package/types/generated/Avatar.macos.d.ts +6 -0
- package/types/generated/AvatarGroup.macos.d.ts +6 -0
- package/types/generated/Badge.macos.d.ts +5 -0
- package/types/generated/Banner.macos.d.ts +4 -0
- package/types/generated/BaseTypeahead.macos.d.ts +7 -0
- package/types/generated/Blockquote.macos.d.ts +6 -0
- package/types/generated/BottomSheet.macos.d.ts +3 -0
- package/types/generated/Breadcrumb.macos.d.ts +4 -0
- package/types/generated/ButtonGroup.macos.d.ts +3 -0
- package/types/generated/Calendar.macos.d.ts +5 -0
- package/types/generated/Card.macos.d.ts +4 -0
- package/types/generated/Carousel.macos.d.ts +6 -0
- package/types/generated/Center.macos.d.ts +4 -0
- package/types/generated/CheckIndicator.macos.d.ts +4 -0
- package/types/generated/CheckboxGroup.macos.d.ts +3 -0
- package/types/generated/CheckboxIndicator.macos.d.ts +4 -0
- package/types/generated/Chip.macos.d.ts +5 -0
- package/types/generated/Citation.macos.d.ts +4 -0
- package/types/generated/ClickableCard.macos.d.ts +3 -0
- package/types/generated/Code.macos.d.ts +5 -0
- package/types/generated/CodeBlock.macos.d.ts +6 -0
- package/types/generated/Collapsible.macos.d.ts +5 -0
- package/types/generated/CommandPalette.macos.d.ts +3 -0
- package/types/generated/ComplexSelector.macos.d.ts +3 -0
- package/types/generated/ContextMenu.macos.d.ts +5 -0
- package/types/generated/DateInput.macos.d.ts +6 -0
- package/types/generated/DateRangeInput.macos.d.ts +5 -0
- package/types/generated/DateTimeInput.macos.d.ts +5 -0
- package/types/generated/Dialog.macos.d.ts +3 -0
- package/types/generated/Drawer.macos.d.ts +7 -0
- package/types/generated/DropdownMenu.macos.d.ts +5 -0
- package/types/generated/Empty.macos.d.ts +4 -0
- package/types/generated/FieldGroup.macos.d.ts +4 -0
- package/types/generated/FormField.macos.d.ts +4 -0
- package/types/generated/FormLayout.macos.d.ts +6 -0
- package/types/generated/HStack.macos.d.ts +4 -0
- package/types/generated/Heading.macos.d.ts +3 -0
- package/types/generated/Icon.macos.d.ts +3 -0
- package/types/generated/IconButton.macos.d.ts +3 -0
- package/types/generated/Image.d.ts +0 -5
- package/types/generated/Image.macos.d.ts +2 -0
- package/types/generated/Image.web.d.ts +12 -1
- package/types/generated/InputGroup.macos.d.ts +5 -0
- package/types/generated/InputNumber.macos.d.ts +5 -0
- package/types/generated/InputRating.macos.d.ts +4 -0
- package/types/generated/Item.macos.d.ts +3 -0
- package/types/generated/Kbd.macos.d.ts +4 -0
- package/types/generated/KeyboardAvoiding.macos.d.ts +3 -0
- package/types/generated/Lightbox.macos.d.ts +2 -0
- package/types/generated/Link.macos.d.ts +3 -0
- package/types/generated/List.macos.d.ts +4 -0
- package/types/generated/ListItem.macos.d.ts +5 -0
- package/types/generated/MenuParts.macos.d.ts +9 -0
- package/types/generated/MenuSurface.macos.d.ts +4 -0
- package/types/generated/MetadataList.macos.d.ts +4 -0
- package/types/generated/MetadataListItem.macos.d.ts +3 -0
- package/types/generated/Meter.macos.d.ts +3 -0
- package/types/generated/MobileNav.macos.d.ts +15 -0
- package/types/generated/MoreMenu.macos.d.ts +2 -0
- package/types/generated/NavIcon.macos.d.ts +4 -0
- package/types/generated/NavLink.macos.d.ts +3 -0
- package/types/generated/NavMenu.macos.d.ts +11 -0
- package/types/generated/NavigationMenu.macos.d.ts +4 -0
- package/types/generated/Outline.macos.d.ts +5 -0
- package/types/generated/OverflowList.macos.d.ts +6 -0
- package/types/generated/Overlay.macos.d.ts +3 -0
- package/types/generated/Pagination.macos.d.ts +4 -0
- package/types/generated/PinInput.macos.d.ts +4 -0
- package/types/generated/Popover.macos.d.ts +3 -0
- package/types/generated/PowerSearch.macos.d.ts +7 -0
- package/types/generated/PowerSearchFilterEditor.macos.d.ts +12 -0
- package/types/generated/PowerSearchToken.macos.d.ts +7 -0
- package/types/generated/Pressable.macos.d.ts +2 -0
- package/types/generated/ProgressBar.macos.d.ts +6 -0
- package/types/generated/ProgressGroup.macos.d.ts +3 -0
- package/types/generated/RadioGroup.macos.d.ts +5 -0
- package/types/generated/RadioIndicator.macos.d.ts +3 -0
- package/types/generated/ResizeHandle.macos.d.ts +7 -0
- package/types/generated/RouteBoundary.macos.d.ts +8 -0
- package/types/generated/RouteHost.macos.d.ts +13 -0
- package/types/generated/Row.macos.d.ts +2 -0
- package/types/generated/SafeArea.macos.d.ts +3 -0
- package/types/generated/Screen.macos.d.ts +3 -0
- package/types/generated/ScrollableArea.macos.d.ts +7 -0
- package/types/generated/Section.macos.d.ts +5 -0
- package/types/generated/Select.macos.d.ts +3 -0
- package/types/generated/SelectableCard.macos.d.ts +3 -0
- package/types/generated/Separator.macos.d.ts +5 -0
- package/types/generated/SideNav.macos.d.ts +19 -0
- package/types/generated/Skeleton.macos.d.ts +5 -0
- package/types/generated/StatusDot.macos.d.ts +5 -0
- package/types/generated/Stepper.macos.d.ts +4 -0
- package/types/generated/TabList.macos.d.ts +18 -0
- package/types/generated/Table.macos.d.ts +4 -0
- package/types/generated/Tabs.macos.d.ts +3 -0
- package/types/generated/Text.macos.d.ts +5 -0
- package/types/generated/Thumbnail.macos.d.ts +7 -0
- package/types/generated/TimeInput.macos.d.ts +6 -0
- package/types/generated/TimeWheelPanel.macos.d.ts +16 -0
- package/types/generated/Timeline.macos.d.ts +3 -0
- package/types/generated/Timer.macos.d.ts +3 -0
- package/types/generated/Timestamp.macos.d.ts +5 -0
- package/types/generated/Toast.macos.d.ts +3 -0
- package/types/generated/ToggleButton.macos.d.ts +3 -0
- package/types/generated/Token.macos.d.ts +5 -0
- package/types/generated/Tokenizer.macos.d.ts +6 -0
- package/types/generated/Toolbar.macos.d.ts +6 -0
- package/types/generated/TopNav.macos.d.ts +25 -0
- package/types/generated/Tree.macos.d.ts +4 -0
- package/types/generated/Typeahead.macos.d.ts +8 -0
- package/types/generated/TypeaheadItem.macos.d.ts +5 -0
- package/types/generated/User.macos.d.ts +5 -0
- package/types/generated/VStack.macos.d.ts +4 -0
- package/types/generated/View.macos.d.ts +3 -0
- package/types/generated/VirtualList.macos.d.ts +6 -0
- package/types/generated/VisuallyHidden.macos.d.ts +5 -0
- package/types/generated/WebView.macos.d.ts +2 -0
- package/types/generated/aliases.macos.d.ts +3 -0
- package/types/generated/anim.macos.d.ts +3 -0
- package/types/generated/animation-host.macos.d.ts +3 -0
- package/types/generated/bottom-sheet-panel.macos.d.ts +7 -0
- package/types/generated/bottom-sheet-service.macos.d.ts +5 -0
- package/types/generated/clipboard-write.macos.d.ts +3 -0
- package/types/generated/colorScheme.macos.d.ts +4 -0
- package/types/generated/command-palette-keys.macos.d.ts +2 -0
- package/types/generated/command-palette-scroll.macos.d.ts +2 -0
- package/types/generated/controls.macos.d.ts +8 -0
- package/types/generated/date-composition.macos.d.ts +5 -0
- package/types/generated/escape-props.macos.d.ts +5 -0
- package/types/generated/hash-image.d.ts +13 -0
- package/types/generated/hosted-unsupported.macos.d.ts +1 -0
- package/types/generated/image-content-binding.d.ts +64 -0
- package/types/generated/image-content.d.ts +56 -0
- package/types/generated/image-sizing.d.ts +37 -0
- package/types/generated/index.macos.d.ts +196 -0
- package/types/generated/index.shared.d.ts +1 -1
- package/types/generated/index.shared.web.d.ts +1 -1
- package/types/generated/layout-shells.macos.d.ts +3 -0
- package/types/generated/layout.macos.d.ts +9 -0
- package/types/generated/macos-extras.macos.d.ts +4 -0
- package/types/generated/measure-bounds.macos.d.ts +3 -0
- package/types/generated/nav-href.macos.d.ts +4 -0
- package/types/generated/outline-dom.macos.d.ts +2 -0
- package/types/generated/pan.macos.d.ts +3 -0
- package/types/generated/platform.macos.d.ts +1 -0
- package/types/generated/presentation.macos.d.ts +15 -0
- package/types/generated/props.d.ts +76 -1
- package/types/generated/query-storage.macos.d.ts +8 -0
- package/types/generated/resize-basis.macos.d.ts +4 -0
- package/types/generated/resize-persist.macos.d.ts +8 -0
- package/types/generated/route.macos.d.ts +28 -0
- package/types/generated/safeAreaInsets.macos.d.ts +10 -0
- package/types/generated/stacks.macos.d.ts +3 -0
- package/types/generated/styled.macos.d.ts +9 -0
- package/types/generated/surfaces.macos.d.ts +4 -0
- package/types/generated/svg-image.macos.d.ts +3 -0
- package/types/generated/text-controls.macos.d.ts +4 -0
- package/types/generated/theme/theme-scheme.macos.d.ts +8 -0
- package/types/generated/toast-service.macos.d.ts +3 -0
- package/types/generated/toast-viewport.macos.d.ts +5 -0
- package/types/generated/translate.macos.d.ts +1 -0
- package/types/generated/unsupported.macos.d.ts +1 -0
- package/types/generated/use-imperative-alert-dialog.macos.d.ts +2 -0
- package/types/generated/use-imperative-dialog.macos.d.ts +2 -0
- package/types/generated/use-lightbox.macos.d.ts +2 -0
- package/types/generated/use-scrollable-area.macos.d.ts +6 -0
- package/types/generated/useMeasure.macos.d.ts +3 -0
- package/types/generated/useResizable.macos.d.ts +8 -0
- package/types/generated/useStore.macos.d.ts +2 -0
- package/types/generated/windows.macos.d.ts +2 -0
- package/src/CameraView.macos.tsrx +0 -6
- package/types/index.macos.d.ts +0 -448
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/** Placeholder-hash decoders shared by the Image leaves: `blurhash:` and
|
|
2
|
+
* `thumbhash:` URIs decode to a PNG in pure JS so a placeholder is a real
|
|
3
|
+
* image on every platform — ImageSource.fromBase64Sync on native, a
|
|
4
|
+
* data:image/png URL on web. Decode sizes stay small (~32px); the view's
|
|
5
|
+
* own fit/stretch scales the result, same as the final image.
|
|
6
|
+
*
|
|
7
|
+
* blurhash decode is a port of the Wolt reference decoder (MIT);
|
|
8
|
+
* thumbHashToRGBA and rgbaToPng are ports of Evan Wallace's thumbhash.js
|
|
9
|
+
* (MIT), with the data-URL/btoa tail replaced by byte output so this file
|
|
10
|
+
* carries no DOM or platform APIs. */
|
|
11
|
+
|
|
12
|
+
export function isHashPlaceholder(src: string): boolean {
|
|
13
|
+
return src.startsWith('blurhash:') || src.startsWith('thumbhash:')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** `blurhash:<hash>` / `thumbhash:<base64>` → PNG bytes as base64, or null. */
|
|
17
|
+
export function hashToPngBase64(src: string): string | null {
|
|
18
|
+
if (src.startsWith('blurhash:')) {
|
|
19
|
+
const rgba = decodeBlurHash(src.slice(9), 32, 32)
|
|
20
|
+
return rgba ? base64Encode(rgbaToPng(32, 32, rgba)) : null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (src.startsWith('thumbhash:')) {
|
|
24
|
+
const hash = base64Decode(src.slice(10))
|
|
25
|
+
const decoded = hash ? thumbHashToRGBA(hash) : null
|
|
26
|
+
return decoded ? base64Encode(rgbaToPng(decoded.w, decoded.h, decoded.rgba)) : null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---------- blurhash (Wolt reference, MIT) ----------
|
|
33
|
+
|
|
34
|
+
const BASE83 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~'
|
|
35
|
+
|
|
36
|
+
function decode83(str: string, start: number, end: number): number {
|
|
37
|
+
let value = 0
|
|
38
|
+
for (let i = start; i < end; i++) {
|
|
39
|
+
const digit = BASE83.indexOf(str[i])
|
|
40
|
+
if (digit === -1) {return -1}
|
|
41
|
+
value = value * 83 + digit
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return value
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function srgbToLinear(value: number): number {
|
|
48
|
+
const v = value / 255
|
|
49
|
+
return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function linearToSrgb(value: number): number {
|
|
53
|
+
const v = Math.max(0, Math.min(1, value))
|
|
54
|
+
return v <= 0.0031308
|
|
55
|
+
? Math.round(v * 12.92 * 255 + 0.5)
|
|
56
|
+
: Math.round((1.055 * Math.pow(v, 1 / 2.4) - 0.055) * 255 + 0.5)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function signPow(value: number, exp: number): number {
|
|
60
|
+
return Math.sign(value) * Math.pow(Math.abs(value), exp)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function decodeBlurHash(hash: string, width: number, height: number): Uint8ClampedArray | null {
|
|
64
|
+
if (hash.length < 6) {return null}
|
|
65
|
+
const sizeFlag = decode83(hash, 0, 1)
|
|
66
|
+
const quantisedMax = decode83(hash, 1, 2)
|
|
67
|
+
const dcValue = decode83(hash, 2, 6)
|
|
68
|
+
if (sizeFlag < 0 || quantisedMax < 0 || dcValue < 0) {return null}
|
|
69
|
+
const numY = Math.floor(sizeFlag / 9) + 1
|
|
70
|
+
const numX = (sizeFlag % 9) + 1
|
|
71
|
+
if (hash.length !== 4 + 2 * numX * numY) {return null}
|
|
72
|
+
const maximumValue = (quantisedMax + 1) / 166
|
|
73
|
+
const colors: [number, number, number][] = new Array(numX * numY)
|
|
74
|
+
colors[0] = [
|
|
75
|
+
srgbToLinear(dcValue >> 16),
|
|
76
|
+
srgbToLinear((dcValue >> 8) & 255),
|
|
77
|
+
srgbToLinear(dcValue & 255),
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
for (let i = 1; i < numX * numY; i++) {
|
|
81
|
+
const value = decode83(hash, 4 + i * 2, 6 + i * 2)
|
|
82
|
+
if (value < 0) {return null}
|
|
83
|
+
const quantR = Math.floor(value / (19 * 19))
|
|
84
|
+
const quantG = Math.floor(value / 19) % 19
|
|
85
|
+
const quantB = value % 19
|
|
86
|
+
colors[i] = [
|
|
87
|
+
signPow((quantR - 9) / 9, 2) * maximumValue,
|
|
88
|
+
signPow((quantG - 9) / 9, 2) * maximumValue,
|
|
89
|
+
signPow((quantB - 9) / 9, 2) * maximumValue,
|
|
90
|
+
]
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const pixels = new Uint8ClampedArray(width * height * 4)
|
|
94
|
+
for (let y = 0; y < height; y++) {
|
|
95
|
+
for (let x = 0; x < width; x++) {
|
|
96
|
+
let r = 0
|
|
97
|
+
let g = 0
|
|
98
|
+
let b = 0
|
|
99
|
+
for (let j = 0; j < numY; j++) {
|
|
100
|
+
for (let i = 0; i < numX; i++) {
|
|
101
|
+
const basis =
|
|
102
|
+
Math.cos((Math.PI * x * i) / width) * Math.cos((Math.PI * y * j) / height)
|
|
103
|
+
|
|
104
|
+
const color = colors[i + j * numX]
|
|
105
|
+
r += color[0] * basis
|
|
106
|
+
g += color[1] * basis
|
|
107
|
+
b += color[2] * basis
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const o = (x + y * width) * 4
|
|
112
|
+
pixels[o] = linearToSrgb(r)
|
|
113
|
+
pixels[o + 1] = linearToSrgb(g)
|
|
114
|
+
pixels[o + 2] = linearToSrgb(b)
|
|
115
|
+
pixels[o + 3] = 255
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return pixels
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------- thumbhash (Evan Wallace reference, MIT) ----------
|
|
123
|
+
|
|
124
|
+
function thumbHashToApproximateAspectRatio(hash: Uint8Array): number {
|
|
125
|
+
const header = hash[3]
|
|
126
|
+
const hasAlpha = (hash[2] & 0x80) !== 0
|
|
127
|
+
const isLandscape = (hash[4] & 0x80) !== 0
|
|
128
|
+
const lx = isLandscape ? (hasAlpha ? 5 : 7) : header & 7
|
|
129
|
+
const ly = isLandscape ? header & 7 : hasAlpha ? 5 : 7
|
|
130
|
+
return lx / ly
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function thumbHashToRGBA(hash: Uint8Array): { w: number; h: number; rgba: Uint8Array } | null {
|
|
134
|
+
if (hash.length < 5) {return null}
|
|
135
|
+
const { PI, min, max, cos, round } = Math
|
|
136
|
+
const header24 = hash[0] | (hash[1] << 8) | (hash[2] << 16)
|
|
137
|
+
const header16 = hash[3] | (hash[4] << 8)
|
|
138
|
+
const l_dc = (header24 & 63) / 63
|
|
139
|
+
const p_dc = ((header24 >> 6) & 63) / 31.5 - 1
|
|
140
|
+
const q_dc = ((header24 >> 12) & 63) / 31.5 - 1
|
|
141
|
+
const l_scale = ((header24 >> 18) & 31) / 31
|
|
142
|
+
const hasAlpha = header24 >> 23 !== 0
|
|
143
|
+
const p_scale = ((header16 >> 3) & 63) / 63
|
|
144
|
+
const q_scale = ((header16 >> 9) & 63) / 63
|
|
145
|
+
const isLandscape = header16 >> 15 !== 0
|
|
146
|
+
const lx = max(3, isLandscape ? (hasAlpha ? 5 : 7) : header16 & 7)
|
|
147
|
+
const ly = max(3, isLandscape ? header16 & 7 : hasAlpha ? 5 : 7)
|
|
148
|
+
const a_dc = hasAlpha ? (hash[5] & 15) / 15 : 1
|
|
149
|
+
const a_scale = (hash[5] >> 4) / 15
|
|
150
|
+
const ac_start = hasAlpha ? 6 : 5
|
|
151
|
+
let ac_index = 0
|
|
152
|
+
const decodeChannel = (nx: number, ny: number, scale: number) => {
|
|
153
|
+
const ac: number[] = []
|
|
154
|
+
for (let cy = 0; cy < ny; cy++)
|
|
155
|
+
{for (let cx = cy ? 0 : 1; cx * ny < nx * (ny - cy); cx++) {
|
|
156
|
+
const byte = hash[ac_start + (ac_index >> 1)]
|
|
157
|
+
if (byte === undefined) return null
|
|
158
|
+
ac.push((((byte >> ((ac_index++ & 1) << 2)) & 15) / 7.5 - 1) * scale)
|
|
159
|
+
}}
|
|
160
|
+
|
|
161
|
+
return ac
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const l_ac = decodeChannel(lx, ly, l_scale)
|
|
165
|
+
const p_ac = decodeChannel(3, 3, p_scale * 1.25)
|
|
166
|
+
const q_ac = decodeChannel(3, 3, q_scale * 1.25)
|
|
167
|
+
const a_ac = hasAlpha ? decodeChannel(5, 5, a_scale) : null
|
|
168
|
+
if (!l_ac || !p_ac || !q_ac || (hasAlpha && !a_ac)) {return null}
|
|
169
|
+
|
|
170
|
+
const ratio = thumbHashToApproximateAspectRatio(hash)
|
|
171
|
+
const w = round(ratio > 1 ? 32 : 32 * ratio)
|
|
172
|
+
const h = round(ratio > 1 ? 32 / ratio : 32)
|
|
173
|
+
const rgba = new Uint8Array(w * h * 4)
|
|
174
|
+
const fx: number[] = []
|
|
175
|
+
const fy: number[] = []
|
|
176
|
+
for (let y = 0, i = 0; y < h; y++) {
|
|
177
|
+
for (let x = 0; x < w; x++, i += 4) {
|
|
178
|
+
let l = l_dc
|
|
179
|
+
let p = p_dc
|
|
180
|
+
let q = q_dc
|
|
181
|
+
let a = a_dc
|
|
182
|
+
for (let cx = 0, n = max(lx, hasAlpha ? 5 : 3); cx < n; cx++)
|
|
183
|
+
{fx[cx] = cos((PI / w) * (x + 0.5) * cx)}
|
|
184
|
+
|
|
185
|
+
for (let cy = 0, n = max(ly, hasAlpha ? 5 : 3); cy < n; cy++)
|
|
186
|
+
{fy[cy] = cos((PI / h) * (y + 0.5) * cy)}
|
|
187
|
+
|
|
188
|
+
for (let cy = 0, j = 0; cy < ly; cy++)
|
|
189
|
+
{for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx * ly < lx * (ly - cy); cx++, j++)
|
|
190
|
+
l += l_ac[j] * fx[cx] * fy2}
|
|
191
|
+
|
|
192
|
+
for (let cy = 0, j = 0; cy < 3; cy++) {
|
|
193
|
+
for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx < 3 - cy; cx++, j++) {
|
|
194
|
+
const f = fx[cx] * fy2
|
|
195
|
+
p += p_ac[j] * f
|
|
196
|
+
q += q_ac[j] * f
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (hasAlpha && a_ac)
|
|
201
|
+
{for (let cy = 0, j = 0; cy < 5; cy++)
|
|
202
|
+
for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx < 5 - cy; cx++, j++)
|
|
203
|
+
a += a_ac[j] * fx[cx] * fy2}
|
|
204
|
+
|
|
205
|
+
const b = l - (2 / 3) * p
|
|
206
|
+
const r = (3 * l - b + q) / 2
|
|
207
|
+
const g = r - q
|
|
208
|
+
rgba[i] = max(0, 255 * min(1, r))
|
|
209
|
+
rgba[i + 1] = max(0, 255 * min(1, g))
|
|
210
|
+
rgba[i + 2] = max(0, 255 * min(1, b))
|
|
211
|
+
rgba[i + 3] = max(0, 255 * min(1, a))
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return { w, h, rgba }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ---------- PNG encode + base64 (Wallace rgbaToDataURL port, MIT) ----------
|
|
219
|
+
|
|
220
|
+
const PNG_CRC_TABLE = [
|
|
221
|
+
0, 498536548, 997073096, 651767980, 1994146192, 1802195444, 1303535960, 1342533948,
|
|
222
|
+
-306674912, -267414716, -690576408, -882789492, -1687895376, -2032938284, -1609899400,
|
|
223
|
+
-1111625188,
|
|
224
|
+
]
|
|
225
|
+
|
|
226
|
+
/** Uncompressed PNG (zlib store blocks) — correctness over size; inputs are
|
|
227
|
+
* ~32px placeholders so the payload stays small. */
|
|
228
|
+
function rgbaToPng(w: number, h: number, rgba: Uint8ClampedArray | Uint8Array): number[] {
|
|
229
|
+
const row = w * 4 + 1
|
|
230
|
+
const idat = 6 + h * (5 + row)
|
|
231
|
+
const bytes = [
|
|
232
|
+
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, w >> 8,
|
|
233
|
+
w & 255, 0, 0, h >> 8, h & 255, 8, 6, 0, 0, 0, 0, 0, 0, 0, idat >>> 24,
|
|
234
|
+
(idat >> 16) & 255, (idat >> 8) & 255, idat & 255, 73, 68, 65, 84, 120, 1,
|
|
235
|
+
]
|
|
236
|
+
|
|
237
|
+
let a = 1
|
|
238
|
+
let b = 0
|
|
239
|
+
for (let y = 0, i = 0, end = row - 1; y < h; y++, end += row - 1) {
|
|
240
|
+
bytes.push(y + 1 < h ? 0 : 1, row & 255, row >> 8, ~row & 255, (row >> 8) ^ 255, 0)
|
|
241
|
+
for (b = (b + a) % 65521; i < end; i++) {
|
|
242
|
+
const u = rgba[i] & 255
|
|
243
|
+
bytes.push(u)
|
|
244
|
+
a = (a + u) % 65521
|
|
245
|
+
b = (b + a) % 65521
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
bytes.push(
|
|
250
|
+
b >> 8, b & 255, a >> 8, a & 255, 0, 0, 0, 0,
|
|
251
|
+
0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
for (let [start, end] of [
|
|
255
|
+
[12, 29],
|
|
256
|
+
[37, 41 + idat],
|
|
257
|
+
]) {
|
|
258
|
+
let c = ~0
|
|
259
|
+
for (let i = start; i < end; i++) {
|
|
260
|
+
c ^= bytes[i]
|
|
261
|
+
c = (c >>> 4) ^ PNG_CRC_TABLE[c & 15]
|
|
262
|
+
c = (c >>> 4) ^ PNG_CRC_TABLE[c & 15]
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
c = ~c
|
|
266
|
+
bytes[end++] = c >>> 24
|
|
267
|
+
bytes[end++] = (c >> 16) & 255
|
|
268
|
+
bytes[end++] = (c >> 8) & 255
|
|
269
|
+
bytes[end++] = c & 255
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return bytes
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const BASE64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
|
276
|
+
|
|
277
|
+
function base64Encode(bytes: number[] | Uint8Array): string {
|
|
278
|
+
let out = ''
|
|
279
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
280
|
+
const b0 = bytes[i]
|
|
281
|
+
const b1 = i + 1 < bytes.length ? bytes[i + 1] : 0
|
|
282
|
+
const b2 = i + 2 < bytes.length ? bytes[i + 2] : 0
|
|
283
|
+
out += BASE64_CHARS[b0 >> 2]
|
|
284
|
+
out += BASE64_CHARS[((b0 & 3) << 4) | (b1 >> 4)]
|
|
285
|
+
out += i + 1 < bytes.length ? BASE64_CHARS[((b1 & 15) << 2) | (b2 >> 6)] : '='
|
|
286
|
+
out += i + 2 < bytes.length ? BASE64_CHARS[b2 & 63] : '='
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return out
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function base64Decode(text: string): Uint8Array | null {
|
|
293
|
+
const clean = text.replace(/-/g, '+').replace(/_/g, '/').replace(/[\s=]/g, '')
|
|
294
|
+
const bytes = new Uint8Array(Math.floor((clean.length * 3) / 4))
|
|
295
|
+
let out = 0
|
|
296
|
+
let acc = 0
|
|
297
|
+
let bits = 0
|
|
298
|
+
for (const ch of clean) {
|
|
299
|
+
const digit = BASE64_CHARS.indexOf(ch)
|
|
300
|
+
if (digit === -1) {return null}
|
|
301
|
+
acc = (acc << 6) | digit
|
|
302
|
+
bits += 6
|
|
303
|
+
if (bits >= 8) {
|
|
304
|
+
bits -= 8
|
|
305
|
+
if (out < bytes.length) {bytes[out++] = (acc >> bits) & 255}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return bytes.subarray(0, out)
|
|
310
|
+
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
/** @jsxImportSource @octane-xplat/macos-renderer */
|
|
2
|
-
import type { CameraViewProps } from './props'
|
|
3
2
|
|
|
4
|
-
export function CameraView(_props:
|
|
3
|
+
export function CameraView(_props: any) @{
|
|
5
4
|
<label className="vx-unsupported">Camera capture is unsupported by the AppKit host</label>
|
|
6
5
|
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { createImageContentBinding } from './image-content-binding'
|
|
3
|
+
|
|
4
|
+
/** The real `issue` is the sized-decode binding — this mock lands src
|
|
5
|
+
* directly and records the decode-gate override per write. */
|
|
6
|
+
const env = {
|
|
7
|
+
isAndroid: false,
|
|
8
|
+
screenScale: () => 3,
|
|
9
|
+
issue: (view: any, src: any, gate?: string) => {
|
|
10
|
+
view.src = src
|
|
11
|
+
|
|
12
|
+
;(view.issueGates ??= []).push(gate)
|
|
13
|
+
},
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const create = () => createImageContentBinding(env)
|
|
17
|
+
|
|
18
|
+
function mockView(width = 0, height = 0) {
|
|
19
|
+
const handlers = new Map<string, Set<() => void>>()
|
|
20
|
+
const view: any = {
|
|
21
|
+
measured: { w: width, h: height },
|
|
22
|
+
imageSource: undefined as any,
|
|
23
|
+
srcWrites: [] as any[],
|
|
24
|
+
on: (event: string, callback: () => void) => {
|
|
25
|
+
const set = handlers.get(event) ?? new Set()
|
|
26
|
+
set.add(callback)
|
|
27
|
+
handlers.set(event, set)
|
|
28
|
+
},
|
|
29
|
+
off: (event: string, callback: () => void) => handlers.get(event)?.delete(callback),
|
|
30
|
+
getActualSize() {
|
|
31
|
+
return { width: view.measured.w, height: view.measured.h }
|
|
32
|
+
},
|
|
33
|
+
emit(event: string) {
|
|
34
|
+
for (const callback of handlers.get(event) ?? []) {callback()}
|
|
35
|
+
},
|
|
36
|
+
handlerCount(event: string) {
|
|
37
|
+
return handlers.get(event)?.size ?? 0
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let src: any
|
|
42
|
+
Object.defineProperty(view, 'src', {
|
|
43
|
+
get: () => src,
|
|
44
|
+
set: (value) => {
|
|
45
|
+
src = value
|
|
46
|
+
view.srcWrites.push(value)
|
|
47
|
+
},
|
|
48
|
+
configurable: true,
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
return view
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const noSources = { source: 'img.png', sources: null, fit: 'cover' as const, position: {}, computed: false }
|
|
55
|
+
|
|
56
|
+
describe('image content binding — source handoff', () => {
|
|
57
|
+
it('forwards a single source to the issue seam with no decode override', () => {
|
|
58
|
+
const view = mockView(100, 100)
|
|
59
|
+
const binding = create()
|
|
60
|
+
binding.update(view, view, noSources)
|
|
61
|
+
expect(view.src).toBe('img.png')
|
|
62
|
+
expect(view.issueGates).toEqual([undefined])
|
|
63
|
+
expect(view.handlerCount('layoutChanged')).toBe(0)
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe('image content binding — source selection (L8)', () => {
|
|
68
|
+
const sources = [
|
|
69
|
+
{ uri: 'https://img.example/photo-200.jpg', width: 200, height: 200 },
|
|
70
|
+
{ uri: 'https://img.example/photo-800.jpg', width: 800, height: 800 },
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
const arrayUpdate = { ...noSources, source: null, sources }
|
|
74
|
+
|
|
75
|
+
it('holds src until a real layout, then picks the closest pixel count', () => {
|
|
76
|
+
const view = mockView(0, 0)
|
|
77
|
+
const binding = create()
|
|
78
|
+
binding.update(view, view, arrayUpdate)
|
|
79
|
+
|
|
80
|
+
expect(view.src).toBeUndefined()
|
|
81
|
+
expect(view.handlerCount('layoutChanged')).toBe(1)
|
|
82
|
+
|
|
83
|
+
// 100x100 dips at scale 3 → 300x300 px → closest to 200²
|
|
84
|
+
view.measured = { w: 100, h: 100 }
|
|
85
|
+
view.emit('layoutChanged')
|
|
86
|
+
expect(view.src).toBe('https://img.example/photo-200.jpg')
|
|
87
|
+
|
|
88
|
+
// 200 dips → 600px → closest to 800²
|
|
89
|
+
view.measured = { w: 200, h: 200 }
|
|
90
|
+
view.emit('layoutChanged')
|
|
91
|
+
expect(view.src).toBe('https://img.example/photo-800.jpg')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('re-issues the same pick on repeat evaluates — dedup lives in the sized binding', () => {
|
|
95
|
+
const view = mockView(100, 100)
|
|
96
|
+
const binding = create()
|
|
97
|
+
binding.update(view, view, arrayUpdate)
|
|
98
|
+
|
|
99
|
+
view.emit('layoutChanged')
|
|
100
|
+
binding.update(view, view, arrayUpdate)
|
|
101
|
+
expect(view.srcWrites.every((w) => w === 'https://img.example/photo-200.jpg')).toBe(true)
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
describe('image content binding — computed content rect (L7)', () => {
|
|
106
|
+
const update = (fit: any, position: any) => ({
|
|
107
|
+
...noSources,
|
|
108
|
+
fit,
|
|
109
|
+
position,
|
|
110
|
+
computed: true,
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('sizes the inner to the host box until imageSource arrives, then to the rect', () => {
|
|
114
|
+
const host = mockView(400, 300)
|
|
115
|
+
const image = mockView(0, 0)
|
|
116
|
+
const binding = create()
|
|
117
|
+
binding.update(host, image, update('cover', {}))
|
|
118
|
+
|
|
119
|
+
// Host-box placeholder — a real measure is what unblocks the
|
|
120
|
+
// sized-decode gate; the src goes through the issue seam.
|
|
121
|
+
expect(image.width).toBe(400)
|
|
122
|
+
expect(image.height).toBe(300)
|
|
123
|
+
expect(image.src).toBe('img.png')
|
|
124
|
+
expect(image.issueGates).toContain('aspectFit')
|
|
125
|
+
|
|
126
|
+
image.imageSource = { width: 200, height: 100 }
|
|
127
|
+
image.emit('imageSourceChange')
|
|
128
|
+
// cover 200x100 in 400x300 → 600x300, centered x=-100
|
|
129
|
+
expect(image.width).toBe(600)
|
|
130
|
+
expect(image.height).toBe(300)
|
|
131
|
+
expect(image.left).toBe(-100)
|
|
132
|
+
expect(image.top).toBe(0)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('centers none at intrinsic size (the iOS TopLeft divergence) and asks for full-res decode', () => {
|
|
136
|
+
const host = mockView(400, 300)
|
|
137
|
+
const image = mockView(0, 0)
|
|
138
|
+
image.imageSource = { width: 200, height: 100 }
|
|
139
|
+
const binding = create()
|
|
140
|
+
binding.update(host, image, update('none', {}))
|
|
141
|
+
|
|
142
|
+
expect(image.width).toBe(200)
|
|
143
|
+
expect(image.height).toBe(100)
|
|
144
|
+
expect(image.left).toBe(100)
|
|
145
|
+
expect(image.top).toBe(100)
|
|
146
|
+
expect(image.issueGates).toContain('none')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('applies edge positions and re-evaluates on resize', () => {
|
|
150
|
+
const host = mockView(400, 300)
|
|
151
|
+
const image = mockView(0, 0)
|
|
152
|
+
image.imageSource = { width: 200, height: 100 }
|
|
153
|
+
const binding = create()
|
|
154
|
+
binding.update(host, image, update('none', { bottom: 0, right: 0 }))
|
|
155
|
+
|
|
156
|
+
expect(image.left).toBe(200)
|
|
157
|
+
expect(image.top).toBe(200)
|
|
158
|
+
|
|
159
|
+
host.measured = { w: 300, h: 200 }
|
|
160
|
+
host.emit('layoutChanged')
|
|
161
|
+
expect(image.left).toBe(100)
|
|
162
|
+
expect(image.top).toBe(100)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('does not resubscribe layoutChanged on repeated updates', () => {
|
|
166
|
+
const host = mockView(400, 300)
|
|
167
|
+
const image = mockView(0, 0)
|
|
168
|
+
const binding = create()
|
|
169
|
+
binding.update(host, image, update('cover', {}))
|
|
170
|
+
binding.update(host, image, update('cover', {}))
|
|
171
|
+
expect(host.handlerCount('layoutChanged')).toBe(1)
|
|
172
|
+
expect(image.handlerCount('imageSourceChange')).toBe(1)
|
|
173
|
+
})
|
|
174
|
+
})
|