mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-11-09 07:23:14 +00:00
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:
commit
a336029e44
34 files changed
+518
-66
No files matched your search
@@ -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");
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;"> · </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;"> · </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;"> · </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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -38,5 +38,10 @@
|
||||
},
|
||||
"addSpeed": "速度変更を追加 (S)",
|
||||
"resizeLeft": "左端を調整",
|
||||
"resizeRight": "右端を調整"
|
||||
"resizeRight": "右端を調整",
|
||||
"hints": {
|
||||
"splitClip": "{{binding}} キーでクリップを分割",
|
||||
"addAnnotation": "{{binding}} キーで注釈を追加",
|
||||
"addAudio": "音楽アイコンをクリックして音声を追加"
|
||||
}
|
||||
}
|
||||
@@ -38,5 +38,10 @@
|
||||
},
|
||||
"addSpeed": "속도 추가 (S)",
|
||||
"resizeLeft": "왼쪽 크기 조절",
|
||||
"resizeRight": "오른쪽 크기 조절"
|
||||
"resizeRight": "오른쪽 크기 조절",
|
||||
"hints": {
|
||||
"splitClip": "{{binding}} 키를 눌러 클립 분할",
|
||||
"addAnnotation": "{{binding}} 키를 눌러 주석 추가",
|
||||
"addAudio": "음악 아이콘을 클릭하여 오디오 추가"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -38,5 +38,10 @@
|
||||
},
|
||||
"addSpeed": "添加变速 (S)",
|
||||
"resizeLeft": "向左调整",
|
||||
"resizeRight": "向右调整"
|
||||
"resizeRight": "向右调整",
|
||||
"hints": {
|
||||
"splitClip": "按 {{binding}} 拆分片段",
|
||||
"addAnnotation": "按 {{binding}} 添加注释",
|
||||
"addAudio": "点击音乐图标添加音频"
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
"editorTitle": "Recordly 編輯器",
|
||||
"subtitle": "螢幕錄製與編輯",
|
||||
"language": "語言",
|
||||
"manageRecordings": "打開錄製影像文件夾"
|
||||
"manageRecordings": "開啟錄製資料夾"
|
||||
},
|
||||
"actions": {
|
||||
"cancel": "取消",
|
||||
|
||||
@@ -38,5 +38,10 @@
|
||||
},
|
||||
"addSpeed": "新增速度(S)",
|
||||
"resizeLeft": "向左調整大小",
|
||||
"resizeRight": "向右調整大小"
|
||||
"resizeRight": "向右調整大小",
|
||||
"hints": {
|
||||
"splitClip": "按 {{binding}} 分割片段",
|
||||
"addAnnotation": "按 {{binding}} 新增註解",
|
||||
"addAudio": "點選音樂圖示以新增音訊"
|
||||
}
|
||||
}
|
||||
@@ -230,7 +230,7 @@
|
||||
"projectDuplicateHelp": "使用相同來源檔案,且儲存的編輯器設定完全一致。保留目前開啟的專案,或最近儲存的副本。編輯內容不同的專案會保留。",
|
||||
"unusedHelp": "未被此媒體庫中的專案及其配套軌道參照的來源檔案。無法檢查未儲存的編輯或媒體庫以外的專案。",
|
||||
"title": "清理媒體庫",
|
||||
"scopeHelp": "搜尋、檔案夾和時間篩選仍然有效。請先檢視建議再選取;此處不會刪除任何內容。",
|
||||
"scopeHelp": "搜尋、資料夾和時間篩選仍然有效。請先檢視建議再選取;此處不會刪除任何內容。",
|
||||
"find": "尋找",
|
||||
"all": "所有項目",
|
||||
"unused": "未用於已儲存專案",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user