From d27aac7719572b90375c6e920a507cf4f3c08306 Mon Sep 17 00:00:00 2001 From: young Date: Sun, 30 Aug 2026 21:23:11 +1000 Subject: [PATCH 1/6] Add live announcements and experimental updates --- .github/workflows/release.yml | 10 +- announcements.json | 6 + docs/announcements.md | 96 +++++ electron/appSettingsStore.ts | 36 ++ electron/electron-env.d.ts | 8 + electron/ipc/handlers.ts | 2 + electron/ipc/register/announcements.ts | 91 +++++ electron/ipc/register/settings.ts | 31 +- electron/main.ts | 25 ++ electron/preload.ts | 7 + electron/updater.ts | 23 ++ src/App.tsx | 14 +- .../announcements/AnnouncementDialog.tsx | 360 ++++++++++++++++++ .../EditorAnnouncementBanner.tsx | 143 +++++++ .../LiveAnnouncementNotifications.tsx | 118 ++++++ src/components/video-editor/SettingsPanel.tsx | 66 ++++ src/components/video-editor/VideoEditor.tsx | 15 + src/content/announcements.ts | 9 + src/i18n/locales/en/settings.json | 6 + src/lib/announcementActions.ts | 22 ++ src/lib/announcementState.ts | 46 +++ src/lib/announcements.test.ts | 257 +++++++++++++ src/lib/announcements.ts | 355 +++++++++++++++++ 23 files changed, 1709 insertions(+), 37 deletions(-) create mode 100644 announcements.json create mode 100644 docs/announcements.md create mode 100644 electron/appSettingsStore.ts create mode 100644 electron/ipc/register/announcements.ts create mode 100644 src/components/announcements/AnnouncementDialog.tsx create mode 100644 src/components/announcements/EditorAnnouncementBanner.tsx create mode 100644 src/components/announcements/LiveAnnouncementNotifications.tsx create mode 100644 src/content/announcements.ts create mode 100644 src/lib/announcementActions.ts create mode 100644 src/lib/announcementState.ts create mode 100644 src/lib/announcements.test.ts create mode 100644 src/lib/announcements.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 148bed0dd..a892dcac4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -607,7 +607,6 @@ 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 @@ -632,11 +631,14 @@ jobs: ) fi - if [ "$IS_PRERELEASE" != "true" ]; then + assets+=( + release-assets/windows-x64/latest*.yml + release-assets/linux-x64/latest-linux.yml + ) + + 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 diff --git a/announcements.json b/announcements.json new file mode 100644 index 000000000..f7e39713e --- /dev/null +++ b/announcements.json @@ -0,0 +1,6 @@ +{ + "settings": { + "aspectRatio": "4:3" + }, + "announcements": [] +} diff --git a/docs/announcements.md b/docs/announcements.md new file mode 100644 index 000000000..51637ff42 --- /dev/null +++ b/docs/announcements.md @@ -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. diff --git a/electron/appSettingsStore.ts b/electron/appSettingsStore.ts new file mode 100644 index 000000000..c777b1004 --- /dev/null +++ b/electron/appSettingsStore.ts @@ -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 { + try { + const content = readFileSync(APP_SETTINGS_FILE, "utf-8"); + const parsed = parseJsonWithByteOrderMark(content); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return {}; + } + + return parsed as Record; + } catch { + return {}; + } +} + +export function writeAppSettingsStore(store: Record) { + writeFileSync(APP_SETTINGS_FILE, JSON.stringify(store, null, 2), "utf-8"); +} + +export function hasAppSetting(store: Record, 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); +} diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 7dce4fa05..8e878b3d7 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -815,6 +815,12 @@ interface Window { skipUpdateVersion: () => Promise<{ success: boolean; message?: string }>; getCurrentUpdateToastPayload: () => Promise; getUpdateStatusSummary: () => Promise; + getExperimentalUpdatesEnabled: () => Promise; + setExperimentalUpdatesEnabled: (enabled: boolean) => Promise<{ + success: boolean; + enabled: boolean; + error?: string; + }>; previewUpdateToast: () => Promise<{ success: boolean }>; checkForAppUpdates: () => Promise<{ success: boolean; logPath: string }>; onUpdateToastStateChanged: ( @@ -866,6 +872,8 @@ interface Window { }>; /** Returns the app version from package.json */ getAppVersion: () => Promise; + /** Returns the configured remote announcement feed, or null when unavailable. */ + getAnnouncements: () => Promise; /** Hide the OS cursor before browser capture starts. */ hideOsCursor: () => Promise<{ success: boolean }>; /** Recording preferences (mic, system audio) */ diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index f6e4dc029..2a0f998eb 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -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"; @@ -64,6 +65,7 @@ export function registerIpcHandlers( }); registerRecordingHandlers(onRecordingStateChange); registerPermissionHandlers(); + registerAnnouncementHandlers(); registerAssetHandlers(); registerExportHandlers(); registerCaptionHandlers(); diff --git a/electron/ipc/register/announcements.ts b/electron/ipc/register/announcements.ts new file mode 100644 index 000000000..a582fb4e7 --- /dev/null +++ b/electron/ipc/register/announcements.ts @@ -0,0 +1,91 @@ +import { ipcMain } from "electron"; + +const DEFAULT_ANNOUNCEMENT_FEED_URL = + "https://raw.githubusercontent.com/webadderallorg/Recordly/main/announcements.json"; +const ANNOUNCEMENT_FETCH_TIMEOUT_MS = 5_000; +const ANNOUNCEMENT_CACHE_TTL_MS = 6 * 60 * 60 * 1_000; +const MAX_ANNOUNCEMENT_FEED_BYTES = 1_000_000; + +let cachedFeed: unknown = null; +let cachedAt = 0; +let hasCachedResult = false; +let pendingFetch: Promise | null = null; + +function getAnnouncementFeedUrl(): string | null { + const configuredUrl = process.env.RECORDLY_ANNOUNCEMENTS_URL?.trim(); + if (configuredUrl?.toLowerCase() === "off") { + return null; + } + + const candidate = configuredUrl || DEFAULT_ANNOUNCEMENT_FEED_URL; + try { + const parsed = new URL(candidate); + return parsed.protocol === "https:" && !parsed.username && !parsed.password + ? parsed.href + : null; + } catch { + return null; + } +} + +async function requestAnnouncementFeed(feedUrl: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), ANNOUNCEMENT_FETCH_TIMEOUT_MS); + + try { + const response = await fetch(feedUrl, { + signal: controller.signal, + headers: { Accept: "application/json" }, + redirect: "follow", + }); + if (!response.ok) { + throw new Error(`Announcement feed returned HTTP ${response.status}`); + } + if (new URL(response.url).protocol !== "https:") { + throw new Error("Announcement feed redirected to an unsafe URL"); + } + + const declaredLength = Number(response.headers.get("content-length")); + if (Number.isFinite(declaredLength) && declaredLength > MAX_ANNOUNCEMENT_FEED_BYTES) { + throw new Error("Announcement feed is too large"); + } + + const text = await response.text(); + if (Buffer.byteLength(text, "utf8") > MAX_ANNOUNCEMENT_FEED_BYTES) { + throw new Error("Announcement feed is too large"); + } + + cachedFeed = JSON.parse(text) as unknown; + return cachedFeed; + } catch (error) { + console.warn("Failed to load announcement feed:", error); + return cachedFeed; + } finally { + cachedAt = Date.now(); + hasCachedResult = true; + clearTimeout(timeout); + } +} + +function fetchAnnouncementFeed(): Promise { + const feedUrl = getAnnouncementFeedUrl(); + if (!feedUrl) { + return Promise.resolve(null); + } + + if (hasCachedResult && Date.now() - cachedAt < ANNOUNCEMENT_CACHE_TTL_MS) { + return Promise.resolve(cachedFeed); + } + + if (!pendingFetch) { + pendingFetch = requestAnnouncementFeed(feedUrl).finally(() => { + pendingFetch = null; + }); + } + + return pendingFetch; +} + +export function registerAnnouncementHandlers() { + ipcMain.handle("announcements:get", fetchAnnouncementFeed); +} diff --git a/electron/ipc/register/settings.ts b/electron/ipc/register/settings.ts index e84f63171..dc1f01d9a 100644 --- a/electron/ipc/register/settings.ts +++ b/electron/ipc/register/settings.ts @@ -1,14 +1,9 @@ -import { readFileSync, writeFileSync } from "node:fs"; import fs from "node:fs/promises"; import { app, ipcMain } from "electron"; +import { hasAppSetting, readAppSettingsStore, writeAppSettingsStore } from "../../appSettingsStore"; import { hideCursor } from "../../cursorHider"; import { closeCountdownWindow, createCountdownWindow, getCountdownWindow } from "../../windows"; -import { - APP_SETTINGS_FILE, - COUNTDOWN_SETTINGS_FILE, - RECORDINGS_SETTINGS_FILE, - SHORTCUTS_FILE, -} from "../constants"; +import { COUNTDOWN_SETTINGS_FILE, RECORDINGS_SETTINGS_FILE, SHORTCUTS_FILE } from "../constants"; import { countdownCancelled, countdownInProgress, @@ -42,28 +37,6 @@ function getBrowserMicrophoneProfileFromEnv() { }; } -function readAppSettingsStore(): Record { - try { - const content = readFileSync(APP_SETTINGS_FILE, "utf-8"); - const parsed = parseJsonWithByteOrderMark(content); - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return {}; - } - - return parsed as Record; - } catch { - return {}; - } -} - -function writeAppSettingsStore(store: Record) { - writeFileSync(APP_SETTINGS_FILE, JSON.stringify(store, null, 2), "utf-8"); -} - -function hasAppSetting(store: Record, key: string): boolean { - return Reflect.getOwnPropertyDescriptor(store, key) !== undefined; -} - export function registerSettingsHandlers() { ipcMain.handle("app:getVersion", () => { return app.getVersion(); diff --git a/electron/main.ts b/electron/main.ts index 60eb16bb9..0758c77e2 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -37,11 +37,13 @@ import { dismissUpdateToast, downloadAvailableUpdate, getCurrentUpdateToastPayload, + getExperimentalUpdatesEnabled, getUpdaterLogPath, getUpdateStatusSummary, installDownloadedUpdateNow, previewUpdateToast, setupAutoUpdates, + setExperimentalUpdatesEnabled, skipAvailableUpdateVersion, } from "./updater"; import { @@ -738,6 +740,29 @@ ipcMain.handle("get-update-status-summary", () => { return getUpdateStatusSummary(); }); +ipcMain.handle("get-experimental-updates-enabled", () => { + return getExperimentalUpdatesEnabled(); +}); + +ipcMain.handle("set-experimental-updates-enabled", async (_event, enabled: unknown) => { + if (typeof enabled !== "boolean") { + return { success: false, enabled: getExperimentalUpdatesEnabled() }; + } + + try { + const savedValue = setExperimentalUpdatesEnabled(enabled); + await checkForAppUpdates(getUpdateDialogWindow); + return { success: true, enabled: savedValue }; + } catch (error) { + console.error("Failed to update experimental updates preference:", error); + return { + success: false, + enabled: getExperimentalUpdatesEnabled(), + error: String(error), + }; + } +}); + ipcMain.handle("preview-update-toast", () => { return { success: previewUpdateToast(sendUpdateToastToWindows) }; }); diff --git a/electron/preload.ts b/electron/preload.ts index d53edb543..5ba1567ae 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -835,6 +835,12 @@ contextBridge.exposeInMainWorld("electronAPI", { getUpdateStatusSummary: () => { return ipcRenderer.invoke("get-update-status-summary"); }, + getExperimentalUpdatesEnabled: () => { + return ipcRenderer.invoke("get-experimental-updates-enabled"); + }, + setExperimentalUpdatesEnabled: (enabled: boolean) => { + return ipcRenderer.invoke("set-experimental-updates-enabled", enabled); + }, previewUpdateToast: () => { return ipcRenderer.invoke("preview-update-toast"); }, @@ -966,6 +972,7 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.invoke("mux-native-windows-recording", expectedDurationMs), hideOsCursor: () => ipcRenderer.invoke("hide-cursor"), getAppVersion: () => ipcRenderer.invoke("app:getVersion"), + getAnnouncements: () => ipcRenderer.invoke("announcements:get"), getRecordingPreferences: () => ipcRenderer.invoke("get-recording-preferences"), getRecordingAudioLabConfig: () => ipcRenderer.invoke("get-recording-audio-lab-config"), setRecordingPreferences: (prefs: { diff --git a/electron/updater.ts b/electron/updater.ts index 86a02aecf..a89bb6783 100644 --- a/electron/updater.ts +++ b/electron/updater.ts @@ -4,6 +4,7 @@ import type { MessageBoxOptions, MessageBoxReturnValue } from "electron"; import { app, BrowserWindow, dialog } from "electron"; import { autoUpdater } from "electron-updater"; import { USER_DATA_PATH } from "./appPaths"; +import { readAppSetting, writeAppSetting } from "./appSettingsStore"; const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; export const UPDATE_REMINDER_DELAY_MS = 3 * 60 * 60 * 1000; @@ -16,6 +17,7 @@ const DEV_UPDATE_PREVIEW_VERSION = "9.9.9"; const DEV_UPDATE_PREVIEW_PROGRESS_STEP_MS = 300; const DEV_UPDATE_PREVIEW_PROGRESS_INCREMENT = 20; const ONE_MEGABYTE = 1024 * 1024; +const EXPERIMENTAL_UPDATES_SETTING_KEY = "experimentalUpdatesEnabled"; export type UpdateToastPhase = "available" | "downloading" | "ready" | "error"; @@ -124,6 +126,25 @@ function configureUpdateFeed() { writeUpdaterLog(`Using overridden update feed: ${UPDATE_FEED_URL_OVERRIDE}`); } +export function getExperimentalUpdatesEnabled() { + return readAppSetting(EXPERIMENTAL_UPDATES_SETTING_KEY) === true; +} + +function applyExperimentalUpdatesPreference() { + const enabled = getExperimentalUpdatesEnabled(); + autoUpdater.allowPrerelease = enabled; + writeUpdaterLog(`Update channel configured: ${enabled ? "experimental" : "stable"}.`); + return enabled; +} + +export function setExperimentalUpdatesEnabled(enabled: boolean) { + writeAppSetting(EXPERIMENTAL_UPDATES_SETTING_KEY, enabled); + autoUpdater.allowPrerelease = enabled; + skippedVersion = null; + writeUpdaterLog(`Experimental updates ${enabled ? "enabled" : "disabled"} by user.`); + return enabled; +} + function canUseAutoUpdates() { return !AUTO_UPDATES_DISABLED && app.isPackaged && !process.mas; } @@ -616,6 +637,7 @@ export async function checkForAppUpdates( manualCheckRequested = Boolean(options?.manual); updateCheckInProgress = true; + applyExperimentalUpdatesPreference(); setUpdateStatusSummary({ status: "checking", detail: "Checking for updates..." }); writeUpdaterLog(`Starting ${manualCheckRequested ? "manual" : "automatic"} update check.`); @@ -650,6 +672,7 @@ export function setupAutoUpdates( updaterInitialized = true; configureUpdateFeed(); + applyExperimentalUpdatesPreference(); autoUpdater.autoDownload = false; autoUpdater.autoInstallOnAppQuit = false; writeUpdaterLog(`Updater initialized. logPath=${UPDATER_LOG_PATH}`); diff --git a/src/App.tsx b/src/App.tsx index 513320f4f..cc6346884 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,6 @@ import { useEffect, useState } from "react"; +import { AnnouncementDialog } from "./components/announcements/AnnouncementDialog"; +import { LiveAnnouncementNotifications } from "./components/announcements/LiveAnnouncementNotifications"; import { CountdownOverlay } from "./components/countdown/CountdownOverlay"; import { LaunchWindow } from "./components/launch/LaunchWindow"; import { SourceSelector } from "./components/launch/SourceSelector"; @@ -72,10 +74,14 @@ export default function App() { return ; case "editor": return ( - - - - + <> + + + + + + + ); default: return ( diff --git a/src/components/announcements/AnnouncementDialog.tsx b/src/components/announcements/AnnouncementDialog.tsx new file mode 100644 index 000000000..42306edcb --- /dev/null +++ b/src/components/announcements/AnnouncementDialog.tsx @@ -0,0 +1,360 @@ +import { ArrowLeft, ArrowRight, ArrowSquareOut, Megaphone } from "@phosphor-icons/react"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { BUNDLED_ANNOUNCEMENT_FEED } from "@/content/announcements"; +import { useI18n } from "@/contexts/I18nContext"; +import { runAnnouncementAction } from "@/lib/announcementActions"; +import { + type Announcement, + type AnnouncementAudience, + parseAnnouncementFeed, + selectAnnouncements, +} from "@/lib/announcements"; +import { + dismissAnnouncements, + readAnnouncementImpressionCounts, + readDismissedAnnouncementIds, + recordAnnouncementImpression, +} from "@/lib/announcementState"; +import { cn } from "@/lib/utils"; +import { Button } from "../ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../ui/dialog"; + +function AnnouncementMediaBanner({ + announcement, + cover = false, +}: { + announcement: Announcement; + cover?: boolean; +}) { + const [failed, setFailed] = useState(false); + const media = announcement.media; + + if (!media || failed) { + return ( +
+ +
+ ); + } + + if (media.type === "video") { + return ( + + ); + } + + return ( + {media.alt setFailed(true)} + /> + ); +} + +export function AnnouncementDialog({ audience }: { audience: AnnouncementAudience }) { + const { t } = useI18n(); + const [announcements, setAnnouncements] = useState([]); + const [currentIndex, setCurrentIndex] = useState(0); + const [open, setOpen] = useState(false); + const [popupAspectRatio, setPopupAspectRatio] = useState( + BUNDLED_ANNOUNCEMENT_FEED.settings.aspectRatio, + ); + const countedThisSessionRef = useRef(new Set()); + + useEffect(() => { + let cancelled = false; + + const loadAnnouncements = async () => { + const dismissedIds = new Set(readDismissedAnnouncementIds()); + const impressionCounts = readAnnouncementImpressionCounts(); + const [appVersion, remoteFeed] = await Promise.all([ + window.electronAPI.getAppVersion().catch(() => "0.0.0"), + window.electronAPI.getAnnouncements().catch(() => null), + ]); + if (cancelled) { + return; + } + + const parsedRemoteFeed = parseAnnouncementFeed(remoteFeed); + const eligible = selectAnnouncements({ + bundled: BUNDLED_ANNOUNCEMENT_FEED.announcements, + remote: parsedRemoteFeed.announcements, + dismissedIds, + impressionCounts, + appVersion, + audience, + }).filter( + (announcement) => + announcement.presentation !== "notification" && + announcement.presentation !== "banner", + ); + setPopupAspectRatio( + parsedRemoteFeed.settings.aspectRatio ?? + BUNDLED_ANNOUNCEMENT_FEED.settings.aspectRatio, + ); + setAnnouncements(eligible); + setCurrentIndex(0); + setOpen(eligible.length > 0); + }; + + void loadAnnouncements(); + return () => { + cancelled = true; + }; + }, [audience]); + + const current = announcements[currentIndex]; + const currentId = current?.id; + const usesCoverMedia = current?.mediaMode === "cover" && Boolean(current.media); + const controls = { + close: current?.controls?.close !== false, + dismiss: current?.controls?.dismiss !== false, + action: current?.controls?.action !== false, + navigation: current?.controls?.navigation !== false, + indicators: current?.controls?.indicators !== false, + }; + + useEffect(() => { + if (!open || !currentId || countedThisSessionRef.current.has(currentId)) { + return; + } + + countedThisSessionRef.current.add(currentId); + recordAnnouncementImpression(currentId); + }, [currentId, open]); + + useEffect(() => { + if (!open || !current?.displayDurationSeconds) { + return; + } + + const timeout = window.setTimeout(() => { + if (currentIndex >= announcements.length - 1) { + setOpen(false); + return; + } + setCurrentIndex((index) => index + 1); + }, current.displayDurationSeconds * 1_000); + + return () => window.clearTimeout(timeout); + }, [announcements.length, current?.displayDurationSeconds, currentIndex, open]); + + const dismissCurrent = () => { + if (!current) { + setOpen(false); + return; + } + + dismissAnnouncements([current.id]); + + const remaining = announcements.filter((announcement) => announcement.id !== current.id); + setAnnouncements(remaining); + if (remaining.length === 0) { + setOpen(false); + setCurrentIndex(0); + return; + } + + setCurrentIndex((index) => Math.min(index, remaining.length - 1)); + }; + + const dismissAll = () => { + dismissAnnouncements(announcements.map((announcement) => announcement.id)); + setAnnouncements([]); + setCurrentIndex(0); + setOpen(false); + }; + + const openAction = async () => { + if (!current?.action) { + return; + } + + try { + const result = await runAnnouncementAction(current.action); + if (!result.success) { + toast.error(result.error || t("announcements.openFailed", "Failed to open link.")); + return; + } + dismissCurrent(); + } catch (error) { + toast.error( + `${t("announcements.openFailed", "Failed to open link.")} ${String(error)}`, + ); + } + }; + + if (!current) { + return null; + } + + return ( + { + if (!nextOpen) { + dismissAll(); + } + }} + > + button]:hidden", + usesCoverMedia && + "isolate min-h-80 text-white [&>button]:bg-black/35 [&>button]:text-white [&>button:hover]:bg-black/55", + )} + style={ + popupAspectRatio + ? { aspectRatio: popupAspectRatio.replace(":", " / ") } + : undefined + } + > + {usesCoverMedia ? ( + <> +
+ +
+
+ ); +} diff --git a/src/components/announcements/EditorAnnouncementBanner.tsx b/src/components/announcements/EditorAnnouncementBanner.tsx new file mode 100644 index 000000000..6180c17b9 --- /dev/null +++ b/src/components/announcements/EditorAnnouncementBanner.tsx @@ -0,0 +1,143 @@ +import { ArrowRight, ArrowSquareOut, X } from "@phosphor-icons/react"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { BUNDLED_ANNOUNCEMENT_FEED } from "@/content/announcements"; +import { useI18n } from "@/contexts/I18nContext"; +import { runAnnouncementAction } from "@/lib/announcementActions"; +import type { Announcement } from "@/lib/announcements"; +import { parseAnnouncementFeed, selectAnnouncements } from "@/lib/announcements"; +import { + dismissAnnouncements, + readAnnouncementImpressionCounts, + readDismissedAnnouncementIds, + recordAnnouncementImpression, +} from "@/lib/announcementState"; + +export function EditorAnnouncementBanner() { + const { t } = useI18n(); + const [announcements, setAnnouncements] = useState([]); + const countedThisSessionRef = useRef(new Set()); + const current = announcements[0]; + + useEffect(() => { + let cancelled = false; + + const loadBanners = async () => { + const dismissedIds = new Set(readDismissedAnnouncementIds()); + const impressionCounts = readAnnouncementImpressionCounts(); + const [appVersion, remoteFeed] = await Promise.all([ + window.electronAPI.getAppVersion().catch(() => "0.0.0"), + window.electronAPI.getAnnouncements().catch(() => null), + ]); + if (cancelled) { + return; + } + + setAnnouncements( + selectAnnouncements({ + bundled: BUNDLED_ANNOUNCEMENT_FEED.announcements, + remote: parseAnnouncementFeed(remoteFeed).announcements, + dismissedIds, + impressionCounts, + appVersion, + audience: "editor", + }).filter((announcement) => announcement.presentation === "banner"), + ); + }; + + void loadBanners(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (!current || countedThisSessionRef.current.has(current.id)) { + return; + } + + countedThisSessionRef.current.add(current.id); + recordAnnouncementImpression(current.id); + }, [current]); + + useEffect(() => { + if (!current?.displayDurationSeconds) { + return; + } + + const timeout = window.setTimeout(() => { + setAnnouncements((items) => items.slice(1)); + }, current.displayDurationSeconds * 1_000); + return () => window.clearTimeout(timeout); + }, [current]); + + if (!current) { + return null; + } + + const dismissCurrent = () => { + dismissAnnouncements([current.id]); + setAnnouncements((items) => items.slice(1)); + }; + + const openAction = async () => { + if (!current.action) { + return; + } + + try { + const result = await runAnnouncementAction(current.action); + if (!result.success) { + toast.error(result.error || t("announcements.openFailed", "Failed to open link.")); + return; + } + dismissCurrent(); + } catch (error) { + toast.error( + `${t("announcements.openFailed", "Failed to open link.")} ${String(error)}`, + ); + } + }; + + const showClose = current.controls?.close !== false; + const showAction = current.action && current.controls?.action !== false; + + return ( +
+
+ {current.title} + + {current.body} +
+ {showAction ? ( + + ) : null} + {showClose ? ( + + ) : null} +
+ ); +} diff --git a/src/components/announcements/LiveAnnouncementNotifications.tsx b/src/components/announcements/LiveAnnouncementNotifications.tsx new file mode 100644 index 000000000..3d6bce30f --- /dev/null +++ b/src/components/announcements/LiveAnnouncementNotifications.tsx @@ -0,0 +1,118 @@ +import { useEffect, useRef } from "react"; +import { toast } from "sonner"; +import { BUNDLED_ANNOUNCEMENT_FEED } from "@/content/announcements"; +import { useI18n } from "@/contexts/I18nContext"; +import { runAnnouncementAction } from "@/lib/announcementActions"; +import type { AnnouncementAudience } from "@/lib/announcements"; +import { parseAnnouncementFeed, selectAnnouncements } from "@/lib/announcements"; +import { + dismissAnnouncements, + readAnnouncementImpressionCounts, + readDismissedAnnouncementIds, + recordAnnouncementImpression, +} from "@/lib/announcementState"; + +const DEFAULT_NOTIFICATION_DURATION_SECONDS = 10; +const MAX_NOTIFICATIONS_PER_LOAD = 5; + +export function LiveAnnouncementNotifications({ audience }: { audience: AnnouncementAudience }) { + const { t } = useI18n(); + const shownThisSessionRef = useRef(new Set()); + + useEffect(() => { + let cancelled = false; + + const showNotifications = async () => { + const dismissedIds = new Set(readDismissedAnnouncementIds()); + const impressionCounts = readAnnouncementImpressionCounts(); + const [appVersion, remoteFeed] = await Promise.all([ + window.electronAPI.getAppVersion().catch(() => "0.0.0"), + window.electronAPI.getAnnouncements().catch(() => null), + ]); + if (cancelled) { + return; + } + + const notifications = selectAnnouncements({ + bundled: BUNDLED_ANNOUNCEMENT_FEED.announcements, + remote: parseAnnouncementFeed(remoteFeed).announcements, + dismissedIds, + impressionCounts, + appVersion, + audience, + }) + .filter((announcement) => announcement.presentation === "notification") + .slice(0, MAX_NOTIFICATIONS_PER_LOAD); + + for (const announcement of notifications) { + if (shownThisSessionRef.current.has(announcement.id)) { + continue; + } + + shownThisSessionRef.current.add(announcement.id); + recordAnnouncementImpression(announcement.id); + const dismiss = () => dismissAnnouncements([announcement.id]); + const controls = { + close: announcement.controls?.close !== false, + action: announcement.controls?.action !== false, + }; + const action = announcement.action; + + toast( + + {announcement.title} + , + { + id: `live-announcement:${announcement.id}`, + description: ( +
+

+ {announcement.body} +

+
+ ), + duration: + (announcement.displayDurationSeconds ?? + DEFAULT_NOTIFICATION_DURATION_SECONDS) * 1_000, + closeButton: controls.close, + onDismiss: dismiss, + action: + action && controls.action + ? { + label: action.label, + onClick: () => { + void runAnnouncementAction(action) + .then((result) => { + if (result.success) { + dismiss(); + return; + } + toast.error( + result.error || + t( + "announcements.openFailed", + "Failed to open link.", + ), + ); + }) + .catch((error) => { + toast.error( + `${t("announcements.openFailed", "Failed to open link.")} ${String(error)}`, + ); + }); + }, + } + : undefined, + }, + ); + } + }; + + void showNotifications(); + return () => { + cancelled = true; + }; + }, [audience, t]); + + return null; +} diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index ea2c0cee8..81c934d5f 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -1119,6 +1119,8 @@ export function SettingsPanel({ const [customImages, setCustomImages] = useState( initialEditorPreferences.customWallpapers, ); + const [experimentalUpdatesEnabled, setExperimentalUpdatesEnabled] = useState(false); + const [savingExperimentalUpdates, setSavingExperimentalUpdates] = useState(false); const removeBackgroundStateRef = useRef<{ aspectRatio: AspectRatio; padding: Padding; @@ -1136,6 +1138,44 @@ export function SettingsPanel({ }); }; + useEffect(() => { + let cancelled = false; + void window.electronAPI + .getExperimentalUpdatesEnabled() + .then((enabled) => { + if (!cancelled) setExperimentalUpdatesEnabled(enabled); + }) + .catch((error) => { + console.error("Failed to load experimental updates preference:", error); + }); + return () => { + cancelled = true; + }; + }, []); + + const updateExperimentalUpdatesPreference = async (enabled: boolean) => { + const previousValue = experimentalUpdatesEnabled; + setExperimentalUpdatesEnabled(enabled); + setSavingExperimentalUpdates(true); + try { + const result = await window.electronAPI.setExperimentalUpdatesEnabled(enabled); + setExperimentalUpdatesEnabled(result.enabled); + if (!result.success) { + toast.error( + result.error || + tSettings("updates.saveFailed", "Failed to change the update channel."), + ); + } + } catch (error) { + setExperimentalUpdatesEnabled(previousValue); + toast.error( + `${tSettings("updates.saveFailed", "Failed to change the update channel.")} ${String(error)}`, + ); + } finally { + setSavingExperimentalUpdates(false); + } + }; + useEffect(() => { let mounted = true; (async () => { @@ -2542,6 +2582,32 @@ export function SettingsPanel({ +
+ {tSettings("updates.title", "Updates")} +
+
+
+ {tSettings("updates.experimental", "Experimental updates")} +
+
+ {tSettings( + "updates.experimentalDescription", + "Receive first-line test builds published as prereleases. These may be less stable.", + )} +
+
+ + void updateExperimentalUpdatesPreference(enabled) + } + aria-label={tSettings("updates.experimental", "Experimental updates")} + className="data-[state=checked]:bg-[#2563EB] scale-75" + /> +
+
+
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 596a84d87..ad38cb4ab 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -31,6 +31,7 @@ import type { Span } from "dnd-timeline"; import { motion } from "motion/react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; +import { EditorAnnouncementBanner } from "@/components/announcements/EditorAnnouncementBanner"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -51,6 +52,8 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover import { Toaster } from "@/components/ui/sonner"; import { useI18n } from "@/contexts/I18nContext"; import { useShortcuts } from "@/contexts/ShortcutsContext"; +import { OPEN_EDITOR_SECTION_EVENT } from "@/lib/announcementActions"; +import { type AnnouncementEditorSection, isAnnouncementEditorSection } from "@/lib/announcements"; import { calculateOutputDimensions, DEFAULT_MP4_CODEC, @@ -607,6 +610,17 @@ export default function VideoEditor() { initialEditorPreferences.aspectRatio, ); const [activeEffectSection, setActiveEffectSection] = useState("scene"); + useEffect(() => { + const handleOpenEditorSection = (event: Event) => { + const section = (event as CustomEvent).detail; + if (isAnnouncementEditorSection(section)) { + setActiveEffectSection(section); + } + }; + + window.addEventListener(OPEN_EDITOR_SECTION_EVENT, handleOpenEditorSection); + return () => window.removeEventListener(OPEN_EDITOR_SECTION_EVENT, handleOpenEditorSection); + }, []); const [exportQuality, setExportQuality] = useState( initialEditorPreferences.exportQuality, ); @@ -6197,6 +6211,7 @@ export default function VideoEditor() {
+
diff --git a/src/content/announcements.ts b/src/content/announcements.ts new file mode 100644 index 000000000..80281a12a --- /dev/null +++ b/src/content/announcements.ts @@ -0,0 +1,9 @@ +import type { AnnouncementFeed } from "@/lib/announcements"; + +/** Announcements bundled with a release. Use a new ID when an item should be shown again. */ +export const BUNDLED_ANNOUNCEMENT_FEED: AnnouncementFeed = { + settings: { + aspectRatio: "4:3", + }, + announcements: [], +}; diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 8f0cfb7b2..6f18ef1b2 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -1,4 +1,10 @@ { + "updates": { + "title": "Updates", + "experimental": "Experimental updates", + "experimentalDescription": "Receive first-line test builds published as prereleases. These may be less stable.", + "saveFailed": "Failed to change the update channel." + }, "zoom": { "level": "Zoom Level", "selectRegion": "Select a zoom region to adjust", diff --git a/src/lib/announcementActions.ts b/src/lib/announcementActions.ts new file mode 100644 index 000000000..2e05ab678 --- /dev/null +++ b/src/lib/announcementActions.ts @@ -0,0 +1,22 @@ +import type { AnnouncementAction, AnnouncementEditorSection } from "./announcements"; + +export const OPEN_EDITOR_SECTION_EVENT = "recordly:open-editor-section"; + +export function openEditorSection(section: AnnouncementEditorSection): void { + window.dispatchEvent( + new CustomEvent(OPEN_EDITOR_SECTION_EVENT, { + detail: section, + }), + ); +} + +export async function runAnnouncementAction( + action: AnnouncementAction, +): Promise<{ success: boolean; error?: string }> { + if (action.section) { + openEditorSection(action.section); + return { success: true }; + } + + return window.electronAPI.openExternalUrl(action.url); +} diff --git a/src/lib/announcementState.ts b/src/lib/announcementState.ts new file mode 100644 index 000000000..13ab2719e --- /dev/null +++ b/src/lib/announcementState.ts @@ -0,0 +1,46 @@ +const DISMISSED_ANNOUNCEMENTS_KEY = "dismissedAnnouncementIds"; +const ANNOUNCEMENT_IMPRESSIONS_KEY = "announcementImpressionCounts"; +const MAX_DISMISSED_ANNOUNCEMENTS = 200; + +export function readDismissedAnnouncementIds(): string[] { + const value = window.electronAPI.getAppSetting(DISMISSED_ANNOUNCEMENTS_KEY); + if (!Array.isArray(value)) { + return []; + } + + return value + .filter((item): item is string => typeof item === "string") + .slice(-MAX_DISMISSED_ANNOUNCEMENTS); +} + +export function dismissAnnouncements(ids: Iterable) { + const dismissedIds = new Set(readDismissedAnnouncementIds()); + for (const id of ids) { + dismissedIds.add(id); + } + window.electronAPI.setAppSetting( + DISMISSED_ANNOUNCEMENTS_KEY, + [...dismissedIds].slice(-MAX_DISMISSED_ANNOUNCEMENTS), + ); +} + +export function readAnnouncementImpressionCounts(): Record { + const value = window.electronAPI.getAppSetting(ANNOUNCEMENT_IMPRESSIONS_KEY); + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + + const counts: Record = {}; + for (const [id, count] of Object.entries(value)) { + if (typeof count === "number" && Number.isInteger(count) && count >= 0) { + counts[id] = count; + } + } + return counts; +} + +export function recordAnnouncementImpression(id: string) { + const counts = readAnnouncementImpressionCounts(); + counts[id] = (counts[id] ?? 0) + 1; + window.electronAPI.setAppSetting(ANNOUNCEMENT_IMPRESSIONS_KEY, counts); +} diff --git a/src/lib/announcements.test.ts b/src/lib/announcements.test.ts new file mode 100644 index 000000000..a616c9249 --- /dev/null +++ b/src/lib/announcements.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, it } from "vitest"; +import { compareVersions, parseAnnouncementFeed, selectAnnouncements } from "./announcements"; + +describe("parseAnnouncementFeed", () => { + it("accepts safe text, media, and actions", () => { + const feed = parseAnnouncementFeed({ + settings: { aspectRatio: "16:9" }, + announcements: [ + { + id: "release-1", + title: "New release", + body: "See what changed.", + audience: "editor", + priority: 200, + mediaMode: "cover", + displayDurationSeconds: 15, + maxImpressions: 3, + controls: { + close: false, + dismiss: false, + action: true, + navigation: false, + indicators: true, + }, + media: { + type: "video", + url: "https://example.com/demo.mp4", + posterUrl: "/announcement-poster.jpg", + }, + action: { label: "Learn more", url: "https://example.com/release" }, + }, + ], + }); + + expect(feed.settings).toEqual({ aspectRatio: "16:9" }); + expect(feed.announcements).toEqual([ + { + id: "release-1", + title: "New release", + body: "See what changed.", + audience: "editor", + priority: 100, + mediaMode: "cover", + displayDurationSeconds: 15, + maxImpressions: 3, + controls: { + close: false, + dismiss: false, + action: true, + navigation: false, + indicators: true, + }, + media: { + type: "video", + url: "https://example.com/demo.mp4", + posterUrl: "/announcement-poster.jpg", + }, + action: { label: "Learn more", url: "https://example.com/release" }, + }, + ]); + }); + + it("keeps notifications as text-only toasts", () => { + const feed = parseAnnouncementFeed({ + announcements: [ + { + id: "notice-1", + title: "Service notice", + body: "A short notification.", + presentation: "notification", + mediaMode: "cover", + media: { type: "image", url: "https://example.com/banner.jpg" }, + action: { label: "Details", url: "https://example.com/details" }, + }, + ], + }); + + expect(feed.announcements).toEqual([ + { + id: "notice-1", + title: "Service notice", + body: "A short notification.", + presentation: "notification", + audience: "all", + priority: 0, + action: { label: "Details", url: "https://example.com/details" }, + }, + ]); + }); + + it("keeps editor banners text-only", () => { + const feed = parseAnnouncementFeed({ + announcements: [ + { + id: "banner-1", + title: "Editor notice", + body: "A short banner.", + presentation: "banner", + mediaMode: "cover", + media: { type: "video", url: "https://example.com/banner.mp4" }, + action: { label: "Details", url: "https://example.com/details" }, + }, + ], + }); + + expect(feed.announcements).toEqual([ + { + id: "banner-1", + title: "Editor notice", + body: "A short banner.", + presentation: "banner", + audience: "all", + priority: 0, + action: { label: "Details", url: "https://example.com/details" }, + }, + ]); + }); + + it("accepts an action that opens a safe editor section", () => { + const feed = parseAnnouncementFeed({ + announcements: [ + { + id: "settings-link", + title: "Try experimental updates", + body: "Open settings to opt in.", + action: { label: "Open settings", section: "settings" }, + }, + ], + }); + + expect(feed.announcements[0]?.action).toEqual({ + label: "Open settings", + section: "settings", + }); + }); + + it("ignores ambiguous or unknown in-app actions", () => { + const announcements = parseAnnouncementFeed({ + announcements: [ + { + id: "ambiguous", + title: "Ambiguous", + body: "Two destinations are not allowed.", + action: { + label: "Open", + url: "https://example.com", + section: "settings", + }, + }, + { + id: "unknown", + title: "Unknown", + body: "Unknown destinations are ignored.", + action: { label: "Open", section: "billing" }, + }, + ], + }).announcements; + + expect(announcements.map((announcement) => announcement.action)).toEqual([ + undefined, + undefined, + ]); + }); + + it("drops malformed items and unsafe URLs", () => { + const feed = parseAnnouncementFeed({ + announcements: [ + { id: "missing-body", title: "Incomplete" }, + { + id: "safe-text", + title: "Still valid", + body: "Unsafe optional fields are ignored.", + media: { type: "image", url: "javascript:alert(1)" }, + action: { label: "Bad", url: "http://example.com" }, + }, + ], + }); + + expect(feed.announcements).toEqual([ + { + id: "safe-text", + title: "Still valid", + body: "Unsafe optional fields are ignored.", + audience: "all", + priority: 0, + }, + ]); + }); +}); + +describe("selectAnnouncements", () => { + it("filters by audience, date, version, and dismissal", () => { + const parsed = parseAnnouncementFeed({ + announcements: [ + { id: "shown", title: "Shown", body: "Visible", priority: 2, minVersion: "1.2.0" }, + { id: "dismissed", title: "Dismissed", body: "Hidden" }, + { id: "future", title: "Future", body: "Hidden", startsAt: "2027-01-01T00:00:00Z" }, + { id: "expired", title: "Expired", body: "Hidden", endsAt: "2025-01-01T00:00:00Z" }, + { id: "newer", title: "Newer", body: "Hidden", minVersion: "2.0.0" }, + ], + }).announcements; + + const selected = selectAnnouncements({ + bundled: [], + remote: parsed, + dismissedIds: new Set(["dismissed"]), + appVersion: "1.3.5-beta.2", + audience: "editor", + now: new Date("2026-09-01T00:00:00Z"), + }); + + expect(selected.map((announcement) => announcement.id)).toEqual(["shown"]); + }); + + it("lets remote announcements replace bundled items with the same ID", () => { + const common = { audience: "all" as const, priority: 0 }; + const selected = selectAnnouncements({ + bundled: [{ id: "same", title: "Bundled", body: "Old", ...common }], + remote: [{ id: "same", title: "Remote", body: "New", ...common }], + dismissedIds: new Set(), + appVersion: "1.0.0", + audience: "editor", + }); + + expect(selected[0]?.title).toBe("Remote"); + }); + + it("stops selecting an announcement after its impression limit", () => { + const common = { audience: "all" as const, priority: 0 }; + const selected = selectAnnouncements({ + bundled: [ + { + id: "limited", + title: "Limited", + body: "Shown twice", + maxImpressions: 2, + ...common, + }, + ], + remote: [], + dismissedIds: new Set(), + impressionCounts: { limited: 2 }, + appVersion: "1.0.0", + audience: "editor", + }); + + expect(selected).toEqual([]); + }); +}); + +describe("compareVersions", () => { + it("compares numeric release segments", () => { + expect(compareVersions("1.3.5-beta.2", "1.3.4")).toBe(1); + expect(compareVersions("1.3", "1.3.0")).toBe(0); + expect(compareVersions("v1.2.9", "1.3.0")).toBe(-1); + }); +}); diff --git a/src/lib/announcements.ts b/src/lib/announcements.ts new file mode 100644 index 000000000..9a65c9f66 --- /dev/null +++ b/src/lib/announcements.ts @@ -0,0 +1,355 @@ +export type AnnouncementAudience = "all" | "editor"; + +export interface AnnouncementMedia { + type: "image" | "video"; + url: string; + alt?: string; + posterUrl?: string; +} + +export type AnnouncementEditorSection = + | "scene" + | "cursor" + | "webcam" + | "captions" + | "settings" + | "extensions"; + +export type AnnouncementAction = + | { label: string; url: string; section?: never } + | { label: string; section: AnnouncementEditorSection; url?: never }; + +export interface AnnouncementControls { + close?: boolean; + dismiss?: boolean; + action?: boolean; + navigation?: boolean; + indicators?: boolean; +} + +export interface Announcement { + id: string; + title: string; + body: string; + presentation?: "popup" | "notification" | "banner"; + audience: AnnouncementAudience; + priority: number; + mediaMode?: "banner" | "cover"; + displayDurationSeconds?: number; + maxImpressions?: number; + controls?: AnnouncementControls; + media?: AnnouncementMedia; + action?: AnnouncementAction; + startsAt?: string; + endsAt?: string; + minVersion?: string; + maxVersion?: string; +} + +export interface AnnouncementFeed { + settings: AnnouncementFeedSettings; + announcements: Announcement[]; +} + +export interface AnnouncementFeedSettings { + aspectRatio?: string; +} + +const MAX_ANNOUNCEMENTS = 50; +const MAX_ID_LENGTH = 100; +const MAX_TITLE_LENGTH = 160; +const MAX_BODY_LENGTH = 2_000; +const MAX_LABEL_LENGTH = 80; +const MAX_URL_LENGTH = 2_048; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function readTrimmedString(value: unknown, maxLength: number): string | undefined { + if (typeof value !== "string") { + return undefined; + } + + const trimmed = value.trim(); + return trimmed && trimmed.length <= maxLength ? trimmed : undefined; +} + +function readDate(value: unknown): string | undefined { + const date = readTrimmedString(value, 64); + return date && Number.isFinite(Date.parse(date)) ? date : undefined; +} + +function readAspectRatio(value: unknown): string | undefined { + const ratio = readTrimmedString(value, 16); + const match = ratio?.match(/^(\d{1,3}):(\d{1,3})$/); + if (!match) { + return undefined; + } + + const width = Number(match[1]); + const height = Number(match[2]); + const numericRatio = width / height; + return width > 0 && height > 0 && numericRatio >= 0.4 && numericRatio <= 3 + ? `${width}:${height}` + : undefined; +} + +function readBoundedInteger(value: unknown, minimum: number, maximum: number): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) { + return undefined; + } + + const rounded = Math.round(value); + return rounded >= minimum && rounded <= maximum ? rounded : undefined; +} + +function readSafeUrl(value: unknown, allowRelative: boolean): string | undefined { + const url = readTrimmedString(value, MAX_URL_LENGTH); + if (!url) { + return undefined; + } + + if (allowRelative && url.startsWith("/") && !url.startsWith("//")) { + return url; + } + + try { + const parsed = new URL(url); + return parsed.protocol === "https:" && !parsed.username && !parsed.password + ? url + : undefined; + } catch { + return undefined; + } +} + +function parseMedia(value: unknown): AnnouncementMedia | undefined { + if (!isRecord(value) || (value.type !== "image" && value.type !== "video")) { + return undefined; + } + + const url = readSafeUrl(value.url, true); + if (!url) { + return undefined; + } + + const alt = readTrimmedString(value.alt, 300); + const posterUrl = value.type === "video" ? readSafeUrl(value.posterUrl, true) : undefined; + return { + type: value.type, + url, + ...(alt ? { alt } : {}), + ...(posterUrl ? { posterUrl } : {}), + }; +} + +function parseAction(value: unknown): AnnouncementAction | undefined { + if (!isRecord(value)) { + return undefined; + } + + const label = readTrimmedString(value.label, MAX_LABEL_LENGTH); + const url = readSafeUrl(value.url, false); + const section = isAnnouncementEditorSection(value.section) ? value.section : undefined; + if (!label || Boolean(url) === Boolean(section)) { + return undefined; + } + + if (url) { + return { label, url }; + } + + return section ? { label, section } : undefined; +} + +export function isAnnouncementEditorSection(value: unknown): value is AnnouncementEditorSection { + return ( + value === "scene" || + value === "cursor" || + value === "webcam" || + value === "captions" || + value === "settings" || + value === "extensions" + ); +} + +function parseControls(value: unknown): AnnouncementControls | undefined { + if (!isRecord(value)) { + return undefined; + } + + const controls: AnnouncementControls = {}; + for (const key of ["close", "dismiss", "action", "navigation", "indicators"] as const) { + if (typeof value[key] === "boolean") { + controls[key] = value[key]; + } + } + + return Object.keys(controls).length > 0 ? controls : undefined; +} + +function parseAnnouncement(value: unknown): Announcement | undefined { + if (!isRecord(value)) { + return undefined; + } + + const id = readTrimmedString(value.id, MAX_ID_LENGTH); + const title = readTrimmedString(value.title, MAX_TITLE_LENGTH); + const body = readTrimmedString(value.body, MAX_BODY_LENGTH); + if (!id || !title || !body) { + return undefined; + } + + const audience = value.audience === "editor" ? "editor" : "all"; + const presentation = + value.presentation === "notification" || value.presentation === "banner" + ? value.presentation + : undefined; + const isTextOnlyPresentation = presentation === "notification" || presentation === "banner"; + const priority = + typeof value.priority === "number" && Number.isFinite(value.priority) + ? Math.max(-100, Math.min(100, value.priority)) + : 0; + const media = isTextOnlyPresentation ? undefined : parseMedia(value.media); + const action = parseAction(value.action); + const controls = parseControls(value.controls); + const mediaMode = + !isTextOnlyPresentation && (value.mediaMode === "cover" || value.mediaMode === "banner") + ? value.mediaMode + : undefined; + const displayDurationSeconds = readBoundedInteger(value.displayDurationSeconds, 3, 300); + const maxImpressions = readBoundedInteger(value.maxImpressions, 1, 100); + const startsAt = readDate(value.startsAt); + const endsAt = readDate(value.endsAt); + const minVersion = readTrimmedString(value.minVersion, 64); + const maxVersion = readTrimmedString(value.maxVersion, 64); + + return { + id, + title, + body, + ...(presentation ? { presentation } : {}), + audience, + priority, + ...(mediaMode ? { mediaMode } : {}), + ...(displayDurationSeconds ? { displayDurationSeconds } : {}), + ...(maxImpressions ? { maxImpressions } : {}), + ...(controls ? { controls } : {}), + ...(media ? { media } : {}), + ...(action ? { action } : {}), + ...(startsAt ? { startsAt } : {}), + ...(endsAt ? { endsAt } : {}), + ...(minVersion ? { minVersion } : {}), + ...(maxVersion ? { maxVersion } : {}), + }; +} + +export function parseAnnouncementFeed(value: unknown): AnnouncementFeed { + if (!isRecord(value) || !Array.isArray(value.announcements)) { + return { settings: {}, announcements: [] }; + } + + const settings = isRecord(value.settings) + ? { aspectRatio: readAspectRatio(value.settings.aspectRatio) } + : {}; + + const announcements: Announcement[] = []; + for (const item of value.announcements.slice(0, MAX_ANNOUNCEMENTS)) { + const announcement = parseAnnouncement(item); + if (announcement) { + announcements.push(announcement); + } + } + + return { + settings: settings.aspectRatio ? { aspectRatio: settings.aspectRatio } : {}, + announcements, + }; +} + +function versionParts(version: string): number[] { + const core = version.trim().replace(/^v/i, "").split("-")[0]; + return core.split(".").map((part) => { + const parsed = Number.parseInt(part, 10); + return Number.isFinite(parsed) ? parsed : 0; + }); +} + +export function compareVersions(left: string, right: string): number { + const leftParts = versionParts(left); + const rightParts = versionParts(right); + const length = Math.max(leftParts.length, rightParts.length); + + for (let index = 0; index < length; index += 1) { + const difference = (leftParts[index] ?? 0) - (rightParts[index] ?? 0); + if (difference !== 0) { + return difference > 0 ? 1 : -1; + } + } + + return 0; +} + +export function selectAnnouncements({ + bundled, + remote, + dismissedIds, + impressionCounts = {}, + appVersion, + audience, + now = new Date(), +}: { + bundled: Announcement[]; + remote: Announcement[]; + dismissedIds: ReadonlySet; + impressionCounts?: Readonly>; + appVersion: string; + audience: AnnouncementAudience; + now?: Date; +}): Announcement[] { + const byId = new Map(); + for (const announcement of bundled) { + byId.set(announcement.id, announcement); + } + for (const announcement of remote) { + byId.set(announcement.id, announcement); + } + + const nowMs = now.getTime(); + return [...byId.values()] + .filter((announcement) => { + if (dismissedIds.has(announcement.id)) { + return false; + } + if ( + announcement.maxImpressions && + (impressionCounts[announcement.id] ?? 0) >= announcement.maxImpressions + ) { + return false; + } + if (announcement.audience !== "all" && announcement.audience !== audience) { + return false; + } + if (announcement.startsAt && Date.parse(announcement.startsAt) > nowMs) { + return false; + } + if (announcement.endsAt && Date.parse(announcement.endsAt) < nowMs) { + return false; + } + if ( + announcement.minVersion && + compareVersions(appVersion, announcement.minVersion) < 0 + ) { + return false; + } + if ( + announcement.maxVersion && + compareVersions(appVersion, announcement.maxVersion) > 0 + ) { + return false; + } + return true; + }) + .sort((left, right) => right.priority - left.priority); +} From 621006ff71d27326f30bed8a11e17f0cbb48346c Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:25:12 +1000 Subject: [PATCH 2/6] Tune export quality for web delivery Use practical bitrate targets across export backends and add dithering when converting full-range canvas frames to BT.709 video range. --- electron/ipc/nativeVideoExport.test.ts | 12 +-- electron/ipc/nativeVideoExport.ts | 6 +- src/lib/exporter/exportBitrate.test.ts | 26 +++--- src/lib/exporter/exportBitrate.ts | 112 +++++++++---------------- 4 files changed, 63 insertions(+), 93 deletions(-) diff --git a/electron/ipc/nativeVideoExport.test.ts b/electron/ipc/nativeVideoExport.test.ts index b9b7ba1a5..76585709a 100644 --- a/electron/ipc/nativeVideoExport.test.ts +++ b/electron/ipc/nativeVideoExport.test.ts @@ -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=a_dither,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"); @@ -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=a_dither", "-colorspace", "bt709", "-color_primaries", @@ -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=a_dither,fps=60,pad=w=1920:h=1080:x=192:y=108:color=0x101010", "-map", "0:v:0", "-an", @@ -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=a_dither,fps=60,pad=w=1920:h=1080:x=192:y=108:color=0x101010", ); }); @@ -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=a_dither,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])); diff --git a/electron/ipc/nativeVideoExport.ts b/electron/ipc/nativeVideoExport.ts index c8bacce1e..7d9e13694 100644 --- a/electron/ipc/nativeVideoExport.ts +++ b/electron/ipc/nativeVideoExport.ts @@ -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=a_dither"; 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=a_dither"; export type NativeExportEncodingMode = "fast" | "balanced" | "quality"; @@ -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, diff --git a/src/lib/exporter/exportBitrate.test.ts b/src/lib/exporter/exportBitrate.test.ts index 301689dd2..13b8a9be1 100644 --- a/src/lib/exporter/exportBitrate.test.ts +++ b/src/lib/exporter/exportBitrate.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest"; import { getMp4ExportBitrate, getSourceQualityBitrate } from "./exportBitrate"; describe("export bitrate policy", () => { - it("keeps source-quality exports at a fuller screen-recording bitrate", () => { - expect(getSourceQualityBitrate(1920, 1080)).toBe(30_000_000); + it("uses the web-delivery source-quality caps", () => { + expect(getSourceQualityBitrate(1280, 720)).toBe(8_000_000); + expect(getSourceQualityBitrate(1920, 1080)).toBe(12_000_000); + expect(getSourceQualityBitrate(3840, 2160)).toBe(45_000_000); expect( getMp4ExportBitrate({ width: 1920, @@ -12,7 +14,7 @@ describe("export bitrate policy", () => { quality: "source", encodingMode: "quality", }), - ).toBe(30_000_000); + ).toBe(12_000_000); expect( getMp4ExportBitrate({ width: 1920, @@ -21,7 +23,7 @@ describe("export bitrate policy", () => { quality: "source", encodingMode: "balanced", }), - ).toBe(22_500_000); + ).toBe(9_600_000); }); it("raises high-resolution 60fps source-quality exports above the 30fps budget", () => { @@ -41,9 +43,9 @@ describe("export bitrate policy", () => { frameRate: 60, }); - expect(thirtyFpsBitrate).toBe(50_000_000); + expect(thirtyFpsBitrate).toBe(20_555_556); expect(sixtyFpsBitrate).toBeGreaterThan(thirtyFpsBitrate); - expect(sixtyFpsBitrate).toBe(70_710_678); + expect(sixtyFpsBitrate).toBe(29_069_946); }); it("keeps modern native static-layout source exports high enough for screen text", () => { @@ -56,7 +58,7 @@ describe("export bitrate policy", () => { encodingMode: "balanced", useModernNativeStaticLayout: true, }), - ).toBe(22_500_000); + ).toBe(9_600_000); expect( getMp4ExportBitrate({ width: 1920, @@ -66,7 +68,7 @@ describe("export bitrate policy", () => { encodingMode: "quality", useModernNativeStaticLayout: true, }), - ).toBe(30_000_000); + ).toBe(12_000_000); }); it("scales modern native static-layout source exports at 60fps", () => { @@ -87,9 +89,9 @@ describe("export bitrate policy", () => { frameRate: 60, }); - expect(thirtyFpsBitrate).toBe(30_000_000); + expect(thirtyFpsBitrate).toBe(12_000_000); expect(sixtyFpsBitrate).toBeGreaterThan(thirtyFpsBitrate); - expect(sixtyFpsBitrate).toBe(42_426_407); + expect(sixtyFpsBitrate).toBe(16_970_563); }); it("does not raise fast exports when the requested bitrate is already lower than the cap", () => { @@ -102,7 +104,7 @@ describe("export bitrate policy", () => { encodingMode: "fast", useModernNativeStaticLayout: true, }), - ).toBe(3_000_000); + ).toBe(6_000_000); }); it("scales the modern native cap with output pixel rate", () => { @@ -115,6 +117,6 @@ describe("export bitrate policy", () => { encodingMode: "quality", useModernNativeStaticLayout: true, }), - ).toBe(72_000_000); + ).toBe(45_000_000); }); }); diff --git a/src/lib/exporter/exportBitrate.ts b/src/lib/exporter/exportBitrate.ts index a82acb7ac..1b585509f 100644 --- a/src/lib/exporter/exportBitrate.ts +++ b/src/lib/exporter/exportBitrate.ts @@ -1,30 +1,49 @@ import type { ExportEncodingMode, ExportMp4FrameRate, ExportQuality } from "./types"; const MIN_MP4_BITRATE = 2_000_000; -const REFERENCE_PIXEL_RATE = 1920 * 1080 * 30; const REFERENCE_FRAME_RATE = 30; +const HD_PIXELS = 1280 * 720; +const FULL_HD_PIXELS = 1920 * 1080; +const UHD_PIXELS = 3840 * 2160; + +function interpolateBitrate( + totalPixels: number, + startPixels: number, + endPixels: number, + startBitrate: number, + endBitrate: number, +): number { + const progress = Math.max( + 0, + Math.min(1, (totalPixels - startPixels) / (endPixels - startPixels)), + ); + return Math.round(startBitrate + (endBitrate - startBitrate) * progress); +} export function getEncodingModeBitrateMultiplier(encodingMode: ExportEncodingMode): number { switch (encodingMode) { case "fast": - return 0.1; + return 0.5; case "quality": return 1; case "balanced": default: - return 0.75; + return 0.8; } } export function getSourceQualityBitrate(width: number, height: number): number { const totalPixels = width * height; - if (totalPixels > 2560 * 1440) { - return 80_000_000; + if (totalPixels <= HD_PIXELS) { + return 8_000_000; + } + if (totalPixels <= FULL_HD_PIXELS) { + return 12_000_000; } - if (totalPixels > 1920 * 1080) { - return 50_000_000; + if (totalPixels >= UHD_PIXELS) { + return 45_000_000; } - return 30_000_000; + return interpolateBitrate(totalPixels, FULL_HD_PIXELS, UHD_PIXELS, 12_000_000, 45_000_000); } function getBaseMp4ExportBitrate(width: number, height: number, quality: ExportQuality): number { @@ -33,13 +52,16 @@ function getBaseMp4ExportBitrate(width: number, height: number, quality: ExportQ } const totalPixels = width * height; - if (totalPixels <= 1280 * 720) { - return 10_000_000; + if (totalPixels <= HD_PIXELS) { + return 5_000_000; + } + if (totalPixels <= FULL_HD_PIXELS) { + return 8_000_000; } - if (totalPixels <= 1920 * 1080) { - return 20_000_000; + if (totalPixels >= UHD_PIXELS) { + return 35_000_000; } - return 30_000_000; + return interpolateBitrate(totalPixels, FULL_HD_PIXELS, UHD_PIXELS, 8_000_000, 35_000_000); } function getFrameRateBitrateMultiplier(frameRate: ExportMp4FrameRate): number { @@ -50,42 +72,6 @@ function getFrameRateBitrateMultiplier(frameRate: ExportMp4FrameRate): number { return Math.sqrt(Math.max(1, frameRate / REFERENCE_FRAME_RATE)); } -function getModernNativeStaticLayoutBitrateCap( - width: number, - height: number, - frameRate: ExportMp4FrameRate, - quality: ExportQuality, -): number { - const referenceCap = - quality === "source" - ? 36_000_000 - : quality === "high" - ? 28_000_000 - : quality === "good" - ? 20_000_000 - : 14_000_000; - const pixelRateScale = Math.max((width * height * frameRate) / REFERENCE_PIXEL_RATE, 0.1); - return Math.round(referenceCap * Math.sqrt(pixelRateScale)); -} - -function getModernNativeStaticLayoutBitrateFloor( - width: number, - height: number, - frameRate: ExportMp4FrameRate, - quality: ExportQuality, -): number { - const referenceFloor = - quality === "source" - ? 22_000_000 - : quality === "high" - ? 16_000_000 - : quality === "good" - ? 12_000_000 - : 8_000_000; - const pixelRateScale = Math.max((width * height * frameRate) / REFERENCE_PIXEL_RATE, 0.1); - return Math.round(referenceFloor * Math.sqrt(pixelRateScale)); -} - export function getMp4ExportBitrate(options: { width: number; height: number; @@ -99,29 +85,9 @@ export function getMp4ExportBitrate(options: { getFrameRateBitrateMultiplier(options.frameRate) * getEncodingModeBitrateMultiplier(options.encodingMode), ); - const nativeStaticLayoutBitrate = - options.useModernNativeStaticLayout && options.encodingMode !== "fast" - ? Math.max( - requestedBitrate, - getModernNativeStaticLayoutBitrateFloor( - options.width, - options.height, - options.frameRate, - options.quality, - ), - ) - : requestedBitrate; - const cappedBitrate = options.useModernNativeStaticLayout - ? Math.min( - nativeStaticLayoutBitrate, - getModernNativeStaticLayoutBitrateCap( - options.width, - options.height, - options.frameRate, - options.quality, - ), - ) - : requestedBitrate; - return Math.max(MIN_MP4_BITRATE, cappedBitrate); + // Keep every backend on the same delivery bitrate policy. Native static-layout + // exports previously applied a second set of floors and caps that could more + // than double the requested web-delivery target. + return Math.max(MIN_MP4_BITRATE, requestedBitrate); } From c74448fd3dfd0833d158495190313c10ea92b00b Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:25:28 +1000 Subject: [PATCH 3/6] Correct colour handling during recording Record captured frames with BT.709 conversion and explicit limited-range metadata. Share the Windows conversion code between capture engines and apply matching colour metadata on macOS and FFmpeg fallbacks. --- electron/ipc/recording/ffmpeg.ts | 10 +++ .../native/ScreenCaptureKitRecorder.swift | 5 ++ .../native/ScreenCaptureKitRecorder.test.ts | 9 +++ electron/native/common/bt709_video.h | 66 +++++++++++++++++++ .../native/wgc-capture/src/mf_encoder.cpp | 37 ++--------- .../native/windows-capture/src/mf_encoder.cpp | 37 ++--------- 6 files changed, 104 insertions(+), 60 deletions(-) create mode 100644 electron/native/common/bt709_video.h diff --git a/electron/ipc/recording/ffmpeg.ts b/electron/ipc/recording/ffmpeg.ts index dae48fc0a..0c38eb0b1 100644 --- a/electron/ipc/recording/ffmpeg.ts +++ b/electron/ipc/recording/ffmpeg.ts @@ -25,12 +25,22 @@ export function getDisplayWorkAreaForSource(source: SelectedSource) { export async function buildFfmpegCaptureArgs(source: SelectedSource, outputPath: string) { const commonOutputArgs = [ "-an", + "-vf", + "scale=in_range=full:out_range=tv:sws_dither=a_dither", "-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p", + "-colorspace", + "bt709", + "-color_primaries", + "bt709", + "-color_trc", + "bt709", + "-color_range", + "tv", "-movflags", "+faststart", outputPath, diff --git a/electron/native/ScreenCaptureKitRecorder.swift b/electron/native/ScreenCaptureKitRecorder.swift index ed3649ece..0d039dfbe 100644 --- a/electron/native/ScreenCaptureKitRecorder.swift +++ b/electron/native/ScreenCaptureKitRecorder.swift @@ -180,6 +180,11 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { outputSettings[AVVideoWidthKey] = outputWidth outputSettings[AVVideoHeightKey] = outputHeight + outputSettings[AVVideoColorPropertiesKey] = [ + AVVideoColorPrimariesKey: AVVideoColorPrimaries_ITU_R_709_2, + AVVideoTransferFunctionKey: AVVideoTransferFunction_ITU_R_709_2, + AVVideoYCbCrMatrixKey: AVVideoYCbCrMatrix_ITU_R_709_2, + ] let videoInput = AVAssetWriterInput(mediaType: .video, outputSettings: outputSettings) videoInput.expectsMediaDataInRealTime = true diff --git a/electron/native/ScreenCaptureKitRecorder.test.ts b/electron/native/ScreenCaptureKitRecorder.test.ts index 852641009..b2de7e594 100644 --- a/electron/native/ScreenCaptureKitRecorder.test.ts +++ b/electron/native/ScreenCaptureKitRecorder.test.ts @@ -40,3 +40,12 @@ describe("ScreenCaptureKitRecorder resume timing", () => { ); }); }); + +describe("ScreenCaptureKitRecorder colour metadata", () => { + it("tags recordings as BT.709", () => { + expect(recorderSource).toContain("AVVideoColorPropertiesKey"); + expect(recorderSource).toContain("AVVideoColorPrimaries_ITU_R_709_2"); + expect(recorderSource).toContain("AVVideoTransferFunction_ITU_R_709_2"); + expect(recorderSource).toContain("AVVideoYCbCrMatrix_ITU_R_709_2"); + }); +}); diff --git a/electron/native/common/bt709_video.h b/electron/native/common/bt709_video.h new file mode 100644 index 000000000..f4758dcce --- /dev/null +++ b/electron/native/common/bt709_video.h @@ -0,0 +1,66 @@ +#pragma once + +#include +#include +#include +#include + +inline int clampVideoSample(int value, int minimum, int maximum) { + return value < minimum ? minimum : (value > maximum ? maximum : value); +} + +inline HRESULT setBt709LimitedVideoAttributes(IMFMediaType* mediaType) { + HRESULT result = mediaType->SetUINT32(MF_MT_VIDEO_PRIMARIES, MFVideoPrimaries_BT709); + if (FAILED(result)) return result; + result = mediaType->SetUINT32(MF_MT_TRANSFER_FUNCTION, MFVideoTransFunc_709); + if (FAILED(result)) return result; + result = mediaType->SetUINT32(MF_MT_YUV_MATRIX, MFVideoTransferMatrix_BT709); + if (FAILED(result)) return result; + return mediaType->SetUINT32(MF_MT_VIDEO_NOMINAL_RANGE, MFNominalRange_16_235); +} + +inline void convertBgraToBt709LimitedNv12( + const uint8_t* bgra, + int bgraPitch, + int width, + int height, + std::vector& nv12Buffer) { + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) { + const uint8_t* pixel = bgra + y * bgraPitch + x * 4; + const int blue = pixel[0]; + const int green = pixel[1]; + const int red = pixel[2]; + const int luma = ((47 * red + 157 * green + 16 * blue + 128) >> 8) + 16; + nv12Buffer[y * width + x] = static_cast(clampVideoSample(luma, 16, 235)); + } + } + + uint8_t* uvPlane = nv12Buffer.data() + width * height; + for (int y = 0; y < height; y += 2) { + for (int x = 0; x < width; x += 2) { + int red = 0; + int green = 0; + int blue = 0; + for (int offsetY = 0; offsetY < 2; ++offsetY) { + for (int offsetX = 0; offsetX < 2; ++offsetX) { + const uint8_t* pixel = + bgra + (y + offsetY) * bgraPitch + (x + offsetX) * 4; + blue += pixel[0]; + green += pixel[1]; + red += pixel[2]; + } + } + red = (red + 2) / 4; + green = (green + 2) / 4; + blue = (blue + 2) / 4; + + // Coefficients sum to zero so neutral greys remain neutral after quantization. + const int chromaBlue = ((-26 * red - 87 * green + 113 * blue + 128) >> 8) + 128; + const int chromaRed = ((112 * red - 102 * green - 10 * blue + 128) >> 8) + 128; + const int uvIndex = (y / 2) * width + x; + uvPlane[uvIndex] = static_cast(clampVideoSample(chromaBlue, 16, 240)); + uvPlane[uvIndex + 1] = static_cast(clampVideoSample(chromaRed, 16, 240)); + } + } +} diff --git a/electron/native/wgc-capture/src/mf_encoder.cpp b/electron/native/wgc-capture/src/mf_encoder.cpp index 4875d0956..17b11effa 100644 --- a/electron/native/wgc-capture/src/mf_encoder.cpp +++ b/electron/native/wgc-capture/src/mf_encoder.cpp @@ -6,16 +6,13 @@ #include #include #include +#include "../../common/bt709_video.h" #pragma comment(lib, "mfplat.lib") #pragma comment(lib, "mfreadwrite.lib") #pragma comment(lib, "mf.lib") #pragma comment(lib, "mfuuid.lib") -static int clampByte(int v) { - return v < 0 ? 0 : (v > 255 ? 255 : v); -} - static UINT32 calculateScreenRecordingBitrate(int width, int height, int fps) { constexpr uint64_t kFourKPixels = 3840ULL * 2160ULL; constexpr uint64_t kQhdPixels = 2560ULL * 1440ULL; @@ -82,6 +79,8 @@ bool MFEncoder::initialize(const std::wstring& outputPath, int width, int height MFSetAttributeRatio(outputType.Get(), MF_MT_FRAME_RATE, fps_, 1); MFSetAttributeRatio(outputType.Get(), MF_MT_PIXEL_ASPECT_RATIO, 1, 1); outputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive); + hr = setBt709LimitedVideoAttributes(outputType.Get()); + if (FAILED(hr)) return false; std::cerr << "Encoder bitrate: " << videoBitrate << " bps for " << width_ << "x" << height_ << "@" << fps_ << "fps" << std::endl; @@ -96,6 +95,8 @@ bool MFEncoder::initialize(const std::wstring& outputPath, int width, int height MFSetAttributeRatio(inputType.Get(), MF_MT_FRAME_RATE, fps_, 1); MFSetAttributeRatio(inputType.Get(), MF_MT_PIXEL_ASPECT_RATIO, 1, 1); inputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive); + hr = setBt709LimitedVideoAttributes(inputType.Get()); + if (FAILED(hr)) return false; // Create SinkWriter with MPEG4 container ComPtr writerAttrs; @@ -225,34 +226,10 @@ bool MFEncoder::writeFrame(ID3D11Texture2D* texture, int64_t timestampHns) { HRESULT hr = context_->Map(stagingTexture_.Get(), 0, D3D11_MAP_READ, 0, &mapped); if (FAILED(hr)) return false; - // Convert BGRA → NV12 + // Convert full-range desktop BGRA to explicitly tagged BT.709 video-range NV12. const uint8_t* bgra = static_cast(mapped.pData); const int bgraPitch = static_cast(mapped.RowPitch); - - // Y plane - for (int y = 0; y < height_; y++) { - for (int x = 0; x < width_; x++) { - const uint8_t* pixel = bgra + y * bgraPitch + x * 4; - uint8_t b = pixel[0], g = pixel[1], r = pixel[2]; - int yVal = ((66 * r + 129 * g + 25 * b + 128) >> 8) + 16; - nv12Buffer_[y * width_ + x] = static_cast(clampByte(yVal)); - } - } - - // UV plane (interleaved, subsampled 2x2) - const int ySize = width_ * height_; - uint8_t* uvPlane = nv12Buffer_.data() + ySize; - for (int y = 0; y < height_; y += 2) { - for (int x = 0; x < width_; x += 2) { - const uint8_t* pixel = bgra + y * bgraPitch + x * 4; - uint8_t b = pixel[0], g = pixel[1], r = pixel[2]; - int u = ((-38 * r - 74 * g + 112 * b + 128) >> 8) + 128; - int v = ((112 * r - 94 * g - 18 * b + 128) >> 8) + 128; - int uvIdx = (y / 2) * width_ + (x / 2) * 2; - uvPlane[uvIdx] = static_cast(clampByte(u)); - uvPlane[uvIdx + 1] = static_cast(clampByte(v)); - } - } + convertBgraToBt709LimitedNv12(bgra, bgraPitch, width_, height_, nv12Buffer_); context_->Unmap(stagingTexture_.Get(), 0); diff --git a/electron/native/windows-capture/src/mf_encoder.cpp b/electron/native/windows-capture/src/mf_encoder.cpp index a1474c20d..bfd62f33b 100644 --- a/electron/native/windows-capture/src/mf_encoder.cpp +++ b/electron/native/windows-capture/src/mf_encoder.cpp @@ -4,16 +4,13 @@ #include #include #include +#include "../../common/bt709_video.h" #pragma comment(lib, "mfplat.lib") #pragma comment(lib, "mfreadwrite.lib") #pragma comment(lib, "mf.lib") #pragma comment(lib, "mfuuid.lib") -static int clampByte(int v) { - return v < 0 ? 0 : (v > 255 ? 255 : v); -} - MFEncoder::MFEncoder() {} MFEncoder::~MFEncoder() { @@ -53,6 +50,8 @@ bool MFEncoder::initialize(const std::wstring& outputPath, int width, int height MFSetAttributeRatio(outputType.Get(), MF_MT_FRAME_RATE, fps_, 1); MFSetAttributeRatio(outputType.Get(), MF_MT_PIXEL_ASPECT_RATIO, 1, 1); outputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive); + hr = setBt709LimitedVideoAttributes(outputType.Get()); + if (FAILED(hr)) return false; // Input media type (NV12) ComPtr inputType; @@ -65,6 +64,8 @@ bool MFEncoder::initialize(const std::wstring& outputPath, int width, int height MFSetAttributeRatio(inputType.Get(), MF_MT_FRAME_RATE, fps_, 1); MFSetAttributeRatio(inputType.Get(), MF_MT_PIXEL_ASPECT_RATIO, 1, 1); inputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive); + hr = setBt709LimitedVideoAttributes(inputType.Get()); + if (FAILED(hr)) return false; // Create SinkWriter with MPEG4 container ComPtr writerAttrs; @@ -132,34 +133,10 @@ bool MFEncoder::writeFrame(ID3D11Texture2D* texture, int64_t timestampHns) { HRESULT hr = context_->Map(stagingTexture_.Get(), 0, D3D11_MAP_READ, 0, &mapped); if (FAILED(hr)) return false; - // Convert BGRA → NV12 + // Convert full-range desktop BGRA to explicitly tagged BT.709 video-range NV12. const uint8_t* bgra = static_cast(mapped.pData); const int bgraPitch = static_cast(mapped.RowPitch); - - // Y plane - for (int y = 0; y < height_; y++) { - for (int x = 0; x < width_; x++) { - const uint8_t* pixel = bgra + y * bgraPitch + x * 4; - uint8_t b = pixel[0], g = pixel[1], r = pixel[2]; - int yVal = ((66 * r + 129 * g + 25 * b + 128) >> 8) + 16; - nv12Buffer_[y * width_ + x] = static_cast(clampByte(yVal)); - } - } - - // UV plane (interleaved, subsampled 2x2) - const int ySize = width_ * height_; - uint8_t* uvPlane = nv12Buffer_.data() + ySize; - for (int y = 0; y < height_; y += 2) { - for (int x = 0; x < width_; x += 2) { - const uint8_t* pixel = bgra + y * bgraPitch + x * 4; - uint8_t b = pixel[0], g = pixel[1], r = pixel[2]; - int u = ((-38 * r - 74 * g + 112 * b + 128) >> 8) + 128; - int v = ((112 * r - 94 * g - 18 * b + 128) >> 8) + 128; - int uvIdx = (y / 2) * width_ + (x / 2) * 2; - uvPlane[uvIdx] = static_cast(clampByte(u)); - uvPlane[uvIdx + 1] = static_cast(clampByte(v)); - } - } + convertBgraToBt709LimitedNv12(bgra, bgraPitch, width_, height_, nv12Buffer_); context_->Unmap(stagingTexture_.Get(), 0); From 358e86c31dfd555919b843537242a9ce97fe33b6 Mon Sep 17 00:00:00 2001 From: young Date: Mon, 31 Aug 2026 13:17:55 +1000 Subject: [PATCH 4/6] Make motion blur streakier and simplify its controls --- src/components/video-editor/SettingsPanel.tsx | 148 ------------------ src/components/video-editor/VideoEditor.tsx | 12 -- src/components/video-editor/audio.test.ts | 4 +- .../video-editor/cursorMotionPresets.ts | 10 +- .../video-editor/projectPersistence.ts | 6 +- .../videoPlayback/cursorRenderer.ts | 2 +- .../videoPlayback/zoomTransform.test.ts | 45 +++++- .../videoPlayback/zoomTransform.ts | 29 ++-- 8 files changed, 69 insertions(+), 187 deletions(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 81c934d5f..5146aa1d0 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -22,10 +22,6 @@ import { Switch } from "@/components/ui/switch"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { useTheme } from "@/contexts/ThemeContext"; import { getAssetPath, getRenderableVideoUrl, getWallpaperThumbnailUrl } from "@/lib/assetPath"; -import { - TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT, - TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION, -} from "@/lib/exporter/temporalMotionBlur"; import { cn } from "@/lib/utils"; import type { BuiltInWallpaper } from "@/lib/wallpapers"; import { @@ -64,7 +60,6 @@ import type { WebcamPositionPreset, ZoomDepth, ZoomMode, - ZoomMotionBlurTuning, ZoomTransitionEasing, } from "./types"; import { @@ -78,7 +73,6 @@ import { DEFAULT_CURSOR_CLICK_EFFECT_DURATION_MS, DEFAULT_CURSOR_CLICK_EFFECT_OPACITY, DEFAULT_CURSOR_CLICK_EFFECT_SCALE, - DEFAULT_CURSOR_MOTION_BLUR, DEFAULT_CURSOR_SIZE, DEFAULT_CURSOR_STYLE, DEFAULT_CURSOR_SWAY, @@ -92,7 +86,6 @@ import { DEFAULT_WEBCAM_SHADOW, DEFAULT_WEBCAM_SIZE, DEFAULT_ZOOM_IN_DURATION_MS, - DEFAULT_ZOOM_MOTION_BLUR_TUNING, DEFAULT_ZOOM_OUT_DURATION_MS, } from "./types"; import { fromCursorSwaySliderValue, toCursorSwaySliderValue } from "./videoPlayback/cursorSway"; @@ -548,14 +541,6 @@ interface SettingsPanelProps { onShadowChange?: (intensity: number) => void; backgroundBlur?: number; onBackgroundBlurChange?: (amount: number) => void; - zoomMotionBlurTuning?: ZoomMotionBlurTuning; - onZoomMotionBlurTuningChange?: (tuning: ZoomMotionBlurTuning) => void; - zoomTemporalMotionBlur?: number; - onZoomTemporalMotionBlurChange?: (amount: number) => void; - zoomMotionBlurSampleCount?: number | null; - onZoomMotionBlurSampleCountChange?: (count: number | null) => void; - zoomMotionBlurShutterFraction?: number | null; - onZoomMotionBlurShutterFractionChange?: (fraction: number | null) => void; connectZooms?: boolean; onConnectZoomsChange?: (enabled: boolean) => void; autoApplyFreshRecordingAutoZooms?: boolean; @@ -600,8 +585,6 @@ interface SettingsPanelProps { onCameraSpringMassMultiplierChange?: (multiplier: number) => void; zoomClassicMode?: boolean; onZoomClassicModeChange?: (enabled: boolean) => void; - cursorMotionBlur?: number; - onCursorMotionBlurChange?: (amount: number) => void; cursorClickEffect?: CursorClickEffectStyle; onCursorClickEffectChange?: (effect: CursorClickEffectStyle) => void; cursorClickEffectColor?: string; @@ -1009,8 +992,6 @@ export function SettingsPanel({ onShadowChange, backgroundBlur = 0, onBackgroundBlurChange, - zoomMotionBlurTuning = DEFAULT_ZOOM_MOTION_BLUR_TUNING, - onZoomMotionBlurTuningChange, connectZooms = true, onConnectZoomsChange, autoApplyFreshRecordingAutoZooms = true, @@ -1043,8 +1024,6 @@ export function SettingsPanel({ onCameraSpringMassMultiplierChange, zoomClassicMode = false, onZoomClassicModeChange, - cursorMotionBlur = DEFAULT_CURSOR_MOTION_BLUR, - onCursorMotionBlurChange, cursorClickEffect = DEFAULT_CURSOR_CLICK_EFFECT, onCursorClickEffectChange, cursorClickEffectColor = DEFAULT_CURSOR_CLICK_EFFECT_COLOR, @@ -1546,7 +1525,6 @@ export function SettingsPanel({ }; const resetZoomSection = () => { - onZoomMotionBlurTuningChange?.(initialEditorPreferences.zoomMotionBlurTuning); onCameraSpringStiffnessMultiplierChange?.( initialEditorPreferences.cameraSpringStiffnessMultiplier, ); @@ -1572,7 +1550,6 @@ export function SettingsPanel({ initialEditorPreferences.cursorSpringDampingMultiplier, ); onCursorSpringMassMultiplierChange?.(initialEditorPreferences.cursorSpringMassMultiplier); - onCursorMotionBlurChange?.(initialEditorPreferences.cursorMotionBlur); onCursorClickEffectChange?.(initialEditorPreferences.cursorClickEffect); onCursorClickEffectColorChange?.(initialEditorPreferences.cursorClickEffectColor); onCursorClickEffectScaleChange?.(initialEditorPreferences.cursorClickEffectScale); @@ -1593,7 +1570,6 @@ export function SettingsPanel({ cursorSpringStiffnessMultiplier, cursorSpringDampingMultiplier, cursorSpringMassMultiplier, - cursorMotionBlur, cursorClickBounce, cursorClickBounceDuration, }) ?? "focused" @@ -1601,7 +1577,6 @@ export function SettingsPanel({ }, [ cursorClickBounce, cursorClickBounceDuration, - cursorMotionBlur, cursorSize, cursorSmoothing, cursorSpringDampingMultiplier, @@ -1620,7 +1595,6 @@ export function SettingsPanel({ onCursorSpringStiffnessMultiplierChange?.(preset.cursorSpringStiffnessMultiplier); onCursorSpringDampingMultiplierChange?.(preset.cursorSpringDampingMultiplier); onCursorSpringMassMultiplierChange?.(preset.cursorSpringMassMultiplier); - onCursorMotionBlurChange?.(preset.cursorMotionBlur); onCursorClickBounceChange?.(preset.cursorClickBounce); onCursorClickBounceDurationChange?.(preset.cursorClickBounceDuration); }; @@ -2719,104 +2693,6 @@ export function SettingsPanel({
-
-
-
- {tSettings("effects.motionBlurDebug", "Motion Blur Debug")} -
-
- {tSettings( - "effects.motionBlurDebugHint", - "Development-only tuning for the split move-vs-zoom blur path. Pan controls drive the streak filter, and zoom controls drive the focus-centered zoom filter.", - )} -
-
- - onZoomMotionBlurTuningChange?.({ - ...zoomMotionBlurTuning, - panVelocityThreshold: value, - }) - } - formatValue={(value) => `${Math.round(value)} px/s`} - parseInput={(text) => - parseFloat(text.replace(/px\/s$/i, "").trim()) - } - /> - - onZoomMotionBlurTuningChange?.({ - ...zoomMotionBlurTuning, - maxDirectionalBlurPx: value, - }) - } - formatValue={(value) => `${value.toFixed(1)} px`} - parseInput={(text) => parseFloat(text.replace(/px$/i, "").trim())} - /> - - onZoomMotionBlurTuningChange?.({ - ...zoomMotionBlurTuning, - zoomVelocityThreshold: value, - }) - } - formatValue={(value) => value.toFixed(3)} - parseInput={(text) => parseFloat(text)} - /> - - onZoomMotionBlurTuningChange?.({ - ...zoomMotionBlurTuning, - maxRadialBlurStrength: value, - }) - } - formatValue={(value) => value.toFixed(3)} - parseInput={(text) => parseFloat(text)} - /> -
-
@@ -3061,19 +2937,6 @@ export function SettingsPanel({ )}
)} - {showDevMotionControls ? ( -
-
- {tSettings( - "effects.exportBlurMovedToDev", - "Export blur tuning is available in Settings > Dev.", - )} -
-
- {`${TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT} samples · ${Math.round(TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION * 100)}% shutter`} -
-
- ) : null} {selectedZoomId && (