diff --git a/README.md b/README.md index d8c2051..ad4661f 100644 --- a/README.md +++ b/README.md @@ -290,6 +290,13 @@ This first version intentionally does **not** silently skip codecs or folders. It ranks and labels items, but leaves the final decision in the manifest and review loop. +The checked-in video defaults are intentionally operator-taste defaults, not a +near-transparent archival preset. The baseline AV1 policy targets VMAF 85 with +an 80 floor and source resolution (`max_height = 0`) so measured samples pursue +small, visually acceptable TV encodes first. Raise the VMAF targets or add a +folder override when a class needs a more conservative pass; use an explicit +scale request when downsampling is desired. + `report`, `encode`, and `validate` all surface source-vs-staged size deltas so you can see the storage win before promotion. diff --git a/config/defaults.toml b/config/defaults.toml index 685b9c6..1c4b7d0 100644 --- a/config/defaults.toml +++ b/config/defaults.toml @@ -26,10 +26,10 @@ encoder = "libsvtav1" pixel_format = "yuv420p10le" preset = 4 crf_search = true -quality_metric = "auto" -target_vmaf = 95.0 +quality_metric = "vmaf" +target_vmaf = 85.0 target_xpsnr = 41.0 -min_target_vmaf = 93.0 +min_target_vmaf = 80.0 min_target_xpsnr = 35.0 target_relax_step_vmaf = 0.5 target_relax_step_xpsnr = 1.0 @@ -41,7 +41,7 @@ max_encoded_percent = 80 default_grain = 8 grain_denoise = 0 thorough = true -max_height = 0 +max_height = 1080 downsample_algorithm = "lanczos" black_bar_handling = "off" black_bar_detect_samples = 3 diff --git a/config/web-smoke.toml b/config/web-smoke.toml index 922177f..630e55e 100644 --- a/config/web-smoke.toml +++ b/config/web-smoke.toml @@ -24,10 +24,10 @@ encoder = "libsvtav1" pixel_format = "yuv420p10le" preset = 4 crf_search = true -quality_metric = "auto" -target_vmaf = 95.0 +quality_metric = "vmaf" +target_vmaf = 85.0 target_xpsnr = 41.0 -min_target_vmaf = 93.0 +min_target_vmaf = 80.0 min_target_xpsnr = 35.0 target_relax_step_vmaf = 0.5 target_relax_step_xpsnr = 1.0 @@ -39,7 +39,7 @@ max_encoded_percent = 80 default_grain = 8 grain_denoise = 0 thorough = true -max_height = 0 +max_height = 1080 downsample_algorithm = "lanczos" black_bar_handling = "off" black_bar_detect_samples = 3 diff --git a/docs/development/browser-qa-matrix.md b/docs/development/browser-qa-matrix.md index 91ea7d3..c7b354b 100644 --- a/docs/development/browser-qa-matrix.md +++ b/docs/development/browser-qa-matrix.md @@ -9,7 +9,7 @@ that a single live machine state happens to render. - Full route and fixture smoke: `npm --prefix frontend run smoke:web` - Existing live app smoke: - `npm --prefix frontend run smoke:web -- --base-url http://127.0.0.1:5555` + `npm --prefix frontend run smoke:web -- --base-url http://127.0.0.1:8777` - Skip narrow checks only for non-UI diagnostics: `npm --prefix frontend run smoke:web -- --skip-narrow` diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index c9210bc..2f24c4e 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -312,6 +312,16 @@ export interface SettingsPayload { libraries: SettingsLibrary[]; remote_hosts: SettingsHost[]; transcode_root: string; + video_defaults: { + quality_metric: string; + target_vmaf: string; + min_target_vmaf: string; + target_xpsnr: string; + min_target_xpsnr: string; + max_height: string; + default_grain: string; + max_encoded_percent: string; + }; encode_queue_scheduler: { mode: string; start_hour: number; diff --git a/frontend/src/lib/components/settings/SettingsEditor.svelte b/frontend/src/lib/components/settings/SettingsEditor.svelte index 0c0ef9f..1554f28 100644 --- a/frontend/src/lib/components/settings/SettingsEditor.svelte +++ b/frontend/src/lib/components/settings/SettingsEditor.svelte @@ -64,6 +64,16 @@ libraries: [], remote_hosts: [], transcode_root: '', + video_defaults: { + quality_metric: 'vmaf', + target_vmaf: '85', + min_target_vmaf: '80', + target_xpsnr: '41', + min_target_xpsnr: '35', + max_height: '1080', + default_grain: '8', + max_encoded_percent: '80' + }, schedule_profiles: [] }; @@ -113,6 +123,7 @@ const archiveCleanup = $derived(savedSettings?.archive_cleanup ?? null); const dirty = $derived(savedSettings ? settingsDraftIsDirty(draft, savedSettings) : false); const savedArchiveRootCopy = $derived(savedSettings?.archive_root || 'unset'); + const defaultMetricCopy = $derived(metricDefaultsCopy(draft.video_defaults)); const cleanupTargetDirty = $derived( savedSettings ? archiveCleanupTargetDirty(draft, savedSettings) : false ); @@ -157,6 +168,7 @@ } ]); const footerSignals = $derived([ + { label: 'Assistant defaults', value: defaultMetricCopy, tone: 'ready' as BadgeTone }, { label: 'Runtime', value: savedSettings?.runtime_settings_path ?? 'unavailable', @@ -228,6 +240,19 @@ ); } + function updateVideoDefault(key: keyof SettingsPayload['video_defaults'], value: string) { + draft.video_defaults = { ...draft.video_defaults, [key]: value }; + } + + function metricDefaultsCopy(defaults: SettingsPayload['video_defaults']) { + const metric = defaults.quality_metric.trim().toLowerCase(); + if (metric === 'xpsnr') + return `XPSNR ${defaults.target_xpsnr} / floor ${defaults.min_target_xpsnr}`; + if (metric === 'auto') + return `Auto · VMAF ${defaults.target_vmaf} · XPSNR ${defaults.target_xpsnr}`; + return `VMAF ${defaults.target_vmaf} / floor ${defaults.min_target_vmaf}`; + } + function toggleScheduleDay( index: number, dayKey: ScheduleDayKey, @@ -397,6 +422,10 @@ Storage {draft.transcode_root.trim() ? 'Set' : 'Missing'} + + Assistant defaults + {defaultMetricCopy} + Work windows {(configuredProfiles.length + 2).toLocaleString('en-US')} @@ -537,6 +566,119 @@ + +
+ +
+ + + + + + + + +
+ Resolution + {Number(draft.video_defaults.max_height) > 0 + ? `max ${draft.video_defaults.max_height}p` + : 'source resolution'} + Used when the assistant note does not request a resolution change. +
+
+
+
@@ -1023,6 +1165,7 @@ Sections Libraries Storage + Assistant defaults Work windows Workers Cleanup @@ -1156,7 +1299,7 @@ .settings-overview { display: grid; gap: var(--mf-space-3); - grid-template-columns: repeat(5, minmax(0, 1fr)); + grid-template-columns: repeat(6, minmax(0, 1fr)); } .settings-overview a { @@ -1393,6 +1536,7 @@ .host-grid label span, .schedule-row__fields label span, .storage-readout span, + .encode-defaults-readout span, .danger-zone > div > span, .option-label, .rail-row span { @@ -1410,7 +1554,20 @@ padding: var(--mf-space-5); } + .encode-defaults-grid { + align-items: end; + display: grid; + gap: var(--mf-space-4); + grid-template-columns: minmax(150px, 1.1fr) repeat(5, minmax(88px, 0.65fr)) minmax(170px, 1.2fr); + padding: var(--mf-space-5); + } + + .encode-defaults-grid .field--number { + max-width: none; + } + .storage-readout, + .encode-defaults-readout, .danger-zone > div, .rail-row { display: grid; @@ -1419,6 +1576,7 @@ } .storage-readout strong, + .encode-defaults-readout strong, .danger-zone strong, .rail-row strong, .host-editor__head strong, @@ -1429,6 +1587,7 @@ } .storage-readout small, + .encode-defaults-readout small, .danger-zone small, .rail-row small, .option-hint, diff --git a/frontend/src/lib/components/workstation/FolderStudioView.svelte b/frontend/src/lib/components/workstation/FolderStudioView.svelte index 7af9364..cb53878 100644 --- a/frontend/src/lib/components/workstation/FolderStudioView.svelte +++ b/frontend/src/lib/components/workstation/FolderStudioView.svelte @@ -4,10 +4,7 @@ import { postJson } from '$lib/api/client'; import { folderRoutePrefix } from '$lib/folder-display'; import { - codecLabel, - formatBitrateCopy, formatDateTimeCopy, - formatResolutionCopy, pathFilename, type FolderCalibrationJob, type FolderCalibrationState, @@ -31,10 +28,12 @@ buildWorkflowSteps, buildFooterSignals, buildProposalRows, + buildOutputReviewRows, + buildDecisionFacts, buildSampleFacts, + buildSampleVerdict, buildStatusTiles, formatBytes, - buildSampleVerdict, predictedFolderSizeBytes, projectedReclaimBytes, record, @@ -44,7 +43,6 @@ resolveReviewArtifacts, resolveWorkflowActionState, resolveWorkflow, - reviewReadyCopy, summarizeStatuses, type WorkflowAction } from './folder-studio-view'; @@ -101,6 +99,13 @@ const retryableSampleJob = $derived(record(status.retryable_sample_job)); const encodeJob = $derived(studioFolder.encode_job ?? null); const reviewArtifacts = $derived(resolveReviewArtifacts(calibration, pendingProposal)); + const reviewPackReady = $derived( + Boolean( + reviewArtifacts.length > 0 || + calibration?.browser_review_ready || + calibration?.review_media_ready + ) + ); const workflow = $derived( resolveWorkflow( studioFolder, @@ -109,21 +114,36 @@ pendingProposal, reviewGate, calibrationJob, - encodeJob + encodeJob, + reviewPackReady ) ); const workflowSteps = $derived(buildWorkflowSteps(workflow)); - const currentStepIndex = $derived( - Math.max( - workflowSteps.findIndex((step) => step.current), - 0 - ) - ); const proposalRows = $derived(buildProposalRows(studioFolder, pendingProposal)); const statusTiles = $derived(buildStatusTiles(studioFolder, status, hosts, workflow)); const footerSignals = $derived(buildFooterSignals(studioFolder, status, hosts)); const sampleFacts = $derived(buildSampleFacts(sampleItem, summary)); const sampleVerdict = $derived(buildSampleVerdict(studioFolder, calibration)); + const outputReviewRows = $derived( + buildOutputReviewRows(studioFolder, calibration, pendingProposal) + ); + const decisionFacts = $derived(buildDecisionFacts(studioFolder, calibration, pendingProposal)); + const sampleResultRow = $derived( + outputReviewRows.find((row) => row.label === 'Sample result') ?? null + ); + const draftReviewRow = $derived( + outputReviewRows.find( + (row) => row.label === 'Next sample draft' || row.label === 'Video output' + ) ?? null + ); + const visualReviewArtifacts = $derived( + [...reviewArtifacts.filter((artifact) => artifact.category === 'visual')] + .sort((left, right) => reviewArtifactPriority(left.kind) - reviewArtifactPriority(right.kind)) + .slice(0, 6) + ); + const audioReviewArtifacts = $derived( + reviewArtifacts.filter((artifact) => artifact.category === 'audio').slice(0, 2) + ); const benchMessages = $derived( buildBenchMessages(calibration, pendingProposal, retryableSampleJob) ); @@ -138,11 +158,6 @@ ) ); const benchRequestDisabled = $derived(benchRequestState.disabled); - const reviewPackReady = $derived( - reviewArtifacts.length > 0 || reviewReadyCopy(calibration) === 'Ready' - ); - const primaryActionState = $derived(workflowActionState(workflow.primaryAction)); - function workflowActionState(action: WorkflowAction) { return resolveWorkflowActionState(action, { reviewPackReady, @@ -152,6 +167,20 @@ }); } + function reviewArtifactPriority(kind: string) { + if (/contact_sheet/i.test(kind)) return 0; + if (/timeline/i.test(kind)) return 1; + return 2; + } + + function mediaAssetHref(url: string) { + return url; + } + + function openReviewMedia(url: string) { + window.open(mediaAssetHref(url), '_blank', 'noreferrer'); + } + function focusBenchComposer() { benchTextarea?.focus(); benchTextarea?.scrollIntoView({ block: 'center' }); @@ -202,7 +231,7 @@ } async function saveProfileAndQueue(action: WorkflowAction) { - if (action !== 'queue-encode') return; + if (action !== 'queue-encode' && action !== 'approve-size-tradeoff') return; const actionState = workflowActionState(action); if (actionState.disabled) return; workflowPending = action; @@ -213,6 +242,7 @@ `${resolve('/')}api/folders/${encodedPrefix}/save-profile`, { confirm_high_impact: true, + confirm_size_tradeoff: action === 'approve-size-tradeoff', reviewed_draft_hash: calibration?.draft_hash ?? '' } ); @@ -225,6 +255,27 @@ workflowPending = null; } } + + async function stopSample() { + const action = 'stop-sample'; + const actionState = workflowActionState(action); + if (actionState.disabled) return; + workflowPending = action; + benchMessage = ''; + benchError = ''; + try { + const response = await postJson<{ ok?: boolean; message?: string }>( + `${resolve('/')}api/calibration-queue/stop`, + {} + ); + benchMessage = response.message || 'Stopped running and queued sample work.'; + await invalidateAll(); + } catch (error) { + benchError = error instanceof Error ? error.message : 'Sample work could not be stopped.'; + } finally { + workflowPending = null; + } + }
-
+

{workflow.title}

{workflow.copy}

-
- Step {currentStepIndex + 1} next action - {workflow.primary} - {primaryActionState.disabled ? primaryActionState.title : 'Ready now'} -
-
- {#if sampleVerdict} -
- Per episode - {sampleVerdict.predictedPerItem} - {sampleVerdict.targetDelta || `Target ${sampleVerdict.target}`} -
-
- Folder output - {sampleVerdict.predictedFolderTotal} - Projected total -
-
- Reclaim - {sampleVerdict.reclaim} - {sampleVerdict.quality} +
+ {#each decisionFacts as fact (fact.label)} +
+ {fact.label} + {fact.value} + {fact.detail}
- {:else} -
- Review pack - {reviewArtifacts.length - ? `${reviewArtifacts.length} artifacts` - : reviewReadyCopy(calibration)} -
-
- Sample - {sampleItem ? pathFilename(sampleItem.rel_path) : '—'} -
- {/if} + {/each}
+ {#if reviewPackReady && workflow.primaryAction !== 'download-review-pack' && workflow.secondaryAction !== 'download-review-pack'} +
+ +
+ {/if} {#if workflow.primaryAction === 'focus-bench'} - {:else if workflow.primaryAction === 'queue-encode'} + {:else if workflow.primaryAction === 'queue-encode' || workflow.primaryAction === 'approve-size-tradeoff'} {/if} - {#if workflow.secondaryAction === 'focus-bench'} + {#if workflow.secondaryAction === 'focus-bench' || workflow.secondaryAction === 'revise-proposal'} + {:else if workflow.secondaryAction === 'queue-encode' || workflow.secondaryAction === 'approve-size-tradeoff'}
+
+
+
+ +

Previous sample evidence

+
+
+ +
+
+ Area + Source + Output / draft + Why it matters +
+ {#each outputReviewRows as row (row.label)} +
+ {row.label} + {row.source} + {row.output} + {row.detail} +
+ {/each} +
+ +
+ {#each visualReviewArtifacts as artifact (artifact.imageUrl || artifact.label)} + + {:else} +
+ Run a sample to generate source-versus-draft review images. +
+ {/each} + {#each audioReviewArtifacts as artifact (artifact.imageUrl || artifact.label)} + + {/each} +
+
+
- +
-
Codec
-
{codecLabel(sampleItem?.video_codec) || '—'}
-
Resolution
-
{formatResolutionCopy(sampleItem?.width, sampleItem?.height) ?? '—'}
-
Bitrate
-
{formatBitrateCopy(sampleItem?.video_bitrate) ?? '—'}
+
{pendingProposal?.proposal_id ? 'Draft' : 'Source'}
+
{draftReviewRow?.output ?? '—'}
+
Reason
+
{draftReviewRow?.detail ?? '—'}
+
Sample
+
+ {sampleResultRow ? `${sampleResultRow.output} from ${sampleResultRow.source}` : '—'} +
Metric
{resolvedMetricCopy(studioFolder)}
@@ -554,7 +658,7 @@
-
+
@@ -575,26 +679,6 @@
- - -
- {#each reviewArtifacts.slice(0, 4) as artifact (artifact.label + artifact.detail)} -
- -
- {artifact.label || 'Review artifact'} - {artifact.detail || artifact.imageUrl || 'No detail returned'} -
-
- {:else} -
Review pack artifacts are not available yet.
- {/each} -
-
@@ -662,17 +746,17 @@
- {#each hosts.hosts.slice(0, 6) as host (host.key)} + {#each sampleHostOptions.slice(0, 6) as host (host.key)}
- {host.message || host.schedule_detail || 'No detail'} + {host.label}{host.detail ? ` · ${host.detail}` : ''}
{/each} - {#if hosts.hosts.length === 0} + {#if sampleHostOptions.length === 0}
Worker status is unavailable.
{/if}
@@ -733,8 +817,7 @@ } .folder-header__facts, - .decision__next, - .decision__metrics, + .decision__facts, .sample-facts { display: flex; gap: var(--mf-space-5); @@ -746,12 +829,10 @@ grid-template-columns: repeat(3, minmax(0, 1fr)); } - .decision__next { + .decision__facts { background: var(--mf-bg-panel-2); border: var(--mf-border-muted); border-left: 2px solid var(--decision-line); - display: grid; - gap: var(--mf-space-2); padding: var(--mf-space-4); } @@ -760,7 +841,7 @@ } .folder-header__facts div, - .decision__metrics div, + .decision-fact, .sample-facts div, .context-list div { display: grid; @@ -770,8 +851,7 @@ .folder-header__facts span, .folder-header__path span, - .decision__next span, - .decision__metrics span, + .decision-fact span, .sample-facts span, .context-list span { color: var(--mf-fg-tertiary); @@ -783,8 +863,7 @@ .folder-header__facts strong, .folder-header__path strong, - .decision__next strong, - .decision__metrics strong, + .decision-fact strong, .sample-facts strong, .context-list strong { font-family: var(--mf-font-mono), monospace; @@ -793,7 +872,7 @@ overflow-wrap: anywhere; } - .decision__next small { + .decision-fact small { color: var(--mf-fg-tertiary); font-size: var(--mf-text-xs); } @@ -878,10 +957,16 @@ border-left: 3px solid var(--decision-line); display: grid; gap: var(--mf-space-6); - grid-template-columns: minmax(0, 1fr) minmax(180px, 240px) minmax(160px, 240px) auto; + grid-template-columns: minmax(24rem, 1.25fr) minmax(14rem, 0.75fr); padding: var(--mf-space-6); } + .decision__summary, + .decision__facts, + .decision__actions { + min-width: 0; + } + .decision--active { --decision-line: var(--mf-active-fg); } @@ -907,11 +992,21 @@ max-width: 68ch; } + .decision__facts { + grid-column: 1 / -1; + } + + .decision-fact { + flex: 1 1 0; + } + .decision__actions { align-items: center; display: flex; gap: var(--mf-space-4); flex-wrap: wrap; + grid-column: 1 / -1; + justify-content: flex-end; min-width: 0; } @@ -933,6 +1028,133 @@ color: var(--mf-fail-fg); } + .review-workspace { + background: var(--mf-bg-panel); + border: var(--mf-border); + border-left: 3px solid var(--mf-ready-fg); + display: grid; + gap: var(--mf-space-5); + padding: var(--mf-space-5); + } + + .review-workspace__header { + align-items: start; + display: grid; + gap: var(--mf-space-5); + grid-template-columns: minmax(0, 1fr); + } + + .review-workspace h2 { + font-size: var(--mf-text-lg); + margin-top: var(--mf-space-3); + } + + .output-review-table__head span { + color: var(--mf-fg-tertiary); + font-size: var(--mf-text-2xs); + font-weight: var(--mf-weight-semibold); + letter-spacing: 0.08em; + text-transform: uppercase; + } + + .output-review-table { + border: var(--mf-border-muted); + display: grid; + overflow: hidden; + } + + .output-review-table__head, + .output-review-row { + display: grid; + gap: var(--mf-space-4); + grid-template-columns: minmax(110px, 0.8fr) minmax(130px, 1fr) minmax(150px, 1.1fr) minmax( + 180px, + 1.4fr + ); + min-width: 0; + } + + .output-review-table__head { + background: var(--mf-bg-strip); + border-bottom: var(--mf-border-muted); + padding: var(--mf-space-3) var(--mf-space-4); + } + + .output-review-row { + background: var(--mf-bg-panel-2); + border-bottom: var(--mf-border-muted); + padding: var(--mf-space-4); + } + + .output-review-row:last-child { + border-bottom: 0; + } + + .output-review-row--wait { + box-shadow: inset 3px 0 0 var(--mf-wait-fg); + } + + .output-review-row strong, + .output-review-row span { + font-family: var(--mf-font-mono), monospace; + font-size: var(--mf-text-xs); + overflow-wrap: anywhere; + } + + .output-review-row strong { + font-family: inherit; + font-weight: var(--mf-weight-semibold); + } + + .output-review-row small { + color: var(--mf-fg-tertiary); + font-size: var(--mf-text-xs); + line-height: var(--mf-leading-snug); + overflow-wrap: anywhere; + } + + .review-media-grid { + display: grid; + gap: var(--mf-space-4); + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .review-media-tile { + background: var(--mf-bg-input); + border: var(--mf-border-muted); + color: var(--mf-fg-primary); + cursor: pointer; + display: grid; + gap: var(--mf-space-3); + font: inherit; + min-width: 0; + padding: var(--mf-space-3); + text-align: left; + text-decoration: none; + } + + .review-media-tile:hover { + border-color: var(--mf-active-line); + } + + .review-media-tile img { + aspect-ratio: 16 / 9; + background: var(--mf-bg-base); + border: var(--mf-border-muted); + object-fit: contain; + width: 100%; + } + + .review-media-tile span { + font-size: var(--mf-text-xs); + font-weight: var(--mf-weight-semibold); + overflow-wrap: anywhere; + } + + .review-media-tile--audio { + grid-column: 1 / -1; + } + .action-form { margin: 0; } @@ -1158,6 +1380,10 @@ grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr); } + .evidence-grid--sample-only { + grid-template-columns: minmax(0, 1fr); + } + .sample-card { display: grid; gap: var(--mf-space-5); @@ -1188,7 +1414,6 @@ overflow-wrap: anywhere; } - .artifact-list, .host-list, .history-list, .step-list { @@ -1197,7 +1422,6 @@ padding: var(--mf-space-5); } - .artifact-row, .host-row { align-items: start; border-bottom: var(--mf-border-muted); @@ -1207,7 +1431,6 @@ padding-bottom: var(--mf-space-4); } - .artifact-row strong, .history-list strong { display: block; font-size: var(--mf-text-sm); @@ -1215,7 +1438,6 @@ overflow-wrap: anywhere; } - .artifact-row span, .host-row span, .history-list span { color: var(--mf-fg-tertiary); @@ -1394,11 +1616,16 @@ } .decision { - grid-template-columns: minmax(0, 1fr) minmax(180px, 240px); + grid-template-columns: minmax(0, 1fr); + } + + .decision__facts { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); } .decision__actions { - grid-column: 1 / -1; + justify-content: flex-start; } } @@ -1422,14 +1649,27 @@ .workflow-strip, .decision, .bench, + .review-workspace__header, .support-grid, .evidence-grid { grid-template-columns: 1fr; } + .output-review-table__head { + display: none; + } + + .output-review-row { + grid-template-columns: 1fr; + gap: var(--mf-space-2); + } + + .review-media-grid { + grid-template-columns: 1fr; + } + .folder-header__facts, - .decision__next, - .decision__metrics, + .decision__facts, .sample-facts, .decision__actions { flex-wrap: wrap; diff --git a/frontend/src/lib/components/workstation/HomeWorkbenchView.svelte b/frontend/src/lib/components/workstation/HomeWorkbenchView.svelte index 780538b..32911d8 100644 --- a/frontend/src/lib/components/workstation/HomeWorkbenchView.svelte +++ b/frontend/src/lib/components/workstation/HomeWorkbenchView.svelte @@ -46,7 +46,7 @@ let searchQuery = $state(''); let libraryFilters = $state>({}); let stateFilters = $state>({}); - let filtersLoaded = $state(false); + let loadedFilterMode = $state<'queue' | 'folders' | null>(null); const libraryOptions = $derived(buildLibraryOptions(folders)); const stateOptions = $derived(buildStateOptions(folders)); const normalizedSearchQuery = $derived(searchQuery.trim().toLowerCase()); @@ -190,10 +190,13 @@ } function restoreFilters() { - if (!browser || filtersLoaded) return; - filtersLoaded = true; + if (!browser || loadedFilterMode === mode) return; + loadedFilterMode = mode; const filters = readStoredWorkbenchFilters(mode); if (!filters) { + searchQuery = ''; + libraryFilters = {}; + stateFilters = {}; clearStoredWorkbenchFilters(mode); return; } @@ -203,7 +206,7 @@ } function persistFilters() { - if (!browser || !filtersLoaded) return; + if (!browser || loadedFilterMode !== mode) return; writeStoredWorkbenchFilters(mode, { searchQuery, libraryFilters, stateFilters }); } diff --git a/frontend/src/lib/components/workstation/folder-studio-view.test.ts b/frontend/src/lib/components/workstation/folder-studio-view.test.ts index 3a21033..40aa32b 100644 --- a/frontend/src/lib/components/workstation/folder-studio-view.test.ts +++ b/frontend/src/lib/components/workstation/folder-studio-view.test.ts @@ -4,6 +4,9 @@ import type { FolderCalibrationJob } from '$lib/folders/studio'; import { buildBenchHostOptions, buildBudgetEnforcementView, + buildDecisionFacts, + buildOutputReviewRows, + buildSampleFacts, buildSampleVerdict, buildWorkflowSteps, predictedFolderSizeBytes, @@ -69,12 +72,47 @@ describe('Folder Studio review request mapping', () => { ]); expect(options).toEqual([ - { key: 'sample-host', label: 'Sample host', detail: 'folder match', available: true }, - { key: 'offline', label: 'Offline host', detail: 'missing media', available: false } + { + key: 'sample-host', + label: 'Sample host', + detail: 'folder match', + available: true, + scheduleOpen: null, + state: 'Ready for samples' + }, + { + key: 'offline', + label: 'Offline host', + detail: 'missing media', + available: false, + scheduleOpen: null, + state: 'Unavailable' + } ]); expect(buildBenchHostOptions(undefined)).toEqual([]); }); + it('marks off-schedule workers as usable for samples but not encode-ready', () => { + const options = buildBenchHostOptions([ + { key: 'm4', label: 'M4', available: true, schedule_open: false } + ]); + + expect(options).toEqual([ + { + key: 'm4', + label: 'M4', + detail: '', + available: true, + scheduleOpen: false, + state: 'Sample ok, encode later' + } + ]); + expect(resolveBenchRequestState('try 300MB', 'm4', options, null, false)).toMatchObject({ + disabled: false, + blocker: '' + }); + }); + it('enables send only for a note, available host, and inactive sample job', () => { const options = buildBenchHostOptions([ { key: 'studio-mini', label: 'Studio Mini', available: true }, @@ -123,6 +161,27 @@ describe('Folder Studio review request mapping', () => { calibrationJob: null }) ).toEqual({ disabled: false, title: '' }); + expect( + resolveWorkflowActionState('revise-proposal', { + reviewPackReady: false, + pendingProposal: null, + calibrationJob: null + }) + ).toEqual({ disabled: false, title: '' }); + expect( + resolveWorkflowActionState('stop-sample', { + reviewPackReady: false, + pendingProposal: null, + calibrationJob: { status: 'running' } as FolderCalibrationJob + }) + ).toEqual({ disabled: false, title: '' }); + expect( + resolveWorkflowActionState('stop-sample', { + reviewPackReady: false, + pendingProposal: null, + calibrationJob: null + }) + ).toEqual({ disabled: true, title: 'No sample job is running.' }); expect( resolveWorkflowActionState('queue-encode', { @@ -205,7 +264,139 @@ describe('Folder Studio review request mapping', () => { expect(projectedReclaimBytes(folder)).toBe(62_485_704_339); }); - it('turns an over-budget sample into a revise-first verdict and workflow', () => { + it('surfaces concrete output, audio, subtitle, and review evidence facts', () => { + const calibration = { + browser_review_ready: true, + review_media_ready: true, + sample_result: { + predicted_total_size_bytes: 803_322_876, + quality_metric: 'VMAF', + quality_score: 95.0448 + }, + advice: { + operator_request: { + budget_bytes: 314_572_800, + budget_label: '300 MB per episode' + }, + multimodal_review_pack: { + artifacts: [ + { + kind: 'video_contact_sheet', + label: 'Review moment 1', + image_url: '/review-media/moment-1.png' + }, + { + kind: 'audio_spectrogram_compare', + label: 'Primary audio compare', + image_url: '/review-media/audio.png' + } + ], + audio_plan: { + summary: 'Primary track ac3 is planned for Opus at 256k.' + } + } + } + } as FolderCalibrationState; + const pendingProposal = { + proposal_id: 'draft-hard-cap', + can_queue: true, + operator_request: { + request_text: + 'I am okay lowering quality or downscaling if needed to actually hit the target.' + }, + preview_policy: { + video: { + encoder: 'libsvtav1', + max_height: 720, + max_encoded_percent: 7, + quality_metric: 'vmaf', + target_vmaf: 89, + min_target_vmaf: 87, + default_grain: 0 + }, + audio: { + convert_to_opus_codecs: ['ac3'], + keep_languages: ['eng'], + surround_5_1_opus_bitrate: '256k' + }, + subtitle: { keep_languages: ['eng'], prefer_text: true, keep_forced: true } + } + } as PendingSampleProposal; + const rows = buildOutputReviewRows( + folderPayload({ + summary: folderSummary({ item_count: 22, total_size_bytes: 80_158_807_611 }), + sample_item: { + rel_path: 'tv/Example/Season 1/Example.S01E01.mkv', + source_size_bytes: 4_349_049_136, + duration_seconds: 3161.376, + video_codec: 'hevc', + width: 1920, + height: 1080, + audio_summary: [ + { codec_name: 'ac3', channels: 6, language: 'eng', bit_rate: '640000', default: 1 } + ], + subtitle_summary: [{ codec_name: 'hdmv_pgs_subtitle', language: 'eng' }] + }, + item_plan: { + video: { source_codec: 'hevc', output_codec: 'av1' }, + audio: { + source_codec: 'ac3', + output_codec: 'opus', + output_bitrate: '256k', + channels: 6, + language: 'eng', + action: 'convert', + source_track_count: 1, + kept_track_count: 1 + }, + subtitles: { + source_track_count: 1, + kept_track_count: 1, + languages: ['eng'], + codecs: ['hdmv_pgs_subtitle'] + } + }, + calibration, + pending_proposal: pendingProposal + }), + calibration, + pendingProposal + ); + + expect(rows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + label: 'Measured sample', + source: 'source 4.1 GiB', + output: '766 MiB', + detail: 'target 300 MB per episode · 2.6x target · folder 16.5 GiB', + tone: 'wait' + }), + expect.objectContaining({ + label: 'Next sample draft', + output: 'AV1 · max 720p', + detail: 'VMAF target 89 · floor 87 · downscale allowed by the size request · grain off' + }), + expect.objectContaining({ + label: 'Audio', + source: 'AC-3 · 640 kbps · 5.1 · ENG · default', + output: 'Primary track ac3 is planned for Opus at 256k.' + }), + expect.objectContaining({ + label: 'Subtitles', + source: '1 subtitle track · ENG PGS', + output: 'Keep 1 subtitle track' + }), + expect.objectContaining({ + label: 'Review media', + source: '2 artifacts ready', + output: 'Visible below' + }) + ]) + ); + }); + + it('lets the operator approve an over-budget sample when the preview looks good', () => { const calibration = { browser_review_ready: true, review_media_ready: true, @@ -231,6 +422,12 @@ describe('Folder Studio review request mapping', () => { item_count: 22, total_size_bytes: 80_158_807_611 }), + sample_item: { + rel_path: 'tv/Example/Season 1/Episode.mkv', + source_size_bytes: 4_349_049_136, + duration_seconds: 3161.376, + video_codec: 'hevc' + }, calibration }); @@ -240,21 +437,65 @@ describe('Folder Studio review request mapping', () => { predictedPerItem: '766 MiB', predictedFolderTotal: '16.5 GiB', reclaim: '58.2 GiB', + predictedBitrate: '2 Mbps', + targetBitrate: '796 kbps', targetDelta: '2.6x target', missesTarget: true }); expect( - resolveWorkflow(folder, folderStatusPayload(), calibration, null, null, null, null) + resolveWorkflow(folder, folderStatusPayload(), calibration, null, null, null, null, true) ).toMatchObject({ label: 'Target missed', - primary: 'Revise sample', - primaryAction: 'focus-bench', - secondary: 'Download review pack' + title: 'Approve this size or revise smaller', + primary: 'Approve anyway and queue', + primaryAction: 'approve-size-tradeoff', + secondary: 'Revise smaller' }); }); - it('keeps an over-budget sample revise-first when a stale warning draft exists', () => { + it('waits for review media before allowing an over-budget approval', () => { + const calibration = { + sample_result: { + predicted_total_size_bytes: 803_322_876, + quality_metric: 'VMAF', + quality_score: 95.0448 + }, + advice: { + operator_request: { + budget_bytes: 314_572_800, + budget_label: '300 MB per episode' + }, + run_verdict: { outcome: 'poor_fit' } + } + } as FolderCalibrationState; + + expect( + resolveWorkflow( + folderPayload({ calibration, sample_item: { rel_path: 'tv/show/e01.mkv' } }), + folderStatusPayload(), + calibration, + null, + null, + null, + null, + false + ) + ).toMatchObject({ + label: 'Review pending', + primary: 'Open Ops', + secondary: 'Revise smaller' + }); + expect( + resolveWorkflowActionState('approve-size-tradeoff', { + reviewPackReady: false, + pendingProposal: null, + calibrationJob: null + }) + ).toEqual({ disabled: true, title: 'Review media is not ready yet.' }); + }); + + it('surfaces a draft warning before over-budget review evidence', () => { const calibration = { browser_review_ready: true, review_media_ready: true, @@ -298,10 +539,200 @@ describe('Folder Studio review request mapping', () => { null ) ).toMatchObject({ - label: 'Target missed', - primary: 'Revise sample', + label: 'Check draft', + primary: 'Download review pack', + primaryAction: 'download-review-pack', + secondary: 'Revise' + }); + }); + + it('asks for a new sample when old evidence used older quality defaults', () => { + const calibration = { + browser_review_ready: true, + review_media_ready: true, + sample_result: { + predicted_total_size_bytes: 803_322_876, + quality_metric: 'VMAF', + quality_target: 95, + quality_score: 95.0448 + }, + advice: { + operator_request: { + budget_bytes: 314_572_800, + budget_label: '300 MB per episode', + request_text: 'Aim for 200-300 MB per episode.' + }, + run_verdict: { + outcome: 'poor_fit' + } + } + } as FolderCalibrationState; + const folder = folderPayload({ + summary: folderSummary({ + item_count: 22, + total_size_bytes: 80_158_807_611, + resolved_policy: { + video: { quality_metric: 'vmaf', target_vmaf: 85, min_target_vmaf: 80, max_height: 0 } + } + }), + sample_item: { + rel_path: 'tv/Example/Season 1/Episode.mkv', + source_size_bytes: 4_349_049_136, + duration_seconds: 3161.376, + video_codec: 'hevc' + }, + calibration + }); + + expect(buildSampleVerdict(folder, calibration)).toMatchObject({ + stalePolicy: true, + title: '766 MiB per episode came from older settings.' + }); + expect( + resolveWorkflow(folder, folderStatusPayload(), calibration, null, null, null, null) + ).toMatchObject({ + label: 'New sample needed', + title: 'Previous sample used older settings', + primary: 'Ask for sample', primaryAction: 'focus-bench', - secondary: 'Download review pack' + secondary: 'Download old pack' + }); + expect(buildDecisionFacts(folder, calibration, null)).toEqual([ + { + label: 'Old sample', + value: '766 MiB · 2 Mbps', + detail: 'VMAF 95.0 · 2.6x target · old target 300 MB per episode' + }, + { + label: 'Current target', + value: 'source resolution', + detail: 'VMAF low-bitrate target 85 · floor 80' + }, + { + label: 'Next action', + value: 'Run fresh sample', + detail: 'The old evidence does not match the current defaults.' + } + ]); + }); + + it('compares sample evidence against the live folder policy before cached summary policy', () => { + const calibration = { + sample_result: { + predicted_total_size_bytes: 803_322_876, + quality_metric: 'VMAF', + quality_target: 95, + quality_score: 95.0448 + } + } as FolderCalibrationState; + const folder = folderPayload({ + summary: folderSummary({ + resolved_policy: { + video: { quality_metric: 'vmaf', target_vmaf: 95, min_target_vmaf: 93 } + } + }), + policy: { + video: { quality_metric: 'vmaf', target_vmaf: 85, min_target_vmaf: 80, max_height: 0 } + }, + calibration + }); + + expect(buildSampleVerdict(folder, calibration)).toMatchObject({ + stalePolicy: true, + title: '766 MiB per episode came from older settings.' + }); + }); + + it('shows a missed measured sample separately from the next capped sample', () => { + const calibration = { + browser_review_ready: true, + review_media_ready: true, + sample_result: { + predicted_total_size_bytes: 803_322_876, + quality_metric: 'VMAF', + quality_score: 95.0448 + }, + advice: { + operator_request: { + budget_bytes: 314_572_800, + budget_label: '300 MB per episode' + } + } + } as FolderCalibrationState; + const pendingProposal = { + proposal_id: 'capped-retry-draft', + can_queue: true, + message: 'Queue this representative sample.', + operator_request: { budget_label: '300 MB per episode' }, + budget_enforcement: { + status: 'enforced_after_miss', + size_target_analysis: { predicted_to_budget_ratio: 2.55 }, + applied_policy: { video: { max_encoded_percent: 7 } } + } + } as PendingSampleProposal; + + expect( + resolveWorkflow( + folderPayload({ + summary: folderSummary({ + item_count: 22, + total_size_bytes: 80_158_807_611 + }), + calibration, + pending_proposal: pendingProposal + }), + folderStatusPayload(), + calibration, + pendingProposal, + null, + null, + null + ) + ).toMatchObject({ + label: 'Capped draft ready', + title: 'Run a sample with a 7% size ceiling', + copy: 'Applied after 2.6x target miss against 300 MB per episode.', + primary: 'Start sample', + primaryAction: 'start-sample' + }); + }); + + it('surfaces blocked drafts before stale target-missed sample copy', () => { + const calibration = { + sample_result: { + predicted_total_size_bytes: 803_322_876, + quality_metric: 'VMAF', + quality_score: 95.0448 + }, + advice: { + operator_request: { + budget_bytes: 314_572_800, + budget_label: '300 MB per episode' + } + } + } as FolderCalibrationState; + const pendingProposal = { + proposal_id: 'blocked-draft', + can_queue: false, + message: 'The draft lowers VMAF based only on a soft size target.' + } as PendingSampleProposal; + + expect( + resolveWorkflow( + folderPayload({ calibration, pending_proposal: pendingProposal }), + folderStatusPayload(), + calibration, + pendingProposal, + null, + null, + null + ) + ).toMatchObject({ + label: 'Draft blocked', + title: 'The draft does not match your request yet', + copy: 'The draft lowers VMAF based only on a soft size target.', + primary: 'Revise draft', + primaryAction: 'revise-proposal' }); }); @@ -341,6 +772,47 @@ describe('Folder Studio review request mapping', () => { }); }); + it('keeps acceptable evidence approve-first when its queueable draft is still present', () => { + const calibration = { + browser_review_ready: true, + review_media_ready: true, + sample_result: { + predicted_total_size_bytes: 250_000_000, + quality_metric: 'VMAF', + quality_score: 95.1 + }, + advice: { + operator_request: { + budget_bytes: 314_572_800, + budget_label: '300 MB per episode' + }, + run_verdict: { outcome: 'good_fit' } + } + } as FolderCalibrationState; + const pendingProposal = { + proposal_id: 'draft-with-evidence', + can_queue: true, + message: 'Queue this representative sample.' + } as PendingSampleProposal; + + expect( + resolveWorkflow( + folderPayload({ calibration, pending_proposal: pendingProposal }), + folderStatusPayload(), + calibration, + pendingProposal, + null, + null, + null + ) + ).toMatchObject({ + label: 'Review ready', + primary: 'Approve and queue', + primaryAction: 'queue-encode', + secondary: 'Download pack' + }); + }); + it('does not approve stale non-queueable drafts without review evidence', () => { const workflow = resolveWorkflow( folderPayload({ @@ -413,6 +885,7 @@ describe('Folder Studio review request mapping', () => { expect(buildBudgetEnforcementView(pendingProposal)).toEqual({ active: true, cap: '7%', + capBytes: null, reason: 'Applied after 2.6x target miss against 300 MB per episode.' }); @@ -427,14 +900,103 @@ describe('Folder Studio review request mapping', () => { ); expect(workflow).toMatchObject({ - label: 'Budget enforced', - title: 'Next sample has a 7% size ceiling', + label: 'Capped draft ready', + title: 'Run a sample with a 7% size ceiling', copy: 'Applied after 2.6x target miss against 300 MB per episode.', primary: 'Start sample', primaryAction: 'start-sample' }); }); + it('summarizes the capped retry facts in the decision panel', () => { + const calibration = { + sample_result: { + predicted_total_size_bytes: 803_322_876, + quality_metric: 'VMAF', + quality_score: 95.0448 + }, + advice: { + operator_request: { + budget_bytes: 314_572_800, + budget_label: '300 MB per episode' + } + } + } as FolderCalibrationState; + const pendingProposal = { + proposal_id: 'capped-retry-draft', + can_queue: true, + operator_request: { budget_label: '300 MB per episode' }, + preview_policy: { + video: { + encoder: 'libsvtav1', + max_height: 720, + max_encoded_percent: 7, + quality_metric: 'vmaf', + target_vmaf: 89, + min_target_vmaf: 87, + default_grain: 0 + } + }, + budget_enforcement: { + status: 'enforced_after_miss', + size_target_analysis: { predicted_to_budget_ratio: 2.55 }, + applied_policy: { video: { max_encoded_percent: 7 } } + } + } as PendingSampleProposal; + const folder = folderPayload({ + calibration, + pending_proposal: pendingProposal, + sample_item: { + rel_path: 'tv/Example/Season 1/Episode.mkv', + source_size_bytes: 4_388_646_674, + duration_seconds: 3161.376, + video_codec: 'h264' + }, + summary: folderSummary({ item_count: 22, total_size_bytes: 80_158_807_611 }) + }); + + expect(buildDecisionFacts(folder, calibration, pendingProposal)).toEqual([ + { + label: 'Last sample', + value: '766 MiB · 2 Mbps', + detail: '2.6x target · target 300 MB per episode · target 796 kbps' + }, + { + label: 'Next size ceiling', + value: '293 MiB max', + detail: '7% of selected source · 300 MB per episode' + }, + { + label: 'Next video plan', + value: 'AV1 · max 720p · 7% cap', + detail: 'VMAF target 89 · floor 87 · downscale enforced after the measured miss · grain off' + } + ]); + }); + + it('surfaces duration and bitrate in representative sample facts', () => { + const facts = buildSampleFacts( + { + rel_path: 'tv/Example/Season 1/Episode.mkv', + source_size_bytes: 4_349_049_136, + duration_seconds: 3161.376, + width: 1920, + height: 1080, + video_codec: 'hevc' + }, + folderSummary({ total_size_bytes: 4_349_049_136 }) + ); + + expect(facts).toEqual([ + { label: 'File', value: 'Episode.mkv' }, + { label: 'Runtime', value: '52m 41s' }, + { label: 'Resolution', value: '1,920x1,080' }, + { label: 'Source rate', value: '11 Mbps' }, + { label: 'Codec', value: 'HEVC' }, + { label: 'Size', value: '4.1 GiB' } + ]); + }); + it('makes unsampled folders start with the review assistant instead of a disabled sample action', () => { const workflow = resolveWorkflow( folderPayload(), @@ -472,4 +1034,24 @@ describe('Folder Studio review request mapping', () => { ['Process', false] ]); }); + + it('marks size tradeoff approvals as the approve step', () => { + const steps = buildWorkflowSteps({ + tone: 'ready', + label: 'Target missed', + title: 'Approve this size or revise smaller', + copy: 'Review the tradeoff.', + primary: 'Approve anyway and queue', + primaryAction: 'approve-size-tradeoff', + secondary: 'Revise smaller', + secondaryAction: 'focus-bench' + }); + + expect(steps.map((step) => [step.label, step.current])).toEqual([ + ['Sample', false], + ['Review', false], + ['Approve', true], + ['Process', false] + ]); + }); }); diff --git a/frontend/src/lib/components/workstation/folder-studio-view.ts b/frontend/src/lib/components/workstation/folder-studio-view.ts index 763d702..fa3f589 100644 --- a/frontend/src/lib/components/workstation/folder-studio-view.ts +++ b/frontend/src/lib/components/workstation/folder-studio-view.ts @@ -1,14 +1,22 @@ import { codecLabel, flattenPolicy, + formatBitrateCopy, + formatLanguageCopy, formatPolicyValue, + formatPercentCopy, formatResolutionCopy, normalizeReviewArtifacts, pathFilename, policyRowLabel, + summarizeAudioPlan, + summarizeAudioTrack, + summarizeSubtitlePlan, + summarizeSubtitleSource, workbenchSection, type FolderCalibrationJob, type FolderCalibrationState, + type FolderItemPlan, type FolderPolicy, type FolderSampleItem, type FolderOperatorRequest, @@ -42,15 +50,19 @@ export type SampleVerdict = { recommendation: string; predictedPerItem: string; predictedFolderTotal: string; + predictedBitrate: string; reclaim: string; quality: string; target: string; + targetBitrate: string; targetDelta: string; missRatio: number | null; missesTarget: boolean; + stalePolicy: boolean; }; export type WorkflowAction = + | 'approve-size-tradeoff' | 'download-review-pack' | 'focus-bench' | 'open-ops' @@ -70,12 +82,27 @@ export type ProposalRow = { changed: boolean; }; +export type OutputReviewRow = { + label: string; + source: string; + output: string; + detail: string; + tone?: ShellTone; +}; + export type BudgetEnforcementView = { active: boolean; cap: string; + capBytes: string | null; reason: string; }; +export type DecisionFact = { + label: string; + value: string; + detail: string; +}; + export type BenchMessage = { id: string; role: 'operator' | 'bench' | 'system'; @@ -91,6 +118,8 @@ export type BenchHostOption = { label: string; detail: string; available: boolean; + scheduleOpen: boolean | null; + state: string; }; export type BenchRequestState = { @@ -141,6 +170,32 @@ function policyVideo(value: unknown): Record | null { return record>(record>(value)?.video); } +function policyAudio(value: unknown): Record | null { + return record>(record>(value)?.audio); +} + +function policySubtitle(value: unknown): Record | null { + return record>(record>(value)?.subtitle); +} + +function activePolicy( + folder: FolderPayload, + pendingProposal: PendingSampleProposal | null +): FolderPolicy | null { + return (pendingProposal?.preview_policy ?? + pendingProposal?.applied_policy ?? + folder.policy ?? + folder.summary?.resolved_policy ?? + null) as FolderPolicy | null; +} + +function activeVideoPolicy( + folder: FolderPayload, + pendingProposal: PendingSampleProposal | null +): Record | null { + return policyVideo(activePolicy(folder, pendingProposal)); +} + export function record>(value: unknown): T | null { return value && typeof value === 'object' ? (value as T) : null; } @@ -149,6 +204,10 @@ function compactText(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; } +function compactParts(parts: Array): string { + return parts.filter((part) => part && part.trim()).join(' · '); +} + function activeCalibrationStatus(value: unknown): boolean { return ['queued', 'running', 'pending_review'].includes(String(value ?? '').toLowerCase()); } @@ -159,11 +218,19 @@ export function buildBenchHostOptions( return (folderOptions ?? []) .map((host) => { const key = compactText(host.key); + const available = host.available !== false; + const scheduleOpen = typeof host.schedule_open === 'boolean' ? host.schedule_open : null; return { key, label: compactText(host.label) || key || 'Host', detail: compactText(host.detail) || compactText(host.message), - available: host.available !== false + available, + scheduleOpen, + state: !available + ? 'Unavailable' + : scheduleOpen === false + ? 'Sample ok, encode later' + : 'Ready for samples' }; }) .filter((host) => host.key); @@ -219,7 +286,17 @@ export function resolveWorkflowActionState( }; } if (action === 'focus-bench') return { disabled: false, title: '' }; + if (action === 'approve-size-tradeoff') { + return reviewPackReady + ? { disabled: false, title: '' } + : { disabled: true, title: 'Review media is not ready yet.' }; + } + if (action === 'revise-proposal') return { disabled: false, title: '' }; if (action === 'open-ops') return { disabled: false, title: '' }; + if (action === 'stop-sample') { + if (activeCalibrationStatus(calibrationJob?.status)) return { disabled: false, title: '' }; + return { disabled: true, title: 'No sample job is running.' }; + } if (action === 'queue-encode') { if (pendingProposal?.proposal_id && pendingProposal.can_queue === false) { return { @@ -412,6 +489,36 @@ export function formatBytes(value: number | null | undefined): string { return `${value.toLocaleString('en-US', { maximumFractionDigits: 0 })} B`; } +function formatDuration(value: number | null | undefined): string { + if (value == null || !Number.isFinite(value) || value <= 0) return '—'; + const totalSeconds = Math.round(value); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m ${seconds.toString().padStart(2, '0')}s`; +} + +function formatAverageBitrate(bytes: number | null, durationSeconds: number | null): string { + if (bytes === null || durationSeconds === null || bytes <= 0 || durationSeconds <= 0) return '—'; + const kbps = (bytes * 8) / durationSeconds / 1000; + if (!Number.isFinite(kbps) || kbps <= 0) return '—'; + if (kbps >= 1000) { + return `${(kbps / 1000).toLocaleString('en-US', { + maximumFractionDigits: kbps >= 10_000 ? 0 : 1 + })} Mbps`; + } + return `${kbps.toLocaleString('en-US', { maximumFractionDigits: 0 })} kbps`; +} + +function formatKbps(value: number | null | undefined): string { + if (value == null || !Number.isFinite(value) || value <= 0) return '—'; + if (value >= 1000) { + return `${(value / 1000).toLocaleString('en-US', { maximumFractionDigits: value >= 10_000 ? 0 : 1 })} Mbps`; + } + return `${value.toLocaleString('en-US', { maximumFractionDigits: 0 })} kbps`; +} + function formatRatio(value: number | null): string { if (value === null || !Number.isFinite(value) || value <= 0) return ''; return `${value.toLocaleString('en-US', { @@ -460,7 +567,8 @@ export function predictedFolderSizeBytes(folder: FolderPayload): number | null { export function buildSampleVerdict( folder: FolderPayload, - calibration: FolderCalibrationState | null + calibration: FolderCalibrationState | null, + pendingProposal: PendingSampleProposal | null = null ): SampleVerdict | null { const result = sampleResult(calibration); if (!result) return null; @@ -475,6 +583,7 @@ export function buildSampleVerdict( const reclaim = sourceSize !== null && sourceSize > 0 ? Math.max(sourceSize - folderTotal, 0) : null; const budget = numberValue(request?.budget_bytes); + const durationSeconds = numberValue(folder.sample_item?.duration_seconds); const target = compactText(request?.budget_label) || (budget ? formatBytes(budget) : 'No size target'); const missRatio = budget && budget > 0 ? predictedSize / budget : null; @@ -484,6 +593,14 @@ export function buildSampleVerdict( const qualityMetric = compactText(result.quality_metric) || compactText(request?.metric).toUpperCase() || 'Quality'; const qualityScore = numberValue(result.quality_score); + const resultTarget = numberValue(result.quality_target); + const activeVideo = activeVideoPolicy(folder, pendingProposal); + const activeMetric = compactText(activeVideo?.quality_metric).toUpperCase(); + const activeTarget = numberValue( + activeMetric === 'XPSNR' ? activeVideo?.target_xpsnr : activeVideo?.target_vmaf + ); + const stalePolicy = + activeTarget !== null && resultTarget !== null && Math.abs(activeTarget - resultTarget) >= 0.1; const quality = qualityScore === null ? '—' : `${qualityMetric} ${qualityScore.toFixed(1)}`; const predictedPerItem = formatBytes(predictedSize); const recommendation = @@ -494,29 +611,36 @@ export function buildSampleVerdict( return { tone: missesTarget ? 'wait' : 'ready', label: missesTarget ? 'Target missed' : 'Sample result', - title: - missesTarget && target !== 'No size target' + title: stalePolicy + ? `${predictedPerItem} per episode came from older settings.` + : missesTarget && target !== 'No size target' ? `${predictedPerItem} per episode misses ${target}.` : `${predictedPerItem} per episode sample is ready.`, recommendation, predictedPerItem, predictedFolderTotal: formatBytes(folderTotal), + predictedBitrate: formatAverageBitrate(predictedSize, durationSeconds), reclaim: reclaim === null ? '—' : formatBytes(reclaim), quality, target, + targetBitrate: formatAverageBitrate(budget, durationSeconds), targetDelta: formatRatio(missRatio), missRatio, - missesTarget + missesTarget, + stalePolicy }; } export function buildBudgetEnforcementView( - pendingProposal: PendingSampleProposal | null + pendingProposal: PendingSampleProposal | null, + sampleItem: FolderSampleItem | null = null ): BudgetEnforcementView | null { if (!pendingProposal) return null; const enforcement = record>(pendingProposal.budget_enforcement); const cap = numberValue(policyVideo(enforcement?.applied_policy)?.max_encoded_percent); if (cap === null || cap <= 0) return null; + const sourceSize = numberValue(sampleItem?.source_size_bytes); + const capBytes = sourceSize !== null && sourceSize > 0 ? (sourceSize * cap) / 100 : null; const analysis = record>(enforcement?.size_target_analysis); const ratio = numberValue(analysis?.predicted_to_budget_ratio); const target = @@ -525,20 +649,136 @@ export function buildBudgetEnforcementView( return { active: enforcement?.status === 'enforced_after_miss', cap: `${cap.toLocaleString('en-US', { maximumFractionDigits: 1 })}%`, + capBytes: capBytes === null ? null : formatBytes(capBytes), reason: `Applied after ${ratioCopy} against ${target}.` }; } +export function buildDecisionFacts( + folder: FolderPayload, + calibration: FolderCalibrationState | null, + pendingProposal: PendingSampleProposal | null +): DecisionFact[] { + const verdict = buildSampleVerdict(folder, calibration, pendingProposal); + const sampleItem = record(folder.sample_item); + const enforcement = buildBudgetEnforcementView(pendingProposal, sampleItem); + const draftPolicy = activePolicy(folder, pendingProposal); + const video = videoPolicySummary(draftPolicy, pendingProposal); + const targetVideoRate = formatKbps(pendingProposal?.operator_request?.target_video_bitrate_kbps); + if (verdict?.stalePolicy && !pendingProposal?.proposal_id) { + return [ + { + label: 'Old sample', + value: compactParts([ + verdict.predictedPerItem, + verdict.predictedBitrate !== '—' ? verdict.predictedBitrate : null + ]), + detail: compactParts([ + verdict.quality, + verdict.targetDelta || null, + verdict.target ? `old target ${verdict.target}` : null + ]) + }, + { + label: 'Current target', + value: video.output, + detail: video.detail || 'Uses the current folder video policy.' + }, + { + label: 'Next action', + value: 'Run fresh sample', + detail: 'The old evidence does not match the current defaults.' + } + ]; + } + if (enforcement?.active) { + return [ + { + label: 'Last sample', + value: verdict + ? compactParts([ + verdict.predictedPerItem, + verdict.predictedBitrate !== '—' ? verdict.predictedBitrate : null + ]) + : 'Measured miss', + detail: compactParts([ + verdict?.targetDelta || null, + verdict?.target ? `target ${verdict.target}` : null, + verdict && verdict.targetBitrate !== '—' ? `target ${verdict.targetBitrate}` : null + ]) + }, + { + label: 'Next size ceiling', + value: enforcement.capBytes ? `${enforcement.capBytes} max` : `${enforcement.cap} cap`, + detail: compactParts([ + enforcement.capBytes ? `${enforcement.cap} of selected source` : null, + pendingProposal?.operator_request?.budget_label ?? null + ]) + }, + { + label: 'Next video plan', + value: video.output, + detail: compactParts([ + video.detail || 'Uses the current folder video policy.', + targetVideoRate !== '—' ? `target video ${targetVideoRate}` : null + ]) + } + ]; + } + if (verdict) { + return [ + { + label: 'Per episode', + value: verdict.predictedPerItem, + detail: compactParts([ + verdict.predictedBitrate !== '—' ? verdict.predictedBitrate : null, + verdict.targetDelta || `Target ${verdict.target}`, + verdict.targetBitrate !== '—' ? `target ${verdict.targetBitrate}` : null + ]) + }, + { label: 'Folder output', value: verdict.predictedFolderTotal, detail: 'Projected total' }, + { label: 'Reclaim', value: verdict.reclaim, detail: verdict.quality } + ]; + } + return [ + { + label: 'Review pack', + value: resolveReviewArtifacts(calibration, pendingProposal).length + ? `${resolveReviewArtifacts(calibration, pendingProposal).length} artifacts` + : reviewReadyCopy(calibration), + detail: 'Evidence state' + }, + { + label: 'Sample', + value: sampleItem ? pathFilename(sampleItem.rel_path) : 'No sample selected', + detail: 'Representative file' + }, + { + label: 'Next action', + value: 'Use the decision buttons', + detail: 'Draft, sample, review, or approve from here' + } + ]; +} + export function buildSampleFacts( sampleItem: FolderSampleItem | null, summary: FolderPayload['summary'] ): Array<{ label: string; value: string }> { return [ { label: 'File', value: sampleItem ? pathFilename(sampleItem.rel_path) : '—' }, + { label: 'Runtime', value: formatDuration(sampleItem?.duration_seconds) }, { label: 'Resolution', value: formatResolutionCopy(sampleItem?.width, sampleItem?.height) ?? '—' }, + { + label: 'Source rate', + value: formatAverageBitrate( + numberValue(sampleItem?.source_size_bytes), + numberValue(sampleItem?.duration_seconds) + ) + }, { label: 'Codec', value: codecLabel(sampleItem?.video_codec) }, { label: 'Size', @@ -547,6 +787,185 @@ export function buildSampleFacts( ]; } +function itemPlan(folder: FolderPayload): FolderItemPlan | null { + const plan = record>(folder.item_plan); + return plan ? (plan as FolderItemPlan) : null; +} + +function reviewPackAudioSummary( + calibration: FolderCalibrationState | null, + pendingProposal: PendingSampleProposal | null +): string { + const reviewPack = + pendingProposal?.multimodal_review_pack ?? calibration?.advice?.multimodal_review_pack; + return compactText(reviewPack?.audio_plan?.summary); +} + +function draftDownscaleReason( + pendingProposal: PendingSampleProposal | null, + maxHeight: number | null +): string | null { + if (maxHeight === null || maxHeight <= 0) return null; + const requestText = compactParts([ + pendingProposal?.operator_request?.request_text ?? null, + pendingProposal?.operator_note ?? null + ]).toLowerCase(); + if (buildBudgetEnforcementView(pendingProposal)?.active) { + return 'downscale enforced after the measured miss'; + } + if (requestText.includes('downscal')) { + return 'downscale allowed by the size request'; + } + return 'draft changes output resolution'; +} + +function videoPolicySummary( + policy: FolderPolicy | null | undefined, + pendingProposal: PendingSampleProposal | null +): { + output: string; + detail: string; +} { + const video = policyVideo(policy); + if (!video) return { output: 'No draft video policy', detail: '' }; + const encoder = compactText(video.encoder); + const maxHeight = numberValue(video.max_height); + const cap = numberValue(video.max_encoded_percent); + const enforcedCap = buildBudgetEnforcementView(pendingProposal)?.active === true; + const metric = compactText(video.quality_metric).toUpperCase(); + const target = numberValue(metric === 'XPSNR' ? video.target_xpsnr : video.target_vmaf); + const floor = numberValue(metric === 'XPSNR' ? video.min_target_xpsnr : video.min_target_vmaf); + const metricCopy = + metric === 'VMAF' && target !== null && target <= 88 ? 'VMAF low-bitrate' : metric; + const output = compactParts([ + encoder.includes('av1') ? 'AV1' : encoder || null, + maxHeight !== null && maxHeight > 0 ? `max ${maxHeight}p` : 'source resolution', + enforcedCap && cap !== null && cap > 0 ? `${formatPercentCopy(cap)} cap` : null + ]); + const detail = compactParts([ + metricCopy ? `${metricCopy}${target !== null ? ` target ${target}` : ''}` : null, + floor !== null ? `floor ${floor}` : null, + draftDownscaleReason(pendingProposal, maxHeight), + numberValue(video.default_grain) === 0 ? 'grain off' : null, + compactText(video.crop) ? `crop ${compactText(video.crop)}` : null + ]); + return { output: output || 'Draft video policy', detail }; +} + +function audioPolicySummary( + policy: FolderPolicy | null | undefined, + sampleItem: FolderSampleItem | null +): string { + const audio = policyAudio(policy); + const primary = sampleItem?.audio_summary?.[0] ?? null; + if (!audio) return 'No draft audio policy'; + const codec = String(primary?.codec_name ?? '').toLowerCase(); + const channels = Number(primary?.channels ?? 0); + const convertCodecs = Array.isArray(audio.convert_to_opus_codecs) + ? audio.convert_to_opus_codecs.map((value) => String(value).toLowerCase()) + : []; + const copyCodecs = Array.isArray(audio.copy_codecs) + ? audio.copy_codecs.map((value) => String(value).toLowerCase()) + : []; + const bitrate = + channels >= 8 + ? compactText(audio.surround_7_1_opus_bitrate) + : channels >= 6 + ? compactText(audio.surround_5_1_opus_bitrate) + : compactText(audio.stereo_opus_bitrate); + if (convertCodecs.includes(codec)) return compactParts(['Opus', bitrate]); + if (copyCodecs.includes(codec)) return `Copy ${codecLabel(codec)}`; + return bitrate ? `Opus ${formatBitrateCopy(bitrate) ?? bitrate}` : 'Keep selected audio'; +} + +function subtitlePolicySummary(policy: FolderPolicy | null | undefined): string { + const subtitle = policySubtitle(policy); + if (!subtitle) return 'No subtitle policy'; + const languages = Array.isArray(subtitle.keep_languages) + ? subtitle.keep_languages.map((value) => formatLanguageCopy(String(value))).filter(Boolean) + : []; + return compactParts([ + languages.length ? `Keep ${languages.join(', ')}` : 'Keep selected subtitles', + subtitle.prefer_text ? 'prefer text' : null, + subtitle.keep_forced ? 'forced kept' : null, + compactText(subtitle.default_mode).replaceAll('_', ' ') + ]); +} + +export function buildOutputReviewRows( + folder: FolderPayload, + calibration: FolderCalibrationState | null, + pendingProposal: PendingSampleProposal | null +): OutputReviewRow[] { + const sampleItem = record(folder.sample_item); + const plan = itemPlan(folder); + const verdict = buildSampleVerdict(folder, calibration, pendingProposal); + const draftPolicy = activePolicy(folder, pendingProposal); + const video = videoPolicySummary(draftPolicy, pendingProposal); + const audioSource = summarizeAudioTrack(sampleItem?.audio_summary?.[0] ?? null); + const audioPlan = summarizeAudioPlan(plan?.audio); + const subtitleSource = summarizeSubtitleSource(sampleItem?.subtitle_summary ?? []); + const subtitlePlan = summarizeSubtitlePlan( + plan?.subtitles, + Boolean(draftPolicy?.subtitle?.prefer_text) + ); + const reviewCount = resolveReviewArtifacts(calibration, pendingProposal).length; + return [ + { + label: 'Measured sample', + source: sampleItem + ? `source ${formatBytes(sampleItem.source_size_bytes)}` + : 'No sample selected', + output: verdict?.predictedPerItem ?? 'No measured output yet', + detail: verdict + ? compactParts([ + verdict.stalePolicy ? 'older settings' : null, + `target ${verdict.target}`, + verdict.targetDelta || null, + `folder ${verdict.predictedFolderTotal}` + ]) + : 'Run a representative sample before approving the folder.', + tone: verdict?.missesTarget ? 'wait' : verdict ? 'ready' : 'idle' + }, + { + label: pendingProposal?.proposal_id ? 'Next sample draft' : 'Video output', + source: compactParts([ + codecLabel(sampleItem?.video_codec), + formatResolutionCopy(sampleItem?.width, sampleItem?.height) + ]), + output: video.output, + detail: video.detail || 'Uses the current folder video policy.', + tone: pendingProposal?.proposal_id ? 'active' : 'idle' + }, + { + label: 'Audio', + source: compactParts([audioSource.headline, audioSource.detail]), + output: + reviewPackAudioSummary(calibration, pendingProposal) || + audioPlan.headline || + audioPolicySummary(draftPolicy, sampleItem), + detail: audioPlan.detail || audioPolicySummary(draftPolicy, sampleItem), + tone: 'idle' + }, + { + label: 'Subtitles', + source: compactParts([subtitleSource.headline, subtitleSource.detail]), + output: subtitlePlan.headline || subtitlePolicySummary(draftPolicy), + detail: subtitlePlan.detail || subtitlePolicySummary(draftPolicy), + tone: 'idle' + }, + { + label: 'Review media', + source: reviewCount ? `${reviewCount} artifacts ready` : 'No review media yet', + output: reviewCount ? 'Visible below' : 'Run sample', + detail: reviewCount + ? 'Use the source/draft contact sheets and compare timelines before approving.' + : 'A sample run creates visual and audio review evidence.', + tone: reviewCount ? 'ready' : 'idle' + } + ]; +} + export function resolveReviewArtifacts( calibration: FolderCalibrationState | null, pendingProposal: PendingSampleProposal | null @@ -569,7 +988,8 @@ export function resolveWorkflow( pendingProposal: PendingSampleProposal | null, reviewGate: ReviewGate | null, calibrationJob: FolderCalibrationJob | null, - encodeJob: EncodeQueueJob | null + encodeJob: EncodeQueueJob | null, + reviewPackReady = false ): WorkflowState { const encodeStatus = String(encodeJob?.status ?? '').toLowerCase(); if (['failed', 'needs_attention', 'stopped'].includes(encodeStatus)) { @@ -647,33 +1067,16 @@ export function resolveWorkflow( secondaryAction: 'stop-sample' }; } - const verdict = buildSampleVerdict(folder, calibration); - if (verdict?.missesTarget) { - const budgetEnforcement = buildBudgetEnforcementView(pendingProposal); - if ( - pendingProposal?.proposal_id && - pendingProposal.can_queue !== false && - budgetEnforcement?.active - ) { - return { - tone: 'ready', - label: 'Budget enforced', - title: `Next sample has a ${budgetEnforcement.cap} size ceiling`, - copy: budgetEnforcement.reason, - primary: 'Start sample', - primaryAction: 'start-sample', - secondary: 'Revise', - secondaryAction: 'revise-proposal' - }; - } + const verdict = buildSampleVerdict(folder, calibration, pendingProposal); + if (verdict?.stalePolicy && !pendingProposal?.proposal_id) { return { tone: 'wait', - label: 'Target missed', - title: 'Sample is too large for the requested target', - copy: `${verdict.predictedPerItem} per episode against ${verdict.target}. ${verdict.recommendation}`, - primary: 'Revise sample', + label: 'New sample needed', + title: 'Previous sample used older settings', + copy: `${verdict.predictedPerItem} per episode was measured against older quality targets. Run a fresh sample using the current source-resolution, low-bitrate defaults before approving this folder.`, + primary: 'Ask for sample', primaryAction: 'focus-bench', - secondary: 'Download review pack', + secondary: 'Download old pack', secondaryAction: 'download-review-pack' }; } @@ -693,20 +1096,64 @@ export function resolveWorkflow( } if (pendingProposal?.proposal_id && pendingProposal.can_queue !== false) { const budgetEnforcement = buildBudgetEnforcementView(pendingProposal); + if (budgetEnforcement?.active || !calibration?.browser_review_ready) { + return { + tone: 'ready', + label: budgetEnforcement?.active ? 'Capped draft ready' : 'Draft ready', + title: budgetEnforcement?.active + ? `Run a sample with a ${budgetEnforcement.cap} size ceiling` + : 'Review draft is ready to sample', + copy: + budgetEnforcement?.reason ?? + pendingProposal.message ?? + 'Review the draft, then queue the representative sample when it looks right.', + primary: 'Start sample', + primaryAction: 'start-sample', + secondary: 'Revise', + secondaryAction: 'revise-proposal' + }; + } + } + if ( + pendingProposal?.proposal_id && + pendingProposal.can_queue === false && + (folder.sample_item || verdict) + ) { return { - tone: 'ready', - label: budgetEnforcement?.active ? 'Budget enforced' : 'Draft ready', - title: budgetEnforcement?.active - ? `Next sample has a ${budgetEnforcement.cap} size ceiling` - : 'Review draft is ready to sample', + tone: 'wait', + label: 'Draft blocked', + title: 'The draft does not match your request yet', copy: - budgetEnforcement?.reason ?? pendingProposal.message ?? - 'Review the draft, then queue the representative sample when it looks right.', - primary: 'Start sample', - primaryAction: 'start-sample', - secondary: 'Revise', - secondaryAction: 'revise-proposal' + 'The bench draft changed something outside your request. Revise it before starting another sample.', + primary: 'Revise draft', + primaryAction: 'revise-proposal', + secondary: 'Download pack', + secondaryAction: 'download-review-pack' + }; + } + if (verdict?.missesTarget && reviewPackReady) { + return { + tone: 'ready', + label: 'Target missed', + title: 'Approve this size or revise smaller', + copy: `${verdict.predictedPerItem} per episode against ${verdict.target}. If the comparison preview looks good, approve this larger result; otherwise revise and sample again.`, + primary: 'Approve anyway and queue', + primaryAction: 'approve-size-tradeoff', + secondary: 'Revise smaller', + secondaryAction: 'focus-bench' + }; + } + if (verdict?.missesTarget) { + return { + tone: 'wait', + label: 'Review pending', + title: 'Target missed, waiting for review media', + copy: `${verdict.predictedPerItem} per episode against ${verdict.target}. Wait for the comparison preview before approving this larger result.`, + primary: 'Open Ops', + primaryAction: 'open-ops', + secondary: 'Revise smaller', + secondaryAction: 'focus-bench' }; } if (calibration?.browser_review_ready || calibration?.review_media_ready) { @@ -758,7 +1205,8 @@ export function buildWorkflowSteps(workflow: WorkflowState): WorkflowStep[] { const reviewCurrent = ['download-review-pack', 'revise-proposal'].includes(activeAction) || ['review ready', 'check draft'].includes(activeLabel); - const approveCurrent = ['queue-encode'].includes(activeAction) || activeLabel === 'approved'; + const approveCurrent = + ['queue-encode', 'approve-size-tradeoff'].includes(activeAction) || activeLabel === 'approved'; const encodeCurrent = ['open-ops', 'retry-encode'].includes(activeAction) || ['processing', 'retry available'].includes(activeLabel); diff --git a/frontend/src/lib/components/workstation/ops-workstation.test.ts b/frontend/src/lib/components/workstation/ops-workstation.test.ts index 88b113a..41be9b3 100644 --- a/frontend/src/lib/components/workstation/ops-workstation.test.ts +++ b/frontend/src/lib/components/workstation/ops-workstation.test.ts @@ -214,10 +214,24 @@ describe('Ops workstation mapping', () => { { label: 'Work schedule', tone: 'ready' }, { label: 'Processing', tone: 'wait' }, { label: 'Sample checks', tone: 'active' }, - { label: 'Workers', tone: 'ready' } + { label: 'Workers', tone: 'ready', value: '1 encode-ready / 2' } ]); }); + it('counts busy and off-schedule workers separately from encode-ready capacity', () => { + const hosts = hostsFixture(); + hosts.hosts[0].active_encode_count = 2; + + const tiles = buildOpsStatusTiles(dashboardFixture(), hosts, null); + + expect(tiles.at(-1)).toMatchObject({ + label: 'Workers', + tone: 'wait', + value: '0 encode-ready / 2', + detail: '2 reachable · waiting or busy' + }); + }); + it('summarizes the first-glance Ops readiness answer', () => { const summary = buildOpsReadinessSummary(dashboardFixture(), hostsFixture(), null); @@ -249,14 +263,27 @@ describe('Ops workstation mapping', () => { const unavailable = { ...ready, available: false, active_encode_count: 0 }; expect(hostTone(ready)).toBe('active'); - expect(hostStateCopy(ready)).toBe('Processing'); - expect(hostTone(scheduledOff)).toBe('idle'); - expect(hostStateCopy(scheduledOff)).toBe('Off schedule'); + expect(hostStateCopy(ready)).toBe('Busy'); + expect(hostTone(scheduledOff)).toBe('wait'); + expect(hostStateCopy(scheduledOff)).toBe('Off encode schedule'); expect(hostTone(unavailable)).toBe('fail'); expect(hostTone(unavailable, true)).toBe('wait'); expect(hostStateCopy(unavailable)).toBe('Unavailable'); }); + it('shows running encode telemetry instead of stale restart errors', () => { + const dashboard = dashboardFixture(); + dashboard.encode_queue.running[0] = { + ...dashboard.encode_queue.running[0], + error: 'Encode queue job was interrupted by a web process restart.', + telemetry_summary: '1% · 0.36x · 8.7 fps · Est. ETA 11h 4m' + }; + + expect(buildOpsQueueRows(dashboard)[0]).toMatchObject({ + detail: '1% · 0.36x · 8.7 fps · Est. ETA 11h 4m' + }); + }); + it('maps worker capabilities to user-facing labels', () => { expect(workerCapabilitiesSummary(['encode_queue', 'sample_calibration', 'proof_encode'])).toBe( 'Process folders · Run samples · Run review evidence' diff --git a/frontend/src/lib/components/workstation/ops-workstation.ts b/frontend/src/lib/components/workstation/ops-workstation.ts index 2a24458..0e0c777 100644 --- a/frontend/src/lib/components/workstation/ops-workstation.ts +++ b/frontend/src/lib/components/workstation/ops-workstation.ts @@ -68,6 +68,29 @@ function record(value: unknown): Record | null { return value && typeof value === 'object' ? (value as Record) : null; } +function activeJobStatus(status: unknown): boolean { + return ['running', 'processing', 'active', 'queued'].includes(String(status ?? '').toLowerCase()); +} + +function hostEncodeReady(host: HostRuntime): boolean { + return ( + host.available && + host.schedule_open !== false && + host.queue_active !== false && + numberValue(host.active_encode_count) < numberValue(host.max_parallel_encodes) + ); +} + +function hostCapacityCounts(hosts: HostsPayload | null | undefined) { + const rows = hosts?.hosts ?? []; + return { + available: rows.filter((host) => host.available).length, + encodeReady: rows.filter(hostEncodeReady).length, + scheduledOff: rows.filter((host) => host.available && host.schedule_open === false).length, + total: rows.length + }; +} + function hostCopy(value: unknown): string { const host = record(value); if (!host) return 'unassigned'; @@ -84,8 +107,9 @@ function calibrationPrefix(job: CalibrationJob): string { } function calibrationDetail(job: CalibrationJob): string { + const status = String(job.status ?? '').toLowerCase(); const raw = - compactText(job.error) || + (activeJobStatus(status) ? '' : compactText(job.error)) || compactText(job.notes) || compactText(job.operator_note) || compactText(job.created_at) || @@ -164,10 +188,10 @@ export function encodeJobProgress(job: EncodeQueueJob): string { export function encodeJobDetail(job: EncodeQueueJob): string { return ( - job.error || - job.attempt_summary || + (activeJobStatus(job.status) ? '' : job.error) || job.telemetry_summary || job.progress?.current_item_rel_path || + job.attempt_summary || job.progress?.failure_analysis?.summary || 'waiting for queue telemetry' ); @@ -344,8 +368,7 @@ export function buildOpsBlockers( const blockers: OpsBlocker[] = []; const queue = dashboard?.encode_queue; const attentionCount = queue?.needs_attention_count ?? 0; - const readyHosts = hosts?.hosts.filter((host) => host.available).length ?? 0; - const totalHosts = hosts?.hosts.length ?? 0; + const capacity = hostCapacityCounts(hosts); const queuedWork = (queue?.queued_count ?? 0) + (queue?.running_count ?? 0); const scheduleWaiting = queue?.queued_waiting_count ?? 0; if (loadError) { @@ -383,13 +406,23 @@ export function buildOpsBlockers( action: 'retry-failed-encode' }); } - if (totalHosts > 0 && readyHosts === 0 && queuedWork > 0) { + if (capacity.total > 0 && capacity.encodeReady === 0 && queuedWork > 0) { + const allAvailableHostsScheduledOff = + capacity.available > 0 && capacity.scheduledOff === capacity.available; + const workersReachable = capacity.available > 0; blockers.push({ key: 'no-hosts-ready', - tone: 'fail', - title: 'No workers can process right now', - detail: - 'Queued work exists, but every configured worker is unavailable or outside its work window.' + tone: workersReachable ? 'wait' : 'fail', + title: allAvailableHostsScheduledOff + ? 'Workers are outside encode windows' + : workersReachable + ? 'Workers are busy or waiting' + : 'No workers can process right now', + detail: allAvailableHostsScheduledOff + ? 'Queued processing will wait for the next allowed encode window. Manual samples can still be prepared from Folder Studio.' + : workersReachable + ? 'Workers are reachable but cannot claim another encode right now. Manual samples can still be prepared from Folder Studio.' + : 'Queued work exists, but every configured worker is unavailable or outside its work window.' }); } else if (scheduleWaiting > 0) { blockers.push({ @@ -409,8 +442,7 @@ export function buildOpsReadinessSummary( ): OpsReadinessSummary { const queue = dashboard?.encode_queue; const calibration = dashboard?.calibration_queue; - const readyHosts = hosts?.hosts.filter((host) => host.available).length ?? 0; - const totalHosts = hosts?.hosts.length ?? 0; + const capacity = hostCapacityCounts(hosts); const runningCount = queue?.running_count ?? 0; const queuedCount = queue?.queued_count ?? 0; const queuedWaiting = queue?.queued_waiting_count ?? 0; @@ -454,13 +486,23 @@ export function buildOpsReadinessSummary( metricValue: String(needsAttention) }; } - if (totalHosts > 0 && readyHosts === 0 && queuedWork > 0) { + if (capacity.total > 0 && capacity.encodeReady === 0 && queuedWork > 0) { + const allAvailableHostsScheduledOff = + capacity.available > 0 && capacity.scheduledOff === capacity.available; + const workersReachable = capacity.available > 0; return { - tone: 'fail', - title: 'No worker can work right now', - detail: - 'Queued work exists, but every configured worker is unavailable or outside its work window.', - metricLabel: 'Workers ready', + tone: workersReachable ? 'wait' : 'fail', + title: allAvailableHostsScheduledOff + ? 'Waiting for encode windows' + : workersReachable + ? 'Workers are busy or waiting' + : 'No worker can work right now', + detail: allAvailableHostsScheduledOff + ? 'Workers are reachable but outside production encode windows; manual sample setup is still allowed.' + : workersReachable + ? 'Workers are reachable but cannot claim another encode right now; manual sample setup is still allowed.' + : 'Queued work exists, but every configured worker is unavailable or outside its work window.', + metricLabel: 'Encode-ready', metricValue: '0' }; } @@ -485,19 +527,20 @@ export function buildOpsReadinessSummary( metricValue: String(queuedWaiting) }; } - if (readyHosts > 0) { + if (capacity.encodeReady > 0) { return { tone: 'ready', title: 'Ready for work', - detail: 'Workers are available and Mediaforce can start eligible processing work.', - metricLabel: 'Workers ready', - metricValue: String(readyHosts) + detail: 'Workers can claim eligible processing work now.', + metricLabel: 'Encode-ready', + metricValue: String(capacity.encodeReady) }; } return { tone: 'idle', title: 'Standing by', - detail: totalHosts > 0 ? 'No current work is waiting on Ops.' : 'Worker status is unavailable.', + detail: + capacity.total > 0 ? 'No current work is waiting on Ops.' : 'Worker status is unavailable.', metricLabel: 'Queued', metricValue: String(queuedCount) }; @@ -510,8 +553,7 @@ export function buildOpsStatusTiles( ): StatusTile[] { const encode = dashboard?.encode_queue; const calibration = dashboard?.calibration_queue; - const readyHosts = hosts?.hosts.filter((host) => host.available).length ?? 0; - const totalHosts = hosts?.hosts.length ?? 0; + const capacity = hostCapacityCounts(hosts); return [ { label: 'Work schedule', @@ -552,13 +594,22 @@ export function buildOpsStatusTiles( }, { label: 'Workers', - value: `${readyHosts} ready / ${totalHosts}`, - detail: totalHosts - ? readyHosts > 0 - ? 'capacity available' - : 'no worker can start work' + value: `${capacity.encodeReady} encode-ready / ${capacity.total}`, + detail: capacity.total + ? capacity.encodeReady > 0 + ? `${capacity.available} reachable` + : capacity.available > 0 + ? `${capacity.available} reachable · waiting or busy` + : 'no worker can start work' : 'worker status unavailable', - tone: readyHosts > 0 ? 'ready' : totalHosts > 0 ? 'fail' : 'idle' + tone: + capacity.encodeReady > 0 + ? 'ready' + : capacity.available > 0 + ? 'wait' + : capacity.total > 0 + ? 'fail' + : 'idle' } ]; } @@ -599,15 +650,16 @@ export function buildOpsFooterSignals( export function hostTone(host: HostRuntime, fleetHasReadyCapacity = false): ShellTone { if (!host.available) return fleetHasReadyCapacity ? 'wait' : 'fail'; - if (host.schedule_open === false || host.queue_active === false) return 'idle'; if (host.active_encode_count > 0) return 'active'; + if (host.schedule_open === false) return 'wait'; + if (host.queue_active === false) return 'idle'; return 'ready'; } export function hostStateCopy(host: HostRuntime): string { if (!host.available) return 'Unavailable'; - if (host.schedule_open === false) return 'Off schedule'; + if (host.active_encode_count > 0) return 'Busy'; + if (host.schedule_open === false) return 'Off encode schedule'; if (host.queue_active === false) return 'Not accepting'; - if (host.active_encode_count > 0) return 'Processing'; return 'Ready'; } diff --git a/frontend/src/lib/folders/studio.ts b/frontend/src/lib/folders/studio.ts index 5a8c152..a662a7d 100644 --- a/frontend/src/lib/folders/studio.ts +++ b/frontend/src/lib/folders/studio.ts @@ -249,6 +249,7 @@ export type FolderCalibrationState = { sample_result?: { chosen_crf?: number; quality_metric?: string; + quality_target?: number; quality_score?: number; predicted_total_size_bytes?: number; predicted_encode_percent?: number; @@ -275,6 +276,7 @@ export type FolderOperatorRequest = { requires_confirmation?: boolean; estimated_source_percent?: number; estimated_video_bitrate_kbps?: number; + target_video_bitrate_kbps?: number; request_text?: string; }; export type FolderRunVerdict = { diff --git a/frontend/src/lib/settings/editor.test.ts b/frontend/src/lib/settings/editor.test.ts index 85872f6..8d3a5fe 100644 --- a/frontend/src/lib/settings/editor.test.ts +++ b/frontend/src/lib/settings/editor.test.ts @@ -131,6 +131,16 @@ describe('settings draft helpers', () => { } ], transcode_root: '/Volumes/Transcode', + video_defaults: { + quality_metric: 'vmaf', + target_vmaf: '85', + min_target_vmaf: '80', + target_xpsnr: '41', + min_target_xpsnr: '35', + max_height: '1080', + default_grain: '8', + max_encoded_percent: '80' + }, encode_queue_scheduler: { mode: 'night', start_hour: 22, diff --git a/frontend/src/lib/settings/editor.ts b/frontend/src/lib/settings/editor.ts index 2029654..dff643f 100644 --- a/frontend/src/lib/settings/editor.ts +++ b/frontend/src/lib/settings/editor.ts @@ -25,6 +25,7 @@ export type SettingsSavePayload = { libraries: SettingsLibrary[]; remote_hosts: SettingsHost[]; transcode_root: string; + video_defaults: SettingsPayload['video_defaults']; encode_queue_scheduler: SettingsPayload['encode_queue_scheduler']; schedule_profiles: ScheduleProfile[]; }; @@ -131,6 +132,7 @@ export function draftFromSettings(payload: SettingsPayload) { allowed_libraries: [...host.allowed_libraries] })), transcode_root: payload.transcode_root, + video_defaults: { ...payload.video_defaults }, schedule_profiles: payload.schedule_profiles .filter((profile) => profile.key || profile.label) .map((profile) => cloneScheduleProfile(profile)) @@ -149,6 +151,7 @@ export function buildSettingsSavePayload( allowed_libraries: [...host.allowed_libraries] })), transcode_root: draft.transcode_root, + video_defaults: { ...draft.video_defaults }, encode_queue_scheduler: { ...settings.encode_queue_scheduler }, schedule_profiles: draft.schedule_profiles.map((profile) => cloneScheduleProfile(profile)) }; diff --git a/mediaforce/advising/prompts.py b/mediaforce/advising/prompts.py index cecaab0..13b0fae 100644 --- a/mediaforce/advising/prompts.py +++ b/mediaforce/advising/prompts.py @@ -55,7 +55,7 @@ def build_seed_prompt( "When latest_failed_sample_job is present, treat it as the most recent failed or stopped sample attempt; use its error, policy, and result fields to avoid repeating the same failure. " "If the operator asks for a smaller encode, do not silently move to a higher quality target or otherwise preserve the old behavior behind reassuring wording. " "When the available policy keys include video.black_bar_handling, consider setting it to smart for letterboxed, matted, widescreen, or black-bar-heavy sources when the operator note, folder class, or sample metadata makes that likely; prefer smart detection over manual crop unless the operator supplied an exact crop. " - "Treat video.max_height as an explicit downsample/cap-height request knob. Do not infer 1080p or 720p scaling from a size budget alone; use max_height only when the operator request or requested_experiment clearly asks for a scale target. " + "Treat video.max_height as an explicit downsample/cap-height request knob. Do not infer 1080p or 720p scaling from a size budget alone; use max_height only when the operator request or requested_experiment clearly asks for a scale target. When the operator asks to preserve source resolution, avoid downscaling, or keep max_height unset/0, preserve video.max_height as 0 rather than replacing it with the source height. " "Do not anchor on legacy H.264 or HEVC bitrate intuition when the policy is using AV1. For AV1, a projected bitrate that looks surprisingly low by older codec standards can still be a plausible high-quality outcome for clean or forgiving material, so do not soften a size-first request just because the bitrate number looks small on its own. " "Teach media-class taste, not a single-title compression floor, but do that in service of the operator's ask rather than as a reason to refuse it. " "If you soften or reject a request, say so explicitly and only do it for the narrow reasons above. " @@ -109,7 +109,7 @@ def build_tune_prompt( "If you intentionally soften or redirect an explicit request, say that plainly in request_response, diagnosis, and suggested_follow_up instead of hiding the tradeoff. " "If the operator asks for a smaller encode, do not silently move to a higher quality target or preserve the old behavior behind reassuring language. " "When the available policy keys include video.black_bar_handling, consider setting it to smart for letterboxed, matted, widescreen, or black-bar-heavy sources when the operator note, review evidence, or sample metadata makes that likely; prefer smart detection over manual crop unless the operator supplied an exact crop. " - "Treat video.max_height as an explicit downsample/cap-height request knob. Do not infer 1080p or 720p scaling from a size budget alone; use max_height only when operator_note_parse.scale_height, requested_experiment, or the operator note clearly asks for a scale target. " + "Treat video.max_height as an explicit downsample/cap-height request knob. Do not infer 1080p or 720p scaling from a size budget alone; use max_height only when operator_note_parse.scale_height, requested_experiment, or the operator note clearly asks for a scale target. When the operator asks to preserve source resolution, avoid downscaling, or keep max_height unset/0, preserve video.max_height as 0 rather than replacing it with the source height. " "Do not anchor on legacy H.264 or HEVC bitrate intuition when the policy is using AV1. For AV1, a projected bitrate that looks surprisingly low by older codec standards can still be a plausible high-quality outcome for clean or forgiving material, so do not redirect a size-first request just because the bitrate number looks small on its own. " "When the operator explicitly wants heavy compression with very little perceptible loss, high-80s VMAF can still be a legitimate AV1 experiment on clean or forgiving material, but treat that as class-dependent and risk-aware rather than a universal default. " "When requested_experiment or retrieved_memory shows the operator repeated the same explicit risky request, treat that as deliberate confirmation and keep the risky draft unless the request cannot be expressed with the available policy keys. " @@ -168,7 +168,7 @@ def build_operator_note_parse_prompt(payload: dict[str, Any]) -> str: "Mark operator_confirmed true only when the note is a clear instruction the system should act on now. " "Directive questions still count as direct requests when they clearly ask for action, such as 'Target 300MB per episode?' or 'Can you target 300MB per episode?'. " "Exploratory wording stays unconfirmed when the operator is asking whether a change would help or is realistic, such as 'Can we try to target 85 VMAF instead? Will that help?' or 'I want to understand if 300MB per episode is realistic.' " - "Extract explicit downsample or output-height cap requests into scale_height, such as 'downsample to 1080p', 'cap at 720p', or 'make the 4K files 1080p'. This is only for requested scaling; do not infer a scale target from source resolution, a size budget, or general smaller-file language. " + "Extract explicit downsample or output-height cap requests into scale_height, such as 'downsample to 1080p', 'cap at 720p', or 'make the 4K files 1080p'. This is only for requested scaling; do not infer a scale target from source resolution, a size budget, or general smaller-file language. If the operator asks to preserve source resolution, avoid downscaling, or keep max_height unset/0, set scale_height to 0 to represent source resolution/no height cap. " "Extract explicit black-bar handling requests into black_bar_handling as smart when the operator asks for smart or automatic black-bar detection/cropping. Extract an exact manual crop into crop only when the note includes a concrete W:H:X:Y crop like 1920:800:0:140. Do not invent a crop. " "If any scale, black-bar, or crop target appears with a size budget or metric target, use combined_experiment. If only scale, black-bar, or crop targets are requested, use scale_target. If both a size budget and a metric target are explicitly requested, use combined_experiment. " "Return JSON only with no markdown fences or extra commentary. " diff --git a/mediaforce/web/app.py b/mediaforce/web/app.py index 0559ff5..635b0c1 100644 --- a/mediaforce/web/app.py +++ b/mediaforce/web/app.py @@ -11,7 +11,7 @@ from collections.abc import AsyncIterator, Awaitable, Callable, Iterator from concurrent.futures import Future, ThreadPoolExecutor from contextlib import asynccontextmanager, contextmanager -from dataclasses import dataclass +from dataclasses import asdict, dataclass from datetime import UTC, datetime, timedelta from functools import lru_cache from pathlib import Path @@ -333,8 +333,11 @@ async def _app_lifespan(_app: FastAPI) -> AsyncIterator[None]: app = FastAPI(title="Mediaforce Calibration Bench", lifespan=_app_lifespan) review_dir = config.paths.review_dir + project_frontend_build_dir = config.paths.project_root / "frontend" / "build" packaged_frontend_build_dir = Path(__file__).resolve().parent / "frontend_build" - frontend_build_dir = packaged_frontend_build_dir if packaged_frontend_build_dir.exists() else config.paths.project_root / "frontend" / "build" + frontend_build_dir = ( + project_frontend_build_dir if project_frontend_build_dir.exists() else packaged_frontend_build_dir + ) review_dir.mkdir(parents=True, exist_ok=True) app.mount("/review-media", StaticFiles(directory=str(review_dir)), name="review_media") frontend_app_dir = frontend_build_dir / "_app" @@ -359,6 +362,7 @@ def _settings_page_payload( libraries: list[dict[str, Any]] | None = None, remote_hosts: list[dict[str, str]] | None = None, transcode_root: str | None = None, + video_defaults: dict[str, Any] | None = None, encode_queue_scheduler: dict[str, Any] | None = None, schedule_profiles: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: @@ -375,6 +379,7 @@ def _settings_page_payload( libraries=libraries, remote_hosts=remote_hosts, transcode_root=transcode_root, + video_defaults=video_defaults, encode_queue_scheduler=encode_queue_scheduler, schedule_profiles=schedule_profiles, ) @@ -419,6 +424,7 @@ def _save_settings_action( libraries: list[dict[str, str]], remote_hosts: list[dict[str, Any]], transcode_root: str, + video_defaults: dict[str, Any], encode_queue_scheduler: dict[str, Any], schedule_profiles: list[dict[str, Any]], ) -> dict[str, Any]: @@ -429,6 +435,7 @@ def _save_settings_action( libraries=libraries, remote_hosts=remote_hosts, transcode_root=transcode_root, + video_defaults=video_defaults, encode_queue_scheduler=encode_queue_scheduler, schedule_profiles=schedule_profiles, ) @@ -589,6 +596,7 @@ def _folder_content_payload(normalized_prefix: str) -> tuple[dict[str, Any], int sample_item=sample_item, calibration=calibration, pending_proposal=pending_proposal_raw, + summary=summary, ) advice_state = _backfill_multimodal_review_pack( config, @@ -601,7 +609,7 @@ def _folder_content_payload(normalized_prefix: str) -> tuple[dict[str, Any], int resolved_metric, _ = select_quality_metric(str(video_policy.get("quality_metric", "auto"))) sample_host_statuses = _sample_calibration_host_statuses(config) sample_host_key = _default_sample_host_key_from_statuses(sample_host_statuses) - sample_host_choices = _sample_host_options_from_statuses(sample_host_statuses) + sample_host_choices = _sample_host_options_from_statuses(config, sample_host_statuses) return ( { **base_context, @@ -827,6 +835,7 @@ def _promote_folder_outputs_action(normalized_prefix: str) -> ActionPayload: def _save_profile_action( normalized_prefix: str, confirm_high_impact: bool, + confirm_size_tradeoff: bool, reviewed_draft_hash: str, ) -> ActionPayload: return save_profile_action( @@ -843,6 +852,7 @@ def _save_profile_action( upsert_override=_upsert_override, auto_queue_folder_encode=_queue_folder_encode_action, confirm_high_impact=confirm_high_impact, + confirm_size_tradeoff=confirm_size_tradeoff, reviewed_draft_hash=reviewed_draft_hash, ) @@ -961,6 +971,7 @@ def _folder_display_policy( sample_item: dict[str, Any], calibration: dict[str, Any] | None, pending_proposal: dict[str, Any] | None, + summary: dict[str, Any] | None = None, ) -> dict[str, Any]: if calibration: calibration_policy = object_dict(calibration.get("policy")) @@ -973,6 +984,9 @@ def _folder_display_policy( current_policy = object_dict(proposal.get("current_policy")) if current_policy: return current_policy + live_policy = object_dict(object_dict(summary).get("resolved_policy")) + if live_policy: + return live_policy return object_dict(sample_item.get("resolved_policy")) @@ -1607,11 +1621,33 @@ def _sample_calibration_host_statuses(config: MediaforceConfig) -> list[HostStat def _sample_host_options(config: MediaforceConfig) -> list[dict[str, Any]]: - return sample_host_options(config, safe_collect_statuses=_safe_collect_host_statuses) + return sample_host_options( + config, + safe_collect_statuses=_safe_collect_host_statuses, + schedule_fields_for_host=lambda status: _sample_host_schedule_fields(config, status), + ) + +def _sample_host_options_from_statuses( + config: MediaforceConfig, + statuses: list[HostStatus], +) -> list[dict[str, Any]]: + return sample_host_options_from_statuses( + statuses, + schedule_fields_for_host=lambda status: _sample_host_schedule_fields(config, status), + ) -def _sample_host_options_from_statuses(statuses: list[HostStatus]) -> list[dict[str, Any]]: - return sample_host_options_from_statuses(statuses) + +def _sample_host_schedule_fields(config: MediaforceConfig, status: HostStatus) -> dict[str, Any]: + host_payload = {**_host_config_for_key(config, status.key), **asdict(status)} + policy = _schedule_profile_policy_for_host(config, host_payload) + schedule_open = _scheduler_allows_encode_run(policy, host_payload=host_payload) + summary = str(policy.get("summary") or "").strip() + return { + "schedule_open": schedule_open, + "schedule_detail": summary, + "schedule_profile_label": str(policy.get("label") or "Always"), + } def _sample_host_help_text(sample_host_choices: list[dict[str, Any]], selected_key: str) -> str: diff --git a/mediaforce/web/routes/folders.py b/mediaforce/web/routes/folders.py index f38dd31..f4c74ca 100644 --- a/mediaforce/web/routes/folders.py +++ b/mediaforce/web/routes/folders.py @@ -20,7 +20,7 @@ def register_folder_routes( queue_folder_encode_action: Callable[[str, str, bool], dict[str, Any]], validate_folder_outputs_action: Callable[[str], dict[str, Any]], promote_folder_outputs_action: Callable[[str], dict[str, Any]], - save_profile_action: Callable[[str, bool, str], dict[str, Any]], + save_profile_action: Callable[[str, bool, bool, str], dict[str, Any]], ) -> None: @app.get("/api/folders/{prefix:path}/status") def api_folder_status(prefix: str) -> JSONResponse: @@ -100,6 +100,7 @@ async def api_folder_save_profile(prefix: str, request: Request) -> JSONResponse save_profile_action( prefix.strip("/"), bool(body.get("confirm_high_impact", False)), + bool(body.get("confirm_size_tradeoff", False)), str(body.get("reviewed_draft_hash", "")), ) ) diff --git a/mediaforce/web/routes/settings.py b/mediaforce/web/routes/settings.py index ea5ff38..379b767 100644 --- a/mediaforce/web/routes/settings.py +++ b/mediaforce/web/routes/settings.py @@ -33,6 +33,7 @@ async def api_settings_save(request: Request) -> JSONResponse: libraries=[dict(item) for item in body.get("libraries", [])], remote_hosts=[dict(item) for item in body.get("remote_hosts", [])], transcode_root=str(body.get("transcode_root", "")).strip(), + video_defaults=dict(body.get("video_defaults", {})), encode_queue_scheduler=dict(body.get("encode_queue_scheduler", {})), schedule_profiles=[dict(item) for item in body.get("schedule_profiles", [])], ) diff --git a/mediaforce/web/runtime/folder_actions.py b/mediaforce/web/runtime/folder_actions.py index 6543b7a..70357ea 100644 --- a/mediaforce/web/runtime/folder_actions.py +++ b/mediaforce/web/runtime/folder_actions.py @@ -746,6 +746,7 @@ def save_profile_action( upsert_override: UpsertOverrideFn, auto_queue_folder_encode: AutoQueueApprovedFolderEncodeFn | None = None, confirm_high_impact: bool = False, + confirm_size_tradeoff: bool = False, reviewed_draft_hash: str = "", ) -> ActionPayload: calibration = load_calibration_state(config, normalized_prefix) @@ -804,7 +805,7 @@ def save_profile_action( operator_request=operator_request or None, calibration_payload=calibration_payload, ) - if size_issue is not None: + if size_issue is not None and not confirm_size_tradeoff: raise HTTPException(status_code=409, detail=size_issue) if str(calibration_payload.get("mode") or "sample") == "sample": if not calibration_payload.get("review_media_ready"): @@ -838,6 +839,7 @@ def save_profile_action( { "approval_artifact": approval_artifact, "operator_approved_at": calibration_payload["accepted_at"], + "operator_approved_size_tradeoff": bool(size_issue), }, ) upsert_override( diff --git a/mediaforce/web/runtime/folder_ai_tuning.py b/mediaforce/web/runtime/folder_ai_tuning.py index 046a01f..9f11f51 100644 --- a/mediaforce/web/runtime/folder_ai_tuning.py +++ b/mediaforce/web/runtime/folder_ai_tuning.py @@ -9,6 +9,7 @@ from mediaforce.core.config import MediaforceConfig from mediaforce.core.db import DBClient, open_db from mediaforce.core.type_defs import object_dict, object_list +from mediaforce.web.runtime.folder_tuning_advice import operator_preserves_source_resolution from mediaforce.web.runtime.folder_tuning_helpers import measured_size_budget_policy_fragment from mediaforce.library.folder_profiles import inspect_prefix from mediaforce.tuning.tuning_memory import promote_learning_artifact, retrieve_learning_context @@ -112,6 +113,62 @@ def _latest_failed_sample_job_payload(job: dict[str, Any] | None) -> dict[str, A } +def _positive_number(value: Any) -> float | None: + if not isinstance(value, str | int | float): + return None + try: + parsed = float(value) + except (TypeError, ValueError): + return None + return parsed if parsed > 0 else None + + +def _measured_budget_fragment_preserving_stricter_cap( + existing_fragment: dict[str, Any], measured_fragment: dict[str, Any] +) -> dict[str, Any]: + existing_cap = _positive_number(object_dict(existing_fragment.get("video")).get("max_encoded_percent")) + measured_cap = _positive_number(object_dict(measured_fragment.get("video")).get("max_encoded_percent")) + if existing_cap is None or measured_cap is None or existing_cap >= measured_cap: + return measured_fragment + return merge_policy_fragments( + measured_fragment, + {"video": {"max_encoded_percent": int(round(existing_cap))}}, + ) + + +def _operator_forbids_hard_budget_cap(operator_request: dict[str, Any] | None) -> bool: + request = object_dict(operator_request) + text = str(request.get("request_text") or "").strip().lower() + if not text: + return False + hard_cap_terms = ("hard cap", "hard size", "ceiling", "max_encoded_percent", "must hit") + priority_terms = ( + "prioritize preserving", + "prioritize resolution", + "prioritize 1080", + "preserve 1080", + "preserve source", + "source resolution", + "do not downscale", + "don't downscale", + "dont downscale", + ) + conditional_terms = ("only if", "if it does not require", "if it doesn't require", "if it doesnt require") + return any(term in text for term in hard_cap_terms) and any( + term in text for term in priority_terms + ) and any(term in text for term in conditional_terms) + + +def _honor_operator_source_resolution( + *, + operator_request: dict[str, Any] | None, + combined_fragment: dict[str, Any], +) -> dict[str, Any]: + if not operator_preserves_source_resolution(operator_request): + return combined_fragment + return merge_policy_fragments(combined_fragment, {"video": {"max_height": 0}}) + + def _proposal_can_queue( *, applied_fragment: dict[str, Any], @@ -691,8 +748,13 @@ def _tuned_preview_action( tuning_payload["review_artifact_critique"] = review_artifact_critique tuning = request_note_tuning(project_root=config.paths.project_root, payload=tuning_payload) tuned_policy, applied_fragment = apply_seed_policy(current_policy, object_dict(tuning.proposed_policy), mode="tune") - combined_fragment = applied_fragment - advice_payload = object_dict(deps.tuning_advice_payload(tuning=tuning, note=trimmed_note, applied_fragment=applied_fragment)) + combined_fragment = _honor_operator_source_resolution( + operator_request=operator_request, + combined_fragment=applied_fragment, + ) + if combined_fragment != applied_fragment: + tuned_policy = deps.apply_policy_fragment(current_policy, combined_fragment) + advice_payload = object_dict(deps.tuning_advice_payload(tuning=tuning, note=trimmed_note, applied_fragment=combined_fragment)) if public_review_pack is not None: advice_payload["multimodal_review_pack"] = public_review_pack if review_artifact_critique is not None: @@ -711,8 +773,22 @@ def _tuned_preview_action( operator_request=operator_request, size_target_analysis=size_target_analysis, ) + if measured_budget_fragment and _operator_forbids_hard_budget_cap(operator_request): + measured_budget_fragment = {} + advice_payload["budget_enforcement"] = { + "status": "skipped_operator_priority", + "size_target_analysis": size_target_analysis, + "reason": "The operator prioritized source resolution over a conditional hard size ceiling.", + } if measured_budget_fragment: + measured_budget_fragment = _measured_budget_fragment_preserving_stricter_cap( + combined_fragment, measured_budget_fragment + ) combined_fragment = merge_policy_fragments(combined_fragment, measured_budget_fragment) + combined_fragment = _honor_operator_source_resolution( + operator_request=operator_request, + combined_fragment=combined_fragment, + ) tuned_policy = deps.apply_policy_fragment(current_policy, combined_fragment) advice_payload["applied_policy"] = combined_fragment advice_payload["budget_enforcement"] = { diff --git a/mediaforce/web/runtime/folder_tuning_advice.py b/mediaforce/web/runtime/folder_tuning_advice.py index cab109a..b2c96a0 100644 --- a/mediaforce/web/runtime/folder_tuning_advice.py +++ b/mediaforce/web/runtime/folder_tuning_advice.py @@ -46,6 +46,14 @@ _CROP_VALUE_RE = re.compile(r"\d+:\d+:\d+:\d+") _SIZE_BUDGET_RE = re.compile(r"(?P\d+(?:\.\d+)?)\s*(?Pkb|mb|gb|tb)\b", re.IGNORECASE) _SCALE_HEIGHT_RE = re.compile(r"(?240|360|480|540|720|1080|1440|2160|4320)p\b", re.IGNORECASE) +_SOURCE_RESOLUTION_RE = re.compile( + r"\b(?:source|original|native)\s+resolution\b|\b(?:do\s+not|don't|dont|no)\s+(?:downsample|downscale|scale\s+down)\b|\bkeep\s+max_height\s+(?:unset|at\s+0|0)\b|\bmax_height\s+(?:unset|0)\b", + re.IGNORECASE, +) +_HARD_SIZE_CAP_RE = re.compile( + r"\b(?:hard\s+(?:cap|ceiling|limit|size)|strict\s+(?:cap|ceiling|limit)|size\s+ceiling|max(?:imum)?\s+size|must\s+(?:hit|be|stay|remain)\s+(?:under|below|at)|do\s+not\s+exceed|max_encoded_percent)\b", + re.IGNORECASE, +) _METRIC_TARGET_RE = re.compile(r"\b(?Pvmaf|xpsnr)\s*(?:of\s*)?(?:around\s*)?(?P\d+(?:\.\d+)?)\b", re.IGNORECASE) _METRIC_DIRECTIVE_RE = re.compile( r"\b(?:use|using|with|evaluate(?:\s+with)?|measure(?:\s+with)?|run(?:\s+with)?|metric(?:\s+is)?)\s+" @@ -96,6 +104,8 @@ def _normalize_operator_note_parse(parsed: dict[str, Any] | None) -> dict[str, A raw_scale_height = int_value(parsed_object.get("scale_height")) if raw_scale_height > 0: scale_height = max(240, min(raw_scale_height, 4320)) + elif parsed_object.get("scale_height") is not None and raw_scale_height == 0: + scale_height = 0 raw_black_bar_handling = str(parsed_object.get("black_bar_handling") or "").strip().lower() if raw_black_bar_handling in {"auto", "smart"}: black_bar_handling = raw_black_bar_handling @@ -115,6 +125,7 @@ def _normalize_operator_note_parse(parsed: dict[str, Any] | None) -> dict[str, A operator_confirmed = False summary = str(parsed_object.get("summary") or "").strip() or "Parsed operator note." reasoning_note = str(parsed_object.get("reasoning_note") or "").strip() or "Structured operator note parse." + hard_size_cap = bool(parsed_object.get("hard_size_cap")) return { "summary": summary, "intent_type": intent_type, @@ -127,6 +138,7 @@ def _normalize_operator_note_parse(parsed: dict[str, Any] | None) -> dict[str, A "scale_height": scale_height, "black_bar_handling": black_bar_handling, "crop": crop, + "hard_size_cap": hard_size_cap, "reasoning_note": reasoning_note, } @@ -148,10 +160,14 @@ def _heuristic_operator_note_parse(note: str) -> dict[str, Any] | None: if any(token in lowered for token in ("smart", "auto", "automatic")): black_bar_handling = "smart" + source_resolution_requested = bool(_SOURCE_RESOLUTION_RE.search(trimmed)) scale_height = int(scale_match.group("height")) if scale_match is not None else None + if source_resolution_requested: + scale_height = 0 crop = crop_match.group(0) if crop_match is not None else None size_budget_value = float(size_budget_match.group("amount")) if size_budget_match is not None else None size_budget_unit = size_budget_match.group("unit").lower() if size_budget_match is not None else None + hard_size_cap = bool(_HARD_SIZE_CAP_RE.search(trimmed)) metric = ( metric_match.group("metric").lower() if metric_match is not None @@ -190,7 +206,7 @@ def _heuristic_operator_note_parse(note: str) -> dict[str, Any] | None: summary_parts: list[str] = [] if scale_height is not None: - summary_parts.append(f"drop to {scale_height}p") + summary_parts.append("keep source resolution" if scale_height == 0 else f"drop to {scale_height}p") if size_budget_value is not None and size_budget_unit is not None: summary_parts.append(f"target about {size_budget_value:g}{size_budget_unit.upper()}") if metric is not None: @@ -215,6 +231,7 @@ def _heuristic_operator_note_parse(note: str) -> dict[str, Any] | None: "scale_height": scale_height, "black_bar_handling": black_bar_handling, "crop": crop, + "hard_size_cap": hard_size_cap, "reasoning_note": "Local heuristic recovered the explicit operator request from the note text.", } @@ -257,7 +274,7 @@ def _merge_operator_note_parse( "black_bar_handling", "crop", ): - if merged.get(key) in {None, ""} and heuristic.get(key) not in {None, ""}: + if merged.get(key) in {None, ""} and heuristic.get(key) is not None and heuristic.get(key) != "": merged[key] = heuristic.get(key) if not merged.get("operator_confirmed") and heuristic.get("operator_confirmed"): merged["operator_confirmed"] = True @@ -470,6 +487,7 @@ def size_budget_request( amount = float_value(parsed_note.get("size_budget_value")) if multiplier is None or amount <= 0: return None + hard_size_cap = bool(parsed_note.get("hard_size_cap")) or bool(_HARD_SIZE_CAP_RE.search(trimmed)) budget_bytes = int(round(amount * multiplier)) source_size_bytes = None duration_seconds = None @@ -499,9 +517,11 @@ def size_budget_request( ) requested_max_encoded_percent = None target_encoded_percent = None - if estimated_source_percent is not None: + if hard_size_cap and estimated_source_percent is not None: requested_max_encoded_percent = round(estimated_source_percent, 2) target_encoded_percent = requested_max_encoded_percent + elif estimated_source_percent is not None: + target_encoded_percent = round(estimated_source_percent, 2) tradeoff_hint = None if isinstance(effective_sample_item, dict): tradeoff_hint = audio_tradeoff_hint( @@ -523,6 +543,7 @@ def size_budget_request( "target_video_bitrate_kbps": estimated_video_bitrate_kbps, "target_encoded_percent": target_encoded_percent, "target_tolerance_percent": SIZE_BUDGET_TARGET_TOLERANCE, + "hard_size_cap": hard_size_cap, "feasibility": feasibility, "requires_confirmation": requires_confirmation, "requested_max_encoded_percent": requested_max_encoded_percent, @@ -580,6 +601,9 @@ def scale_target_request(trimmed: str, parsed_note: dict[str, Any]) -> dict[str, height = max(240, min(height, 4320)) video_policy["max_height"] = height labels.append(f"{height}p max height") + elif parsed_note.get("scale_height") is not None and height == 0: + video_policy["max_height"] = 0 + labels.append("source resolution") if black_bar_handling in {"auto", "smart"}: video_policy["black_bar_handling"] = black_bar_handling video_policy["crop"] = "" @@ -594,7 +618,7 @@ def scale_target_request(trimmed: str, parsed_note: dict[str, Any]) -> dict[str, "operator_note_parse": parsed_note, "honor_mode": "literal_experiment", "request_type": "scale_target", - "scale_height": height if height > 0 else None, + "scale_height": height if height > 0 or parsed_note.get("scale_height") is not None else None, "scale_label": " + ".join(labels), "black_bar_handling": black_bar_handling if black_bar_handling in {"auto", "smart"} else None, "crop": crop if _CROP_VALUE_RE.fullmatch(crop) else None, @@ -648,6 +672,7 @@ def operator_requested_experiment( "estimated_video_bitrate_kbps": object_dict(requested_size_budget).get("estimated_video_bitrate_kbps"), "feasibility": object_dict(requested_size_budget).get("feasibility"), "requires_confirmation": object_dict(requested_size_budget).get("requires_confirmation"), + "hard_size_cap": object_dict(requested_size_budget).get("hard_size_cap"), "requested_max_encoded_percent": object_dict(requested_size_budget).get("requested_max_encoded_percent"), "applied_max_encoded_percent": object_dict(requested_size_budget).get("applied_max_encoded_percent"), "operator_confirmed": operator_confirmed, @@ -679,6 +704,22 @@ def apply_policy_fragment(policy: dict[str, Any], fragment: dict[str, Any] | Non return updated_policy +def operator_preserves_source_resolution(operator_request: dict[str, Any] | None) -> bool: + request = object_dict(operator_request) + if not request: + return False + if int_value(request.get("scale_height")) == 0 and request.get("scale_height") is not None: + return True + scale_request = object_dict(request.get("scale_request")) + if int_value(scale_request.get("scale_height")) == 0 and scale_request.get("scale_height") is not None: + return True + video = object_dict(object_dict(request.get("applied_policy")).get("video")) + if int_value(video.get("max_height")) == 0 and video.get("max_height") is not None: + return True + text = str(request.get("request_text") or "") + return bool(_SOURCE_RESOLUTION_RE.search(text)) + + def operator_request_signature(operator_request: dict[str, Any] | None) -> tuple[Any, ...] | None: request = object_dict(operator_request) if not request: diff --git a/mediaforce/web/runtime/folder_tuning_helpers.py b/mediaforce/web/runtime/folder_tuning_helpers.py index f899eff..300e1db 100644 --- a/mediaforce/web/runtime/folder_tuning_helpers.py +++ b/mediaforce/web/runtime/folder_tuning_helpers.py @@ -115,13 +115,15 @@ def measured_size_budget_policy_fragment( analysis = object_dict(size_target_analysis) if str(request.get("request_type") or "").strip().lower() not in {"size_budget", "combined_experiment"}: return {} + size_budget_request = object_dict(request.get("size_budget_request")) + if not bool(request.get("hard_size_cap") or size_budget_request.get("hard_size_cap")): + return {} if str(analysis.get("status") or "").strip().lower() != "over_target": return {} requested_cap = _number_or_none(request.get("requested_max_encoded_percent")) if requested_cap is None: requested_cap = _number_or_none(request.get("target_encoded_percent")) if requested_cap is None: - size_budget_request = object_dict(request.get("size_budget_request")) requested_cap = _number_or_none(size_budget_request.get("requested_max_encoded_percent")) if requested_cap is None or requested_cap <= 0: return {} @@ -179,8 +181,8 @@ def _unrequested_size_tradeoff_issue() -> str | None: current_height is None or abs(preview_height - current_height) > 0.01 ): return ( - "The draft changes resolution based only on a size budget. Ask for that resolution or run a " - "measured follow-up before changing the height cap." + "The draft changes resolution based only on a size budget. Ask for that resolution " + "explicitly before changing the height cap." ) for key in ("stereo_opus_bitrate", "surround_5_1_opus_bitrate", "surround_7_1_opus_bitrate"): if key in requested_audio: @@ -240,10 +242,10 @@ def _size_budget_alignment_issue() -> str | None: "The draft turns the size budget into a max_encoded_percent change. Size budgets are planning " "targets for measured comparison unless the operator explicitly asks for a hard cap." ) + tradeoff_issue = _unrequested_size_tradeoff_issue() + if tradeoff_issue is not None: + return tradeoff_issue if not allow_measured_size_quality_tradeoff: - tradeoff_issue = _unrequested_size_tradeoff_issue() - if tradeoff_issue is not None: - return tradeoff_issue for key in ("default_grain", "grain_denoise", "min_crf", "max_crf", "preset"): if key in requested_video: continue @@ -271,10 +273,10 @@ def _size_budget_cap_alignment_issue() -> str | None: "The draft turns the size budget into a max_encoded_percent change. Size budgets are planning " "targets for measured comparison unless the operator explicitly asks for a hard cap." ) + tradeoff_issue = _unrequested_size_tradeoff_issue() + if tradeoff_issue is not None: + return tradeoff_issue if not allow_measured_size_quality_tradeoff: - tradeoff_issue = _unrequested_size_tradeoff_issue() - if tradeoff_issue is not None: - return tradeoff_issue for key in ("default_grain", "grain_denoise", "min_crf", "max_crf", "preset"): if key in requested_video: continue @@ -291,6 +293,9 @@ def _size_budget_cap_alignment_issue() -> str | None: return f"The draft does not apply the requested {requested_cap:.0f}% size ceiling." if preview_cap > requested_cap + 0.01: return f"The draft uses a {preview_cap:.0f}% size ceiling instead of the requested {requested_cap:.0f}% ceiling." + tradeoff_issue = _unrequested_size_tradeoff_issue() + if tradeoff_issue is not None: + return tradeoff_issue return None def _metric_alignment_issue() -> str | None: @@ -328,6 +333,10 @@ def _scale_alignment_issue() -> str | None: if requested_height is None: return None preview_height = _policy_height(preview_video) + if requested_height <= 0: + if preview_height is not None and preview_height > 0: + return "The draft sets a height cap even though the operator requested source resolution." + return None if preview_height is None: return f"The draft does not apply the requested {requested_height:.0f}p height cap." if abs(preview_height - requested_height) > 0.01: diff --git a/mediaforce/web/runtime/host_runtime.py b/mediaforce/web/runtime/host_runtime.py index 9f8955a..3dd4754 100644 --- a/mediaforce/web/runtime/host_runtime.py +++ b/mediaforce/web/runtime/host_runtime.py @@ -180,7 +180,7 @@ def _running_encode_counts_by_host( .group_by(_running_job_host_key_sql()) ).mappings().fetchall() for row in rows: - host_key = str(row["host_key"] or "").strip() + host_key = f"{row['host_key'] or ''}".strip() if not host_key: continue counts[host_key] = int(row["job_count"] or 0) @@ -220,12 +220,15 @@ def _running_encode_jobs_for_hosts(connection: DBClient, *, format_eta_seconds: def _running_job_host_key_sql() -> Any: - current_key = func.nullif(func.trim(func.json_extract(encode_jobs.c.host_json, "$.key")), "") - current_host = func.nullif(func.trim(func.json_extract(encode_jobs.c.host_json, "$.host")), "") - current_label = func.nullif(func.trim(func.json_extract(encode_jobs.c.host_json, "$.label")), "") - last_key = func.nullif(func.trim(func.json_extract(encode_jobs.c.last_host_json, "$.key")), "") - last_host = func.nullif(func.trim(func.json_extract(encode_jobs.c.last_host_json, "$.host")), "") - last_label = func.nullif(func.trim(func.json_extract(encode_jobs.c.last_host_json, "$.label")), "") + def json_text_value(column: Any, key: str) -> Any: + return func.nullif(func.trim(func.json_extract(column, key)), "") + + current_key = json_text_value(encode_jobs.c.host_json, "$.key") + current_host = json_text_value(encode_jobs.c.host_json, "$.host") + current_label = json_text_value(encode_jobs.c.host_json, "$.label") + last_key = json_text_value(encode_jobs.c.last_host_json, "$.key") + last_host = json_text_value(encode_jobs.c.last_host_json, "$.host") + last_label = json_text_value(encode_jobs.c.last_host_json, "$.label") return func.coalesce( current_key, current_host, @@ -407,14 +410,26 @@ def sample_calibration_host_statuses(config: MediaforceConfig, *, safe_collect_s return hosts -def sample_host_options(config: MediaforceConfig, *, safe_collect_statuses: Any) -> list[dict[str, Any]]: +def sample_host_options( + config: MediaforceConfig, + *, + safe_collect_statuses: Any, + schedule_fields_for_host: Any | None = None, +) -> list[dict[str, Any]]: return sample_host_options_from_statuses( - sample_calibration_host_statuses(config, safe_collect_statuses=safe_collect_statuses)) + sample_calibration_host_statuses(config, safe_collect_statuses=safe_collect_statuses), + schedule_fields_for_host=schedule_fields_for_host, + ) -def sample_host_options_from_statuses(statuses: list[HostStatus]) -> list[dict[str, Any]]: +def sample_host_options_from_statuses( + statuses: list[HostStatus], + *, + schedule_fields_for_host: Any | None = None, +) -> list[dict[str, Any]]: options: list[dict[str, Any]] = [] for status in statuses: + schedule_fields = object_dict(schedule_fields_for_host(status)) if schedule_fields_for_host else {} options.append( { "key": status.key, @@ -422,6 +437,7 @@ def sample_host_options_from_statuses(statuses: list[HostStatus]) -> list[dict[s "detail": status.message if not status.available else ( "This machine" if host_status_targets_current_machine(status) else "Remote host"), "available": status.available, + **schedule_fields, } ) return options diff --git a/mediaforce/web/runtime/settings_payloads.py b/mediaforce/web/runtime/settings_payloads.py index 367f51b..0ea2962 100644 --- a/mediaforce/web/runtime/settings_payloads.py +++ b/mediaforce/web/runtime/settings_payloads.py @@ -4,7 +4,7 @@ from mediaforce.web.settings_runtime import HOST_CAPABILITY_OPTIONS, index_schedule_profile_rows, \ index_settings_library_rows, index_settings_remote_rows, schedule_profile_options, settings_archive_root, \ settings_library_rows_for_config, settings_remote_rows_for_config, settings_schedule_profile_rows_for_config, \ - settings_transcode_root_value + settings_transcode_root_value, settings_video_defaults_for_config def settings_page_payload( @@ -21,6 +21,7 @@ def settings_page_payload( libraries: list[dict[str, Any]] | None = None, remote_hosts: list[dict[str, Any]] | None = None, transcode_root: str | None = None, + video_defaults: dict[str, Any] | None = None, encode_queue_scheduler: dict[str, Any] | None = None, schedule_profiles: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: @@ -43,6 +44,7 @@ def settings_page_payload( "libraries": index_settings_library_rows(libraries) if libraries is not None else settings_library_rows_for_config(config), "remote_hosts": index_settings_remote_rows(remote_hosts) if remote_hosts is not None else settings_remote_rows_for_config(config), "transcode_root": resolved_transcode_root, + "video_defaults": dict(video_defaults) if video_defaults is not None else settings_video_defaults_for_config(config), "encode_queue_scheduler": resolved_encode_queue_scheduler, "schedule_profiles": resolved_schedule_profiles, "schedule_profile_options": schedule_profile_options(schedule_profiles=resolved_schedule_profiles), diff --git a/mediaforce/web/settings_runtime.py b/mediaforce/web/settings_runtime.py index 86003b6..bf9f806 100644 --- a/mediaforce/web/settings_runtime.py +++ b/mediaforce/web/settings_runtime.py @@ -33,6 +33,16 @@ LEGACY_DEFAULT_SCHEDULE_PROFILE = "default" DEFAULT_HOST_SCHEDULE_PROFILE = ALWAYS_SCHEDULE_PROFILE DEFAULT_HOST_MAX_PARALLEL_ENCODES = 1 +DEFAULT_VIDEO_DEFAULTS = { + "quality_metric": "auto", + "target_vmaf": "85", + "min_target_vmaf": "80", + "target_xpsnr": "41", + "min_target_xpsnr": "35", + "max_height": "1080", + "default_grain": "8", + "max_encoded_percent": "80", +} HOST_CAPABILITY_OPTIONS = ( {"key": "encode_queue", "label": "Queue encodes", "help": "Allow this host to run queued folder encodes."}, { @@ -292,6 +302,26 @@ def settings_transcode_root_value(config: MediaforceConfig) -> str: return stringify_pathlike(media.get("staging_root") if isinstance(media, dict) else None) +def settings_video_defaults_for_config(config: MediaforceConfig) -> dict[str, str]: + video = config.raw.get("video") + if not isinstance(video, dict): + return dict(DEFAULT_VIDEO_DEFAULTS) + return { + "quality_metric": str(video.get("quality_metric") or DEFAULT_VIDEO_DEFAULTS["quality_metric"]).strip().lower(), + "target_vmaf": _settings_number_text(video.get("target_vmaf"), DEFAULT_VIDEO_DEFAULTS["target_vmaf"]), + "min_target_vmaf": _settings_number_text(video.get("min_target_vmaf"), DEFAULT_VIDEO_DEFAULTS["min_target_vmaf"]), + "target_xpsnr": _settings_number_text(video.get("target_xpsnr"), DEFAULT_VIDEO_DEFAULTS["target_xpsnr"]), + "min_target_xpsnr": _settings_number_text( + video.get("min_target_xpsnr"), DEFAULT_VIDEO_DEFAULTS["min_target_xpsnr"] + ), + "max_height": _settings_number_text(video.get("max_height"), DEFAULT_VIDEO_DEFAULTS["max_height"]), + "default_grain": _settings_number_text(video.get("default_grain"), DEFAULT_VIDEO_DEFAULTS["default_grain"]), + "max_encoded_percent": _settings_number_text( + video.get("max_encoded_percent"), DEFAULT_VIDEO_DEFAULTS["max_encoded_percent"] + ), + } + + def settings_archive_root(transcode_root: str) -> str: cleaned_root = transcode_root.strip() if not cleaned_root: @@ -307,6 +337,16 @@ def stringify_pathlike(value: JSONValue | Path) -> str: return "" +def _settings_number_text(value: JSONValue, default: str) -> str: + try: + parsed = float(str(value)) + except (TypeError, ValueError): + return default + if parsed.is_integer(): + return str(int(parsed)) + return f"{parsed:g}" + + def settings_form_indexes(form_data: dict[str, str], prefix: str) -> list[int]: indexes: set[int] = set() for key in form_data: @@ -429,6 +469,7 @@ def build_runtime_settings_payload( libraries: list[dict[str, Any]], remote_hosts: list[dict[str, Any]], transcode_root: str, + video_defaults: dict[str, Any] | None = None, encode_queue_scheduler: dict[str, Any], schedule_profiles: list[dict[str, Any]], ) -> dict[str, Any]: @@ -590,6 +631,7 @@ def _text(value: JSONValue, default: str = "") -> str: if invalid_host_profiles: raise ValueError("Unknown schedule profile for host assignment: " + ", ".join(invalid_host_profiles)) + normalized_video_defaults = normalize_video_defaults(video_defaults) staging_root = Path(transcode_root).expanduser() return { "media": { @@ -598,6 +640,7 @@ def _text(value: JSONValue, default: str = "") -> str: "staging_root": str(staging_root), "archive_root": str(staging_root / "_replaced"), }, + "video": normalized_video_defaults, "remote_hosts": normalized_remotes, "encode_queue": { "scheduler": normalize_encode_queue_scheduler(encode_queue_scheduler), @@ -606,6 +649,50 @@ def _text(value: JSONValue, default: str = "") -> str: } +def normalize_video_defaults(raw: dict[str, Any] | None) -> dict[str, Any]: + payload = dict(DEFAULT_VIDEO_DEFAULTS) + if isinstance(raw, dict): + payload.update(raw) + metric = str(payload.get("quality_metric") or DEFAULT_VIDEO_DEFAULTS["quality_metric"]).strip().lower() + if metric not in {"auto", "vmaf", "xpsnr"}: + metric = DEFAULT_VIDEO_DEFAULTS["quality_metric"] + + def _float_field(key: str, *, minimum: float, maximum: float) -> float: + try: + value = float(str(payload.get(key, DEFAULT_VIDEO_DEFAULTS[key]))) + except (TypeError, ValueError) as exc: + raise ValueError(f"Video default {key} must be a number.") from exc + if value < minimum or value > maximum: + raise ValueError(f"Video default {key} must be between {minimum:g} and {maximum:g}.") + return value + + def _int_field(key: str, *, minimum: int, maximum: int) -> int: + value = _float_field(key, minimum=minimum, maximum=maximum) + if not value.is_integer(): + raise ValueError(f"Video default {key} must be a whole number.") + return int(value) + + target_vmaf = _float_field("target_vmaf", minimum=1, maximum=100) + min_target_vmaf = _float_field("min_target_vmaf", minimum=1, maximum=100) + if min_target_vmaf > target_vmaf: + raise ValueError("Video default min_target_vmaf must be less than or equal to target_vmaf.") + target_xpsnr = _float_field("target_xpsnr", minimum=1, maximum=100) + min_target_xpsnr = _float_field("min_target_xpsnr", minimum=1, maximum=100) + if min_target_xpsnr > target_xpsnr: + raise ValueError("Video default min_target_xpsnr must be less than or equal to target_xpsnr.") + + return { + "quality_metric": metric, + "target_vmaf": target_vmaf, + "min_target_vmaf": min_target_vmaf, + "target_xpsnr": target_xpsnr, + "min_target_xpsnr": min_target_xpsnr, + "max_height": _int_field("max_height", minimum=0, maximum=4320), + "default_grain": _int_field("default_grain", minimum=0, maximum=50), + "max_encoded_percent": _float_field("max_encoded_percent", minimum=1, maximum=100), + } + + def merge_runtime_settings_payload(existing: dict[str, Any], updates: dict[str, Any]) -> dict[str, Any]: merged = dict(existing) if "encode_queue" in updates: diff --git a/tests/test_encode_queue_recovery.py b/tests/test_encode_queue_recovery.py index 2caa53a..3fb81b1 100644 --- a/tests/test_encode_queue_recovery.py +++ b/tests/test_encode_queue_recovery.py @@ -10,6 +10,7 @@ import threading import unittest from collections.abc import Mapping +from dataclasses import replace from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any, Callable, cast @@ -2009,6 +2010,85 @@ def test_runtime_settings_payload_defaults_host_schedule_to_always(self) -> None ) self.assertEqual(payload["remote_hosts"][0]["schedule_profile"], "always") + def test_runtime_settings_payload_persists_video_defaults(self) -> None: + payload = web_app._build_runtime_settings_payload( + libraries=[{"key": "tv", "path": str(self.root / "source" / "tv")}], + remote_hosts=[], + transcode_root=str(self.root / "staging"), + video_defaults={ + "quality_metric": "VMAF", + "target_vmaf": "85", + "min_target_vmaf": "80", + "target_xpsnr": "41", + "min_target_xpsnr": "35", + "max_height": "1080", + "default_grain": "8", + "max_encoded_percent": "80", + }, + encode_queue_scheduler={"mode": "anytime", "start_hour": 22, "end_hour": 8, "timezone": "local"}, + schedule_profiles=[], + ) + + self.assertEqual( + payload["video"], + { + "quality_metric": "vmaf", + "target_vmaf": 85.0, + "min_target_vmaf": 80.0, + "target_xpsnr": 41.0, + "min_target_xpsnr": 35.0, + "max_height": 1080, + "default_grain": 8, + "max_encoded_percent": 80.0, + }, + ) + + def test_runtime_settings_payload_persists_xpsnr_video_defaults(self) -> None: + payload = web_app._build_runtime_settings_payload( + libraries=[{"key": "tv", "path": str(self.root / "source" / "tv")}], + remote_hosts=[], + transcode_root=str(self.root / "staging"), + video_defaults={ + "quality_metric": "XPSNR", + "target_vmaf": "85", + "min_target_vmaf": "80", + "target_xpsnr": "39.5", + "min_target_xpsnr": "34.5", + "max_height": "1080", + "default_grain": "8", + "max_encoded_percent": "80", + }, + encode_queue_scheduler={"mode": "anytime", "start_hour": 22, "end_hour": 8, "timezone": "local"}, + schedule_profiles=[], + ) + + self.assertEqual(payload["video"]["quality_metric"], "xpsnr") + self.assertEqual(payload["video"]["target_xpsnr"], 39.5) + self.assertEqual(payload["video"]["min_target_xpsnr"], 34.5) + + def test_runtime_settings_payload_rejects_unsupported_video_metric(self) -> None: + payload = web_app._build_runtime_settings_payload( + libraries=[{"key": "tv", "path": str(self.root / "source" / "tv")}], + remote_hosts=[], + transcode_root=str(self.root / "staging"), + video_defaults={"quality_metric": "ssim"}, + encode_queue_scheduler={"mode": "anytime", "start_hour": 22, "end_hour": 8, "timezone": "local"}, + schedule_profiles=[], + ) + + self.assertEqual(payload["video"]["quality_metric"], "auto") + + def test_runtime_settings_payload_defaults_video_metric_to_auto(self) -> None: + payload = web_app._build_runtime_settings_payload( + libraries=[{"key": "tv", "path": str(self.root / "source" / "tv")}], + remote_hosts=[], + transcode_root=str(self.root / "staging"), + encode_queue_scheduler={"mode": "anytime", "start_hour": 22, "end_hour": 8, "timezone": "local"}, + schedule_profiles=[], + ) + + self.assertEqual(payload["video"]["quality_metric"], "auto") + def test_runtime_settings_payload_normalizes_media_access(self) -> None: payload = web_app._build_runtime_settings_payload( libraries=[{"key": "tv", "path": str(self.root / "source" / "tv")}], @@ -4079,13 +4159,13 @@ def test_web_startup_cli_args_override_environment_defaults(self) -> None: "--host", "127.0.0.9", "--port", - "5555", + "8777", "--no-reload", ]) settings = web_app._web_startup_settings(args) self.assertEqual(settings.host, "127.0.0.9") - self.assertEqual(settings.port, 5555) + self.assertEqual(settings.port, 8777) self.assertFalse(settings.reload_enabled) def test_web_startup_shell_env_overrides_project_env_file(self) -> None: @@ -4118,11 +4198,11 @@ def test_main_uses_cli_values_for_uvicorn(self) -> None: ), patch("mediaforce.web.app.load_config", return_value=config), patch( "mediaforce.web.app.create_app", return_value=object() ), patch("mediaforce.web.app.uvicorn.run") as uvicorn_run_mock: - web_app.main(["--host", "127.0.0.9", "--port", "5555", "--no-reload"]) + web_app.main(["--host", "127.0.0.9", "--port", "8777", "--no-reload"]) uvicorn_run_mock.assert_called_once() self.assertEqual(uvicorn_run_mock.call_args.kwargs["host"], "127.0.0.9") - self.assertEqual(uvicorn_run_mock.call_args.kwargs["port"], 5555) + self.assertEqual(uvicorn_run_mock.call_args.kwargs["port"], 8777) self.assertNotIn("reload", uvicorn_run_mock.call_args.kwargs) def test_main_uses_cli_config_path_for_reload_app(self) -> None: @@ -4754,6 +4834,52 @@ def test_save_profile_action_rejects_alignment_mismatch_before_approval(self) -> self.assertEqual(exc.exception.status_code, 409) self.assertIn("does not apply the requested 1080p height cap", str(exc.exception.detail)) + def test_save_profile_action_allows_explicit_size_tradeoff_approval(self) -> None: + saved_payloads: list[folder_actions_runtime.ActionPayload] = [] + merged_advice: list[folder_actions_runtime.ActionPayload] = [] + calibration_payload: folder_actions_runtime.ActionPayload = { + "mode": "sample", + "job_id": "sample-1", + "review_media_ready": True, + "policy": {"video": {"target_vmaf": 85.0, "max_encoded_percent": 80}}, + "sample_item": { + "library_item_id": 1, + "resolved_policy": {"video": {"target_vmaf": 85.0, "max_encoded_percent": 80}}, + }, + "sample_result": {"predicted_total_size_bytes": 376 * 1024 * 1024}, + } + + result = folder_actions_runtime.save_profile_action( + self.config, + "tv/show", + now_iso=lambda: "2026-05-24T04:40:00+00:00", + load_sample_item=lambda *_args, **_kwargs: None, + load_calibration_state=lambda *_args, **_kwargs: dict(calibration_payload), + calibration_draft_hash=web_app._calibration_draft_hash, + save_calibration_state=lambda _config, _prefix, payload: saved_payloads.append(dict(payload)), + load_advice_state=lambda *_args, **_kwargs: { + "request_disposition": "honored", + "operator_request": { + "operator_confirmed": True, + "request_type": "size_budget", + "budget_bytes": 300 * 1024 * 1024, + "budget_label": "300 MB per episode", + "applied_policy": None, + }, + }, + record_visual_approval_artifact=lambda *_args, **_kwargs: {"artifact_id": "approval-1"}, + merge_advice_state=lambda _config, _prefix, payload: merged_advice.append(dict(payload)), + upsert_override=lambda *_args, **_kwargs: None, + auto_queue_folder_encode=lambda *_args, **_kwargs: {"ok": True, "message": "Queued folder encode."}, + confirm_size_tradeoff=True, + reviewed_draft_hash=web_app._calibration_draft_hash(calibration_payload), + ) + + self.assertTrue(result["ok"]) + self.assertTrue(result["queued"]) + self.assertEqual(saved_payloads[0]["accepted_at"], "2026-05-24T04:40:00+00:00") + self.assertTrue(merged_advice[0]["operator_approved_size_tradeoff"]) + def test_run_sampled_calibration_keeps_review_directory_for_approval(self) -> None: source_path = self._create_source_file("episode-review.mkv") preview_dir = self.config.paths.review_dir / "run-123" / "item-00" @@ -5218,6 +5344,56 @@ def test_resolve_sample_host_accepts_remote_sample_host(self) -> None: host = web_app._resolve_sample_host(self.config, "cbusillo@m1-mini") self.assertEqual(host.key, "cbusillo@m1-mini") + def test_resolve_sample_host_allows_closed_encode_schedule_for_manual_samples(self) -> None: + config = replace( + self.config, + raw={ + **self.config.raw, + "remote_hosts": [ + { + "label": "M4 Studio", + "host": "cbusillo@localhost", + "capabilities": ["encode_queue", "sample_calibration"], + "schedule_profile": "tou_vb", + } + ], + "encode_queue": { + "schedule_profiles": [ + { + "key": "tou_vb", + "label": "Time of Use VB Super off Peak", + "mode": "night", + "timezone": "host_local", + "start_hour": 0, + "end_hour": 5, + "days_of_week": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], + } + ] + }, + }, + ) + statuses = [ + HostStatus( + key="cbusillo@localhost", + label="M4 Studio", + mode="ssh", + priority=100, + capabilities=["encode_queue", "sample_calibration"], + available=True, + message="Mounted and ready", + missing_paths=[], + repo_path=str(self.root), + utc_offset_minutes=-240, + ) + ] + + with patch("mediaforce.web.app._safe_collect_host_statuses", return_value=statuses): + with patch("mediaforce.web.app.datetime") as fake_datetime: + fake_datetime.now.return_value = web_app._parse_iso("2026-05-24T16:30:00+00:00") + host = web_app._resolve_sample_host(config, "cbusillo@localhost") + + self.assertEqual(host.key, "cbusillo@localhost") + def test_resolve_sample_host_rejects_non_sample_host(self) -> None: statuses = [ HostStatus( diff --git a/tests/test_tuning_runtime.py b/tests/test_tuning_runtime.py index f532850..92fd043 100644 --- a/tests/test_tuning_runtime.py +++ b/tests/test_tuning_runtime.py @@ -158,7 +158,13 @@ def _fake_operator_note_parse(*, project_root: Path, payload: dict[str, object]) r"\b(?P480|540|576|720|900|1080|1440|2160)p\b.{0,40}\b(?:downsample|downscale|scale|resize|cap|limit)\b", lower, ) + source_resolution_requested = bool(re.search( + r"\b(?:source|original|native)\s+resolution\b|\b(?:do\s+not|don't|dont|no)\s+(?:downsample|downscale|scale\s+down)\b|\bkeep\s+max_height\s+(?:unset|at\s+0|0)\b|\bmax_height\s+(?:unset|0)\b", + lower, + )) scale_height = int(scale_match.group("height")) if scale_match else None + if source_resolution_requested: + scale_height = 0 black_bar_handling = "smart" if re.search(r"\b(?:smart|auto(?:matic)?)\b.{0,24}\bblack[- ]?bar", lower) or re.search( r"\bblack[- ]?bar.{0,24}\b(?:smart|auto(?:matic)?)\b", lower ) else None @@ -388,6 +394,36 @@ def test_folder_display_policy_prefers_saved_calibration_over_pending_preview(se self.assertEqual(policy["video"]["quality_metric"], "xpsnr") self.assertEqual(policy["video"]["target_xpsnr"], 35.5) + def test_folder_display_policy_prefers_live_summary_over_sample_snapshot(self) -> None: + sample_item = { + "resolved_policy": { + "video": { + "quality_metric": "vmaf", + "target_vmaf": 95.0, + "max_height": 720, + } + } + } + summary = { + "resolved_policy": { + "video": { + "quality_metric": "vmaf", + "target_vmaf": 85.0, + "max_height": 1080, + } + } + } + + policy = _folder_display_policy( + sample_item=sample_item, + calibration=None, + pending_proposal=None, + summary=summary, + ) + + self.assertEqual(policy["video"]["target_vmaf"], 85.0) + self.assertEqual(policy["video"]["max_height"], 1080) + def test_folder_ai_tune_confirm_retries_latest_stopped_sample_without_pending_proposal(self) -> None: saved_jobs: list[dict[str, object]] = [] host = HostStatus( @@ -3462,11 +3498,37 @@ def test_operator_requested_experiment_detects_size_budget_request(self) -> None self.assertEqual(request["budget_label"], "200 MB per episode") self.assertEqual(request["feasibility"], "aggressive") self.assertFalse(request["requires_confirmation"]) + self.assertFalse(request["hard_size_cap"]) self.assertAlmostEqual(request["estimated_source_percent"], 4.36, places=2) self.assertAlmostEqual(request["target_encoded_percent"], 4.36, places=2) + self.assertIsNone(request["requested_max_encoded_percent"]) self.assertIsNone(request["applied_max_encoded_percent"]) self.assertIsNone(request["applied_policy"]) + def test_operator_requested_experiment_detects_explicit_hard_size_cap(self) -> None: + request = _operator_requested_experiment( + "Hard cap this at 300MB per episode.", + { + "source_size_bytes": 4_349_049_136, + "duration_seconds": 3161.376, + "audio_summary": [{"codec_name": "ac3", "channels": 6}], + "resolved_policy": { + "video": {"encoder": "libsvtav1"}, + "audio": { + "convert_to_opus_codecs": ["ac3"], + "surround_5_1_opus_bitrate": "256k", + }, + }, + }, + ) + + assert request is not None + self.assertEqual(request["request_type"], "size_budget") + self.assertTrue(request["hard_size_cap"]) + self.assertEqual(request["budget_label"], "300 MB per episode") + self.assertAlmostEqual(request["requested_max_encoded_percent"], 7.23, places=2) + self.assertIsNone(request["applied_policy"]) + def test_operator_requested_experiment_detects_scale_target_request(self) -> None: request = _operator_requested_experiment("Downsample the 4K files to 1080p for this folder.") @@ -3476,6 +3538,18 @@ def test_operator_requested_experiment_detects_scale_target_request(self) -> Non self.assertEqual(request["scale_height"], 1080) self.assertEqual(request["applied_policy"]["video"]["max_height"], 1080) + def test_operator_requested_experiment_detects_source_resolution_request(self) -> None: + request = _operator_requested_experiment( + "Keep source resolution at 1080p. Do not downscale and keep max_height unset or 0." + ) + + assert request is not None + self.assertEqual(request["request_type"], "scale_target") + self.assertTrue(request["operator_confirmed"]) + self.assertEqual(request["scale_height"], 0) + self.assertEqual(request["scale_label"], "source resolution") + self.assertEqual(request["applied_policy"]["video"]["max_height"], 0) + def test_operator_requested_experiment_detects_smart_black_bar_request(self) -> None: request = _operator_requested_experiment("Use smart black-bar detection for this letterboxed season.") @@ -3506,6 +3580,30 @@ def test_operator_requested_experiment_combines_scale_with_metric_request(self) self.assertEqual(request["applied_policy"]["video"]["target_vmaf"], 88.0) self.assertEqual(request["applied_policy"]["video"]["max_height"], 1080) + def test_operator_requested_experiment_combines_source_resolution_with_budget(self) -> None: + request = _operator_requested_experiment( + "Keep source resolution, do not downscale, and target 300MB per episode if possible.", + { + "source_size_bytes": 4_349_049_136, + "duration_seconds": 3161.376, + "audio_summary": [{"codec_name": "ac3", "channels": 6}], + "resolved_policy": { + "video": {"encoder": "libsvtav1"}, + "audio": { + "convert_to_opus_codecs": ["ac3"], + "surround_5_1_opus_bitrate": "256k", + }, + }, + }, + ) + + assert request is not None + self.assertEqual(request["request_type"], "combined_experiment") + self.assertEqual(request["budget_label"], "300 MB per episode") + self.assertEqual(request["scale_height"], 0) + self.assertEqual(request["applied_policy"]["video"]["max_height"], 0) + self.assertAlmostEqual(request["estimated_video_bitrate_kbps"], 540.0, places=1) + def test_size_budget_feasibility_treats_old_codec_style_low_bitrates_as_aggressive_first(self) -> None: feasibility, requires_confirmation = size_budget_feasibility( source_percent=4.36, @@ -3724,7 +3822,7 @@ def test_folder_ai_tune_preview_keeps_current_policy_for_first_size_budget_sampl self.assertIsNone(proposal["job_fields"]["seed_applied_policy"]) self.assertEqual(proposal["request_disposition"], "honored") - def test_folder_ai_tune_preview_enforces_size_cap_after_measured_miss(self) -> None: + def test_folder_ai_tune_preview_keeps_soft_size_target_from_becoming_cap_after_measured_miss(self) -> None: saved_proposals: list[dict[str, Any]] = [] base_policy = {"video": {"target_vmaf": 95.0, "max_encoded_percent": 80}} host = HostStatus( @@ -3742,8 +3840,8 @@ def test_folder_ai_tune_preview_enforces_size_cap_after_measured_miss(self) -> N "operator_confirmed": True, "budget_label": "300 MB per episode", "budget_bytes": 314_572_800, - "requested_max_encoded_percent": 7.23, "target_encoded_percent": 7.23, + "hard_size_cap": False, "applied_policy": None, } size_target_analysis = { @@ -3849,16 +3947,145 @@ def fake_request_note_tuning(*, project_root: Path, payload: dict[str, object]) self.assertTrue(result["ok"]) proposal = saved_proposals[0] self.assertTrue(proposal["can_queue"]) - self.assertEqual(proposal["preview_policy"]["video"]["max_encoded_percent"], 7) + self.assertEqual(proposal["preview_policy"]["video"]["max_encoded_percent"], 80) self.assertEqual(proposal["preview_policy"]["video"]["target_vmaf"], 88.0) - self.assertEqual(proposal["applied_policy"]["video"]["max_encoded_percent"], 7) - self.assertEqual(proposal["operator_request"]["applied_max_encoded_percent"], 7) - self.assertEqual(proposal["operator_request"]["applied_policy"]["video"]["max_encoded_percent"], 7) - self.assertEqual(proposal["budget_enforcement"]["status"], "enforced_after_miss") - self.assertEqual(proposal["advice_payload"]["budget_enforcement"]["status"], "enforced_after_miss") + self.assertEqual(proposal["applied_policy"], {"video": {"target_vmaf": 88.0, "min_target_vmaf": 88.0}}) + self.assertNotIn("applied_max_encoded_percent", proposal["operator_request"]) + self.assertIsNone(proposal["budget_enforcement"]) + self.assertNotIn("budget_enforcement", proposal["advice_payload"]) + + def test_folder_ai_tune_preview_keeps_stricter_ai_size_cap_for_hard_cap_after_measured_miss(self) -> None: + saved_proposals: list[dict[str, Any]] = [] + base_policy = {"video": {"target_vmaf": 95.0, "max_encoded_percent": 80}} + host = HostStatus( + key="host-1", + label="M4 Studio", + mode="ssh", + priority=10, + capabilities=["sample_calibration"], + available=True, + message="Mounted and ready", + missing_paths=[], + ) + operator_request = { + "request_type": "size_budget", + "operator_confirmed": True, + "budget_label": "300 MB per episode", + "budget_bytes": 314_572_800, + "requested_max_encoded_percent": 7.23, + "target_encoded_percent": 7.23, + "hard_size_cap": True, + "applied_policy": None, + } + size_target_analysis = { + "status": "over_target", + "predicted_total_size_bytes": 803_322_876, + "budget_bytes": 314_572_800, + "predicted_to_budget_ratio": 2.55, + } + + def apply_fragment(current: dict[str, Any], fragment: dict[str, Any]) -> dict[str, Any]: + merged = dict(current) + for section, values in fragment.items(): + if isinstance(values, dict) and isinstance(merged.get(section), dict): + merged[section] = {**merged[section], **values} + else: + merged[section] = values + return merged + + def fake_request_note_tuning(*, project_root: Path, payload: dict[str, object]) -> TuningPolicyResponse: + self.assertEqual(project_root, self.config.paths.project_root) + self.assertEqual(payload["requested_experiment"], operator_request) + return TuningPolicyResponse( + ok=True, + summary="Use a tighter cap for the next measured sample.", + raw="{}", + prompt_version="test", + diagnosis="The last sample missed the requested size target.", + confidence="high", + evidence_checked=["runtime_toolbelt.size_target_analysis"], + suggested_follow_up=None, + request_disposition="honored", + request_response="I tightened the next sample against the measured miss.", + feasibility_note=None, + proposed_policy={"video": {"target_vmaf": 88.0, "max_encoded_percent": 5}}, + toolbelt_used=["size_target_analysis"], + self_check={"status": "pass", "summary": "ok", "issues": []}, + ) + + deps = FolderAiTuneDeps( + resolve_sample_host=lambda _config, _host_key: host, + load_job_state=lambda *_args, **_kwargs: None, + load_retryable_sample_job_state=lambda *_args, **_kwargs: None, + sample_item=lambda *_args, **_kwargs: { + "rel_path": "tv/show/episode.mkv", + "source_path": str(self.root / "source" / "tv" / "show" / "episode.mkv"), + "source_size_bytes": 4_000_000_000, + "video_codec": "h264", + "duration_seconds": 2500.0, + "audio_summary": [], + "subtitle_summary": [], + "resolved_policy": dict(base_policy), + }, + operator_requested_experiment=lambda *_args, **_kwargs: dict(operator_request), + load_calibration_state=lambda *_args, **_kwargs: { + "policy": dict(base_policy), + "sample_result": { + "predicted_total_size_bytes": 803_322_876, + "predicted_encode_percent": 18.47, + "quality_metric": "VMAF", + "quality_score": 95.04, + }, + }, + recent_tuning_sessions=lambda *_args, **_kwargs: [], + matching_request_history=lambda *_args, **_kwargs: None, + metric_support=lambda: {"vmaf": True}, + maybe_seed_baseline_policy=lambda *_args, **_kwargs: None, + seed_advice_payload=lambda *_args, **_kwargs: None, + proposal_alignment_issue=proposal_alignment_issue, + now_iso=lambda: "2026-04-25T18:30:00+00:00", + proposal_signal_copy=lambda *_args, **_kwargs: "signal", + proposal_context_snapshot=lambda **kwargs: dict(kwargs), + save_pending_proposal=lambda _config, _prefix, payload: saved_proposals.append(dict(payload)), + pending_proposal_public_view=lambda payload: payload, + build_tuning_runtime_toolbelt=lambda *_args, **_kwargs: { + "size_target_analysis": dict(size_target_analysis), + "recent_sample_result": {"quality_score": 95.04}, + }, + review_pack_dir=lambda *_args, **_kwargs: self.root / "review-pack", + remove_path_if_exists=lambda *_args, **_kwargs: None, + build_multimodal_review_pack=lambda *_args, **_kwargs: None, + multimodal_review_pack_public_view=lambda *_args, **_kwargs: None, + tuning_advice_payload=lambda **kwargs: {"summary": kwargs["tuning"].summary}, + load_pending_proposal=lambda *_args, **_kwargs: None, + apply_policy_fragment=apply_fragment, + save_advice_state=lambda *_args, **_kwargs: None, + save_job_state=lambda *_args, **_kwargs: None, + clear_pending_proposal=lambda *_args, **_kwargs: None, + record_tuning_session=lambda *_args, **_kwargs: "session-1", + ) + + with patch("mediaforce.web.runtime.folder_ai_tuning.inspect_prefix", return_value={"item_count": 1}), patch( + "mediaforce.web.runtime.folder_ai_tuning.request_note_tuning", + side_effect=fake_request_note_tuning, + ): + result = folder_ai_tune_preview_action( + self.config, + deps, + "tv/show", + "Hard cap at 200-300 MB per episode.", + "host-1", + ) + + self.assertTrue(result["ok"]) + proposal = saved_proposals[0] + self.assertEqual(proposal["preview_policy"]["video"]["max_encoded_percent"], 5) + self.assertEqual(proposal["applied_policy"]["video"]["max_encoded_percent"], 5) + self.assertEqual(proposal["operator_request"]["applied_max_encoded_percent"], 5) + self.assertEqual(proposal["operator_request"]["applied_policy"]["video"]["max_encoded_percent"], 5) self.assertEqual( proposal["advice_payload"]["budget_enforcement"]["applied_policy"], - {"video": {"max_encoded_percent": 7}}, + {"video": {"max_encoded_percent": 5}}, ) def test_folder_ai_tune_preview_exposes_latest_failed_sample_job_to_bench(self) -> None: @@ -4074,11 +4301,24 @@ def test_operator_requested_experiment_detects_combined_budget_and_vmaf_request( self.assertEqual(request["applied_policy"]["video"]["target_vmaf"], 85.0) self.assertNotIn("max_encoded_percent", request["applied_policy"]["video"]) - def test_measured_size_budget_policy_fragment_enforces_cap_after_miss(self) -> None: + def test_measured_size_budget_policy_fragment_ignores_soft_target_after_miss(self) -> None: + fragment = measured_size_budget_policy_fragment( + operator_request={ + "request_type": "size_budget", + "requested_max_encoded_percent": 7.23, + "hard_size_cap": False, + }, + size_target_analysis={"status": "over_target"}, + ) + + self.assertEqual(fragment, {}) + + def test_measured_size_budget_policy_fragment_enforces_explicit_hard_cap_after_miss(self) -> None: fragment = measured_size_budget_policy_fragment( operator_request={ "request_type": "size_budget", "requested_max_encoded_percent": 7.23, + "hard_size_cap": True, }, size_target_analysis={"status": "over_target"}, ) @@ -5364,8 +5604,8 @@ def test_proposal_alignment_issue_rejects_size_budget_resolution_drop(self) -> N self.assertEqual( issue, - "The draft changes resolution based only on a size budget. Ask for that resolution or run a " - "measured follow-up before changing the height cap.", + "The draft changes resolution based only on a size budget. Ask for that resolution " + "explicitly before changing the height cap.", ) def test_proposal_alignment_issue_rejects_size_budget_new_resolution_cap(self) -> None: @@ -5383,8 +5623,54 @@ def test_proposal_alignment_issue_rejects_size_budget_new_resolution_cap(self) - self.assertEqual( issue, - "The draft changes resolution based only on a size budget. Ask for that resolution or run a " - "measured follow-up before changing the height cap.", + "The draft changes resolution based only on a size budget. Ask for that resolution " + "explicitly before changing the height cap.", + ) + + def test_proposal_alignment_issue_rejects_measured_size_budget_resolution_drop(self) -> None: + issue = proposal_alignment_issue( + operator_request={ + "request_type": "size_budget", + "budget_label": "300 MB per episode", + "budget_bytes": 300 * 1024 * 1024, + "applied_policy": None, + }, + request_disposition="honored", + current_policy={"video": {"target_vmaf": 85.0, "max_height": 1080, "max_encoded_percent": 80}}, + preview_policy={"video": {"target_vmaf": 82.0, "max_height": 720, "max_encoded_percent": 80}}, + allow_measured_size_quality_tradeoff=True, + ) + + self.assertEqual( + issue, + "The draft changes resolution based only on a size budget. Ask for that resolution " + "explicitly before changing the height cap.", + ) + + def test_proposal_alignment_issue_rejects_measured_size_budget_audio_drop(self) -> None: + issue = proposal_alignment_issue( + operator_request={ + "request_type": "size_budget", + "budget_label": "300 MB per episode", + "budget_bytes": 300 * 1024 * 1024, + "applied_policy": None, + }, + request_disposition="honored", + current_policy={ + "video": {"target_vmaf": 85.0, "max_encoded_percent": 80}, + "audio": {"surround_5_1_opus_bitrate": "256k"}, + }, + preview_policy={ + "video": {"target_vmaf": 82.0, "max_encoded_percent": 80}, + "audio": {"surround_5_1_opus_bitrate": "160k"}, + }, + allow_measured_size_quality_tradeoff=True, + ) + + self.assertEqual( + issue, + "The draft changes audio.surround_5_1_opus_bitrate based only on a size budget. Ask for that audio " + "tradeoff or measure it explicitly before changing audio quality.", ) def test_proposal_alignment_issue_rejects_size_budget_audio_drop(self) -> None: @@ -5598,6 +5884,34 @@ def test_proposal_alignment_issue_validates_scale_target(self) -> None: self.assertEqual(issue, "The draft uses 720p instead of the requested 1080p height cap.") + def test_proposal_alignment_issue_validates_source_resolution_target(self) -> None: + issue = proposal_alignment_issue( + operator_request={ + "request_type": "scale_target", + "scale_height": 0, + "applied_policy": {"video": {"max_height": 0}}, + }, + request_disposition="honored", + current_policy={"video": {"max_height": 0}}, + preview_policy={"video": {"max_height": 1080}}, + ) + + self.assertEqual(issue, "The draft sets a height cap even though the operator requested source resolution.") + + def test_proposal_alignment_issue_allows_source_resolution_target(self) -> None: + issue = proposal_alignment_issue( + operator_request={ + "request_type": "scale_target", + "scale_height": 0, + "applied_policy": {"video": {"max_height": 0}}, + }, + request_disposition="honored", + current_policy={"video": {"max_height": 720}}, + preview_policy={"video": {"max_height": 0}}, + ) + + self.assertIsNone(issue) + def test_proposal_alignment_issue_validates_smart_black_bar_target(self) -> None: issue = proposal_alignment_issue( operator_request={