Skip to content

Commit 2926652

Browse files
Merge branch 'main' into fix/web-api-parity
2 parents 42e50cb + 7ef6b5a commit 2926652

116 files changed

Lines changed: 8590 additions & 1113 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/verify-recording.yml‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,15 @@ jobs:
251251
done
252252
SwitchAudioSource -s "BlackHole 2ch" -t output
253253
SwitchAudioSource -s "BlackHole 2ch" -t input
254+
# macOS 15 plays alert and interface sounds through the default
255+
# output, BlackHole, so ScreenCaptureKit records them as system
256+
# audio and Instant's mixed track and every export carry them over
257+
# the microphone markers. Hosted runners and macOS 26 capture
258+
# silence there; silence these too so every runner records the same.
259+
osascript -e 'set volume alert volume 0' || true
260+
defaults write -g com.apple.sound.uiaudio.enabled -int 0
261+
defaults write -g com.apple.sound.beep.volume -float 0
262+
defaults write -g com.apple.sound.beep.feedback -int 0
254263
echo "outputs:"; SwitchAudioSource -a -t output
255264
echo "default output: $(SwitchAudioSource -c -t output)"
256265
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import type { VideoId } from "@cap/recorder-core";
2+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3+
import { RECORDING_STATE_KEY } from "../shared/storage-keys";
4+
import type {
5+
OffscreenResponse,
6+
RecordingStatus,
7+
ServiceWorkerResponse,
8+
} from "../shared/types";
9+
10+
type MessageListener = Parameters<
11+
typeof chrome.runtime.onMessage.addListener
12+
>[0];
13+
14+
let onMessage: MessageListener;
15+
let stored: Record<string, unknown>;
16+
let pollReplies: Array<(response: OffscreenResponse) => void>;
17+
let contextReplies: Array<(contexts: object[]) => void>;
18+
19+
const liveStatus: RecordingStatus = {
20+
phase: "recording",
21+
startedAt: 1,
22+
durationMs: 1000,
23+
updatedAt: 1001,
24+
};
25+
26+
beforeEach(async () => {
27+
vi.resetModules();
28+
stored = {};
29+
pollReplies = [];
30+
contextReplies = [];
31+
const storage = {
32+
get: (_keys: string[], callback: (items: object) => void) =>
33+
callback({ ...stored }),
34+
set: (items: object, callback: () => void) => {
35+
Object.assign(stored, items);
36+
callback();
37+
},
38+
setAccessLevel: vi.fn(),
39+
};
40+
vi.stubGlobal("chrome", {
41+
storage: { local: storage, session: storage },
42+
runtime: {
43+
getURL: (path: string) => `chrome-extension://test/${path}`,
44+
ContextType: { OFFSCREEN_DOCUMENT: "OFFSCREEN_DOCUMENT" },
45+
getContexts: vi.fn((_filter, callback) => {
46+
contextReplies.push(callback);
47+
}),
48+
sendMessage: vi.fn((_message, callback) => {
49+
pollReplies.push(callback);
50+
}),
51+
onMessage: {
52+
addListener: (listener: MessageListener) => {
53+
onMessage = listener;
54+
},
55+
},
56+
onInstalled: { addListener: vi.fn() },
57+
},
58+
action: {
59+
setPopup: vi.fn((_options, callback) => callback()),
60+
setBadgeText: vi.fn((_options, callback) => callback()),
61+
setTitle: vi.fn((_options, callback) => callback()),
62+
onClicked: { addListener: vi.fn() },
63+
},
64+
tabs: {
65+
query: (_query: object, callback: (tabs: object[]) => void) =>
66+
callback([]),
67+
onActivated: { addListener: vi.fn() },
68+
onUpdated: { addListener: vi.fn() },
69+
onRemoved: { addListener: vi.fn() },
70+
},
71+
windows: { onFocusChanged: { addListener: vi.fn() } },
72+
});
73+
await import("./service-worker");
74+
});
75+
76+
afterEach(() => {
77+
vi.unstubAllGlobals();
78+
});
79+
80+
const broadcast = (status: RecordingStatus) =>
81+
onMessage(
82+
{
83+
target: "recording-status",
84+
type: "recording-status-changed",
85+
status,
86+
},
87+
{},
88+
vi.fn(),
89+
);
90+
91+
const pollStatus = () =>
92+
new Promise<ServiceWorkerResponse>((resolve) => {
93+
onMessage(
94+
{ target: "service-worker", type: "get-recording-status" },
95+
{},
96+
resolve,
97+
);
98+
});
99+
100+
const answerContexts = (present: boolean) => {
101+
const reply = contextReplies.shift();
102+
if (!reply) throw new Error("No pending context query");
103+
reply(
104+
present ? [{ documentUrl: "chrome-extension://test/offscreen.html" }] : [],
105+
);
106+
};
107+
108+
describe("recording status synchronization", () => {
109+
it.each(["recording", "paused", "uploading"] as const)(
110+
"restores %s from the offscreen document after a worker restart",
111+
async (phase) => {
112+
const currentStatus = { ...liveStatus, phase };
113+
const response = pollStatus();
114+
answerContexts(true);
115+
await vi.waitFor(() => expect(contextReplies).toHaveLength(1));
116+
answerContexts(true);
117+
await vi.waitFor(() => expect(pollReplies).toHaveLength(1));
118+
pollReplies[0]({ ok: true, status: currentStatus });
119+
120+
expect(await response).toMatchObject({ ok: true, status: currentStatus });
121+
expect(stored[RECORDING_STATE_KEY]).toMatchObject({
122+
status: currentStatus,
123+
});
124+
},
125+
);
126+
127+
it.each([
128+
{
129+
phase: "completed",
130+
videoId: "video" as VideoId,
131+
shareUrl: "https://cap.so/s/video",
132+
},
133+
{ phase: "error", message: "Upload failed", recoverable: true },
134+
] satisfies RecordingStatus[])(
135+
"preserves a newer $phase broadcast over an older poll response",
136+
async (nextStatus) => {
137+
const response = pollStatus();
138+
answerContexts(true);
139+
await vi.waitFor(() => expect(contextReplies).toHaveLength(1));
140+
answerContexts(true);
141+
await vi.waitFor(() => expect(pollReplies).toHaveLength(1));
142+
143+
broadcast(nextStatus);
144+
pollReplies[0]({ ok: true, status: liveStatus });
145+
146+
expect(await response).toMatchObject({ ok: true, status: nextStatus });
147+
expect(stored[RECORDING_STATE_KEY]).toMatchObject({ status: nextStatus });
148+
expect(chrome.action.setBadgeText).toHaveBeenLastCalledWith(
149+
{ text: "" },
150+
expect.any(Function),
151+
);
152+
},
153+
);
154+
155+
it("preserves a recording broadcast received while checking for the offscreen document", async () => {
156+
const response = pollStatus();
157+
broadcast(liveStatus);
158+
answerContexts(false);
159+
160+
expect(await response).toMatchObject({ ok: true, status: liveStatus });
161+
expect(stored[RECORDING_STATE_KEY]).toMatchObject({ status: liveStatus });
162+
});
163+
164+
it("clears a phantom recording when its offscreen document is gone", async () => {
165+
broadcast(liveStatus);
166+
const response = pollStatus();
167+
answerContexts(false);
168+
169+
expect(await response).toMatchObject({
170+
ok: true,
171+
status: { phase: "idle" },
172+
});
173+
expect(stored[RECORDING_STATE_KEY]).toMatchObject({
174+
status: { phase: "idle" },
175+
});
176+
});
177+
});

‎apps/chrome-extension/src/background/service-worker.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1267,7 +1267,9 @@ const forwardToOffscreen = (type: OffscreenRequest["type"]) =>
12671267
sendOffscreen({ target: "offscreen", type } as OffscreenRequest);
12681268

12691269
const syncRecordingStatus = async () => {
1270+
const initialStatus = recordingStatus;
12701271
const hasDocument = await hasOffscreenDocument();
1272+
if (recordingStatus !== initialStatus) return recordingStatus;
12711273
if (!hasDocument) {
12721274
if (
12731275
isActiveRecordingStatus(recordingStatus) ||
@@ -1292,6 +1294,7 @@ const syncRecordingStatus = async () => {
12921294
{ target: "offscreen", type: "get-recording-status" },
12931295
{ createIfMissing: false },
12941296
);
1297+
if (recordingStatus !== initialStatus) return recordingStatus;
12951298
if (response.ok && response.status) {
12961299
if (
12971300
recordingStatus.phase === "creating" &&

‎apps/cli/src/selftest/playback.rs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -571,7 +571,7 @@ mod fixture {
571571
use cap_recording::{
572572
AudioFrame, ChannelAudioSource, ChannelAudioSourceConfig, ChannelVideoSource,
573573
ChannelVideoSourceConfig, OutputPipeline,
574-
ffmpeg::{FFmpegVideoFrame, Mp4Muxer, OggMuxer},
574+
ffmpeg::{FFmpegVideoFrame, Mp4Muxer, OggMuxer, OggMuxerConfig},
575575
};
576576
use cap_timestamp::{Timestamp, Timestamps};
577577
use relative_path::RelativePathBuf;
@@ -783,7 +783,7 @@ mod fixture {
783783
audio_info, mic_rx,
784784
))
785785
.with_timestamps(timestamps)
786-
.build::<OggMuxer>(())
786+
.build::<OggMuxer>(OggMuxerConfig::default())
787787
.await
788788
.map_err(|e| format!("mic pipeline: {e}"))?;
789789
let sys_pipeline = OutputPipeline::builder(audio_path.clone())
@@ -794,7 +794,7 @@ mod fixture {
794794
// System audio anchors at the recording epoch, exactly like the
795795
// studio recorder configures it.
796796
.with_audio_anchor(cap_recording::AudioAnchor::PipelineEpoch)
797-
.build::<OggMuxer>(())
797+
.build::<OggMuxer>(OggMuxerConfig::default())
798798
.await
799799
.map_err(|e| format!("system audio pipeline: {e}"))?;
800800

‎apps/cli/src/upload.rs‎

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,27 @@ pub async fn upload_video_path(file_path: &Path, name: Option<String>) -> Result
258258
}
259259
}
260260

261+
fn agent_upload_payload(
262+
file_name: &str,
263+
name: Option<&str>,
264+
content_length: u64,
265+
meta: &VideoMeta,
266+
) -> serde_json::Value {
267+
let mut payload = json!({
268+
"fileName": file_name,
269+
"contentType": "video/mp4",
270+
"contentLength": content_length,
271+
"durationSeconds": meta.duration_in_secs,
272+
"width": meta.width,
273+
"height": meta.height,
274+
"fps": meta.fps,
275+
});
276+
if let Some(name) = name {
277+
payload["title"] = json!(name);
278+
}
279+
payload
280+
}
281+
261282
async fn upload_file_with_agent(
262283
file_path: &Path,
263284
name: Option<&str>,
@@ -276,16 +297,7 @@ async fn upload_file_with_agent(
276297
.mutate_json(
277298
Method::POST,
278299
"/uploads",
279-
&json!({
280-
"fileName": file_name,
281-
"contentType": "video/mp4",
282-
"contentLength": content_length,
283-
"durationSeconds": meta.duration_in_secs,
284-
"width": meta.width,
285-
"height": meta.height,
286-
"fps": meta.fps,
287-
"title": name,
288-
}),
300+
&agent_upload_payload(file_name, name, content_length, meta),
289301
)
290302
.await
291303
.map_err(|error| error.to_string())?;
@@ -529,9 +541,26 @@ async fn upload_file(http: &Client, put_url: &str, path: &Path) -> Result<(), St
529541

530542
#[cfg(test)]
531543
mod tests {
532-
use super::prefer_agent_upload_sources;
544+
use super::{VideoMeta, agent_upload_payload, prefer_agent_upload_sources};
533545
use crate::credentials::{AgentCredentialSource, CredentialSource};
534546

547+
#[test]
548+
fn agent_upload_payload_only_includes_a_requested_title() {
549+
let meta = VideoMeta {
550+
duration_in_secs: 1.0,
551+
width: 640,
552+
height: 360,
553+
fps: 30.0,
554+
};
555+
for name in [None, Some("Demo"), Some("")] {
556+
let payload = agent_upload_payload("demo.mp4", name, 1024, &meta);
557+
match name {
558+
Some(name) => assert_eq!(payload["title"], name),
559+
None => assert!(payload.get("title").is_none()),
560+
}
561+
}
562+
}
563+
535564
#[test]
536565
fn explicit_agent_environment_credential_has_highest_upload_priority() {
537566
assert!(prefer_agent_upload_sources(

‎apps/desktop-gpui/kernels/src/thumbnail.rs‎

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ pub fn fit_centered(image: &RgbaImage, target_width: u32, target_height: u32) ->
6262
// `image::imageops::resize(.., FilterType::Lanczos3)`, so the output is
6363
// byte-identical; only the loop order changes, so whole blocks of a row stay in
6464
// registers and vectorize instead of one generic `get_pixel` per tap.
65+
// Kept out of line so inlining it doesn't slow `fit_centered`'s no-resize paths.
66+
#[inline(never)]
6567
fn resize_lanczos3(image: &RgbaImage, width: u32, height: u32) -> RgbaImage {
6668
let source_row = image.width() as usize * 4;
6769
let mut vertical = vec![0f32; source_row * height as usize];
@@ -74,26 +76,50 @@ fn resize_lanczos3(image: &RgbaImage, width: u32, height: u32) -> RgbaImage {
7476

7577
let columns = lanczos3_taps(image.width(), width);
7678
let mut resized = RgbaImage::new(width, height);
77-
for (output, row) in resized
78-
.chunks_exact_mut(width as usize * 4)
79-
.zip(vertical.chunks_exact(source_row))
79+
let output_row = width as usize * 4;
80+
let mut outputs = resized.chunks_exact_mut(output_row * HORIZONTAL_ROWS);
81+
let mut inputs = vertical.chunks_exact(source_row * HORIZONTAL_ROWS);
82+
for (output, input) in outputs.by_ref().zip(inputs.by_ref()) {
83+
horizontal_pass::<HORIZONTAL_ROWS>(output, input, &columns);
84+
}
85+
for (output, input) in outputs
86+
.into_remainder()
87+
.chunks_exact_mut(output_row)
88+
.zip(inputs.remainder().chunks_exact(source_row))
8089
{
81-
for (pixel, taps) in output.chunks_exact_mut(4).zip(&columns) {
82-
let mut sum = [0f32; 4];
83-
for (index, weight) in taps.weights.iter().enumerate() {
84-
let start = (taps.start + index) * 4;
85-
for (channel, &value) in sum.iter_mut().zip(&row[start..start + 4]) {
90+
horizontal_pass::<1>(output, input, &columns);
91+
}
92+
resized
93+
}
94+
95+
// Each pixel's sum is one chain of dependent additions, so ROWS rows that
96+
// share the same taps run side by side to keep the adders busy.
97+
fn horizontal_pass<const ROWS: usize>(output: &mut [u8], input: &[f32], columns: &[Taps]) {
98+
let output_row = output.len() / ROWS;
99+
let source_row = input.len() / ROWS;
100+
let rows: [&[[f32; 4]]; ROWS] =
101+
std::array::from_fn(|row| input[row * source_row..][..source_row].as_chunks().0);
102+
for (column, taps) in columns.iter().enumerate() {
103+
let windows = rows.map(|row| &row[taps.start..taps.start + taps.weights.len()]);
104+
let mut sums = [[0f32; 4]; ROWS];
105+
for (index, &weight) in taps.weights.iter().enumerate() {
106+
for (sum, window) in sums.iter_mut().zip(&windows) {
107+
for (channel, &value) in sum.iter_mut().zip(&window[index]) {
86108
*channel += value * weight;
87109
}
88110
}
111+
}
112+
for (row, sum) in sums.iter().enumerate() {
113+
let pixel = &mut output[row * output_row + column * 4..][..4];
89114
for (channel, value) in pixel.iter_mut().zip(sum) {
90115
*channel = value.clamp(0.0, 255.0).round() as u8;
91116
}
92117
}
93118
}
94-
resized
95119
}
96120

121+
const HORIZONTAL_ROWS: usize = 8;
122+
97123
fn vertical_pass(vertical: &mut [f32], pixels: &[u8], rows: &[Taps], source_row: usize) {
98124
#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
99125
if is_x86_feature_detected!("avx2") {
@@ -345,6 +371,8 @@ mod tests {
345371
(1920, 1080, 320, 180),
346372
(1366, 768, 320, 180),
347373
(777, 1333, 104, 180),
374+
(1920, 1080, 319, 179),
375+
(2560, 1440, 320, 13),
348376
(321, 181, 320, 180),
349377
(320, 400, 320, 180),
350378
(100, 60, 320, 180),

0 commit comments

Comments
 (0)