From ad8c0fc15f46759301a95d86a0a0667bd3740ca1 Mon Sep 17 00:00:00 2001 From: Caner Altinbasak Date: Fri, 10 Apr 2026 11:03:12 +0100 Subject: [PATCH 1/2] feat(OS-20908): Add large-file-download example Demonstrates downloading large files (multi-GB) to SD card on memory-constrained BrightSign players using Node.js streams with TCP-level backpressure via roHtmlWidget. Avoids OOM by never buffering more than ~48 KB regardless of file size. --- examples/README.md | 5 + examples/large-file-download/README.md | 57 ++++++ examples/large-file-download/autorun.brs | 17 ++ examples/large-file-download/index.html | 212 +++++++++++++++++++++++ 4 files changed, 291 insertions(+) create mode 100644 examples/large-file-download/README.md create mode 100644 examples/large-file-download/autorun.brs create mode 100644 examples/large-file-download/index.html diff --git a/examples/README.md b/examples/README.md index e3aef43..9a2ee5d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -23,6 +23,11 @@ Note that some starter examples include the creation of a `brightsign-dumps` fol - **Location**: `examples/local-storage` - **Features**: Image slideshow that caches images in browser localStorage for persistent, smooth playback and looping. +#### Large File Download Example + +- **Location**: `examples/large-file-download` +- **Features**: Downloads large files (multi-GB) to SD card on memory-constrained players without OOM or UI blocking. Uses Node.js streams with TCP-level backpressure via `roHtmlWidget`. + ### Node.js Examples #### Node Starter Example diff --git a/examples/large-file-download/README.md b/examples/large-file-download/README.md new file mode 100644 index 0000000..9e0bf98 --- /dev/null +++ b/examples/large-file-download/README.md @@ -0,0 +1,57 @@ +# Large File Download Example + +## Introduction + +This example demonstrates how to download a large file (multi-GB) to an SD card on a memory-constrained BrightSign player without running out of memory or blocking the UI. + +The application is defined in `index.html` which uses Node.js streams via `roHtmlWidget` to download a file with true TCP-level backpressure. There is an `autorun.brs` file which tells the player to run the application. + +## Why Not Use `fetch()`? + +The browser's Fetch API does not propagate backpressure to the network layer. When downloading from a fast network connection to a slow SD card, `fetch()` will buffer the entire response body in memory — causing an OOM kill on devices with limited RAM (500 MB or less). + +This example uses Node's `http`/`https` modules with `stream.pipeline()` instead. When the SD card's write buffer is full, backpressure propagates all the way back to the TCP socket, causing the sender to slow down. Memory usage stays bounded to roughly 48 KB regardless of file size. + +## How It Works + +1. **Node.js HTTP request**: Uses `http.get()` / `https.get()` to initiate the download, which returns a native Node.js readable stream +2. **Metering Transform stream**: A passthrough `Transform` stream counts downloaded bytes for progress reporting +3. **`stream.pipeline()`**: Wires the response stream → meter → file write stream with automatic backpressure and error teardown +4. **Decoupled UI rendering**: A `requestAnimationFrame` loop updates progress, a CSS progress bar, and an FPS counter independently of the download — the main thread is never blocked +5. **Redirect handling**: Follows HTTP 3xx redirects since Node's `http.get` does not do this automatically + +## Prerequisites + +1. **Network connection**: The BrightSign player should be connected to a network with access to the file server. +2. **SD card**: The destination SD card should have enough free space for the downloaded file. + +## Configuration + +Edit the constants at the top of the ` + + From 824a06f3f7526396a4185a062826ffb762e14a5a Mon Sep 17 00:00:00 2001 From: Caner Altinbasak Date: Fri, 10 Apr 2026 13:46:33 +0100 Subject: [PATCH 2/2] Address review comments on large-file-download example - Replace internal hostname with empty placeholder URL - Fix buffer size comment (48 KB not 64 KB) and align with README - Resolve relative redirect Location headers, add redirect limit - Use consistent units in progress display (auto-switch MB/GB) - Use roVideoMode for display resolution instead of hardcoded 1080p - Use file:///sd:/index.html to match other examples - Remove unnecessary websecurity: False --- examples/large-file-download/README.md | 2 +- examples/large-file-download/autorun.brs | 16 +++++++++---- examples/large-file-download/index.html | 30 ++++++++++++++---------- 3 files changed, 30 insertions(+), 18 deletions(-) diff --git a/examples/large-file-download/README.md b/examples/large-file-download/README.md index 9e0bf98..37a5afb 100644 --- a/examples/large-file-download/README.md +++ b/examples/large-file-download/README.md @@ -10,7 +10,7 @@ The application is defined in `index.html` which uses Node.js streams via `roHtm The browser's Fetch API does not propagate backpressure to the network layer. When downloading from a fast network connection to a slow SD card, `fetch()` will buffer the entire response body in memory — causing an OOM kill on devices with limited RAM (500 MB or less). -This example uses Node's `http`/`https` modules with `stream.pipeline()` instead. When the SD card's write buffer is full, backpressure propagates all the way back to the TCP socket, causing the sender to slow down. Memory usage stays bounded to roughly 48 KB regardless of file size. +This example uses Node's `http`/`https` modules with `stream.pipeline()` instead. When the SD card's write buffer is full, backpressure propagates all the way back to the TCP socket, causing the sender to slow down. Memory usage stays bounded to roughly 48 KB (three stream buffers at 16 KB each) regardless of file size. ## How It Works diff --git a/examples/large-file-download/autorun.brs b/examples/large-file-download/autorun.brs index 1a506a2..1f6cf15 100644 --- a/examples/large-file-download/autorun.brs +++ b/examples/large-file-download/autorun.brs @@ -1,14 +1,20 @@ Sub Main() + width = 1920 + height = 1080 + vidmode = CreateObject("roVideoMode") + if type(vidmode) = "roVideoMode" + width = vidmode.GetResX() + height = vidmode.GetResY() + end if + htmlConfig = { mouse_enabled: True, nodejs_enabled: True, brightsign_js_objects_enabled: True, - security_params: { - websecurity: False, - }, - url: "file:///index.html" + url: "file:///sd:/index.html" } - h = createobject("rohtmlwidget", createobject("rorectangle", 0, 0, 1920, 1080), htmlConfig) + rect = CreateObject("roRectangle", 0, 0, width, height) + h = CreateObject("roHtmlWidget", rect, htmlConfig) h.show() while true sleep(1000) diff --git a/examples/large-file-download/index.html b/examples/large-file-download/index.html index e01ef9d..f0ee602 100644 --- a/examples/large-file-download/index.html +++ b/examples/large-file-download/index.html @@ -68,9 +68,10 @@ const fs = require("fs"); const { pipeline, Transform } = require("stream"); - const VIDEO_URL = "http://repton/Media/8k/8K%20greaterthan%205gb.mp4"; // REPLACE WITH YOUR VIDEO URL - const OUTPUT_FILE = "/storage/sd/largevideo.mp4"; // REPLACE WITH YOUR OUTPUT PATH - // Keep buffers small — ~64 KB total across the three streams. + const VIDEO_URL = ""; // REPLACE WITH A PUBLIC OR OTHERWISE REACHABLE FILE URL + const OUTPUT_FILE = ""; // REPLACE WITH YOUR OUTPUT PATH, e.g. "/storage/sd/largevideo.mp4" + const MAX_REDIRECTS = 10; + // Keep buffers small — ~48 KB total across the three streams. const HWM = 16 * 1024; const progressDiv = document.getElementById("progress"); @@ -84,11 +85,9 @@ let finished = false; let errorMsg = null; - function fmtMB(b) { - return (b / 1048576).toFixed(2); - } - function fmtGB(b) { - return (b / 1073741824).toFixed(2); + function fmtSize(b) { + if (b >= 1073741824) return (b / 1073741824).toFixed(2) + " GB"; + return (b / 1048576).toFixed(2) + " MB"; } // FPS measurement state @@ -117,7 +116,7 @@ progressDiv.textContent = "100%"; barFill.style.width = "100%"; statusDiv.textContent = - "Complete: " + fmtMB(downloadedBytes) + " MB"; + "Complete: " + fmtSize(downloadedBytes); spinnerEl.style.display = "none"; return; } @@ -128,7 +127,7 @@ barFill.style.width = pct + "%"; if (totalBytes) { statusDiv.textContent = - fmtMB(downloadedBytes) + " / " + fmtGB(totalBytes) + " GB"; + fmtSize(downloadedBytes) + " / " + fmtSize(totalBytes); } requestAnimationFrame(render); } @@ -139,7 +138,8 @@ function download() { requestAnimationFrame(render); - function doRequest(url) { + function doRequest(url, redirectCount) { + redirectCount = redirectCount || 0; var parsed = new URL(url); var mod = parsed.protocol === "https:" @@ -155,7 +155,13 @@ res.headers.location ) { res.resume(); - doRequest(res.headers.location); + if (redirectCount >= MAX_REDIRECTS) { + errorMsg = "Too many redirects"; + return; + } + // Resolve relative Location headers against the current URL. + var next = new URL(res.headers.location, url).href; + doRequest(next, redirectCount + 1); return; }