mirror of
https://github.com/i12bp8/TagTinker.git
synced 2026-08-28 13:24:08 +00:00
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:
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,2 +0,0 @@
|
||||
#Thu Apr 23 20:33:11 CEST 2026
|
||||
gradle.version=8.11.1
|
||||
Binary file not shown.
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
|
||||
|
||||
@@ -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
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
-284
@@ -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,
|
||||
)
|
||||
-275
@@ -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.
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
Vendored
-172
@@ -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" "$@"
|
||||
Vendored
-84
@@ -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 +0,0 @@
|
||||
sdk.dir=/home/i12bp8/Android/sdk
|
||||
@@ -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")
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 & 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">0°</span></div>
|
||||
<div class="row"><label>Dither</label>
|
||||
<select id="dither" style="grid-column: span 2">
|
||||
<option value="fs" selected>Floyd–Steinberg (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>
|
||||
Reference in New Issue
Block a user