quality preset

This commit is contained in:
2026-05-28 14:18:10 +05:30
parent 413f06fb97
commit 5ada2d773b
5 changed files with 74 additions and 7 deletions

View File

@@ -24,6 +24,11 @@ pub struct Settings {
/// bulk extract (take every Nth frame in range). 1 = no decimation.
#[serde(default = "default_frame_skip")]
pub frame_skip: u64,
/// Maximum height (in pixels) of the canvas backing store during preview.
/// 0 = no cap beyond native resolution. Common presets: 2160 (4K),
/// 1440 (2K), 1080, 720, 480. Smaller → less draw cost.
#[serde(default)]
pub preview_max_height: u32,
}
fn default_sam_url() -> String {
@@ -58,6 +63,7 @@ impl Default for Settings {
cache_capacity: default_cache_capacity(),
hwaccel: String::new(),
frame_skip: default_frame_skip(),
preview_max_height: 0,
}
}
}

View File

@@ -301,6 +301,31 @@ export function SettingsModal({ open, onClose, onSaved }: Props) {
/>
</label>
</div>
<div className="settings-row">
<label>
<div className="settings-label">
Preview quality
<span className="dim"> · caps backing-store height; doesn't affect extracted frames</span>
</div>
<select
value={settings.preview_max_height}
onChange={(e) =>
setSettings({
...settings,
preview_max_height: Number(e.target.value),
})
}
>
<option value={0}>Original (no cap)</option>
<option value={2160}>4K 2160p</option>
<option value={1440}>2K 1440p</option>
<option value={1080}>1080p</option>
<option value={720}>720p</option>
<option value={480}>480p</option>
</select>
</label>
</div>
</section>
{error && <div className="picker-error">{error}</div>}

View File

@@ -1606,6 +1606,15 @@ main {
0 1px 0 rgba(255, 255, 255, 0.04) inset;
}
/* ExtractMode: canvas-frame must fill the stage at the video's aspect-ratio.
We can't rely on the <canvas>'s intrinsic size anymore (its backing store
is JS-controlled and capped), so this rule supplies the real display size.
The :not(.zoomable) scope leaves AnnotateMode's zoom layout untouched. */
.canvas-stage:not(.zoomable) .canvas-frame {
height: 100%;
max-width: 100%;
}
.canvas-frame canvas {
width: 100%;
height: 100%;

View File

@@ -28,6 +28,13 @@ export function ExtractMode({ username, settings }: Props) {
useEffect(() => {
frameSkipRef.current = Math.max(1, settings?.frame_skip ?? 1);
}, [settings?.frame_skip]);
// Preview-quality cap (in px height; 0 = original). Read per-draw via ref
// so a settings change triggers an immediate redraw of the current frame
// without rebuilding the playback effect.
const previewMaxHRef = useRef<number>(settings?.preview_max_height ?? 0);
useEffect(() => {
previewMaxHRef.current = settings?.preview_max_height ?? 0;
}, [settings?.preview_max_height]);
const [video, setVideo] = useState<VideoInfo | null>(null);
const [frameIdx, setFrameIdx] = useState(0);
const [playing, setPlaying] = useState(false);
@@ -120,20 +127,27 @@ export function ExtractMode({ username, settings }: Props) {
const ctx = canvas.getContext("2d");
if (!ctx) return;
// Backing store = displayed CSS rect × DPR, capped at video native
// resolution (anything above is upsampling for no benefit). Halves
// drawImage cost on 4K-into-1080p, which is the common slow-system
// case. Coords below are in image-pixel space — the transform handles
// the mapping to backing pixels, so overlay.ts is unchanged.
// Backing store = displayed CSS rect × DPR, capped at video native and
// optionally at the user's Preview-quality preset. Halves drawImage
// cost on 4K-into-1080p (the common slow-system case). Coords below
// are in image-pixel space — the transform maps to backing pixels so
// overlay.ts is unchanged.
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
let maxW = video.width;
let maxH = video.height;
const previewMaxH = previewMaxHRef.current;
if (previewMaxH > 0 && previewMaxH < video.height) {
maxH = previewMaxH;
maxW = Math.round(previewMaxH * (video.width / video.height));
}
const targetW = Math.max(
1,
Math.min(video.width, Math.round(rect.width * dpr))
Math.min(maxW, Math.round(rect.width * dpr))
);
const targetH = Math.max(
1,
Math.min(video.height, Math.round(rect.height * dpr))
Math.min(maxH, Math.round(rect.height * dpr))
);
if (canvas.width !== targetW) canvas.width = targetW;
if (canvas.height !== targetH) canvas.height = targetH;
@@ -249,6 +263,17 @@ export function ExtractMode({ username, settings }: Props) {
useEffect(() => clearBitmapCache, [clearBitmapCache]);
// Live re-render when the preview-quality cap changes so the user sees the
// resolution swap immediately on Settings save instead of having to seek.
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !video) return;
const idx = frameIdxRef.current;
const cached = getCachedBitmap(idx);
if (cached) drawBitmap(cached, idx);
// If not cached, the next pump invocation picks up the new cap naturally.
}, [settings?.preview_max_height, video, drawBitmap, getCachedBitmap]);
// Redraw on canvas resize: backing-store is sized from CSS rect × DPR, so
// a window resize/maximize would otherwise leave the old backing dimensions
// until the next decode landed. Cheap — just re-runs drawBitmap on the

View File

@@ -97,6 +97,8 @@ export interface Settings {
hwaccel: string;
/** Decimation factor for playback advancement + bulk extract. 1 = no decimation. */
frame_skip: number;
/** Cap (height in px) on the canvas backing-store during preview. 0 = original. */
preview_max_height: number;
}
export interface SamModelInfo {