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.thumbnail((thumbnail_width, thumbnail_height), Image.Resampling.LANCZOS)
|
||||
|
||||
# Encode full capture thumbnail as JPEG
|
||||
# Encode thumbnail as JPEG
|
||||
img_buffer = io.BytesIO()
|
||||
thumbnail.save(img_buffer, format='JPEG', quality=85)
|
||||
img_buffer.seek(0)
|
||||
full_capture_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8')
|
||||
full_capture_data_uri = f"data:image/jpeg;base64,{full_capture_b64}"
|
||||
thumbnail_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8')
|
||||
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
|
||||
actual_fps = frame_count / actual_duration if actual_duration > 0 else 0
|
||||
@@ -1067,7 +1074,8 @@ async def test_template(
|
||||
|
||||
return TemplateTestResponse(
|
||||
full_capture=CaptureImage(
|
||||
image=full_capture_data_uri,
|
||||
image=thumbnail_data_uri,
|
||||
full_image=full_data_uri,
|
||||
width=width,
|
||||
height=height,
|
||||
thumbnail_width=thumbnail_width,
|
||||
@@ -1469,34 +1477,41 @@ async def test_picture_stream(
|
||||
thumbnail = pil_image.copy()
|
||||
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"]
|
||||
if pp_template_ids:
|
||||
try:
|
||||
pp = pp_store.get_template(pp_template_ids[0])
|
||||
img_array = np.array(thumbnail, dtype=np.float32) / 255.0
|
||||
|
||||
if pp.brightness != 1.0:
|
||||
img_array *= pp.brightness
|
||||
def apply_pp(img):
|
||||
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:
|
||||
luminance = np.dot(img_array[..., :3], [0.299, 0.587, 0.114])
|
||||
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)
|
||||
thumbnail = apply_pp(thumbnail)
|
||||
pil_image = apply_pp(pil_image)
|
||||
except ValueError:
|
||||
logger.warning(f"PP template {pp_template_ids[0]} not found, skipping postprocessing preview")
|
||||
|
||||
# Encode thumbnail
|
||||
img_buffer = io.BytesIO()
|
||||
thumbnail.save(img_buffer, format='JPEG', quality=85)
|
||||
img_buffer.seek(0)
|
||||
full_capture_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8')
|
||||
full_capture_data_uri = f"data:image/jpeg;base64,{full_capture_b64}"
|
||||
thumbnail_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8')
|
||||
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
|
||||
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(
|
||||
full_capture=CaptureImage(
|
||||
image=full_capture_data_uri,
|
||||
image=thumbnail_data_uri,
|
||||
full_image=full_data_uri,
|
||||
width=width,
|
||||
height=height,
|
||||
thumbnail_width=thumbnail_width,
|
||||
|
||||
@@ -292,9 +292,10 @@ class TemplateTestRequest(BaseModel):
|
||||
class CaptureImage(BaseModel):
|
||||
"""Captured image with metadata."""
|
||||
|
||||
image: str = Field(description="Base64-encoded image data")
|
||||
width: int = Field(description="Image width in pixels")
|
||||
height: int = Field(description="Image height in pixels")
|
||||
image: str = Field(description="Base64-encoded thumbnail image data")
|
||||
full_image: Optional[str] = Field(None, description="Base64-encoded full-resolution image data")
|
||||
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_height: Optional[int] = Field(None, description="Thumbnail height (if resized)")
|
||||
|
||||
|
||||
@@ -30,6 +30,37 @@ function unlockBody() {
|
||||
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
|
||||
let currentLocale = 'en';
|
||||
let translations = {};
|
||||
@@ -2638,9 +2669,6 @@ async function showTestTemplateModal(templateId) {
|
||||
// Restore last used capture duration
|
||||
restoreCaptureDuration();
|
||||
|
||||
// Reset results
|
||||
document.getElementById('test-template-results').style.display = 'none';
|
||||
|
||||
// Show modal
|
||||
const modal = document.getElementById('test-template-modal');
|
||||
modal.style.display = 'flex';
|
||||
@@ -2836,7 +2864,6 @@ async function runTemplateTest() {
|
||||
}
|
||||
|
||||
const template = window.currentTestingTemplate;
|
||||
const resultsDiv = document.getElementById('test-template-results');
|
||||
|
||||
// Show full-page overlay spinner with progress
|
||||
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) {
|
||||
const resultsDiv = document.getElementById('test-template-results');
|
||||
|
||||
// Hide overlay spinner
|
||||
hideOverlaySpinner();
|
||||
|
||||
// Full capture preview
|
||||
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';
|
||||
const fullImageSrc = result.full_capture.full_image || result.full_capture.image;
|
||||
openLightbox(fullImageSrc, buildTestStatsHtml(result));
|
||||
}
|
||||
|
||||
// Save template
|
||||
@@ -2989,9 +3014,32 @@ function renderPictureStreamsList(streams) {
|
||||
const container = document.getElementById('streams-list');
|
||||
|
||||
if (streams.length === 0) {
|
||||
container.innerHTML = `<div class="template-card add-template-card" onclick="showAddStreamModal()">
|
||||
<div class="add-template-icon">+</div>
|
||||
<div class="add-template-label">${t('streams.add')}</div>
|
||||
container.innerHTML = `
|
||||
<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">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>`;
|
||||
return;
|
||||
}
|
||||
@@ -3046,10 +3094,41 @@ function renderPictureStreamsList(streams) {
|
||||
`;
|
||||
};
|
||||
|
||||
let html = streams.map(renderCard).join('');
|
||||
html += `<div class="template-card add-template-card" onclick="showAddStreamModal()">
|
||||
<div class="add-template-icon">+</div>
|
||||
<div class="add-template-label">${t('streams.add')}</div>
|
||||
const rawStreams = streams.filter(s => s.stream_type === 'raw');
|
||||
const processedStreams = streams.filter(s => s.stream_type === 'processed');
|
||||
|
||||
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>`;
|
||||
|
||||
container.innerHTML = html;
|
||||
@@ -3061,15 +3140,14 @@ function onStreamTypeChange() {
|
||||
document.getElementById('stream-processed-fields').style.display = streamType === 'processed' ? '' : 'none';
|
||||
}
|
||||
|
||||
async function showAddStreamModal() {
|
||||
document.getElementById('stream-modal-title').textContent = t('streams.add');
|
||||
async function showAddStreamModal(presetType) {
|
||||
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-id').value = '';
|
||||
document.getElementById('stream-error').style.display = 'none';
|
||||
document.getElementById('stream-type').disabled = false;
|
||||
|
||||
// Reset to raw type
|
||||
document.getElementById('stream-type').value = 'raw';
|
||||
document.getElementById('stream-type').value = streamType;
|
||||
onStreamTypeChange();
|
||||
|
||||
// Populate dropdowns
|
||||
@@ -3087,15 +3165,15 @@ async function editStream(streamId) {
|
||||
if (!response.ok) throw new Error(`Failed to load stream: ${response.status}`);
|
||||
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-name').value = stream.name;
|
||||
document.getElementById('stream-description').value = stream.description || '';
|
||||
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').disabled = true;
|
||||
onStreamTypeChange();
|
||||
|
||||
// Populate dropdowns before setting values
|
||||
@@ -3286,7 +3364,6 @@ let _currentTestStreamId = null;
|
||||
async function showTestStreamModal(streamId) {
|
||||
_currentTestStreamId = streamId;
|
||||
restoreStreamTestDuration();
|
||||
document.getElementById('test-stream-results').style.display = 'none';
|
||||
|
||||
const modal = document.getElementById('test-stream-modal');
|
||||
modal.style.display = 'flex';
|
||||
@@ -3340,16 +3417,8 @@ async function runStreamTest() {
|
||||
|
||||
function displayStreamTestResults(result) {
|
||||
hideOverlaySpinner();
|
||||
|
||||
const previewImg = document.getElementById('test-stream-preview-image');
|
||||
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';
|
||||
const fullImageSrc = result.full_capture.full_image || result.full_capture.image;
|
||||
openLightbox(fullImageSrc, buildTestStatsHtml(result));
|
||||
}
|
||||
|
||||
// ===== 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.
|
||||
</span>
|
||||
</p>
|
||||
<div id="streams-list" class="templates-grid">
|
||||
<div id="streams-list">
|
||||
<div class="loading-spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -460,34 +460,6 @@
|
||||
<span data-i18n="templates.test.run">🧪 Run Test</span>
|
||||
</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>
|
||||
@@ -512,34 +484,6 @@
|
||||
<span data-i18n="streams.test.run">🧪 Run Test</span>
|
||||
</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>
|
||||
@@ -559,13 +503,7 @@
|
||||
<input type="text" id="stream-name" data-i18n-placeholder="streams.name.placeholder" placeholder="My Stream" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<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>
|
||||
<input type="hidden" id="stream-type" value="raw">
|
||||
|
||||
<!-- Raw stream fields -->
|
||||
<div id="stream-raw-fields">
|
||||
@@ -692,6 +630,15 @@
|
||||
</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>
|
||||
// Initialize theme
|
||||
|
||||
@@ -197,8 +197,14 @@
|
||||
"common.edit": "Edit",
|
||||
"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.group.raw": "Screen Capture Streams",
|
||||
"streams.group.processed": "Processed Streams",
|
||||
"streams.add": "Add Picture Stream",
|
||||
"streams.add.raw": "Add Screen Capture",
|
||||
"streams.add.processed": "Add Processed Stream",
|
||||
"streams.edit": "Edit Picture Stream",
|
||||
"streams.edit.raw": "Edit Screen Capture",
|
||||
"streams.edit.processed": "Edit Processed Stream",
|
||||
"streams.name": "Stream Name:",
|
||||
"streams.name.placeholder": "My Stream",
|
||||
"streams.type": "Type:",
|
||||
@@ -245,7 +251,7 @@
|
||||
"postprocessing.config.show": "Show settings",
|
||||
"device.button.stream_selector": "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.none": "-- No stream assigned --",
|
||||
"device.stream_selector.saved": "Stream settings updated",
|
||||
|
||||
@@ -197,8 +197,14 @@
|
||||
"common.edit": "Редактировать",
|
||||
"streams.title": "\uD83D\uDCFA Видеопотоки",
|
||||
"streams.description": "Видеопотоки определяют конвейер захвата. Сырой поток захватывает экран с помощью шаблона захвата. Обработанный поток применяет постобработку к другому потоку. Назначайте потоки устройствам.",
|
||||
"streams.group.raw": "Потоки Захвата Экрана",
|
||||
"streams.group.processed": "Обработанные Потоки",
|
||||
"streams.add": "Добавить Видеопоток",
|
||||
"streams.add.raw": "Добавить Захват Экрана",
|
||||
"streams.add.processed": "Добавить Обработанный",
|
||||
"streams.edit": "Редактировать Видеопоток",
|
||||
"streams.edit.raw": "Редактировать Захват Экрана",
|
||||
"streams.edit.processed": "Редактировать Обработанный Поток",
|
||||
"streams.name": "Имя Потока:",
|
||||
"streams.name.placeholder": "Мой Поток",
|
||||
"streams.type": "Тип:",
|
||||
@@ -245,7 +251,7 @@
|
||||
"postprocessing.config.show": "Показать настройки",
|
||||
"device.button.stream_selector": "Настройки потока",
|
||||
"device.stream_settings.title": "📺 Настройки потока",
|
||||
"device.stream_selector.label": "Назначенный Видеопоток:",
|
||||
"device.stream_selector.label": "Видеопоток:",
|
||||
"device.stream_selector.hint": "Выберите видеопоток, определяющий что это устройство захватывает и обрабатывает",
|
||||
"device.stream_selector.none": "-- Поток не назначен --",
|
||||
"device.stream_selector.saved": "Настройки потока обновлены",
|
||||
|
||||
@@ -2045,6 +2045,131 @@ input:-webkit-autofill:focus {
|
||||
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 */
|
||||
@media (max-width: 768px) {
|
||||
.templates-grid {
|
||||
|
||||
Reference in New Issue
Block a user