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
27 changes: 23 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -607,13 +607,29 @@ jobs:
- name: Stage release assets
shell: bash
env:
IS_PRERELEASE: ${{ needs.prepare-release.outputs.prerelease }}
RELEASE_SCOPE: ${{ needs.prepare-release.outputs.release_scope }}
run: |
set -euo pipefail
shopt -s nullglob

mkdir -p release-assets/upload

required_metadata=(
release-assets/windows-x64/latest.yml
release-assets/linux-x64/latest-linux.yml
)

if [ "$RELEASE_SCOPE" = "all" ]; then
required_metadata+=(release-assets/macos-merged/latest-mac.yml)
fi

for file in "${required_metadata[@]}"; do
if [ ! -f "$file" ]; then
echo "Required update metadata is missing: $file"
exit 1
fi
done

assets=(
release-assets/windows-x64/*.exe
release-assets/windows-x64/*.blockmap
Expand All @@ -632,11 +648,14 @@ jobs:
)
fi

if [ "$IS_PRERELEASE" != "true" ]; then
assets+=(
release-assets/windows-x64/latest.yml
release-assets/linux-x64/latest-linux.yml
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if [ "$RELEASE_SCOPE" = "all" ]; then
assets+=(
release-assets/macos-merged/latest-mac.yml
release-assets/windows-x64/latest*.yml
release-assets/linux-x64/latest-linux.yml
)
fi

Expand Down
6 changes: 6 additions & 0 deletions announcements.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"settings": {
"aspectRatio": "4:3"
},
"announcements": []
}
96 changes: 96 additions & 0 deletions docs/announcements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# In-app announcements

Recordly can show dismissible announcements in the editor as a popup, carousel slide, lightweight live notification, or header banner. Popups can contain images or video; notifications and banners are text-only with optional buttons.

## Remote announcements

Edit [`announcements.json`](../announcements.json) on the `main` branch to publish an announcement without releasing a new app version. Released clients check the raw GitHub file at most once every six hours per running app instance. Fetch failures are silent and never block startup.

```json
{
"settings": {
"aspectRatio": "4:3"
},
"announcements": [
{
"id": "recordly-1.4-release",
"title": "A faster Recordly is here",
"body": "Exports are faster and cursor motion is smoother. Thanks for using Recordly!",
"presentation": "popup",
"audience": "editor",
"priority": 10,
"mediaMode": "cover",
"displayDurationSeconds": 15,
"maxImpressions": 3,
"controls": {
"close": true,
"dismiss": false,
"action": true,
"navigation": false,
"indicators": true
},
"startsAt": "2026-09-01T00:00:00Z",
"endsAt": "2026-10-01T00:00:00Z",
"minVersion": "1.4.0",
"media": {
"type": "image",
"url": "https://example.com/recordly-1.4-banner.jpg",
"alt": "Recordly 1.4 feature preview"
},
"action": {
"label": "See what changed",
"url": "https://github.com/webadderallorg/Recordly/releases"
}
},
{
"id": "recordly-maintenance-notice",
"title": "Quick service notice",
"body": "Cloud sharing will undergo brief maintenance tonight.",
"presentation": "notification",
"audience": "editor",
"displayDurationSeconds": 10,
"maxImpressions": 2,
"startsAt": "2026-09-05T00:00:00Z",
"endsAt": "2026-09-06T00:00:00Z"
},
{
"id": "recordly-editor-banner",
"title": "Try the new editor",
"body": "The redesigned timeline is now available.",
"presentation": "banner",
"audience": "editor",
"maxImpressions": 3,
"action": {
"label": "Open settings",
"section": "settings"
}
}
]
}
```

Use a new stable `id` whenever an announcement should appear again. Once a user dismisses an ID, it remains dismissed. Remote items with the same ID override bundled items.

Supported fields:

- `settings.aspectRatio` sets one shared `width:height` ratio for the entire popup carousel, such as `16:9`, `4:3`, or `1:1`. All slides keep that same size. If omitted, the bundled default is used.
- `id`, `title`, and `body` are required.
- `presentation` is `popup`, `notification`, or `banner` and defaults to `popup`. Notifications appear as text-only non-modal toasts, while banners appear only in the editor directly beneath its header. Media and `mediaMode` are ignored for notifications and banners. At most five notifications are shown from one feed load; banners are shown one at a time by priority.
- `audience` is `all` or `editor` and defaults to `all`.
- `priority` controls carousel order; larger numbers appear first.
- `mediaMode` is `banner` or `cover`. `banner` is the default current layout; `cover` fills the popup with the media and overlays the text.
- `startsAt` and `endsAt` are optional ISO timestamps.
- `displayDurationSeconds` accepts 3–300 seconds. It auto-advances popup slides; for notifications it controls how long the toast remains visible and defaults to 10 seconds.
- `maxImpressions` optionally limits an announcement to 1–100 app sessions. Each announcement counts at most once per session; an explicit dismissal always hides it permanently.
- `controls` can independently show or hide `close`, `dismiss`, `action`, `navigation`, and `indicators`. Every control defaults to `true`. Notifications and banners use only `close` and `action`; popup carousels use the other controls. Escape and clicking outside a popup remain available even when visible close controls are hidden.
- `minVersion` and `maxVersion` are optional inclusive app-version bounds.
- `media.type` is `image` or `video` for popups. Media URLs must be HTTPS or root-relative bundled assets. Videos can also specify `posterUrl`.
- `action` has a label and exactly one destination: an HTTPS `url` opened in the system browser, or an editor `section` opened inside the app. Supported sections are `scene`, `cursor`, `webcam`, `captions`, `settings`, and `extensions`. An action containing both destinations, neither destination, or an unknown section is ignored.

Set `RECORDLY_ANNOUNCEMENTS_URL` before launching the app to use a different HTTPS feed. Set it to `off` to disable remote announcements.

## Announcements bundled with an update

Add typed entries to `src/content/announcements.ts`. Bundled items use the same schema and are available offline. This is useful when a message should ship atomically with a new release.

Remote content is treated as data only: HTML is not rendered, URLs are restricted, feeds are size-limited and time-limited, and malformed items are ignored.
36 changes: 36 additions & 0 deletions electron/appSettingsStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { readFileSync, writeFileSync } from "node:fs";
import { APP_SETTINGS_FILE } from "./ipc/constants";
import { parseJsonWithByteOrderMark } from "./ipc/utils";

export function readAppSettingsStore(): Record<string, unknown> {
try {
const content = readFileSync(APP_SETTINGS_FILE, "utf-8");
const parsed = parseJsonWithByteOrderMark<unknown>(content);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return {};
}

return parsed as Record<string, unknown>;
} catch {
return {};
}
}

export function writeAppSettingsStore(store: Record<string, unknown>) {
writeFileSync(APP_SETTINGS_FILE, JSON.stringify(store, null, 2), "utf-8");
}

export function hasAppSetting(store: Record<string, unknown>, key: string): boolean {
return Reflect.getOwnPropertyDescriptor(store, key) !== undefined;
}

export function readAppSetting(key: string): unknown {
const store = readAppSettingsStore();
return hasAppSetting(store, key) ? store[key] : null;
}

export function writeAppSetting(key: string, value: unknown) {
const store = readAppSettingsStore();
store[key] = value;
writeAppSettingsStore(store);
}
8 changes: 8 additions & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -815,6 +815,12 @@ interface Window {
skipUpdateVersion: () => Promise<{ success: boolean; message?: string }>;
getCurrentUpdateToastPayload: () => Promise<UpdateToastState | null>;
getUpdateStatusSummary: () => Promise<UpdateStatusSummary>;
getExperimentalUpdatesEnabled: () => Promise<boolean>;
setExperimentalUpdatesEnabled: (enabled: boolean) => Promise<{
success: boolean;
enabled: boolean;
error?: string;
}>;
previewUpdateToast: () => Promise<{ success: boolean }>;
checkForAppUpdates: () => Promise<{ success: boolean; logPath: string }>;
onUpdateToastStateChanged: (
Expand Down Expand Up @@ -866,6 +872,8 @@ interface Window {
}>;
/** Returns the app version from package.json */
getAppVersion: () => Promise<string>;
/** Returns the configured remote announcement feed, or null when unavailable. */
getAnnouncements: () => Promise<unknown | null>;
/** Hide the OS cursor before browser capture starts. */
hideOsCursor: () => Promise<{ success: boolean }>;
/** Recording preferences (mic, system audio) */
Expand Down
2 changes: 2 additions & 0 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { BrowserWindow } from "electron";
import { registerAnnouncementHandlers } from "./register/announcements";
import { registerAssetHandlers } from "./register/assets";
import { registerCaptionHandlers } from "./register/captions";
import { registerExportHandlers } from "./register/export";
Expand Down Expand Up @@ -64,6 +65,7 @@ export function registerIpcHandlers(
});
registerRecordingHandlers(onRecordingStateChange);
registerPermissionHandlers();
registerAnnouncementHandlers();
registerAssetHandlers();
registerExportHandlers();
registerCaptionHandlers();
Expand Down
12 changes: 7 additions & 5 deletions electron/ipc/nativeVideoExport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ describe("native static layout command builders", () => {
expect(args).toContain("-filter_complex");
expect(args).toContain(
"color=c=0x101010:s=1920x1080:r=60:d=60.000,format=nv12,setrange=limited,hwupload_cuda[bg];" +
"[0:v]scale_cuda=w=1536:h=864:format=nv12:passthrough=0,hwdownload,format=nv12,scale=in_range=auto:out_range=tv,fps=60,hwupload_cuda[fg];" +
"[0:v]scale_cuda=w=1536:h=864:format=nv12:passthrough=0,hwdownload,format=nv12,scale=in_range=auto:out_range=tv:sws_dither=bayer,fps=60,hwupload_cuda[fg];" +
"[bg][fg]overlay_cuda=192:108:shortest=0:repeatlast=1:eof_action=repeat,trim=duration=60.000,setpts=PTS-STARTPTS[out]",
);
expect(args).toContain("h264_nvenc");
Expand All @@ -179,7 +179,7 @@ describe("native static layout command builders", () => {
expect(args).toEqual(
expect.arrayContaining([
"-vf",
"vflip,scale=in_range=full:out_range=tv",
"vflip,scale=in_range=full:out_range=tv:sws_dither=bayer",
"-colorspace",
"bt709",
"-color_primaries",
Expand All @@ -198,7 +198,7 @@ describe("native static layout command builders", () => {
expect(args).toEqual(
expect.arrayContaining([
"-vf",
"scale_cuda=w=1536:h=864:format=nv12:passthrough=0,hwdownload,format=nv12,scale=in_range=auto:out_range=tv,fps=60,pad=w=1920:h=1080:x=192:y=108:color=0x101010",
"scale_cuda=w=1536:h=864:format=nv12:passthrough=0,hwdownload,format=nv12,scale=in_range=auto:out_range=tv:sws_dither=bayer,fps=60,pad=w=1920:h=1080:x=192:y=108:color=0x101010",
"-map",
"0:v:0",
"-an",
Expand All @@ -214,7 +214,7 @@ describe("native static layout command builders", () => {
});

expect(args).toContain(
"scale_cuda=w=1536:h=864:format=nv12:passthrough=0,hwdownload,format=nv12,scale=in_range=auto:out_range=tv,fps=60,pad=w=1920:h=1080:x=192:y=108:color=0x101010",
"scale_cuda=w=1536:h=864:format=nv12:passthrough=0,hwdownload,format=nv12,scale=in_range=auto:out_range=tv:sws_dither=bayer,fps=60,pad=w=1920:h=1080:x=192:y=108:color=0x101010",
);
});

Expand Down Expand Up @@ -266,7 +266,9 @@ describe("native static layout command builders", () => {
);
expect(filterComplex).toContain("[fgbase][mask]alphamerge[fg]");
expect(filterComplex).toContain("overlay=x=192:y=108:format=auto");
expect(filterComplex).toContain("scale=in_range=full:out_range=tv,format=yuv420p[out]");
expect(filterComplex).toContain(
"scale=in_range=full:out_range=tv:sws_dither=bayer,format=yuv420p[out]",
);
expect(args).toContain("h264_nvenc");
expect(args).toEqual(expect.arrayContaining(["-pix_fmt", "yuv420p"]));
expect(args).toEqual(expect.arrayContaining([...FFMPEG_BT709_VIDEO_COLOR_ARGS]));
Expand Down
6 changes: 3 additions & 3 deletions electron/ipc/nativeVideoExport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ export const FFMPEG_BT709_VIDEO_COLOR_ARGS = [
"tv",
] as const;

const FFMPEG_AUTO_TO_VIDEO_RANGE_FILTER = "scale=in_range=auto:out_range=tv";
const FFMPEG_AUTO_TO_VIDEO_RANGE_FILTER = "scale=in_range=auto:out_range=tv:sws_dither=bayer";
const FFMPEG_AUTO_TO_FULL_RANGE_FILTER = "scale=in_range=auto:out_range=full";
const FFMPEG_FULL_TO_VIDEO_RANGE_FILTER = "scale=in_range=full:out_range=tv";
const FFMPEG_FULL_TO_VIDEO_RANGE_FILTER = "scale=in_range=full:out_range=tv:sws_dither=bayer";

export type NativeExportEncodingMode = "fast" | "balanced" | "quality";

Expand Down Expand Up @@ -311,7 +311,7 @@ export function buildNativeVideoExportArgs(
"-i",
"pipe:0",
"-vf",
"vflip,scale=in_range=full:out_range=tv",
`vflip,${FFMPEG_FULL_TO_VIDEO_RANGE_FILTER}`,
"-an",
"-c:v",
encoder,
Expand Down
23 changes: 17 additions & 6 deletions electron/ipc/recording/ffmpeg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,29 @@ export function getDisplayWorkAreaForSource(source: SelectedSource) {
}

export async function buildFfmpegCaptureArgs(source: SelectedSource, outputPath: string) {
const commonOutputArgs = [
const buildOutputArgs = (inputRange: "auto" | "full") => [
"-an",
"-vf",
`scale=in_range=${inputRange}:out_range=tv:out_color_matrix=bt709:sws_dither=bayer`,
"-c:v",
"libx264",
"-preset",
"veryfast",
"-pix_fmt",
"yuv420p",
"-colorspace",
"bt709",
"-color_primaries",
"bt709",
"-color_trc",
"bt709",
"-color_range",
"tv",
"-movflags",
"+faststart",
outputPath,
];
const fullRangeOutputArgs = buildOutputArgs("full");

if (process.platform === "win32") {
if (source?.id?.startsWith("window:")) {
Expand All @@ -57,7 +68,7 @@ export async function buildFfmpegCaptureArgs(source: SelectedSource, outputPath:
"0",
"-i",
windowId ? `hwnd=${windowId}` : `title=${windowTitle}`,
...commonOutputArgs,
...fullRangeOutputArgs,
];
}

Expand All @@ -71,7 +82,7 @@ export async function buildFfmpegCaptureArgs(source: SelectedSource, outputPath:
"0",
"-i",
"desktop",
...commonOutputArgs,
...fullRangeOutputArgs,
];
}

Expand All @@ -95,7 +106,7 @@ export async function buildFfmpegCaptureArgs(source: SelectedSource, outputPath:
`${Math.max(2, bounds.width)}x${Math.max(2, bounds.height)}`,
"-i",
`${displayEnv}+${Math.round(bounds.x)},${Math.round(bounds.y)}`,
...commonOutputArgs,
...fullRangeOutputArgs,
];
}

Expand All @@ -112,7 +123,7 @@ export async function buildFfmpegCaptureArgs(source: SelectedSource, outputPath:
`${Math.max(2, bounds.width)}x${Math.max(2, bounds.height)}`,
"-i",
`${displayEnv}+${Math.round(bounds.x)},${Math.round(bounds.y)}`,
...commonOutputArgs,
...fullRangeOutputArgs,
];
}

Expand All @@ -127,7 +138,7 @@ export async function buildFfmpegCaptureArgs(source: SelectedSource, outputPath:
"60",
"-i",
"1:none",
...commonOutputArgs,
...buildOutputArgs("auto"),
];
}

Expand Down
Loading
Loading