Make the export experience clearer and more useful (#857)

Hide the legacy pipeline choice while retaining the internal fallback, and improve Lightning exports with rotating localised tips and announcement messages.
This commit is contained in:
webadderall
2026-09-02 20:13:54 +10:00
committed by GitHub
23 changed files with 279 additions and 104 deletions
@@ -102,7 +102,8 @@ export function AnnouncementDialog({ audience }: { audience: AnnouncementAudienc
}).filter(
(announcement) =>
announcement.presentation !== "notification" &&
announcement.presentation !== "banner",
announcement.presentation !== "banner" &&
announcement.presentation !== "export",
);
setPopupAspectRatio(
parsedRemoteFeed.settings.aspectRatio ??
@@ -25,7 +25,6 @@ interface ExportSettingsMenuProps {
mp4FrameRate: ExportMp4FrameRate;
onMp4FrameRateChange?: (frameRate: ExportMp4FrameRate) => void;
exportPipelineModel?: ExportPipelineModel;
onExportPipelineModelChange?: (pipelineModel: ExportPipelineModel) => void;
experimentalNvidiaCudaExport?: boolean;
onExperimentalNvidiaCudaExportChange?: (enabled: boolean) => void;
nvidiaCudaExportAvailable?: boolean;
@@ -54,7 +53,6 @@ export function ExportSettingsMenu({
mp4FrameRate,
onMp4FrameRateChange,
exportPipelineModel = "modern",
onExportPipelineModelChange,
experimentalNvidiaCudaExport = false,
onExperimentalNvidiaCudaExportChange,
nvidiaCudaExportAvailable = false,
@@ -281,69 +279,6 @@ export function ExportSettingsMenu({
);
})}
</div>
<div className="mb-1 flex items-center justify-between px-1">
<span className="text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground/70">
{tSettings("export.pipelineTitle", "Pipeline")}
</span>
</div>
<div className="mb-3 grid min-h-10 w-full grid-cols-2 rounded-xl border border-foreground/5 bg-foreground/5 p-0.5">
{(
[
{
value: "legacy",
label: tSettings("export.pipeline.legacy", "Legacy"),
},
{
value: "modern",
label: tSettings("export.pipeline.modern", "Lightning (Beta)"),
},
] as const
).map((option) => {
const isActive = exportPipelineModel === option.value;
return (
<button
key={option.value}
type="button"
onClick={() => onExportPipelineModelChange?.(option.value)}
aria-pressed={isActive}
className="relative rounded-lg px-1 py-1 text-[11px] font-medium transition-colors"
>
{isActive ? (
<motion.span
layoutId="header-export-pipeline-pill"
className="absolute inset-0 rounded-lg bg-neutral-800 dark:bg-white"
transition={{
type: "spring",
stiffness: 420,
damping: 34,
}}
/>
) : null}
<span
className={cn(
"relative z-10",
isActive
? "text-white dark:text-black"
: "text-muted-foreground hover:text-foreground",
)}
>
{option.label}
</span>
</button>
);
})}
</div>
<p className="mb-3 px-1 text-[10px] text-muted-foreground/70">
{isLegacyModel
? tSettings(
"export.pipeline.legacyHint",
"Legacy uses the current stable WebCodecs export path.",
)
: tSettings(
"export.pipeline.lightningHint",
"Lightning (Beta) automatically uses the fastest compatible backend and falls back when needed.",
)}
</p>
{!isLegacyModel && nvidiaCudaExportAvailable ? (
<div className="mb-3 flex min-h-12 items-center justify-between gap-3 rounded-lg border border-[#2563EB]/20 bg-[#2563EB]/5 px-3 py-2">
<div className="min-w-0">
@@ -97,6 +97,12 @@ describe("editorPreferences", () => {
expect(DEFAULT_EDITOR_PREFERENCES.exportPipelineModel).toBe("modern");
});
it("migrates hidden legacy pipeline preferences to Lightning", () => {
expect(normalizeEditorPreferences({ exportPipelineModel: "legacy" })).toMatchObject({
exportPipelineModel: "modern",
});
});
it("bakes in the stronger split motion blur defaults", () => {
expect(DEFAULT_EDITOR_PREFERENCES.zoomMotionBlurTuning).toMatchObject({
panVelocityThreshold: 0,
@@ -1,15 +1,14 @@
import type { RefObject } from "react";
import { useCallback } from "react";
import type { useI18n } from "@/contexts/I18nContext";
import type { useVideoEditorAudio } from "../audio/useVideoEditorAudio";
import type { getSmokeExportConfig } from "../smokeExportConfig";
import type { useAppearanceState } from "../state/useAppearanceState";
import type { useTimelineState } from "../state/useTimelineState";
import { openExternalLink, RECORDLY_ISSUES_URL } from "../TutorialHelp";
import type { CursorTelemetryPoint, SpeedRegion, ZoomRegion } from "../types";
import type { VideoPlaybackRef } from "../VideoPlayback";
import { useExportDialogActions } from "./useExportDialogActions";
import type { useExportDimensions } from "./useExportDimensions";
import { useExportMessages } from "./useExportMessages";
import { useExportRunner } from "./useExportRunner";
import type { useExportSession } from "./useExportSession";
import type { useExportSettings } from "./useExportSettings";
@@ -91,12 +90,10 @@ export function useEditorExportController(input: Input) {
session: input.session,
settings: input.settings,
});
const openLightningIssues = useCallback(async () => {
await openExternalLink(
RECORDLY_ISSUES_URL,
input.t("editor.feedback.openFailed", "Failed to open link."),
);
}, [input.t]);
const exportMessage = useExportMessages({
t: input.t,
active: status.isLightningExportInProgress,
});
return { dialogActions, status, openLightningIssues };
return { dialogActions, status, exportMessage };
}
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { buildExportMessageStream, type ExportMessage } from "./useExportMessages";
function message(id: string): ExportMessage {
return { id, text: id, durationSeconds: 6 };
}
describe("buildExportMessageStream", () => {
it("alternates built-in tips with streamed announcements", () => {
expect(
buildExportMessageStream(
[message("tip-1"), message("tip-2"), message("tip-3")],
[message("announcement-1"), message("announcement-2")],
).map(({ id }) => id),
).toEqual(["tip-1", "announcement-1", "tip-2", "announcement-2", "tip-3"]);
});
it("uses only built-in tips when the announcement feed is empty", () => {
expect(
buildExportMessageStream([message("tip-1"), message("tip-2")], []).map(({ id }) => id),
).toEqual(["tip-1", "tip-2"]);
});
});
@@ -0,0 +1,141 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { BUNDLED_ANNOUNCEMENT_FEED } from "@/content/announcements";
import type { useI18n } from "@/contexts/I18nContext";
import {
readAnnouncementImpressionCounts,
readDismissedAnnouncementIds,
recordAnnouncementImpression,
} from "@/lib/announcementState";
import type { Announcement } from "@/lib/announcements";
import { parseAnnouncementFeed, selectAnnouncements } from "@/lib/announcements";
const DEFAULT_MESSAGE_DURATION_SECONDS = 6;
const MAX_EXPORT_MESSAGE_LENGTH = 220;
export type ExportMessage = {
id: string;
text: string;
durationSeconds: number;
announcementId?: string;
};
function compactMessage(value: string): string {
const compacted = value.replace(/\s+/g, " ").trim();
if (compacted.length <= MAX_EXPORT_MESSAGE_LENGTH) return compacted;
return `${compacted.slice(0, MAX_EXPORT_MESSAGE_LENGTH - 1).trimEnd()}…`;
}
export function buildExportMessageStream(
tips: ExportMessage[],
announcements: ExportMessage[],
): ExportMessage[] {
const stream: ExportMessage[] = [];
const length = Math.max(tips.length, announcements.length);
for (let index = 0; index < length; index += 1) {
if (tips[index]) stream.push(tips[index]);
if (announcements[index]) stream.push(announcements[index]);
}
return stream;
}
function toExportMessage(announcement: Announcement): ExportMessage {
return {
id: `announcement:${announcement.id}`,
announcementId: announcement.id,
text: compactMessage(`${announcement.title} — ${announcement.body}`),
durationSeconds: announcement.displayDurationSeconds ?? DEFAULT_MESSAGE_DURATION_SECONDS,
};
}
export function useExportMessages({
t,
active,
}: {
t: ReturnType<typeof useI18n>["t"];
active: boolean;
}): string | null {
const [announcementMessages, setAnnouncementMessages] = useState<ExportMessage[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const countedThisSessionRef = useRef(new Set<string>());
const tips = useMemo<ExportMessage[]>(
() => [
{
id: "tip:auto-zooms",
text: t(
"editor.exportTips.autoZooms",
"Tip: Turn off auto-applied zooms in settings",
),
durationSeconds: DEFAULT_MESSAGE_DURATION_SECONDS,
},
{
id: "tip:experimental-builds",
text: t(
"editor.exportTips.experimentalBuilds",
"Tip: Try experimental builds by turning on access in settings",
),
durationSeconds: DEFAULT_MESSAGE_DURATION_SECONDS,
},
{
id: "tip:cursor-appearance",
text: t(
"editor.exportTips.cursorAppearance",
"Tip: You can customise your cursor appearance",
),
durationSeconds: DEFAULT_MESSAGE_DURATION_SECONDS,
},
],
[t],
);
const messages = useMemo(
() => buildExportMessageStream(tips, announcementMessages),
[tips, announcementMessages],
);
const current = messages[currentIndex % messages.length];
useEffect(() => {
let cancelled = false;
const loadExportAnnouncements = 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;
setAnnouncementMessages(
selectAnnouncements({
bundled: BUNDLED_ANNOUNCEMENT_FEED.announcements,
remote: parseAnnouncementFeed(remoteFeed).announcements,
dismissedIds,
impressionCounts,
appVersion,
audience: "editor",
})
.filter((announcement) => announcement.presentation === "export")
.map(toExportMessage),
);
};
void loadExportAnnouncements();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!active || messages.length < 2 || !current) return;
const timeout = window.setTimeout(
() => setCurrentIndex((index) => (index + 1) % messages.length),
current.durationSeconds * 1_000,
);
return () => window.clearTimeout(timeout);
}, [active, current, messages.length]);
useEffect(() => {
const announcementId = active ? current?.announcementId : undefined;
if (!announcementId || countedThisSessionRef.current.has(announcementId)) return;
countedThisSessionRef.current.add(announcementId);
recordAnnouncementImpression(announcementId);
}, [active, current]);
return active ? (current?.text ?? null) : null;
}
@@ -28,7 +28,7 @@ type Props = {
handleRetrySaveExport: () => void;
handleStartExportFromDropdown: () => void;
revealExportedFile: () => void;
openLightningIssues: () => void;
exportMessage: string | null;
};
export function EditorExportMenu(props: Props) {
@@ -48,7 +48,7 @@ export function EditorExportMenu(props: Props) {
handleRetrySaveExport,
handleStartExportFromDropdown,
revealExportedFile,
openLightningIssues,
exportMessage,
} = props;
const {
exportQuality,
@@ -56,7 +56,6 @@ export function EditorExportMenu(props: Props) {
exportEncodingMode,
setExportEncodingMode,
exportPipelineModel,
setExportPipelineModel,
mp4FrameRate,
setMp4FrameRate,
exportFormat,
@@ -123,18 +122,9 @@ export function EditorExportMenu(props: Props) {
<p className="text-xs text-muted-foreground">
{t("editor.exportStatus.renderingFile", "Rendering your file.")}
</p>
{isLightningExportInProgress ? (
<p className="mt-1 flex items-center gap-1 text-[11px] text-muted-foreground/70">
PLEASE{" "}
<button
type="button"
onClick={() => void openLightningIssues()}
className="underline decoration-slate-500/70 underline-offset-2 transition-colors hover:text-foreground"
>
report bugs
</button>
with Lightning export{" "}
<span aria-hidden="true">{"\u{1F64F}"}</span>
{isLightningExportInProgress && exportMessage ? (
<p className="mt-1 text-[11px] leading-relaxed text-muted-foreground/70">
{exportMessage}
</p>
) : null}
{isLegacyExportInProgress ? (
@@ -269,7 +259,6 @@ export function EditorExportMenu(props: Props) {
mp4FrameRate={mp4FrameRate}
onMp4FrameRateChange={setMp4FrameRate}
exportPipelineModel={exportPipelineModel}
onExportPipelineModelChange={setExportPipelineModel}
experimentalNvidiaCudaExport={
experimentalNvidiaCudaExport && nvidiaCudaExportAvailable
}
@@ -46,7 +46,7 @@ type Props = {
handleRetrySaveExport: () => void;
handleStartExportFromDropdown: () => void;
revealExportedFile: () => void;
openLightningIssues: () => void;
exportMessage: string | null;
};
export function EditorHeader(props: Props) {
@@ -80,7 +80,7 @@ export function EditorHeader(props: Props) {
handleRetrySaveExport,
handleStartExportFromDropdown,
revealExportedFile,
openLightningIssues,
exportMessage,
} = props;
const {
isEditingProjectName,
@@ -219,7 +219,7 @@ export function EditorHeader(props: Props) {
handleRetrySaveExport={handleRetrySaveExport}
handleStartExportFromDropdown={handleStartExportFromDropdown}
revealExportedFile={revealExportedFile}
openLightningIssues={openLightningIssues}
exportMessage={exportMessage}
/>
</div>
</div>
@@ -89,7 +89,7 @@ export function EditorShell(props: Props) {
handleSelectAnnotation,
handleAutoSuggestZoomsConsumed,
} = editing;
const { dialogActions, status: exportStatus, openLightningIssues } = exportController;
const { dialogActions, status: exportStatus, exportMessage } = exportController;
const editorDialogs = (
<EditorDialogs
t={t}
@@ -176,7 +176,7 @@ export function EditorShell(props: Props) {
handleRetrySaveExport={dialogActions.handleRetrySaveExport}
handleStartExportFromDropdown={dialogActions.handleStartExportFromDropdown}
revealExportedFile={dialogActions.revealExportedFile}
openLightningIssues={openLightningIssues}
exportMessage={exportMessage}
/>
<EditorAnnouncementBanner />
<div className="relative flex min-h-0 flex-1 flex-col gap-3 p-4">
@@ -197,11 +197,9 @@ export function normalizeExportBackendPreference(value: unknown): ExportBackendP
return "auto";
}
export function normalizeExportPipelineModel(value: unknown): ExportPipelineModel {
if (value === "modern" || value === "legacy") {
return value;
}
export function normalizeExportPipelineModel(_value: unknown): ExportPipelineModel {
// Legacy remains available to internal smoke/export routing, but persisted
// user selections migrate to the only pipeline exposed by the editor UI.
return "modern";
}
+5
View File
@@ -117,6 +117,11 @@
"discardChanges": "Änderungen verwerfen",
"saveProject": "Projekt speichern"
},
"exportTips": {
"autoZooms": "Tipp: Automatisch angewendete Zooms können in den Einstellungen deaktiviert werden",
"experimentalBuilds": "Tipp: Aktiviere den Zugriff auf experimentelle Builds in den Einstellungen",
"cursorAppearance": "Tipp: Du kannst das Aussehen deines Cursors anpassen"
},
"account": { "title": "Konto", "comingSoon": "Konto demnächst verfügbar" },
"nativeCaptureUnavailable": {
"title": "Es ist nichts kaputt, aber wir können kein animiertes Cursor-Overlay rendern.",
+5
View File
@@ -118,6 +118,11 @@
"discardChanges": "Discard changes",
"saveProject": "Save project"
},
"exportTips": {
"autoZooms": "Tip: Turn off auto-applied zooms in settings",
"experimentalBuilds": "Tip: Try experimental builds by turning on access in settings",
"cursorAppearance": "Tip: You can customise your cursor appearance"
},
"account": { "title": "Account", "comingSoon": "Account coming soon" },
"nativeCaptureUnavailable": {
"title": "Nothing’s broken, but we won’t be able to render an animated cursor overlay.",
+5
View File
@@ -118,6 +118,11 @@
"discardChanges": "Descartar cambios",
"saveProject": "Guardar proyecto"
},
"exportTips": {
"autoZooms": "Consejo: Desactiva los zooms automáticos en la configuración",
"experimentalBuilds": "Consejo: Activa el acceso a versiones experimentales en la configuración",
"cursorAppearance": "Consejo: Puedes personalizar la apariencia del cursor"
},
"account": { "title": "Cuenta", "comingSoon": "Cuenta próximamente" },
"nativeCaptureUnavailable": {
"title": "Nada está roto, pero no podremos renderizar una superposición de cursor animada.",
+5
View File
@@ -118,6 +118,11 @@
"discardChanges": "Ignorer les modifications",
"saveProject": "Enregistrer le projet"
},
"exportTips": {
"autoZooms": "Astuce : Désactivez les zooms automatiques dans les paramètres",
"experimentalBuilds": "Astuce : Activez l’accès aux versions expérimentales dans les paramètres",
"cursorAppearance": "Astuce : Vous pouvez personnaliser l’apparence du curseur"
},
"account": { "title": "Compte", "comingSoon": "Compte bientôt disponible" },
"nativeCaptureUnavailable": {
"title": "Rien n'est cassé, mais nous ne pourrons pas afficher une superposition animée du curseur.",
+5
View File
@@ -118,6 +118,11 @@
"discardChanges": "Ignora modifiche",
"saveProject": "Salva progetto"
},
"exportTips": {
"autoZooms": "Suggerimento: Disattiva gli zoom applicati automaticamente nelle impostazioni",
"experimentalBuilds": "Suggerimento: Attiva l’accesso alle build sperimentali nelle impostazioni",
"cursorAppearance": "Suggerimento: Puoi personalizzare l’aspetto del cursore"
},
"account": { "title": "Account", "comingSoon": "Account in arrivo" },
"nativeCaptureUnavailable": {
"title": "Niente è rotto, ma non sarà possibile renderizzare un overlay del cursore animato.",
+5
View File
@@ -119,6 +119,11 @@
"discardChanges": "변경 사항 버리기",
"saveProject": "프로젝트 저장"
},
"exportTips": {
"autoZooms": "팁: 설정에서 자동 적용 확대를 끌 수 있습니다",
"experimentalBuilds": "팁: 설정에서 실험적 빌드 액세스를 켜 보세요",
"cursorAppearance": "팁: 커서 모양을 사용자 지정할 수 있습니다"
},
"account": { "title": "계정", "comingSoon": "계정 기능 준비 중" },
"nativeCaptureUnavailable": {
"title": "문제가 생긴 것은 아니지만, 애니메이션 커서 오버레이를 렌더링할 수 없습니다.",
+5
View File
@@ -119,6 +119,11 @@
"discardChanges": "Wijzigingen negeren",
"saveProject": "Project opslaan"
},
"exportTips": {
"autoZooms": "Tip: Schakel automatisch toegepaste zooms uit in de instellingen",
"experimentalBuilds": "Tip: Schakel toegang tot experimentele builds in via de instellingen",
"cursorAppearance": "Tip: Je kunt het uiterlijk van je cursor aanpassen"
},
"account": { "title": "Account", "comingSoon": "Account binnenkort beschikbaar" },
"nativeCaptureUnavailable": {
"title": "Er is niets kapot, maar we kunnen geen geanimeerde cursor-overlay renderen.",
+5
View File
@@ -118,6 +118,11 @@
"discardChanges": "Descartar alterações",
"saveProject": "Salvar projeto"
},
"exportTips": {
"autoZooms": "Dica: Desative os zooms aplicados automaticamente nas configurações",
"experimentalBuilds": "Dica: Ative o acesso a versões experimentais nas configurações",
"cursorAppearance": "Dica: Você pode personalizar a aparência do cursor"
},
"account": { "title": "Conta", "comingSoon": "Conta em breve" },
"nativeCaptureUnavailable": {
"title": "Nada está quebrado, mas não poderemos renderizar uma sobreposição animada do cursor.",
+5
View File
@@ -118,6 +118,11 @@
"discardChanges": "Отменить изменения",
"saveProject": "Сохранить проект"
},
"exportTips": {
"autoZooms": "Совет: Автоматические приближения можно отключить в настройках",
"experimentalBuilds": "Совет: Включите доступ к экспериментальным сборкам в настройках",
"cursorAppearance": "Совет: Внешний вид курсора можно настроить"
},
"account": { "title": "Учётная запись", "comingSoon": "Учётная запись скоро появится" },
"nativeCaptureUnavailable": {
"title": "Всё в порядке, но мы не можем отобразить анимированное наложение курсора.",
+5
View File
@@ -118,6 +118,11 @@
"discardChanges": "放弃更改",
"saveProject": "保存项目"
},
"exportTips": {
"autoZooms": "提示:可在设置中关闭自动应用的缩放",
"experimentalBuilds": "提示:可在设置中开启实验版本访问权限",
"cursorAppearance": "提示:你可以自定义光标外观"
},
"account": { "title": "账户", "comingSoon": "账户功能即将推出" },
"nativeCaptureUnavailable": {
"title": "没有出错,但我们无法渲染动画光标叠加层。",
+5
View File
@@ -118,6 +118,11 @@
"discardChanges": "捨棄變更",
"saveProject": "儲存專案"
},
"exportTips": {
"autoZooms": "提示:可在設定中關閉自動套用的縮放",
"experimentalBuilds": "提示:可在設定中開啟實驗版本存取權",
"cursorAppearance": "提示:你可以自訂游標外觀"
},
"account": { "title": "帳號", "comingSoon": "帳號功能即將推出" },
"nativeCaptureUnavailable": {
"title": "沒有出錯,但我們無法轉譯動畫游標覆蓋層。",
+27
View File
@@ -116,6 +116,33 @@ describe("parseAnnouncementFeed", () => {
]);
});
it("accepts text-only messages for the export status stream", () => {
const feed = parseAnnouncementFeed({
announcements: [
{
id: "export-tip-1",
title: "Did you know?",
body: "Cursor styles can be changed in the editor.",
presentation: "export",
media: { type: "image", url: "https://example.com/ignored.jpg" },
displayDurationSeconds: 8,
},
],
});
expect(feed.announcements).toEqual([
{
id: "export-tip-1",
title: "Did you know?",
body: "Cursor styles can be changed in the editor.",
presentation: "export",
audience: "all",
priority: 0,
displayDurationSeconds: 8,
},
]);
});
it("accepts an action that opens a safe editor section", () => {
const feed = parseAnnouncementFeed({
announcements: [
+6 -3
View File
@@ -31,7 +31,7 @@ export interface Announcement {
id: string;
title: string;
body: string;
presentation?: "popup" | "notification" | "banner";
presentation?: "popup" | "notification" | "banner" | "export";
audience: AnnouncementAudience;
priority: number;
mediaMode?: "banner" | "cover";
@@ -203,10 +203,13 @@ function parseAnnouncement(value: unknown): Announcement | undefined {
const audience = value.audience === "editor" ? "editor" : "all";
const presentation =
value.presentation === "notification" || value.presentation === "banner"
value.presentation === "notification" ||
value.presentation === "banner" ||
value.presentation === "export"
? value.presentation
: undefined;
const isTextOnlyPresentation = presentation === "notification" || presentation === "banner";
const isTextOnlyPresentation =
presentation === "notification" || presentation === "banner" || presentation === "export";
const priority =
typeof value.priority === "number" && Number.isFinite(value.priority)
? Math.max(-100, Math.min(100, value.priority))