info@altius-group.ch
Froideville, Waadt
DE

A camera that submits like a file input

/ 10 Min. Lesezeit / aktualisiert 04.09.2026

A form field that opens the camera has a problem the rest of the form does not.

<input type="file"> carries its value in a FileList. FileList has no constructor, and input.files is not something you can build by hand — the browser populates it when a user picks a file, and that is the only sanctioned path. Meanwhile getUserMedia, canvas.toBlob and MediaRecorder all hand you a Blob, which is not a FileList and cannot be turned into one by any obvious means.

So the usual answer is to give up on the file input: base64 the blob into a hidden text field, or POST it to a bespoke endpoint, and special-case it everywhere downstream.

PaperPoint does not, and the whole reason is four lines that appear four times in its client code.

The one constructible FileList

canvas.toBlob(function(blob) {
    const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
    const file = new File([blob], `photo-${timestamp}.jpg`, { type: 'image/jpeg' });

    const input = document.getElementById(`field_${fieldId}`);
    if (input) {
        const dataTransfer = new DataTransfer();
        dataTransfer.items.add(file);
        input.files = dataTransfer.files;

        // Trigger change event
        input.dispatchEvent(new Event('change', { bubbles: true }));
    }

}, 'image/jpeg', 0.8);

DataTransfer exists for drag-and-drop. Its files property is a genuine FileList, it is constructible, and input.files accepts one. That is the entire trick, and it is the only supported way to put a programmatically created file into a file input.

What it buys is worth more than the four lines suggest. After that assignment, the field is an ordinary file input containing a file. The form submits as multipart/form-data. The server sees request.FILES. Django’s FileExtensionValidator runs. The size checks run. The storage backend writes it. None of the upload path knows a camera was involved.

Compare the alternative: a base64 string in a hidden input means a custom parser on the server, a different size limit (base64 inflates by a third), a different validation path, a different storage call, and a second set of bugs. Every one of those is a place where the camera path and the upload path can diverge — and they will, because only one of them gets exercised by the developer’s own testing.

There is a second dividend, which is that the form still works with JavaScript disabled or broken. The <input type="file"> is really there; the camera button is an enhancement layered on top. A person whose browser refuses camera permission can still attach a photo from their gallery, through the same field, into the same column.

The dispatch that is not optional

input.dispatchEvent(new Event('change', { bubbles: true }));

Assigning to input.files does not fire change. The event only fires for user interaction, so every listener attached to that field — the preview thumbnail, the conditional-logic engine that shows a follow-up question once a photo exists, the dirty-form guard, the collaborative session’s debounced save — sees nothing at all.

bubbles: true matters just as much. Delegated listeners bound on the form or on document are the common pattern in this codebase, and a non-bubbling event reaches none of them. The default for a constructed Event is bubbles: false, so it has to be said explicitly, and the failure it prevents is the worst kind: the value is correct, the submission works, and only the interactive behaviour around it is silently dead.

Three devices, three producers, one destination

The photo path goes through a canvas:

const context = canvas.getContext('2d');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
context.drawImage(video, 0, 0);

Setting the canvas to videoWidth / videoHeight rather than the element’s CSS size is the detail that decides whether you capture the sensor’s resolution or the size of the preview box on screen. The video element might be 320 pixels wide in the layout while the stream is 1280 — and drawImage at the wrong canvas size crops or downsamples silently.

The audio and video paths go through MediaRecorder:

const mediaRecorder = new MediaRecorder(stream);
const chunks = [];

mediaRecorder.ondataavailable = (event) => {
    chunks.push(event.data);
};

mediaRecorder.onstop = () => {
    const blob = new Blob(chunks, { type: 'video/webm' });
    const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
    const file = new File([blob], `recording-${timestamp}.webm`, { type: 'video/webm' });

    const input = document.getElementById(`field_${fieldId}`);
    if (input) {
        const dataTransfer = new DataTransfer();
        dataTransfer.items.add(file);
        input.files = dataTransfer.files;
        input.dispatchEvent(new Event('change', { bubbles: true }));
    }

    showVideoPreview(file, fieldId);
};

Assembling in onstop rather than in ondataavailable is right: the recorder may emit one chunk or many depending on whether a timeslice was requested and on what the browser feels like doing, and only onstop guarantees you have all of them.

The audio path is the same code with audio/webm. Three producers — canvas, recorder, recorder — and one identical five-line landing.

That repetition is worth noticing, because the newer widget layer has already extracted it:

createFile(blob, filename, mimeType) {
    const file = new File([blob], filename, { type: mimeType });
    const dataTransfer = new DataTransfer();
    dataTransfer.items.add(file);

    if (this.fileInput) {
        this.fileInput.files = dataTransfer.files;
        this.fileInput.dispatchEvent(new Event('change', { bubbles: true }));
    }

    return file;
}

One method, on the widget base class, doing exactly what the three copies do. The older media-handler.js predates it and has not been migrated. That is a straightforward piece of tidying with a real payoff: the bubbles: true and the dispatchEvent are precisely the kind of detail that gets dropped when a fourth copy is written, and a single createFile means it cannot be.

Filenames that survive the round trip

const timestamp = new Date().toISOString().replace(/[:.]/g, '-');

toISOString() produces 2026-09-11T14:32:07.412Z. The colons and the dot are both hostile: colons are illegal in Windows filenames and are the separator in Content-Disposition headers, and a dot before the extension confuses anything that splits on the last one — or the first one.

Replacing both with hyphens gives 2026-09-11T14-32-07-412Z, which is sortable, readable, unique enough for one device, and safe everywhere it is about to travel. The server renames it anyway — PaperPoint stores uploads under a UUID-prefixed sanitised path — but the original name is kept alongside, and a name that arrives already clean is one less thing the sanitiser has to mangle.

}, 'image/jpeg', 0.8);

Quality 0.8 is the right default for photographs of documents and sites: the difference from 0.95 is invisible on a phone screen and roughly halves the bytes, which matters when the upload is happening over a mobile connection behind a building.

Assignment replaces; it never appends

One property of the trick deserves to be stated plainly, because it is invisible until it bites:

input.files = dataTransfer.files;

That replaces the input’s entire list. The DataTransfer was built empty and had one file added to it, so after the assignment the input holds exactly one file — whatever was there before is gone.

For a single-value field that is exactly right and there is nothing to think about. For a multi-value one it is a silent data loss: take three photos in a row and the field holds the third.

Accumulating requires seeding the DataTransfer with what is already there before adding the new capture — iterate input.files, items.add each one, then add the new File, then assign. Four extra lines, and they only matter for the field types that are declared multi-value.

The server already knows which those are:

if field_obj.field_type in MULTIVALUE_FILE_FIELD_TYPES:
    existing = data.get(field_name)
    if not isinstance(existing, list):
        existing = [existing] if existing else []
    existing.append(str(form_file.id))
    data[field_name] = existing
else:
    data[field_name] = str(form_file.id)

files, images and multi_image_camera append; everything else overwrites. Which means the append-versus-replace decision is made twice — once in Python against a field type, and once in JavaScript by the absence of any code at all. Two halves of one rule, and only one of them is written down. When the client grows a multi-capture flow, that is the line it has to match.

The hardware is a singleton, and it does not release instantly

This is the part of the module I would keep verbatim:

MediaFieldHandler.acquireStream = async function(widgetId, constraints) {
    // 1. Stop all existing media streams (central + widget-managed)
    window.stopAllMediaStreams();

    // 2. Wait for browser to fully release hardware (critical on mobile)
    await new Promise(function(resolve) { setTimeout(resolve, 300); });

    // 3. Acquire new stream
    var stream = await navigator.mediaDevices.getUserMedia(constraints);

    // 4. Track the active stream so stopAllMediaStreams can release it
    MediaFieldHandler._activeWidget = widgetId;
    MediaFieldHandler._activeStream = stream;

    return stream;
};

A form can hold several media fields. A person fills in the photo field, then scrolls down to the audio field. On desktop, opening the second stream while the first is still live mostly works. On mobile it does not: the camera and the microphone are exclusive resources, and getUserMedia fails — usually with NotReadableError, sometimes by returning a stream with no frames, which is worse because nothing throws.

Stopping everything first is obvious. The 300 ms wait is not, and it is the line that makes the difference. track.stop() returns synchronously, but the operating system’s release of the underlying device is asynchronous and finishes some milliseconds later. Requesting the camera in the same tick as releasing it is a race that a phone loses.

Three hundred milliseconds is a magic number, and the comment says what it is for — critical on mobile — which is the minimum a magic number needs. It is imperceptible to a user who has just tapped a button, and it removes a failure that reproduces on exactly the devices the developer does not have on their desk.

Cleanup is wired to the page lifecycle, not just to buttons:

// Set up cleanup on page unload
window.addEventListener('beforeunload', () => this.cleanup());
cleanup: function() {
    Object.keys(this.cameraStreams).forEach(fieldId => { this.stopCamera(fieldId); });
    Object.keys(this.videoStreams).forEach(fieldId => { this.stopVideoRecording(fieldId); });
    Object.keys(this.audioStreams).forEach(fieldId => { this.stopAudioRecording(fieldId); });
}

The visible symptom of getting this wrong is a camera indicator light that stays on after the form is submitted — which is, correctly, the thing users report as a privacy problem rather than as a bug.

Two sets of books

The one thing here I would change is that stream ownership is tracked twice.

There are the per-field maps — cameraStreams[fieldId], videoStreams[fieldId], audioStreams[fieldId] — populated at the call sites:

if (video) {
    video.srcObject = stream;
    MediaFieldHandler.cameraStreams[fieldId] = stream;
}

And there is the central pair, _activeStream / _activeWidget, populated inside acquireStream.

Both are needed today: stopCamera(fieldId) reads the map, stopAllMediaStreams() reads the central one. But they can disagree — a stream acquired and then not stored in the map because the video element was missing is tracked centrally and not per-field, and stopCamera would then not stop it.

Given that the hardware genuinely is a singleton, the central pair is the truer model, and the maps are a legacy of a design where several streams could coexist. Collapsing to one active stream plus its owning field id would remove a class of leak that is hard to reproduce and easy to introduce.

The other rough edge is the failure path:

} catch (err) {
    console.error('Camera access denied:', err);
    alert('Camera access denied or not available. Please use the upload option instead.');
    showUploadArea(fieldId);
}

Falling back to the upload area is exactly right — the field degrades to the plain file input it was built on top of, which is the payoff of the whole approach. The alert() is not: it is modal, unstyled, untranslated in an application that ships in four languages, and it blocks the page on a device where the user has just been shown a permission dialog. The same message in the field’s own status area would say the same thing without stopping the world.

What carries over

  • DataTransfer is the only constructible FileList. Build a File, add it to a DataTransfer, assign dataTransfer.files to input.files. Then the rest of your stack does not need to know.
  • Keep the real <input type="file">. Every byte that reaches the server through the same path as an ordinary upload is a byte you do not have to validate, size-check and store twice.
  • Assigning files does not fire change — dispatch it, with bubbles: true. The value will be right and every listener will be dead.
  • Size the canvas to videoWidth, not to the element. Otherwise you capture the preview box.
  • Media hardware is exclusive and releases asynchronously. Stop everything, wait, then acquire — and put the reason in the comment, because the delay looks like superstition.
  • Assigning files replaces the list. Seed the DataTransfer from input.files first if the field is meant to accumulate — and check that the client’s rule matches the server’s.
  • Degrade to the underlying control on permission failure. The plain file input is still there; that is the whole point of building on it.
Bereit loszulegen?

Sprechen wir über Ihr Projekt

Erzählen Sie uns von Ihrem Bedarf in IoT, GIS oder individueller Entwicklung — wir melden uns innerhalb von 24 Stunden.