Stream partial decoded images during SSTV decode progress

The decode canvas was always black because nothing drew on it. Now the
backend encodes partial JPEG snapshots every 5% progress and the frontend
uses an <img> tag with in-place DOM updates instead of recreating innerHTML
on every SSE event.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-02-07 11:58:00 +00:00
parent 06c218c736
commit a0f64f6fa6
3 changed files with 76 additions and 24 deletions

View File

@@ -345,18 +345,33 @@ const SSTVGeneral = (function() {
const liveContent = document.getElementById('sstvGeneralLiveContent');
if (!liveContent) return;
liveContent.innerHTML = `
<div class="sstv-general-canvas-container">
<canvas id="sstvGeneralCanvas" width="320" height="256"></canvas>
</div>
<div class="sstv-general-decode-info">
<div class="sstv-general-mode-label">${data.mode || 'Detecting mode...'}</div>
<div class="sstv-general-progress-bar">
<div class="progress" style="width: ${data.progress || 0}%"></div>
let container = liveContent.querySelector('.sstv-general-decode-container');
if (!container) {
liveContent.innerHTML = `
<div class="sstv-general-decode-container">
<div class="sstv-general-canvas-container">
<img id="sstvGeneralDecodeImg" width="320" height="256" alt="Decoding..." style="display:block;background:#000;">
</div>
<div class="sstv-general-decode-info">
<div class="sstv-general-mode-label"></div>
<div class="sstv-general-progress-bar">
<div class="progress" style="width: 0%"></div>
</div>
<div class="sstv-general-status-message"></div>
</div>
</div>
<div class="sstv-general-status-message">${data.message || 'Decoding...'}</div>
</div>
`;
`;
container = liveContent.querySelector('.sstv-general-decode-container');
}
container.querySelector('.sstv-general-mode-label').textContent = data.mode || 'Detecting mode...';
container.querySelector('.progress').style.width = (data.progress || 0) + '%';
container.querySelector('.sstv-general-status-message').textContent = data.message || 'Decoding...';
if (data.partial_image) {
const img = container.querySelector('#sstvGeneralDecodeImg');
if (img) img.src = data.partial_image;
}
}
/**

View File

@@ -780,18 +780,33 @@ const SSTV = (function() {
const liveContent = document.getElementById('sstvLiveContent');
if (!liveContent) return;
liveContent.innerHTML = `
<div class="sstv-canvas-container">
<canvas id="sstvCanvas" width="320" height="256"></canvas>
</div>
<div class="sstv-decode-info">
<div class="sstv-mode-label">${data.mode || 'Detecting mode...'}</div>
<div class="sstv-progress-bar">
<div class="progress" style="width: ${data.progress || 0}%"></div>
let container = liveContent.querySelector('.sstv-decode-container');
if (!container) {
liveContent.innerHTML = `
<div class="sstv-decode-container">
<div class="sstv-canvas-container">
<img id="sstvDecodeImg" width="320" height="256" alt="Decoding..." style="display:block;background:#000;">
</div>
<div class="sstv-decode-info">
<div class="sstv-mode-label"></div>
<div class="sstv-progress-bar">
<div class="progress" style="width: 0%"></div>
</div>
<div class="sstv-status-message"></div>
</div>
</div>
<div class="sstv-status-message">${data.message || 'Decoding...'}</div>
</div>
`;
`;
container = liveContent.querySelector('.sstv-decode-container');
}
container.querySelector('.sstv-mode-label').textContent = data.mode || 'Detecting mode...';
container.querySelector('.progress').style.width = (data.progress || 0) + '%';
container.querySelector('.sstv-status-message').textContent = data.message || 'Decoding...';
if (data.partial_image) {
const img = container.querySelector('#sstvDecodeImg');
if (img) img.src = data.partial_image;
}
}
/**

View File

@@ -9,7 +9,9 @@ original monolithic utils/sstv.py.
from __future__ import annotations
import base64
import contextlib
import io
import subprocess
import threading
import time
@@ -95,6 +97,7 @@ class DecodeProgress:
signal_level: int | None = None # 0-100 RMS audio level, None = not measured
sstv_tone: str | None = None # 'leader', 'sync', 'noise', None
vis_state: str | None = None # VIS detector state name
partial_image: str | None = None # base64 data URL of partial decode
def to_dict(self) -> dict:
result: dict = {
@@ -114,6 +117,8 @@ class DecodeProgress:
result['sstv_tone'] = self.sstv_tone
if self.vis_state:
result['vis_state'] = self.vis_state
if self.partial_image:
result['partial_image'] = self.partial_image
return result
@@ -380,6 +385,7 @@ class SSTVDecoder:
image_decoder: SSTVImageDecoder | None = None
current_mode_name: str | None = None
chunk_counter = 0
last_partial_pct = -1
logger.info("Audio decode thread started")
rtl_fm_error: str = ''
@@ -418,12 +424,28 @@ class SSTVDecoder:
# Currently decoding an image
complete = image_decoder.feed(samples)
# Encode partial image every 5% progress
pct = image_decoder.progress_percent
partial_url = None
if pct >= last_partial_pct + 5 or complete:
last_partial_pct = pct
try:
img = image_decoder.get_image()
if img is not None:
buf = io.BytesIO()
img.save(buf, format='JPEG', quality=40)
b64 = base64.b64encode(buf.getvalue()).decode('ascii')
partial_url = f'data:image/jpeg;base64,{b64}'
except Exception:
pass
# Emit progress
self._emit_progress(DecodeProgress(
status='decoding',
mode=current_mode_name,
progress_percent=image_decoder.progress_percent,
message=f'Decoding {current_mode_name}: {image_decoder.progress_percent}%'
progress_percent=pct,
message=f'Decoding {current_mode_name}: {pct}%',
partial_image=partial_url,
))
if complete: