-
Notifications
You must be signed in to change notification settings - Fork 6
Add large-file-download example OS-20908 #123
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
| 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; | ||
|
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> | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.