feat(mesh): Reticulum LoRa hardware gates pass + RNS Resource transfer + image/voice attachments
Phase 0 gates #2/#3 (two-node LXMF-over-LoRa, external Sideband interop) passed on real hardware (.116's flashed Heltec V3 RNode <-> a phone-flashed RNode running Sideband) — RNS announce, encrypted DM round-trip, and contact binding all verified live. Fixed two bugs found in the process: the Reticulum send path wasn't stamping outbound messages as E2E despite LXMF being unconditionally encrypted, and the per-message transport pill collapsed Meshcore/Meshtastic into one generic "lora" color instead of distinguishing the three radio transports. Built on top of that link: a Columba-style image/file send experience — compression-quality presets with a real transfer-time estimate (mesh.transport-advice, now device-throughput-aware), receive-side thumbnail previews + auto-render for already-local attachments, and async voice messages, all reusing the existing ContentRef/ContentInline attachment pipeline. The headline addition is genuine RNS Resource transfer support (daemon-side RNS.Link + RNS.Resource, Rust-side send_resource/resource_recv plumbing, a new "resource-mesh" transport-advice tier) so compressed photos up to 2MB now actually transfer over LoRa for Reticulum peers instead of always falling back to Tor past the small inline-chunk cap. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
12e7990b10
commit
f54c853128
@@ -0,0 +1,115 @@
|
||||
// Client-side image compression presets for the mesh chat attachment picker,
|
||||
// mirroring Columba's ImageCompressionPreset (Low/Medium/High/Original) —
|
||||
// resize + iteratively-quality-reduced JPEG, entirely in the browser so no
|
||||
// extra round-trip is needed before the existing send pipeline takes over.
|
||||
|
||||
export interface ImageCompressionPreset {
|
||||
key: 'low' | 'medium' | 'high' | 'original'
|
||||
displayName: string
|
||||
description: string
|
||||
maxDimensionPx: number
|
||||
targetBytes: number
|
||||
initialQuality: number
|
||||
minQuality: number
|
||||
}
|
||||
|
||||
export const IMAGE_COMPRESSION_PRESETS: ImageCompressionPreset[] = [
|
||||
{
|
||||
key: 'low',
|
||||
displayName: 'Low',
|
||||
description: '32KB max — best for LoRa',
|
||||
maxDimensionPx: 320,
|
||||
targetBytes: 32 * 1024,
|
||||
initialQuality: 60,
|
||||
minQuality: 30,
|
||||
},
|
||||
{
|
||||
key: 'medium',
|
||||
displayName: 'Medium',
|
||||
description: '128KB max — balanced',
|
||||
maxDimensionPx: 800,
|
||||
targetBytes: 128 * 1024,
|
||||
initialQuality: 75,
|
||||
minQuality: 40,
|
||||
},
|
||||
{
|
||||
key: 'high',
|
||||
displayName: 'High',
|
||||
description: '512KB max — good quality',
|
||||
maxDimensionPx: 2048,
|
||||
targetBytes: 512 * 1024,
|
||||
initialQuality: 90,
|
||||
minQuality: 50,
|
||||
},
|
||||
{
|
||||
key: 'original',
|
||||
displayName: 'Original',
|
||||
description: 'No compression',
|
||||
maxDimensionPx: Infinity,
|
||||
targetBytes: Infinity,
|
||||
initialQuality: 100,
|
||||
minQuality: 100,
|
||||
},
|
||||
]
|
||||
|
||||
/** Resize + iteratively shrink JPEG quality until under the preset's target size
|
||||
* (or `minQuality` is reached, whichever comes first). `original` is a no-op. */
|
||||
export async function compressImage(file: File, preset: ImageCompressionPreset): Promise<File> {
|
||||
if (preset.key === 'original') return file
|
||||
|
||||
const bitmap = await createImageBitmap(file)
|
||||
const { width, height } = scaledDimensions(bitmap.width, bitmap.height, preset.maxDimensionPx)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) throw new Error('Canvas 2D context unavailable')
|
||||
ctx.drawImage(bitmap, 0, 0, width, height)
|
||||
bitmap.close()
|
||||
|
||||
let quality = preset.initialQuality / 100
|
||||
let blob = await canvasToJpegBlob(canvas, quality)
|
||||
while (blob.size > preset.targetBytes && quality > preset.minQuality / 100) {
|
||||
quality = Math.max(quality - 0.1, preset.minQuality / 100)
|
||||
blob = await canvasToJpegBlob(canvas, quality)
|
||||
}
|
||||
|
||||
const name = file.name.replace(/\.[^./\\]+$/, '') + '.jpg'
|
||||
return new File([blob], name, { type: 'image/jpeg', lastModified: Date.now() })
|
||||
}
|
||||
|
||||
/** Tiny low-res JPEG (default 64px / low quality) for the `thumb_bytes` field
|
||||
* on a ContentRef message — shown immediately on receipt, before the full
|
||||
* image is fetched. */
|
||||
export async function makeThumbnail(file: File, maxDimensionPx = 64, quality = 0.4): Promise<Uint8Array> {
|
||||
const bitmap = await createImageBitmap(file)
|
||||
const { width, height } = scaledDimensions(bitmap.width, bitmap.height, maxDimensionPx)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) throw new Error('Canvas 2D context unavailable')
|
||||
ctx.drawImage(bitmap, 0, 0, width, height)
|
||||
bitmap.close()
|
||||
|
||||
const blob = await canvasToJpegBlob(canvas, quality)
|
||||
return new Uint8Array(await blob.arrayBuffer())
|
||||
}
|
||||
|
||||
function scaledDimensions(width: number, height: number, maxDimensionPx: number): { width: number; height: number } {
|
||||
if (!Number.isFinite(maxDimensionPx) || (width <= maxDimensionPx && height <= maxDimensionPx)) {
|
||||
return { width, height }
|
||||
}
|
||||
const scale = maxDimensionPx / Math.max(width, height)
|
||||
return { width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)) }
|
||||
}
|
||||
|
||||
function canvasToJpegBlob(canvas: HTMLCanvasElement, quality: number): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(blob) => (blob ? resolve(blob) : reject(new Error('canvas.toBlob failed'))),
|
||||
'image/jpeg',
|
||||
quality,
|
||||
)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user