Ender 3S1 Pro thumbnail preview not working

Hi all.

I installed the latest firmware on my E3S1Pro, which promised a gcode thumbnail preview support.

I haven’t been able to get the feature to work.

I have tried the Creality slicer, which does embed a 48x48 JPG and a 200x200 JPG, but the thumbnails are not shown in the display.

I have also tried Cura 5 with an assortment of plugins and custom scripts.

I have also tried Prusaslicer with a few post-processing scripts.

I have tried different dimensions and picture formats.

Nothing works. Has anyone been able to get it to work?

3 Likes

Use creality slicer

1 Like

Thank you for your reply, I have tried it and it doesn’t work.

Can you try in your printer? I will upload a gcode sliced with creality slicer 4.8.2 and you can try and see if the thumbnail displays for you.

1 Like

Yea i have and ender v2 but yeah i can try

Just sliced this using Creality Slicer 4.8.2 with the CrealityThumbnail plugin enabled and exporting as “Creality format”.
If you open the gcode with a text editor you can see that the fila starts with a 48x48 embedded JPG and is followed by a 200x200 embedded JPG:

I wasnt able to find my cord for my printer to test but found a thread that may help seem to be an s1 issue in the firmware

Hope that helps was less then a month ago so it should still be relavent. When im dont using my other printer ill plug it in and give it a shot though

Ok so I made great progress.

I had found that post already, but was having trouble getting it to work consistently across slicers.

First of all, I found out that there is a new creality slicer called creality print.
This slicer did result in thumbnails showing in the display.
After a lot of trial and error decoding and re-encoding the jpgs, I finally got a working script for Cura and prusaslicer.

For now only the thumbnail works, haven’t gotten to the remaining time, layers etc

To run the script you’ll need to have pillow and pyqt6 installed.
Pillow alone does not work for some reason.

The code is a sort of mix of the codes found here and here

The script has the added benefit of displaying a thumbnail in the file explorer if you use powertoys.

(NOTE this is the PRUSASLICER script)

#!/usr/bin/env python3

# ------------------------------------------------------------------------------
# Contains code from the jpg re-encoder thumbnail post processor script:
# github.com/alexqzd/Marlin/blob/Gcode-preview/Display%20firmware/gcode_thumb_to_jpg.py
# ------------------------------------------------------------------------------

import sys
import re
import os
import base64 
import io
import subprocess

import matplotlib.pyplot as plt
from PyQt6.QtCore import QBuffer

try:
    from PIL import Image, ImageOps
except ImportError:
    subprocess.check_call([sys.executable, "-m", "pip3", "install", "Pillow"])
    from PIL import Image
    
def install(package):
    subprocess.check_call([sys.executable, "-m", "pip3", "install", package])

# Get the g-code source file name
sourceFile = sys.argv[1]

# Read the ENTIRE g-code file into memory
with open(sourceFile, "r") as f:
    lines = f.read()

thumb_expresion = '; thumbnail_JPG begin.*?\n((.|\n)*?); thumbnail_JPG end'
size_expresion = '; thumbnail_JPG begin [0-9]+x[0-9]+ [0-9]+'
size_expresion_group = '; thumbnail_JPG begin [0-9]+x[0-9]+ ([0-9]+)'
tail_expresion = '; thumbnail_JPG end'

thumb_matches = re.findall(thumb_expresion, lines)
size_matches = re.findall(size_expresion, lines)
tail_matches = re.findall(tail_expresion, lines)

def encodedStringToGcodeComment(encodedString):
    n = 76
    return '; ' + '\n; '.join(encodedString[i:i+n] for i in range(0, len(encodedString), n)) + '\n'


for idx, match in enumerate(thumb_matches):
    original = match[0]
    encoded = original.replace("; ", "")
    encoded = encoded.replace("\n", "")
    encoded = encoded.replace("\r", "")
    decoded = base64.b64decode(encoded)
    img_png = Image.open(io.BytesIO(decoded))
    img_png_rgb = img_png.convert('RGB')

    thumbnail_buffer = QBuffer()
    thumbnail_buffer.open(QBuffer.OpenModeFlag.ReadWrite)
    img_png_rgb.save(thumbnail_buffer, "jpeg", quality=40, optimize=True)
    thumbnail_data = thumbnail_buffer.data()
    thumbnail_length = thumbnail_data.length()
    base64_bytes = base64.b64encode(thumbnail_data)
    base64_message = base64_bytes.decode('ascii')
    thumbnail_buffer.close()

    encodedjpg_gcode = encodedStringToGcodeComment(base64_message)
    lines = lines.replace(original, "")

    size_match = size_matches[idx]
    size = re.findall(size_expresion_group, size_match)
    new_size = size_match.replace(size[0], str(len(base64_message)))

    width = 300
    height = 300

    x1 = (int)(width/80) + 1
    x2 = width - x1
    header = "; thumbnail_jpg begin {}*{} {} {} {} {}\n".format(width, height, thumbnail_length, x1, x2, 500)

    lines = lines.replace(size_match, "")

    tail_match = tail_matches[idx]
    tail = '; thumbnail_jpg end\n'
    lines = lines.replace(tail_match, "")

#Prepare header values
ph = re.search('; generated by (.*)\n', lines)
if ph is not None : lines = lines.replace(ph[0], "")

time = 0
match = re.search('; estimated printing time \(normal mode\) = (.*)\n', lines)
if match is not None :
  h = re.search('(\d+)h',match[1])
  h = int(h[1]) if h is not None else 0
  m = re.search('(\d+)m',match[1])
  m = int(m[1]) if m is not None else 0
  s = re.search('(\d+)s',match[1])
  s = int(s[1]) if s is not None else 0
  time = h*3600+m*60+s

match = re.search('; filament used \[mm\] = ([0-9.]+)', lines)
filament = float(match[1])/1000 if match is not None else 0

match = os.getenv('SLIC3R_LAYER_HEIGHT')
layer = float(match) if match is not None else 0

minx = 0
miny = 0
minz = 0
maxx = 0
maxy = 0
maxz = 0

try:
    with open(sourceFile, "w+") as of:
        
        of.write(header)
        of.write(encodedjpg_gcode)
        of.write(tail)
        of.write(";\n")
        of.write("\n")

        if ph is not None : of.write(ph[0])
        of.write(';FLAVOR:Marlin\n')
        of.write(';TIME:{:d}\n'.format(time))
        of.write(';Filament used: {:.6f}\n'.format(filament))
        of.write(';Layer height: {:.2f}\n'.format(layer))
        of.write(';MINX:{:.3f}\n'.format(minx))
        of.write(';MINY:{:.3f}\n'.format(miny))
        of.write(';MINZ:{:.3f}\n'.format(minz))
        of.write(';MAXX:{:.3f}\n'.format(maxx))
        of.write(';MAXY:{:.3f}\n'.format(maxy))
        of.write(';MAXZ:{:.3f}\n'.format(maxz))

        lines = lines.replace("\n\n;\n\n\n;\n; \n", "")

        of.write(lines)

except:
    print('Error writing output file')
    input()
finally:
    of.close()
    f.close()
1 Like

Fixed the estimated time, used filament and nº of layers:

#!/usr/bin/env python3

# ------------------------------------------------------------------------------
# Contains code from the jpg re-encoder thumbnail post processor script:
# github.com/alexqzd/Marlin/blob/Gcode-preview/Display%20firmware/gcode_thumb_to_jpg.py
# ------------------------------------------------------------------------------

import base64 
import io
import os
import re
import sys

from PIL import Image
from PyQt6.QtCore import QBuffer

# Get the g-code source file name
sourceFile = sys.argv[1]

# Read the ENTIRE g-code file into memory
with open(sourceFile, "r") as f:
    lines = f.read()

thumb_expresion = '; thumbnail_JPG begin.*?\n((.|\n)*?); thumbnail_JPG end'
size_expresion = '; thumbnail_JPG begin [0-9]+x[0-9]+ [0-9]+'
size_expresion_group = '; thumbnail_JPG begin [0-9]+x[0-9]+ ([0-9]+)'
tail_expresion = '; thumbnail_JPG end'

thumb_matches = re.findall(thumb_expresion, lines)
size_matches = re.findall(size_expresion, lines)
tail_matches = re.findall(tail_expresion, lines)

def encodedStringToGcodeComment(encodedString):
    chunks = 76
    return '; ' + '\n; '.join(encodedString[i:i+chunks] for i in range(0, len(encodedString), chunks)) + '\n'


for idx, match in enumerate(thumb_matches):
    # Getting PrusaSlicer-generated jpg, removing newlines and semicolons
    original = match[0]
    encoded = original.replace("; ", "")
    encoded = encoded.replace("\n", "")
    encoded = encoded.replace("\r", "")

    # Decoding from b64, storing in a Qt buffer, re-encoding to b64
    decoded = base64.b64decode(encoded)
    img_png = Image.open(io.BytesIO(decoded))
    img_png_rgb = img_png.convert('RGB')

    thumbnail_buffer = QBuffer()
    thumbnail_buffer.open(QBuffer.OpenModeFlag.ReadWrite)
    img_png_rgb.save(thumbnail_buffer, "jpeg", quality=40, optimize=True) #Lower quality to keep filesize down
    thumbnail_data = thumbnail_buffer.data()
    thumbnail_length = thumbnail_data.length()

    # Formatting to b64 in ascii back with semicolons and new lines based on chunk size
    base64_bytes = base64.b64encode(thumbnail_data)
    base64_message = base64_bytes.decode('ascii')
    thumbnail_buffer.close()

    encodedjpg_gcode = encodedStringToGcodeComment(base64_message)
    lines = lines.replace(original, "")

    # Building header
    size_match = size_matches[idx]
    size = re.findall(size_expresion_group, size_match)
    new_size = size_match.replace(size[0], str(len(base64_message)))

    width = 300
    height = 300

    x1 = (int)(width/80) + 1
    x2 = width - x1

    header = "; thumbnail_jpg begin {}*{} {} {} {} {}\n".format(width, height, thumbnail_length, x1, x2, 500)
    lines = lines.replace(size_match, "")

    # Building thumbnail jpg end
    tail_match = tail_matches[idx]
    tail = '; thumbnail_jpg end\n'
    lines = lines.replace(tail_match, "")

#Prepare header values
ph = re.search('; generated by (.*)\n', lines)
if ph is not None : lines = lines.replace(ph[0], "")

# Formattign estimated time
time = 0
match = re.search('; estimated printing time \(normal mode\) = (.*)\n', lines)
if match is not None :
  h = re.search('(\d+)h',match[1])
  h = int(h[1]) if h is not None else 0
  m = re.search('(\d+)m',match[1])
  m = int(m[1]) if m is not None else 0
  s = re.search('(\d+)s',match[1])
  s = int(s[1]) if s is not None else 0
  time = h*3600+m*60+s

# formatting used filament
match = re.search('; filament used \[mm\] = ([0-9.]+)', lines)
filament = float(match[1])/1000 if match is not None else 0

# Formatting object dimensions on build plate
match = os.getenv('SLIC3R_LAYER_HEIGHT')
layer = float(match) if match is not None else 0

minx = 0
miny = 0
minz = layer
maxx = 0
maxy = 0
maxz = (lines.count('Z.2') + 1) * layer

# Write all data to file
try:
    with open(sourceFile, "w+") as of:
        
        of.write(header)
        of.write(encodedjpg_gcode)
        of.write(tail)
        of.write(";\n")
        of.write("\n")

        of.write(';FLAVOR:Marlin\n')
        of.write(';TIME:{:d}\n'.format(time))
        of.write(';Filament used: {:.6f}\n'.format(filament))
        of.write(';Layer height: {:.2f}\n'.format(layer))
        of.write(';MINX:{:.3f}\n'.format(minx))
        of.write(';MINY:{:.3f}\n'.format(miny))
        of.write(';MINZ:{:.3f}\n'.format(minz))
        of.write(';MAXX:{:.3f}\n'.format(maxx))
        of.write(';MAXY:{:.3f}\n'.format(maxy))
        of.write(';MAXZ:{:.3f}\n'.format(maxz))

        # For gcode cleanliness
        lines = lines.replace("\n\n;\n\n\n;\n; \n", "")
        of.write('\n;\n;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;\n')
        if ph is not None : of.write(ph[0])

        of.write(lines)

except:
    print('Error writing output file')
    input()
finally:
    of.close()
    f.close()

Managed to make it work without using PyQt6!

#!/usr/bin/env python3

# ------------------------------------------------------------------------------
# Contains code from the jpg re-encoder thumbnail post processor script:
# github.com/alexqzd/Marlin/blob/Gcode-preview/Display%20firmware/gcode_thumb_to_jpg.py
# ------------------------------------------------------------------------------

import base64 
import io
import os
import re
import sys
import traceback

from PIL import Image

# Get the g-code source file name
sourceFile = sys.argv[1]

# Read the ENTIRE g-code file into memory
with open(sourceFile, "r") as f:
    lines = f.read()

thumb_expresion = '; thumbnail_JPG begin.*?\n((.|\n)*?); thumbnail_JPG end'
size_expresion = '; thumbnail_JPG begin [0-9]+x[0-9]+ [0-9]+'
size_expresion_group = '; thumbnail_JPG begin [0-9]+x[0-9]+ ([0-9]+)'
tail_expresion = '; thumbnail_JPG end'

thumb_matches = re.findall(thumb_expresion, lines)
size_matches = re.findall(size_expresion, lines)
tail_matches = re.findall(tail_expresion, lines)

def encodedStringToGcodeComment(encodedString):
    chunks = 76
    return '; ' + '\n; '.join(encodedString[i:i+chunks] for i in range(0, len(encodedString), chunks)) + '\n'


for idx, match in enumerate(thumb_matches):
    original = match[0]
    encoded = original.replace("; ", "")
    encoded = encoded.replace("\n", "")
    encoded = encoded.replace("\r", "")
    decoded = base64.b64decode(encoded)
    img_png = Image.open(io.BytesIO(decoded))
    img_png_rgb = img_png.convert('RGB')
    img_byte_arr = io.BytesIO()
    img_png_rgb.save(img_byte_arr, format='jpeg', quality=40, optimize=True)
    img_byte_arr = img_byte_arr.getvalue()
    encodedjpg = base64.b64encode(img_byte_arr).decode("ascii")
    encodedjpg_gcode = encodedStringToGcodeComment(encodedjpg)
    lines = lines.replace(original, "")

    # Building header
    size_match = size_matches[idx]
    size = re.findall(size_expresion_group, size_match)
    new_size = size_match.replace(size[0], str(len(encodedjpg_gcode)))

    width = 300
    height = 300

    x1 = (int)(width/80) + 1
    x2 = width - x1

    header = "; thumbnail_jpg begin {}*{} {} {} {} {}\n".format(width, height, len(img_byte_arr), x1, x2, 500)
    lines = lines.replace(size_match, "")

    # Building thumbnail jpg end
    tail_match = tail_matches[idx]
    tail = '; thumbnail_jpg end\n'
    lines = lines.replace(tail_match, "")

#Prepare header values
ph = re.search('; generated by (.*)\n', lines)
if ph is not None : lines = lines.replace(ph[0], "")

# Formattign estimated time
time = 0
match = re.search('; estimated printing time \(normal mode\) = (.*)\n', lines)
if match is not None :
  h = re.search('(\d+)h',match[1])
  h = int(h[1]) if h is not None else 0
  m = re.search('(\d+)m',match[1])
  m = int(m[1]) if m is not None else 0
  s = re.search('(\d+)s',match[1])
  s = int(s[1]) if s is not None else 0
  time = h*3600+m*60+s

# formatting used filament
match = re.search('; filament used \[mm\] = ([0-9.]+)', lines)
filament = float(match[1])/1000 if match is not None else 0

# Formatting object dimensions on build plate
match = os.getenv('SLIC3R_LAYER_HEIGHT')
layer = float(match) if match is not None else 0

minx = 0
miny = 0
minz = layer
maxx = 0
maxy = 0
maxz = (lines.count('Z.2') + 1) * layer

# Write all data to file
try:
    with open(sourceFile, "w+") as of:
        
        of.write(header)
        of.write(encodedjpg_gcode)
        of.write(tail)
        of.write(";\n")
        of.write("\n")

        of.write(';FLAVOR:Marlin\n')
        of.write(';TIME:{:d}\n'.format(time))
        of.write(';Filament used: {:.6f}\n'.format(filament))
        of.write(';Layer height: {:.2f}\n'.format(layer))
        of.write(';MINX:{:.3f}\n'.format(minx))
        of.write(';MINY:{:.3f}\n'.format(miny))
        of.write(';MINZ:{:.3f}\n'.format(minz))
        of.write(';MAXX:{:.3f}\n'.format(maxx))
        of.write(';MAXY:{:.3f}\n'.format(maxy))
        of.write(';MAXZ:{:.3f}\n'.format(maxz))

        # For gcode cleanliness
        lines = lines.replace("\n\n;\n\n\n;\n; \n", "")
        of.write('\n;\n;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;\n')
        if ph is not None : of.write(ph[0])
        # of.write('; Thumbnail and estimated time, fliament used data generated\n')

        of.write(lines)

except Exception as E:
    print('Error writing output file')
    print(traceback.print_exception(E))
    input()
finally:
    of.close()
    f.close()

The Cura extension script for people without a ultimaker account:

# Cura Ender-3 S1 Pro Thumbnail creator
#
# Based on: https://github.com/KenHuffman/UltimakerCuraScripts
# Cretaed by: Ken Huffman (huffmancoding@gmail.com)

import base64

from UM.Logger import Logger
from cura.Snapshot import Snapshot
from PyQt6.QtCore import QByteArray, QIODevice, QBuffer

from ..Script import Script


class CreateEnder3S1ProThumbnail(Script):
    def __init__(self):
        super().__init__()

    def _createSnapshot(self, width, height):
        Logger.log("d", "Creating thumbnail image...")
        try:
            return Snapshot.snapshot(width, height)
        except Exception:
            Logger.logException("w", "Failed to create snapshot image")

    def _encodeSnapshot(self, snapshot):
        Logger.log("d", "Encoding thumbnail image...")
        try:
            thumbnail_buffer = QBuffer()
            thumbnail_buffer.open(QBuffer.OpenModeFlag.ReadWrite)
            thumbnail_image = snapshot
            thumbnail_image.save(thumbnail_buffer, "JPG")
            thumbnail_data = thumbnail_buffer.data()
            thumbnail_length = thumbnail_data.length()
            base64_bytes = base64.b64encode(thumbnail_data)
            base64_message = base64_bytes.decode('ascii')
            thumbnail_buffer.close()
            Logger.log("d", "Snapshot thumbnail_length={}".format(thumbnail_length))
            return (base64_message, thumbnail_length)
        except Exception:
            Logger.logException("w", "Failed to encode snapshot image")

    def _convertSnapshotToGcode(self, thumbnail_length, encoded_snapshot, width, height, chunk_size=76):
        Logger.log("d", "Converting snapshot into gcode...")
        gcode = []

        # these numbers appear to be related to image size, guessing here
        x1 = (int)(width/80) + 1
        x2 = width - x1
        header = "; thumbnail_jpg begin {}*{} {} {} {} {}".format(
            width, height, thumbnail_length, x1, x2, 500)
        Logger.log("d", "Gcode header={}".format(header))
        gcode.append(header)

        chunks = ["; {}".format(encoded_snapshot[i:i+chunk_size])
                  for i in range(0, len(encoded_snapshot), chunk_size)]
        gcode.extend(chunks)

        gcode.append("; thumbnail_jpg end")
        gcode.append(";")
        gcode.append("")

        return gcode

    def getSettingDataString(self):
        return """{
            "name": "Create Ender-3 S1 Pro Thumbnail",
            "key": "CreateEnder3S1ProThumbnail",
            "metadata": {},
            "version": 2,
            "settings":
            {
                "width":
                {
                    "label": "Width",
                    "description": "Width of the generated thumbnail",
                    "unit": "px",
                    "type": "int",
                    "default_value": 300,
                    "minimum_value": "0",
                    "minimum_value_warning": "12",
                    "maximum_value_warning": "800"
                },
                "height":
                {
                    "label": "Height",
                    "description": "Height of the generated thumbnail",
                    "unit": "px",
                    "type": "int",
                    "default_value": 300,
                    "minimum_value": "0",
                    "minimum_value_warning": "12",
                    "maximum_value_warning": "600"
                }
            }
        }"""

    def execute(self, data):
        width = self.getSettingValueByKey("width")
        height = self.getSettingValueByKey("height")
        Logger.log("d", "CreateEnder3S1ProThumbnail Plugin start with width={}, height={}...".format(width, height))

        snapshot = self._createSnapshot(width, height)
        if snapshot:
            Logger.log("d", "Snapshot created")
            (encoded_snapshot, thumbnail_length) = self._encodeSnapshot(snapshot)
            snapshot_gcode = self._convertSnapshotToGcode(
                thumbnail_length, encoded_snapshot, width, height)

            Logger.log("d", "Layer count={}".format(len(data)))
            if len(data) > 0:
                # The Ender-3 S1 Proreally wants this at the top of the file
                layer_index = 0
                lines = data[layer_index].split("\n")
                Logger.log("d", "Adding snapshot gcode lines (len={}) before '{}'".format(len(snapshot_gcode), lines[0]))
                lines[0:0] = snapshot_gcode
                final_lines = "\n".join(lines)
                data[layer_index] = final_lines

        Logger.log("d", "CreateEnder3S1ProThumbnail Plugin end")
        return data

Hi @ghylander I created the adapted cura script for the S1 Pro & was going to see if I can adapt it further to display the print time, filament & layer info like you did for the PrusaSlicer script. Do you have an example of a PrusaSlicer gcode that I can use to set up the correct formatting of the data?

Hi, the trick is how to format the text containing the information.

The number of layers displayed by the printer is actually a calculated value.
The printer takes the model MAXZ and divides it by LAYER_HEIGHT to obtain the number of layers.

In PrusaSlicer there is no MAXZ variable (for now at least), I calculate it by counting the # of instances of the BEFORE_LAYER_CHANGE macro (this is actually a change I’ve just made from a previous strategy). In Cura, you have variables that each gives you the MIN/MAX of XYZ, so just use that instead.

The estimated time must be formatted in seconds (the printer will format it back into HH:MM:SS), and the filament used doesn’t need the m at the end, the printer will append it too.

This is what the gcode must look like::

; thumbnail_jpg begin 300*300 10172 4 296 500
...
; thumbnail_jpg end
;

;FLAVOR:Marlin
;TIME:16402
;Filament used: 17.897130
;Layer height: 0.20
;MINX:0.000
;MINY:0.000
;MINZ:0.200
;MAXX:0.000
;MAXY:0.000
;MAXZ:63.000

;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; generated by PrusaSlicer 2.7.1+win64 on 2024-01-24 at 13:27:55 UTC

; external perimeters extrusion width = 0.45mm
...

There cannot be any text between the end of the jpg thumbnail and the “;FLAVOR:Marlin” line, but it seems there can be any amount of semicolons and newlines.

I haven’t tried if changing the order of the “TIME”, “Filament used”, etc lines, I don’t know if it matters.

Gcode generated with the Creality print slicer have more lines under the “FLAVOR:Marlin”, but they are not necessary for the screen to display information.

Changed MAXZ calculation strategy.

#!/usr/bin/env python3

# ------------------------------------------------------------------------------
# Contains code from the jpg re-encoder thumbnail post processor script:
# github.com/alexqzd/Marlin/blob/Gcode-preview/Display%20firmware/gcode_thumb_to_jpg.py
# ------------------------------------------------------------------------------

import base64 
import io
import os
import re
import sys
import traceback

from PIL import Image

# Get the g-code source file name
sourceFile = sys.argv[1]

# Read the ENTIRE g-code file into memory
with open(sourceFile, "r") as f:
    lines = f.read()

thumb_expresion = '; thumbnail_JPG begin.*?\n((.|\n)*?); thumbnail_JPG end'
size_expresion = '; thumbnail_JPG begin [0-9]+x[0-9]+ [0-9]+'
size_expresion_group = '; thumbnail_JPG begin [0-9]+x[0-9]+ ([0-9]+)'
tail_expresion = '; thumbnail_JPG end'

thumb_matches = re.findall(thumb_expresion, lines)
size_matches = re.findall(size_expresion, lines)
tail_matches = re.findall(tail_expresion, lines)

def encodedStringToGcodeComment(encodedString):
    chunks = 76
    return '; ' + '\n; '.join(encodedString[i:i+chunks] for i in range(0, len(encodedString), chunks)) + '\n'


for idx, match in enumerate(thumb_matches):
    original = match[0]
    encoded = original.replace("; ", "")
    encoded = encoded.replace("\n", "")
    encoded = encoded.replace("\r", "")
    decoded = base64.b64decode(encoded)
    img_png = Image.open(io.BytesIO(decoded))
    img_png_rgb = img_png.convert('RGB')
    img_byte_arr = io.BytesIO()
    img_png_rgb.save(img_byte_arr, format='jpeg', quality=40, optimize=True)
    img_byte_arr = img_byte_arr.getvalue()
    encodedjpg = base64.b64encode(img_byte_arr).decode("ascii")
    encodedjpg_gcode = encodedStringToGcodeComment(encodedjpg)
    lines = lines.replace(original, "")

    # Building header
    size_match = size_matches[idx]
    size = re.findall(size_expresion_group, size_match)
    new_size = size_match.replace(size[0], str(len(encodedjpg_gcode)))

    width = 300
    height = 300

    x1 = (int)(width/80) + 1
    x2 = width - x1

    header = "; thumbnail_jpg begin {}*{} {} {} {} {}\n".format(width, height, len(img_byte_arr), x1, x2, 500)
    lines = lines.replace(size_match, "")

    # Building thumbnail jpg end
    tail_match = tail_matches[idx]
    tail = '; thumbnail_jpg end\n'
    lines = lines.replace(tail_match, "")

#Prepare header values
ph = re.search('; generated by (.*)\n', lines)
if ph is not None : lines = lines.replace(ph[0], "")

# Formattign estimated time
time = 0
match = re.search('; estimated printing time \(normal mode\) = (.*)\n', lines)
if match is not None :
  h = re.search('(\d+)h',match[1])
  h = int(h[1]) if h is not None else 0
  m = re.search('(\d+)m',match[1])
  m = int(m[1]) if m is not None else 0
  s = re.search('(\d+)s',match[1])
  s = int(s[1]) if s is not None else 0
  time = h*3600+m*60+s

# formatting used filament
match = re.search('; filament used \[mm\] = ([0-9.]+)', lines)
filament = float(match[1])/1000 if match is not None else 0

# Formatting object dimensions on build plate
match = os.getenv('SLIC3R_LAYER_HEIGHT')
layer = float(match) if match is not None else 0

minx = 0
miny = 0
minz = layer
maxx = 0
maxy = 0
maxz = (lines.count('BEFORE_LAYER_CHANGE') - 1) * layer

# Write all data to file
try:
    with open(sourceFile, "w+") as of:
        
        of.write(header)
        of.write(encodedjpg_gcode)
        of.write(tail)
        of.write(";\n")
        of.write("\n")

        of.write(';FLAVOR:Marlin\n')
        of.write(';TIME:{:d}\n'.format(time))
        of.write(';Filament used: {:.6f}\n'.format(filament))
        of.write(';Layer height: {:.2f}\n'.format(layer))
        of.write(';MINX:{:.3f}\n'.format(minx))
        of.write(';MINY:{:.3f}\n'.format(miny))
        of.write(';MINZ:{:.3f}\n'.format(minz))
        of.write(';MAXX:{:.3f}\n'.format(maxx))
        of.write(';MAXY:{:.3f}\n'.format(maxy))
        of.write(';MAXZ:{:.3f}\n'.format(maxz))

        # For gcode cleanliness
        lines = lines.replace("\n\n;\n\n\n;\n; \n", "")
        of.write('\n;\n;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;\n')
        if ph is not None : of.write(ph[0])

        of.write(lines)

except Exception as E:
    print('Error writing output file')
    print(traceback.print_exception(E))
    input()
finally:
    of.close()
    f.close()

@ghylander Thanks for the pointers. I had a stab at it last night by trying to read as much as I could from your PrusaSlicer code, comparing it to the Creality Slicer gcode then applying that to the Cura script. It seems like I’ve got it to work.
The main headache was working out how to extract the required data from Cura & format it correctly. I ended up having to use some different calcs & syntax.

# Cura Ender-3 S1 Pro Thumbnail Generator
#
# Based on: https://github.com/KenHuffman/UltimakerCuraScripts for the Ender-3 V2 Neo
# Created by: Ken Huffman (huffmancoding@gmail.com)
# Modified by: PartySausage for the Ender-3 S1 Pro to display Thumbnail & Print Data

import base64

from UM.Logger import Logger
from cura.Snapshot import Snapshot
from PyQt6.QtCore import QByteArray, QIODevice, QBuffer

from ..Script import Script

from UM.Application import Application
from UM.Qt.Duration import DurationFormat


class CreateEnder3S1ProThumbnailV2(Script):
    def __init__(self):
        super().__init__()

    def _createSnapshot(self, width, height):
        Logger.log("d", "Creating thumbnail image...")
        try:
            return Snapshot.snapshot(width, height)
        except Exception:
            Logger.logException("w", "Failed to create snapshot image")

    def _encodeSnapshot(self, snapshot):
        Logger.log("d", "Encoding thumbnail image...")
        try:
            thumbnail_buffer = QBuffer()
            thumbnail_buffer.open(QBuffer.OpenModeFlag.ReadWrite)
            thumbnail_image = snapshot
            thumbnail_image.save(thumbnail_buffer, "JPG")
            thumbnail_data = thumbnail_buffer.data()
            thumbnail_length = thumbnail_data.length()
            base64_bytes = base64.b64encode(thumbnail_data)
            base64_message = base64_bytes.decode('ascii')
            thumbnail_buffer.close()
            Logger.log("d", "Snapshot thumbnail_length={}".format(thumbnail_length))
            return (base64_message, thumbnail_length)
        except Exception:
            Logger.logException("w", "Failed to encode snapshot image")

    def _convertSnapshotToGcode(self, data, thumbnail_length, encoded_snapshot, width, height, chunk_size=76):
        Logger.log("d", "Converting snapshot into gcode...")
        gcode = []

        # these numbers appear to be related to image size, guessing here
        x1 = (int)(width/80) + 1
        x2 = width - x1
        header = "; jpg begin {}*{} {} {} {} {}".format(
            width, height, thumbnail_length, x1, x2, 500)
        Logger.log("d", "Gcode header={}".format(header))
        gcode.append(header)

        chunks = ["; {}".format(encoded_snapshot[i:i+chunk_size])
                  for i in range(0, len(encoded_snapshot), chunk_size)]
        gcode.extend(chunks)

        gcode.append("; jpg end")
        gcode.append(";")
        gcode.append("")
        
        # Generate Print Data
        gcode.append(";FLAVOR:Marlin")
				
				# Retrieve Print Time 'H:M:S'
        TIME_HMS = str(Application.getInstance().getPrintInformation().currentPrintTime.getDisplayString(DurationFormat.Format.ISO8601))
        
				# Format Print Time 'S'
        TIME_S = float(float(TIME_HMS.split(":")[0]) * 3600 + float(TIME_HMS.split(":")[1]) * 60 + float(TIME_HMS.split(":",2)[2]))
        gcode.append(";TIME:" + str(TIME_S))

        mycura = Application.getInstance().getGlobalContainerStack()
				
				# Retrieve Filament Length (m)
        filament_amt = Application.getInstance().getPrintInformation().materialLengths
        gcode.append(";Filament used:" + str(filament_amt[0]))
        
				# Retrieve Filament Layer Height (mm)
        gcode.append(";Layer height:" + str(mycura.getProperty("layer_height", "value")))

				# Retrieve No. of Layers)
        layer_count_string = ""

        for layer in data:
            layer_index = data.index(layer)
            lines = layer.split("\n")
            for line in lines:
                if line.startswith(";LAYER_COUNT:"):
                    layer_count_string = line

				# Retrieve & Calculate Print Dimensions (mm) 
				# Only MINZ & MAXZ used for Ender 3 S1 Pro 'Number of Layers' Print Data Calculations
				# The reset is used to format the gcode correctly 									
        minx = 0
        miny = 0
        minz = mycura.getProperty("layer_height", "value")
        maxx = 0
        maxy = 0
        maxz = float(layer_count_string.split(":",1)[1]) * minz
				
        gcode.append(";MINX:" + "{:.3f}".format(minx))
        gcode.append(";MINY:" + "{:.3f}".format(miny))
        gcode.append(";MINZ:" + "{:.3f}".format(minz))
        gcode.append(";MAXX:" + "{:.3f}".format(maxx))
        gcode.append(";MAXY:" + "{:.3f}".format(maxy))
        gcode.append(";MAXZ:" + "{:.3f}".format(maxz))
				
				# Footer						
        gcode.append("")		
        gcode.append(";---------------- End of Ender 3 S1 Pro Thumbnail & Print Data ---------------")		
        gcode.append("")		
        return gcode

    def getSettingDataString(self):
        return """{
            "name": "Create Ender-3 S1 Pro Thumbnail V2",
            "key": "CreateEnder3S1ProThumbnailV2",
            "metadata": {},
            "version": 2,
            "settings":
            {
                "width":
                {
                    "label": "Width",
                    "description": "Width of the generated thumbnail",
                    "unit": "px",
                    "type": "int",
                    "default_value": 300,
                    "minimum_value": "0",
                    "minimum_value_warning": "12",
                    "maximum_value_warning": "800"
                },
                "height":
                {
                    "label": "Height",
                    "description": "Height of the generated thumbnail",
                    "unit": "px",
                    "type": "int",
                    "default_value": 300,
                    "minimum_value": "0",
                    "minimum_value_warning": "12",
                    "maximum_value_warning": "600"
                }
            }
        }"""

    def execute(self, data):
        width = self.getSettingValueByKey("width")
        height = self.getSettingValueByKey("height")
        Logger.log("d", "CreateEnder3S1ProThumbnailV2 Plugin start with width={}, height={}...".format(width, height))

        snapshot = self._createSnapshot(width, height)
        if snapshot:
            Logger.log("d", "Snapshot created")
            (encoded_snapshot, thumbnail_length) = self._encodeSnapshot(snapshot)
            snapshot_gcode = self._convertSnapshotToGcode(
                data, thumbnail_length, encoded_snapshot, width, height)
										
            Logger.log("d", "Layer count={}".format(len(data)))
            if len(data) > 0:
                # The Ender-3 S1 Pro really wants this at the top of the file
                layer_index = 0
                lines = data[layer_index].split("\n")
                Logger.log("d", "Adding snapshot gcode lines (len={}) before '{}'".format(len(snapshot_gcode), lines[0]))
                lines[0:0] = snapshot_gcode
                final_lines = "\n".join(lines)
                data[layer_index] = final_lines

        Logger.log("d", "CreateEnder3S1ProThumbnailV2 Plugin end")
        return data

Yeah, shuffling around random lines of the gcode to format it like the printer expects it is tricky indeed, glad you managed!

Heavily refactored the code.
Works the same, just streamlined the whole process.
Also added some checks and error text to help users.
Added some code to handle multiple embedded thumbnails, but I don’t know if thumbnail preview works in the E3S1Pro when there are multiple jpg thumbnails.

#!/usr/bin/env python3

# ------------------------------------------------------------------------------
# Contains code from the jpg re-encoder thumbnail post processor script:
# github.com/alexqzd/Marlin/blob/Gcode-preview/Display%20firmware/gcode_thumb_to_jpg.py
# ------------------------------------------------------------------------------

import base64 
import io
import os
import re
import sys
import traceback

from PIL import Image

# Get the g-code source file name
sourceFile = sys.argv[1]

# Read the ENTIRE g-code file into memory
with open(sourceFile, "r") as f:
    lines = f.read()

thumbnailMatchExpression = r'(; thumbnail_JPG begin [0-9]+x[0-9]+ [0-9]+\n)((.|\n)*?)(; thumbnail_JPG end)'

thumbnailMatches = re.findall(thumbnailMatchExpression, lines)

if not thumbnailMatches:
    print('No thumbnail was detected in the original gcode.')
    print('Note that only JPG format is supported in the Ender 3 S1 Pro.')
    print('Ensure PrusaSlicer is generating a thumbnail and that the thumbnail is in JPG format.')
    print('The process will now exit. The PrusaSlicer gcode was not modified.')
    input('Press ener to exit.')
    sys.exit()


def encodedStringToGcodeComment(encodedString):
    chunks = 76
    return '; ' + '\n; '.join(encodedString[i:i+chunks] for i in range(0, len(encodedString), chunks)) + '\n'


def thumbnailFormatting(lines, thumbnailOriginalTextGroups):

    headOri = thumbnailOriginalTextGroups[0]
    thumbnailJPGOri = thumbnailOriginalTextGroups[1]
    tailOri = thumbnailOriginalTextGroups[-1]

    thumbnailFullTextOri = headOri + thumbnailJPGOri + tailOri

    lines = lines.replace(thumbnailFullTextOri, '')

    # Decoding, formatting and re-encoding the embbeded JPG
    encoded = thumbnailJPGOri.replace('; ', '')
    encoded = encoded.replace('\n', '')
    encoded = encoded.replace('\r', '')
    decoded = base64.b64decode(encoded)
    img_png = Image.open(io.BytesIO(decoded))
    img_png_rgb = img_png.convert('RGB')
    img_byte_arr = io.BytesIO()
    img_png_rgb.save(img_byte_arr, format='jpeg', quality=40, optimize=True)
    img_byte_arr = img_byte_arr.getvalue()
    encodedjpg = base64.b64encode(img_byte_arr).decode('ascii')
    encodedjpg_gcode = encodedStringToGcodeComment(encodedjpg)

    # Building header
    width = 300
    height = 300

    x1 = (int)(width/80) + 1
    x2 = width - x1

    header = '; thumbnail_jpg begin {}*{} {} {} {} {}\n'.format(width, height, len(img_byte_arr), x1, x2, 500)

    # Building thumbnail jpg tail
    tail = '; thumbnail_jpg end\n'

    # Building the new full thumbnail
    thumbnailFullTextNew = header + encodedjpg_gcode + tail + ';\n\n'

    return lines, thumbnailFullTextNew


#Prepare header values
ph = re.search(r'; generated by (.*)\n', lines)
if ph is not None : lines = lines.replace(ph[0], '')

# Formattign estimated time
time = 0
match = re.search(r'; estimated printing time \(normal mode\) = (.*)\n', lines)
if match is not None :
    h = re.search(r'(\d+)h', match[1])
    h = int(h[1]) if h is not None else 0
    m = re.search(r'(\d+)m', match[1])
    m = int(m[1]) if m is not None else 0
    s = re.search(r'(\d+)s', match[1])
    s = int(s[1]) if s is not None else 0
    time = h*3600+m*60+s
else:
    input('There was an error reading the estimated time.\nPress any key to continue.')

# formatting used filament
match = re.search(r'; filament used \[mm\] = ([0-9.]+)', lines)
filament = 0
filament = float(match[1])/1000 if match is not None else input('There was an error reading the estimated filament.\nPress any key to continue.')

# Formatting object dimensions on build plate
match = os.getenv('SLIC3R_LAYER_HEIGHT')
layer = float(match) if match is not None else input('There was an error reading the layer height.\nPress any key to continue.')

minx = 0
miny = 0
minz = layer
maxx = 0
maxy = 0
maxz = (lines.count('BEFORE_LAYER_CHANGE') - 1) * layer

# Build new full gcode file text
newLines = ''

# Start with the thumbnails
for match in thumbnailMatches:
    lines, thumbnailFullTextNew = thumbnailFormatting(lines, match)
    newLines += thumbnailFullTextNew

# Continue with lines for printing data display
newLines += ';FLAVOR:Marlin\n'
newLines += ';TIME:{:d}\n'.format(time)
newLines += ';Filament used: {:.6f}\n'.format(filament)
newLines += ';Layer height: {:.2f}\n'.format(layer)
newLines += ';MINX:{:.3f}\n'.format(minx)
newLines += ';MINY:{:.3f}\n'.format(miny)
newLines += ';MINZ:{:.3f}\n'.format(minz)
newLines += ';MAXX:{:.3f}\n'.format(maxx)
newLines += ';MAXY:{:.3f}\n'.format(maxy)
newLines += ';MAXZ:{:.3f}\n'.format(maxz)

# For gcode cleanliness
newLines += '\n;\n;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;\n'
if ph is not None: newLines += ph[0]
lines = lines.replace('\n\n;\n\n;\n; \n\n\n\n\n\n', '')
lines = lines.replace('\n\n;\n\n;\n; \n', '')

# Rest of gcode
newLines += lines

# Write all data to file
with open(sourceFile, 'w+') as of:
    of.write(newLines)

Hi can you tell me how I can add this script to Cura?

See Here No Thumbnail on creality 3 vs2 - UltiMaker Cura - UltiMaker Community of 3D Printing Experts

1 Like

Works perfectly thank you!

1 Like

If you have Microsoft powertoys installed, with the file explorer add-ons you can enable g-code (as well as stl) thumbnail preview in the file explorer:

1 Like