Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 82 additions & 1 deletion electron/native-bridge/services/compositorViewService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, unknown>) {
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");
});
});
69 changes: 51 additions & 18 deletions electron/native-bridge/services/compositorViewService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<root>/public`) and WRONG when packaged, where it points at
* `<resources>/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 `<resources>/{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 };
Expand All @@ -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;
Expand Down
30 changes: 17 additions & 13 deletions poc-d3d/src/shaders.hlsl
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
42 changes: 39 additions & 3 deletions src/components/ai-edition/RightPanes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
//
Expand Down Expand Up @@ -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);
};

Expand Down
80 changes: 80 additions & 0 deletions src/components/ai-edition/backgroundImageUpload.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<I18nProvider>
<BackgroundPane />
</I18nProvider>,
);
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();
});
});
2 changes: 2 additions & 0 deletions src/i18n/locales/ar/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@
"presets": "إعدادات مسبقة",
"help": "اختر ما يظهر خلف التسجيل: صورة خلفية مضمّنة، أو لون خالص، أو تدرّج لوني، أو صورة خاصة بك من القرص.",
"customWallpaper": "خلفية مخصصة",
"unsupportedImage": "صورة غير مدعومة. استخدم ملف JPG أو PNG.",
"imageReadFailed": "تعذّر قراءة ملف الصورة.",
"imageLabel": "الخلفية {{index}}",
"colorLabel": "اللون {{color}}"
},
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}}"
},
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/es/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}}"
},
Expand Down
Loading
Loading