Fix Recordly 2.0 part 4 follow-up review findings

Preserve existing PR history and integrate validated second-cycle fixes.
This commit is contained in:
webadderall committed 2026-11-08 11:03:10 +11:00
commit a336029e44
34 files changed
+518 -66

No files matched your search

+204 -1
View File
@@ -1,6 +1,11 @@
import * as fs from "node:fs";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { describe, expect, it } from "vitest";
import os from "node:os";
import path from "node:path";
import { runInNewContext } from "node:vm";
import ts from "typescript";
import { afterEach, describe, expect, it } from "vitest";
// js-yaml is included by the repository's existing Electron build toolchain.
const { load } = createRequire(import.meta.url)("js-yaml");
@@ -45,3 +50,201 @@ describe("macOS published distribution gate", () => {
});
}
});
const smokeSource = readFileSync(
new URL("../scripts/smoke-packaged-binaries.mjs", import.meta.url),
"utf8",
);
const smokeProgram = ts.transpileModule(smokeSource, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
esModuleInterop: true,
},
}).outputText;
const smokeDirectories = [];
afterEach(() => {
for (const directory of smokeDirectories.splice(0)) {
fs.rmSync(directory, { recursive: true, force: true });
}
});
function smokeFixture(platform = "darwin") {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "recordly-packaged-smoke-"));
smokeDirectories.push(directory);
fs.writeFileSync(
path.join(directory, "package.json"),
JSON.stringify({ name: "recordly", productName: "Recordly" }),
);
return {
directory,
addArtifact(arch, packages, libraries) {
const app = path.join(
directory,
`artifact-${arch}`,
...(platform === "darwin" ? ["Recordly.app", "Contents"] : []),
);
const resources = path.join(app, platform === "darwin" ? "Resources" : "resources");
const unpacked = path.join(resources, "app.asar.unpacked");
const put = (file, value = "fixture") => {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, value, { mode: 0o755 });
};
put(
path.join(
app,
...(platform === "darwin"
? ["MacOS", "Recordly"]
: [platform === "win32" ? "Recordly.exe" : "recordly"]),
),
);
put(
path.join(resources, "licenses", "manifest.json"),
JSON.stringify({ platform, arch }),
);
put(
path.join(
unpacked,
"node_modules",
"ffmpeg-static",
platform === "win32" ? "ffmpeg.exe" : "ffmpeg",
),
);
for (const name of packages) {
const module = path.join(unpacked, "node_modules", name);
put(path.join(module, "sherpa-onnx.node"));
for (const library of libraries) put(path.join(module, library));
}
const helperNames =
platform === "darwin"
? [
"recordly-permission-drag-helper",
"recordly-screencapturekit-helper",
"recordly-window-list",
"recordly-system-cursors",
"recordly-native-cursor-monitor",
"whisper-cli",
"whisper-runtime.json",
]
: platform === "win32"
? [
"wgc-capture.exe",
"cursor-monitor.exe",
"recordly-gpu-export.exe",
"helpers-manifest.json",
"whisper-cli.exe",
"whisper-runtime.json",
"recordly-nvidia-cuda-compositor.exe",
]
: ["whisper-cli", "whisper-runtime.json"];
for (const helperArch of ["arm64", "x64"]) {
for (const helper of helperNames)
put(
path.join(
unpacked,
"electron",
"native",
"bin",
`${platform}-${helperArch}`,
helper,
),
);
}
},
run() {
const messages = [];
runInNewContext(smokeProgram, {
exports: {},
require(name) {
if (name === "node:fs") return fs;
if (name === "node:path") return path;
if (name === "node:child_process")
return { execFileSync: () => "ffmpeg version fixture\n" };
if (name === "./verify-distribution-notices.mjs")
return {
verifyPackagedDistributionNotices: (resources) =>
JSON.parse(
fs.readFileSync(
path.join(resources, "licenses", "manifest.json"),
"utf8",
),
),
};
throw new Error(`Unexpected smoke dependency: ${name}`);
},
process: {
cwd: () => directory,
platform,
arch: "arm64",
env: { RECORDLY_RELEASE_DIR: directory },
},
console: { log: (message) => messages.push(message) },
});
return messages;
},
};
}
describe("packaged caption runtime validation", () => {
it("checks each macOS artifact's target package when both architectures share an output directory", () => {
const fixture = smokeFixture();
for (const arch of ["arm64", "x64"])
fixture.addArtifact(
arch,
[`sherpa-onnx-darwin-${arch}`],
["libonnxruntime.dylib", "libsherpa-onnx-c-api.dylib"],
);
expect(fixture.run().at(-1)).toContain("packaged binary path smoke passed");
});
it("rejects a macOS x64 artifact containing only the build host's arm64 caption package", () => {
const fixture = smokeFixture();
fixture.addArtifact(
"arm64",
["sherpa-onnx-darwin-arm64"],
["libonnxruntime.dylib", "libsherpa-onnx-c-api.dylib"],
);
fixture.addArtifact(
"x64",
["sherpa-onnx-darwin-arm64"],
["libonnxruntime.dylib", "libsherpa-onnx-c-api.dylib"],
);
expect(() => fixture.run()).toThrow(/Parakeet.*missing/);
});
for (const [platform, packageName, core, api, provider] of [
[
"win32",
"sherpa-onnx-win-arm64",
"onnxruntime.dll",
"sherpa-onnx-c-api.dll",
"onnxruntime_providers_shared.dll",
],
[
"darwin",
"sherpa-onnx-darwin-arm64",
"libonnxruntime.1.23.2.dylib",
"libsherpa-onnx-c-api.dylib",
"libonnxruntime_providers_shared.dylib",
],
[
"linux",
"sherpa-onnx-linux-arm64",
"libonnxruntime.so.1.23.2",
"libsherpa-onnx-c-api.so",
"libonnxruntime_providers_shared.so",
],
]) {
it(`rejects a ${platform} provider library when the core ONNX Runtime library is missing`, () => {
const fixture = smokeFixture(platform);
fixture.addArtifact("arm64", [packageName], [provider, api]);
expect(() => fixture.run()).toThrow(/Parakeet library onnxruntime is missing/);
});
it(`accepts the ${platform} core library with a provider library beside it`, () => {
const fixture = smokeFixture(platform);
fixture.addArtifact("arm64", [packageName], [provider, core, api]);
expect(fixture.run().at(-1)).toContain("packaged binary path smoke passed");
});
}
});
+19 -19
View File
@@ -241,26 +241,26 @@ function verifyFfmpeg(unpackedRoot) {
}
console.log(output.split(/\r?\n/, 1)[0]);
return notices;
}
function verifyParakeetRuntime(unpackedRoot) {
function verifyParakeetRuntime(unpackedRoot, { platform, arch }) {
const modules = path.join(unpackedRoot, "node_modules");
const platform = process.platform === "win32" ? "win" : process.platform;
const packages = readdirSync(modules).filter((name) =>
name.startsWith(`sherpa-onnx-${platform}-`),
);
if (packages.length === 0) fail("the unpacked Parakeet native runtime is missing");
for (const name of packages) {
const directory = path.join(modules, name);
assertFile(path.join(directory, "sherpa-onnx.node"), `Parakeet addon (${name})`);
const files = readdirSync(directory);
for (const library of ["onnxruntime", "sherpa-onnx-c-api"]) {
const filename = files.find(
(file) => file.includes(library) && /\.(?:dll|dylib|so)(?:\.|$)/.test(file),
);
if (!filename) fail(`Parakeet library ${library} is missing from ${name}`);
assertFile(path.join(directory, filename), `Parakeet ${library} (${name})`);
}
const name = `sherpa-onnx-${platform === "win32" ? "win" : platform}-${arch}`;
const directory = path.join(modules, name);
if (!existsSync(directory)) fail(`the unpacked Parakeet native runtime ${name} is missing`);
assertFile(path.join(directory, "sherpa-onnx.node"), `Parakeet addon (${name})`);
const files = readdirSync(directory);
for (const library of ["onnxruntime", "sherpa-onnx-c-api"]) {
const pattern =
platform === "win32"
? new RegExp(`^${library}\\.dll$`)
: platform === "darwin"
? new RegExp(`^lib${library}(?:\\.\\d+)*\\.dylib$`)
: new RegExp(`^lib${library}\\.so(?:\\.\\d+)*$`);
const filename = files.find((file) => pattern.test(file));
if (!filename) fail(`Parakeet library ${library} is missing from ${name}`);
assertFile(path.join(directory, filename), `Parakeet ${library} (${name})`);
}
}
@@ -306,8 +306,8 @@ console.log(
for (const unpackedRoot of unpackedRoots) {
console.log(`[packaged-smoke] root: ${relativePath(unpackedRoot)}`);
assertPackagedAppExecutable(unpackedRoot);
verifyFfmpeg(unpackedRoot);
verifyParakeetRuntime(unpackedRoot);
const target = verifyFfmpeg(unpackedRoot);
verifyParakeetRuntime(unpackedRoot, target);
verifyNativeHelpers(unpackedRoot);
}
+4 -4
View File
@@ -7,7 +7,7 @@
"subject": "Your {{ .PlanName }} subscription is active",
"heading": "You're all set with",
"message": "Your {{ .PlanName }} is ready. Let's make something worth sharing!",
"detail": "Your cloud recordings stay available while Cloud is active. If Cloud ends, you have 7 days to save them or renew.",
"detail": "For recording retention after your subscription ends, review the Terms of service at https://videos.recordly.dev/terms. Keep local copies of recordings you want to retain.",
"facts": [
["Plan", "{{ .PlanName }}"],
["Price", "{{ .Amount }} / {{ .Interval }}"],
@@ -63,7 +63,7 @@
"heading": "Subscription ending with",
"message": "We're sorry to see you go. Your {{ .PlanName }} subscription won't renew, and you can enjoy your Cloud benefits until the date below.",
"facts": [["Plan", "{{ .PlanName }}"], ["Access until", "{{ .EndDate }}"]],
"detail": "Your cloud recordings expire 7 days after Cloud ends. Save a copy or renew before then. Your local files stay yours.",
"detail": "For recording retention after your subscription ends, review the Terms of service at https://videos.recordly.dev/terms. Keep local copies of recordings you want to retain.",
"action": "Manage subscription",
"actionUrl": "{{ .ActionURL }}",
"footer": "If you didn't request this cancellation, contact support.",
@@ -94,9 +94,9 @@
"category": "Subscriptions",
"subject": "Your Recordly subscription has ended",
"heading": "Your subscription to",
"message": "We're sorry to see you go. When your subscription ends, you lose all access to benefits of Cloud.",
"message": "Your {{ .PlanName }} subscription has ended. Review your account for current plan details.",
"facts": [["Previous plan", "{{ .PlanName }}"], ["Ended on", "{{ .EndDate }}"]],
"detail": "Your cloud recordings expire 7 days after the end date above. Save a copy or renew before then. Your local files stay yours.",
"detail": "For recording retention after your subscription ends, review the Terms of service at https://videos.recordly.dev/terms. Keep local copies of recordings you want to retain.",
"action": "View your account",
"actionUrl": "{{ .ActionURL }}",
"footer": "You can review available plans in Recordly at any time.",
@@ -44,7 +44,7 @@
</td></tr>
<tr><td style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; padding:24px 0 0;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; width:100%;"><tr><td class="email-muted" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; padding:5px 12px 5px 0; color:#71717a; font-size:14px; line-height:22px; vertical-align:top;">Plan</td><td class="email-text" align="right" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; padding:5px 0; color:#18181b; font-size:14px; line-height:22px; overflow-wrap:anywhere;">{{ .PlanName }}</td></tr><tr><td class="email-muted" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; padding:5px 12px 5px 0; color:#71717a; font-size:14px; line-height:22px; vertical-align:top;">Price</td><td class="email-text" align="right" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; padding:5px 0; color:#18181b; font-size:14px; line-height:22px; overflow-wrap:anywhere;">{{ .Amount }} / {{ .Interval }}</td></tr><tr><td class="email-muted" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; padding:5px 12px 5px 0; color:#71717a; font-size:14px; line-height:22px; vertical-align:top;">Next renewal</td><td class="email-text" align="right" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; padding:5px 0; color:#18181b; font-size:14px; line-height:22px; overflow-wrap:anywhere;">{{ .RenewalDate }}</td></tr></table></td></tr>
<tr><td align="center" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; padding:28px 0 0;"><table role="presentation" cellpadding="0" cellspacing="0" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; margin:0 auto;"><tr><td align="center" bgcolor="#0485f7" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; border-radius:24px; background-color:#0485f7; mso-padding-alt:10px 16px;"><a class="email-action" href="{{ .ActionURL }}" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; display:inline-block; border-radius:24px; background-color:#0485f7; padding:10px 16px; color:#fcfcfc; font-size:16px; line-height:24px; font-weight:500; text-align:center; text-decoration:none;">Manage subscription</a></td></tr></table></td></tr>
<tr><td align="center" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; padding:20px 0 0;"><p class="email-muted" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; margin:0; color:#71717a; font-size:14px; line-height:23px;">Your cloud recordings stay available while Cloud is active. If Cloud ends, you have 7 days to save them or renew.</p></td></tr>
<tr><td align="center" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; padding:20px 0 0;"><p class="email-muted" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; margin:0; color:#71717a; font-size:14px; line-height:23px;">For recording retention after your subscription ends, review the Terms of service at https://videos.recordly.dev/terms. Keep local copies of recordings you want to retain.</p></td></tr>
<tr><td align="center" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; padding:44px 0 32px;">
<p class="email-muted" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; margin:0 0 10px; color:#71717a; font-size:12px; line-height:20px;">You received this email because you subscribed to Recordly.</p>
<p style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; margin:0; font-size:12px; line-height:20px;"><a class="email-link" href="mailto:support@recordly.dev" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; color:#18181b; text-decoration:underline;">Contact support</a><span class="email-muted" style="color:#71717a;"> &nbsp;·&nbsp; </span><a class="email-link" href="https://videos.recordly.dev/privacy" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; color:#18181b; text-decoration:underline;">Privacy</a></p>
@@ -8,7 +8,7 @@ Next renewal: {{ .RenewalDate }}
Manage subscription: {{ .ActionURL }}
Your cloud recordings stay available while Cloud is active. If Cloud ends, you have 7 days to save them or renew.
For recording retention after your subscription ends, review the Terms of service at https://videos.recordly.dev/terms. Keep local copies of recordings you want to retain.
You received this email because you subscribed to Recordly.
@@ -44,7 +44,7 @@
</td></tr>
<tr><td style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; padding:24px 0 0;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; width:100%;"><tr><td class="email-muted" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; padding:5px 12px 5px 0; color:#71717a; font-size:14px; line-height:22px; vertical-align:top;">Plan</td><td class="email-text" align="right" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; padding:5px 0; color:#18181b; font-size:14px; line-height:22px; overflow-wrap:anywhere;">{{ .PlanName }}</td></tr><tr><td class="email-muted" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; padding:5px 12px 5px 0; color:#71717a; font-size:14px; line-height:22px; vertical-align:top;">Access until</td><td class="email-text" align="right" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; padding:5px 0; color:#18181b; font-size:14px; line-height:22px; overflow-wrap:anywhere;">{{ .EndDate }}</td></tr></table></td></tr>
<tr><td align="center" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; padding:28px 0 0;"><table role="presentation" cellpadding="0" cellspacing="0" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; margin:0 auto;"><tr><td align="center" bgcolor="#0485f7" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; border-radius:24px; background-color:#0485f7; mso-padding-alt:10px 16px;"><a class="email-action" href="{{ .ActionURL }}" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; display:inline-block; border-radius:24px; background-color:#0485f7; padding:10px 16px; color:#fcfcfc; font-size:16px; line-height:24px; font-weight:500; text-align:center; text-decoration:none;">Manage subscription</a></td></tr></table></td></tr>
<tr><td align="center" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; padding:20px 0 0;"><p class="email-muted" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; margin:0; color:#71717a; font-size:14px; line-height:23px;">Your cloud recordings expire 7 days after Cloud ends. Save a copy or renew before then. Your local files stay yours.</p></td></tr>
<tr><td align="center" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; padding:20px 0 0;"><p class="email-muted" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; margin:0; color:#71717a; font-size:14px; line-height:23px;">For recording retention after your subscription ends, review the Terms of service at https://videos.recordly.dev/terms. Keep local copies of recordings you want to retain.</p></td></tr>
<tr><td align="center" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; padding:44px 0 32px;">
<p class="email-muted" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; margin:0 0 10px; color:#71717a; font-size:12px; line-height:20px;">If you didn't request this cancellation, contact support.</p>
<p style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; margin:0; font-size:12px; line-height:20px;"><a class="email-link" href="mailto:support@recordly.dev" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; color:#18181b; text-decoration:underline;">Contact support</a><span class="email-muted" style="color:#71717a;"> &nbsp;·&nbsp; </span><a class="email-link" href="https://videos.recordly.dev/privacy" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; color:#18181b; text-decoration:underline;">Privacy</a></p>
@@ -7,7 +7,7 @@ Access until: {{ .EndDate }}
Manage subscription: {{ .ActionURL }}
Your cloud recordings expire 7 days after Cloud ends. Save a copy or renew before then. Your local files stay yours.
For recording retention after your subscription ends, review the Terms of service at https://videos.recordly.dev/terms. Keep local copies of recordings you want to retain.
If you didn't request this cancellation, contact support.
@@ -40,11 +40,11 @@
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; width:100%; max-width:560px; margin:0 auto;">
<tr><td align="center" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; padding:0;">
<h1 class="email-heading email-text" aria-label="Your subscription to Recordly" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; margin:0 0 16px; color:#18181b; font-size:33px; line-height:40px; font-weight:500; letter-spacing:-1.1px;">Your subscription to <span class="email-title-brand" style="display:inline-block; white-space:nowrap;"><img src="https://kgmqmaivltdolfzjrjjs.supabase.co/storage/v1/object/public/email-assets/recordly-mark-v1.png" width="35" height="35" alt="" style="display:inline-block; width:1.05em; height:1.05em; margin:0 7px 0 0; border:0; vertical-align:-0.15em;">Recordly</span></h1>
<p class="email-intro email-muted" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; margin:0; color:#71717a; font-size:15px; line-height:24px; overflow-wrap:anywhere;">We're sorry to see you go. When your subscription ends, you lose all access to benefits of Cloud.</p>
<p class="email-intro email-muted" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; margin:0; color:#71717a; font-size:15px; line-height:24px; overflow-wrap:anywhere;">Your {{ .PlanName }} subscription has ended. Review your account for current plan details.</p>
</td></tr>
<tr><td style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; padding:24px 0 0;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; width:100%;"><tr><td class="email-muted" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; padding:5px 12px 5px 0; color:#71717a; font-size:14px; line-height:22px; vertical-align:top;">Previous plan</td><td class="email-text" align="right" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; padding:5px 0; color:#18181b; font-size:14px; line-height:22px; overflow-wrap:anywhere;">{{ .PlanName }}</td></tr><tr><td class="email-muted" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; padding:5px 12px 5px 0; color:#71717a; font-size:14px; line-height:22px; vertical-align:top;">Ended on</td><td class="email-text" align="right" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; padding:5px 0; color:#18181b; font-size:14px; line-height:22px; overflow-wrap:anywhere;">{{ .EndDate }}</td></tr></table></td></tr>
<tr><td align="center" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; padding:28px 0 0;"><table role="presentation" cellpadding="0" cellspacing="0" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; margin:0 auto;"><tr><td align="center" bgcolor="#0485f7" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; border-radius:24px; background-color:#0485f7; mso-padding-alt:10px 16px;"><a class="email-action" href="{{ .ActionURL }}" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; display:inline-block; border-radius:24px; background-color:#0485f7; padding:10px 16px; color:#fcfcfc; font-size:16px; line-height:24px; font-weight:500; text-align:center; text-decoration:none;">View your account</a></td></tr></table></td></tr>
<tr><td align="center" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; padding:20px 0 0;"><p class="email-muted" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; margin:0; color:#71717a; font-size:14px; line-height:23px;">Your cloud recordings expire 7 days after the end date above. Save a copy or renew before then. Your local files stay yours.</p></td></tr>
<tr><td align="center" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; padding:20px 0 0;"><p class="email-muted" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; margin:0; color:#71717a; font-size:14px; line-height:23px;">For recording retention after your subscription ends, review the Terms of service at https://videos.recordly.dev/terms. Keep local copies of recordings you want to retain.</p></td></tr>
<tr><td align="center" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; padding:44px 0 32px;">
<p class="email-muted" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; margin:0 0 10px; color:#71717a; font-size:12px; line-height:20px;">You can review available plans in Recordly at any time.</p>
<p style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; margin:0; font-size:12px; line-height:20px;"><a class="email-link" href="mailto:support@recordly.dev" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; color:#18181b; text-decoration:underline;">Contact support</a><span class="email-muted" style="color:#71717a;"> &nbsp;·&nbsp; </span><a class="email-link" href="https://videos.recordly.dev/privacy" style="font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; color:#18181b; text-decoration:underline;">Privacy</a></p>
@@ -1,13 +1,13 @@
Your subscription to Recordly
We're sorry to see you go. When your subscription ends, you lose all access to benefits of Cloud.
Your {{ .PlanName }} subscription has ended. Review your account for current plan details.
Previous plan: {{ .PlanName }}
Ended on: {{ .EndDate }}
View your account: {{ .ActionURL }}
Your cloud recordings expire 7 days after the end date above. Save a copy or renew before then. Your local files stay yours.
For recording retention after your subscription ends, review the Terms of service at https://videos.recordly.dev/terms. Keep local copies of recordings you want to retain.
You can review available plans in Recordly at any time.
@@ -185,6 +185,7 @@ try {
.request()
.headers()
.authorization?.replace(/^Bearer /, "");
expect(requestToken, "POST must carry a bearer token").toBeTruthy();
const requestOwner = JSON.parse(
Buffer.from(requestToken.split(".")[1], "base64url").toString(),
).sub;
@@ -42,6 +42,7 @@ export default function SharingControls({
setSettings(state);
setComments(result.comments);
setTotal(result.total);
setPage(1);
})
.catch((cause) => {
if (!cancelled)
+1 -2
View File
@@ -250,8 +250,7 @@ export function RecordlySignInFields({
if (passwordMode) {
if (!password) return;
await runProtected(async (captchaToken) => {
await onPasswordSignIn(password, captchaToken);
setPassword("");
if (await onPasswordSignIn(password, captchaToken)) setPassword("");
});
} else await requestCode();
return;
+3 -1
View File
@@ -100,7 +100,9 @@ export function SplitPricingModal({ onSignIn }: { onSignIn: () => void }) {
: paymentPending
? t("checkPayment", "Check payment status")
: retryPricing
? t("retryPricing", "Retry loading pricing")
? plus
? t("refreshPlan", "Refresh plan")
: t("retryPricing", "Retry loading pricing")
: plus || manageSubscription
? t("manage", "Manage subscription")
: t("start", "Get started");
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { buildActiveCaptionLayout, flattenCaptionWords } from "./captionLayout";
import {
buildActiveCaptionLayout,
flattenCaptionWords,
prepareCaptionLayout,
} from "./captionLayout";
import { type CaptionCue, DEFAULT_AUTO_CAPTION_SETTINGS } from "./types";
describe("flattenCaptionWords", () => {
@@ -80,6 +84,68 @@ describe("current-word caption layout", () => {
}
});
it.each([
"chronological",
"reversed",
] as const)("keeps the visible overlapping cue and its highlighted word together for %s saved cues", (order) => {
const next: CaptionCue = {
id: "b",
startMs: 800,
endMs: 1800,
text: "next phrase",
words: [
{ text: "next", startMs: 800, endMs: 1200 },
{ text: "phrase", startMs: 1400, endMs: 1800, leadingSpace: true },
],
};
const cues = order === "chronological" ? [cue, next] : [next, cue];
const options = {
cues,
settings: {
...DEFAULT_AUTO_CAPTION_SETTINGS,
maxRows: 1,
animationStyle: "none" as const,
},
maxWidthPx: 1000,
measureText: (text: string) => text.length * 10,
};
const sample = prepareCaptionLayout(options);
expect(sample(799)?.cue.id).toBe("a");
for (const timeMs of [800, 900, 999, 1000]) {
const active = sample(timeMs)!;
expect(active.editTarget.text).toBe("next phrase");
expect(active.cue.id).toBe("b");
const emphasized = active.visibleLines
.flatMap((line) => line.words)
.filter((word) => word.state === "active");
expect(emphasized.map((word) => word.cueId)).toEqual(["b"]);
expect(emphasized[0].index).toBe(active.activeWordIndex);
expect(active.activeWordProgress).toBeCloseTo((timeMs - 800) / 400);
expect(buildActiveCaptionLayout({ ...options, timeMs })).toEqual(active);
}
expect(sample(799)?.cue.id).toBe("a");
expect(sample(900)?.cue.id).toBe("b");
expect(sample(1800)).toBeNull();
});
it("keeps an overlapping cue lead-in unhighlighted until its own first word", () => {
const next: CaptionCue = {
id: "b",
startMs: 800,
endMs: 1800,
text: "next",
words: [{ text: "next", startMs: 880, endMs: 1700 }],
};
for (const timeMs of [800, 850, 879]) {
const active = layout(timeMs, [cue, next])!;
expect(active.editTarget.text).toBe("next");
expect(active.cue.id).toBe("b");
expect(active.activeWordIndex).toBe(-1);
expect(active.visibleLines[0].words[0].state).toBe("upcoming");
}
expect(layout(880, [cue, next])?.visibleLines[0].words[0].state).toBe("active");
});
it("finishes Pop in 90ms while Fade retains its 180ms entrance", () => {
const animated = (animationStyle: "pop" | "fade", timeMs: number) =>
buildActiveCaptionLayout({
+4 -13
View File
@@ -125,16 +125,6 @@ function splitCaptionWords(cue: CaptionCue) {
}));
}
function getActiveCaptionCue(cues: CaptionCue[], timeMs: number) {
for (const cue of cues) {
if (timeMs >= cue.startMs && timeMs < cue.endMs) {
return cue;
}
}
return null;
}
function isWithinCaptionCoverage(sorted: CaptionCue[], timeMs: number) {
for (let index = 0; index < sorted.length; index += 1) {
const cue = sorted[index];
@@ -559,11 +549,12 @@ export function prepareCaptionLayout(
prepared = preparePage(pages[visiblePageIndex]);
preparedPages.set(visiblePageIndex, prepared);
}
const currentCue = getActiveCaptionCue(options.cues, timeMs);
// The visible page owns the cue through overlaps and bridged gaps.
const currentCue = prepared.cue;
// Hold emphasis through acoustic gaps; a cue lead-in cannot highlight its predecessor.
let activeWordIndex = -1;
sourceWords.forEach((word, index) => {
if (currentCue && word.cueId !== currentCue.id) return;
if (word.cueId !== currentCue.id) return;
if (
timeMs >= word.startMs &&
(activeWordIndex < 0 || word.startMs >= sourceWords[activeWordIndex].startMs)
@@ -597,7 +588,7 @@ export function prepareCaptionLayout(
);
return {
fontBaselinePx: options.fontBaselinePx,
cue: currentCue ?? prepared.cue,
cue: currentCue,
blockKey: prepared.blockKey,
visibleLines,
hasWordTimings: prepared.hasWordTimings,
+1 -1
View File
@@ -213,11 +213,11 @@ export async function startBrowserRecorder(
setRecording(false);
};
const mainStartedAt = Date.now();
if (startWasCancelled()) return;
beginWebcamCapture();
resetRecordingClock(mainStartedAt);
webcamTimeOffsetMs.current =
webcamStartTime.current === null ? 0 : webcamStartTime.current - mainStartedAt;
if (startWasCancelled()) return;
recorder.start(RECORDER_TIMESLICE_MS);
setRecording(true);
try {
+6 -1
View File
@@ -38,5 +38,10 @@
},
"addSpeed": "Geschwindigkeit hinzufügen (S)",
"resizeLeft": "Größe links anpassen",
"resizeRight": "Größe rechts anpassen"
"resizeRight": "Größe rechts anpassen",
"hints": {
"splitClip": "Drücke {{binding}}, um den Clip zu teilen",
"addAnnotation": "Drücke {{binding}}, um eine Anmerkung hinzuzufügen",
"addAudio": "Klicke auf das Musiksymbol, um Audio hinzuzufügen"
}
}
+6 -1
View File
@@ -38,5 +38,10 @@
},
"addSpeed": "Add Speed (S)",
"resizeLeft": "Resize left",
"resizeRight": "Resize right"
"resizeRight": "Resize right",
"hints": {
"splitClip": "Press {{binding}} to split clip",
"addAnnotation": "Press {{binding}} to add annotation",
"addAudio": "Click music icon to add audio"
}
}
+6 -1
View File
@@ -38,5 +38,10 @@
},
"addSpeed": "Agregar Velocidad (S)",
"resizeLeft": "Redimensionar izquierda",
"resizeRight": "Redimensionar derecha"
"resizeRight": "Redimensionar derecha",
"hints": {
"splitClip": "Pulsa {{binding}} para dividir el clip",
"addAnnotation": "Pulsa {{binding}} para añadir una anotación",
"addAudio": "Haz clic en el icono de música para añadir audio"
}
}
+1 -1
View File
@@ -31,7 +31,7 @@
"commercialRequirement": "Un abonnement Recordly Cloud actif est requis pour un usage commercial.",
"featureShare": "Partagez des vidéos en haute définition 1080p",
"featureStorage": "Stockage cloud et bande passante illimités",
"featureWeeklyUploads": "{{count}} nouveaux envois sur 7 jours glissants",
"featureWeeklyUploads": "Jusqu’à {{count}} nouveaux envois par période glissante de 7 jours",
"featureUploadFallback": "Importez des enregistrements plus volumineux",
"featureLinks": "Les liens restent accessibles tant que Cloud est actif",
"featureExisting": "Conservez vos enregistrements cloud tant que Cloud est actif",
+6 -1
View File
@@ -38,5 +38,10 @@
},
"addSpeed": "Ajouter une vitesse (S)",
"resizeLeft": "Redimensionner vers la gauche",
"resizeRight": "Redimensionner vers la droite"
"resizeRight": "Redimensionner vers la droite",
"hints": {
"splitClip": "Appuyez sur {{binding}} pour scinder le clip",
"addAnnotation": "Appuyez sur {{binding}} pour ajouter une annotation",
"addAudio": "Cliquez sur l’icône de musique pour ajouter de l’audio"
}
}
+6 -1
View File
@@ -38,5 +38,10 @@
},
"addSpeed": "Aggiungi velocità (S)",
"resizeLeft": "Ridimensiona a sinistra",
"resizeRight": "Ridimensiona a destra"
"resizeRight": "Ridimensiona a destra",
"hints": {
"splitClip": "Premi {{binding}} per dividere la clip",
"addAnnotation": "Premi {{binding}} per aggiungere un’annotazione",
"addAudio": "Fai clic sull’icona della musica per aggiungere audio"
}
}
+6 -1
View File
@@ -38,5 +38,10 @@
},
"addSpeed": "速度変更を追加 (S)",
"resizeLeft": "左端を調整",
"resizeRight": "右端を調整"
"resizeRight": "右端を調整",
"hints": {
"splitClip": "{{binding}} キーでクリップを分割",
"addAnnotation": "{{binding}} キーで注釈を追加",
"addAudio": "音楽アイコンをクリックして音声を追加"
}
}
+6 -1
View File
@@ -38,5 +38,10 @@
},
"addSpeed": "속도 추가 (S)",
"resizeLeft": "왼쪽 크기 조절",
"resizeRight": "오른쪽 크기 조절"
"resizeRight": "오른쪽 크기 조절",
"hints": {
"splitClip": "{{binding}} 키를 눌러 클립 분할",
"addAnnotation": "{{binding}} 키를 눌러 주석 추가",
"addAudio": "음악 아이콘을 클릭하여 오디오 추가"
}
}
+6 -1
View File
@@ -38,5 +38,10 @@
},
"addSpeed": "Snelheid toevoegen (S)",
"resizeLeft": "Links verkleinen",
"resizeRight": "Rechts verkleinen"
"resizeRight": "Rechts verkleinen",
"hints": {
"splitClip": "Druk op {{binding}} om de clip te splitsen",
"addAnnotation": "Druk op {{binding}} om een annotatie toe te voegen",
"addAudio": "Klik op het muziekpictogram om audio toe te voegen"
}
}
+6 -1
View File
@@ -38,5 +38,10 @@
},
"addSpeed": "Adicionar velocidade (S)",
"resizeLeft": "Redimensionar para a esquerda",
"resizeRight": "Redimensionar para a direita"
"resizeRight": "Redimensionar para a direita",
"hints": {
"splitClip": "Pressione {{binding}} para dividir o clipe",
"addAnnotation": "Pressione {{binding}} para adicionar uma anotação",
"addAudio": "Clique no ícone de música para adicionar áudio"
}
}
+6 -1
View File
@@ -38,5 +38,10 @@
},
"addSpeed": "添加变速 (S)",
"resizeLeft": "向左调整",
"resizeRight": "向右调整"
"resizeRight": "向右调整",
"hints": {
"splitClip": "按 {{binding}} 拆分片段",
"addAnnotation": "按 {{binding}} 添加注释",
"addAudio": "点击音乐图标添加音频"
}
}
+1 -1
View File
@@ -4,7 +4,7 @@
"editorTitle": "Recordly 編輯器",
"subtitle": "螢幕錄製與編輯",
"language": "語言",
"manageRecordings": "打開錄製影像文件夾"
"manageRecordings": "開啟錄製資料夾"
},
"actions": {
"cancel": "取消",
+6 -1
View File
@@ -38,5 +38,10 @@
},
"addSpeed": "新增速度(S)",
"resizeLeft": "向左調整大小",
"resizeRight": "向右調整大小"
"resizeRight": "向右調整大小",
"hints": {
"splitClip": "按 {{binding}} 分割片段",
"addAnnotation": "按 {{binding}} 新增註解",
"addAudio": "點選音樂圖示以新增音訊"
}
}
+1 -1
View File
@@ -230,7 +230,7 @@
"projectDuplicateHelp": "使用相同來源檔案,且儲存的編輯器設定完全一致。保留目前開啟的專案,或最近儲存的副本。編輯內容不同的專案會保留。",
"unusedHelp": "未被此媒體庫中的專案及其配套軌道參照的來源檔案。無法檢查未儲存的編輯或媒體庫以外的專案。",
"title": "清理媒體庫",
"scopeHelp": "搜尋、檔案夾和時間篩選仍然有效。請先檢視建議再選取;此處不會刪除任何內容。",
"scopeHelp": "搜尋、資料夾和時間篩選仍然有效。請先檢視建議再選取;此處不會刪除任何內容。",
"find": "尋找",
"all": "所有項目",
"unused": "未用於已儲存專案",
+61 -1
View File
@@ -9,7 +9,7 @@ type OfflineRenderTestHarness = AudioProcessor & {
prepareOfflineRender(
videoUrl: string,
trimRegions: never[],
speedRegions: never[],
speedRegions: import("@/components/video-editor/types").SpeedRegion[],
audioRegions: never[],
sourceAudioFallbackPaths: string[],
sourceAudioFallbackStartDelayMsByPath?: Record<string, number>,
@@ -53,6 +53,66 @@ function fakeAudioBuffer(channels: Float32Array[]): AudioBuffer {
}
describe("AudioProcessor offline render preparation", () => {
it("prepares edited sidecar audio when remote video metadata does not allow CORS", async () => {
// A metadata-only video can load cross-origin without a CORS opt-in; the
// same response is rejected when the request explicitly asks for CORS.
class MetadataVideo extends EventTarget {
crossOrigin = "";
preload = "";
duration = 12;
private source = "";
load = vi.fn();
set src(value: string) {
this.source = value;
if (value)
queueMicrotask(() =>
this.dispatchEvent(
new Event(
this.crossOrigin === "anonymous" ? "error" : "loadedmetadata",
),
),
);
}
get src() {
return this.source;
}
}
const videos: MetadataVideo[] = [];
vi.stubGlobal("document", {
createElement: (tag: string) => {
expect(tag).toBe("video");
const video = new MetadataVideo();
videos.push(video);
return video;
},
});
const processor = new AudioProcessor() as unknown as OfflineRenderTestHarness;
const sidecar = { duration: 12, numberOfChannels: 2 } as AudioBuffer;
const decode = vi.spyOn(processor, "decodeAudioFromUrl").mockResolvedValue(sidecar);
try {
const prepared = await processor.prepareOfflineRender(
"https://media.example.test/no-cors/recording.mp4",
[],
[{ id: "speed", startMs: 0, endMs: 1000, speed: 2 }],
[],
["/tmp/recording.system.wav"],
);
expect(prepared.mainBufferEntry).toBeNull();
expect(prepared.companionEntries[0].buffer).toBe(sidecar);
expect(prepared).toMatchObject({ outputDurationMs: 11500 });
expect(decode).toHaveBeenCalledExactlyOnceWith("/tmp/recording.system.wav");
expect(videos).toHaveLength(2);
for (const video of videos) {
expect(video.preload).toBe("metadata");
expect(video.src).toBe("");
expect(video.load).toHaveBeenCalledOnce();
}
} finally {
decode.mockRestore();
vi.unstubAllGlobals();
}
});
it("routes a muted full-track clip through offline audio rendering", async () => {
const processor = new AudioProcessor();
const render = vi
+1 -1
View File
@@ -239,7 +239,7 @@ export class AudioMediaProcessor extends AudioTimelineProcessor {
const source = await resolveMediaElementSource(url);
try {
const media = document.createElement("video");
media.crossOrigin = "anonymous";
// Only metadata is read; CORS access to decoded media is unnecessary.
media.preload = "metadata";
media.src = source.src;
@@ -4,6 +4,7 @@ import {
scalePreviewBorderRadius,
} from "@/components/video-editor/videoPlayback/layoutUtils";
import { getSceneEffectMetrics } from "@/components/video-editor/videoPlayback/sceneEffects";
import { redactDiagnostic } from "@/lib/feedback/diagnosticRedaction";
import { normalizeLightningRuntimePlatform } from "./backendPolicy";
import {
getNowMs,
@@ -389,6 +390,12 @@ export async function exportNativeStaticLayout(
console.warn("[VideoExporter] Native static layout export unavailable", {
error: result.error,
});
session.native.nativeStaticLayoutSkipReason = redactDiagnostic(
result.error || "native-static-export-unavailable",
).slice(0, 300);
session.native.nativeStaticLayoutSkipReasons = [
session.native.nativeStaticLayoutSkipReason,
];
restoreEncoderState();
return null;
}
@@ -58,6 +58,73 @@ afterEach(() => {
vi.unstubAllGlobals();
});
describe("ModernVideoExporter native static-layout eligibility", () => {
it.each([
"details",
"no-error",
"missing-path",
] as const)("retains a safe unavailable native-attempt reason for %s", async (mode) => {
const exporter = createExporter();
const { session, operations } = exporterInternals(exporter);
const initialEncoder = {
encodeBackend: session.metrics.encodeBackend,
encoderName: session.metrics.encoderName,
diagnosticEncoder: session.metrics.diagnosticEncoder,
diagnosticMuxer: session.metrics.diagnosticMuxer,
};
const privateError =
"Native layout failed for /Users/example/private-source.mov\nAuthorization: Bearer fixture-token\napi_key=fixture-key\n" +
"x".repeat(450);
const nativeExport = vi.mocked(window.electronAPI.nativeStaticLayoutExport);
nativeExport.mockResolvedValue({
success: mode === "missing-path",
error: mode === "details" ? privateError : undefined,
});
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
try {
await expect(
operations.tryExportNativeStaticLayout(videoInfo, { audioMode: "none" }, 60, 1800),
).resolves.toBeNull();
expect(nativeExport).toHaveBeenCalledOnce();
const reason = session.native.nativeStaticLayoutSkipReason;
expect(reason).toBeTruthy();
expect(session.native.nativeStaticLayoutSkipReasons).toEqual([reason]);
const diagnostics = session.metrics.buildDiagnosticSnapshot();
expect(diagnostics.attempts.at(-1)).toMatchObject({
route: "native-static-layout",
status: "unavailable",
reason,
});
expect(session.metrics.buildExportMetrics().nativeStaticLayoutSkipReasons).toEqual([
reason,
]);
if (mode === "details") {
expect(reason).toHaveLength(300);
expect(reason).toContain("[local path]");
expect(reason).toContain("[redacted]");
} else expect(reason).toBe("native-static-export-unavailable");
const recorded = JSON.stringify({
diagnostics,
reasons: session.native.nativeStaticLayoutSkipReasons,
});
for (const privateValue of [
"/Users/example",
"private-source.mov",
"fixture-token",
"fixture-key",
])
expect(recorded).not.toContain(privateValue);
expect({
encodeBackend: session.metrics.encodeBackend,
encoderName: session.metrics.encoderName,
diagnosticEncoder: session.metrics.diagnosticEncoder,
diagnosticMuxer: session.metrics.diagnosticMuxer,
}).toEqual(initialEncoder);
expect(session.native.nativeStaticLayoutSessionId).toBeNull();
} finally {
warn.mockRestore();
}
});
it.each([
{ name: "a full frame", sourceWidth: 1920, sourceHeight: 1080, padding: 0 },
{