Unable to display a video window on my dashboard

I am using websockets for the comm interface which is working fine if I use the video_preview node ( node-red-contrib-image-output) . In fact it looks quite good, but I really want the window on my dashboard.

Google suggested a template node and I have set the Type to Widget (group-Scoped), Group to to Group 1, Size to 14x13 and the following script:

<> <div style="text-align: center;">
>     <!-- Image element that acts as our video canvas -->
>     <img id="videoStream" src="" style="width: 100%; height: auto; background-color: #000;" />
> </div>
> 
> <script>
>     (function(scope) {
>         // Watch for incoming messages from the Node-RED flow
>         scope.$watch('msg', function(msg) {
>             if (msg && msg.payload) {
>                 // Update the image source dynamically with the Base64 data
>                 var img = document.getElementById('videoStream');
>                 img.src = 'data:image/jpeg;base64,' + msg.payload;
>             }
>         });
>     })(scope);
> </script>

The large size was suggested but all it did was to make the window bigger, but no video.
Any help is appreciated.

Thanks
Mark

Welcome to the forum.

Which dashboard are you using? The old node-red-dashboard or the newer @flowfuse/node-red-dashboard? The script you posted is for the old one. Look in manage palette to find which one.

Yes it is the flowfuse version. I did a search and found another script but it now shows black with a white rectangle on the dashboard.

That is why it is not working, the template you posted is for the old dashboard.

What exactly are you trying to do? Display an image on the dashboard or a video stream?

The goal is to have a video window streaming from a small robot along with status and maybe some control groups. The source is captured JPEG using opencv and converted to BASE64. The image size is 640x480, but that could change.
The template and group node Size setting is confusing as I don't know how to relate each of the selection blocks to pixels.
I am quite new to Node-Red so right now is quite a learning process.
Here is the script that I am now using:

template>
    <div class="video-container">
        <!-- The image element that will render our live stream -->
        <img :src="imageSrc" alt="Live Video Feed" class="video-feed" />
    </div>
</template>

<script>
    export default {
    data() {
        return {
            imageSrc: '' // Holds the current frame URL
        };
    },
    watch: {
        // Watch for incoming messages from Node-RED
        msg: function (newMsg) {
            if (newMsg && newMsg.topic === 'video-frame') {
                // Scenario A: If your data arrives as a Base64 string
                if (typeof newMsg.payload === 'string') {
                    this.imageSrc = 'data:image/jpeg;base64,' + newMsg.payload;
                } 
                // Scenario B: If your data arrives as a binary array/buffer
                else if (newMsg.payload instanceof ArrayBuffer || ArrayBuffer.isView(newMsg.payload)) {
                    const blob = new Blob([newMsg.payload], { type: 'image/jpeg' });
                    
                    // Revoke old URL to clear browser memory leaks
                    if (this.imageSrc.startsWith('blob:')) {
                        URL.revokeObjectURL(this.imageSrc);
                    }
                    this.imageSrc = URL.createObjectURL(blob);
                }
            }
        }
    }
}
</script>

<style scoped>
    .video-container {
        display: flex;
        justify-content: center;
        align-items: center;
        width: 100%;
        height: 100%;
        background-color: #000;
    }

    .video-feed {
        max-width: 100%;
        max-height: 100%;
        object-fit: contain;
    }
</style>


<style scoped>
    .video-container {
        display: flex;
        justify-content: center;
        align-items: center;
        width: 100%;
        height: 100%;
        background-color: #000;
    }

What format is the stream? Can you not display that directly?

I'm not sure that I follow. What do you mean by streaming directly? As for the format, it is JPEG with BASE64 encoding. The image preview node worked with the previous script which I guess shouldn't be surprising, but it only displayed in the node edit window.

What is the original source of the video and how does it get to opencv?

[Edit] if you want to show video why are you converting it to jpeg images?

The source is an Arducam uvc camera. I chose converting to JPEG and then to Base64 as a string to be sent through web sockets as the simplest approach with low bandwidth, although not necessarily the most efficient.That is OK for my needs.
Here is the Python worker thread:

def websocket_worker(self, uri):
        print("websocket_worker starting...")
        self.ws_thread_running = True

        # 0 is usually the default UVC camera
        cap = cv2.VideoCapture(0)
        
        # Set resolution
        cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
        cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)

    #    try:
        while True:
            ret, frame = cap.read()
            if not ret:
                print(f'ret: {ret}')
                break
            # Encode frame to JPEG
            _, buffer = cv2.imencode('.jpg', frame, [int(cv2.IMWRITE_JPEG_QUALITY), 50])
            # Convert to Base64
            jpg_as_text = base64.b64encode(buffer).decode('utf-8')
            # Send over websocket
            self.websocket.send(jpg_as_text)

            # Control framerate (~10-15 fps is ideal for WebSockets)
            time.sleep(0.05)

So are you planning to send the video as a sequence of jpeg images? Why can't you access the original video via http, for example.

Well I have to answer that with a question. Why can't I display a sequence of JPEG images given the template script clearly supports JPEG with Base64 encoding? Either there is something wrong with the script or the way a video is displayed in the dashboard.

What do you see in the browser developer tools if you inspect the image?

[Edit] Also, feed the message that you are sending to the template node, containing the image, into a debug node and show us what is there.

Debug output:
7/27/2026, 12:38:52 PMnode: debug 2
msg.payload : string[53752]
"/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDABALDA4MChAODQ4SERATGCgaGBYWGDEjJR0oOjM9PDkzODdA..."
7/27/2026, 12:38:52 PMnode: debug 2
msg.payload : string[53924]
"/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDABALDA4MChAODQ4SERATGCgaGBYWGDEjJR0oOjM9PDkzODdA..."
7/27/2026, 12:38:52 PMnode: debug 2
msg.payload : string[53780]
"/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDABALDA4MChAODQ4SERATGCgaGBYWGDEjJR0oOjM9PDkzODdA..."
7/27/2026, 12:38:52 PMnode: debug 2
msg.payload : string[53788]
"/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDABALDA4MChAODQ4SERATGCgaGBYWGDEjJR0oOjM9PDkzODdA..."
7/27/2026, 12:38:52 PMnode: debug 2
msg.payload : string[53784]

Not sure what you are looking for the dev tools. Here is a photo of the elements as I could not do a copy for whatever reason. Does that tell you anything?

Just an input here to the discussion, I had a rather similar need that we touched upon earlier some 1,5 years ago. I had a template that worked very well in DB-1 one and I wanted it to be migrated to DB-2. With great help from @Steve-Mcl I finally got it working. That template supports more than what you need; live & recorded video of various formats as well as showing images (it does support base64). So maybe this can at least help you moving forward (I will create a small flow to demonstrate how to show images asap)

Here is a simple flow for showing images. Not so fancy but it gives you the idea:

  • The http request works for now but I do not know for how long, you can edit the url as you prefere
  • You can send images as buffers via MQTT
  • I have added a file node to read and show a local image file

Well, you see the rest, just try it out

[
    {
        "id": "99ff9ce1be7b0cb5",
        "type": "mqtt in",
        "z": "f2f18323778993ca",
        "name": "Image",
        "topic": "motion/#",
        "qos": "0",
        "datatype": "auto-detect",
        "broker": "90b02ee78ae15ec3",
        "nl": false,
        "rap": false,
        "inputs": 0,
        "x": 350,
        "y": 1120,
        "wires": [
            [
                "63a080c688704ed5"
            ]
        ]
    },
    {
        "id": "63a080c688704ed5",
        "type": "base64",
        "z": "f2f18323778993ca",
        "name": "Encode",
        "action": "",
        "property": "payload",
        "x": 510,
        "y": 1120,
        "wires": [
            [
                "8cc9831c8070309d"
            ]
        ]
    },
    {
        "id": "5149bda46c22f5b0",
        "type": "inject",
        "z": "f2f18323778993ca",
        "name": "",
        "props": [
            {
                "p": "payload"
            },
            {
                "p": "topic",
                "vt": "str"
            }
        ],
        "repeat": "",
        "crontab": "",
        "once": false,
        "onceDelay": "",
        "topic": "",
        "payload": "data:image/jpg;base64,R0lGODlhEAAQAMQAAORHHOVSKudfOulrSOp3WOyDZu6QdvCchPGolfO0o/XBs/fNwfjZ0frl3/zy7////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAkAABAALAAAAAAQABAAAAVVICSOZGlCQAosJ6mu7fiyZeKqNKToQGDsM8hBADgUXoGAiqhSvp5QAnQKGIgUhwFUYLCVDFCrKUE1lBavAViFIDlTImbKC5Gm2hB0SlBCBMQiB0UjIQA7",
        "payloadType": "str",
        "x": 700,
        "y": 1170,
        "wires": [
            [
                "198f7c3982262ac6"
            ]
        ]
    },
    {
        "id": "198f7c3982262ac6",
        "type": "ui-template",
        "z": "f2f18323778993ca",
        "group": "e09ee07d132abea6",
        "page": "",
        "ui": "",
        "name": "",
        "order": 2,
        "width": "0",
        "height": "0",
        "head": "",
        "format": "<!-- https://vuetifyjs.com/en/components/images/#usage -->\n\n<template>\n    <div>\n        <!-- Image src: {{ info || 'none' }} -->\n        <v-img\n            v-if=\"msg.payload\"\n            :width=\"msg.width || 150\"\n            aspect-ratio=\"msg.aspec || 16/9\"\n            cover\n            :src=\"msg.payload\"\n    ></v-img>\n    </div>\n</template>\n\n<script>\n    export default {\n        computed: {\n            info: function () {\n                if (!this.msg.payload) {\n                    return ''\n                }\n                if (this.msg.payload.length > 80) {\n                    return this.msg.payload.substring(0,77) + \"...\"\n                }\n                return this.msg.payload\n            }\n        }\n    }\n</script>",
        "storeOutMessages": true,
        "passthru": true,
        "resendOnRefresh": true,
        "templateScope": "local",
        "className": "",
        "x": 890,
        "y": 1120,
        "wires": [
            []
        ]
    },
    {
        "id": "8cc9831c8070309d",
        "type": "change",
        "z": "f2f18323778993ca",
        "name": "",
        "rules": [
            {
                "t": "set",
                "p": "payload",
                "pt": "msg",
                "to": "\"data:image/jpg;base64,\" & payload",
                "tot": "jsonata"
            }
        ],
        "action": "",
        "property": "",
        "from": "",
        "to": "",
        "reg": false,
        "x": 700,
        "y": 1120,
        "wires": [
            [
                "198f7c3982262ac6"
            ]
        ]
    },
    {
        "id": "fcfa403fb3fce806",
        "type": "file in",
        "z": "f2f18323778993ca",
        "name": "",
        "filename": "meter.jpg",
        "filenameType": "str",
        "format": "",
        "chunk": false,
        "sendError": false,
        "encoding": "none",
        "allProps": false,
        "x": 350,
        "y": 1190,
        "wires": [
            [
                "63a080c688704ed5"
            ]
        ]
    },
    {
        "id": "8686f031974e9f12",
        "type": "inject",
        "z": "f2f18323778993ca",
        "name": "",
        "props": [
            {
                "p": "payload"
            },
            {
                "p": "topic",
                "vt": "str"
            }
        ],
        "repeat": "",
        "crontab": "",
        "once": false,
        "onceDelay": 0.1,
        "topic": "",
        "payload": "true",
        "payloadType": "bool",
        "x": 190,
        "y": 1190,
        "wires": [
            [
                "fcfa403fb3fce806"
            ]
        ]
    },
    {
        "id": "baaf2660585b63e6",
        "type": "http request",
        "z": "f2f18323778993ca",
        "name": "",
        "method": "GET",
        "ret": "bin",
        "paytoqs": "ignore",
        "url": "https://www.svtstatic.se/image-news/760/cinema/0.45/0.28/c0c81d44e24d9c4eeb96086a835bd305009cd59b6974a655e33724f101ff29b3",
        "tls": "",
        "persist": false,
        "proxy": "",
        "insecureHTTPParser": false,
        "authType": "",
        "senderr": false,
        "headers": [],
        "x": 350,
        "y": 1260,
        "wires": [
            [
                "63a080c688704ed5",
                "0da183dd1d248de9"
            ]
        ]
    },
    {
        "id": "b65fbbb4acf64638",
        "type": "inject",
        "z": "f2f18323778993ca",
        "name": "",
        "props": [
            {
                "p": "payload"
            },
            {
                "p": "topic",
                "vt": "str"
            }
        ],
        "repeat": "",
        "crontab": "",
        "once": false,
        "onceDelay": 0.1,
        "topic": "",
        "payload": "true",
        "payloadType": "bool",
        "x": 190,
        "y": 1260,
        "wires": [
            [
                "baaf2660585b63e6"
            ]
        ]
    },
    {
        "id": "0da183dd1d248de9",
        "type": "debug",
        "z": "f2f18323778993ca",
        "name": "debug 15",
        "active": true,
        "tosidebar": true,
        "console": false,
        "tostatus": false,
        "complete": "false",
        "statusVal": "",
        "statusType": "auto",
        "x": 540,
        "y": 1260,
        "wires": []
    },
    {
        "id": "90b02ee78ae15ec3",
        "type": "mqtt-broker",
        "name": "",
        "broker": "192.168.0.233",
        "port": "1883",
        "clientid": "",
        "autoConnect": true,
        "usetls": false,
        "protocolVersion": "4",
        "keepalive": "60",
        "cleansession": true,
        "autoUnsubscribe": true,
        "birthTopic": "",
        "birthQos": "0",
        "birthPayload": "",
        "birthMsg": {},
        "closeTopic": "",
        "closeQos": "0",
        "closePayload": "",
        "closeMsg": {},
        "willTopic": "",
        "willQos": "0",
        "willPayload": "",
        "willMsg": {},
        "userProps": "",
        "sessionExpiry": ""
    },
    {
        "id": "e09ee07d132abea6",
        "type": "ui-group",
        "name": "Group 4",
        "page": "f85cf0eee9119058",
        "width": "4",
        "height": "1",
        "order": 1,
        "showTitle": false,
        "className": "",
        "visible": "true",
        "disabled": "false"
    },
    {
        "id": "f85cf0eee9119058",
        "type": "ui-page",
        "name": "Hemma",
        "ui": "72c1e5a9ec204878",
        "path": "/home",
        "icon": "home",
        "layout": "grid",
        "theme": "0d92c765bfad87e6",
        "order": 2,
        "className": "",
        "visible": "true",
        "disabled": "false"
    },
    {
        "id": "72c1e5a9ec204878",
        "type": "ui-base",
        "name": "My Dashboard",
        "path": "/dashboard",
        "includeClientData": true,
        "acceptsClientConfig": [
            "ui-notification",
            "ui-control",
            "ui-chart"
        ],
        "showPathInSidebar": false
    },
    {
        "id": "0d92c765bfad87e6",
        "type": "ui-theme",
        "name": "Basic Blue Theme",
        "colors": {
            "surface": "#4d58ff",
            "primary": "#0094ce",
            "bgPage": "#eeeeee",
            "groupBg": "#ffffff",
            "groupOutline": "#cccccc"
        },
        "sizes": {
            "pagePadding": "12px",
            "groupGap": "12px",
            "groupBorderRadius": "4px",
            "widgetGap": "12px",
            "density": "default"
        }
    },
    {
        "id": "38d5e333f8aee67c",
        "type": "global-config",
        "env": [],
        "modules": {
            "node-red-node-base64": "1.0.0",
            "@flowfuse/node-red-dashboard": "1.30.2"
        }
    }
]

As you can see the src is not filled in, so that bit of your template is not working.

In your template script you have
if (newMsg && newMsg.topic === 'video-frame') {
but from the debug you showed it does not look as if you have set msg.topic to 'video-frame' as required by your script.

OK it is working now. I removed the if (newMsg && newMsg.topic === 'video-frame') check from the script.
Thank you Colin for your help

I would leave in the check that newMsg is not null so that it does not generate errors if it is null (I think that may be possible on startup, but not certain). So make it
if (newMsg) {

It is OK to use AI to help with generating code, but you should make sure that you understand what it has provided and check that it meets your requirements. Often it generates code that looks ok, but is actually garbage.

You also don't need all the buffer stuff it provided if you are always providing a string.

100% agree regarding AI code. In this case I had no clue as to how the script worked with msg.payload and I am just now going through the message document.
Good point on checking newMsg for null. I will add that back in.
Again thanks for the help.