Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
2cd07cd
feat(rendering): add noise texture support to gradient shader
richiemcilroy Mar 19, 2026
342cb7c
feat(project): add gradient noise and animation config fields
richiemcilroy Mar 19, 2026
df23647
feat(rendering): support gradient noise in wgpu background layer
richiemcilroy Mar 19, 2026
b7f3cac
chore(rendering-skia): update gradient layer and tests for new fields
richiemcilroy Mar 19, 2026
525d5d1
feat(project): add track field to mask and text segments
richiemcilroy Mar 19, 2026
902ff33
refactor(rendering): create mask uniform buffer per-render call
richiemcilroy Mar 19, 2026
7d782c5
fix(rendering): disable fade duration for sensitive mask type
richiemcilroy Mar 19, 2026
68c0ef7
refactor(enc-avfoundation): fix clippy collapsible_if and use is_mult…
richiemcilroy Mar 19, 2026
2bb4849
fix(recording): remove auto-restart on system display unavailable
richiemcilroy Mar 19, 2026
6f3d272
perf(recording): skip full camera channels to reduce frame drops
richiemcilroy Mar 19, 2026
4a3f504
feat(desktop): add track field to mask and text segment types
richiemcilroy Mar 19, 2026
7359699
feat(editor): add timelineTracks utility module
richiemcilroy Mar 19, 2026
012ac88
feat(editor): extract color-utils and GradientEditor components
richiemcilroy Mar 19, 2026
9ba81b0
refactor(editor): simplify ConfigSidebar with extracted components
richiemcilroy Mar 19, 2026
e69a5ef
feat(editor): add multi-track timeline support
richiemcilroy Mar 19, 2026
aaa8bf1
fix(editor): improve clip, scene, and zoom track segment handles
richiemcilroy Mar 19, 2026
734170c
fix(editor): improve mask overlay interaction and visibility
richiemcilroy Mar 19, 2026
3fe4ccf
fix(desktop): improve camera and microphone permission request flow
richiemcilroy Mar 19, 2026
bb3fb24
perf(desktop): reuse ImageData buffers in camera previews
richiemcilroy Mar 19, 2026
7e5e16d
fix(desktop): improve WebSocket cleanup and add dispose method
richiemcilroy Mar 19, 2026
56915bc
feat(editor): add platform-specific zoom hint to player
richiemcilroy Mar 19, 2026
3a0e097
feat(editor): simplify presets dropdown and add save to preset
richiemcilroy Mar 19, 2026
c9c5af8
test(desktop): add memory soak test scripts
richiemcilroy Mar 19, 2026
115710c
chore(desktop): update auto-generated bindings
richiemcilroy Mar 19, 2026
a49f2f8
Add tauri-specta generated bindings
richiemcilroy Mar 19, 2026
ff8df5e
Type setup permissions and extract deleteTrackLane
richiemcilroy Mar 19, 2026
e3cd8ec
Remove unused srgb_to_linear function
richiemcilroy Mar 19, 2026
baac89c
feat(editor): add getColorPreviewBorderColor utility with inset border
richiemcilroy Mar 19, 2026
5fbfdae
style(editor): resize color swatch and add inset border in CaptionsTab
richiemcilroy Mar 19, 2026
dc857e5
style(editor): resize color swatch and add inset border in ConfigSidebar
richiemcilroy Mar 19, 2026
4712bf4
fix(desktop): use app config identifier for permission resets
richiemcilroy Mar 19, 2026
e701e47
fix(desktop): log activation policy errors instead of silently ignoring
richiemcilroy Mar 19, 2026
0760c29
fix(rendering): reduce default noise scale from 50 to 3
richiemcilroy Mar 19, 2026
94e10b5
fix(rendering): reduce gradient noise intensity with 0.25 multiplier
richiemcilroy Mar 19, 2026
574f853
test(rendering): add color conversion normalization test
richiemcilroy Mar 19, 2026
f063430
fix(desktop): prefix unused app param with underscore in request_perm…
richiemcilroy Mar 19, 2026
be691c6
fmt
richiemcilroy Mar 19, 2026
9347cbb
fmt
richiemcilroy Mar 19, 2026
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
4 changes: 3 additions & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
"preparescript": "node scripts/prepare.js",
"localdev": "dotenv -e ../../.env -- vinxi dev --port 3002",
"build": "vinxi build",
"tauri": "tauri"
"tauri": "tauri",
"test:memory": "node scripts/desktop-memory-soak.js",
"test:memory:unit": "vitest run scripts/desktop-memory-soak.test.js"
},
"dependencies": {
"@aerofoil/rive-solid-canvas": "^2.1.4",
Expand Down
299 changes: 299 additions & 0 deletions apps/desktop/scripts/desktop-memory-soak-lib.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,299 @@
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";

const DEV_APP_COMMAND_PREFIX = fileURLToPath(
new URL("../../../target/debug/cap-desktop", import.meta.url),
);
const INSTALLED_APP_COMMAND_PREFIX = "/Applications/Cap.app/Contents/MacOS/Cap";

export const DEFAULTS = {
appCommandPrefix:
process.env.CAP_APP_COMMAND_PREFIX ||
(existsSync(DEV_APP_COMMAND_PREFIX)
? DEV_APP_COMMAND_PREFIX
: INSTALLED_APP_COMMAND_PREFIX),
baselineSeconds: 30,
cycles: 3,
initialWebKitWindowSeconds: 180,
intervalSeconds: 10,
maxCycleRatchetMb: 128,
maxTotalGrowthMb: 768,
newWebKitWindowSeconds: 180,
recordingSeconds: 20,
displaySleepSeconds: 20,
launchTimeoutSeconds: 30,
stopTimeoutSeconds: 10,
storePath:
process.env.CAP_STORE_PATH ||
`${process.env.HOME}/Library/Application Support/so.cap.desktop.dev/store`,
settleSeconds: 120,
trailingSamples: 3,
};

const MEDIA_PROCESS_PATTERNS = [
["coreaudiod", /\/usr\/sbin\/coreaudiod$/],
[
"cmio-host",
/com\.apple\.cmio\.videodriverkithostextension\.systemextension/,
],
["cameracaptured", /\/usr\/libexec\/cameracaptured$/],
["continuity-agent", /\/usr\/libexec\/ContinuityCaptureAgent(?:\s|$)/],
["audioaccessoryd", /\/System\/Library\/CoreServices\/audioaccessoryd$/],
["avconferenced", /\/usr\/libexec\/avconferenced$/],
["vdcassistant", /\/VDC\.plugin\/Contents\/Resources\/VDCAssistant$/],
];

function toKb(mb) {
return Math.round(mb * 1024);
}

function parseElapsedSeconds(etime) {
const parts = etime.split("-");
const dayPart = parts.length === 2 ? Number(parts[0]) : 0;
const timePart = parts.length === 2 ? parts[1] : parts[0];
const segments = timePart.split(":").map(Number);

let hours = 0;
let minutes = 0;
let seconds = 0;

if (segments.length === 3) {
[hours, minutes, seconds] = segments;
} else if (segments.length === 2) {
[minutes, seconds] = segments;
}

return dayPart * 24 * 60 * 60 + hours * 60 * 60 + minutes * 60 + seconds;
}

export function parsePsOutput(output) {
return output
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const match = line.match(/^(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/);

if (!match) return null;

const [, pid, rssKb, vszKb, etime, command] = match;

return {
command,
etimes: parseElapsedSeconds(etime),
pid: Number(pid),
rssKb: Number(rssKb),
vszKb: Number(vszKb),
};
})
.filter(Boolean);
}

export function classifyProcess(
command,
appCommandPrefix = DEFAULTS.appCommandPrefix,
) {
if (
command.startsWith(
"/System/Library/PrivateFrameworks/SkyLight.framework/Resources/WindowServer",
)
) {
return {
group: "system",
kind: "window-server",
label: "windowserver",
};
}

if (command === appCommandPrefix) {
return { group: "cap", kind: "cap-main", label: "cap-main" };
}

if (command.startsWith(`${appCommandPrefix} --crash-reporter-server=`)) {
return { group: "cap", kind: "cap-crash", label: "cap-crash" };
}

if (command.includes("com.apple.WebKit.GPU.xpc")) {
return { group: "cap", kind: "webkit-gpu", label: "wk-gpu" };
}

if (command.includes("com.apple.WebKit.Networking.xpc")) {
return { group: "cap", kind: "webkit-networking", label: "wk-net" };
}

if (command.includes("com.apple.WebKit.WebContent.xpc")) {
return { group: "cap", kind: "webkit-webcontent", label: "wk-webcontent" };
}

for (const [label, pattern] of MEDIA_PROCESS_PATTERNS) {
if (pattern.test(command)) {
return { group: "media", kind: label, label };
}
}

return null;
}

export function findCapMainProcess(
processes,
appCommandPrefix = DEFAULTS.appCommandPrefix,
) {
return processes
.filter((processInfo) => processInfo.command === appCommandPrefix)
.sort((left, right) => right.etimes - left.etimes)[0];
}

export function discoverTrackedProcesses(
processes,
tracked,
options = DEFAULTS,
) {
const nextTracked = new Map(tracked);
const capMain = findCapMainProcess(processes, options.appCommandPrefix);

if (!capMain) {
throw new Error(
`No running Cap process found for ${options.appCommandPrefix}`,
);
}

for (const processInfo of processes) {
const classification = classifyProcess(
processInfo.command,
options.appCommandPrefix,
);

if (!classification) continue;

const isAlreadyTracked = nextTracked.has(processInfo.pid);
const isLongLivedCapWebKit =
classification.group === "cap" &&
classification.kind.startsWith("webkit-") &&
Math.abs(processInfo.etimes - capMain.etimes) <=
options.initialWebKitWindowSeconds;
const isFreshCapWebKit =
classification.group === "cap" &&
classification.kind.startsWith("webkit-") &&
processInfo.etimes <= options.newWebKitWindowSeconds;
const shouldTrack =
classification.kind === "cap-main" ||
classification.kind === "cap-crash" ||
classification.group === "system" ||
classification.group === "media" ||
isLongLivedCapWebKit ||
isFreshCapWebKit;

if (!shouldTrack) continue;

nextTracked.set(processInfo.pid, {
...processInfo,
...classification,
isNew: !isAlreadyTracked,
});
}

return nextTracked;
}

export function buildSample(processes, tracked, options = DEFAULTS) {
const discoveredTracked = discoverTrackedProcesses(
processes,
tracked,
options,
);
const liveTracked = new Map();
let capTotalKb = 0;
let mediaTotalKb = 0;
let windowServerTotalKb = 0;
const processRows = [];
const newPids = [];

for (const [pid, trackedProcess] of discoveredTracked.entries()) {
const liveProcess = processes.find(
(processInfo) => processInfo.pid === pid,
);

if (!liveProcess) continue;

const row = {
...trackedProcess,
etimes: liveProcess.etimes,
rssKb: liveProcess.rssKb,
vszKb: liveProcess.vszKb,
};

liveTracked.set(pid, row);
processRows.push(row);

if (row.group === "cap") capTotalKb += row.rssKb;
if (row.group === "media") mediaTotalKb += row.rssKb;
if (row.group === "system") windowServerTotalKb += row.rssKb;
if (row.isNew) newPids.push(pid);
}

processRows.sort((left, right) => right.rssKb - left.rssKb);

return {
capTotalKb,
grandTotalKb: capTotalKb + mediaTotalKb + windowServerTotalKb,
mediaTotalKb,
newPids: newPids.sort((left, right) => left - right),
processRows,
tracked: liveTracked,
windowServerTotalKb,
};
}

export function pickSettledSample(
samples,
trailingSamples = DEFAULTS.trailingSamples,
) {
if (samples.length === 0) {
throw new Error("Cannot pick a settled sample from an empty set");
}

const window = samples.slice(-Math.min(trailingSamples, samples.length));

return window.reduce((best, sample) =>
sample.grandTotalKb < best.grandTotalKb ? sample : best,
);
}

export function evaluateCycles(
baselineKb,
settledCycleKbs,
options = DEFAULTS,
) {
const maxCycleRatchetKb = toKb(options.maxCycleRatchetMb);
const maxTotalGrowthKb = toKb(options.maxTotalGrowthMb);
const failures = [];

for (let index = 0; index < settledCycleKbs.length; index += 1) {
const current = settledCycleKbs[index];
const previous = index === 0 ? baselineKb : settledCycleKbs[index - 1];
const cycleDeltaKb = current - previous;
const totalDeltaKb = current - baselineKb;

if (cycleDeltaKb > maxCycleRatchetKb) {
failures.push({
cycle: index + 1,
type: "cycle-ratchet",
valueKb: cycleDeltaKb,
});
}

if (totalDeltaKb > maxTotalGrowthKb) {
failures.push({
cycle: index + 1,
type: "total-growth",
valueKb: totalDeltaKb,
});
}
}

return failures;
}

export function formatMb(kb) {
return `${(kb / 1024).toFixed(1)} MB`;
}
Loading
Loading