diff --git a/electron/releaseDistributionGate.test.mjs b/electron/releaseDistributionGate.test.mjs index 662f438b..0a6b855e 100644 --- a/electron/releaseDistributionGate.test.mjs +++ b/electron/releaseDistributionGate.test.mjs @@ -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"); + }); + } +}); diff --git a/scripts/smoke-packaged-binaries.mjs b/scripts/smoke-packaged-binaries.mjs index 0315f7a5..c13b3208 100644 --- a/scripts/smoke-packaged-binaries.mjs +++ b/scripts/smoke-packaged-binaries.mjs @@ -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); } diff --git a/services/email/billing/catalog.json b/services/email/billing/catalog.json index ad0e809f..cc301697 100644 --- a/services/email/billing/catalog.json +++ b/services/email/billing/catalog.json @@ -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.", diff --git a/services/email/billing/subscription-active.html b/services/email/billing/subscription-active.html index 54696a07..0cd9ff82 100644 --- a/services/email/billing/subscription-active.html +++ b/services/email/billing/subscription-active.html @@ -44,7 +44,7 @@
| Plan | {{ .PlanName }} |
| Price | {{ .Amount }} / {{ .Interval }} |
| Next renewal | {{ .RenewalDate }} |
| Manage subscription |
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.
diff --git a/services/email/billing/subscription-active.txt b/services/email/billing/subscription-active.txt index a0596a51..d28a8fa9 100644 --- a/services/email/billing/subscription-active.txt +++ b/services/email/billing/subscription-active.txt @@ -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. diff --git a/services/email/billing/subscription-cancellation-scheduled.html b/services/email/billing/subscription-cancellation-scheduled.html index a993e72b..384d9fc4 100644 --- a/services/email/billing/subscription-cancellation-scheduled.html +++ b/services/email/billing/subscription-cancellation-scheduled.html @@ -44,7 +44,7 @@| Plan | {{ .PlanName }} |
| Access until | {{ .EndDate }} |
| Manage subscription |
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.
diff --git a/services/email/billing/subscription-cancellation-scheduled.txt b/services/email/billing/subscription-cancellation-scheduled.txt index 3579f69c..13f401a0 100644 --- a/services/email/billing/subscription-cancellation-scheduled.txt +++ b/services/email/billing/subscription-cancellation-scheduled.txt @@ -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. diff --git a/services/email/billing/subscription-ended.html b/services/email/billing/subscription-ended.html index 6819ad2f..8a00eb46 100644 --- a/services/email/billing/subscription-ended.html +++ b/services/email/billing/subscription-ended.html @@ -40,11 +40,11 @@
Your subscription to
- | ||||
| ||||
| ||||
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. diff --git a/services/email/billing/subscription-ended.txt b/services/email/billing/subscription-ended.txt index 8c896929..07f2bf30 100644 --- a/services/email/billing/subscription-ended.txt +++ b/services/email/billing/subscription-ended.txt @@ -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. diff --git a/services/recordly-share/worker/web/scripts/comment-auth.browser-test.mjs b/services/recordly-share/worker/web/scripts/comment-auth.browser-test.mjs index 9a7a8fc1..59cf1bb4 100644 --- a/services/recordly-share/worker/web/scripts/comment-auth.browser-test.mjs +++ b/services/recordly-share/worker/web/scripts/comment-auth.browser-test.mjs @@ -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; diff --git a/services/recordly-share/worker/web/src/components/SharingControls.tsx b/services/recordly-share/worker/web/src/components/SharingControls.tsx index e36c5520..0ca989bb 100644 --- a/services/recordly-share/worker/web/src/components/SharingControls.tsx +++ b/services/recordly-share/worker/web/src/components/SharingControls.tsx @@ -42,6 +42,7 @@ export default function SharingControls({ setSettings(state); setComments(result.comments); setTotal(result.total); + setPage(1); }) .catch((cause) => { if (!cancelled) diff --git a/src/components/auth/RecordlySignInFields.tsx b/src/components/auth/RecordlySignInFields.tsx index ba890e26..9c86ffa0 100644 --- a/src/components/auth/RecordlySignInFields.tsx +++ b/src/components/auth/RecordlySignInFields.tsx @@ -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; diff --git a/src/components/billing/SplitPricingModal.tsx b/src/components/billing/SplitPricingModal.tsx index ff946892..88174f65 100644 --- a/src/components/billing/SplitPricingModal.tsx +++ b/src/components/billing/SplitPricingModal.tsx @@ -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"); diff --git a/src/components/video-editor/captionLayout.test.ts b/src/components/video-editor/captionLayout.test.ts index 59a6b16c..a2ac0b30 100644 --- a/src/components/video-editor/captionLayout.test.ts +++ b/src/components/video-editor/captionLayout.test.ts @@ -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({ diff --git a/src/components/video-editor/captionLayout.ts b/src/components/video-editor/captionLayout.ts index 11c167c6..018fa749 100644 --- a/src/components/video-editor/captionLayout.ts +++ b/src/components/video-editor/captionLayout.ts @@ -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, diff --git a/src/hooks/startBrowserRecorder.ts b/src/hooks/startBrowserRecorder.ts index c99979fa..88d8ed51 100644 --- a/src/hooks/startBrowserRecorder.ts +++ b/src/hooks/startBrowserRecorder.ts @@ -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 { diff --git a/src/i18n/locales/de/timeline.json b/src/i18n/locales/de/timeline.json index 3a15827f..4dc41991 100644 --- a/src/i18n/locales/de/timeline.json +++ b/src/i18n/locales/de/timeline.json @@ -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" + } } diff --git a/src/i18n/locales/en/timeline.json b/src/i18n/locales/en/timeline.json index 86cc447b..653fb618 100644 --- a/src/i18n/locales/en/timeline.json +++ b/src/i18n/locales/en/timeline.json @@ -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" + } } diff --git a/src/i18n/locales/es/timeline.json b/src/i18n/locales/es/timeline.json index 08421f85..be1f01cb 100644 --- a/src/i18n/locales/es/timeline.json +++ b/src/i18n/locales/es/timeline.json @@ -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" + } } diff --git a/src/i18n/locales/fr/billing.json b/src/i18n/locales/fr/billing.json index c996e757..58184ef4 100644 --- a/src/i18n/locales/fr/billing.json +++ b/src/i18n/locales/fr/billing.json @@ -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", diff --git a/src/i18n/locales/fr/timeline.json b/src/i18n/locales/fr/timeline.json index 2413f377..cccf8cd0 100644 --- a/src/i18n/locales/fr/timeline.json +++ b/src/i18n/locales/fr/timeline.json @@ -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" + } } diff --git a/src/i18n/locales/it/timeline.json b/src/i18n/locales/it/timeline.json index d39098d0..e3878d11 100644 --- a/src/i18n/locales/it/timeline.json +++ b/src/i18n/locales/it/timeline.json @@ -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" + } } diff --git a/src/i18n/locales/ja/timeline.json b/src/i18n/locales/ja/timeline.json index a516f361..fa467284 100644 --- a/src/i18n/locales/ja/timeline.json +++ b/src/i18n/locales/ja/timeline.json @@ -38,5 +38,10 @@ }, "addSpeed": "速度変更を追加 (S)", "resizeLeft": "左端を調整", - "resizeRight": "右端を調整" + "resizeRight": "右端を調整", + "hints": { + "splitClip": "{{binding}} キーでクリップを分割", + "addAnnotation": "{{binding}} キーで注釈を追加", + "addAudio": "音楽アイコンをクリックして音声を追加" + } } diff --git a/src/i18n/locales/ko/timeline.json b/src/i18n/locales/ko/timeline.json index ccb9be80..e9af4ff9 100644 --- a/src/i18n/locales/ko/timeline.json +++ b/src/i18n/locales/ko/timeline.json @@ -38,5 +38,10 @@ }, "addSpeed": "속도 추가 (S)", "resizeLeft": "왼쪽 크기 조절", - "resizeRight": "오른쪽 크기 조절" + "resizeRight": "오른쪽 크기 조절", + "hints": { + "splitClip": "{{binding}} 키를 눌러 클립 분할", + "addAnnotation": "{{binding}} 키를 눌러 주석 추가", + "addAudio": "음악 아이콘을 클릭하여 오디오 추가" + } } diff --git a/src/i18n/locales/nl/timeline.json b/src/i18n/locales/nl/timeline.json index 0147195f..3af8a3fc 100644 --- a/src/i18n/locales/nl/timeline.json +++ b/src/i18n/locales/nl/timeline.json @@ -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" + } } diff --git a/src/i18n/locales/pt-BR/timeline.json b/src/i18n/locales/pt-BR/timeline.json index 84689d56..8803480e 100644 --- a/src/i18n/locales/pt-BR/timeline.json +++ b/src/i18n/locales/pt-BR/timeline.json @@ -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" + } } diff --git a/src/i18n/locales/zh-CN/timeline.json b/src/i18n/locales/zh-CN/timeline.json index 54d64867..21f9300d 100644 --- a/src/i18n/locales/zh-CN/timeline.json +++ b/src/i18n/locales/zh-CN/timeline.json @@ -38,5 +38,10 @@ }, "addSpeed": "添加变速 (S)", "resizeLeft": "向左调整", - "resizeRight": "向右调整" + "resizeRight": "向右调整", + "hints": { + "splitClip": "按 {{binding}} 拆分片段", + "addAnnotation": "按 {{binding}} 添加注释", + "addAudio": "点击音乐图标添加音频" + } } diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index ab48a801..e5233892 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -4,7 +4,7 @@ "editorTitle": "Recordly 編輯器", "subtitle": "螢幕錄製與編輯", "language": "語言", - "manageRecordings": "打開錄製影像文件夾" + "manageRecordings": "開啟錄製資料夾" }, "actions": { "cancel": "取消", diff --git a/src/i18n/locales/zh-TW/timeline.json b/src/i18n/locales/zh-TW/timeline.json index ab4c4506..29ea40be 100644 --- a/src/i18n/locales/zh-TW/timeline.json +++ b/src/i18n/locales/zh-TW/timeline.json @@ -38,5 +38,10 @@ }, "addSpeed": "新增速度(S)", "resizeLeft": "向左調整大小", - "resizeRight": "向右調整大小" + "resizeRight": "向右調整大小", + "hints": { + "splitClip": "按 {{binding}} 分割片段", + "addAnnotation": "按 {{binding}} 新增註解", + "addAudio": "點選音樂圖示以新增音訊" + } } diff --git a/src/i18n/locales/zh-TW/ui.json b/src/i18n/locales/zh-TW/ui.json index 03bcf2f7..11776ba3 100644 --- a/src/i18n/locales/zh-TW/ui.json +++ b/src/i18n/locales/zh-TW/ui.json @@ -230,7 +230,7 @@ "projectDuplicateHelp": "使用相同來源檔案,且儲存的編輯器設定完全一致。保留目前開啟的專案,或最近儲存的副本。編輯內容不同的專案會保留。", "unusedHelp": "未被此媒體庫中的專案及其配套軌道參照的來源檔案。無法檢查未儲存的編輯或媒體庫以外的專案。", "title": "清理媒體庫", - "scopeHelp": "搜尋、檔案夾和時間篩選仍然有效。請先檢視建議再選取;此處不會刪除任何內容。", + "scopeHelp": "搜尋、資料夾和時間篩選仍然有效。請先檢視建議再選取;此處不會刪除任何內容。", "find": "尋找", "all": "所有項目", "unused": "未用於已儲存專案", diff --git a/src/lib/exporter/audioEncoder.test.ts b/src/lib/exporter/audioEncoder.test.ts index 38e4ffb6..fb21c528 100644 --- a/src/lib/exporter/audioEncoder.test.ts +++ b/src/lib/exporter/audioEncoder.test.ts @@ -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 |