Improve stream UI: grouped sections, full-size preview lightbox, and test redesign
- Separate Screen Capture and Processed streams into grouped sections with headers - Remove redundant Type dropdown from stream modal (type inferred from add button) - Add full-resolution image to test endpoints alongside thumbnails - Add image lightbox with clickable preview for full-size viewing - Move test results from modal into lightbox overlay with capture stats - Apply postprocessing to both thumbnail and full image for processed streams - Rename "Assigned Picture Stream" to "Picture Stream" in device settings - Fix null reference errors from removed test result HTML elements Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1052,12 +1052,19 @@ async def test_template(
|
|||||||
thumbnail = pil_image.copy()
|
thumbnail = pil_image.copy()
|
||||||
thumbnail.thumbnail((thumbnail_width, thumbnail_height), Image.Resampling.LANCZOS)
|
thumbnail.thumbnail((thumbnail_width, thumbnail_height), Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
# Encode full capture thumbnail as JPEG
|
# Encode thumbnail as JPEG
|
||||||
img_buffer = io.BytesIO()
|
img_buffer = io.BytesIO()
|
||||||
thumbnail.save(img_buffer, format='JPEG', quality=85)
|
thumbnail.save(img_buffer, format='JPEG', quality=85)
|
||||||
img_buffer.seek(0)
|
img_buffer.seek(0)
|
||||||
full_capture_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8')
|
thumbnail_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8')
|
||||||
full_capture_data_uri = f"data:image/jpeg;base64,{full_capture_b64}"
|
thumbnail_data_uri = f"data:image/jpeg;base64,{thumbnail_b64}"
|
||||||
|
|
||||||
|
# Encode full-resolution image as JPEG
|
||||||
|
full_buffer = io.BytesIO()
|
||||||
|
pil_image.save(full_buffer, format='JPEG', quality=90)
|
||||||
|
full_buffer.seek(0)
|
||||||
|
full_b64 = base64.b64encode(full_buffer.getvalue()).decode('utf-8')
|
||||||
|
full_data_uri = f"data:image/jpeg;base64,{full_b64}"
|
||||||
|
|
||||||
# Calculate metrics
|
# Calculate metrics
|
||||||
actual_fps = frame_count / actual_duration if actual_duration > 0 else 0
|
actual_fps = frame_count / actual_duration if actual_duration > 0 else 0
|
||||||
@@ -1067,7 +1074,8 @@ async def test_template(
|
|||||||
|
|
||||||
return TemplateTestResponse(
|
return TemplateTestResponse(
|
||||||
full_capture=CaptureImage(
|
full_capture=CaptureImage(
|
||||||
image=full_capture_data_uri,
|
image=thumbnail_data_uri,
|
||||||
|
full_image=full_data_uri,
|
||||||
width=width,
|
width=width,
|
||||||
height=height,
|
height=height,
|
||||||
thumbnail_width=thumbnail_width,
|
thumbnail_width=thumbnail_width,
|
||||||
@@ -1469,34 +1477,41 @@ async def test_picture_stream(
|
|||||||
thumbnail = pil_image.copy()
|
thumbnail = pil_image.copy()
|
||||||
thumbnail.thumbnail((thumbnail_width, thumbnail_height), Image.Resampling.LANCZOS)
|
thumbnail.thumbnail((thumbnail_width, thumbnail_height), Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
# Apply postprocessing to preview if this is a processed stream
|
# Apply postprocessing if this is a processed stream
|
||||||
pp_template_ids = chain["postprocessing_template_ids"]
|
pp_template_ids = chain["postprocessing_template_ids"]
|
||||||
if pp_template_ids:
|
if pp_template_ids:
|
||||||
try:
|
try:
|
||||||
pp = pp_store.get_template(pp_template_ids[0])
|
pp = pp_store.get_template(pp_template_ids[0])
|
||||||
img_array = np.array(thumbnail, dtype=np.float32) / 255.0
|
|
||||||
|
|
||||||
if pp.brightness != 1.0:
|
def apply_pp(img):
|
||||||
img_array *= pp.brightness
|
arr = np.array(img, dtype=np.float32) / 255.0
|
||||||
|
if pp.brightness != 1.0:
|
||||||
|
arr *= pp.brightness
|
||||||
|
if pp.saturation != 1.0:
|
||||||
|
lum = np.dot(arr[..., :3], [0.299, 0.587, 0.114])[..., np.newaxis]
|
||||||
|
arr[..., :3] = lum + (arr[..., :3] - lum) * pp.saturation
|
||||||
|
if pp.gamma != 1.0:
|
||||||
|
arr = np.power(np.clip(arr, 0, 1), 1.0 / pp.gamma)
|
||||||
|
return Image.fromarray(np.clip(arr * 255.0, 0, 255).astype(np.uint8))
|
||||||
|
|
||||||
if pp.saturation != 1.0:
|
thumbnail = apply_pp(thumbnail)
|
||||||
luminance = np.dot(img_array[..., :3], [0.299, 0.587, 0.114])
|
pil_image = apply_pp(pil_image)
|
||||||
luminance = luminance[..., np.newaxis]
|
|
||||||
img_array[..., :3] = luminance + (img_array[..., :3] - luminance) * pp.saturation
|
|
||||||
|
|
||||||
if pp.gamma != 1.0:
|
|
||||||
img_array = np.power(np.clip(img_array, 0, 1), 1.0 / pp.gamma)
|
|
||||||
|
|
||||||
img_array = np.clip(img_array * 255.0, 0, 255).astype(np.uint8)
|
|
||||||
thumbnail = Image.fromarray(img_array)
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.warning(f"PP template {pp_template_ids[0]} not found, skipping postprocessing preview")
|
logger.warning(f"PP template {pp_template_ids[0]} not found, skipping postprocessing preview")
|
||||||
|
|
||||||
|
# Encode thumbnail
|
||||||
img_buffer = io.BytesIO()
|
img_buffer = io.BytesIO()
|
||||||
thumbnail.save(img_buffer, format='JPEG', quality=85)
|
thumbnail.save(img_buffer, format='JPEG', quality=85)
|
||||||
img_buffer.seek(0)
|
img_buffer.seek(0)
|
||||||
full_capture_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8')
|
thumbnail_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8')
|
||||||
full_capture_data_uri = f"data:image/jpeg;base64,{full_capture_b64}"
|
thumbnail_data_uri = f"data:image/jpeg;base64,{thumbnail_b64}"
|
||||||
|
|
||||||
|
# Encode full-resolution image
|
||||||
|
full_buffer = io.BytesIO()
|
||||||
|
pil_image.save(full_buffer, format='JPEG', quality=90)
|
||||||
|
full_buffer.seek(0)
|
||||||
|
full_b64 = base64.b64encode(full_buffer.getvalue()).decode('utf-8')
|
||||||
|
full_data_uri = f"data:image/jpeg;base64,{full_b64}"
|
||||||
|
|
||||||
actual_fps = frame_count / actual_duration if actual_duration > 0 else 0
|
actual_fps = frame_count / actual_duration if actual_duration > 0 else 0
|
||||||
avg_capture_time_ms = (total_capture_time / frame_count * 1000) if frame_count > 0 else 0
|
avg_capture_time_ms = (total_capture_time / frame_count * 1000) if frame_count > 0 else 0
|
||||||
@@ -1504,7 +1519,8 @@ async def test_picture_stream(
|
|||||||
|
|
||||||
return TemplateTestResponse(
|
return TemplateTestResponse(
|
||||||
full_capture=CaptureImage(
|
full_capture=CaptureImage(
|
||||||
image=full_capture_data_uri,
|
image=thumbnail_data_uri,
|
||||||
|
full_image=full_data_uri,
|
||||||
width=width,
|
width=width,
|
||||||
height=height,
|
height=height,
|
||||||
thumbnail_width=thumbnail_width,
|
thumbnail_width=thumbnail_width,
|
||||||
|
|||||||
@@ -292,9 +292,10 @@ class TemplateTestRequest(BaseModel):
|
|||||||
class CaptureImage(BaseModel):
|
class CaptureImage(BaseModel):
|
||||||
"""Captured image with metadata."""
|
"""Captured image with metadata."""
|
||||||
|
|
||||||
image: str = Field(description="Base64-encoded image data")
|
image: str = Field(description="Base64-encoded thumbnail image data")
|
||||||
width: int = Field(description="Image width in pixels")
|
full_image: Optional[str] = Field(None, description="Base64-encoded full-resolution image data")
|
||||||
height: int = Field(description="Image height in pixels")
|
width: int = Field(description="Original image width in pixels")
|
||||||
|
height: int = Field(description="Original image height in pixels")
|
||||||
thumbnail_width: Optional[int] = Field(None, description="Thumbnail width (if resized)")
|
thumbnail_width: Optional[int] = Field(None, description="Thumbnail width (if resized)")
|
||||||
thumbnail_height: Optional[int] = Field(None, description="Thumbnail height (if resized)")
|
thumbnail_height: Optional[int] = Field(None, description="Thumbnail height (if resized)")
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,37 @@ function unlockBody() {
|
|||||||
document.body.style.paddingRight = '';
|
document.body.style.paddingRight = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Image lightbox
|
||||||
|
function openLightbox(imageSrc, statsHtml) {
|
||||||
|
const lightbox = document.getElementById('image-lightbox');
|
||||||
|
const img = document.getElementById('lightbox-image');
|
||||||
|
const statsEl = document.getElementById('lightbox-stats');
|
||||||
|
img.src = imageSrc;
|
||||||
|
if (statsHtml) {
|
||||||
|
statsEl.innerHTML = statsHtml;
|
||||||
|
statsEl.style.display = '';
|
||||||
|
} else {
|
||||||
|
statsEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
lightbox.classList.add('active');
|
||||||
|
lockBody();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLightbox(event) {
|
||||||
|
if (event && event.target && event.target.closest('.lightbox-content')) return;
|
||||||
|
const lightbox = document.getElementById('image-lightbox');
|
||||||
|
lightbox.classList.remove('active');
|
||||||
|
document.getElementById('lightbox-image').src = '';
|
||||||
|
document.getElementById('lightbox-stats').style.display = 'none';
|
||||||
|
unlockBody();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && document.getElementById('image-lightbox').classList.contains('active')) {
|
||||||
|
closeLightbox();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Locale management
|
// Locale management
|
||||||
let currentLocale = 'en';
|
let currentLocale = 'en';
|
||||||
let translations = {};
|
let translations = {};
|
||||||
@@ -2638,9 +2669,6 @@ async function showTestTemplateModal(templateId) {
|
|||||||
// Restore last used capture duration
|
// Restore last used capture duration
|
||||||
restoreCaptureDuration();
|
restoreCaptureDuration();
|
||||||
|
|
||||||
// Reset results
|
|
||||||
document.getElementById('test-template-results').style.display = 'none';
|
|
||||||
|
|
||||||
// Show modal
|
// Show modal
|
||||||
const modal = document.getElementById('test-template-modal');
|
const modal = document.getElementById('test-template-modal');
|
||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
@@ -2836,7 +2864,6 @@ async function runTemplateTest() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const template = window.currentTestingTemplate;
|
const template = window.currentTestingTemplate;
|
||||||
const resultsDiv = document.getElementById('test-template-results');
|
|
||||||
|
|
||||||
// Show full-page overlay spinner with progress
|
// Show full-page overlay spinner with progress
|
||||||
showOverlaySpinner(t('templates.test.running'), captureDuration);
|
showOverlaySpinner(t('templates.test.running'), captureDuration);
|
||||||
@@ -2869,25 +2896,23 @@ async function runTemplateTest() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display test results
|
function buildTestStatsHtml(result) {
|
||||||
|
const p = result.performance;
|
||||||
|
const res = `${result.full_capture.width}x${result.full_capture.height}`;
|
||||||
|
return `
|
||||||
|
<div class="stat-item"><span>${t('templates.test.results.duration')}:</span> <strong>${p.capture_duration_s.toFixed(2)}s</strong></div>
|
||||||
|
<div class="stat-item"><span>${t('templates.test.results.frame_count')}:</span> <strong>${p.frame_count}</strong></div>
|
||||||
|
<div class="stat-item"><span>${t('templates.test.results.actual_fps')}:</span> <strong>${p.actual_fps.toFixed(1)}</strong></div>
|
||||||
|
<div class="stat-item"><span>${t('templates.test.results.avg_capture_time')}:</span> <strong>${p.avg_capture_time_ms.toFixed(1)}ms</strong></div>
|
||||||
|
<div class="stat-item"><span>Resolution:</span> <strong>${res}</strong></div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display test results — opens lightbox with stats overlay
|
||||||
function displayTestResults(result) {
|
function displayTestResults(result) {
|
||||||
const resultsDiv = document.getElementById('test-template-results');
|
|
||||||
|
|
||||||
// Hide overlay spinner
|
|
||||||
hideOverlaySpinner();
|
hideOverlaySpinner();
|
||||||
|
const fullImageSrc = result.full_capture.full_image || result.full_capture.image;
|
||||||
// Full capture preview
|
openLightbox(fullImageSrc, buildTestStatsHtml(result));
|
||||||
const previewImg = document.getElementById('test-template-preview-image');
|
|
||||||
previewImg.innerHTML = `<img src="${result.full_capture.image}" alt="Capture preview" style="max-width: 100%; border-radius: 4px;">`;
|
|
||||||
|
|
||||||
// Performance stats
|
|
||||||
document.getElementById('test-template-actual-duration').textContent = `${result.performance.capture_duration_s.toFixed(2)}s`;
|
|
||||||
document.getElementById('test-template-frame-count').textContent = result.performance.frame_count;
|
|
||||||
document.getElementById('test-template-actual-fps').textContent = `${result.performance.actual_fps.toFixed(1)} FPS`;
|
|
||||||
document.getElementById('test-template-avg-capture-time').textContent = `${result.performance.avg_capture_time_ms.toFixed(1)}ms`;
|
|
||||||
|
|
||||||
// Show results
|
|
||||||
resultsDiv.style.display = 'block';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save template
|
// Save template
|
||||||
@@ -2989,9 +3014,32 @@ function renderPictureStreamsList(streams) {
|
|||||||
const container = document.getElementById('streams-list');
|
const container = document.getElementById('streams-list');
|
||||||
|
|
||||||
if (streams.length === 0) {
|
if (streams.length === 0) {
|
||||||
container.innerHTML = `<div class="template-card add-template-card" onclick="showAddStreamModal()">
|
container.innerHTML = `
|
||||||
<div class="add-template-icon">+</div>
|
<div class="stream-group">
|
||||||
<div class="add-template-label">${t('streams.add')}</div>
|
<div class="stream-group-header">
|
||||||
|
<span class="stream-group-icon">📷</span>
|
||||||
|
<span class="stream-group-title">${t('streams.group.raw')}</span>
|
||||||
|
<span class="stream-group-count">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="templates-grid">
|
||||||
|
<div class="template-card add-template-card" onclick="showAddStreamModal('raw')">
|
||||||
|
<div class="add-template-icon">+</div>
|
||||||
|
<div class="add-template-label">${t('streams.add.raw')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stream-group">
|
||||||
|
<div class="stream-group-header">
|
||||||
|
<span class="stream-group-icon">🎨</span>
|
||||||
|
<span class="stream-group-title">${t('streams.group.processed')}</span>
|
||||||
|
<span class="stream-group-count">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="templates-grid">
|
||||||
|
<div class="template-card add-template-card" onclick="showAddStreamModal('processed')">
|
||||||
|
<div class="add-template-icon">+</div>
|
||||||
|
<div class="add-template-label">${t('streams.add.processed')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3046,10 +3094,41 @@ function renderPictureStreamsList(streams) {
|
|||||||
`;
|
`;
|
||||||
};
|
};
|
||||||
|
|
||||||
let html = streams.map(renderCard).join('');
|
const rawStreams = streams.filter(s => s.stream_type === 'raw');
|
||||||
html += `<div class="template-card add-template-card" onclick="showAddStreamModal()">
|
const processedStreams = streams.filter(s => s.stream_type === 'processed');
|
||||||
<div class="add-template-icon">+</div>
|
|
||||||
<div class="add-template-label">${t('streams.add')}</div>
|
let html = '';
|
||||||
|
|
||||||
|
// Screen Capture streams section
|
||||||
|
html += `<div class="stream-group">
|
||||||
|
<div class="stream-group-header">
|
||||||
|
<span class="stream-group-icon">📷</span>
|
||||||
|
<span class="stream-group-title">${t('streams.group.raw')}</span>
|
||||||
|
<span class="stream-group-count">${rawStreams.length}</span>
|
||||||
|
</div>
|
||||||
|
<div class="templates-grid">
|
||||||
|
${rawStreams.map(renderCard).join('')}
|
||||||
|
<div class="template-card add-template-card" onclick="showAddStreamModal('raw')">
|
||||||
|
<div class="add-template-icon">+</div>
|
||||||
|
<div class="add-template-label">${t('streams.add.raw')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
// Processed streams section
|
||||||
|
html += `<div class="stream-group">
|
||||||
|
<div class="stream-group-header">
|
||||||
|
<span class="stream-group-icon">🎨</span>
|
||||||
|
<span class="stream-group-title">${t('streams.group.processed')}</span>
|
||||||
|
<span class="stream-group-count">${processedStreams.length}</span>
|
||||||
|
</div>
|
||||||
|
<div class="templates-grid">
|
||||||
|
${processedStreams.map(renderCard).join('')}
|
||||||
|
<div class="template-card add-template-card" onclick="showAddStreamModal('processed')">
|
||||||
|
<div class="add-template-icon">+</div>
|
||||||
|
<div class="add-template-label">${t('streams.add.processed')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
@@ -3061,15 +3140,14 @@ function onStreamTypeChange() {
|
|||||||
document.getElementById('stream-processed-fields').style.display = streamType === 'processed' ? '' : 'none';
|
document.getElementById('stream-processed-fields').style.display = streamType === 'processed' ? '' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showAddStreamModal() {
|
async function showAddStreamModal(presetType) {
|
||||||
document.getElementById('stream-modal-title').textContent = t('streams.add');
|
const streamType = presetType || 'raw';
|
||||||
|
const titleKey = streamType === 'raw' ? 'streams.add.raw' : 'streams.add.processed';
|
||||||
|
document.getElementById('stream-modal-title').textContent = t(titleKey);
|
||||||
document.getElementById('stream-form').reset();
|
document.getElementById('stream-form').reset();
|
||||||
document.getElementById('stream-id').value = '';
|
document.getElementById('stream-id').value = '';
|
||||||
document.getElementById('stream-error').style.display = 'none';
|
document.getElementById('stream-error').style.display = 'none';
|
||||||
document.getElementById('stream-type').disabled = false;
|
document.getElementById('stream-type').value = streamType;
|
||||||
|
|
||||||
// Reset to raw type
|
|
||||||
document.getElementById('stream-type').value = 'raw';
|
|
||||||
onStreamTypeChange();
|
onStreamTypeChange();
|
||||||
|
|
||||||
// Populate dropdowns
|
// Populate dropdowns
|
||||||
@@ -3087,15 +3165,15 @@ async function editStream(streamId) {
|
|||||||
if (!response.ok) throw new Error(`Failed to load stream: ${response.status}`);
|
if (!response.ok) throw new Error(`Failed to load stream: ${response.status}`);
|
||||||
const stream = await response.json();
|
const stream = await response.json();
|
||||||
|
|
||||||
document.getElementById('stream-modal-title').textContent = t('streams.edit');
|
const editTitleKey = stream.stream_type === 'raw' ? 'streams.edit.raw' : 'streams.edit.processed';
|
||||||
|
document.getElementById('stream-modal-title').textContent = t(editTitleKey);
|
||||||
document.getElementById('stream-id').value = streamId;
|
document.getElementById('stream-id').value = streamId;
|
||||||
document.getElementById('stream-name').value = stream.name;
|
document.getElementById('stream-name').value = stream.name;
|
||||||
document.getElementById('stream-description').value = stream.description || '';
|
document.getElementById('stream-description').value = stream.description || '';
|
||||||
document.getElementById('stream-error').style.display = 'none';
|
document.getElementById('stream-error').style.display = 'none';
|
||||||
|
|
||||||
// Set type and disable changing it for existing streams
|
// Set type (hidden input)
|
||||||
document.getElementById('stream-type').value = stream.stream_type;
|
document.getElementById('stream-type').value = stream.stream_type;
|
||||||
document.getElementById('stream-type').disabled = true;
|
|
||||||
onStreamTypeChange();
|
onStreamTypeChange();
|
||||||
|
|
||||||
// Populate dropdowns before setting values
|
// Populate dropdowns before setting values
|
||||||
@@ -3286,7 +3364,6 @@ let _currentTestStreamId = null;
|
|||||||
async function showTestStreamModal(streamId) {
|
async function showTestStreamModal(streamId) {
|
||||||
_currentTestStreamId = streamId;
|
_currentTestStreamId = streamId;
|
||||||
restoreStreamTestDuration();
|
restoreStreamTestDuration();
|
||||||
document.getElementById('test-stream-results').style.display = 'none';
|
|
||||||
|
|
||||||
const modal = document.getElementById('test-stream-modal');
|
const modal = document.getElementById('test-stream-modal');
|
||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
@@ -3340,16 +3417,8 @@ async function runStreamTest() {
|
|||||||
|
|
||||||
function displayStreamTestResults(result) {
|
function displayStreamTestResults(result) {
|
||||||
hideOverlaySpinner();
|
hideOverlaySpinner();
|
||||||
|
const fullImageSrc = result.full_capture.full_image || result.full_capture.image;
|
||||||
const previewImg = document.getElementById('test-stream-preview-image');
|
openLightbox(fullImageSrc, buildTestStatsHtml(result));
|
||||||
previewImg.innerHTML = `<img src="${result.full_capture.image}" alt="Stream preview" style="max-width: 100%; border-radius: 4px;">`;
|
|
||||||
|
|
||||||
document.getElementById('test-stream-actual-duration').textContent = `${result.performance.capture_duration_s.toFixed(2)}s`;
|
|
||||||
document.getElementById('test-stream-frame-count').textContent = result.performance.frame_count;
|
|
||||||
document.getElementById('test-stream-actual-fps').textContent = `${result.performance.actual_fps.toFixed(1)} FPS`;
|
|
||||||
document.getElementById('test-stream-avg-capture-time').textContent = `${result.performance.avg_capture_time_ms.toFixed(1)}ms`;
|
|
||||||
|
|
||||||
document.getElementById('test-stream-results').style.display = 'block';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Processing Templates =====
|
// ===== Processing Templates =====
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
Picture streams define the capture pipeline. A raw stream captures from a display using a capture template. A processed stream applies postprocessing to another stream. Assign streams to devices.
|
Picture streams define the capture pipeline. A raw stream captures from a display using a capture template. A processed stream applies postprocessing to another stream. Assign streams to devices.
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<div id="streams-list" class="templates-grid">
|
<div id="streams-list">
|
||||||
<div class="loading-spinner"></div>
|
<div class="loading-spinner"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -460,34 +460,6 @@
|
|||||||
<span data-i18n="templates.test.run">🧪 Run Test</span>
|
<span data-i18n="templates.test.run">🧪 Run Test</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div id="test-template-results" style="display: none; margin-top: 16px;">
|
|
||||||
<div class="test-results-container">
|
|
||||||
<div class="test-preview-section">
|
|
||||||
<div id="test-template-preview-image" class="test-preview-image"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="test-performance-section">
|
|
||||||
<div class="test-performance-stats">
|
|
||||||
<div class="stat-item">
|
|
||||||
<span data-i18n="templates.test.results.duration">Duration:</span>
|
|
||||||
<strong id="test-template-actual-duration">-</strong>
|
|
||||||
</div>
|
|
||||||
<div class="stat-item">
|
|
||||||
<span data-i18n="templates.test.results.frame_count">Frames:</span>
|
|
||||||
<strong id="test-template-frame-count">-</strong>
|
|
||||||
</div>
|
|
||||||
<div class="stat-item">
|
|
||||||
<span data-i18n="templates.test.results.actual_fps">Actual FPS:</span>
|
|
||||||
<strong id="test-template-actual-fps">-</strong>
|
|
||||||
</div>
|
|
||||||
<div class="stat-item">
|
|
||||||
<span data-i18n="templates.test.results.avg_capture_time">Avg Capture:</span>
|
|
||||||
<strong id="test-template-avg-capture-time">-</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -512,34 +484,6 @@
|
|||||||
<span data-i18n="streams.test.run">🧪 Run Test</span>
|
<span data-i18n="streams.test.run">🧪 Run Test</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div id="test-stream-results" style="display: none; margin-top: 16px;">
|
|
||||||
<div class="test-results-container">
|
|
||||||
<div class="test-preview-section">
|
|
||||||
<div id="test-stream-preview-image" class="test-preview-image"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="test-performance-section">
|
|
||||||
<div class="test-performance-stats">
|
|
||||||
<div class="stat-item">
|
|
||||||
<span data-i18n="templates.test.results.duration">Duration:</span>
|
|
||||||
<strong id="test-stream-actual-duration">-</strong>
|
|
||||||
</div>
|
|
||||||
<div class="stat-item">
|
|
||||||
<span data-i18n="templates.test.results.frame_count">Frames:</span>
|
|
||||||
<strong id="test-stream-frame-count">-</strong>
|
|
||||||
</div>
|
|
||||||
<div class="stat-item">
|
|
||||||
<span data-i18n="templates.test.results.actual_fps">Actual FPS:</span>
|
|
||||||
<strong id="test-stream-actual-fps">-</strong>
|
|
||||||
</div>
|
|
||||||
<div class="stat-item">
|
|
||||||
<span data-i18n="templates.test.results.avg_capture_time">Avg Capture:</span>
|
|
||||||
<strong id="test-stream-avg-capture-time">-</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -559,13 +503,7 @@
|
|||||||
<input type="text" id="stream-name" data-i18n-placeholder="streams.name.placeholder" placeholder="My Stream" required>
|
<input type="text" id="stream-name" data-i18n-placeholder="streams.name.placeholder" placeholder="My Stream" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<input type="hidden" id="stream-type" value="raw">
|
||||||
<label for="stream-type" data-i18n="streams.type">Stream Type:</label>
|
|
||||||
<select id="stream-type" onchange="onStreamTypeChange()">
|
|
||||||
<option value="raw" data-i18n="streams.type.raw">Screen Capture</option>
|
|
||||||
<option value="processed" data-i18n="streams.type.processed">Processed</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Raw stream fields -->
|
<!-- Raw stream fields -->
|
||||||
<div id="stream-raw-fields">
|
<div id="stream-raw-fields">
|
||||||
@@ -692,6 +630,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Image Lightbox -->
|
||||||
|
<div id="image-lightbox" class="lightbox" onclick="closeLightbox(event)">
|
||||||
|
<button class="lightbox-close" onclick="closeLightbox()" title="Close">✕</button>
|
||||||
|
<div class="lightbox-content">
|
||||||
|
<img id="lightbox-image" src="" alt="Full size preview">
|
||||||
|
<div id="lightbox-stats" class="lightbox-stats" style="display: none;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="/static/app.js"></script>
|
<script src="/static/app.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// Initialize theme
|
// Initialize theme
|
||||||
|
|||||||
@@ -197,8 +197,14 @@
|
|||||||
"common.edit": "Edit",
|
"common.edit": "Edit",
|
||||||
"streams.title": "\uD83D\uDCFA Picture Streams",
|
"streams.title": "\uD83D\uDCFA Picture Streams",
|
||||||
"streams.description": "Picture streams define the capture pipeline. A raw stream captures from a display using a capture template. A processed stream applies postprocessing to another stream. Assign streams to devices.",
|
"streams.description": "Picture streams define the capture pipeline. A raw stream captures from a display using a capture template. A processed stream applies postprocessing to another stream. Assign streams to devices.",
|
||||||
|
"streams.group.raw": "Screen Capture Streams",
|
||||||
|
"streams.group.processed": "Processed Streams",
|
||||||
"streams.add": "Add Picture Stream",
|
"streams.add": "Add Picture Stream",
|
||||||
|
"streams.add.raw": "Add Screen Capture",
|
||||||
|
"streams.add.processed": "Add Processed Stream",
|
||||||
"streams.edit": "Edit Picture Stream",
|
"streams.edit": "Edit Picture Stream",
|
||||||
|
"streams.edit.raw": "Edit Screen Capture",
|
||||||
|
"streams.edit.processed": "Edit Processed Stream",
|
||||||
"streams.name": "Stream Name:",
|
"streams.name": "Stream Name:",
|
||||||
"streams.name.placeholder": "My Stream",
|
"streams.name.placeholder": "My Stream",
|
||||||
"streams.type": "Type:",
|
"streams.type": "Type:",
|
||||||
@@ -245,7 +251,7 @@
|
|||||||
"postprocessing.config.show": "Show settings",
|
"postprocessing.config.show": "Show settings",
|
||||||
"device.button.stream_selector": "Stream Settings",
|
"device.button.stream_selector": "Stream Settings",
|
||||||
"device.stream_settings.title": "📺 Stream Settings",
|
"device.stream_settings.title": "📺 Stream Settings",
|
||||||
"device.stream_selector.label": "Assigned Picture Stream:",
|
"device.stream_selector.label": "Picture Stream:",
|
||||||
"device.stream_selector.hint": "Select a picture stream that defines what this device captures and processes",
|
"device.stream_selector.hint": "Select a picture stream that defines what this device captures and processes",
|
||||||
"device.stream_selector.none": "-- No stream assigned --",
|
"device.stream_selector.none": "-- No stream assigned --",
|
||||||
"device.stream_selector.saved": "Stream settings updated",
|
"device.stream_selector.saved": "Stream settings updated",
|
||||||
|
|||||||
@@ -197,8 +197,14 @@
|
|||||||
"common.edit": "Редактировать",
|
"common.edit": "Редактировать",
|
||||||
"streams.title": "\uD83D\uDCFA Видеопотоки",
|
"streams.title": "\uD83D\uDCFA Видеопотоки",
|
||||||
"streams.description": "Видеопотоки определяют конвейер захвата. Сырой поток захватывает экран с помощью шаблона захвата. Обработанный поток применяет постобработку к другому потоку. Назначайте потоки устройствам.",
|
"streams.description": "Видеопотоки определяют конвейер захвата. Сырой поток захватывает экран с помощью шаблона захвата. Обработанный поток применяет постобработку к другому потоку. Назначайте потоки устройствам.",
|
||||||
|
"streams.group.raw": "Потоки Захвата Экрана",
|
||||||
|
"streams.group.processed": "Обработанные Потоки",
|
||||||
"streams.add": "Добавить Видеопоток",
|
"streams.add": "Добавить Видеопоток",
|
||||||
|
"streams.add.raw": "Добавить Захват Экрана",
|
||||||
|
"streams.add.processed": "Добавить Обработанный",
|
||||||
"streams.edit": "Редактировать Видеопоток",
|
"streams.edit": "Редактировать Видеопоток",
|
||||||
|
"streams.edit.raw": "Редактировать Захват Экрана",
|
||||||
|
"streams.edit.processed": "Редактировать Обработанный Поток",
|
||||||
"streams.name": "Имя Потока:",
|
"streams.name": "Имя Потока:",
|
||||||
"streams.name.placeholder": "Мой Поток",
|
"streams.name.placeholder": "Мой Поток",
|
||||||
"streams.type": "Тип:",
|
"streams.type": "Тип:",
|
||||||
@@ -245,7 +251,7 @@
|
|||||||
"postprocessing.config.show": "Показать настройки",
|
"postprocessing.config.show": "Показать настройки",
|
||||||
"device.button.stream_selector": "Настройки потока",
|
"device.button.stream_selector": "Настройки потока",
|
||||||
"device.stream_settings.title": "📺 Настройки потока",
|
"device.stream_settings.title": "📺 Настройки потока",
|
||||||
"device.stream_selector.label": "Назначенный Видеопоток:",
|
"device.stream_selector.label": "Видеопоток:",
|
||||||
"device.stream_selector.hint": "Выберите видеопоток, определяющий что это устройство захватывает и обрабатывает",
|
"device.stream_selector.hint": "Выберите видеопоток, определяющий что это устройство захватывает и обрабатывает",
|
||||||
"device.stream_selector.none": "-- Поток не назначен --",
|
"device.stream_selector.none": "-- Поток не назначен --",
|
||||||
"device.stream_selector.saved": "Настройки потока обновлены",
|
"device.stream_selector.saved": "Настройки потока обновлены",
|
||||||
|
|||||||
@@ -2045,6 +2045,131 @@ input:-webkit-autofill:focus {
|
|||||||
margin-right: 6px;
|
margin-right: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Stream group sections */
|
||||||
|
.stream-group {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-group:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-group-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 2px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-group-icon {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-group-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-group-count {
|
||||||
|
background: var(--border-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
min-width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Image Lightbox */
|
||||||
|
.lightbox {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.92);
|
||||||
|
z-index: 10000;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
cursor: zoom-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-content {
|
||||||
|
position: relative;
|
||||||
|
max-width: 95%;
|
||||||
|
max-height: 95%;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-content img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 90vh;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.2s;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-close:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-stats {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8px;
|
||||||
|
left: 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
color: white;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-stats .stat-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-stats .stat-item span {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-stats .stat-item strong {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive adjustments */
|
/* Responsive adjustments */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.templates-grid {
|
.templates-grid {
|
||||||
|
|||||||
Reference in New Issue
Block a user