Drop-folder image flow + web image preparer

- Add web-image-prep/: self-contained HTML5 image preparer with
  perceptual Oklab 3-colour quantisation, Floyd-Steinberg/Atkinson/
  Bayer dither, all supported tag profiles, BMPs that match the FAP
  reader exactly. Auto-deployed to GitHub Pages.
- Add 'Set Image' under Targeted Payloads. Lists every BMP in
  apps_data/tagtinker/dropped/, scrolls long names, marks foreign-
  resolution files with a '~' prefix.
- Stream-time nearest-neighbour rescaling so any BMP can target any
  tag at any page; image options screen is now Page-only.
- Remove Phone Sync menu and the Android companion app: replaced by
  the browser preparer + drop folder.
- Static, IR-friendly transmit screen ('Tinkering Tag', point hint,
  3-dot loader, 'Flipped ;)').
This commit is contained in:
i12bp8
2026-04-26 15:40:27 +02:00
parent c74da35ca7
commit d608afb43b
51 changed files with 1140 additions and 2808 deletions
+37
View File
@@ -0,0 +1,37 @@
name: Deploy Image Prep to GitHub Pages
on:
push:
branches: [main]
paths:
- "web-image-prep/**"
- ".github/workflows/pages.yml"
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure Pages
uses: actions/configure-pages@v5
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: web-image-prep
- name: Deploy
id: deployment
uses: actions/deploy-pages@v4
+7 -7
View File
@@ -15,7 +15,7 @@
## Overview
TagTinker is a Flipper Zero app and Android Companion for exploring infrared electronic shelf-label (ESL) protocols. It allows you to transmit custom images and text to supported graphics tags.
TagTinker is a Flipper Zero app for exploring infrared electronic shelf-label (ESL) protocols. It allows you to transmit custom images and text to supported graphics tags. A companion **web image preparer** runs entirely in the browser and lets you drop, dither and download Flipper-ready BMPs without any install.
As the Flipper Zero team notes:
> "FYI: this is pure infrared signal, same that you use in TV remotes. The whole security was relying on obscurity of protocol."
@@ -28,18 +28,18 @@ This tool is built for IoT security curiosity, learning about obscure protocols,
## Features
- **TagTinker Flipper App:** High-performance, zero-allocation RLE streaming IR engine.
- **TagTinker Android Companion:** Edit and dither images directly on your phone and sync them instantly to the Flipper Zero over BLE.
- **TagTinker Image Prep (web):** Single-file, dependency-free HTML page that lists every supported tag profile, runs a full image pipeline (tone, contrast, detail, sharpen, dither, photo-grade Oklab 3-colour quantisation) and exports a Flipper-ready BMP. Hosted at **[i12bp8.github.io/TagTinker](https://i12bp8.github.io/TagTinker/)** (source: `web-image-prep/`).
- **Drop-folder image flow:** Drop a prepared BMP into `apps_data/tagtinker/dropped/` on the Flipper SD card, then open `Targeted Payloads → <tag> → Set Image` and pick it. The Flipper rescales any BMP on the fly so a single file can target any tag and any page.
- **NFC Tag Scan:** Instantly identify ESL targets by scanning their NFC tag — no manual barcode entry needed.
- Display text, custom images, and test-patterns.
- Support for monochrome and accent-color (red/yellow) graphics tags.
## Getting Started
The TagTinker Android app manages preparing display payloads and uploading them to the Flipper over BLE.
1. Build the Flipper app from this repository and install it via `ufbt`.
2. Download the pre-built Android Companion APK from the [Releases](https://github.com/i12bp8/TagTinker/releases) page and install it on your device.
3. Open the Flipper app and go to **Phone Sync (Custom Images)**.
4. Use the Android app to connect, prepare an image, and send it directly to the Flipper for transmission.
1. Build the Flipper app from this repository and install it via `ufbt`. The first launch creates `apps_data/tagtinker/dropped/` on your SD card.
2. Open **[i12bp8.github.io/TagTinker](https://i12bp8.github.io/TagTinker/)** in any browser, pick your tag profile, drop an image, tweak, and download the BMP.
3. Copy the BMP into `apps_data/tagtinker/dropped/` on the SD card (over `qFlipper`, USB MTP, or whatever you use).
4. On the Flipper open `Targeted Payloads → <your tag> → Set Image`, pick the BMP, choose a page, send.
## FAQ
@@ -1,2 +0,0 @@
#Thu Apr 23 20:33:11 CEST 2026
gradle.version=8.11.1
Binary file not shown.
@@ -1,4 +0,0 @@
kotlin version: 2.0.21
error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output:
1. Kotlin compile daemon is ready
@@ -1,4 +0,0 @@
kotlin version: 2.0.21
error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output:
1. Kotlin compile daemon is ready
-95
View File
@@ -1,95 +0,0 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
}
android {
namespace = "xyz.i12bp8.tagtinker.android"
compileSdk = 35
defaultConfig {
applicationId = "xyz.i12bp8.tagtinker.android"
minSdk = 29
targetSdk = 35
versionCode = 1
versionName = "2.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
}
signingConfigs {
create("release") {
storeFile = file("tagtinker.keystore")
storePassword = "tagtinker"
keyAlias = "tagtinker"
keyPassword = "tagtinker"
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
signingConfig = signingConfigs.getByName("release")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2025.04.01")
implementation(composeBom)
androidTestImplementation(composeBom)
implementation("androidx.core:core-ktx:1.16.0")
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.activity:activity-compose:1.10.1")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.navigation:navigation-compose:2.8.9")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3:1.3.2")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.camera:camera-core:1.4.2")
implementation("androidx.camera:camera-camera2:1.4.2")
implementation("androidx.camera:camera-lifecycle:1.4.2")
implementation("androidx.camera:camera-view:1.4.2")
implementation("com.google.mlkit:barcode-scanning:17.3.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
implementation("no.nordicsemi.android.kotlin.ble:scanner:1.3.1")
implementation("no.nordicsemi.android.kotlin.ble:client:1.3.1")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}
-1
View File
@@ -1 +0,0 @@
@@ -1,34 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-feature
android:name="android.hardware.bluetooth_le"
android:required="true" />
<uses-feature
android:name="android.software.companion_device_setup"
android:required="true" />
<application
android:allowBackup="true"
android:label="TagTinker"
android:theme="@style/Theme.TagTinker">
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
File diff suppressed because it is too large Load Diff
@@ -1,265 +0,0 @@
package xyz.i12bp8.tagtinker.android
import android.app.Application
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import java.io.File
import java.util.UUID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import xyz.i12bp8.tagtinker.android.ble.FlipperBleClient
import xyz.i12bp8.tagtinker.android.data.HistoryStore
import xyz.i12bp8.tagtinker.android.data.isValidBarcode
import xyz.i12bp8.tagtinker.android.data.normalizeBarcode
import xyz.i12bp8.tagtinker.android.data.tagFromBarcode
import xyz.i12bp8.tagtinker.android.model.BlePeripheral
import xyz.i12bp8.tagtinker.android.model.EditorState
import xyz.i12bp8.tagtinker.android.model.PayloadHistoryItem
import xyz.i12bp8.tagtinker.android.model.TagRecord
import xyz.i12bp8.tagtinker.android.model.UploadJob
import xyz.i12bp8.tagtinker.android.model.UploadProgress
import xyz.i12bp8.tagtinker.android.render.ImagePipeline
data class MainUiState(
val status: String = "Disconnected",
val connected: Boolean = false,
val scanning: Boolean = false,
val devices: List<BlePeripheral> = emptyList(),
val tag: TagRecord? = null,
val editor: EditorState = EditorState(),
val sourceBitmap: Bitmap? = null,
val previewBitmap: Bitmap? = null,
val payloadSizeLabel: String = "No image",
val progress: Float = 0f,
val logs: List<String> = emptyList(),
val history: List<PayloadHistoryItem> = emptyList(),
val manualBarcode: String = "",
)
class MainViewModel(application: Application) : AndroidViewModel(application) {
private val bleClient = FlipperBleClient(application)
private val historyStore = HistoryStore(application)
private val _uiState = MutableStateFlow(MainUiState(history = historyStore.load()))
val uiState: StateFlow<MainUiState> = _uiState.asStateFlow()
private var renderJob: Job? = null
init {
viewModelScope.launch {
bleClient.scanResults.collect { results ->
_uiState.value = _uiState.value.copy(devices = results)
}
}
viewModelScope.launch {
bleClient.scanStatus.collect { status ->
if (!status.isNullOrBlank() && !_uiState.value.connected) {
_uiState.value = _uiState.value.copy(status = status)
}
}
}
}
fun startScan() {
bleClient.startScan()
_uiState.value = _uiState.value.copy(scanning = true, status = "Scanning nearby Flippers")
log("Scanning")
}
fun stopScan() {
bleClient.stopScan()
_uiState.value = _uiState.value.copy(scanning = false)
}
fun setStatus(status: String) {
_uiState.value = _uiState.value.copy(status = status)
log(status)
}
fun connect(address: String) {
viewModelScope.launch {
runCatching {
bleClient.connect(address)
}.onSuccess {
_uiState.value = _uiState.value.copy(
connected = true,
scanning = false,
status = "BLE ready",
)
log("BLE ready")
}.onFailure {
_uiState.value = _uiState.value.copy(status = it.message ?: "BLE failed")
log(it.message ?: "BLE failed")
}
}
}
fun disconnect() {
viewModelScope.launch {
bleClient.disconnect()
_uiState.value = MainUiState(history = historyStore.load())
}
}
fun onManualBarcodeChanged(value: String) {
_uiState.value = _uiState.value.copy(manualBarcode = value)
}
fun acceptBarcode(raw: String) {
val barcode = normalizeBarcode(raw)
if (!isValidBarcode(barcode)) {
log("Invalid barcode")
return
}
_uiState.value = _uiState.value.copy(
tag = tagFromBarcode(barcode),
manualBarcode = barcode,
)
renderPreview()
log("Loaded tag $barcode")
}
fun loadImage(uri: Uri) {
viewModelScope.launch {
val bitmap = withContext(Dispatchers.IO) {
getApplication<Application>().contentResolver.openInputStream(uri)?.use { input ->
BitmapFactory.decodeStream(input)
}
} ?: return@launch
_uiState.value = _uiState.value.copy(sourceBitmap = bitmap)
renderPreview()
log("Loaded image")
}
}
fun updateEditor(transform: EditorState) {
_uiState.value = _uiState.value.copy(editor = transform)
scheduleRenderPreview()
}
fun updateEditor(mutator: (EditorState) -> EditorState) {
_uiState.value = _uiState.value.copy(editor = mutator(_uiState.value.editor))
scheduleRenderPreview()
}
private fun scheduleRenderPreview() {
renderJob?.cancel()
renderJob = viewModelScope.launch {
delay(72)
renderPreview()
}
}
private fun renderPreview() {
val tag = _uiState.value.tag ?: return
val source = _uiState.value.sourceBitmap ?: run {
_uiState.value = _uiState.value.copy(previewBitmap = null, payloadSizeLabel = "No image")
return
}
viewModelScope.launch(Dispatchers.Default) {
val result = ImagePipeline.render(
source = source,
width = tag.width,
height = tag.height,
accentColor = tag.color,
editor = _uiState.value.editor,
)
withContext(Dispatchers.Main) {
val cacheFile = File(getApplication<Application>().cacheDir, "latest_payload.bmp")
cacheFile.writeBytes(result.bmpBytes)
_uiState.value = _uiState.value.copy(
previewBitmap = result.bitmap,
payloadSizeLabel = "${result.bmpBytes.size} B",
)
}
}
}
fun sendCurrentPayload() {
val state = _uiState.value
val tag = state.tag ?: return
val bmpFile = File(getApplication<Application>().cacheDir, "latest_payload.bmp")
if (!bmpFile.exists()) {
log("No payload rendered")
return
}
viewModelScope.launch {
_uiState.value = _uiState.value.copy(
progress = 0f,
status = "Uploading to Flipper",
)
runCatching {
bleClient.upload(
UploadJob(
jobId = UUID.randomUUID().toString().replace("-", "").take(6).uppercase(),
barcode = tag.barcode,
width = tag.width,
height = tag.height,
page = (state.editor.page - 1).coerceAtLeast(0),
bmpBytes = bmpFile.readBytes(),
),
) { progress ->
applyProgress(progress)
}
}.onSuccess {
val stamped = File(
getApplication<Application>().filesDir,
"payload-${System.currentTimeMillis()}.bmp",
)
bmpFile.copyTo(stamped, overwrite = true)
val updated = listOf(
PayloadHistoryItem(
id = UUID.randomUUID().toString(),
barcode = tag.barcode,
name = tag.name,
width = tag.width,
height = tag.height,
color = tag.color,
page = state.editor.page,
bmpPath = stamped.absolutePath,
timestamp = System.currentTimeMillis(),
),
) + _uiState.value.history
historyStore.save(updated)
_uiState.value = _uiState.value.copy(
history = updated.take(24),
progress = 1f,
status = "Saved on Flipper",
)
log("Upload complete")
}.onFailure {
_uiState.value = _uiState.value.copy(status = it.message ?: "Upload failed")
log(it.message ?: "Upload failed")
}
}
}
fun resendHistory(item: PayloadHistoryItem) {
acceptBarcode(item.barcode)
_uiState.value = _uiState.value.copy(
editor = _uiState.value.editor.copy(page = item.page),
sourceBitmap = BitmapFactory.decodeFile(item.bmpPath),
)
renderPreview()
}
private fun applyProgress(progress: UploadProgress) {
_uiState.value = _uiState.value.copy(
progress = if (progress.bytesTotal == 0) 0f else progress.bytesDone.toFloat() / progress.bytesTotal,
status = progress.label,
)
}
private fun log(message: String) {
_uiState.value = _uiState.value.copy(
logs = (_uiState.value.logs + "${System.currentTimeMillis()}: $message").takeLast(24),
)
}
}
@@ -1,284 +0,0 @@
package xyz.i12bp8.tagtinker.android.ble
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.util.Log
import androidx.core.content.ContextCompat
import java.util.UUID
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import no.nordicsemi.android.kotlin.ble.client.main.callback.ClientBleGatt
import no.nordicsemi.android.kotlin.ble.client.main.service.ClientBleGattCharacteristic
import no.nordicsemi.android.kotlin.ble.core.data.BleWriteType
import no.nordicsemi.android.kotlin.ble.core.data.BleGattConnectOptions
import no.nordicsemi.android.kotlin.ble.core.data.util.DataByteArray
import no.nordicsemi.android.kotlin.ble.core.scanner.BleScanMode
import no.nordicsemi.android.kotlin.ble.core.scanner.BleScannerSettings
import no.nordicsemi.android.kotlin.ble.scanner.BleScanner
import no.nordicsemi.android.kotlin.ble.scanner.aggregator.BleScanResultAggregator
import xyz.i12bp8.tagtinker.android.model.BlePeripheral
import xyz.i12bp8.tagtinker.android.model.UploadJob
import xyz.i12bp8.tagtinker.android.model.UploadProgress
// Official Flipper Serial UUIDs
private val serialServiceUuid = UUID.fromString("8fe5b3d5-2e7f-4a98-2a48-7acc60fe0000")
private val serialWriteUuid = UUID.fromString("19ed82ae-ed21-4c9d-4145-228e62fe0000") // Phone -> Flipper
private val serialNotifyUuid = UUID.fromString("19ed82ae-ed21-4c9d-4145-228e61fe0000") // Flipper -> Phone
private const val logTag = "TagTinkerBle"
private const val connectTimeoutMs = 30000L
private const val mtuSize = 247
// How many bytes per chunk we send to the Flipper.
// 128 bytes → ~172 chars base64 → well under BLE MTU with header.
private const val CHUNK_BYTES = 128
class FlipperBleClient(private val context: Context) {
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val adapter: BluetoothAdapter? =
context.getSystemService(BluetoothManager::class.java)?.adapter
private val _scanResults = MutableStateFlow<List<BlePeripheral>>(emptyList())
val scanResults: StateFlow<List<BlePeripheral>> = _scanResults
private val _scanStatus = MutableStateFlow<String?>(null)
val scanStatus: StateFlow<String?> = _scanStatus
private var notifyJob: Job? = null
private var client: ClientBleGatt? = null
private var writeCharacteristic: ClientBleGattCharacteristic? = null
private var notifyCharacteristic: ClientBleGattCharacteristic? = null
private val ackWaiters = linkedMapOf<String, CompletableDeferred<String>>()
private val rxBuffer = StringBuilder()
private var helloReceived = false
private var pongReceived = false
fun isAvailable(): Boolean {
return ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) ==
PackageManager.PERMISSION_GRANTED
}
fun startScan() { _scanStatus.value = "Use System Picker" }
fun stopScan() {}
@SuppressLint("MissingPermission")
suspend fun connect(address: String) {
Log.d(logTag, "Connecting to $address...")
disconnect()
val bluetoothDevice = adapter?.getRemoteDevice(address) ?: error("Device not found")
val serverDevice = no.nordicsemi.android.kotlin.ble.core.RealServerDevice(bluetoothDevice)
val gatt = withTimeout(connectTimeoutMs) {
ClientBleGatt.connect(context, serverDevice, appScope, BleGattConnectOptions(autoConnect = false))
}
if (!gatt.isConnected) error("Connection failed")
this.client = gatt
Log.d(logTag, "GATT connected, waiting for bond...")
try {
withTimeout(15000) { gatt.waitForBonding() }
} catch (e: Exception) {
Log.w(logTag, "Bonding stalled, continuing anyway")
}
gatt.requestMtu(mtuSize)
delay(500)
Log.d(logTag, "Discovering services...")
gatt.discoverServices()
val services = withTimeout(20000) {
var found: no.nordicsemi.android.kotlin.ble.client.main.service.ClientBleGattServices? = null
repeat(3) { attempt ->
if (attempt > 0) {
Log.d(logTag, "Retrying service discovery (attempt ${attempt + 1})...")
gatt.discoverServices()
}
found = withTimeoutOrNull(5000) {
gatt.services.filter { it != null }.first()
}
if (found != null) return@withTimeout found
delay(1000)
}
found
} ?: error("BLE services unavailable")
val serialService = services.findService(serialServiceUuid) ?: error("Serial service missing")
notifyCharacteristic = serialService.findCharacteristic(serialNotifyUuid)
writeCharacteristic = serialService.findCharacteristic(serialWriteUuid)
helloReceived = false
pongReceived = false
val notifyProps = notifyCharacteristic?.properties
Log.d(logTag, "Notify char props: $notifyProps")
notifyJob = appScope.launch {
if (notifyProps?.contains(no.nordicsemi.android.kotlin.ble.core.data.BleGattProperty.PROPERTY_NOTIFY) == true ||
notifyProps?.contains(no.nordicsemi.android.kotlin.ble.core.data.BleGattProperty.PROPERTY_INDICATE) == true) {
notifyCharacteristic?.getNotifications(bufferOverflow = BufferOverflow.SUSPEND)?.collect {
handleNotify(it.value)
}
} else {
Log.e(logTag, "Notify characteristic has no NOTIFY or INDICATE property!")
}
}
Log.d(logTag, "Subscribed to notifications, waiting for handshake...")
sendHandshake()
}
private suspend fun sendHandshake() {
// Wait briefly to see if Flipper auto-sends TT_HELLO
delay(3000)
if (helloReceived) return
Log.d(logTag, "Flipper quiet — sending TT_PING poke...")
repeat(3) { attempt ->
runCatching {
sendLine("TT_PING", ack = "TT_PONG")
}.onSuccess {
Log.d(logTag, "Handshake OK via PING")
return
}.onFailure {
Log.w(logTag, "Handshake attempt ${attempt + 1} failed: ${it.message}")
delay(1200)
}
}
if (!helloReceived && !pongReceived) {
throw IllegalStateException("Handshake failed — Flipper not responding")
}
}
suspend fun disconnect() {
notifyJob?.cancel()
notifyJob = null
runCatching { client?.disconnect() }
runCatching { client?.close() }
client = null
ackWaiters.values.forEach { it.cancel() }
ackWaiters.clear()
rxBuffer.clear()
}
private fun handleNotify(bytes: ByteArray) {
if (bytes.isEmpty()) return
Log.d(logTag, "RX Packet: ${bytes.joinToString("") { "%02X".format(it) }}")
String(bytes, Charsets.UTF_8).forEach { char ->
if (char == '\n' || char == '\r') {
val line = rxBuffer.toString().trim()
rxBuffer.clear()
if (line.isNotBlank()) {
Log.d(logTag, "RX Line: $line")
if (line == "TT_HELLO") helloReceived = true
if (line == "TT_PONG") pongReceived = true
ackWaiters.remove(line)?.complete(line)
}
} else if (char.code in 32..126) {
rxBuffer.append(char)
}
}
}
/**
* Sends a line to the Flipper and optionally waits for a specific ACK response.
*
* Flow control is entirely ACK-based: the phone sends one chunk, waits for
* TT_ACK|N from the Flipper, then sends the next. There is no credit/window
* mechanism — that approach was unreliable for large images.
*
* We retry up to [maxAttempts] times with [ackTimeoutMs] timeout each.
*/
suspend fun sendLine(
line: String,
ack: String? = null,
ackTimeoutMs: Long = 8000L,
maxAttempts: Int = 3,
) {
val char = writeCharacteristic ?: error("Not connected")
val payload = "$line\n".encodeToByteArray()
repeat(maxAttempts) { attempt ->
val deferred = ack?.let { CompletableDeferred<String>() }
if (ack != null) ackWaiters[ack] = deferred!!
Log.d(logTag, "TX (attempt ${attempt + 1}/$maxAttempts): $line")
char.write(DataByteArray(payload), BleWriteType.NO_RESPONSE)
if (ack == null) return
try {
withTimeout(ackTimeoutMs) { deferred!!.await() }
return // ACK received — success
} catch (e: Exception) {
Log.w(logTag, "Timeout waiting for '$ack' (attempt ${attempt + 1})")
ackWaiters.remove(ack)
if (attempt < maxAttempts - 1) delay(200) // brief pause before retry
}
}
error("Timed out after $maxAttempts attempts sending: $line")
}
/**
* Uploads a BMP image to the Flipper via the TT_BEGIN / TT_DATA / TT_END protocol.
*
* Each chunk is sent and confirmed via ACK before the next chunk is sent.
* Chunk size is [CHUNK_BYTES] bytes (128), encoded as URL-safe base64.
*/
suspend fun upload(job: UploadJob, onProgress: (UploadProgress) -> Unit) {
onProgress(UploadProgress(0, job.bmpBytes.size, "Preparing..."))
// Verify connection is alive
sendLine("TT_PING", ack = "TT_PONG")
// Begin upload — Flipper opens file and responds TT_ACK|BEGIN
val beginCmd = "TT_BEGIN|${job.jobId}|${job.barcode}|${job.width}|${job.height}|${job.page}|${job.bmpBytes.size}"
sendLine(beginCmd, ack = "TT_ACK|BEGIN", ackTimeoutMs = 10000L)
// Stream chunks — each chunk waits for its individual ACK
var offset = 0
var sequence = 1
while (offset < job.bmpBytes.size) {
val end = minOf(job.bmpBytes.size, offset + CHUNK_BYTES)
val chunk = job.bmpBytes.copyOfRange(offset, end)
val b64 = android.util.Base64.encodeToString(
chunk,
android.util.Base64.NO_WRAP or android.util.Base64.URL_SAFE
)
onProgress(UploadProgress(offset, job.bmpBytes.size, "Chunk $sequence / ${(job.bmpBytes.size + CHUNK_BYTES - 1) / CHUNK_BYTES}"))
sendLine("TT_DATA|$sequence|$b64", ack = "TT_ACK|$sequence", ackTimeoutMs = 10000L)
offset = end
sequence++
}
// Finalize — Flipper renames file and marks ready
sendLine("TT_END|${job.jobId}", ack = "TT_ACK|END", ackTimeoutMs = 15000L)
onProgress(UploadProgress(job.bmpBytes.size, job.bmpBytes.size, "Sync complete!"))
}
}
@@ -1,52 +0,0 @@
package xyz.i12bp8.tagtinker.android.data
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
import xyz.i12bp8.tagtinker.android.model.PayloadHistoryItem
class HistoryStore(context: Context) {
private val prefs = context.getSharedPreferences("tagtinker_history", Context.MODE_PRIVATE)
fun load(): List<PayloadHistoryItem> {
val raw = prefs.getString("items", "[]") ?: "[]"
val json = JSONArray(raw)
return buildList {
for (index in 0 until json.length()) {
val item = json.getJSONObject(index)
add(
PayloadHistoryItem(
id = item.getString("id"),
barcode = item.getString("barcode"),
name = item.getString("name"),
width = item.getInt("width"),
height = item.getInt("height"),
color = item.getString("color"),
page = item.getInt("page"),
bmpPath = item.getString("bmpPath"),
timestamp = item.getLong("timestamp"),
),
)
}
}
}
fun save(items: List<PayloadHistoryItem>) {
val array = JSONArray()
items.take(24).forEach { item ->
array.put(
JSONObject()
.put("id", item.id)
.put("barcode", item.barcode)
.put("name", item.name)
.put("width", item.width)
.put("height", item.height)
.put("color", item.color)
.put("page", item.page)
.put("bmpPath", item.bmpPath)
.put("timestamp", item.timestamp),
)
}
prefs.edit().putString("items", array.toString()).apply()
}
}
@@ -1,80 +0,0 @@
package xyz.i12bp8.tagtinker.android.data
import xyz.i12bp8.tagtinker.android.model.TagProfile
import xyz.i12bp8.tagtinker.android.model.TagRecord
private val tagProfiles = listOf(
TagProfile(1275, 320, 192, "graphic", "mono", "DM110"),
TagProfile(1276, 320, 140, "graphic", "mono", "DM90"),
TagProfile(1300, 172, 72, "graphic", "mono", "DM3370"),
TagProfile(1314, 400, 300, "graphic", "mono", "SmartTag HD110"),
TagProfile(1315, 296, 128, "graphic", "mono", "SmartTag HD L"),
TagProfile(1317, 152, 152, "graphic", "mono", "SmartTag HD S"),
TagProfile(1318, 208, 112, "graphic", "mono", "SmartTag HD M"),
TagProfile(1319, 800, 480, "graphic", "mono", "SmartTag HD200"),
TagProfile(1322, 152, 152, "graphic", "mono", "SmartTag HD S"),
TagProfile(1324, 208, 112, "graphic", "mono", "SmartTag HD M FZ"),
TagProfile(1327, 208, 112, "graphic", "red", "SmartTag HD M Red"),
TagProfile(1328, 296, 128, "graphic", "red", "SmartTag HD L Red"),
TagProfile(1336, 400, 300, "graphic", "red", "SmartTag HD110 Red"),
TagProfile(1339, 152, 152, "graphic", "red", "SmartTag HD S Red"),
TagProfile(1340, 800, 480, "graphic", "red", "SmartTag HD200 Red"),
TagProfile(1344, 296, 128, "graphic", "yellow", "SmartTag HD L Yellow"),
TagProfile(1346, 800, 480, "graphic", "yellow", "SmartTag HD200 Yellow"),
TagProfile(1348, 264, 176, "graphic", "red", "SmartTag HD T Red"),
TagProfile(1349, 264, 176, "graphic", "yellow", "SmartTag HD T Yellow"),
TagProfile(1351, 648, 480, "graphic", "mono", "SmartTag HD150"),
TagProfile(1353, 648, 480, "graphic", "red", "SmartTag HD150 Red"),
TagProfile(1370, 296, 128, "graphic", "red", "SmartTag HD L Red 2021"),
TagProfile(1371, 648, 480, "graphic", "red", "SmartTag HD150 Red 2021"),
TagProfile(1627, 296, 128, "graphic", "red", "SmartTag HD L Red"),
TagProfile(1628, 296, 128, "graphic", "red", "SmartTag HD L Red"),
TagProfile(1639, 152, 152, "graphic", "red", "SmartTag HD S Red"),
)
fun normalizeBarcode(raw: String): String {
val compact = raw.filter { it.isLetterOrDigit() }.uppercase()
return when {
compact.length >= 17 && compact.firstOrNull()?.isLetter() == true -> compact.take(17)
compact.length == 16 && compact.all { it.isDigit() } -> "N$compact"
else -> compact.take(17)
}
}
fun isValidBarcode(barcode: String): Boolean {
return barcode.length == 17 &&
barcode.first().isLetter() &&
barcode.drop(1).all { it.isDigit() }
}
fun fallbackProfile(type: Int = 0): TagProfile {
return TagProfile(
type = type,
width = 296,
height = 128,
kind = "graphic",
color = "mono",
model = if (type == 0) "Unknown graphic tag" else "Unknown $type",
known = false,
)
}
fun profileFromBarcode(barcode: String): TagProfile {
if (!isValidBarcode(barcode)) return fallbackProfile()
val type = barcode.substring(12, 16).toIntOrNull() ?: 0
return tagProfiles.firstOrNull { it.type == type } ?: fallbackProfile(type)
}
fun tagFromBarcode(raw: String): TagRecord {
val barcode = normalizeBarcode(raw)
val profile = profileFromBarcode(barcode)
return TagRecord(
barcode = barcode,
name = profile.model.ifBlank { barcode },
width = profile.width,
height = profile.height,
color = profile.color,
model = profile.model,
type = profile.type,
)
}
@@ -1,74 +0,0 @@
package xyz.i12bp8.tagtinker.android.model
import java.util.UUID
data class TagProfile(
val type: Int,
val width: Int,
val height: Int,
val kind: String,
val color: String,
val model: String,
val known: Boolean = true,
)
data class TagRecord(
val id: String = UUID.randomUUID().toString(),
val barcode: String,
val name: String,
val width: Int,
val height: Int,
val color: String,
val model: String,
val type: Int,
)
enum class FitMode { Fit, Fill, Stretch }
enum class DitherMode { Floyd, Atkinson, Threshold }
data class EditorState(
val fitMode: FitMode = FitMode.Fit,
val ditherMode: DitherMode = DitherMode.Floyd,
val detail: Int = 60,
val scale: Float = 1f,
val offsetX: Float = 0f,
val offsetY: Float = 0f,
val contrast: Int = 8,
val brightness: Int = 0,
val useColor: Boolean = false,
val page: Int = 1,
)
data class PayloadHistoryItem(
val id: String,
val barcode: String,
val name: String,
val width: Int,
val height: Int,
val color: String,
val page: Int,
val bmpPath: String,
val timestamp: Long,
)
data class BlePeripheral(
val address: String,
val name: String,
val rssi: Int,
)
data class UploadJob(
val jobId: String,
val barcode: String,
val width: Int,
val height: Int,
val page: Int,
val bmpBytes: ByteArray,
)
data class UploadProgress(
val bytesDone: Int,
val bytesTotal: Int,
val label: String,
)
@@ -1,275 +0,0 @@
package xyz.i12bp8.tagtinker.android.render
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Matrix
import android.graphics.Paint
import kotlin.math.max
import kotlin.math.min
import xyz.i12bp8.tagtinker.android.model.DitherMode
import xyz.i12bp8.tagtinker.android.model.EditorState
import xyz.i12bp8.tagtinker.android.model.FitMode
private val white = intArrayOf(255, 255, 255)
private val black = intArrayOf(0, 0, 0)
private val red = intArrayOf(218, 45, 53)
private val yellow = intArrayOf(226, 177, 32)
data class RenderResult(
val bitmap: Bitmap,
val bmpBytes: ByteArray,
)
object ImagePipeline {
fun render(source: Bitmap, width: Int, height: Int, accentColor: String, editor: EditorState): RenderResult {
val framed = drawSourceFrame(source, width, height, editor)
val adjusted = adjustBitmap(framed, editor)
val paletted = quantize(adjusted, accentColor, editor)
return RenderResult(
bitmap = paletted,
bmpBytes = buildBmp(paletted, accentColor != "mono" && editor.useColor),
)
}
private fun drawSourceFrame(source: Bitmap, width: Int, height: Int, editor: EditorState): Bitmap {
val output = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(output)
canvas.drawColor(Color.WHITE)
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
isFilterBitmap = true
}
if (editor.fitMode == FitMode.Stretch) {
canvas.drawBitmap(source, null, android.graphics.Rect(0, 0, width, height), paint)
return output
}
val scaleFactor = when (editor.fitMode) {
FitMode.Fill -> max(width / source.width.toFloat(), height / source.height.toFloat())
else -> min(width / source.width.toFloat(), height / source.height.toFloat())
} * editor.scale
val drawWidth = max(1f, source.width * scaleFactor)
val drawHeight = max(1f, source.height * scaleFactor)
val left = (width - drawWidth) / 2f + editor.offsetX * width * 0.5f
val top = (height - drawHeight) / 2f + editor.offsetY * height * 0.5f
val matrix = Matrix().apply {
postScale(drawWidth / source.width, drawHeight / source.height)
postTranslate(left, top)
}
canvas.drawBitmap(source, matrix, paint)
return output
}
private fun adjustBitmap(bitmap: Bitmap, editor: EditorState): Bitmap {
val out = bitmap.copy(Bitmap.Config.ARGB_8888, true)
val pixels = IntArray(out.width * out.height)
out.getPixels(pixels, 0, out.width, 0, 0, out.width, out.height)
val contrast = editor.contrast
val brightness = editor.brightness
val factor = (259f * (contrast + 255)) / (255 * (259 - contrast))
for (i in pixels.indices) {
val color = pixels[i]
val r = clamp(factor * (Color.red(color) - 128) + 128 + brightness)
val g = clamp(factor * (Color.green(color) - 128) + 128 + brightness)
val b = clamp(factor * (Color.blue(color) - 128) + 128 + brightness)
pixels[i] = Color.argb(255, r, g, b)
}
out.setPixels(pixels, 0, out.width, 0, 0, out.width, out.height)
return out
}
private fun quantize(bitmap: Bitmap, accentColor: String, editor: EditorState): Bitmap {
val width = bitmap.width
val height = bitmap.height
val src = IntArray(width * height)
bitmap.getPixels(src, 0, width, 0, 0, width, height)
val out = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val dst = IntArray(width * height)
val palette = if (editor.useColor && accentColor != "mono") {
arrayOf(white, black, if (accentColor == "yellow") yellow else red)
} else {
arrayOf(white, black)
}
if (editor.ditherMode == DitherMode.Threshold) {
src.indices.forEach { index ->
dst[index] = nearestColor(src[index], palette, editor.detail)
}
} else {
val work = FloatArray(width * height * 3)
src.indices.forEach { index ->
work[index * 3] = Color.red(src[index]).toFloat()
work[index * 3 + 1] = Color.green(src[index]).toFloat()
work[index * 3 + 2] = Color.blue(src[index]).toFloat()
}
for (y in 0 until height) {
for (x in 0 until width) {
val base = (y * width + x) * 3
val current = Color.rgb(
clamp(work[base]),
clamp(work[base + 1]),
clamp(work[base + 2]),
)
val next = nearestColor(current, palette, editor.detail)
dst[y * width + x] = next
val er = Color.red(current) - Color.red(next)
val eg = Color.green(current) - Color.green(next)
val eb = Color.blue(current) - Color.blue(next)
diffuse(work, width, height, x, y, er, eg, eb, editor.ditherMode)
}
}
}
out.setPixels(dst, 0, width, 0, 0, width, height)
return out
}
private fun diffuse(
work: FloatArray,
width: Int,
height: Int,
x: Int,
y: Int,
er: Int,
eg: Int,
eb: Int,
mode: DitherMode,
) {
val points = if (mode == DitherMode.Atkinson) {
listOf(
intArrayOf(1, 0, 1, 8),
intArrayOf(2, 0, 1, 8),
intArrayOf(-1, 1, 1, 8),
intArrayOf(0, 1, 1, 8),
intArrayOf(1, 1, 1, 8),
intArrayOf(0, 2, 1, 8),
)
} else {
listOf(
intArrayOf(1, 0, 7, 16),
intArrayOf(-1, 1, 3, 16),
intArrayOf(0, 1, 5, 16),
intArrayOf(1, 1, 1, 16),
)
}
points.forEach { point ->
val nx = x + point[0]
val ny = y + point[1]
if (nx !in 0 until width || ny !in 0 until height) return@forEach
val factor = point[2].toFloat() / point[3]
val base = (ny * width + nx) * 3
work[base] += er * factor
work[base + 1] += eg * factor
work[base + 2] += eb * factor
}
}
private fun nearestColor(color: Int, palette: Array<IntArray>, detail: Int): Int {
val threshold = 128 + (detail - 50) * 1.18f
if (palette.size == 2) {
val luma = Color.red(color) * 0.299f + Color.green(color) * 0.587f + Color.blue(color) * 0.114f
return if (luma >= threshold) Color.WHITE else Color.BLACK
}
var best = palette.first()
var bestDist = Float.MAX_VALUE
palette.forEach { candidate ->
val dr = Color.red(color) - candidate[0]
val dg = Color.green(color) - candidate[1]
val db = Color.blue(color) - candidate[2]
val dist = dr * dr * 0.92f + dg * dg + db * db * 0.86f
if (dist < bestDist) {
bestDist = dist
best = candidate
}
}
return Color.rgb(best[0], best[1], best[2])
}
private fun buildBmp(bitmap: Bitmap, accent: Boolean): ByteArray {
val width = bitmap.width
val height = bitmap.height
val rowStride = ((width + 31) / 32) * 4
val colorStride = if (accent) rowStride else 0
val pixelBytes = (rowStride + colorStride) * height
val paletteBytes = if (accent) 12 else 8
val headerSize = 14 + 40 + paletteBytes
val fileSize = headerSize + pixelBytes
val bytes = ByteArray(fileSize)
writeAscii(bytes, 0, "BM")
writeIntLE(bytes, 2, fileSize)
writeIntLE(bytes, 10, headerSize)
writeIntLE(bytes, 14, 40)
writeIntLE(bytes, 18, width)
writeIntLE(bytes, 22, height)
writeShortLE(bytes, 26, 1)
writeShortLE(bytes, 28, if (accent) 2 else 1)
writeIntLE(bytes, 34, pixelBytes)
var paletteOffset = 54
writePalette(bytes, paletteOffset, Color.WHITE)
paletteOffset += 4
writePalette(bytes, paletteOffset, Color.BLACK)
if (accent) {
paletteOffset += 4
var accentColor = Color.RED
for (y in 0 until height) {
for (x in 0 until width) {
val c = bitmap.getPixel(x, y)
if (c != Color.WHITE && c != Color.BLACK) accentColor = c
}
}
writePalette(bytes, paletteOffset, accentColor)
}
val monoOffset = headerSize
val accentOffset = headerSize + rowStride * height
for (y in 0 until height) {
val flippedY = height - 1 - y
for (x in 0 until width) {
val color = bitmap.getPixel(x, flippedY)
val byteIndex = x / 8
val bit = 7 - (x % 8)
if (color == Color.BLACK) {
bytes[monoOffset + y * rowStride + byteIndex] =
(bytes[monoOffset + y * rowStride + byteIndex].toInt() or (1 shl bit)).toByte()
} else if (accent && color != Color.WHITE) {
bytes[accentOffset + y * rowStride + byteIndex] =
(bytes[accentOffset + y * rowStride + byteIndex].toInt() or (1 shl bit)).toByte()
}
}
}
return bytes
}
private fun writeAscii(bytes: ByteArray, offset: Int, value: String) {
value.encodeToByteArray().copyInto(bytes, offset)
}
private fun writeShortLE(bytes: ByteArray, offset: Int, value: Int) {
bytes[offset] = (value and 0xff).toByte()
bytes[offset + 1] = ((value shr 8) and 0xff).toByte()
}
private fun writeIntLE(bytes: ByteArray, offset: Int, value: Int) {
bytes[offset] = (value and 0xff).toByte()
bytes[offset + 1] = ((value shr 8) and 0xff).toByte()
bytes[offset + 2] = ((value shr 16) and 0xff).toByte()
bytes[offset + 3] = ((value shr 24) and 0xff).toByte()
}
private fun writePalette(bytes: ByteArray, offset: Int, color: Int) {
bytes[offset] = Color.blue(color).toByte()
bytes[offset + 1] = Color.green(color).toByte()
bytes[offset + 2] = Color.red(color).toByte()
bytes[offset + 3] = 0
}
private fun clamp(value: Float): Int = value.toInt().coerceIn(0, 255)
}
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.TagTinker" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/black</item>
</style>
</resources>
Binary file not shown.
-5
View File
@@ -1,5 +0,0 @@
plugins {
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
}
-4
View File
@@ -1,4 +0,0 @@
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
Binary file not shown.
@@ -1,6 +0,0 @@
#Thu Aug 08 15:05:08 CEST 2019
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
-172
View File
@@ -1,172 +0,0 @@
#!/usr/bin/env sh
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS=""
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=$((i+1))
done
case $i in
(0) set -- ;;
(1) set -- "$args0" ;;
(2) set -- "$args0" "$args1" ;;
(3) set -- "$args0" "$args1" "$args2" ;;
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=$(save "$@")
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
cd "$(dirname "$0")"
fi
exec "$JAVACMD" "$@"
-84
View File
@@ -1,84 +0,0 @@
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windows variants
if not "%OS%" == "Windows_NT" goto win9xME_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
-1
View File
@@ -1 +0,0 @@
sdk.dir=/home/i12bp8/Android/sdk
-18
View File
@@ -1,18 +0,0 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "TagTinkerAndroid"
include(":app")
+8 -132
View File
@@ -2,61 +2,9 @@
enum {
ImageOptionPage,
ImageOptionOffsetX,
ImageOptionOffsetY,
ImageOptionCompression,
ImageOptionFrameRepeat,
ImageOptionTransmit,
};
static const char* image_option_compression_labels[] = {"Auto", "Raw", "RLE"};
static const uint16_t image_option_coord_values[] = {
0, 8, 16, 24, 32, 40, 48, 56, 64, 72, 80, 88, 96, 104, 112, 120, 128,
136, 144, 152, 160, 168, 176, 184, 192, 200, 208, 216, 224, 232, 240, 248, 256, 264,
272, 280, 288, 296, 304, 312, 320, 328, 336, 344, 352, 360, 368, 376, 384, 392, 400,
408, 416, 424, 432, 440, 448, 456, 464, 472, 480, 488, 496, 504, 512, 520, 528, 536,
544, 552, 560, 568, 576, 584, 592, 600, 608, 616, 624, 632, 640, 648, 656, 664, 672,
680, 688, 696, 704, 712, 720, 728, 736, 744, 752, 760, 768, 776, 784, 792, 800,
};
#define IMAGE_OPTION_COORD_COUNT COUNT_OF(image_option_coord_values)
static uint8_t image_option_nearest_coord_index(uint16_t value) {
uint8_t best_idx = 0;
uint16_t best_diff = UINT16_MAX;
for(uint8_t i = 0; i < IMAGE_OPTION_COORD_COUNT; i++) {
uint16_t current = image_option_coord_values[i];
uint16_t diff = (current > value) ? (current - value) : (value - current);
if(diff < best_diff) {
best_diff = diff;
best_idx = i;
}
}
return best_idx;
}
static void image_option_clamp_position(TagTinkerApp* app) {
if(!app) return;
if(app->selected_target < 0 || app->selected_target >= app->target_count) return;
TagTinkerImageTxJob* job = &app->image_tx_job;
const TagTinkerTarget* target = &app->targets[app->selected_target];
if(!target->profile.known || !target->profile.width || !target->profile.height) return;
uint16_t max_x = (job->width < target->profile.width) ?
(uint16_t)(target->profile.width - job->width) :
0U;
uint16_t max_y = (job->height < target->profile.height) ?
(uint16_t)(target->profile.height - job->height) :
0U;
if(job->pos_x > max_x) job->pos_x = max_x;
if(job->pos_y > max_y) job->pos_y = max_y;
}
static void image_option_page_changed(VariableItem* item) {
TagTinkerApp* app = variable_item_get_context(item);
app->image_tx_job.page = variable_item_get_current_value_index(item);
@@ -67,59 +15,11 @@ static void image_option_page_changed(VariableItem* item) {
variable_item_set_current_value_text(item, buf);
}
static void image_option_offset_x_changed(VariableItem* item) {
TagTinkerApp* app = variable_item_get_context(item);
uint8_t index = variable_item_get_current_value_index(item);
if(index >= IMAGE_OPTION_COORD_COUNT) index = IMAGE_OPTION_COORD_COUNT - 1U;
app->image_tx_job.pos_x = image_option_coord_values[index];
image_option_clamp_position(app);
app->draw_x = app->image_tx_job.pos_x;
char buf[8];
snprintf(buf, sizeof(buf), "%u", app->image_tx_job.pos_x);
variable_item_set_current_value_text(item, buf);
}
static void image_option_offset_y_changed(VariableItem* item) {
TagTinkerApp* app = variable_item_get_context(item);
uint8_t index = variable_item_get_current_value_index(item);
if(index >= IMAGE_OPTION_COORD_COUNT) index = IMAGE_OPTION_COORD_COUNT - 1U;
app->image_tx_job.pos_y = image_option_coord_values[index];
image_option_clamp_position(app);
app->draw_y = app->image_tx_job.pos_y;
char buf[8];
snprintf(buf, sizeof(buf), "%u", app->image_tx_job.pos_y);
variable_item_set_current_value_text(item, buf);
}
static void image_option_compression_changed(VariableItem* item) {
TagTinkerApp* app = variable_item_get_context(item);
uint8_t index = variable_item_get_current_value_index(item);
if(index > TagTinkerCompressionRle) index = TagTinkerCompressionRle;
app->compression_mode = (TagTinkerCompressionMode)index;
variable_item_set_current_value_text(item, image_option_compression_labels[index]);
}
static void image_option_frame_repeat_changed(VariableItem* item) {
TagTinkerApp* app = variable_item_get_context(item);
uint8_t index = variable_item_get_current_value_index(item);
app->data_frame_repeats = index + 1U;
char buf[4];
snprintf(buf, sizeof(buf), "%u", app->data_frame_repeats);
variable_item_set_current_value_text(item, buf);
}
static void image_option_enter_cb(void* ctx, uint32_t index) {
TagTinkerApp* app = ctx;
if(index != ImageOptionTransmit) return;
image_option_clamp_position(app);
app->tx_spam = false;
scene_manager_next_scene(app->scene_manager, TagTinkerSceneTransmit);
}
@@ -134,7 +34,6 @@ void tagtinker_scene_image_options_on_enter(void* ctx) {
return;
}
image_option_clamp_position(app);
variable_item_list_reset(list);
VariableItem* item =
@@ -146,37 +45,14 @@ void tagtinker_scene_image_options_on_enter(void* ctx) {
variable_item_set_current_value_text(item, buf);
}
item = variable_item_list_add(
list, "Offset X", IMAGE_OPTION_COORD_COUNT, image_option_offset_x_changed, app);
variable_item_set_current_value_index(item, image_option_nearest_coord_index(job->pos_x));
{
char buf[8];
snprintf(buf, sizeof(buf), "%u", job->pos_x);
variable_item_set_current_value_text(item, buf);
}
item = variable_item_list_add(
list, "Offset Y", IMAGE_OPTION_COORD_COUNT, image_option_offset_y_changed, app);
variable_item_set_current_value_index(item, image_option_nearest_coord_index(job->pos_y));
{
char buf[8];
snprintf(buf, sizeof(buf), "%u", job->pos_y);
variable_item_set_current_value_text(item, buf);
}
item = variable_item_list_add(
list, "Compression", 3, image_option_compression_changed, app);
variable_item_set_current_value_index(item, app->compression_mode);
variable_item_set_current_value_text(item, image_option_compression_labels[app->compression_mode]);
item = variable_item_list_add(
list, "Frame Repeat", 5, image_option_frame_repeat_changed, app);
variable_item_set_current_value_index(item, app->data_frame_repeats - 1U);
{
char buf[4];
snprintf(buf, sizeof(buf), "%u", app->data_frame_repeats);
variable_item_set_current_value_text(item, buf);
}
/* Position, compression and frame-repeat are intentionally fixed here:
* pages are the only knob that meaningfully changes per-image, the rest
* are best left at their defaults (top-left, auto RLE, x2 frame repeat).
* Power users can still tune them in Settings. */
job->pos_x = 0;
job->pos_y = 0;
app->draw_x = 0;
app->draw_y = 0;
variable_item_list_add(list, ">> Send BMP <<", 0, NULL, app);
variable_item_list_set_enter_callback(list, image_option_enter_cb, app);
-7
View File
@@ -8,7 +8,6 @@ enum {
MainMenuBroadcast,
MainMenuTargetESL,
MainMenuSettings,
MainMenuPhoneSync,
MainMenuAbout,
};
@@ -25,7 +24,6 @@ void tagtinker_scene_main_menu_on_enter(void* ctx) {
submenu_add_item(app->submenu, "Broadcast Payloads", MainMenuBroadcast, main_menu_cb, app);
submenu_add_item(app->submenu, "Targeted Payloads", MainMenuTargetESL, main_menu_cb, app);
submenu_add_item(app->submenu, "Phone Sync (Custom Images)", MainMenuPhoneSync, main_menu_cb, app);
submenu_add_item(app->submenu, "Settings", MainMenuSettings, main_menu_cb, app);
submenu_add_item(app->submenu, "About", MainMenuAbout, main_menu_cb, app);
@@ -52,11 +50,6 @@ bool tagtinker_scene_main_menu_on_event(void* ctx, SceneManagerEvent event) {
case MainMenuSettings:
scene_manager_next_scene(app->scene_manager, TagTinkerSceneSettings);
return true;
case MainMenuPhoneSync:
/* state=1 tells About scene to show the browser sync receiver */
scene_manager_set_scene_state(app->scene_manager, TagTinkerSceneAbout, 1);
scene_manager_next_scene(app->scene_manager, TagTinkerSceneAbout);
return true;
case MainMenuAbout:
scene_manager_set_scene_state(app->scene_manager, TagTinkerSceneAbout, 0);
scene_manager_next_scene(app->scene_manager, TagTinkerSceneAbout);
+122 -10
View File
@@ -1,9 +1,117 @@
#include "../tagtinker_app.h"
#define EVT_SYNCED_IMAGE_BASE 300
#define TAGTINKER_DROPPED_DIR APP_DATA_PATH("dropped")
/* Build a synced image entry from any .bmp file in the dropped/ folder. The
* Flipper rescales BMPs at send time (see tagtinker_scene_transmit.c), so any
* BMP can target any tag - the entry's width/height are the *target* dims, not
* the source dims, and tx_stream_bmp_image samples the source pixels via
* nearest-neighbour as it streams.
*
* Filenames produced by web-image-prep are "<W>x<H>[_<label>].bmp"; the W/H
* prefix is optional and used only as a label hint. Legacy "_p<page>" suffix
* sets the default page. */
static bool tagtinker_parse_dropped_filename(
const char* name,
const char* expected_barcode,
uint16_t target_w,
uint16_t target_h,
TagTinkerSyncedImage* out) {
if(!name || !out) return false;
size_t name_len = strlen(name);
if(name_len < 5U) return false; /* min: "a.bmp" */
const char* ext = name + name_len - 4;
if(!((ext[0] == '.') &&
(ext[1] == 'b' || ext[1] == 'B') &&
(ext[2] == 'm' || ext[2] == 'M') &&
(ext[3] == 'p' || ext[3] == 'P'))) return false;
unsigned page = 1U;
int consumed = 0;
unsigned w_hint = 0U, h_hint = 0U;
bool has_hint = false;
/* Optional resolution prefix - we don't filter on it, the transmitter
* rescales any BMP to the target's dimensions automatically. The "_p<N>"
* suffix, when present, just sets the default page in image options. */
if(sscanf(name, "%ux%u%n", &w_hint, &h_hint, &consumed) >= 2) {
has_hint = true;
if((size_t)consumed < name_len && name[consumed] == '_' && name[consumed + 1] == 'p') {
unsigned parsed_page = 0U;
if(sscanf(name + consumed + 2, "%u", &parsed_page) == 1 && parsed_page <= 7U) {
page = parsed_page;
}
}
}
memset(out, 0, sizeof(*out));
if(expected_barcode) {
strncpy(out->barcode, expected_barcode, TAGTINKER_BC_LEN);
out->barcode[TAGTINKER_BC_LEN] = '\0';
}
/* Use the filename (without .bmp) as a stable job_id for display. */
size_t id_len = name_len - 4U;
if(id_len > TAGTINKER_SYNC_JOB_ID_LEN) id_len = TAGTINKER_SYNC_JOB_ID_LEN;
memcpy(out->job_id, name, id_len);
out->job_id[id_len] = '\0';
/* Stamp the target's resolution: the transmitter will rescale on the fly. */
out->width = target_w;
out->height = target_h;
out->page = (uint8_t)page;
/* Mark as resampled when the filename hints at a different source size,
* or when there's no hint at all (we can't tell, so flag it as foreign
* to be safe - it's a subtle "this might not be native" indicator). */
if(has_hint) {
out->resampled = (w_hint != target_w) || (h_hint != target_h);
} else {
out->resampled = true;
}
snprintf(out->image_path, sizeof(out->image_path), "%s/%s", TAGTINKER_DROPPED_DIR, name);
return true;
}
static void dropped_images_load(TagTinkerApp* app) {
if(app->selected_target < 0 || app->selected_target >= app->target_count) return;
const TagTinkerTarget* target = &app->targets[app->selected_target];
if(!tagtinker_target_supports_graphics(target)) return;
Storage* storage = furi_record_open(RECORD_STORAGE);
storage_common_mkdir(storage, TAGTINKER_DROPPED_DIR);
File* dir = storage_file_alloc(storage);
if(storage_dir_open(dir, TAGTINKER_DROPPED_DIR)) {
FileInfo info;
char name[128];
while(app->synced_image_count < TAGTINKER_MAX_SYNCED_IMAGES &&
storage_dir_read(dir, &info, name, (uint16_t)sizeof(name))) {
if(file_info_is_dir(&info)) continue;
TagTinkerSyncedImage entry;
if(!tagtinker_parse_dropped_filename(
name,
target->barcode,
target->profile.width,
target->profile.height,
&entry)) {
continue;
}
app->synced_images[app->synced_image_count++] = entry;
}
storage_dir_close(dir);
}
storage_file_free(dir);
furi_record_close(RECORD_STORAGE);
}
static uint8_t synced_image_menu_map[TAGTINKER_MAX_SYNCED_IMAGES];
static char synced_image_labels[TAGTINKER_MAX_SYNCED_IMAGES][48];
/* Wide enough for "~ P9 " plus a 32-char job_id - the submenu module
* auto-marquees the selected row when it overflows the screen, so the full
* filename stays visible by selecting the entry. */
static char synced_image_labels[TAGTINKER_MAX_SYNCED_IMAGES][64];
static void synced_image_list_cb(void* ctx, uint32_t index) {
TagTinkerApp* app = ctx;
@@ -90,28 +198,32 @@ void tagtinker_scene_synced_image_list_on_enter(void* ctx) {
TagTinkerApp* app = ctx;
synced_images_load(app);
dropped_images_load(app);
submenu_reset(app->submenu);
submenu_set_header(app->submenu, "Uploads");
submenu_set_header(app->submenu, "Set Image");
if(app->synced_image_count == 0) {
submenu_add_item(app->submenu, "No synced images", 0, synced_image_list_cb, app);
submenu_add_item(app->submenu, "No matching BMPs", 0, synced_image_list_cb, app);
submenu_add_item(app->submenu, "Drop into apps_data/", 0, synced_image_list_cb, app);
submenu_add_item(app->submenu, " tagtinker/dropped/", 0, synced_image_list_cb, app);
submenu_add_item(app->submenu, "Use Image Prep page", 0, synced_image_list_cb, app);
} else {
uint8_t menu_idx = 0;
for(int16_t i = (int16_t)app->synced_image_count - 1; i >= 0; i--) {
const TagTinkerSyncedImage* image = &app->synced_images[i];
const char* suffix = image->job_id;
size_t suffix_len = strlen(image->job_id);
if(suffix_len > 6U) suffix += suffix_len - 6U;
/* "~" prefix subtly marks BMPs that aren't native to this tag's
* resolution (the FAP rescales them at TX time). The full job_id
* is appended so the submenu's auto-marquee can scroll long
* filenames sideways when the row is selected. */
snprintf(
synced_image_labels[menu_idx],
sizeof(synced_image_labels[menu_idx]),
"P%u %ux%u #%s",
"%sP%u %s",
image->resampled ? "~ " : "",
image->page,
image->width,
image->height,
suffix);
image->job_id);
synced_image_menu_map[menu_idx] = (uint8_t)i;
submenu_add_item(
app->submenu,
+5
View File
@@ -70,6 +70,7 @@ void tagtinker_scene_target_actions_on_enter(void* ctx) {
if(allow_graphics) {
submenu_add_item(app->submenu, "Set Text", TagTinkerTargetPushText, target_actions_cb, app);
submenu_add_item(app->submenu, "Set Image", TagTinkerTargetPushSyncedImage, target_actions_cb, app);
}
submenu_add_item(app->submenu, "LED Test", TagTinkerTargetPingFlash, target_actions_cb, app);
@@ -95,6 +96,10 @@ bool tagtinker_scene_target_actions_on_event(void* ctx, SceneManagerEvent event)
if(!tagtinker_target_supports_graphics(&app->targets[app->selected_target])) return true;
scene_manager_next_scene(app->scene_manager, TagTinkerScenePresetList);
return true;
case TagTinkerTargetPushSyncedImage:
if(!tagtinker_target_supports_graphics(&app->targets[app->selected_target])) return true;
scene_manager_next_scene(app->scene_manager, TagTinkerSceneSyncedImageList);
return true;
case TagTinkerTargetPingFlash:
{
TagTinkerTarget* target = &app->targets[app->selected_target];
+88 -133
View File
@@ -415,17 +415,45 @@ static inline uint8_t bmp_read_pixel(const uint8_t* row_buf, uint16_t x) {
return bit ? 0U : 1U;
}
static inline bool bmp_read_row(
/* Map an output row index to a source row using nearest-neighbour rescaling,
* then read that source row (handling top-down vs bottom-up BMPs and the
* stacked-plane layout used by 2bpp accent BMPs). The transmitter calls this
* once per output row, with a small cache so we don't re-seek the file when
* an upscale maps several output rows to the same source row. */
static inline uint16_t bmp_map_y(uint16_t out_y, uint16_t tx_h, uint16_t src_h) {
if(tx_h == 0U || src_h == 0U) return 0U;
uint32_t y = (uint32_t)out_y * (uint32_t)src_h / (uint32_t)tx_h;
if(y >= src_h) y = src_h - 1U;
return (uint16_t)y;
}
static inline uint16_t bmp_map_x(uint16_t out_x, uint16_t tx_w, uint16_t src_w) {
if(tx_w == 0U || src_w == 0U) return 0U;
uint32_t x = (uint32_t)out_x * (uint32_t)src_w / (uint32_t)tx_w;
if(x >= src_w) x = src_w - 1U;
return (uint16_t)x;
}
static inline bool bmp_read_row_at(
File* file, const TxBmpInfo* info,
uint16_t y, uint16_t tx_height, uint16_t plane_offset,
uint16_t src_y, uint16_t plane_offset_rows,
uint8_t* row_buf) {
uint16_t src_row = info->top_down ? y : (uint16_t)(tx_height - 1U - y);
uint16_t actual_row =
info->top_down ? src_y : (uint16_t)(info->height - 1U - src_y);
uint32_t off = info->data_offset +
((uint32_t)src_row + (uint32_t)plane_offset) * info->row_stride;
((uint32_t)actual_row + (uint32_t)plane_offset_rows) * info->row_stride;
storage_file_seek(file, off, true);
return storage_file_read(file, row_buf, info->row_stride) == info->row_stride;
}
#define BMP_FETCH_ROW(out_y, plane_off) do { \
uint16_t _src_y = bmp_map_y((out_y), tx_height, info.height); \
if(_src_y != cached_src_y) { \
if(!bmp_read_row_at(file, &info, _src_y, (plane_off), row_buf)) { ok = false; break; } \
cached_src_y = _src_y; \
} \
} while(0)
static bool tx_stream_bmp_image(TagTinkerApp* app) {
const TagTinkerImageTxJob* job = &app->image_tx_job;
tx_debug_log("BMP TX: path=%s w=%u h=%u page=%u",
@@ -445,17 +473,25 @@ static bool tx_stream_bmp_image(TagTinkerApp* app) {
return false;
}
uint16_t tx_width = info.width;
uint16_t tx_height = info.height;
/* Output dims come from the target's profile; source dims come from the
* BMP file. The streaming pipeline below rescales source -> target with
* nearest-neighbour as it reads, so any BMP can drive any tag. */
uint16_t tx_width = (job->width > 0U) ? job->width : info.width;
uint16_t tx_height = (job->height > 0U) ? job->height : info.height;
TagTinkerTagColor accent_color = tx_target_color(app);
bool accent_capable =
accent_color == TagTinkerTagColorRed || accent_color == TagTinkerTagColorYellow;
bool use_second_plane = app->color_clear || accent_capable;
bool has_secondary_in_bmp = (info.bpp == 2);
/* Plane offset (in source rows) of the secondary plane in 2bpp BMPs. */
uint16_t plane2_off_rows = info.height;
UNUSED(accent_color);
uint8_t row_buf[64];
/* Source row stride is bounded by max profile width (800 px) -> 104 B,
* round up generously to 128 to absorb any future profile additions. */
uint8_t row_buf[128];
uint16_t cached_src_y = UINT16_MAX;
/* ---- PASS 1: Count RLE compressed bit length ---- */
size_t rle_bits = 0;
@@ -463,10 +499,11 @@ static bool tx_stream_bmp_image(TagTinkerApp* app) {
uint32_t run_count = 0;
bool first = true;
cached_src_y = UINT16_MAX;
for(uint16_t y = 0; ok && y < tx_height; y++) {
if(!bmp_read_row(file, &info, y, tx_height, 0, row_buf)) { ok = false; break; }
BMP_FETCH_ROW(y, 0U);
for(uint16_t x = 0; x < tx_width; x++) {
uint8_t pix = bmp_read_pixel(row_buf, x);
uint8_t pix = bmp_read_pixel(row_buf, bmp_map_x(x, tx_width, info.width));
if(first) { rle_bits = 1; run_pixel = pix; run_count = 1; first = false; }
else if(pix == run_pixel) { run_count++; }
else { rle_bits += rle_run_bits(run_count); run_pixel = pix; run_count = 1; }
@@ -474,10 +511,11 @@ static bool tx_stream_bmp_image(TagTinkerApp* app) {
}
if(ok && use_second_plane) {
if(has_secondary_in_bmp) {
cached_src_y = UINT16_MAX;
for(uint16_t y = 0; ok && y < tx_height; y++) {
if(!bmp_read_row(file, &info, y, tx_height, tx_height, row_buf)) { ok = false; break; }
BMP_FETCH_ROW(y, plane2_off_rows);
for(uint16_t x = 0; x < tx_width; x++) {
uint8_t pix = bmp_read_pixel(row_buf, x);
uint8_t pix = bmp_read_pixel(row_buf, bmp_map_x(x, tx_width, info.width));
if(first) { rle_bits = 1; run_pixel = pix; run_count = 1; first = false; }
else if(pix == run_pixel) { run_count++; }
else { rle_bits += rle_run_bits(run_count); run_pixel = pix; run_count = 1; }
@@ -541,10 +579,11 @@ static bool tx_stream_bmp_image(TagTinkerApp* app) {
run_pixel = 0; run_count = 0; first = true;
cached_src_y = UINT16_MAX;
for(uint16_t y = 0; ok && y < tx_height; y++) {
if(!bmp_read_row(file, &info, y, tx_height, 0, row_buf)) { ok = false; break; }
BMP_FETCH_ROW(y, 0U);
for(uint16_t x = 0; x < tx_width; x++) {
uint8_t pix = bmp_read_pixel(row_buf, x);
uint8_t pix = bmp_read_pixel(row_buf, bmp_map_x(x, tx_width, info.width));
if(first) { ENC_BIT(pix); run_pixel = pix; run_count = 1; first = false; }
else if(pix == run_pixel) { run_count++; }
else { ENC_RUN(run_count); run_pixel = pix; run_count = 1; }
@@ -552,10 +591,11 @@ static bool tx_stream_bmp_image(TagTinkerApp* app) {
}
if(ok && use_second_plane) {
if(has_secondary_in_bmp) {
cached_src_y = UINT16_MAX;
for(uint16_t y = 0; ok && y < tx_height; y++) {
if(!bmp_read_row(file, &info, y, tx_height, tx_height, row_buf)) { ok = false; break; }
BMP_FETCH_ROW(y, plane2_off_rows);
for(uint16_t x = 0; x < tx_width; x++) {
uint8_t pix = bmp_read_pixel(row_buf, x);
uint8_t pix = bmp_read_pixel(row_buf, bmp_map_x(x, tx_width, info.width));
if(first) { ENC_BIT(pix); run_pixel = pix; run_count = 1; first = false; }
else if(pix == run_pixel) { run_count++; }
else { ENC_RUN(run_count); run_pixel = pix; run_count = 1; }
@@ -575,20 +615,22 @@ static bool tx_stream_bmp_image(TagTinkerApp* app) {
} else {
/* ---- PASS 2: RAW encode into heap buffer ---- */
size_t bit_idx = 0;
cached_src_y = UINT16_MAX;
for(uint16_t y = 0; ok && y < tx_height; y++) {
if(!bmp_read_row(file, &info, y, tx_height, 0, row_buf)) { ok = false; break; }
BMP_FETCH_ROW(y, 0U);
for(uint16_t x = 0; x < tx_width; x++) {
uint8_t pix = bmp_read_pixel(row_buf, x);
uint8_t pix = bmp_read_pixel(row_buf, bmp_map_x(x, tx_width, info.width));
if(pix != 0) encoded[bit_idx / 8U] |= (1U << (7U - (bit_idx % 8U)));
bit_idx++;
}
}
if(ok && use_second_plane) {
if(has_secondary_in_bmp) {
cached_src_y = UINT16_MAX;
for(uint16_t y = 0; ok && y < tx_height; y++) {
if(!bmp_read_row(file, &info, y, tx_height, tx_height, row_buf)) { ok = false; break; }
BMP_FETCH_ROW(y, plane2_off_rows);
for(uint16_t x = 0; x < tx_width; x++) {
uint8_t pix = bmp_read_pixel(row_buf, x);
uint8_t pix = bmp_read_pixel(row_buf, bmp_map_x(x, tx_width, info.width));
if(pix != 0) encoded[bit_idx / 8U] |= (1U << (7U - (bit_idx % 8U)));
bit_idx++;
}
@@ -672,131 +714,44 @@ static void transmit_draw_cb(Canvas* canvas, void* _model) {
TagTinkerApp* app = model->app;
uint32_t t = model->tick;
/* Left: Top-Down Detailed Flipper Zero Vector Model */
int f_x = -15; // Protruding from left screen edge
int f_y = 10;
int f_w = 40;
int f_h = 36;
/* Main casing */
canvas_draw_rframe(canvas, f_x, f_y, f_w, f_h, 3);
/* Outer screen outline */
canvas_draw_rframe(canvas, f_x + 10, f_y + 4, 20, 16, 2);
/* Screen inner solid */
canvas_draw_box(canvas, f_x + 12, f_y + 6, 16, 12);
/* Flipper Screen highlight glow */
canvas_set_color(canvas, ColorWhite);
canvas_draw_dot(canvas, f_x + 14, f_y + 8);
canvas_draw_dot(canvas, f_x + 15, f_y + 8);
canvas_set_color(canvas, ColorBlack);
/* D-Pad Matrix */
canvas_draw_circle(canvas, f_x + 18, f_y + 27, 5); // Outer
canvas_draw_circle(canvas, f_x + 18, f_y + 27, 2); // Inner button
/* GPIO / Case Vents */
for(int i=0; i<4; i++) {
canvas_draw_dot(canvas, f_x + 32, f_y + 8 + i*3);
canvas_draw_dot(canvas, f_x + 34, f_y + 8 + i*3);
}
/* IR Blaster window on the right tip */
int ir_x = f_x + f_w; // 25
int ir_y = f_y + 10;
canvas_draw_box(canvas, ir_x, ir_y, 4, 16);
/* Static title - large bold "Tinkering Tag" with the instruction below.
* Kept text-only (no busy animation) because the IR blaster runs at the
* highest thread priority and starves the GUI; trying to animate during
* transmission either looks janky or steals CPU from the IR timing. */
canvas_set_font(canvas, FontPrimary);
canvas_draw_str_aligned(canvas, 64, 12, AlignCenter, AlignTop, "Tinkering Tag");
int center_y = f_y + 18; /* perfectly aligns with tag center */
canvas_set_font(canvas, FontSecondary);
if(app->tx_active) {
canvas_draw_str_aligned(
canvas, 64, 28, AlignCenter, AlignTop, "Point the flipper at the tag");
if (app->tx_active) {
/* Aggressive Pulsing Data-Stream Chevrons with bits */
uint8_t wave_phase = (t * 2) % 15;
for(int w=0; w<2; w++) {
int r = wave_phase + w*18;
if(r > 0 && r < 20) {
int wave_x = ir_x + 4 + r;
int wave_h = r/2 + 3;
/* 2px thick chevron pointing right */
canvas_draw_line(canvas, wave_x, center_y, wave_x - wave_h/2, center_y - wave_h);
canvas_draw_line(canvas, wave_x, center_y, wave_x - wave_h/2, center_y + wave_h);
canvas_draw_line(canvas, wave_x+1, center_y, wave_x+1 - wave_h/2, center_y - wave_h);
canvas_draw_line(canvas, wave_x+1, center_y, wave_x+1 - wave_h/2, center_y + wave_h);
/* Data stream particles */
if((t + w) % 2 == 0) {
canvas_draw_dot(canvas, wave_x + 3, center_y - wave_h/2);
canvas_draw_dot(canvas, wave_x - 3, center_y + wave_h/2 + 2);
}
}
}
}
/* Right: Detailed ESL tag model */
int tag_x = 52;
int tag_y = 8;
int tag_w = 75;
int tag_h = 40;
/* Outer casing bounding rect */
canvas_draw_rframe(canvas, tag_x, tag_y, tag_w, tag_h, 2);
/* Internal 3D bezel shadow */
canvas_draw_rframe(canvas, tag_x+1, tag_y+1, tag_w-2, tag_h-2, 1);
/* True 32-bit encoded barcode representation on the rim */
int bc_x = tag_x + 3;
int bc_w = 8;
int bc_y = tag_y + 4;
uint8_t bar_pattern[] = {0b10110100, 0b11010010, 0b10011011, 0b01101010};
for(int i=0; i<32; i++) {
if( (bar_pattern[i/8] >> (7-(i%8))) & 1 ) {
canvas_draw_line(canvas, bc_x, bc_y + i, bc_x + bc_w - 1, bc_y + i);
}
}
/* Screen display hardware border */
int scr_x = tag_x + 14;
int scr_y = tag_y + 3;
int scr_w = tag_w - 17;
int scr_h = tag_h - 6;
canvas_draw_frame(canvas, scr_x - 1, scr_y - 1, scr_w + 2, scr_h + 2);
/* E-Paper Sweep Logic */
int cycle = t % 40; /* 2 second loop at 20fps */
if (app->tx_active) {
bool show_result = (cycle >= 20);
if(!show_result) {
canvas_set_font(canvas, FontPrimary);
canvas_draw_str_aligned(
canvas, scr_x + scr_w / 2, scr_y + scr_h / 2, AlignCenter, AlignCenter, "UPDATING");
} else {
canvas_set_font(canvas, FontSecondary);
canvas_draw_str_aligned(
canvas, scr_x + scr_w / 2, scr_y + scr_h / 2, AlignCenter, AlignCenter, "FLIPPED ;)");
}
/* Glitchy Matrix Wipe Transition */
if(cycle >= 15 && cycle < 20) {
int wipe_h = ((cycle - 15) * scr_h) / 5;
canvas_draw_box(canvas, scr_x, scr_y, scr_w, wipe_h);
/* Algorithmic leading edge burn/glitch */
for(int xx = 0; xx < scr_w; xx += 2) {
canvas_draw_dot(canvas, scr_x + xx, scr_y + wipe_h + ((xx+t)%4));
}
} else if (cycle >= 20 && cycle < 25) {
int wipe_h = ((cycle - 20) * scr_h) / 5;
canvas_draw_box(canvas, scr_x, scr_y + wipe_h, scr_w, scr_h - wipe_h);
/* Algorithmic trailing edge burn/glitch */
for(int xx = 0; xx < scr_w; xx += 2) {
canvas_draw_dot(canvas, scr_x + xx, scr_y + wipe_h - 1 - ((xx+t)%4));
/* Tiny three-dot loading indicator: rotates one bright dot through
* the dot triplet so even when the tick is delayed by IR bursts the
* change is obvious and cheap to draw. */
const int dot_y = 44;
const int dot_cx = 64;
const int dot_spacing = 6;
const uint8_t phase = (t / 4U) % 3U;
for(int i = 0; i < 3; i++) {
int x = dot_cx + (i - 1) * dot_spacing;
if((uint8_t)i == phase) {
canvas_draw_disc(canvas, x, dot_y, 2);
} else {
canvas_draw_circle(canvas, x, dot_y, 1);
}
}
} else {
canvas_set_font(canvas, FontSecondary);
canvas_draw_str_aligned(
canvas, scr_x + scr_w / 2, scr_y + scr_h / 2, AlignCenter, AlignCenter, "FLIPPED ;)");
canvas_draw_str_aligned(canvas, 64, 30, AlignCenter, AlignTop, "Flipped ;)");
}
/* Submenu action hint decoupled from canvas models */
/* Bottom action hint. */
canvas_set_font(canvas, FontSecondary);
if(app->tx_spam) {
canvas_draw_str_aligned(canvas, 64, 55, AlignCenter, AlignTop, "[<-] Stop Repeat");
} else {
canvas_draw_str_aligned(canvas, 64, 55, AlignCenter, AlignTop, app->tx_active ? "[<-] Cancel" : "[<-] Back");
canvas_draw_str_aligned(
canvas, 64, 55, AlignCenter, AlignTop, app->tx_active ? "[<-] Cancel" : "[<-] Back");
}
}
+10
View File
@@ -830,6 +830,16 @@ static TagTinkerApp* app_alloc(void) {
app->text_padding_pct = 0;
strcpy(app->text_input_buf, "TagTinker");
app->selected_target = -1;
/* Ensure the dropped-image folder exists so the user can pre-stage BMPs
* prepared with the web image preparer (web-image-prep/index.html). */
{
Storage* storage = furi_record_open(RECORD_STORAGE);
storage_common_mkdir(storage, APP_DATA_PATH(""));
storage_common_mkdir(storage, APP_DATA_PATH("dropped"));
furi_record_close(RECORD_STORAGE);
}
tagtinker_settings_load(app);
tagtinker_targets_load(app);
tagtinker_recents_load(app);
+3
View File
@@ -72,6 +72,9 @@ typedef struct {
uint16_t width;
uint16_t height;
uint8_t page;
/* True when this BMP was authored for a different resolution than the
* current target, meaning the transmitter will rescale it on the fly. */
bool resampled;
char image_path[TAGTINKER_IMAGE_PATH_LEN + 1];
} TagTinkerSyncedImage;
+860
View File
@@ -0,0 +1,860 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="theme-color" content="#08080b">
<title>TagTinker · Image Prep</title>
<style>
:root {
--bg: #08080b;
--bg-1: #0d0d12;
--bg-2: #131319;
--line: #1f1f29;
--line-2: #2a2a36;
--text: #ededf2;
--text-dim: #8a8a98;
--text-soft: #b8b8c4;
--pp: #a78bfa;
--pp-hi: #c4b5fd;
--pp-deep: #6d4dd4;
--good: #5fd28d;
--bad: #ff6478;
--r: 14px;
--mono: ui-monospace, "JetBrains Mono", "SFMono-Regular", Menlo, monospace;
--sans: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--text); font-family: var(--sans); -webkit-font-smoothing: antialiased; }
body {
background:
radial-gradient(1100px 600px at 80% -200px, rgba(167,139,250,0.16), transparent 60%),
radial-gradient(900px 500px at -10% 10%, rgba(109,77,212,0.12), transparent 60%),
var(--bg);
min-height: 100dvh;
}
a { color: var(--pp-hi); }
.wrap { max-width: 1180px; margin: 0 auto; padding: 28px 22px 80px; }
/* ─── HERO ─────────────────────────────────────────────────────────── */
header.hero { padding: 32px 0 36px; }
.pill {
display: inline-flex; align-items: center; gap: 8px;
padding: 4px 10px; border-radius: 999px; font-size: 12px;
background: rgba(167,139,250,0.08); color: var(--pp-hi);
border: 1px solid rgba(167,139,250,0.18); font-family: var(--mono);
}
.pill .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--pp); box-shadow: 0 0 10px var(--pp); }
h1 {
font-size: clamp(44px, 9vw, 96px);
font-weight: 800; letter-spacing: -0.04em; line-height: 0.95;
margin: 14px 0 6px;
background: linear-gradient(180deg, #fff 0%, #b8b8c4 65%, #6f6f80 100%);
-webkit-background-clip: text; background-clip: text; color: transparent;
}
h1 .v {
background: linear-gradient(180deg, var(--pp-hi), var(--pp-deep));
-webkit-background-clip: text; background-clip: text; color: transparent;
font-weight: 800; letter-spacing: -0.03em;
}
.sub { color: var(--text-soft); font-size: clamp(15px, 2vw, 17px); max-width: 640px; line-height: 1.55; }
/* ─── CARD ─────────────────────────────────────────────────────────── */
.card {
background: linear-gradient(180deg, rgba(255,255,255,0.02), transparent 60%), var(--bg-1);
border: 1px solid var(--line);
border-radius: var(--r);
padding: 18px;
margin-top: 18px;
}
.card h2 {
margin: 0 0 14px; font-size: 13px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.14em; color: var(--text-dim);
}
/* ─── LAYOUT ───────────────────────────────────────────────────────── */
.grid { display: grid; grid-template-columns: 1fr; gap: 18px; }
@media (min-width: 900px) {
.grid { grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr); gap: 22px; }
/* Sticky scroll on desktop: column stretches to right-side height,
* the card aligns to the top of its column and sticks while scrolling. */
.grid > .col-preview { align-self: start; position: sticky; top: 18px; }
}
/* ─── PREVIEW ──────────────────────────────────────────────────────── */
.preview-card { padding: 16px; }
.preview-shell {
position: relative;
border-radius: 10px;
background:
linear-gradient(45deg, #15151c 25%, transparent 25%),
linear-gradient(-45deg, #15151c 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #15151c 75%),
linear-gradient(-45deg, transparent 75%, #15151c 75%);
background-size: 14px 14px;
background-position: 0 0, 0 7px, 7px -7px, -7px 0;
background-color: #0a0a10;
padding: 18px;
display: flex; align-items: center; justify-content: center;
min-height: 280px;
overflow: auto;
border: 1px solid var(--line);
}
canvas#out {
display: block;
image-rendering: pixelated;
image-rendering: crisp-edges;
background: white;
border-radius: 4px;
box-shadow: 0 12px 40px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.03);
max-width: 100%;
}
.preview-meta {
display: flex; align-items: center; justify-content: space-between;
font-family: var(--mono); font-size: 11px; color: var(--text-dim);
margin-top: 12px;
}
.badges { display: flex; gap: 6px; flex-wrap: wrap; }
.badge {
padding: 4px 9px; border-radius: 999px; font-size: 11px;
background: var(--bg-2); color: var(--text-soft); border: 1px solid var(--line); font-family: var(--mono);
}
.badge.purple { background: rgba(167,139,250,0.10); color: var(--pp-hi); border-color: rgba(167,139,250,0.22); }
.badge.red { background: rgba(255,100,120,0.10); color: #ff95a4; border-color: rgba(255,100,120,0.25); }
.badge.yellow { background: rgba(255,200,80,0.10); color: #ffd87a; border-color: rgba(255,200,80,0.25); }
/* ─── DROPZONE ─────────────────────────────────────────────────────── */
.drop {
border: 1.5px dashed var(--line-2); border-radius: 10px;
padding: 22px; text-align: center; color: var(--text-dim);
cursor: pointer; transition: 140ms; font-size: 14px;
}
.drop b { color: var(--text); }
.drop:hover { border-color: var(--pp); color: var(--text); }
.drop.hot { border-color: var(--pp); color: var(--text); background: rgba(167,139,250,0.06); }
/* ─── FORM CONTROLS ────────────────────────────────────────────────── */
.row { display: grid; grid-template-columns: 110px 1fr 44px; align-items: center; gap: 10px; margin: 8px 0; }
.row label { font-size: 13px; color: var(--text-soft); }
.row .val { font-family: var(--mono); font-size: 11px; color: var(--text-dim); text-align: right; }
input[type=range] {
-webkit-appearance: none; appearance: none;
width: 100%; height: 22px; background: transparent; cursor: pointer;
}
input[type=range]::-webkit-slider-runnable-track { height: 3px; background: var(--line-2); border-radius: 999px; }
input[type=range]::-moz-range-track { height: 3px; background: var(--line-2); border-radius: 999px; }
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: 14px; height: 14px; border-radius: 50%;
background: var(--pp); border: none; margin-top: -5.5px;
box-shadow: 0 0 0 4px rgba(167,139,250,0.12);
}
input[type=range]::-moz-range-thumb {
width: 14px; height: 14px; border-radius: 50%; background: var(--pp); border: none;
}
select, input[type=text], input[type=search], input[type=number] {
background: var(--bg);
color: var(--text);
border: 1px solid var(--line);
border-radius: 10px;
padding: 10px 12px;
font: inherit; font-size: 14px;
width: 100%;
outline: none;
transition: 120ms;
}
select:focus, input:focus { border-color: var(--pp); box-shadow: 0 0 0 4px rgba(167,139,250,0.12); }
.seg {
display: inline-flex; gap: 0;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 10px; padding: 3px;
}
.seg button {
border: none; background: transparent; color: var(--text-dim);
padding: 7px 12px; border-radius: 7px; cursor: pointer; font-size: 13px;
}
.seg button.on { background: var(--bg-2); color: var(--text); box-shadow: inset 0 0 0 1px var(--line-2); }
button.primary {
background: linear-gradient(180deg, var(--pp), var(--pp-deep));
color: white; font-weight: 600; font-size: 14px;
border: none; border-radius: 10px;
padding: 12px 18px; cursor: pointer;
box-shadow: 0 8px 24px rgba(109,77,212,0.35);
transition: 120ms;
width: 100%;
}
button.primary:hover { filter: brightness(1.08); transform: translateY(-1px); }
button.primary:disabled { opacity: 0.55; cursor: not-allowed; transform: none; box-shadow: none; }
button.ghost {
background: transparent; color: var(--text-soft);
border: 1px solid var(--line); border-radius: 10px;
padding: 9px 14px; cursor: pointer; font-size: 13px;
}
button.ghost:hover { color: var(--text); border-color: var(--line-2); }
.actions { display: grid; grid-template-columns: 1fr auto; gap: 8px; align-items: center; }
.filename {
font-family: var(--mono); font-size: 11px; color: var(--text-dim);
margin-top: 8px; word-break: break-all;
}
.stat { display: flex; justify-content: space-between; font-family: var(--mono); font-size: 12px; color: var(--text-dim); padding: 3px 0; }
details {
border-top: 1px solid var(--line);
padding-top: 10px; margin-top: 10px;
}
details summary {
cursor: pointer; list-style: none; user-select: none;
color: var(--text-dim); font-size: 12px; text-transform: uppercase; letter-spacing: 0.12em;
display: flex; justify-content: space-between; align-items: center;
}
details summary::-webkit-details-marker { display: none; }
details summary::after { content: "+"; color: var(--pp); font-family: var(--mono); }
details[open] summary::after { content: ""; }
/* ─── HELP ─────────────────────────────────────────────────────────── */
.help { margin-top: 18px; padding: 18px; }
.help h3 { margin: 0 0 8px; font-size: 13px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--pp-hi); }
.help ol { margin: 0 0 0 18px; padding: 0; line-height: 1.7; color: var(--text-soft); font-size: 14px; }
.help code { font-family: var(--mono); background: rgba(167,139,250,0.10); border: 1px solid rgba(167,139,250,0.18); padding: 1px 6px; border-radius: 5px; color: var(--pp-hi); font-size: 12px; }
footer { margin-top: 28px; color: var(--text-dim); font-size: 12px; text-align: center; }
</style>
</head>
<body>
<div class="wrap">
<header class="hero">
<span class="pill"><span class="dot"></span> Image Prep</span>
<h1>TagTinker <span class="v">V2.1</span></h1>
<p class="sub">Drop an image, pick the tag, ship the BMP. Photo-grade tone &amp; dither, every supported ESL profile, no install.</p>
</header>
<div class="grid">
<!-- LEFT · live preview (sticky on desktop, top on mobile) -->
<div class="col-preview">
<div class="card preview-card">
<div class="preview-shell">
<canvas id="out" width="296" height="128"></canvas>
</div>
<div class="preview-meta">
<div class="badges">
<span class="badge purple" id="b-mode">MONO</span>
<span class="badge" id="b-size">×</span>
<span class="badge" id="b-time">— ms</span>
</div>
<div id="b-zoom">×4</div>
</div>
</div>
</div>
<!-- RIGHT · controls -->
<div>
<div class="card">
<h2>1 · Source</h2>
<div class="drop" id="drop">
<div><b>Drop, paste, or pick</b> an image</div>
<div style="font-size:12px;margin-top:4px;color:var(--text-dim)">JPG · PNG · WebP · GIF</div>
</div>
<input id="file" type="file" accept="image/*" hidden>
</div>
<div class="card">
<h2>2 · Tag profile</h2>
<input id="filter" type="search" placeholder="Search profile by name, code, or color…" style="margin-bottom:10px">
<select id="profile" size="6"></select>
<div style="margin-top:10px">
<div class="stat"><span>Resolution</span><span id="pf-size"></span></div>
<div class="stat"><span>Color mode</span><span id="pf-color"></span></div>
<div class="stat"><span>Type code</span><span id="pf-type"></span></div>
</div>
</div>
<div class="card">
<h2>3 · Tune</h2>
<div class="row">
<label>Frame</label>
<div class="seg" id="fit">
<button data-v="fit" class="on">Fit</button>
<button data-v="fill">Fill</button>
<button data-v="stretch">Stretch</button>
</div>
<span></span>
</div>
<div class="row"><label>Brightness</label><input type="range" min="-50" max="50" value="0" id="r-br"><span class="val" id="v-br">0</span></div>
<div class="row"><label>Contrast</label> <input type="range" min="-50" max="50" value="10" id="r-co"><span class="val" id="v-co">10</span></div>
<div class="row"><label>Detail</label> <input type="range" min="0" max="100" value="85" id="r-dt"><span class="val" id="v-dt">85</span></div>
<div class="row"><label>Sharpen</label> <input type="range" min="0" max="100" value="50" id="r-sp"><span class="val" id="v-sp">50</span></div>
<div class="row" id="r-sat-row"><label>Saturation</label><input type="range" min="-50" max="100" value="20" id="r-sa"><span class="val" id="v-sa">20</span></div>
<details>
<summary>More</summary>
<div class="row"><label>Scale</label> <input type="range" min="50" max="250" value="100" id="r-sc"><span class="val" id="v-sc">100</span></div>
<div class="row"><label>Offset X</label> <input type="range" min="-100" max="100" value="0" id="r-ox"><span class="val" id="v-ox">0</span></div>
<div class="row"><label>Offset Y</label> <input type="range" min="-100" max="100" value="0" id="r-oy"><span class="val" id="v-oy">0</span></div>
<div class="row"><label>Rotate</label> <input type="range" min="-180" max="180" value="0" id="r-rt"><span class="val" id="v-rt"></span></div>
<div class="row"><label>Dither</label>
<select id="dither" style="grid-column: span 2">
<option value="fs" selected>FloydSteinberg (recommended)</option>
<option value="atk">Atkinson (clean)</option>
<option value="bayer8">Bayer 8×8 (orderly)</option>
<option value="thr">Threshold (hard)</option>
</select>
</div>
<div style="display:flex;gap:14px;margin-top:8px;font-size:13px;color:var(--text-soft)">
<label><input type="checkbox" id="c-invert"> Invert</label>
<label><input type="checkbox" id="c-flipH"> Flip H</label>
<label><input type="checkbox" id="c-flipV"> Flip V</label>
</div>
</details>
</div>
<div class="card">
<h2>4 · Export</h2>
<div class="row">
<label>Label <span style="color:var(--text-dim);font-size:11px">(optional)</span></label>
<input id="label" type="text" maxlength="20" placeholder="cat-photo" style="grid-column: span 2">
</div>
<p style="font-size:12px;color:var(--text-dim);margin:4px 0 6px;line-height:1.5">
Page, position and IR repeats are picked on the Flipper right before sending — one BMP, any page, any tag.
</p>
<div class="actions" style="margin-top:14px">
<button id="dl" class="primary" disabled>Download .bmp</button>
<button id="reset" class="ghost">Reset</button>
</div>
<div class="filename" id="filename"></div>
</div>
<div class="card help">
<h3>Drop · Pick · Send</h3>
<ol>
<li>Pick a profile, tune, hit <b>Download .bmp</b>.</li>
<li>Copy the file to <code>SD/apps_data/tagtinker/dropped/</code> on your Flipper.</li>
<li>Open <b>TagTinker → Targeted Payloads → your tag → Set Image</b>.</li>
<li>The Flipper lists every BMP whose resolution matches that tag — pick a <b>page</b>, then send.</li>
</ol>
<p style="margin:10px 0 0;font-size:12px;color:var(--text-dim)">Filenames are resolution-shaped only, so one BMP can be sent to any matching tag, on any page.</p>
</div>
</div>
</div>
<footer>TagTinker · V2.1 · GPL-3.0</footer>
</div>
<script>
/* ============================================================================
* Self-contained image preparer.
* Output BMP layout matches the FAP reader (1bpp mono / "2bpp" stacked planes
* for accent tags). Filename: <W>x<H>_p<page>[_<label>].bmp
* ========================================================================== */
const PROFILES = [
{ code:1275, w:320, h:192, color:"mono", name:"DM110" },
{ code:1276, w:320, h:140, color:"mono", name:"DM90" },
{ code:1300, w:172, h:72, color:"mono", name:"DM3370" },
{ code:1314, w:400, h:300, color:"mono", name:"SmartTag HD110" },
{ code:1315, w:296, h:128, color:"mono", name:"SmartTag HD L" },
{ code:1317, w:152, h:152, color:"mono", name:"SmartTag HD S" },
{ code:1318, w:208, h:112, color:"mono", name:"SmartTag HD M" },
{ code:1319, w:800, h:480, color:"mono", name:"SmartTag HD200" },
{ code:1322, w:152, h:152, color:"mono", name:"SmartTag HD S" },
{ code:1324, w:208, h:112, color:"mono", name:"SmartTag HD M FZ" },
{ code:1327, w:208, h:112, color:"red", name:"SmartTag HD M Red" },
{ code:1328, w:296, h:128, color:"red", name:"SmartTag HD L Red" },
{ code:1336, w:400, h:300, color:"red", name:"SmartTag HD110 Red" },
{ code:1339, w:152, h:152, color:"red", name:"SmartTag HD S Red" },
{ code:1340, w:800, h:480, color:"red", name:"SmartTag HD200 Red" },
{ code:1344, w:296, h:128, color:"yellow", name:"SmartTag HD L Yellow" },
{ code:1346, w:800, h:480, color:"yellow", name:"SmartTag HD200 Yellow" },
{ code:1348, w:264, h:176, color:"red", name:"SmartTag HD T Red" },
{ code:1349, w:264, h:176, color:"yellow", name:"SmartTag HD T Yellow" },
{ code:1351, w:648, h:480, color:"mono", name:"SmartTag HD150" },
{ code:1353, w:648, h:480, color:"red", name:"SmartTag HD150 Red" },
{ code:1354, w:648, h:480, color:"red", name:"SmartTag HD150 Red" },
{ code:1370, w:296, h:128, color:"red", name:"SmartTag HD L Red (2021)" },
{ code:1371, w:648, h:480, color:"red", name:"SmartTag HD150 Red (2021)" },
{ code:1627, w:296, h:128, color:"red", name:"SmartTag HD L Red" },
{ code:1628, w:296, h:128, color:"red", name:"SmartTag HD L Red" },
{ code:1639, w:152, h:152, color:"red", name:"SmartTag HD S Red" },
];
const ACCENT_RGB = { red: [218, 45, 53], yellow: [226, 177, 32] };
const $ = id => document.getElementById(id);
const out = $("out"), ctx = out.getContext("2d", { willReadFrequently: true });
const work = document.createElement("canvas");
const wctx = work.getContext("2d", { willReadFrequently: true });
const S = {
brightness: 0, contrast: 10, detail: 85, sharpen: 50, saturation: 20,
scale: 100, offsetX: 0, offsetY: 0, rotate: 0,
dither: "fs", invert: false, flipH: false, flipV: false,
fit: "fit",
};
let sourceBitmap = null;
let lastQuant = null;
let renderQ = false;
/* ─── Profile picker ──────────────────────────────────────────────── */
const profileSel = $("profile"), filter = $("filter");
function rebuildProfileList() {
const q = filter.value.trim().toLowerCase();
profileSel.innerHTML = "";
PROFILES.forEach((p, i) => {
if (q && !(p.name.toLowerCase().includes(q) || p.color.includes(q) || String(p.code).includes(q))) return;
const opt = document.createElement("option");
opt.value = i;
opt.textContent = `${p.code} · ${p.w}×${p.h} · ${p.color.toUpperCase()} · ${p.name}`;
profileSel.appendChild(opt);
});
if (profileSel.options.length && !profileSel.value) profileSel.selectedIndex = 0;
applyProfile();
}
const currentProfile = () => PROFILES[parseInt(profileSel.value, 10)] ?? PROFILES[0];
function applyProfile() {
const p = currentProfile();
$("pf-size").textContent = `${p.w} × ${p.h}`;
$("pf-color").textContent = p.color.toUpperCase();
$("pf-type").textContent = p.code;
$("b-size").textContent = `${p.w} × ${p.h}`;
const m = $("b-mode");
m.textContent = p.color.toUpperCase();
m.className = "badge " + (p.color === "mono" ? "purple" : p.color);
$("r-sat-row").style.opacity = (p.color === "mono") ? 0.45 : 1;
scheduleRender();
updateFilename();
}
filter.addEventListener("input", rebuildProfileList);
profileSel.addEventListener("change", applyProfile);
/* ─── Bindings ────────────────────────────────────────────────────── */
function bindRange(id, key, fmt = v => v) {
const el = $("r-" + id), out = $("v-" + id);
const pull = () => { S[key] = parseFloat(el.value); out.textContent = fmt(el.value); scheduleRender(); };
el.addEventListener("input", pull); pull();
}
function bindCheck(id, key) {
const el = $("c-" + id);
const pull = () => { S[key] = el.checked; scheduleRender(); };
el.addEventListener("change", pull); pull();
}
bindRange("br", "brightness"); bindRange("co", "contrast");
bindRange("dt", "detail"); bindRange("sp", "sharpen");
bindRange("sa", "saturation");
bindRange("sc", "scale"); bindRange("ox", "offsetX");
bindRange("oy", "offsetY"); bindRange("rt", "rotate", v => v + "°");
bindCheck("invert", "invert"); bindCheck("flipH", "flipH"); bindCheck("flipV", "flipV");
$("dither").addEventListener("change", () => { S.dither = $("dither").value; scheduleRender(); });
$("fit").addEventListener("click", e => {
const b = e.target.closest("button"); if (!b) return;
$("fit").querySelectorAll("button").forEach(x => x.classList.remove("on"));
b.classList.add("on");
S.fit = b.dataset.v; scheduleRender();
});
$("reset").addEventListener("click", () => {
Object.assign(S, {
brightness: 0, contrast: 10, detail: 85, sharpen: 50, saturation: 20,
scale: 100, offsetX: 0, offsetY: 0, rotate: 0,
dither: "fs", invert: false, flipH: false, flipV: false, fit: "fit",
});
document.querySelectorAll("input[type=range]").forEach(r => {
const k = r.id.slice(2);
const map = { br:"brightness", co:"contrast", dt:"detail", sp:"sharpen",
sa:"saturation", sc:"scale", ox:"offsetX", oy:"offsetY", rt:"rotate" };
if (map[k] !== undefined) {
r.value = S[map[k]];
r.dispatchEvent(new Event("input"));
}
});
document.querySelectorAll("input[type=checkbox]").forEach(c => { c.checked = false; });
$("dither").value = "fs";
$("fit").querySelectorAll("button").forEach(b => b.classList.toggle("on", b.dataset.v === "fit"));
scheduleRender();
});
/* ─── File ingestion ──────────────────────────────────────────────── */
const drop = $("drop"), fileInput = $("file");
drop.addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", () => fileInput.files[0] && loadFile(fileInput.files[0]));
["dragenter", "dragover"].forEach(e => drop.addEventListener(e, ev => { ev.preventDefault(); drop.classList.add("hot"); }));
["dragleave", "drop"].forEach(e => drop.addEventListener(e, ev => { ev.preventDefault(); drop.classList.remove("hot"); }));
drop.addEventListener("drop", ev => { const f = ev.dataTransfer?.files?.[0]; if (f) loadFile(f); });
window.addEventListener("paste", ev => {
for (const it of ev.clipboardData?.items || []) {
if (it.type.startsWith("image/")) { loadFile(it.getAsFile()); break; }
}
});
async function loadFile(file) {
const url = URL.createObjectURL(file);
try {
const img = new Image();
img.crossOrigin = "anonymous";
img.src = url;
await img.decode();
sourceBitmap = img;
drop.querySelector("div").innerHTML =
`<b>${file.name}</b><br><span style="color:var(--text-dim);font-size:12px">${img.width}×${img.height}</span>`;
$("dl").disabled = false;
scheduleRender();
} catch {
drop.querySelector("div").innerHTML = `<b style="color:var(--bad)">Could not decode</b>`;
} finally {
URL.revokeObjectURL(url);
}
}
/* ─── Pipeline ────────────────────────────────────────────────────── */
function scheduleRender() {
if (renderQ) return;
renderQ = true;
requestAnimationFrame(() => { renderQ = false; render(); });
}
function render() {
const t0 = performance.now();
const p = currentProfile();
const W = p.w, H = p.h, ACC = p.color;
out.width = W; out.height = H;
const stage = out.parentElement.getBoundingClientRect();
const scale = Math.max(1, Math.min(8, Math.floor(Math.min((stage.width - 36) / W, (stage.height - 36) / H))));
out.style.width = (W * scale) + "px";
out.style.height = (H * scale) + "px";
$("b-zoom").textContent = "×" + scale;
/* Compose source. */
work.width = W; work.height = H;
wctx.fillStyle = "white"; wctx.fillRect(0, 0, W, H);
if (sourceBitmap) {
let dW, dH;
if (S.fit === "stretch") { dW = W; dH = H; }
else {
const f = (S.fit === "fill"
? Math.max(W / sourceBitmap.width, H / sourceBitmap.height)
: Math.min(W / sourceBitmap.width, H / sourceBitmap.height)) * (S.scale / 100);
dW = Math.max(1, sourceBitmap.width * f);
dH = Math.max(1, sourceBitmap.height * f);
}
const cx = W/2 + (S.offsetX/100) * (W/2);
const cy = H/2 + (S.offsetY/100) * (H/2);
wctx.save();
wctx.translate(cx, cy);
wctx.rotate((S.rotate || 0) * Math.PI / 180);
wctx.scale(S.flipH ? -1 : 1, S.flipV ? -1 : 1);
wctx.imageSmoothingEnabled = true;
wctx.imageSmoothingQuality = "high";
wctx.drawImage(sourceBitmap, -dW/2, -dH/2, dW, dH);
wctx.restore();
} else {
wctx.fillStyle = "#9090a0"; wctx.font = "13px sans-serif"; wctx.textAlign = "center";
wctx.fillText("Drop or paste an image", W/2, H/2);
}
let img = wctx.getImageData(0, 0, W, H);
toneAndColor(img, ACC);
if (S.sharpen > 0) img = unsharp(img, 1, S.sharpen / 100);
const quant = quantize(img, ACC);
/* Draw quantized result. */
const vis = ctx.createImageData(W, H);
const accentRGB = ACC === "mono" ? [0,0,0] : ACCENT_RGB[ACC];
for (let i = 0; i < W * H; i++) {
const v = quant.idx[i];
const o = i * 4;
const c = v === 0 ? [255,255,255] : v === 1 ? [0,0,0] : accentRGB;
vis.data[o] = c[0]; vis.data[o+1] = c[1]; vis.data[o+2] = c[2]; vis.data[o+3] = 255;
}
ctx.putImageData(vis, 0, 0);
lastQuant = quant;
$("b-time").textContent = `${(performance.now() - t0).toFixed(1)} ms`;
}
/* ─── Tone & color (linear-light, photo-grade) ────────────────────── */
const srgb2lin = c => { c /= 255; return c <= 0.04045 ? c/12.92 : Math.pow((c + 0.055)/1.055, 2.4); };
const lin2srgb = c => { c = Math.max(0, Math.min(1, c)); return 255 * (c <= 0.0031308 ? c*12.92 : 1.055*Math.pow(c, 1/2.4) - 0.055); };
const clamp01 = x => x < 0 ? 0 : x > 1 ? 1 : x;
const clamp255 = x => x < 0 ? 0 : x > 255 ? 255 : x;
function toneAndColor(img, ACC) {
const d = img.data;
const co = S.contrast / 100, br = S.brightness / 100;
const sat = ACC === "mono" ? 1 : 1 + S.saturation / 100;
/* Subtle filmic curve for accent profiles, identity for mono. */
const filmic = ACC === "mono" ? 0 : 0.35;
for (let i = 0; i < d.length; i += 4) {
let r = srgb2lin(d[i]), g = srgb2lin(d[i+1]), b = srgb2lin(d[i+2]);
/* Saturation in linear */
const l = 0.2126*r + 0.7152*g + 0.0722*b;
r = l + (r - l) * sat; g = l + (g - l) * sat; b = l + (b - l) * sat;
/* ACES-lite filmic for photo-grade accent profiles */
if (filmic > 0) {
const a = 2.51, bb = 0.03, c = 2.43, dd = 0.59, e = 0.14;
const map = x => clamp01((x*(a*x+bb))/(x*(c*x+dd)+e));
r = r*(1-filmic) + map(r)*filmic;
g = g*(1-filmic) + map(g)*filmic;
b = b*(1-filmic) + map(b)*filmic;
}
let R = lin2srgb(r), G = lin2srgb(g), B = lin2srgb(b);
if (co !== 0) {
const f = (259 * (co*255 + 255)) / (255 * (259 - co*255));
R = f*(R-128)+128; G = f*(G-128)+128; B = f*(B-128)+128;
}
R += br*128; G += br*128; B += br*128;
d[i] = clamp255(R); d[i+1] = clamp255(G); d[i+2] = clamp255(B);
}
if (S.invert) for (let i = 0; i < d.length; i += 4) { d[i]=255-d[i]; d[i+1]=255-d[i+1]; d[i+2]=255-d[i+2]; }
}
function unsharp(img, radius, amount) {
const W = img.width, H = img.height, blur = boxBlur(img, radius);
const out = new ImageData(W, H);
for (let i = 0; i < img.data.length; i += 4) {
for (let k = 0; k < 3; k++) {
out.data[i+k] = clamp255(img.data[i+k] + (img.data[i+k] - blur.data[i+k]) * amount);
}
out.data[i+3] = 255;
}
return out;
}
function boxBlur(img, radius) {
const W = img.width, H = img.height, src = img.data;
const tmp = new Uint8ClampedArray(W*H*4);
const out = new ImageData(W, H), dst = out.data;
const size = 2*radius + 1;
for (let y = 0; y < H; y++) {
let rs=0, gs=0, bs=0;
for (let k = -radius; k <= radius; k++) {
const x = Math.min(W-1, Math.max(0, k)); const o = (y*W+x)*4;
rs += src[o]; gs += src[o+1]; bs += src[o+2];
}
for (let x = 0; x < W; x++) {
const o = (y*W+x)*4;
tmp[o] = rs/size; tmp[o+1] = gs/size; tmp[o+2] = bs/size; tmp[o+3] = 255;
const oa = (y*W + Math.max(0, x-radius))*4, ob = (y*W + Math.min(W-1, x+radius+1))*4;
rs += src[ob]-src[oa]; gs += src[ob+1]-src[oa+1]; bs += src[ob+2]-src[oa+2];
}
}
for (let x = 0; x < W; x++) {
let rs=0, gs=0, bs=0;
for (let k = -radius; k <= radius; k++) {
const y = Math.min(H-1, Math.max(0, k)); const o = (y*W+x)*4;
rs += tmp[o]; gs += tmp[o+1]; bs += tmp[o+2];
}
for (let y = 0; y < H; y++) {
const o = (y*W+x)*4;
dst[o] = rs/size; dst[o+1] = gs/size; dst[o+2] = bs/size; dst[o+3] = 255;
const oa = (Math.max(0, y-radius)*W + x)*4, ob = (Math.min(H-1, y+radius+1)*W + x)*4;
rs += tmp[ob]-tmp[oa]; gs += tmp[ob+1]-tmp[oa+1]; bs += tmp[ob+2]-tmp[oa+2];
}
}
return out;
}
/* ─── Quantize + dither ───────────────────────────────────────────── */
const BAYER8 = (() => {
const m = [];
for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) {
let v = 0, xc = x ^ y, yc = y;
for (let i = 0; i < 3; i++) {
v |= ((yc & 1) << (2*i + 1)); v |= ((xc & 1) << (2*i));
xc >>= 1; yc >>= 1;
}
m.push((v + 0.5) / 64 - 0.5);
}
return m;
})();
const KERNELS = {
fs: [[1,0,7/16],[-1,1,3/16],[0,1,5/16],[1,1,1/16]],
atk: [[1,0,1/8],[2,0,1/8],[-1,1,1/8],[0,1,1/8],[1,1,1/8],[0,2,1/8]],
};
/* Linear-RGB → Oklab.
* Oklab is a perceptually uniform colour space, so Euclidean distance there
* matches "how different two colours look" much better than RGB. Doing the
* nearest-palette search in Oklab lets the dither algorithm naturally pick
* the W/B/Accent mix that a human eye would perceive as the closest blend
* of the original photo — which is exactly the "alive, photoreal" feel of
* a 3-colour halftone newspaper. */
function rgb2oklab(r, g, b) {
/* Inputs in 0..1 linear-light. */
const l = 0.4122214708*r + 0.5363325363*g + 0.0514459929*b;
const m = 0.2119034982*r + 0.6806995451*g + 0.1073969566*b;
const s = 0.0883024619*r + 0.2817188376*g + 0.6299787005*b;
const l_ = Math.cbrt(l), m_ = Math.cbrt(m), s_ = Math.cbrt(s);
return [
0.2104542553*l_ + 0.7936177850*m_ - 0.0040720468*s_,
1.9779984951*l_ - 2.4285922050*m_ + 0.4505937099*s_,
0.0259040371*l_ + 0.7827717662*m_ - 0.8086757660*s_,
];
}
function quantize(img, ACC) {
const W = img.width, H = img.height, src = img.data, N = W*H;
const idx = new Uint8Array(N);
const useColor = ACC !== "mono";
const detail = S.detail / 100;
/* Detail bias nudges the threshold for mono and tilts accent affinity for tri-colour. */
const tilt = (detail - 0.5) * 0.20;
/* Build palette in linear RGB (0..1) and Oklab. */
const paletteRGB = useColor
? [[1,1,1], [0,0,0], ACCENT_RGB[ACC].map(v => srgb2lin(v))]
: [[1,1,1], [0,0,0]];
const paletteLab = paletteRGB.map(([r,g,b]) => rgb2oklab(r,g,b));
/* Working buffer in linear RGB so error diffusion sums physically. */
const buf = new Float32Array(N * 3);
for (let i = 0; i < N; i++) {
buf[i*3] = srgb2lin(src[i*4]);
buf[i*3+1] = srgb2lin(src[i*4+1]);
buf[i*3+2] = srgb2lin(src[i*4+2]);
}
/* Saturation-aware nearest in Oklab. The accent palette gets a tiny bonus
* proportional to chroma so the algorithm uses W+B for neutral pixels and
* mixes accent in only where the photo actually has colour — just like a
* real risograph or newsprint print. */
function nearest(r, g, b) {
const lab = rgb2oklab(r, g, b);
let best = 0, bd = Infinity;
for (let p = 0; p < paletteRGB.length; p++) {
const dl = lab[0] - paletteLab[p][0];
const da = lab[1] - paletteLab[p][1];
const db = lab[2] - paletteLab[p][2];
let d = dl*dl + da*da + db*db;
if (useColor && p === 2) {
/* Pull accent closer when the source pixel itself is chromatic. */
const chroma = Math.hypot(lab[1], lab[2]);
d -= chroma * (0.012 + tilt * 0.018);
}
if (d < bd) { bd = d; best = p; }
}
return best;
}
if (S.dither === "thr" || S.dither === "bayer8") {
const m = S.dither === "bayer8" ? BAYER8 : null;
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
const i = y*W + x;
let r = buf[i*3], g = buf[i*3+1], b = buf[i*3+2];
if (m) {
const t = m[(y&7)*8 + (x&7)] * 0.18;
r += t; g += t; b += t;
} else {
/* Threshold mode for mono — pure luma cut adjusted by detail slider. */
if (!useColor) {
const lum = 0.2126*r + 0.7152*g + 0.0722*b;
idx[i] = lum >= (0.5 - tilt) ? 0 : 1;
continue;
}
}
idx[i] = nearest(r, g, b);
}
} else {
const k = KERNELS[S.dither] || KERNELS.fs;
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
const i = y*W + x;
const r = buf[i*3], g = buf[i*3+1], b = buf[i*3+2];
const p = nearest(r, g, b);
idx[i] = p;
/* Diffuse residual error in linear RGB so neighbouring pixels can pick
* a different palette entry and the three colours visually blend. */
const er = r - paletteRGB[p][0];
const eg = g - paletteRGB[p][1];
const eb = b - paletteRGB[p][2];
for (const [dx, dy, w] of k) {
const nx = x+dx, ny = y+dy;
if (nx < 0 || nx >= W || ny < 0 || ny >= H) continue;
const j = (ny*W + nx) * 3;
buf[j] += er * w;
buf[j+1] += eg * w;
buf[j+2] += eb * w;
}
}
}
return { idx, W, H };
}
/* ─── BMP encoder · matches FAP reader ────────────────────────────── */
function buildBmp(quant, ACC) {
const W = quant.W, H = quant.H, idx = quant.idx;
const accent = ACC !== "mono";
const rowStride = ((W + 31) >> 5) << 2;
const pixelBytes = (rowStride + (accent ? rowStride : 0)) * H;
const paletteBytes = accent ? 12 : 8;
const headerSize = 14 + 40 + paletteBytes;
const fileSize = headerSize + pixelBytes;
const out = new Uint8Array(fileSize);
const dv = new DataView(out.buffer);
out[0] = 0x42; out[1] = 0x4D;
dv.setUint32(2, fileSize, true);
dv.setUint32(10, headerSize, true);
dv.setUint32(14, 40, true);
dv.setInt32(18, W, true);
dv.setInt32(22, H, true);
dv.setUint16(26, 1, true);
dv.setUint16(28, accent ? 2 : 1, true);
dv.setUint32(34, pixelBytes, true);
let po = 54;
out[po+0]=255; out[po+1]=255; out[po+2]=255; po+=4;
out[po+0]=0; out[po+1]=0; out[po+2]=0; po+=4;
if (accent) {
const [r, g, b] = ACCENT_RGB[ACC];
out[po+0]=b; out[po+1]=g; out[po+2]=r;
}
const monoOff = headerSize;
const accOff = headerSize + rowStride * H;
for (let y = 0; y < H; y++) {
const fy = H - 1 - y;
for (let x = 0; x < W; x++) {
const v = idx[fy*W + x];
const bIdx = x >> 3, bit = 7 - (x & 7);
if (v === 1) out[monoOff + y*rowStride + bIdx] |= (1 << bit);
else if (accent && v === 2) out[accOff + y*rowStride + bIdx] |= (1 << bit);
}
}
return out;
}
/* ─── Filename + download ─────────────────────────────────────────── */
function safeLabel() {
return ($("label").value || "").trim().replace(/[^a-zA-Z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 20);
}
function buildFilename() {
const p = currentProfile();
const lbl = safeLabel();
return `${p.w}x${p.h}${lbl ? "_" + lbl : ""}.bmp`;
}
function updateFilename() { $("filename").textContent = buildFilename(); }
$("label").addEventListener("input", updateFilename);
$("dl").addEventListener("click", () => {
if (!lastQuant) return;
const p = currentProfile();
const blob = new Blob([buildBmp(lastQuant, p.color)], { type: "image/bmp" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = buildFilename();
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 4000);
});
/* ─── Boot ────────────────────────────────────────────────────────── */
rebuildProfileList();
window.addEventListener("resize", scheduleRender);
</script>
</body>
</html>