From b25ea974ba3b2ec91a413cc765a6ed30973c6c2f Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sun, 26 Jul 2026 23:49:19 +0200 Subject: [PATCH 1/3] fix(compositor): resolve scene assets outside the asar, so themes and wallpapers load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native compositor is a Rust addon that opens asset paths with `image::open`. `resolveSceneAssetPaths` handed it paths rooted at `VITE_PUBLIC`, which is `/public` in dev but `/app.asar/dist` in a packaged build: vite copies `public/` into `dist`, so the files are in there, but inside the archive. Only Electron's patched `fs` reads through an asar, so every bundled wallpaper and every themed cursor sprite failed to load in an installed app — silently, on the native side's colour/default-art fallbacks. Picking a cursor theme looked like it did nothing, and a bundled image background came out a flat colour. A CUSTOM uploaded background kept working the whole time, which is what pinned it down: it travels as a `data:` URL the addon decodes in memory, never a path. `extraResources` already copies both trees to `/{wallpapers,cursors}` as real files — electron-builder.json5's own "Asset layout contract" comment names exactly this, and `ASSET_BASE_DIR` in electron/windows.ts already resolves them that way for the renderer. So probe the bases in order and take the one that actually holds the file, the same candidate-list idiom `ffmpegSharedBinCandidates` uses right below. Existence-probing rather than branching on `app.isPackaged` keeps `--dir` staging builds working too. This resolver also feeds `exportMulti`, so the same assets were missing from rendered exports in installed builds, not just from the preview. --- .../services/compositorViewService.test.ts | 83 ++++++++++++++++++- .../services/compositorViewService.ts | 69 +++++++++++---- 2 files changed, 133 insertions(+), 19 deletions(-) diff --git a/electron/native-bridge/services/compositorViewService.test.ts b/electron/native-bridge/services/compositorViewService.test.ts index 4e2f5bbde..c0b05038e 100644 --- a/electron/native-bridge/services/compositorViewService.test.ts +++ b/electron/native-bridge/services/compositorViewService.test.ts @@ -2,7 +2,12 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { CompositorViewService, ffmpegSharedBinCandidates } from "./compositorViewService"; +import { CURSOR_THEMES } from "../../../src/lib/cursor/cursorThemes"; +import { + CompositorViewService, + ffmpegSharedBinCandidates, + resolveSceneAssetPaths, +} from "./compositorViewService"; /** A source checkout's `poc-d3d/.cargo/config.toml`, with `FFMPEG_DIR` written as `body`. */ function writeCargoConfig(root: string, body: string): void { @@ -172,3 +177,79 @@ describe("CompositorViewService ffmpeg PATH prepend", () => { expect(occurrences?.length).toBe(1); }); }); + +describe("resolveSceneAssetPaths", () => { + // A packaged install: `wallpapers/` and `cursors/` exist as real files under + // resourcesPath (extraResources), while VITE_PUBLIC points into the asar, where + // nothing is readable by the Rust addon — the exact layout that broke both features. + let resources: string; + let originalResourcesPath: PropertyDescriptor | undefined; + let originalVitePublic: string | undefined; + const themed = CURSOR_THEMES.find((t) => t.assets.arrow); + + beforeEach(() => { + resources = fs.mkdtempSync(path.join(os.tmpdir(), "openscreen-scene-assets-")); + fs.mkdirSync(path.join(resources, "wallpapers"), { recursive: true }); + fs.writeFileSync(path.join(resources, "wallpapers", "wallpaper1.jpg"), "jpg"); + if (themed?.assets.arrow) { + const arrow = path.join(resources, themed.assets.arrow.assetPath); + fs.mkdirSync(path.dirname(arrow), { recursive: true }); + fs.writeFileSync(arrow, "png"); + } + originalResourcesPath = Object.getOwnPropertyDescriptor(process, "resourcesPath"); + Object.defineProperty(process, "resourcesPath", { value: resources, configurable: true }); + originalVitePublic = process.env.VITE_PUBLIC; + // vite copies public/ into dist, so the names exist inside the archive — but the + // archive is a file, so no candidate under it can ever pass an existence check. + process.env.VITE_PUBLIC = path.join(resources, "app.asar", "dist"); + }); + + afterEach(() => { + if (originalResourcesPath) { + Object.defineProperty(process, "resourcesPath", originalResourcesPath); + } + if (originalVitePublic === undefined) { + delete process.env.VITE_PUBLIC; + } else { + process.env.VITE_PUBLIC = originalVitePublic; + } + fs.rmSync(resources, { recursive: true, force: true }); + }); + + function resolved(scene: Record) { + return JSON.parse(resolveSceneAssetPaths(JSON.stringify(scene))); + } + + it("resolves a bundled wallpaper to the extraResources copy, not the unreadable asar path", () => { + const out = resolved({ background: { kind: "image", path: "/wallpapers/wallpaper1.jpg" } }); + + expect(out.background.path).toBe(path.join(resources, "wallpapers", "wallpaper1.jpg")); + expect(out.background.path).not.toContain("app.asar"); + expect(fs.existsSync(out.background.path)).toBe(true); + }); + + it("resolves a cursor theme's arrow sprite to a path that exists on disk", () => { + if (!themed) return; // no bundled theme ships an arrow override + const out = resolved({ cursor: { theme: themed.id } }); + + expect(out.cursor.cursorSpritePath).toBe(path.join(resources, themed.assets.arrow!.assetPath)); + expect(fs.existsSync(out.cursor.cursorSpritePath)).toBe(true); + }); + + it("leaves a custom upload's data: URL untouched — the addon decodes it in memory", () => { + const dataUrl = "data:image/png;base64,SGkh"; + const out = resolved({ background: { kind: "image", path: dataUrl } }); + + expect(out.background.path).toBe(dataUrl); + }); + + it("nulls the sprite for the default theme so the built-in art is used", () => { + expect(resolved({ cursor: { theme: "default" } }).cursor.cursorSpritePath).toBeNull(); + }); + + it("leaves the scene alone when no base dir holds the asset", () => { + const out = resolved({ background: { kind: "image", path: "/wallpapers/absent.jpg" } }); + + expect(out.background.path).toBe("/wallpapers/absent.jpg"); + }); +}); diff --git a/electron/native-bridge/services/compositorViewService.ts b/electron/native-bridge/services/compositorViewService.ts index 396207506..e9189db81 100644 --- a/electron/native-bridge/services/compositorViewService.ts +++ b/electron/native-bridge/services/compositorViewService.ts @@ -33,25 +33,55 @@ const localRequire: NodeRequire = createRequire(import.meta.url) as unknown as N * schemes (data:, http:) are left as-is; the native side falls back to a flat * colour when it can't load them. Malformed JSON passes through untouched. */ -/** Absolute path of a theme's "arrow" sprite under `publicDir`, or null (unknown theme / - * default / theme ships no arrow override — same fallback the web renderer applies). */ -function resolveCursorThemeArrowPath(themeId: string, publicDir: string): string | null { - if (!themeId || themeId === DEFAULT_CURSOR_THEME_ID) { - return null; +/** + * Bases holding the `wallpapers/` and `cursors/` trees, most specific first. + * + * `VITE_PUBLIC` is right in dev (`/public`) and WRONG when packaged, where it points at + * `/app.asar/dist`: vite copies `public/` into `dist`, so the files are there — but + * inside the asar archive. Only Electron's patched `fs` can read through an asar, and the + * compositor is a Rust addon calling `image::open` on a raw OS path, so every themed cursor + * sprite and every bundled wallpaper silently failed to load in an installed build. The cursor + * fell back to its default art (a theme pick looked like it did nothing) and the background fell + * back to a flat colour. A CUSTOM uploaded image kept working throughout, because it travels as a + * `data:` URL the addon decodes in memory rather than a path — which is exactly the asymmetry that + * pinned this down. + * + * `extraResources` copies both trees to `/{wallpapers,cursors}` as real files (see + * electron-builder.json5, whose own "Asset layout contract" comment names this), so + * `process.resourcesPath` is the packaged answer. Probing for existence rather than branching on + * `app.isPackaged` keeps `--dir` staging builds and tests working too, and mirrors the + * candidate-list idiom `ffmpegSharedBinCandidates` already uses below. Same base dir as + * `ASSET_BASE_DIR` in electron/windows.ts, which resolves these two trees for the renderer. + */ +function sceneAssetBaseDirs(): string[] { + return [process.env.VITE_PUBLIC, process.resourcesPath].filter( + (dir): dir is string => typeof dir === "string" && dir.length > 0, + ); +} + +/** First base under which `relativePath` actually exists, joined; null when none does (leave the + * scene's own value alone rather than hand the addon a path we know is not there). */ +export function resolveSceneAssetPath(relativePath: string): string | null { + for (const base of sceneAssetBaseDirs()) { + const candidate = path.join(base, relativePath); + if (fs.existsSync(candidate)) { + return candidate; + } } - const theme = CURSOR_THEMES.find((t) => t.id === themeId); - const arrow = theme?.assets.arrow; - if (!arrow) { + return null; +} + +/** Absolute path of a theme's "arrow" sprite, or null (unknown theme / default / theme ships no + * arrow override / asset missing — same fallback to built-in art the web renderer applies). */ +function resolveCursorThemeArrowPath(themeId: string): string | null { + if (!themeId || themeId === DEFAULT_CURSOR_THEME_ID) { return null; } - return path.join(publicDir, arrow.assetPath); + const arrow = CURSOR_THEMES.find((t) => t.id === themeId)?.assets.arrow; + return arrow ? resolveSceneAssetPath(arrow.assetPath) : null; } -function resolveSceneAssetPaths(sceneJson: string): string { - const publicDir = process.env.VITE_PUBLIC; - if (!publicDir) { - return sceneJson; - } +export function resolveSceneAssetPaths(sceneJson: string): string { try { const scene = JSON.parse(sceneJson) as { background?: { kind?: string; path?: string }; @@ -60,12 +90,15 @@ function resolveSceneAssetPaths(sceneJson: string): string { let changed = false; const bg = scene.background; if (bg?.kind === "image" && typeof bg.path === "string" && bg.path.startsWith("/")) { - // strip the leading slash so path.join keeps it under publicDir - bg.path = path.join(publicDir, bg.path.replace(/^\/+/, "")); - changed = true; + // strip the leading slash so path.join keeps it under the base dir + const resolved = resolveSceneAssetPath(bg.path.replace(/^\/+/, "")); + if (resolved) { + bg.path = resolved; + changed = true; + } } if (scene.cursor && typeof scene.cursor.theme === "string") { - scene.cursor.cursorSpritePath = resolveCursorThemeArrowPath(scene.cursor.theme, publicDir); + scene.cursor.cursorSpritePath = resolveCursorThemeArrowPath(scene.cursor.theme); changed = true; } return changed ? JSON.stringify(scene) : sceneJson; From 817ec2a6c1eb9155422e392fc10c47ebb5d11b96 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sun, 26 Jul 2026 23:49:42 +0200 Subject: [PATCH 2/3] fix(zoom): feather the tilted plane's edge at radius 0, so a 3D zoom stops reading as truncated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tilt path (mode 8) decides coverage with a binary `r.z < 0.5` reject, and the 1.5px SDF feather that turns that staircase into an edge sat behind `if (radius_px > 0.0)`. With the Roundness slider at 0 — its default — the plane kept hard aliased edges, which is precisely the failure this branch's own comment describes: "des arêtes de couteau qui coupent le contenu en pleine phrase, et ça se lit comme une troncature plutôt que comme une inclinaison". Hence the report that the 3D zoom looks cut off, but only below some minimum corner radius. `sd_round_rect` degenerates to a plain box SDF at r = 0, so the guard bought nothing: drop it and clamp with `max(radius_px, 0.0)`. The mode 12 shadow already applies its radius unguarded for the same reason. The geometry was never the problem — `every_preset_stays_inside_the_original_rect` already holds, so nothing was being clipped by the frame. --- poc-d3d/src/shaders.hlsl | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/poc-d3d/src/shaders.hlsl b/poc-d3d/src/shaders.hlsl index 47e3a0636..65f98a664 100644 --- a/poc-d3d/src/shaders.hlsl +++ b/poc-d3d/src/shaders.hlsl @@ -286,19 +286,23 @@ float4 ps_main(VSOut i) : SV_Target return float4(0.0, 0.0, 0.0, 0.0); // hors du quad projeté } float2 uv = float2(lerp(src.x, src.z, saturate(r.x)), lerp(src.y, src.w, saturate(r.y))); - float tilt_a = 1.0; - if (radius_px > 0.0) - { - // Coins arrondis DANS LE REPÈRE DU PLAN (`dst_prev.xy` = sa taille avant projection) : - // le rayon reste constant le long du bord, alors qu'un arrondi calculé dans la bbox - // s'étirerait avec la perspective. Sans cet arrondi, un écran penché a des arêtes de - // couteau qui coupent le contenu en pleine phrase, et ça se lit comme une troncature - // plutôt que comme une inclinaison. - float2 plane_px = dst_prev.xy; - float2 p = float2(r.x, r.y) * plane_px - plane_px * 0.5; - float d = sd_round_rect(p, plane_px * 0.5, radius_px); - tilt_a = 1.0 - smoothstep(0.0, 1.5, d); - } + // Coins arrondis DANS LE REPÈRE DU PLAN (`dst_prev.xy` = sa taille avant projection) : + // le rayon reste constant le long du bord, alors qu'un arrondi calculé dans la bbox + // s'étirerait avec la perspective. Sans cet arrondi, un écran penché a des arêtes de + // couteau qui coupent le contenu en pleine phrase, et ça se lit comme une troncature + // plutôt que comme une inclinaison. + // + // Inconditionnel, rayon 0 COMPRIS : `sd_round_rect` dégénère alors en SDF de rectangle et + // le feather de 1.5 px subsiste, ce qui est précisément ce qui fait lire une arête inclinée + // comme une arête. Sous l'ancienne garde `radius_px > 0`, un slider Roundness à 0 laissait + // la couverture du plan au seul test binaire `r.z < 0.5` ci-dessus : des marches d'escalier + // en escalier franc, soit la troncature même que cette branche existe pour éviter (d'où le + // symptôme « le tilt 3D est tronqué, mais pas au-dessus d'un certain arrondi »). L'ombre du + // mode 12 applique déjà son `max(radius_px, 0.0)` sans garde, pour la même raison. + float2 plane_px = dst_prev.xy; + float2 p = float2(r.x, r.y) * plane_px - plane_px * 0.5; + float d = sd_round_rect(p, plane_px * 0.5, max(radius_px, 0.0)); + float tilt_a = 1.0 - smoothstep(0.0, 1.5, d); return float4(sample_yuv(uv) * tilt_a, tilt_a); // prémultiplié, comme les autres modes } From 2bb40581acaabc55f43f6254224a2ec5c869aea7 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 27 Jul 2026 00:01:24 +0200 Subject: [PATCH 3/3] fix(background): accept a blank MIME type by extension, and say so when a file is refused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `handleFileSelected` gated custom wallpaper uploads on `file.type.startsWith("image/")`, so a file the browser reports no MIME type for — which Windows does for some files and some locales — was dropped on the floor, with no toast, no message, nothing. The user picked a PNG and the pane simply did not react. That exact case had been fixed once, in "Allow PNG custom background uploads": `isSupportedBackgroundImageType` fell back to the file extension when `type` was blank, and its test named a real offender (`生成画像1.png`). The module was never imported by the upload path though — only by its own test — so the 2026-07-26 reorg removed it as dead code, correctly, and the repo lost the fallback while keeping the weaker check that needed it. So restore the fallback where the upload actually happens, and derive the `accept` filter from the same two lists the validation uses, since those had already drifted apart once (the accept string was an inline copy of the deleted module's constant). An explicit non-image type is still refused — only a blank one earns the extension fallback, so `notes.txt` renamed to `notes.png` does not get through. Rejections and unreadable files now raise a toast, localized across all 13 locales. Tests are the recovered cases plus a component test asserting the toast actually reaches the user, which is the half that was really missing. --- src/components/ai-edition/RightPanes.tsx | 42 +++++++++- .../ai-edition/backgroundImageUpload.test.tsx | 80 +++++++++++++++++++ src/i18n/locales/ar/settings.json | 2 + src/i18n/locales/en/settings.json | 2 + src/i18n/locales/es/settings.json | 2 + src/i18n/locales/fr/settings.json | 2 + src/i18n/locales/it/settings.json | 2 + src/i18n/locales/ja-JP/settings.json | 2 + src/i18n/locales/ko-KR/settings.json | 2 + src/i18n/locales/pt-BR/settings.json | 2 + src/i18n/locales/ru/settings.json | 2 + src/i18n/locales/tr/settings.json | 2 + src/i18n/locales/vi/settings.json | 2 + src/i18n/locales/zh-CN/settings.json | 2 + src/i18n/locales/zh-TW/settings.json | 2 + 15 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 src/components/ai-edition/backgroundImageUpload.test.tsx diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index 30e243952..5b68d84a3 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -28,6 +28,7 @@ import { useRef, useState, } from "react"; +import { toast } from "sonner"; import defaultCursorPreviewUrl from "@/assets/cursors/Cursor=Default.svg"; import GradientEditor, { type GradientEditorState } from "@/components/ui/gradient-editor"; import { useScopedT } from "@/contexts/I18nContext"; @@ -158,7 +159,35 @@ const COLOR_PALETTE: readonly string[] = [ "#1e293b", ]; -const IMAGE_ACCEPT = ".jpg,.jpeg,.png,image/jpeg,image/png"; +// One source for the file dialog's filter AND the post-pick validation. They were separate +// before — the accept string was an inline copy of a constant living in a module whose +// extension fallback never got wired up, so the dialog offered files the handler then +// dropped on the floor. +const IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png"]; +// `image/jpg` is not the registered type but real systems emit it, so accept it too. +const IMAGE_MIME_TYPES = ["image/jpeg", "image/jpg", "image/png"]; +const IMAGE_ACCEPT = [...IMAGE_EXTENSIONS, ...IMAGE_MIME_TYPES].join(","); + +/** + * Whether a picked file is a background image we can use. + * + * A blank `type` falls back to the extension: the browser reports no MIME type for some + * files and some locales on Windows, and a bare `file.type.startsWith("image/")` then + * rejected perfectly good PNGs — silently, since the handler just returned. That is the + * case "Allow PNG custom background uploads" fixed once already (its test named a real + * one: `生成画像1.png`, arriving with no MIME type at all). + * + * An explicit non-image type is still a rejection. Only a blank one earns the fallback, + * so `notes.txt` renamed to `notes.png` does not sneak through on its extension. + */ +export function isSupportedBackgroundImage(type: string, fileName: string): boolean { + const mime = type.trim().toLowerCase(); + if (mime) { + return IMAGE_MIME_TYPES.includes(mime); + } + const name = fileName.trim().toLowerCase(); + return IMAGE_EXTENSIONS.some((extension) => name.endsWith(extension)); +} // Wallpaper picker — image / solid color / gradient tabs. // @@ -214,13 +243,20 @@ export function BackgroundPane() { const file = e.target.files?.[0]; e.target.value = ""; if (!file) return; - if (!file.type.startsWith("image/")) return; + if (!isSupportedBackgroundImage(file.type, file.name)) { + toast.error(ts("background.unsupportedImage")); + return; + } const reader = new FileReader(); reader.onload = () => { const dataUrl = typeof reader.result === "string" ? reader.result : ""; - if (!dataUrl) return; + if (!dataUrl) { + toast.error(ts("background.imageReadFailed")); + return; + } void set({ wallpaper: dataUrl }); }; + reader.onerror = () => toast.error(ts("background.imageReadFailed")); reader.readAsDataURL(file); }; diff --git a/src/components/ai-edition/backgroundImageUpload.test.tsx b/src/components/ai-edition/backgroundImageUpload.test.tsx new file mode 100644 index 000000000..d4b7f4607 --- /dev/null +++ b/src/components/ai-edition/backgroundImageUpload.test.tsx @@ -0,0 +1,80 @@ +// Cases recovered from the deleted `video-editor/backgroundImageUpload.test.ts`, whose +// module was dropped as dead code in the 2026-07-26 reorg — correctly, since only its own +// test imported it, but the empty-MIME fallback it encoded had never been wired into the +// pane that actually handles the upload. These pin the behaviour to the live code path. +import { cleanup, fireEvent, render } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { I18nProvider } from "@/contexts/I18nContext"; +import { BackgroundPane, isSupportedBackgroundImage } from "./RightPanes"; + +const toastError = vi.hoisted(() => vi.fn()); +vi.mock("sonner", () => ({ toast: { error: toastError, success: vi.fn(), info: vi.fn() } })); + +afterEach(() => { + cleanup(); + toastError.mockClear(); +}); + +describe("background image upload validation", () => { + it("accepts PNG images for custom backgrounds", () => { + expect(isSupportedBackgroundImage("image/png", "生成画像1.png")).toBe(true); + }); + + it("accepts PNG images by extension when the browser does not provide a MIME type", () => { + // The regression this guards: Windows reports no MIME type for some files and + // locales, and the pane used to drop them silently. + expect(isSupportedBackgroundImage("", "生成画像1.png")).toBe(true); + }); + + it("keeps rejecting non-image uploads", () => { + expect(isSupportedBackgroundImage("text/plain", "notes.txt")).toBe(false); + }); + + it("does not allow extension fallback for explicit unsupported MIME types", () => { + expect(isSupportedBackgroundImage("text/plain", "notes.png")).toBe(false); + }); + + it("accepts jpeg and the non-standard image/jpg some systems emit", () => { + expect(isSupportedBackgroundImage("image/jpeg", "shot.jpeg")).toBe(true); + expect(isSupportedBackgroundImage("image/jpg", "shot.jpg")).toBe(true); + }); + + it("ignores case and stray whitespace in either field", () => { + expect(isSupportedBackgroundImage(" IMAGE/PNG ", "shot.png")).toBe(true); + expect(isSupportedBackgroundImage("", " SHOT.PNG ")).toBe(true); + }); + + it("rejects a blank type with an extension we do not support", () => { + expect(isSupportedBackgroundImage("", "clip.webp")).toBe(false); + expect(isSupportedBackgroundImage("", "noextension")).toBe(false); + }); +}); + +describe("a rejected upload tells the user", () => { + function pick(file: File) { + const { container } = render( + + + , + ); + const input = container.querySelector('input[type="file"]'); + if (!(input instanceof HTMLInputElement)) throw new Error("no file input rendered"); + // jsdom leaves `files` unwritable, so define it the way a real pick would. + Object.defineProperty(input, "files", { value: [file], configurable: true }); + fireEvent.change(input); + } + + it("surfaces an error instead of silently dropping the file", () => { + // The whole point: this used to `return` with no feedback at all. + pick(new File(["nope"], "notes.txt", { type: "text/plain" })); + + expect(toastError).toHaveBeenCalledTimes(1); + expect(toastError.mock.calls[0]?.[0]).toBe("Unsupported image. Use a JPG or PNG file."); + }); + + it("stays quiet for a file it accepts", () => { + pick(new File(["x"], "生成画像1.png", { type: "" })); + + expect(toastError).not.toHaveBeenCalled(); + }); +}); diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index b2c0a67cb..02756bc46 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -84,6 +84,8 @@ "presets": "إعدادات مسبقة", "help": "اختر ما يظهر خلف التسجيل: صورة خلفية مضمّنة، أو لون خالص، أو تدرّج لوني، أو صورة خاصة بك من القرص.", "customWallpaper": "خلفية مخصصة", + "unsupportedImage": "صورة غير مدعومة. استخدم ملف JPG أو PNG.", + "imageReadFailed": "تعذّر قراءة ملف الصورة.", "imageLabel": "الخلفية {{index}}", "colorLabel": "اللون {{color}}" }, diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 172d3658b..4c6b51439 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -84,6 +84,8 @@ "presets": "Presets", "help": "Choose what appears behind the recording: a bundled wallpaper image, a solid color, a gradient, or a custom image from disk.", "customWallpaper": "Custom wallpaper", + "unsupportedImage": "Unsupported image. Use a JPG or PNG file.", + "imageReadFailed": "Could not read that image file.", "imageLabel": "Background {{index}}", "colorLabel": "Color {{color}}" }, diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index fa9ecbdee..819aa459b 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -84,6 +84,8 @@ "presets": "Ajustes preestablecidos", "help": "Elige qué aparece detrás de la grabación: un fondo incluido, un color sólido, un degradado o una imagen propia del disco.", "customWallpaper": "Fondo personalizado", + "unsupportedImage": "Imagen no compatible. Usa un archivo JPG o PNG.", + "imageReadFailed": "No se pudo leer ese archivo de imagen.", "imageLabel": "Fondo {{index}}", "colorLabel": "Color {{color}}" }, diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index 34e167664..f02e7199a 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -84,6 +84,8 @@ "presets": "Préréglages", "help": "Choisissez ce qui apparaît derrière l'enregistrement : un fond d'écran fourni, une couleur unie, un dégradé, ou une image personnelle depuis le disque.", "customWallpaper": "Fond personnalisé", + "unsupportedImage": "Image non prise en charge. Utilisez un fichier JPG ou PNG.", + "imageReadFailed": "Impossible de lire ce fichier image.", "imageLabel": "Fond {{index}}", "colorLabel": "Couleur {{color}}" }, diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index 5ba1f263e..fa2e7d3a2 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -84,6 +84,8 @@ "presets": "Predefiniti", "help": "Scegli cosa appare dietro la registrazione: uno sfondo incluso, un colore pieno, un gradiente o un'immagine personale dal disco.", "customWallpaper": "Sfondo personalizzato", + "unsupportedImage": "Immagine non supportata. Usa un file JPG o PNG.", + "imageReadFailed": "Impossibile leggere quel file immagine.", "imageLabel": "Sfondo {{index}}", "colorLabel": "Colore {{color}}" }, diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index 9c92a3c04..01e704378 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -84,6 +84,8 @@ "presets": "プリセット", "help": "録画の背景に表示するものを選びます。同梱の壁紙画像、単色、グラデーション、またはディスク上の任意の画像から選べます。", "customWallpaper": "カスタム壁紙", + "unsupportedImage": "対応していない画像です。JPG または PNG ファイルを使用してください。", + "imageReadFailed": "この画像ファイルを読み込めませんでした。", "imageLabel": "背景 {{index}}", "colorLabel": "色 {{color}}" }, diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json index 2ccaffbf6..421f9afc1 100644 --- a/src/i18n/locales/ko-KR/settings.json +++ b/src/i18n/locales/ko-KR/settings.json @@ -84,6 +84,8 @@ "presets": "프리셋", "help": "녹화 뒤에 표시할 항목을 선택하세요. 기본 제공 배경 이미지, 단색, 그라데이션 또는 디스크의 사용자 이미지를 사용할 수 있습니다.", "customWallpaper": "사용자 배경", + "unsupportedImage": "지원하지 않는 이미지입니다. JPG 또는 PNG 파일을 사용하세요.", + "imageReadFailed": "이미지 파일을 읽을 수 없습니다.", "imageLabel": "배경 {{index}}", "colorLabel": "색상 {{color}}" }, diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json index d33935dc4..3f50902ea 100644 --- a/src/i18n/locales/pt-BR/settings.json +++ b/src/i18n/locales/pt-BR/settings.json @@ -84,6 +84,8 @@ "presets": "Predefinições", "help": "Escolha o que aparece atrás da gravação: um papel de parede incluído, uma cor sólida, um gradiente ou uma imagem sua do disco.", "customWallpaper": "Papel de parede personalizado", + "unsupportedImage": "Imagem não suportada. Use um arquivo JPG ou PNG.", + "imageReadFailed": "Não foi possível ler esse arquivo de imagem.", "imageLabel": "Fundo {{index}}", "colorLabel": "Cor {{color}}" }, diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index 5363f9907..d53cc9c4b 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -84,6 +84,8 @@ "presets": "Пресеты", "help": "Выберите, что будет за записью: встроенное изображение, сплошной цвет, градиент или собственная картинка с диска.", "customWallpaper": "Свои обои", + "unsupportedImage": "Неподдерживаемое изображение. Используйте файл JPG или PNG.", + "imageReadFailed": "Не удалось прочитать этот файл изображения.", "imageLabel": "Фон {{index}}", "colorLabel": "Цвет {{color}}" }, diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index 36b7fbade..8108b730f 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -84,6 +84,8 @@ "presets": "Ön ayarlar", "help": "Kaydın arkasında ne görüneceğini seçin: yerleşik bir duvar kâğıdı, düz renk, gradyan veya diskinizdeki özel bir görsel.", "customWallpaper": "Özel duvar kâğıdı", + "unsupportedImage": "Desteklenmeyen görsel. JPG veya PNG dosyası kullanın.", + "imageReadFailed": "Bu görsel dosyası okunamadı.", "imageLabel": "Arka plan {{index}}", "colorLabel": "Renk {{color}}" }, diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json index a7fc09974..16e1be3a0 100644 --- a/src/i18n/locales/vi/settings.json +++ b/src/i18n/locales/vi/settings.json @@ -84,6 +84,8 @@ "presets": "Có sẵn", "help": "Chọn nội dung hiển thị phía sau bản ghi: ảnh nền có sẵn, màu đơn sắc, dải màu chuyển sắc, hoặc ảnh riêng của bạn từ ổ đĩa.", "customWallpaper": "Ảnh nền tùy chỉnh", + "unsupportedImage": "Ảnh không được hỗ trợ. Hãy dùng tệp JPG hoặc PNG.", + "imageReadFailed": "Không thể đọc tệp ảnh này.", "imageLabel": "Nền {{index}}", "colorLabel": "Màu {{color}}" }, diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index d1d152bd0..977802567 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -84,6 +84,8 @@ "presets": "预设", "help": "选择录制内容背后显示的元素:内置壁纸图片、纯色、渐变,或来自磁盘的自定义图片。", "customWallpaper": "自定义壁纸", + "unsupportedImage": "不支持的图片格式。请使用 JPG 或 PNG 文件。", + "imageReadFailed": "无法读取该图片文件。", "imageLabel": "背景 {{index}}", "colorLabel": "颜色 {{color}}" }, diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index a2e949cbd..881ddfd34 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -85,6 +85,8 @@ "presets": "預設", "help": "選擇錄製內容背後顯示的元素:內建桌布圖片、純色、漸層,或來自磁碟的自訂圖片。", "customWallpaper": "自訂桌布", + "unsupportedImage": "不支援的圖片格式。請使用 JPG 或 PNG 檔案。", + "imageReadFailed": "無法讀取該圖片檔案。", "imageLabel": "背景 {{index}}", "colorLabel": "顏色 {{color}}" },