Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 71 additions & 8 deletions studio/src-tauri/src/capture_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2062,13 +2062,39 @@ fn demo_file_key(path: &Path) -> Option<String> {
/// scanned: its key (size + first 64 KB) differs from the saved one. Their
/// highlights are frame numbers in the old file, so they don't line up (#21).
/// Missing files and demos with no saved key are left out.
pub fn changed_demos(demos: &[KnownDemo]) -> Vec<String> {
demos
.iter()
.filter(|d| !d.file_key.is_empty() && Path::new(&d.path).is_file())
.filter(|d| demo_file_key(Path::new(&d.path)).is_some_and(|key| key != d.file_key))
.map(|d| d.path.clone())
.collect()
///
/// `checked(done, total)` runs after each demo, so Start Capture's line can
/// count them (#953). Every call; the caller throttles.
pub fn changed_demos_with_progress(
demos: &[KnownDemo],
mut checked: impl FnMut(usize, usize),
) -> Vec<String> {
let mut changed = Vec::new();
for (i, d) in demos.iter().enumerate() {
if !d.file_key.is_empty()
&& Path::new(&d.path).is_file()
&& demo_file_key(Path::new(&d.path)).is_some_and(|key| key != d.file_key)
{
changed.push(d.path.clone());
}
checked(i + 1, demos.len());
}
changed
}

/// Whether a `changed_demos_progress` event goes out: the last demo always
/// (so the count ends on "12 of 12"), the others at most one per ~33 ms.
pub(crate) fn changed_progress_due(
last_ms: &std::sync::atomic::AtomicU32,
now_ms: u32,
done: usize,
total: usize,
) -> bool {
if done == total {
last_ms.store(now_ms, std::sync::atomic::Ordering::Relaxed);
return true;
}
progress_emit_due(last_ms, now_ms)
}

/// A project demo that is no longer at its saved path, and the file that
Expand Down Expand Up @@ -4009,11 +4035,48 @@ mod tests {
];
std::fs::write(&replaced, b"HLDEMO a different, shorter one").unwrap();
assert_eq!(
changed_demos(&demos),
changed_demos_with_progress(&demos, |_, _| {}),
vec![replaced.to_string_lossy().into_owned()]
);
}

#[test]
fn the_changed_demo_check_counts_every_demo_it_reads() {
let scratch = Scratch::new("changed_count");
let same = scratch.path().join("same.dem");
std::fs::write(&same, b"HLDEMO the scanned one").unwrap();
let demos = vec![
KnownDemo {
path: same.to_string_lossy().into_owned(),
file_key: demo_file_key(&same).unwrap(),
},
KnownDemo {
path: scratch
.path()
.join("gone.dem")
.to_string_lossy()
.into_owned(),
file_key: "1-00".into(),
},
];
let mut seen = Vec::new();
let changed = changed_demos_with_progress(&demos, |done, total| seen.push((done, total)));
assert!(changed.is_empty());
assert_eq!(seen, vec![(1, 2), (2, 2)]);
}

#[test]
fn the_changed_demo_count_is_throttled_but_always_ends_on_the_last() {
use std::sync::atomic::AtomicU32;
let last = AtomicU32::new(u32::MAX);
assert!(changed_progress_due(&last, 0, 1, 10));
assert!(!changed_progress_due(&last, 10, 2, 10));
assert!(!changed_progress_due(&last, 32, 3, 10));
assert!(changed_progress_due(&last, 33, 4, 10));
// The last demo goes out however soon after the one before.
assert!(changed_progress_due(&last, 34, 10, 10));
}

#[test]
fn a_missing_demo_with_no_key_or_no_match_is_reported_without_a_candidate() {
let scratch = Scratch::new("locate_none");
Expand Down
25 changes: 22 additions & 3 deletions studio/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,11 +155,30 @@ async fn locate_missing_demos(
.await
}

/// Which demos are no longer the file they were scanned from (#21).
/// Which demos are no longer the file they were scanned from (#21). Emits
/// `changed_demos_progress` ({ done, total }, at most every ~33 ms and on
/// the last demo) for Start Capture's line (#953).
#[tauri::command]
async fn changed_demos(demos: Vec<capture_manager::KnownDemo>) -> Result<Vec<String>, String> {
async fn changed_demos(
app_handle: tauri::AppHandle,
demos: Vec<capture_manager::KnownDemo>,
) -> Result<Vec<String>, String> {
messages::flatten_spawn_blocking(tokio::task::spawn_blocking(move || {
Ok(capture_manager::changed_demos(&demos))
use tauri::Emitter;
let started = std::time::Instant::now();
let last_emit = std::sync::atomic::AtomicU32::new(u32::MAX);
Ok(capture_manager::changed_demos_with_progress(
&demos,
|done, total| {
let now_ms = started.elapsed().as_millis().min(u32::MAX as u128 - 1) as u32;
if capture_manager::changed_progress_due(&last_emit, now_ms, done, total) {
let _ = app_handle.emit(
"changed_demos_progress",
serde_json::json!({ "done": done, "total": total }),
);
}
},
))
}))
.await
}
Expand Down
59 changes: 48 additions & 11 deletions studio/src/capture_pane.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { profileState } from './command_profiles.js';
import { initCaptureSummary, renderCaptureSummary } from './capture_summary_ui.js';
import { computeRequiredCaptureBytes, AGR_BYTES_PER_FRAME } from './capture_estimate.js';
import { setStatusLine, uiStatusText } from './status_line.js';
import { createCapturePrecheck } from './capture_precheck.js';
import { timeLeftSuffix } from './capture_time_left.js';
import { refreshAfterTyping } from './input_refresh.js';

Expand All @@ -39,6 +40,9 @@ let unlistenPatchingFinished = null;
// Tracks whether a batch is actively running so refreshLaunchGuard() never
// re-enables Start Capture out from under the capture_status "running" lock.
let capturingInFlight = false;
// Start Capture's checks before the batch (#953): while they run,
// refreshLaunchGuard() leaves Start greyed out too.
let precheck = null;
// The batch's time left (#643): the last estimate, the status line it goes
// after, and the timer that counts it down between clip boundaries.
let timeLeft = null;
Expand Down Expand Up @@ -494,7 +498,7 @@ export async function refreshLaunchGuard(state) {
const bannedCommandsPresent = bannedCount > 0;
const blocked = bannedCommandsPresent || pathsMissing || noHighlightsSelected || noDrivesConfigured || noUsableSpace || insufficientSpace;

if (!capturingInFlight) {
if (!capturingInFlight && !precheck?.isChecking()) {
startBtn.disabled = blocked;
}
// Every change that can block Start comes through here, so the strip
Expand Down Expand Up @@ -884,6 +888,7 @@ export function initCaptureUI(getState, onSettingsChange, onStatusChange, getTak
};

currentGetState = getState;
precheck = createCapturePrecheck({ startBtn, statusEl, progressContainer, refreshStart: () => refreshLaunchGuard() });
initCaptureSummary(currentCaptureSetup);
currentOnSettingsChange = onSettingsChange || null;
currentOnStatusChange = onStatusChange || null;
Expand Down Expand Up @@ -1381,7 +1386,26 @@ export function initCaptureUI(getState, onSettingsChange, onStatusChange, getTak

if (startBtn) {
startBtn.addEventListener('click', async () => {
if (precheck.isChecking()) return;
precheck.begin();
try {
await runChecksAndStart();
} finally {
// A check that threw, or returned without saying why.
precheck.stop();
}
});

// Start Capture Batch's checks, in order, each on the footer line (#953).
// A check that stops the batch calls precheck.stop() with the reason its
// toast gives; one that passes everything hands over to the batch.
const runChecksAndStart = async () => {
const state = getState ? getState() : { scanPaths: [], targetDrives: [], currentScannedDemos: [] };
// Shows `text` as a toast and on the line, and brings Start back.
const stopWith = (text) => {
showToast(text, 'error');
precheck.stop(text);
};

// OBS mode: verify live, right before committing to a batch, rather
// than trusting whatever a check found minutes (or a whole session)
Expand All @@ -1392,9 +1416,10 @@ export function initCaptureUI(getState, onSettingsChange, onStatusChange, getTak
// it (much like HLAE doesn't need to already be open either) and wait
// for it to come up before giving up.
if (document.querySelector('#config-capture-mode')?.value === 'obs') {
precheck.step('obs');
await runObsConnectionTest();
if (!isObsConnected() && !(await launchObsAndRetry())) {
showToast(STRINGS.CAPTURE.OBS_NOT_CONNECTED_WARNING, 'error');
stopWith(STRINGS.CAPTURE.OBS_NOT_CONNECTED_WARNING);
return;
}
}
Expand All @@ -1404,17 +1429,18 @@ export function initCaptureUI(getState, onSettingsChange, onStatusChange, getTak
// never let a capture start without sufficient disk space (this is
// also what closes the old zero-drive bypass: `availableBytes === 0`
// now blocks unconditionally instead of skipping the check).
precheck.step('disk');
const guard = await refreshLaunchGuard(state);
if (guard && guard.blocked) {
// Ahead of the disk branches, and well ahead of the running-game check
// below: "you haven't picked anything yet" is the answer the user needs,
// and it should not arrive behind a modal about closing their game.
if (guard.noHighlightsSelected) {
showToast(STRINGS.CAPTURE.NO_HIGHLIGHTS_SELECTED_WARNING, 'error');
stopWith(STRINGS.CAPTURE.NO_HIGHLIGHTS_SELECTED_WARNING);
} else if (guard.availableBytes === 0) {
showToast(STRINGS.CAPTURE.NO_CAPTURE_OUTPUT_DIR_WITH_SPACE, 'error');
stopWith(STRINGS.CAPTURE.NO_CAPTURE_OUTPUT_DIR_WITH_SPACE);
} else {
showToast(STRINGS.CAPTURE.insufficientDiskSpaceToast((guard.requiredBytes / 1e9).toFixed(2), (guard.availableBytes / 1e9).toFixed(2)), 'error');
stopWith(STRINGS.CAPTURE.insufficientDiskSpaceToast((guard.requiredBytes / 1e9).toFixed(2), (guard.availableBytes / 1e9).toFixed(2)));
}
return;
}
Expand All @@ -1424,14 +1450,18 @@ export function initCaptureUI(getState, onSettingsChange, onStatusChange, getTak
// still missing after that stops the batch, since its highlights
// cannot be captured. A picked demo with more than one map stops it
// too (#217); main.js has said why then ('handled').
const demosReady = pickedDemosPresent ? await pickedDemosPresent() : true;
precheck.step('demos');
const demosReady = pickedDemosPresent
? await pickedDemosPresent({ onProgress: (p) => precheck.step('demos', p?.done, p?.total) })
: true;
if (demosReady !== true) {
if (demosReady === false) showToast(STRINGS.CAPTURE.DEMOS_MISSING_NOT_STARTED, 'error');
if (demosReady === false) stopWith(STRINGS.CAPTURE.DEMOS_MISSING_NOT_STARTED);
return;
}

// A map a session crashed on before, for a reason the demo file can't
// show (#207): ask before patching.
precheck.step('maps');
const pickedPaths = (state.currentScannedDemos || [])
.filter((d) => (d.streaks || []).some((s) => s.selected === true))
.map((d) => d.path);
Expand All @@ -1444,14 +1474,15 @@ export function initCaptureUI(getState, onSettingsChange, onStatusChange, getTak
// One with no camera data captures a frozen view (#920).
if (!(await confirmNoCameraData(picked))) return;

precheck.step('paths');
const activePayload = buildCapturePayload(state);
if (!activePayload) return; // buildCapturePayload already toasted the reason

try {
await validatePaths(activePayload.hlae_path, activePayload.game_path);
} catch (err) {
console.error("Executable path validation failed:", err);
showToast(String(err), 'error');
stopWith(String(err));
return;
}

Expand All @@ -1470,8 +1501,9 @@ export function initCaptureUI(getState, onSettingsChange, onStatusChange, getTak
resolution_width: activePayload.resolution_width,
resolution_height: activePayload.resolution_height,
};
precheck.step('game');
if (!(await closeGameWithOtherSettings(launchRequest, { button: startBtn }))) {
setStatusLine(statusEl, STRINGS.RUNNING_GAME.BATCH_NOT_STARTED_STATUS);
precheck.stop(STRINGS.RUNNING_GAME.BATCH_NOT_STARTED_STATUS);
return;
}
let engineAlreadyRunning = false;
Expand All @@ -1483,24 +1515,29 @@ export function initCaptureUI(getState, onSettingsChange, onStatusChange, getTak
}

const runBatch = () => {
precheck.handOver();
showToast(STRINGS.CAPTURE.INITIALIZING_CAPTURE_BATCH, "info");
startBtn.disabled = true;
if (cancelBtn) cancelBtn.disabled = false;
return startBatchWithPayload(activePayload, state);
};

if (engineAlreadyRunning) {
// Its window asks what to do and starts the batch itself; Start comes
// back behind it in case the answer is no.
precheck.stop(STRINGS.CAPTURE_PRECHECK.GAME_OPEN);
requestProcessGuardedLaunch(runBatch, { forBatch: true });
return;
}

// Before any patching: without Steam the game can't start.
precheck.step('steam');
if (!(await ensureSteamReady())) {
setStatusLine(statusEl, STRINGS.STEAM.BATCH_NOT_STARTED_STATUS);
precheck.stop(STRINGS.STEAM.BATCH_NOT_STARTED_STATUS);
return;
}
runBatch();
});
};
}

// Split out of the click handler so the process-conflict modal can park it
Expand Down
77 changes: 77 additions & 0 deletions studio/src/capture_precheck.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// capture_precheck.js — what the Capture footer shows while Start Capture
// Batch checks everything before the batch starts (#953): OBS, disk space,
// the picked demos, the maps, the paths, a running game and Steam. Start is
// greyed out and the bar slides from the click, so the click never looks
// lost; a check that stops the batch brings Start back and says why.
// capture_pane.js calls it at each step.
//
// The sliding bar is the page's own `.progress-bar-container.indeterminate`;
// it can move to progress_view.js once #951 lands.
import { STRINGS } from './strings.js';
import { setStatusLine } from './status_line.js';

/// The line for `step`; `done`/`total` count the picked demos read so far.
export function precheckLine(step, done = 0, total = 0) {
const S = STRINGS.CAPTURE_PRECHECK;
switch (step) {
case 'obs': return S.OBS;
case 'disk': return S.DISK;
case 'demos': return total > 0 ? S.demosCounted(Math.min(done, total), total) : S.DEMOS;
case 'maps': return S.MAPS;
case 'paths': return S.PATHS;
case 'game': return S.GAME;
case 'steam': return S.STEAM;
default: return S.STARTING;
}
}

/**
* The footer while the checks run. `refreshStart()` puts Start back the way
* the disk-space guard wants it (capture_pane.js' refreshLaunchGuard).
*/
export function createCapturePrecheck({ startBtn, statusEl, progressContainer, refreshStart }) {
let checking = false;
let shownBefore = '';

function slide(on) {
if (!progressContainer) return;
progressContainer.classList.toggle('indeterminate', on);
}

return {
isChecking: () => checking,

/// The click: Start greys out and the first line shows at once.
begin() {
checking = true;
if (startBtn) startBtn.disabled = true;
if (progressContainer) {
shownBefore = progressContainer.style.display;
progressContainer.style.display = 'block';
}
slide(true);
setStatusLine(statusEl, precheckLine('start'));
},

step(name, done, total) {
if (checking) setStatusLine(statusEl, precheckLine(name, done, total));
},

/// A check stopped the batch: `why` is what its toast said (or nothing,
/// when the user said no to a question, or the check set its own line).
stop(why = STRINGS.CAPTURE_PRECHECK.NOT_STARTED) {
if (!checking) return;
checking = false;
slide(false);
if (progressContainer) progressContainer.style.display = shownBefore;
if (why) setStatusLine(statusEl, why);
refreshStart?.();
},

/// Every check passed: the batch's own lines take over from here.
handOver() {
checking = false;
slide(false);
},
};
}
Loading
Loading