This commit is contained in:
j3d1 2026-08-24 21:35:38 +02:00
parent f87b689e27
commit 0c025db799
16 changed files with 555 additions and 105 deletions

View file

@ -149,6 +149,10 @@ class StorageLocationViewSet(viewsets.ModelViewSet):
serializer_class = StorageLocationSerializer
authentication_classes = [SignatureAuthentication]
permission_classes = [IsAuthenticated]
# Detail routes address a location by its owner-scoped id, not the internal row id. See
# docs/implementation.md#inventory-detail-routes-use-owner-scoped-ids.
lookup_field = 'id'
lookup_url_kwarg = 'pk'
def get_queryset(self):
if type(self.request.user) == KnownIdentity and self.request.user.user.exists():

View file

@ -169,7 +169,27 @@ class ItemTag(models.Model):
inventory_item = models.ForeignKey(InventoryItem, on_delete=models.CASCADE)
class OwnerStorageLocationSequence(models.Model):
"""Tracks the last StorageLocation id handed out per owner for sequential, gapless allocation
(see StorageLocation.create_for_owner)."""
owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='+', unique=True)
last_id = models.PositiveIntegerField(default=0)
@classmethod
def allocate(cls, *, owner):
with transaction.atomic():
seq, _ = cls.objects.select_for_update().get_or_create(owner=owner)
seq.last_id += 1
seq.save(update_fields=['last_id'])
return seq.last_id
class StorageLocation(models.Model):
internal_id = models.AutoField(primary_key=True)
# Externally visible id, sequential/gapless within the owner's own locations (see
# OwnerStorageLocationSequence), never internal_id; always allocate via create_for_owner, not
# .objects.create().
id = models.PositiveIntegerField(editable=False)
name = models.CharField(max_length=255)
description = models.TextField(null=True, blank=True)
category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, blank=True,
@ -177,10 +197,23 @@ class StorageLocation(models.Model):
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children')
owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='storage_locations')
class Meta:
constraints = [
models.UniqueConstraint(fields=['owner', 'id'], name='storagelocation_unique_owner_scoped_id'),
]
def __str__(self):
parent = str(self.parent) + "/" if self.parent else ""
return parent + self.name
@classmethod
def create_for_owner(cls, *, owner, **kwargs):
"""The only supported way to create a StorageLocation: atomically allocates the next id
for this owner's scope."""
with transaction.atomic():
next_id = OwnerStorageLocationSequence.allocate(owner=owner)
return cls.objects.create(owner=owner, id=next_id, **kwargs)
class WorkflowInstance(models.Model):
slug = models.CharField(max_length=255)

View file

@ -339,13 +339,17 @@ def import_locations(user, data):
if category_path:
category = get_or_create_category(category_path)
location, _ = StorageLocation.objects.update_or_create(
owner=user, name=name, parent=parent,
defaults={
'description': row.get('description', '') or '',
'category': category,
},
)
defaults = {
'description': row.get('description', '') or '',
'category': category,
}
try:
location = StorageLocation.objects.get(owner=user, name=name, parent=parent)
for field, value in defaults.items():
setattr(location, field, value)
location.save(update_fields=list(defaults.keys()))
except StorageLocation.DoesNotExist:
location = StorageLocation.create_for_owner(owner=user, name=name, parent=parent, **defaults)
resolved_by_path[path] = location
imported += 1
except Exception as error:

View file

@ -1,3 +1,4 @@
from django.core.exceptions import ObjectDoesNotExist
from rest_framework import serializers
from authentication.models import KnownIdentity, ToolshedUser, FriendRequestIncoming, Group, GroupInviteIncoming
from authentication.serializers import OwnerSerializer, GroupOwnerSerializer
@ -150,15 +151,50 @@ class CategorySerializer(serializers.ModelSerializer):
return resolve_category_handle(data.split("/")[-1])
class OwnerScopedPrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField):
"""Resolves/represents by the owner-scoped `id` rather than the model's internal pk, scoped to
the requesting user - StorageLocation.parent points at another StorageLocation, whose publicly
visible identity is now the owner-scoped id (see StorageLocation.create_for_owner), not
internal_id."""
def use_pk_only_optimization(self):
# False: to_representation needs the owner-scoped `id`, not just the internal pk that the
# PKOnlyObject optimization would otherwise limit us to.
return False
def get_queryset(self):
queryset = super().get_queryset()
request = self.context.get('request')
if request is not None and type(request.user) == KnownIdentity and request.user.user.exists():
return queryset.filter(owner=request.user.user.get())
return queryset.none()
def to_internal_value(self, data):
queryset = self.get_queryset()
try:
if isinstance(data, bool):
raise TypeError
return queryset.get(id=data)
except ObjectDoesNotExist:
self.fail('does_not_exist', pk_value=data)
except (TypeError, ValueError):
self.fail('incorrect_type', data_type=type(data).__name__)
def to_representation(self, value):
return value.id
class StorageLocationSerializer(serializers.ModelSerializer):
owner = OwnerSerializer(read_only=True)
category = serializers.CharField(required=False, allow_null=True, allow_blank=True)
parent = OwnerScopedPrimaryKeyRelatedField(queryset=StorageLocation.objects.all(), required=False,
allow_null=True)
path = serializers.SerializerMethodField()
class Meta:
model = StorageLocation
fields = ['id', 'name', 'description', 'path', 'category', 'owner', 'parent']
read_only_fields = ['path']
read_only_fields = ['id', 'path']
@staticmethod
def get_path(obj):
@ -166,6 +202,9 @@ class StorageLocationSerializer(serializers.ModelSerializer):
return StorageLocationSerializer.get_path(obj.parent) + "/" + obj.name
return obj.name
def create(self, validated_data):
return StorageLocation.create_for_owner(**validated_data)
class ItemPropertySerializer(serializers.ModelSerializer):
property = PropertySerializer(read_only=True)
@ -195,6 +234,8 @@ class InventoryItemSerializer(serializers.ModelSerializer):
properties = ItemPropertySerializer(many=True, required=False, source='itemproperty_set')
category = CategorySerializer(required=False, allow_null=True)
files = FileSerializer(many=True, read_only=True)
storage_location = OwnerScopedPrimaryKeyRelatedField(queryset=StorageLocation.objects.all(), required=False,
allow_null=True)
class Meta:
model = InventoryItem

View file

@ -49,12 +49,12 @@ class InventoryTestMixin(CategoryTestMixin, TagTestMixin, PropertyTestMixin):
class LocationTestMixin:
def prepare_locations(self):
self.f['loc1'] = StorageLocation.objects.create(name='loc1', owner=self.f['local_user1'])
self.f['loc2'] = StorageLocation.objects.create(name='loc2', owner=self.f['local_user1'],
category=self.f['cat1'])
self.f['loc3'] = StorageLocation.objects.create(name='loc3', owner=self.f['local_user1'], parent=self.f['loc1'])
self.f['loc4'] = StorageLocation.objects.create(name='loc4', owner=self.f['local_user1'], parent=self.f['loc1'],
category=self.f['cat1'])
self.f['loc1'] = StorageLocation.create_for_owner(name='loc1', owner=self.f['local_user1'])
self.f['loc2'] = StorageLocation.create_for_owner(name='loc2', owner=self.f['local_user1'],
category=self.f['cat1'])
self.f['loc3'] = StorageLocation.create_for_owner(name='loc3', owner=self.f['local_user1'], parent=self.f['loc1'])
self.f['loc4'] = StorageLocation.create_for_owner(name='loc4', owner=self.f['local_user1'], parent=self.f['loc1'],
category=self.f['cat1'])
class WorkflowTestMixin:

Binary file not shown.

View file

@ -32,13 +32,156 @@
<script>
import * as BIcons from "bootstrap-icons-vue";
import {loadAnyDCode} from "../../vendor/anyd-qr.js";
import {loadAnyDCode, toGrayFrame} from "../../vendor/anyd-qr.js";
// Same file as above, but as a plain URL (Vite's `?url` suffix skips bundling it into this
// module) - the decode worker built in createDecodeWorker() below needs a fetchable/importable
// URL for its own, independent `import()`, since a worker has no access to this module's own
// import bindings.
import anydUrl from "../../vendor/anyd-qr.js?url";
import cameraManager from "@/cameraManager.js";
// How long a detected code's overlay box stays drawn before fading, so it doesn't linger once
// the code has moved out of frame.
const OVERLAY_CLEAR_MS = 2000;
// Capture-loop tuning, matching prototypes/anydcode-wasm's advanced camera demo's own
// defaults/findings (see its parameter-sweep-findings.md) but at a rate suited to a
// battery-conscious inventory-scanning UI rather than that demo's max-responsiveness dial.
const CAPTURE_FPS = 4;
const CAPTURE_DOWNSCALE = 2;
// Milliseconds to suppress re-emitting a symbology+text pair already reported - independent of
// (and shorter than) COOLDOWN_MS below, since it throttles noisy duplicate "scan" events even
// on ticks where a candidate did get decoded (e.g. because the worker was otherwise idle).
const EMIT_DEDUPE_MS = 800;
// Location-based cooldown: skip re-decoding a candidate whose box overlaps a recent successful
// decode while the worker has other work pending, so a code sitting still in frame doesn't keep
// re-paying decode2d's cost every tick. Ported the IoU-overlap idea (not the code) from
// prototypes/anydcode-wasm/packages/qr-wasm/advanced_demo/pipeline.js's createCooldownTracker.
const COOLDOWN_MS = 1500;
const COOLDOWN_IOU_THRESHOLD = 0.3;
// A crop bigger than this (px, longer side) is almost always oversampled for its module count,
// so shrink it before decode2d - cheaper, and no less reliable for a close-up code - capped at
// MAX_DECODE_DOWNSCALE so a genuinely complex/high-version code that happens to fill the frame
// isn't pushed below a safe pixels-per-module floor. Values as validated by the advanced demo's
// parameter sweep (see its parameter-sweep-findings.md).
const DECODE_TARGET_CROP_SIZE = 400;
const MAX_DECODE_DOWNSCALE = 3;
// A queued decode job older than this is dropped unsent rather than dispatched - by the time the
// (single) worker would reach it, the frame it came from is stale enough that decoding it is
// wasted work. MAX_DECODE_QUEUE bounds how many candidates can be waiting at once; there's only
// one worker, so this is a queue depth, not a pool size.
const MAX_QUEUE_AGE_MS = 1000;
const MAX_DECODE_QUEUE = 4;
function boundingBox(corners) {
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const [x, y] of corners) {
if (x < x0) x0 = x;
if (y < y0) y0 = y;
if (x > x1) x1 = x;
if (y > y1) y1 = y;
}
return {x0, y0, x1, y1};
}
// Intersection-over-union of two `{x0,y0,x1,y1}` boxes, 0 (no overlap) to 1 (identical).
function iou(a, b) {
const x0 = Math.max(a.x0, b.x0);
const y0 = Math.max(a.y0, b.y0);
const x1 = Math.min(a.x1, b.x1);
const y1 = Math.min(a.y1, b.y1);
const interArea = Math.max(0, x1 - x0) * Math.max(0, y1 - y0);
if (interArea === 0) return 0;
const areaA = (a.x1 - a.x0) * (a.y1 - a.y0);
const areaB = (b.x1 - b.x0) * (b.y1 - b.y0);
return interArea / (areaA + areaB - interArea);
}
// Crop a sub-frame out of `luma` (row-major, `width`x`height`), expanding by `pad` pixels and
// clamping to the source bounds, so a tight `locate()` box still leaves the decoder its quiet
// zone.
function cropGray(width, height, luma, box, pad) {
const x0 = Math.max(0, Math.floor(box.x0 - pad));
const y0 = Math.max(0, Math.floor(box.y0 - pad));
const x1 = Math.min(width, Math.ceil(box.x1 + pad));
const y1 = Math.min(height, Math.ceil(box.y1 + pad));
const w = Math.max(1, x1 - x0);
const h = Math.max(1, y1 - y0);
const out = new Uint8Array(w * h);
for (let row = 0; row < h; row++) {
const srcStart = (y0 + row) * width + x0;
out.set(luma.subarray(srcStart, srcStart + w), row * w);
}
return {width: w, height: h, luma: out, offsetX: x0, offsetY: y0};
}
// Pick how much to shrink this candidate's crop before decode2d(), from its own detected size -
// `1` (no change) for the common small/typical case; only a candidate detected as unusually
// large gets downscaled, and only by as much as its size actually calls for.
function chooseDecodeDownscale(box, targetSize, maxDownscale) {
const size = Math.max(box.x1 - box.x0, box.y1 - box.y0);
return Math.min(maxDownscale, Math.max(1, Math.floor(size / targetSize)));
}
// Crop `box` (expanded by `pad`, clamped to the frame) for decoding, downscaling by
// `decodeDownscale` in the same step when that's `> 1`. At `1` this is exactly `cropGray`; a
// real downscale instead re-samples from `sourceCanvas` (the RGBA source) so the browser's own
// image scaling does the antialiased averaging a manual subsample of an already-grayscale array
// would not.
function cropForDecode(width, height, luma, box, pad, decodeDownscale, sourceCanvas, scratchCanvas, scratchCtx) {
if (decodeDownscale === 1) {
return {...cropGray(width, height, luma, box, pad), decodeDownscale};
}
const x0 = Math.max(0, Math.floor(box.x0 - pad));
const y0 = Math.max(0, Math.floor(box.y0 - pad));
const x1 = Math.min(width, Math.ceil(box.x1 + pad));
const y1 = Math.min(height, Math.ceil(box.y1 + pad));
const srcW = Math.max(1, x1 - x0);
const srcH = Math.max(1, y1 - y0);
const dstW = Math.max(1, Math.round(srcW / decodeDownscale));
const dstH = Math.max(1, Math.round(srcH / decodeDownscale));
if (scratchCanvas.width !== dstW || scratchCanvas.height !== dstH) {
scratchCanvas.width = dstW;
scratchCanvas.height = dstH;
}
scratchCtx.drawImage(sourceCanvas, x0, y0, srcW, srcH, 0, 0, dstW, dstH);
const cropLuma = toGrayFrame(scratchCtx.getImageData(0, 0, dstW, dstH)).luma;
return {width: dstW, height: dstH, luma: cropLuma, offsetX: x0, offsetY: y0, decodeDownscale};
}
// Builds the off-main-thread decode2d()/decode1d() half of the pipeline: a single Worker (its
// source assembled from a template string, not a separate file, to keep this pipeline contained
// in this component) that dynamically imports the same anyd-qr.js bundle already used on the
// main thread for locate(), so a slow decode never blocks capture, detection, or this
// component's own overlay/UI rendering. It gets its own wasm instance - a worker's wasm memory
// isn't shared with the main thread - via that import's own `loadAnyDCode()` cache, exactly like
// the main thread's `anydPromise` below.
function createDecodeWorker() {
const absoluteAnydUrl = new URL(anydUrl, window.location.href).href;
const source = `
import { loadAnyDCode } from ${JSON.stringify(absoluteAnydUrl)};
const anydPromise = loadAnyDCode();
self.onmessage = async (event) => {
const {jobId, width, height, luma} = event.data;
try {
const anyd = await anydPromise;
const codes = anyd.decodeImage({width, height, luma: new Uint8Array(luma)});
self.postMessage({type: "result", jobId, codes});
} catch (err) {
self.postMessage({type: "error", jobId, message: err?.message ?? String(err)});
}
};
`;
const blobUrl = URL.createObjectURL(new Blob([source], {type: "application/javascript"}));
const worker = new Worker(blobUrl, {type: "module"});
URL.revokeObjectURL(blobUrl);
return worker;
}
export default {
name: "CameraScanner",
components: {
@ -173,22 +316,182 @@ export default {
this.localSelectedCameraId = cameraManager.getSelectedCameraId() || "";
const anyd = await this.anydPromise;
this.scanner = anyd.createCameraScanner(this.$refs.video, {
fps: 4,
downscale: 2,
onDecode: (codes) => {
this.$emit("scan", codes);
this.drawOverlay(codes);
},
onError: (err) => console.error("[camera scan]", err),
});
this.scanner.start();
this.detectWasm = anyd.raw;
this.seen = new Map();
this.cooldownEntries = [];
clearInterval(this.captureTimer);
this.captureTimer = setInterval(() => this.captureTick(), 1000 / CAPTURE_FPS);
this.cameraRunning = true;
} catch (e) {
this.$emit("error", `Camera error: ${e.message ?? e}`);
}
},
// The tick driving the whole pipeline: grab+downscale the current video frame, run
// `locate()` right here on the main thread (cheap enough not to need a worker of its
// own), then hand each candidate crop off to the decode worker rather than decoding it
// inline - see createDecodeWorker()'s doc comment for why.
captureTick() {
const video = this.$refs.video;
if (!video || !video.videoWidth || !video.videoHeight || !this.detectWasm) {
return;
}
const width = Math.max(1, Math.round(video.videoWidth / CAPTURE_DOWNSCALE));
const height = Math.max(1, Math.round(video.videoHeight / CAPTURE_DOWNSCALE));
if (this.captureCanvas.width !== width || this.captureCanvas.height !== height) {
this.captureCanvas.width = width;
this.captureCanvas.height = height;
}
this.captureCtx.drawImage(video, 0, 0, width, height);
const frame = toGrayFrame(this.captureCtx.getImageData(0, 0, width, height));
let candidates;
try {
candidates = this.detectWasm.locate(width, height, frame.luma);
} catch (err) {
this.$emit("error", `Camera error: ${err.message ?? err}`);
return;
}
if (!Array.isArray(candidates)) {
return;
}
const now = performance.now();
for (const candidate of candidates) {
if (!candidate?.corners) continue;
const box = boundingBox(candidate.corners);
const nativeBox = {
x0: box.x0 * CAPTURE_DOWNSCALE,
y0: box.y0 * CAPTURE_DOWNSCALE,
x1: box.x1 * CAPTURE_DOWNSCALE,
y1: box.y1 * CAPTURE_DOWNSCALE,
};
// Something decoded near here recently, and the worker has other work pending -
// skip re-decoding this candidate rather than spend it re-reading a code that's
// (almost certainly) still just sitting there. If the worker/queue is otherwise
// idle, decode anyway: there's no contention to save, and it keeps this
// location's cooldown entry from going stale between real hits.
if ((this.decodeWorkerBusy || this.decodeQueue.length > 0) && this.findCooldownNear(nativeBox, now)) {
continue;
}
const decodeDownscale = chooseDecodeDownscale(box, DECODE_TARGET_CROP_SIZE, MAX_DECODE_DOWNSCALE);
const crop = cropForDecode(
width, height, frame.luma, box, 4, decodeDownscale,
this.captureCanvas, this.decodeScratchCanvas, this.decodeScratchCtx);
this.enqueueDecodeJob(crop, decodeDownscale, now);
}
},
enqueueDecodeJob(crop, decodeDownscale, now) {
this.pruneStaleJobs(now);
if (this.decodeQueue.length >= MAX_DECODE_QUEUE) {
return;
}
this.decodeQueue.push({
jobId: ++this.jobCounter,
generation: this.captureGeneration,
enqueuedAt: now,
payload: {jobId: this.jobCounter, width: crop.width, height: crop.height, luma: crop.luma.buffer},
transfer: [crop.luma.buffer],
offsetX: crop.offsetX,
offsetY: crop.offsetY,
downscale: CAPTURE_DOWNSCALE,
decodeDownscale,
});
this.dispatchNextJob();
},
// FIFO order means the front of the queue is always the oldest, so the first
// non-expired entry means nothing behind it can be expired either - one pass from the
// front is sufficient.
pruneStaleJobs(now) {
while (this.decodeQueue.length > 0 && now - this.decodeQueue[0].enqueuedAt > MAX_QUEUE_AGE_MS) {
this.decodeQueue.shift();
}
},
dispatchNextJob() {
if (this.decodeWorkerBusy) {
return;
}
this.pruneStaleJobs(performance.now());
const job = this.decodeQueue.shift();
if (!job) {
return;
}
this.decodeWorkerBusy = true;
this.pendingJob = job;
this.decodeWorker.postMessage(job.payload, job.transfer);
},
// The worker only ever saw this one candidate's crop, so any `box` it reports is
// relative to that crop's own pixels - coarser than the capture frame's if this
// candidate got its own decode-time downscale. Scale back up by that factor to land in
// the crop's own space, then apply its offset, then the capture downscale in effect
// when this job was dispatched, to land on native video pixels.
handleWorkerMessage(event) {
const msg = event.data;
const job = this.pendingJob;
this.decodeWorkerBusy = false;
this.pendingJob = null;
if (msg.type === "error") {
console.error("[camera scan]", msg.message);
} else if (job && job.jobId === msg.jobId && job.generation === this.captureGeneration) {
const now = performance.now();
const codes = (msg.codes ?? []).map((code) => ({
...code,
box: code.box && {
x0: (code.box.x0 * job.decodeDownscale + job.offsetX) * job.downscale,
y0: (code.box.y0 * job.decodeDownscale + job.offsetY) * job.downscale,
x1: (code.box.x1 * job.decodeDownscale + job.offsetX) * job.downscale,
y1: (code.box.y1 * job.decodeDownscale + job.offsetY) * job.downscale,
},
}));
for (const code of codes) {
if (code.box) this.rememberCooldown(code.box, now);
}
const dedupeNow = Date.now();
const fresh = codes.filter((code) => {
const key = `${code.type}|${code.text ?? ""}`;
const lastSeen = this.seen.get(key);
if (lastSeen !== undefined && dedupeNow - lastSeen < EMIT_DEDUPE_MS) {
return false;
}
this.seen.set(key, dedupeNow);
return true;
});
if (fresh.length > 0) {
this.$emit("scan", fresh);
this.drawOverlay(fresh);
}
}
this.dispatchNextJob();
},
rememberCooldown(box, now) {
const existing = this.cooldownEntries.find((e) => iou(e.box, box) >= COOLDOWN_IOU_THRESHOLD);
if (existing) {
existing.box = box;
existing.decodedAt = now;
} else {
this.cooldownEntries.push({box, decodedAt: now});
}
// Prune well past the cooldown window so a long session doesn't accumulate forever.
this.cooldownEntries = this.cooldownEntries.filter((e) => now - e.decodedAt < COOLDOWN_MS * 4);
},
findCooldownNear(box, now) {
return this.cooldownEntries.some(
(e) => now - e.decodedAt < COOLDOWN_MS && iou(e.box, box) >= COOLDOWN_IOU_THRESHOLD);
},
async switchCamera(cameraId) {
if (!this.cameraRunning) return;
try {
@ -200,8 +503,15 @@ export default {
},
stopCamera() {
this.scanner?.stop();
this.scanner = null;
clearInterval(this.captureTimer);
this.captureTimer = null;
this.decodeQueue = [];
// Bumped so a decode job dispatched before this stop - still in flight in the
// worker - is recognized as stale and discarded once its result arrives (see
// handleWorkerMessage), rather than applied against whatever's running by then.
// decodeWorkerBusy/pendingJob are deliberately left alone: they resolve themselves
// when that in-flight result lands.
this.captureGeneration++;
cameraManager.closeStream();
if (this.$refs.video) {
this.$refs.video.srcObject = null;
@ -235,8 +545,28 @@ export default {
// in-flight load; loadAnyDCode also memoizes, so later calls elsewhere in the app are
// free too.
this.anydPromise = loadAnyDCode();
this.scanner = null;
this.detectWasm = null;
this.clearOverlayTimer = null;
this.captureTimer = null;
this.captureGeneration = 0;
this.seen = new Map();
this.cooldownEntries = [];
// decode2d()/decode1d() run in this worker, off the main thread, so a slow decode never
// stalls locate()/capture or this component's own rendering - see createDecodeWorker().
// Long-lived for the component's lifetime (not recreated per start/stop) so switching
// cameras or restarting the scan doesn't pay wasm-instantiation cost again.
this.decodeWorker = createDecodeWorker();
this.decodeWorker.onmessage = (event) => this.handleWorkerMessage(event);
this.decodeWorkerBusy = false;
this.pendingJob = null;
this.jobCounter = 0;
this.decodeQueue = [];
this.captureCanvas = typeof OffscreenCanvas !== "undefined" ? new OffscreenCanvas(1, 1) : document.createElement("canvas");
this.captureCtx = this.captureCanvas.getContext("2d");
this.decodeScratchCanvas = typeof OffscreenCanvas !== "undefined" ? new OffscreenCanvas(1, 1) : document.createElement("canvas");
this.decodeScratchCtx = this.decodeScratchCanvas.getContext("2d");
},
mounted() {
window.addEventListener("resize", this.syncOverlaySize);
@ -260,6 +590,7 @@ export default {
this.$refs.video?.removeEventListener("loadedmetadata", this.onVideoResize);
this.$refs.video?.removeEventListener("resize", this.onVideoResize);
clearTimeout(this.clearOverlayTimer);
this.decodeWorker.terminate();
},
}
</script>

View file

@ -90,12 +90,12 @@
background: #fff;
}
/* Corner badge flagging *why* a layout that's otherwise available (fields filled in) still
failed to render - e.g. label.js's "bigger code than the tape allows" or "doesn't fit on this
tape" errors (see redraw()'s catch). Shows the short label directly (e.g. "too big") rather than
just an icon, so the reason reads at a glance; the full sentence is still the title tooltip. Not
shown for the more common "fields not filled in yet" case (see isSelectable/unavailableReason)
since that's already conveyed by the greyed-out thumbnail. */
/* Corner badge flagging *why* a layout failed to render - a capacity error (label.js's "bigger
code than the tape allows"/"doesn't fit on this tape") or label-layouts.js's templateContent
throwing "missing data" because a field it needs isn't filled in yet (see redraw()'s catch).
Shows the short label directly (e.g. "too big", "missing data") rather than just an icon, so the
reason reads at a glance; the full sentence is still the title tooltip. Layered on top of the
greyed-out/unselectable state isSelectable/unavailableReason already apply for the same case. */
.template-thumb-warning {
position: absolute;
top: .35rem;
@ -276,17 +276,8 @@ export default {
if (!canvas) {
continue;
}
const content = templateContent(t, this.fields);
if (!this.isAvailable(t) || !content) {
canvas.width = 1;
canvas.height = 1;
delete this.failed[t.id];
delete this.warned[t.id];
delete this.qrInfo[t.id];
delete this.textInfo[t.id];
continue;
}
try {
const content = templateContent(t, this.fields);
const {warnings, qrInfo, textInfo} = this.tape
? drawLabel(canvas, this.tape, content, "along")
: drawFallbackLabel(canvas, content, "along");

View file

@ -86,14 +86,39 @@ export const LABEL_TEMPLATES = [
},
{
id: "rmqr-url", name: "rMQR Token", description: "The code with the encoded text printed next to it.",
required_vars: ["shortUrl","itemId"], tags: ["external"],
layout: [{type: "rmqr", content: c => c.shortUrl},GAP,{type: "text", content: c => c.itemId?.toString().padStart(4, "0")}]
required_vars: ["shortUrl", "itemId"], tags: ["external"],
layout: [{type: "rmqr", content: c => c.shortUrl}, GAP, {
type: "text",
content: c => c.itemId?.toString().padStart(4, "0")
}]
},
{
id: "mqr-tokem-id", name: "rMQR Token", description: "The code with the encoded text printed next to it.",
required_vars: ["shortId","itemId"], tags: ["internal"],
layout: [{type: "mqr", content: c => c.shortId},GAP,{type: "text", content: c => c.itemId?.toString().padStart(4, "0")}]
},...QR_ONLY_TEMPLATES,
required_vars: ["shortId", "itemId"], tags: ["internal"],
layout: [{type: "mqr", content: c => c.shortId}, GAP, {
type: "text",
content: c => c.itemId?.toString().padStart(4, "0")
}]
},
{
id: "location-rmqr-url", name: "rMQR Token", description: "The code with the encoded text printed next to it.",
required_vars: ["shortUrl", "locationId"], tags: ["external"],
layout: [{type: "rmqr", content: c => c.shortUrl}, GAP, {
type: "text",
content: c => c.locationId?.toString().padStart(4, "0")
}]
},
{
id: "location-mqr-tokem-id",
name: "rMQR Token",
description: "The code with the encoded text printed next to it.",
required_vars: ["shortId", "locationId"],
tags: ["internal"],
layout: [{type: "mqr", content: c => c.shortId}, GAP, {
type: "text",
content: c => c.locationId?.toString().padStart(4, "0")
}]
}, ...QR_ONLY_TEMPLATES,
{
id: "qr-text", name: "QR code + text", description: "The code with the encoded text printed next to it.",
@ -110,7 +135,7 @@ export const LABEL_TEMPLATES = [
id: "id-qr-text-vertical", name: "ID + QR code + text below",
description: "The code with the encoded text printed below it.",
required_vars: ["itemId", "text", "userHandle"],
layout: [[{type: "text", content: c => "Item: "+c.itemId}, GAP, {
layout: [[{type: "text", content: c => "Item: " + c.itemId}, GAP, {
type: "qr",
content: c => c.text
}, GAP, {type: "text", content: c => c.userHandle}]]
@ -148,6 +173,17 @@ export const LABEL_TEMPLATES = [
required_vars: ["userHandle", "itemId"], tags: ["internal"],
layout: [{type: "text", content: c => [c.userHandle, c.itemId]}]
},
{
id: "location-id", name: "Location ID", description: "Just the bare storage location id, as text only.",
required_vars: ["locationId"], tags: ["internal"],
layout: [{type: "text", content: c => c.locationId}]
},
{
id: "owner-id-text-location", name: "Owner + location ID",
description: "The owner's handle and the location id, as two lines of text - no code.",
required_vars: ["userHandle", "locationId"], tags: ["internal"],
layout: [{type: "text", content: c => [c.userHandle, c.locationId]}]
},
{
id: "item-url-qr-handle", name: "Item URL + handle",
description: "Scannable item URL, with the item's compact handle printed alongside.",
@ -272,14 +308,15 @@ export function templateIsAvailable(t, fields) {
}
// Resolves t's content leaves against fields into a tree for label.js's
// drawLabel/drawFallbackLabel, or null if nothing to render yet.
// drawLabel/drawFallbackLabel. Throws the same {message, short} shape as label.js's own
// capacity errors (see docs/implementation.md#missing-data-is-a-templatecontent-error) when a
// leaf isn't resolved yet, rather than returning null, so a caller's single catch around
// drawLabel/drawFallbackLabel handles both without a separate isAvailable pre-check.
export function templateContent(t, fields) {
const tree = mapTree(t.layout, leaf => leaf.type === "empty" ? leaf : {...leaf, value: leaf.content(fields)});
let hasContent = false;
walkLeaves(tree, leaf => {
if (leaf.type !== "empty" && leaf.value && (!Array.isArray(leaf.value) || leaf.value.some(Boolean))) {
hasContent = true;
}
});
return hasContent ? tree : null;
if (!templateIsAvailable(t, fields)) {
const err = new Error("This layout needs more fields filled in before it can be drawn.");
err.short = "missing data";
throw err;
}
return mapTree(t.layout, leaf => leaf.type === "empty" ? leaf : {...leaf, value: leaf.content(fields)});
}

View file

@ -81,14 +81,18 @@ const TEXT_REFERENCE_PX = 100; /* font size text leaves measure their natural a
// Below 10px, a general-purpose sans-serif gets illegible, so drawTextLeaf switches to a bitmap
// font (Tom Thumb/Silkscreen) instead; see the empirical rationale (font choice, `scale`, and why
// sizes aren't dpi-adjusted) at docs/implementation.md#pixel-font-selection.
const PIXEL_FONT_TIERS = [
{belowPx: 8, family: "Tom Thumb", scale: 3.2},
{belowPx: 10, family: "Silkscreen"},
// sizes aren't dpi-adjusted) at docs/implementation.md#pixel-font-selection. The 10px+ tier names
// Inter explicitly (see ../scss/_label-fonts.scss) rather than falling back to the CSS generic
// "sans-serif" keyword, which resolves to a different real font per browser/OS and would make the
// same label print differently depending on where it was rendered from.
const FONT_TIERS = [
{belowPx: 8, family: "Tom Thumb", scale: 3.2, pixel: true},
{belowPx: 10, family: "Silkscreen", pixel: true},
{belowPx: Infinity, family: "Inter"},
];
function fontFamilyFor(fontPx) {
return PIXEL_FONT_TIERS.find(t => fontPx < t.belowPx) ?? {family: "sans-serif"};
return FONT_TIERS.find(t => fontPx < t.belowPx);
}
// Below this font size (px) or physical height (mm) - Tom Thumb's real-Chromium-tested legibility
@ -291,11 +295,12 @@ function checkTextSizes(node, referencePx, pxPerMm, warnings, textInfo) {
}
}
// Always measures in sans-serif at the reference size, since the eventual font (see
// PIXEL_FONT_TIERS) isn't known until layoutTree sizes the box this aspect ratio feeds into; the
// resulting mismatch is invisible in practice, and checkTextSizes still catches real failures.
// Always measures in Inter at the reference size, since the eventual font (see FONT_TIERS) isn't
// known until layoutTree sizes the box this aspect ratio feeds into; the resulting mismatch (when
// a pixel font tier ends up chosen instead) is invisible in practice, and checkTextSizes still
// catches real failures.
function measureTextBlock(ctx, lines, referencePx) {
ctx.font = `${referencePx}px sans-serif`;
ctx.font = `${referencePx}px "Inter"`;
const width = Math.max(...lines.map(line => ctx.measureText(line).width));
const height = referencePx * 1.15 * lines.length;
return {width, height};
@ -342,20 +347,18 @@ function drawQrLeaf(ctx, node) {
// there's no "too small to draw" case left to special-case here.
function drawTextLeaf(ctx, node, referencePx) {
const fontPx = effectiveFontPx(node, referencePx);
const {family, scale = 1} = fontFamilyFor(fontPx);
const isPixelFont = family !== "sans-serif";
const {family, scale = 1, pixel: isPixelFont = false} = fontFamilyFor(fontPx);
// Position (not size - fontPx is already a whole pixel) still needs snapping for pixel fonts
// to stay grid-aligned, since node.box.x/y are ordinary (fractional) layout math; sans-serif is
// to stay grid-aligned, since node.box.x/y are ordinary (fractional) layout math; Inter is
// left exact since anti-aliasing handles fractional positions fine.
const snap = isPixelFont ? Math.round : (v) => v;
// `scale` (Tom Thumb only, see PIXEL_FONT_TIERS above) corrects the size handed to ctx.font
// for its real ink; fontPx itself stays the logical size used for centering/stacking math.
// `scale` (Tom Thumb only, see FONT_TIERS above) corrects the size handed to ctx.font for its
// real ink; fontPx itself stays the logical size used for centering/stacking math.
ctx.font = `${fontPx * scale}px "${family}"`;
// Canvas text silently falls back if drawn before a not-yet-loaded font resolves, unlike DOM
// text. See docs/implementation.md#canvas-font-loading.
if (isPixelFont) {
document.fonts.load(ctx.font);
}
// text. See docs/implementation.md#canvas-font-loading. Every tier now names a real,
// self-hosted webfont (see FONT_TIERS above), so this always applies, not just to pixel fonts.
document.fonts.load(ctx.font);
ctx.textAlign = "center";
const centerX = snap(node.box.x + node.box.width / 2);
const lineHeight = node.box.height / node.lines.length;
@ -527,9 +530,9 @@ export function drawFallbackLabel(canvas, content, orientation = "along") {
// print label should show/encode; keyed by `kind` so each kind's format is defined in one place.
export const LABEL_CONTENT_BUILDERS = {
// The self-contained Item URL (see docs/design-in-progress/items-labels.md), built from just
// the prefill's {userHandle, id}; the short link (Print.vue's `shortUrl`) needs an async store
// the prefill's {userHandle, item}; the short link (Print.vue's `shortUrl`) needs an async store
// lookup, so it stays a separate field/template rather than being baked in here.
"item": ({userHandle, id}) => `${window.location.origin}/i/${encodeHandleForUrl(userHandle)}/${id}`,
"item": ({userHandle, item}) => `${window.location.origin}/i/${encodeHandleForUrl(userHandle)}/${item}`,
// Storage locations have no long-form URL route yet (see router.js), so `text` starts blank;
// the short link and any future location template still work via the base vars below.
};
@ -542,7 +545,7 @@ export function buildLabelContent(prefill) {
return build ? build(prefill.components) : "";
}
// Splits a prefill's {userHandle, id} into label-layouts.js's user/domain base vars (the same way
// Splits a prefill's {userHandle, item/location} into label-layouts.js's user/domain base vars (the same way
// store.js's lookupServer does), tagging on whichever id field the resource's templates key
// required_vars by.
function splitUserHandle(userHandle) {
@ -560,19 +563,19 @@ function splitUserHandle(userHandle) {
// computed live elsewhere (see DERIVED_VARS, Print.vue's `shortUrl`), and omitting a field
// (rather than leaving it present-but-empty) signals "not available" to templateIsAvailable.
const LABEL_FIELD_BUILDERS = {
"item": ({userHandle, id}) => {
"item": ({userHandle, item}) => {
const split = splitUserHandle(userHandle);
if (!split || !id) {
if (!split || !item) {
return {};
}
return {...split, itemId: String(id)};
return {...split, itemId: String(item)};
},
"storage-location": ({userHandle, id}) => {
"storage-location": ({userHandle, location}) => {
const split = splitUserHandle(userHandle);
if (!split || !id) {
if (!split || !location) {
return {};
}
return {...split, locationId: String(id)};
return {...split, locationId: String(location)};
},
};

View file

@ -139,7 +139,9 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
meta: {requiresAuth: true},
props: route => {
const {kind, ...components} = route.query;
return {prefill: kind ? {kind, components} : null};
// queryFields lets any base var (e.g. ?text=...) be set directly, without going through
// a `kind` builder - see Print.vue's varValues.
return {prefill: kind ? {kind, components} : null, queryFields: components};
}
}, {
path: '/scan',

View file

@ -94,6 +94,7 @@ $body-color: $gray-700;
@import "dropdown";
@import "pixel-fonts";
@import "pixel-fonts-candidates";
@import "label-fonts";
#root, body, html {
height: 100%;

View file

@ -154,7 +154,7 @@ export default {
// See docs/implementation.md#print-link-shape-for-personal-items.
printLinkFor(item) {
if (!item.owner) return null
return {path: '/print', query: {kind: 'item', userHandle: item.owner, id: item.id}}
return {path: '/print', query: {kind: 'item', userHandle: item.owner, item: item.id}}
},
},
async mounted() {

View file

@ -51,7 +51,7 @@
Delete
</button>
<button v-if="canPrint" class="btn btn-secondary"
@click="$router.push({path: '/print', query: {kind: 'item', userHandle: decodedHandle, id}})">
@click="$router.push({path: '/print', query: {kind: 'item', userHandle: decodedHandle, item: id}})">
<b-icon-printer></b-icon-printer>
Print label
</button>

View file

@ -57,7 +57,7 @@
</div>
<div class="card-body">
<p class="text-muted small mb-3">
Nine pixel/bitmap-style fonts found in frontend/public, rendered live from the "Text"
Ten pixel/bitmap-style fonts found in frontend/public, rendered live from the "Text"
field above at a range of sizes so they can be judged the same way Tom Thumb/Silkscreen
were. <strong>CodersCrux</strong> and <strong>712Serif</strong> fail to load as web
fonts at all - Chromium's OTS sanitizer rejects their cmap table - so their cells below
@ -86,7 +86,7 @@
<h5 class="card-title mb-0">Label preview</h5>
</div>
<div class="card-body">
<div class="label-preview mb-3" v-show="selectedContent">
<div class="label-preview mb-3" v-show="fallbackReady">
<canvas ref="fallbackCanvas"></canvas>
</div>
@ -144,7 +144,7 @@
Connect a printer to preview and print a label.
</p>
<template v-else>
<div class="preview-row mb-3" v-show="selectedContent">
<div class="preview-row mb-3" v-show="labelBitmap">
<div class="ruler-v">
<div class="ruler-v-corner"></div>
<div class="ruler ruler-v-ticks"
@ -306,6 +306,12 @@ export default {
prefill: {
type: Object,
default: null
},
// Every query param other than `kind` (router.js), so any base var - e.g. ?text=... - can
// be set directly without needing a `kind` builder in label-layouts.js.
queryFields: {
type: Object,
default: () => ({})
}
},
data() {
@ -327,13 +333,14 @@ export default {
// Scan-reliability messages from label.js's drawLabel/drawFallbackLabel (too few px per QR module, or too small in mm) - the label still rendered/prints fine, just flagged as a risk.
warnings: [],
// One input per BASE_VARS entry; derived vars (userHandle/itemUrl/itemHandle) are calculated-only (see the `fields` computed), never stored here. Prefilled from query params but left editable.
// One input per BASE_VARS entry; derived vars (userHandle/itemUrl/itemHandle) are calculated-only (see the `fields` computed), never stored here. Prefilled from query params but left editable. queryFields is applied last so an explicit ?text=... etc. always wins over a `kind` builder's computed value.
varValues: {
...Object.fromEntries(BASE_VARS.map(v => [v, ""])),
text: buildLabelContent(this.prefill),
// Defaults to this page's own origin; editable since any frontend can resolve any handle, so a label needn't point back at this one.
webdomain: window.location.origin,
...buildLabelFields(this.prefill),
...Object.fromEntries(BASE_VARS.filter(v => v in this.queryFields).map(v => [v, this.queryFields[v]])),
},
copies: 1,
selectedTemplate: LABEL_TEMPLATES[0].id,
@ -347,7 +354,7 @@ export default {
brotherCliExample: "brother_ql --model QL-000 --printer usb://0000:0000 print --label 00 label.png",
niimbotCliExample: "niimprint --model b00 --conn usb print --density 3 --image label.png",
// Nine pixel/bitmap font candidates for legibility testing only (see the disabled card above); not used by label.js's real PIXEL_FONT_TIERS.
// Ten pixel/bitmap font candidates for legibility testing only (see the disabled card above); not used by label.js's real FONT_TIERS.
candidateFonts: [
{family: "Pixelon"},
{family: "Pixelbasel"},
@ -358,6 +365,7 @@ export default {
{family: "6pxExpert"},
{family: "Jersey10"},
{family: "Jersey15"},
{family: "Terminus"},
],
candidateSizes: [5, 6, 7, 8, 10, 12, 16, 20],
candidateZoom: 4,
@ -395,9 +403,6 @@ export default {
currentTemplate() {
return LABEL_TEMPLATES.find(t => t.id === this.selectedTemplate) || LABEL_TEMPLATES[0];
},
selectedContent() {
return templateContent(this.currentTemplate, this.fields);
},
deviceRows() {
if (!this.blob) {
return [];
@ -625,8 +630,7 @@ export default {
redraw() {
this.labelBitmap = null;
const content = this.selectedContent;
if (!this.tape || !content) {
if (!this.tape) {
return;
}
const canvas = this.$refs.labelCanvas;
@ -636,6 +640,7 @@ export default {
this.resizeObserver.observe(canvas.parentElement);
let textSizesPx, warnings;
try {
const content = templateContent(this.currentTemplate, this.fields);
({textSizesPx, warnings} = drawLabel(canvas, this.tape, content, this.orientation));
} catch (e) {
this.error = e.message;
@ -653,10 +658,6 @@ export default {
redrawFallback() {
this.fallbackReady = false;
const content = this.selectedContent;
if (!content) {
return;
}
const canvas = this.$refs.fallbackCanvas;
if (!canvas) {
return;
@ -664,6 +665,7 @@ export default {
this.resizeObserver.observe(canvas.parentElement);
let warnings;
try {
const content = templateContent(this.currentTemplate, this.fields);
({warnings} = drawFallbackLabel(canvas, content, this.orientation));
} catch (e) {
this.error = e.message;
@ -739,7 +741,7 @@ export default {
}
},
// Renders the "Text" field at exact size/family with no layout snapping, so the font itself is judged; ink-centered vertically (like label.js's drawTextLeaf) so unreliable metrics don't clip.
// Renders the "Text" field at exact size/family with no layout snapping, so the font itself is judged; ink-centered vertically (like label.js's drawTextLeaf) so unreliable metrics don't clip. Thresholded through canvasToBitmap/bitmapToCanvas afterwards, same as redraw()'s real label preview, so a candidate is judged on the same 1-bit dots the printer would actually fire, not the anti-aliased canvas render.
drawCandidateCell(canvas, family, fontPx) {
if (!canvas) {
return;
@ -758,6 +760,7 @@ export default {
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#000";
ctx.fillText(text, 2, (canvas.height + up - down) / 2);
bitmapToCanvas(canvas, canvasToBitmap(canvas));
canvas.style.width = `${canvas.width * this.candidateZoom}px`;
canvas.style.height = `${canvas.height * this.candidateZoom}px`;
},

View file

@ -136,7 +136,7 @@ export default {
// Routes to Print.vue with this location's raw identity, same shape as Inventory.vue's
// printLinkFor. See docs/implementation.md#print-link-shape-for-storage-locations.
printLinkFor(location) {
return {path: '/print', query: {kind: 'storage-location', userHandle: location.owner, id: location.id}}
return {path: '/print', query: {kind: 'storage-location', userHandle: location.owner, location: location.id}}
},
},
async mounted() {