Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions examples/large-file-download/README.md
Original file line number Diff line number Diff line change
@@ -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 (three stream buffers at 16 KB each) 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 `<script>` section in `index.html`:

- `VIDEO_URL`: The URL of the file to download
- `OUTPUT_FILE`: The destination path on the SD card (e.g. `/storage/sd/largevideo.mp4`)

## Steps to Set Up and Run the Application

### 1. Manual Transfer

1. Copy the `autorun.brs` and `index.html` files to the root of the SD card.
2. Insert the SD card into the player.
3. Reboot the player.

### 2. Automated Transfer Using BrightSign CLI for DWS

Configure the CLI by choosing a name for your player and passing your player's information:

`bsc local player --add --player playerName --ip ip-address --user username --pass password --storage sd`

Push files to the player:

`bsc local file --upload --player playerName --file ./index.html --destination sd/`
`bsc local file --upload --player playerName --file ./autorun.brs --destination sd/`

## Troubleshooting

- **No progress shown**: Verify the player is connected to the network and the `VIDEO_URL` is reachable from the player.
- **Slow download speed**: The download speed is limited by the slower of the network connection or the SD card write speed. This is by design — buffering faster than the SD card can write would consume memory.
23 changes: 23 additions & 0 deletions examples/large-file-download/autorun.brs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
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,
url: "file:///sd:/index.html"
}
rect = CreateObject("roRectangle", 0, 0, width, height)
h = CreateObject("roHtmlWidget", rect, htmlConfig)
h.show()
while true
sleep(1000)
end while
End Sub

218 changes: 218 additions & 0 deletions examples/large-file-download/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>Download</title>
<style>
* {
margin: 0;
padding: 0;
}
body {
font-family: monospace;
background: #000;
color: #0f0;
}
#progress {
padding: 10px;
font-size: 24px;
}
#status {
padding: 10px;
font-size: 14px;
color: #888;
}
#fps {
padding: 10px;
font-size: 14px;
color: #ff0;
}
#bar-track {
margin: 10px;
height: 20px;
background: #222;
border-radius: 4px;
overflow: hidden;
}
#bar-fill {
height: 100%;
width: 0%;
background: #0f0;
transition: width 0.3s linear;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
#spinner {
display: inline-block;
width: 18px;
height: 18px;
margin: 10px;
border: 3px solid #333;
border-top-color: #0f0;
border-radius: 50%;
animation: spin 1s linear infinite;
vertical-align: middle;
}
</style>
</head>
<body>
<div><span id="spinner"></span><span id="progress">0%</span></div>
<div id="bar-track"><div id="bar-fill"></div></div>
<div id="status">Initializing...</div>
<div id="fps">FPS: --</div>
<script>
(function () {
"use strict";

const fs = require("fs");
const { pipeline, Transform } = require("stream");

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");
const statusDiv = document.getElementById("status");
const fpsDiv = document.getElementById("fps");
const barFill = document.getElementById("bar-fill");
const spinnerEl = document.getElementById("spinner");

let totalBytes = 0;
let downloadedBytes = 0;
let finished = false;
let errorMsg = null;

function fmtSize(b) {
if (b >= 1073741824) return (b / 1073741824).toFixed(2) + " GB";
return (b / 1048576).toFixed(2) + " MB";
}

// FPS measurement state
let frameCount = 0;
let fpsLastTime = performance.now();
let currentFps = 0;

// Decoupled render loop — runs at display frame rate, never blocks I/O.
function render(now) {
frameCount++;
if (now - fpsLastTime >= 1000) {
currentFps = frameCount;
frameCount = 0;
fpsLastTime = now;
}
fpsDiv.textContent = "FPS: " + currentFps;

if (errorMsg !== null) {
progressDiv.textContent = "ERROR";
statusDiv.textContent = errorMsg;
barFill.style.background = "#f00";
spinnerEl.style.animationPlayState = "paused";
return;
}
if (finished) {
progressDiv.textContent = "100%";
barFill.style.width = "100%";
statusDiv.textContent =
"Complete: " + fmtSize(downloadedBytes);
spinnerEl.style.display = "none";
return;
}
var pct = totalBytes
? Math.floor((downloadedBytes / totalBytes) * 100)
: 0;
progressDiv.textContent = pct + "%";
barFill.style.width = pct + "%";
if (totalBytes) {
statusDiv.textContent =
fmtSize(downloadedBytes) + " / " + fmtSize(totalBytes);
}
Comment thread
caneraltinbasak marked this conversation as resolved.
requestAnimationFrame(render);
}

// Use Node's http/https — unlike fetch(), Node streams support
// real TCP-level backpressure so the sender slows down when the
// SD card can't keep up. No data accumulates in memory.
function download() {
requestAnimationFrame(render);

function doRequest(url, redirectCount) {
redirectCount = redirectCount || 0;
var parsed = new URL(url);
var mod =
parsed.protocol === "https:"
? require("https")
: require("http");

mod
.get(url, function (res) {
// Follow redirects (301/302/307/308)
if (
res.statusCode >= 300 &&
res.statusCode < 400 &&
res.headers.location
) {
res.resume();
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;
Comment thread
caneraltinbasak marked this conversation as resolved.
}

if (res.statusCode !== 200) {
res.resume();
errorMsg = "HTTP " + res.statusCode;
return;
}

totalBytes =
parseInt(res.headers["content-length"], 10) || 0;

// A tiny Transform that only counts bytes — zero-copy
// passthrough, adds no buffering beyond its highWaterMark.
var meter = new Transform({
highWaterMark: HWM,
transform: function (chunk, _enc, cb) {
downloadedBytes += chunk.length;
cb(null, chunk);
},
});

var out = fs.createWriteStream(OUTPUT_FILE, {
highWaterMark: HWM,
});

// pipeline wires up backpressure (res ← meter ← out)
// and tears down every stream on error or completion.
pipeline(res, meter, out, function (err) {
if (err) {
errorMsg = err.message;
console.error(err);
} else {
finished = true;
console.log(
"Download complete: " + downloadedBytes + " bytes"
);
}
});
})
.on("error", function (err) {
errorMsg = err.message;
console.error(err);
});
}

doRequest(VIDEO_URL);
}

download();
})();
</script>
</body>
</html>
Loading