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; 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 } 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}}" },