Refresh bundled wallpapers and gradient presets

This commit is contained in:
webadderall committed 2026-04-28 09:17:51 +10:00
1 parent a9615c486e
commit 4970f25e3c
28 files changed
+908 -268

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

+6 -28
View File
@@ -19,6 +19,7 @@ import {
getRenderableVideoUrl,
getWallpaperThumbnailUrl,
} from "@/lib/assetPath";
import { GRADIENTS } from "@/lib/backgroundGradients";
import type { ExtensionSettingField } from "@/lib/extensions";
import { extensionHost, type FrameInstance } from "@/lib/extensions";
import { cn } from "@/lib/utils";
@@ -89,33 +90,6 @@ const tahoeCursorUrl = cursorSetAssets.tahoe.arrow.url;
const BUILTIN_CURSOR_PREVIEW_SIZE = 28;
const BUILTIN_CURSOR_PREVIEW_FRAME_SIZE = 48;
const GRADIENTS = [
"linear-gradient( 111.6deg, rgba(114,167,232,1) 9.4%, rgba(253,129,82,1) 43.9%, rgba(253,129,82,1) 54.8%, rgba(249,202,86,1) 86.3% )",
"linear-gradient(120deg, #d4fc79 0%, #96e6a1 100%)",
"radial-gradient( circle farthest-corner at 3.2% 49.6%, rgba(80,12,139,0.87) 0%, rgba(161,10,144,0.72) 83.6% )",
"linear-gradient( 111.6deg, rgba(0,56,68,1) 0%, rgba(163,217,185,1) 51.5%, rgba(231, 148, 6, 1) 88.6% )",
"linear-gradient( 107.7deg, rgba(235,230,44,0.55) 8.4%, rgba(252,152,15,1) 90.3% )",
"linear-gradient( 91deg, rgba(72,154,78,1) 5.2%, rgba(251,206,70,1) 95.9% )",
"radial-gradient( circle farthest-corner at 10% 20%, rgba(2,37,78,1) 0%, rgba(4,56,126,1) 19.7%, rgba(85,245,221,1) 100.2% )",
"linear-gradient( 109.6deg, rgba(15,2,2,1) 11.2%, rgba(36,163,190,1) 91.1% )",
"linear-gradient(135deg, #FBC8B4, #2447B1)",
"linear-gradient(109.6deg, #F635A6, #36D860)",
"linear-gradient(90deg, #FF0101, #4DFF01)",
"linear-gradient(315deg, #EC0101, #5044A9)",
"linear-gradient(45deg, #ff9a9e 0%, #fad0c4 99%, #fad0c4 100%)",
"linear-gradient(to top, #a18cd1 0%, #fbc2eb 100%)",
"linear-gradient(to right, #ff8177 0%, #ff867a 0%, #ff8c7f 21%, #f99185 52%, #cf556c 78%, #b12a5b 100%)",
"linear-gradient(120deg, #84fab0 0%, #8fd3f4 100%)",
"linear-gradient(to right, #4facfe 0%, #00f2fe 100%)",
"linear-gradient(to top, #fcc5e4 0%, #fda34b 15%, #ff7882 35%, #c8699e 52%, #7046aa 71%, #0c1db8 87%, #020f75 100%)",
"linear-gradient(to right, #fa709a 0%, #fee140 100%)",
"linear-gradient(to top, #30cfd0 0%, #330867 100%)",
"linear-gradient(to top, #c471f5 0%, #fa71cd 100%)",
"linear-gradient(to right, #f78ca0 0%, #f9748f 19%, #fd868c 60%, #fe9a8b 100%)",
"linear-gradient(to top, #48c6ef 0%, #6f86d6 100%)",
"linear-gradient(to right, #0acffe 0%, #495aff 100%)",
];
const CAPTION_ANIMATION_OPTIONS: Array<{ value: AutoCaptionAnimation; label: string }> = [
{ value: "none", label: "Off" },
{ value: "fade", label: "Fade" },
@@ -129,7 +103,11 @@ function isHexWallpaper(value: string): boolean {
}
function getBackgroundTabForWallpaper(value: string): BackgroundTab {
if (GRADIENTS.includes(value)) {
if (
GRADIENTS.includes(value) ||
value.startsWith("linear-gradient") ||
value.startsWith("radial-gradient")
) {
return "gradient";
}
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { blobGradientPreviewCss, GRADIENTS } from "./backgroundGradients";
describe("backgroundGradients", () => {
it("builds deterministic blob previews", () => {
const first = blobGradientPreviewCss({
baseMode: "light",
baseA: "#3182ce",
baseB: "#4fd1c5",
colors: ["#feb2b2", "rgba(252, 184, 219, 0.75)", "rgba(239, 194, 250, 0.5)"],
seed: 910001,
blobCount: 9,
intensity: 0.74,
angleDeg: 135,
});
const second = blobGradientPreviewCss({
baseMode: "light",
baseA: "#3182ce",
baseB: "#4fd1c5",
colors: ["#feb2b2", "rgba(252, 184, 219, 0.75)", "rgba(239, 194, 250, 0.5)"],
seed: 910001,
blobCount: 9,
intensity: 0.74,
angleDeg: 135,
});
expect(first).toBe(second);
expect(first).toContain("linear-gradient(");
expect(first).toContain("radial-gradient(");
});
it("keeps two blob rows and one linear row", () => {
expect(GRADIENTS).toHaveLength(24);
expect(GRADIENTS.slice(0, 16).every((gradient) => gradient.includes("radial-gradient("))).toBe(
true,
);
expect(GRADIENTS.slice(16).every((gradient) => gradient.startsWith("linear-gradient("))).toBe(
true,
);
});
});
+366
View File
@@ -0,0 +1,366 @@
type RgbaColor = {
r: number;
g: number;
b: number;
a: number;
};
export type BlobGradientConfig = {
baseMode?: "light" | "dark";
baseA?: string;
baseB?: string;
colors: string[];
seed: number;
blobCount: number;
intensity: number;
angleDeg: number;
};
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function mulberry32(seed: number) {
let state = (Number(seed) || 1) >>> 0;
return () => {
state += 0x6d2b79f5;
let value = Math.imul(state ^ (state >>> 15), 1 | state);
value ^= value + Math.imul(value ^ (value >>> 7), 61 | value);
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
};
}
function canvasAngleDegToCssAngleDeg(angleDeg: number) {
const angle = Number(angleDeg) || 0;
const cssAngle = angle + 90;
return ((cssAngle % 360) + 360) % 360;
}
function parseHexColor(raw: string): RgbaColor | null {
let value = raw.trim().replace(/^#/, "");
if (![3, 4, 6, 8].includes(value.length)) {
return null;
}
if (value.length <= 4) {
value = value
.split("")
.map((character) => `${character}${character}`)
.join("");
}
const parsed = Number.parseInt(value, 16);
if (Number.isNaN(parsed)) {
return null;
}
if (value.length === 6) {
return {
r: (parsed >> 16) & 255,
g: (parsed >> 8) & 255,
b: parsed & 255,
a: 1,
};
}
return {
r: (parsed >> 24) & 255,
g: (parsed >> 16) & 255,
b: (parsed >> 8) & 255,
a: (parsed & 255) / 255,
};
}
function parseRgbColor(raw: string): RgbaColor | null {
const match = raw.match(/^rgba?\((.+)\)$/i);
if (!match) {
return null;
}
const parts = match[1].split(",").map((part) => part.trim());
if (parts.length < 3) {
return null;
}
const [r, g, b, a = "1"] = parts;
return {
r: clamp(Number.parseFloat(r), 0, 255),
g: clamp(Number.parseFloat(g), 0, 255),
b: clamp(Number.parseFloat(b), 0, 255),
a: clamp(Number.parseFloat(a), 0, 1),
};
}
function hueToRgb(p: number, q: number, t: number) {
let next = t;
if (next < 0) next += 1;
if (next > 1) next -= 1;
if (next < 1 / 6) return p + (q - p) * 6 * next;
if (next < 1 / 2) return q;
if (next < 2 / 3) return p + (q - p) * (2 / 3 - next) * 6;
return p;
}
function parseHslColor(raw: string): RgbaColor | null {
const match = raw.match(/^hsla?\((.+)\)$/i);
if (!match) {
return null;
}
const parts = match[1].split(",").map((part) => part.trim());
if (parts.length < 3) {
return null;
}
const [hueRaw, saturationRaw, lightnessRaw, alphaRaw = "1"] = parts;
const hue = ((((Number.parseFloat(hueRaw) % 360) + 360) % 360) / 360);
const saturation = clamp(Number.parseFloat(saturationRaw) / 100, 0, 1);
const lightness = clamp(Number.parseFloat(lightnessRaw) / 100, 0, 1);
const alpha = clamp(Number.parseFloat(alphaRaw), 0, 1);
if (saturation === 0) {
const channel = Math.round(lightness * 255);
return { r: channel, g: channel, b: channel, a: alpha };
}
const q =
lightness < 0.5
? lightness * (1 + saturation)
: lightness + saturation - lightness * saturation;
const p = 2 * lightness - q;
return {
r: Math.round(hueToRgb(p, q, hue + 1 / 3) * 255),
g: Math.round(hueToRgb(p, q, hue) * 255),
b: Math.round(hueToRgb(p, q, hue - 1 / 3) * 255),
a: alpha,
};
}
function parseColor(raw: string): RgbaColor | null {
return parseHexColor(raw) ?? parseRgbColor(raw) ?? parseHslColor(raw);
}
function toCssRgba(color: string, alphaMultiplier = 1) {
const parsed = parseColor(String(color).trim());
if (!parsed) {
return alphaMultiplier <= 0 ? "rgba(0, 0, 0, 0)" : color;
}
const alpha = clamp(parsed.a * alphaMultiplier, 0, 1);
return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${alpha})`;
}
export function blobGradientPreviewCss(config: BlobGradientConfig) {
const baseMode = String(config.baseMode || "dark");
const baseA = String(config.baseA || (baseMode === "light" ? "#ffffff" : "#05060A"));
const baseB = String(config.baseB || (baseMode === "light" ? "#f3f4f6" : "#111827"));
const cssAngleDeg = canvasAngleDegToCssAngleDeg(config.angleDeg);
const colors = Array.isArray(config.colors) ? config.colors.filter(Boolean) : [];
const seed = Number.isFinite(Number(config.seed)) ? Number(config.seed) : 1;
const intensity = clamp(Number(config.intensity) || 0.85, 0, 1);
const blobCount = clamp(Number(config.blobCount) || (baseMode === "light" ? 6 : 7), 3, 10);
const rand = mulberry32(seed);
const pick = (index: number, fallback: string) =>
String(colors[index % Math.max(1, colors.length)] || fallback);
const leadingAlpha = baseMode === "light" ? intensity * 0.3 : intensity * 0.7;
const trailingAlpha = baseMode === "light" ? intensity * 0.18 : intensity * 0.42;
const lobesPerBlob = 2;
const blobs: string[] = [];
for (let index = 0; index < blobCount; index += 1) {
for (let lobe = 0; lobe < lobesPerBlob; lobe += 1) {
const x = Math.round(6 + rand() * 88);
const y = Math.round(6 + rand() * 88);
const stop = Math.round(42 + rand() * 30);
const color = pick(index + lobe, index % 2 ? "#ec4899" : "#8b5cf6");
const alpha = (index === 0 ? leadingAlpha : trailingAlpha) * (lobe === 0 ? 1 : 0.55);
blobs.push(
`radial-gradient(circle at ${x}% ${y}%, ${toCssRgba(color, alpha)} 0%, ${toCssRgba(color, 0)} ${stop}%)`,
);
}
}
const base = `linear-gradient(${cssAngleDeg}deg, ${baseA}, ${baseB})`;
return [base, ...blobs].join(", ");
}
const BLOB_GRADIENT_PRESETS: BlobGradientConfig[] = [
{
baseMode: "light",
baseA: "#3182ce",
baseB: "#4fd1c5",
colors: ["#feb2b2", "rgba(252, 184, 219, 0.75)", "rgba(239, 194, 250, 0.5)", "#348fc5", "#389ac2", "#3ca5c1", "#41b0c2", "#45bbc3"],
seed: 910001,
blobCount: 9,
intensity: 0.74,
angleDeg: 135,
},
{
baseMode: "light",
baseA: "#fdd575",
baseB: "#d22e8c",
colors: ["#4964da", "rgba(146, 73, 221, 0.8)", "rgba(176, 60, 196, 0.6)", "rgba(192, 55, 173, 0.4)", "#fcc06a", "#fbaa63", "#f99262", "#f55973"],
seed: 910002,
blobCount: 9,
intensity: 0.76,
angleDeg: 163,
},
{
baseMode: "light",
baseA: "hsla(173, 77%, 83%, 1)",
baseB: "hsla(173, 77%, 83%, 1)",
colors: ["hsla(250, 76%, 61%, 1)", "hsla(184, 91%, 91%, 1)", "hsla(213, 97%, 75%, 1)", "hsla(149, 93%, 64%, 1)", "hsla(118, 94%, 89%, 1)"],
seed: 910003,
blobCount: 8,
intensity: 0.74,
angleDeg: 146,
},
{
baseMode: "dark",
baseA: "hsla(327, 100%, 50%, 1)",
baseB: "hsla(327, 100%, 50%, 1)",
colors: ["hsla(11, 100%, 50%, 1)", "hsla(236, 100%, 23%, 1)", "hsla(206, 100%, 9%, 1)", "hsla(327, 100%, 50%, 1)"],
seed: 910004,
blobCount: 8,
intensity: 0.82,
angleDeg: 142,
},
{
baseMode: "light",
baseA: "hsla(197, 92%, 60%, 1)",
baseB: "hsla(197, 92%, 60%, 1)",
colors: ["hsla(55, 93%, 54%, 0.85)", "hsla(229, 71%, 68%, 1)", "hsla(308, 93%, 51%, 1)", "hsla(318, 60%, 76%, 1)", "hsla(324, 92%, 59%, 1)"],
seed: 910005,
blobCount: 9,
intensity: 0.76,
angleDeg: 138,
},
{
baseMode: "light",
baseA: "hsla(259, 100%, 63%, 1)",
baseB: "hsla(259, 100%, 63%, 1)",
colors: ["hsla(318, 80%, 97%, 1)", "hsla(161, 99%, 84%, 1)", "hsla(163, 90%, 78%, 1)", "hsla(314, 91%, 59%, 1)"],
seed: 910006,
blobCount: 9,
intensity: 0.74,
angleDeg: 132,
},
{
baseMode: "light",
baseA: "hsla(259, 84%, 37%, 1)",
baseB: "hsla(259, 84%, 37%, 1)",
colors: ["hsla(248, 82%, 99%, 1)", "hsla(318, 83%, 77%, 1)", "hsla(302, 95%, 45%, 1)", "hsla(210, 87%, 65%, 1)", "hsla(317, 98%, 84%, 1)"],
seed: 910007,
blobCount: 9,
intensity: 0.75,
angleDeg: 136,
},
{
baseMode: "light",
baseA: "hsla(185, 58%, 56%, 1)",
baseB: "hsla(185, 58%, 56%, 1)",
colors: ["hsla(253, 66%, 75%, 1)", "hsla(226, 66%, 63%, 1)", "hsla(359, 63%, 67%, 1)", "hsla(181, 88%, 59%, 1)", "hsla(293, 91%, 54%, 1)", "hsla(293, 89%, 59%, 1)", "hsla(197, 99%, 92%, 1)", "hsla(251, 96%, 90%, 1)", "hsla(270, 63%, 55%, 1)", "hsla(278, 99%, 97%, 1)"],
seed: 910008,
blobCount: 9,
intensity: 0.76,
angleDeg: 129,
},
{
baseMode: "light",
baseA: "hsla(204, 0%, 100%, 1)",
baseB: "hsla(204, 0%, 100%, 1)",
colors: ["hsla(295, 77%, 74%, 0.35)", "hsla(236, 77%, 74%, 0.35)", "hsla(186, 77%, 74%, 0.35)", "hsla(127, 77%, 74%, 0.35)", "hsla(62, 77%, 74%, 0.35)", "hsla(23, 77%, 74%, 0.35)"],
seed: 910009,
blobCount: 9,
intensity: 0.7,
angleDeg: 135,
},
{
baseMode: "light",
baseA: "hsla(240,100%,59%,0.47)",
baseB: "hsla(240,100%,59%,0.47)",
colors: ["hsla(198,71%,73%,1)", "hsla(283,100%,56%,1)", "hsla(300,100%,50%,1)", "hsla(189,100%,50%,1)", "hsla(266,100%,50%,1)"],
seed: 830001,
blobCount: 9,
intensity: 0.76,
angleDeg: 135,
},
{
baseMode: "light",
baseA: "hsla(324,100%,50%,1)",
baseB: "hsla(324,100%,50%,1)",
colors: ["hsla(235,100%,79%,1)", "hsla(189,100%,56%,1)", "hsla(355,100%,93%,1)", "hsla(340,100%,50%,1)", "hsla(0,100%,70%,1)", "hsla(201,100%,67%,1)", "hsla(312,100%,50%,1)"],
seed: 830002,
blobCount: 9,
intensity: 0.76,
angleDeg: 135,
},
{
baseMode: "light",
baseA: "hsla(359,100%,68%,1)",
baseB: "hsla(359,100%,68%,1)",
colors: ["hsla(202,90%,65%,1)", "hsla(300,100%,50%,1)", "hsla(172,100%,50%,1)", "hsla(239,100%,60%,1)", "hsla(360,100%,50%,1)", "hsla(275,85%,76%,1)", "hsla(238,67%,70%,1)"],
seed: 830003,
blobCount: 9,
intensity: 0.76,
angleDeg: 135,
},
{
baseMode: "light",
baseA: "#ff99bb",
baseB: "#ff99bb",
colors: ["hsla(295,85%,65%,1)", "hsla(30,70%,75%,1)", "hsla(76,94%,67%,1)", "hsla(276,74%,63%,1)", "hsla(169,75%,74%,1)", "hsla(226,93%,60%,1)", "hsla(28,87%,67%,1)"],
seed: 500001,
blobCount: 9,
intensity: 0.76,
angleDeg: 135,
},
{
baseMode: "light",
baseA: "#99ecff",
baseB: "#99ecff",
colors: ["hsla(200,71%,65%,1)", "hsla(350,60%,70%,1)", "hsla(308,94%,62%,1)", "hsla(154,90%,61%,1)", "hsla(331,62%,72%,1)", "hsla(125,87%,71%,1)", "hsla(336,62%,60%,1)"],
seed: 509974,
blobCount: 9,
intensity: 0.76,
angleDeg: 135,
},
{
baseMode: "light",
baseA: "#9c99ff",
baseB: "#9c99ff",
colors: ["hsla(238,99%,74%,1)", "hsla(187,94%,62%,1)", "hsla(209,89%,76%,1)", "hsla(337,98%,60%,1)", "hsla(116,80%,68%,1)", "hsla(3,72%,71%,1)", "hsla(336,60%,72%,1)"],
seed: 519947,
blobCount: 9,
intensity: 0.76,
angleDeg: 135,
},
{
baseMode: "light",
baseA: "#ffbd99",
baseB: "#ffbd99",
colors: ["hsla(207,76%,65%,1)", "hsla(350,69%,69%,1)", "hsla(269,69%,78%,1)", "hsla(157,97%,76%,1)", "hsla(231,71%,63%,1)", "hsla(242,93%,74%,1)", "hsla(276,80%,78%,1)"],
seed: 529920,
blobCount: 9,
intensity: 0.76,
angleDeg: 135,
},
];
const LINEAR_GRADIENTS = [
"linear-gradient(135deg, #6D28D9 0%, #EA580C 100%)",
"linear-gradient(135deg, #1D4ED8 0%, #0891B2 100%)",
"linear-gradient(135deg, #7C3AED 0%, #DB2777 100%)",
"linear-gradient(135deg, #4338CA 0%, #A21CAF 100%)",
"linear-gradient(135deg, #7C3AED 0%, #F97316 100%)",
"linear-gradient(135deg, #0EA5E9 0%, #1D4ED8 100%)",
"linear-gradient(135deg, #FB7185 0%, #F59E0B 100%)",
"linear-gradient(135deg, #0B1020 0%, #7700FF 100%)",
];
export const GRADIENTS = [
...BLOB_GRADIENT_PRESETS.map(blobGradientPreviewCss),
...LINEAR_GRADIENTS,
];
@@ -0,0 +1,45 @@
import { describe, expect, it, vi } from "vitest";
import {
isCssGradientBackground,
paintCssGradientBackground,
} from "./cssGradientBackground";
function createMockGradient() {
return {
addColorStop: vi.fn(),
};
}
function createMockContext() {
return {
createLinearGradient: vi.fn(() => createMockGradient()),
createRadialGradient: vi.fn(() => createMockGradient()),
fillRect: vi.fn(),
save: vi.fn(),
restore: vi.fn(),
translate: vi.fn(),
scale: vi.fn(),
fillStyle: "",
} as unknown as CanvasRenderingContext2D;
}
describe("cssGradientBackground", () => {
it("recognizes layered css gradients", () => {
expect(
isCssGradientBackground(
"radial-gradient(20% 30% at 10% 20%, #ffffff 0%, #00000000 100%), linear-gradient(135deg, #111111 0%, #222222 100%)",
),
).toBe(true);
});
it("renders layered gradients back-to-front onto canvas", () => {
const ctx = createMockContext();
const gradient =
"radial-gradient(22% 34% at 12% 18%, #CEFAFFCC 0%, #00000000 78%), radial-gradient(28% 36% at 86% 84%, #8A4FFFF5 10%, #00000000 84%), linear-gradient(125deg, #4EB5FFFF 1%, #4C00FCFF 100%)";
expect(paintCssGradientBackground(ctx, gradient, 1920, 1080)).toBe(true);
expect(ctx.createLinearGradient).toHaveBeenCalledTimes(1);
expect(ctx.createRadialGradient).toHaveBeenCalledTimes(2);
expect(ctx.fillRect).toHaveBeenCalledTimes(3);
});
});
+360
View File
@@ -0,0 +1,360 @@
type GradientStop = {
color: string;
position?: number;
};
type LinearGradientLayer = {
type: "linear";
angleDeg: number;
stops: GradientStop[];
};
type RadialGradientLayer = {
type: "radial";
centerX: number;
centerY: number;
radiusX: number;
radiusY: number;
stops: GradientStop[];
};
type GradientLayer = LinearGradientLayer | RadialGradientLayer;
const COLOR_PREFIX_PATTERN =
/^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|hsla?\([^)]+\)|[a-zA-Z]+)\s*(.*)$/;
export function isCssGradientBackground(value: string): boolean {
if (!value) {
return false;
}
return value.startsWith("linear-gradient") || value.startsWith("radial-gradient");
}
export function paintCssGradientBackground(
ctx: CanvasRenderingContext2D,
background: string,
width: number,
height: number,
): boolean {
const layers = splitTopLevel(background)
.map((layer) => layer.trim())
.filter((layer) => layer.length > 0)
.map((layer) => parseGradientLayer(layer, width, height));
if (layers.length === 0 || layers.some((layer) => layer === null)) {
return false;
}
for (const layer of [...(layers as GradientLayer[])].reverse()) {
drawGradientLayer(ctx, layer, width, height);
}
return true;
}
function splitTopLevel(value: string): string[] {
const parts: string[] = [];
let current = "";
let depth = 0;
for (const character of value) {
if (character === "(") {
depth += 1;
} else if (character === ")") {
depth = Math.max(0, depth - 1);
}
if (character === "," && depth === 0) {
parts.push(current);
current = "";
continue;
}
current += character;
}
if (current.trim().length > 0) {
parts.push(current);
}
return parts;
}
function parseGradientLayer(
layer: string,
width: number,
height: number,
): GradientLayer | null {
const match = layer.match(/^(linear|radial)-gradient\((.*)\)$/i);
if (!match) {
return null;
}
const [, rawType, rawParams] = match;
const parts = splitTopLevel(rawParams).map((part) => part.trim()).filter(Boolean);
if (parts.length === 0) {
return null;
}
if (rawType === "linear") {
return parseLinearGradient(parts, width, height);
}
return parseRadialGradient(parts, width, height);
}
function parseLinearGradient(
parts: string[],
_width: number,
_height: number,
): LinearGradientLayer | null {
let angleDeg = 180;
let stopParts = parts;
if (parts[0] && isLinearDirection(parts[0])) {
angleDeg = parseLinearAngle(parts[0]);
stopParts = parts.slice(1);
}
const stops = parseGradientStops(stopParts);
if (stops.length === 0) {
return null;
}
return { type: "linear", angleDeg, stops };
}
function parseRadialGradient(
parts: string[],
width: number,
height: number,
): RadialGradientLayer | null {
let descriptor = "";
let stopParts = parts;
if (parts[0] && !startsWithColor(parts[0])) {
descriptor = parts[0];
stopParts = parts.slice(1);
}
const stops = parseGradientStops(stopParts);
if (stops.length === 0) {
return null;
}
const defaults = {
centerX: width / 2,
centerY: height / 2,
radiusX: Math.max(width, height) / 2,
radiusY: Math.max(width, height) / 2,
};
if (!descriptor) {
return { type: "radial", ...defaults, stops };
}
const positionMatch = descriptor.match(/at\s+([0-9.]+)%\s+([0-9.]+)%/i);
const centerX = positionMatch ? (Number(positionMatch[1]) / 100) * width : defaults.centerX;
const centerY = positionMatch ? (Number(positionMatch[2]) / 100) * height : defaults.centerY;
const ellipseMatch = descriptor.match(/([0-9.]+)%\s+([0-9.]+)%\s+at\s+[0-9.]+%\s+[0-9.]+%/i);
if (ellipseMatch) {
return {
type: "radial",
centerX,
centerY,
radiusX: Math.max((Number(ellipseMatch[1]) / 100) * width, 1),
radiusY: Math.max((Number(ellipseMatch[2]) / 100) * height, 1),
stops,
};
}
if (/circle\s+farthest-corner/i.test(descriptor)) {
const radius = Math.max(
hypot(centerX, centerY),
hypot(width - centerX, centerY),
hypot(centerX, height - centerY),
hypot(width - centerX, height - centerY),
);
return {
type: "radial",
centerX,
centerY,
radiusX: radius,
radiusY: radius,
stops,
};
}
if (/circle\s+at/i.test(descriptor)) {
const radius = Math.max(
hypot(centerX, centerY),
hypot(width - centerX, centerY),
hypot(centerX, height - centerY),
hypot(width - centerX, height - centerY),
);
return {
type: "radial",
centerX,
centerY,
radiusX: radius,
radiusY: radius,
stops,
};
}
return { type: "radial", centerX, centerY, radiusX: defaults.radiusX, radiusY: defaults.radiusY, stops };
}
function drawGradientLayer(
ctx: CanvasRenderingContext2D,
layer: GradientLayer,
width: number,
height: number,
) {
if (layer.type === "linear") {
const { startX, startY, endX, endY } = computeLinearEndpoints(
layer.angleDeg,
width,
height,
);
const gradient = ctx.createLinearGradient(startX, startY, endX, endY);
applyGradientStops(gradient, layer.stops);
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
return;
}
if (Math.abs(layer.radiusX - layer.radiusY) < 0.5) {
const gradient = ctx.createRadialGradient(
layer.centerX,
layer.centerY,
0,
layer.centerX,
layer.centerY,
Math.max(layer.radiusX, 1),
);
applyGradientStops(gradient, layer.stops);
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
return;
}
ctx.save();
ctx.translate(layer.centerX, layer.centerY);
ctx.scale(layer.radiusX, layer.radiusY);
const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, 1);
applyGradientStops(gradient, layer.stops);
ctx.fillStyle = gradient;
ctx.fillRect(-2, -2, 4, 4);
ctx.restore();
}
function parseGradientStops(parts: string[]): GradientStop[] {
const stops = parts
.map((part) => parseGradientStop(part))
.filter((stop): stop is GradientStop => stop !== null);
if (stops.length === 0) {
return [];
}
assignStopPositions(stops);
return stops;
}
function parseGradientStop(part: string): GradientStop | null {
const match = part.match(COLOR_PREFIX_PATTERN);
if (!match) {
return null;
}
const [, color, remainder] = match;
const positionMatch = remainder.match(/([0-9.]+)%/);
return {
color,
position: positionMatch ? Number(positionMatch[1]) / 100 : undefined,
};
}
function assignStopPositions(stops: GradientStop[]) {
if (stops.length === 1) {
stops[0].position ??= 0;
return;
}
stops[0].position ??= 0;
stops[stops.length - 1].position ??= 1;
let segmentStart = 0;
for (let index = 1; index < stops.length; index += 1) {
if (stops[index].position == null) {
continue;
}
const start = stops[segmentStart].position ?? 0;
const end = stops[index].position ?? 1;
const gap = index - segmentStart;
for (let offset = 1; offset < gap; offset += 1) {
stops[segmentStart + offset].position = start + ((end - start) * offset) / gap;
}
segmentStart = index;
}
}
function applyGradientStops(gradient: CanvasGradient, stops: GradientStop[]) {
for (const stop of stops) {
gradient.addColorStop(stop.position ?? 0, stop.color);
}
}
function isLinearDirection(value: string): boolean {
return value.startsWith("to ") || value.includes("deg");
}
function parseLinearAngle(value: string): number {
const degreeMatch = value.match(/(-?[0-9.]+)deg/i);
if (degreeMatch) {
return Number(degreeMatch[1]);
}
const normalized = value.trim().toLowerCase();
if (normalized === "to top") return 0;
if (normalized === "to right") return 90;
if (normalized === "to bottom") return 180;
if (normalized === "to left") return 270;
if (normalized === "to top right" || normalized === "to right top") return 45;
if (normalized === "to bottom right" || normalized === "to right bottom") return 135;
if (normalized === "to bottom left" || normalized === "to left bottom") return 225;
if (normalized === "to top left" || normalized === "to left top") return 315;
return 180;
}
function computeLinearEndpoints(angleDeg: number, width: number, height: number) {
const radians = (angleDeg * Math.PI) / 180;
const directionX = Math.sin(radians);
const directionY = -Math.cos(radians);
const halfWidth = width / 2;
const halfHeight = height / 2;
const scaleX = directionX === 0 ? 0 : halfWidth / Math.abs(directionX);
const scaleY = directionY === 0 ? 0 : halfHeight / Math.abs(directionY);
const reach = Math.max(scaleX, scaleY);
const centerX = halfWidth;
const centerY = halfHeight;
return {
startX: centerX - directionX * reach,
startY: centerY - directionY * reach,
endX: centerX + directionX * reach,
endY: centerY + directionY * reach,
};
}
function startsWithColor(value: string): boolean {
return COLOR_PREFIX_PATTERN.test(value.trim());
}
function hypot(x: number, y: number) {
return Math.sqrt(x * x + y * y);
}
+3 -46
View File
@@ -68,6 +68,7 @@ import { clampMediaTimeToDuration } from "@/lib/mediaTiming";
import { isVideoWallpaperSource } from "@/lib/wallpapers";
import { renderAnnotations } from "./annotationRenderer";
import { renderCaptions } from "./captionRenderer";
import { isCssGradientBackground, paintCssGradientBackground } from "./cssGradientBackground";
import { ForwardFrameSource } from "./forwardFrameSource";
import { resolveMediaElementSource } from "./localMediaSource";
@@ -498,52 +499,8 @@ export class FrameRenderer {
} else if (wallpaper.startsWith("#")) {
bgCtx.fillStyle = wallpaper;
bgCtx.fillRect(0, 0, this.config.width, this.config.height);
} else if (
wallpaper.startsWith("linear-gradient") ||
wallpaper.startsWith("radial-gradient")
) {
const gradientMatch = wallpaper.match(/(linear|radial)-gradient\((.+)\)/);
if (gradientMatch) {
const [, type, params] = gradientMatch;
const parts = params.split(",").map((s) => s.trim());
let gradient: CanvasGradient;
if (type === "linear") {
gradient = bgCtx.createLinearGradient(0, 0, 0, this.config.height);
parts.forEach((part, index) => {
if (part.startsWith("to ") || part.includes("deg")) return;
const colorMatch = part.match(
/^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|[a-z]+)/,
);
if (colorMatch) {
const color = colorMatch[1];
const position = index / (parts.length - 1);
gradient.addColorStop(position, color);
}
});
} else {
const cx = this.config.width / 2;
const cy = this.config.height / 2;
const radius = Math.max(this.config.width, this.config.height) / 2;
gradient = bgCtx.createRadialGradient(cx, cy, 0, cx, cy, radius);
parts.forEach((part, index) => {
const colorMatch = part.match(
/^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|[a-z]+)/,
);
if (colorMatch) {
const color = colorMatch[1];
const position = index / (parts.length - 1);
gradient.addColorStop(position, color);
}
});
}
bgCtx.fillStyle = gradient;
bgCtx.fillRect(0, 0, this.config.width, this.config.height);
} else {
} else if (isCssGradientBackground(wallpaper)) {
if (!paintCssGradientBackground(bgCtx, wallpaper, this.config.width, this.config.height)) {
console.warn("[FrameRenderer] Could not parse gradient, using black fallback");
bgCtx.fillStyle = "#000000";
bgCtx.fillRect(0, 0, this.config.width, this.config.height);
+3 -37
View File
@@ -85,6 +85,7 @@ import {
VIDEO_SHADOW_LAYER_PROFILES,
WEBCAM_SHADOW_LAYER_PROFILES,
} from "./shadowProfile";
import { isCssGradientBackground, paintCssGradientBackground } from "./cssGradientBackground";
import type { ExportRenderBackend } from "./types";
interface FrameRenderConfig {
@@ -962,45 +963,10 @@ export class FrameRenderer {
} else if (wallpaper.startsWith("#")) {
bgCtx.fillStyle = wallpaper;
bgCtx.fillRect(0, 0, this.config.width, this.config.height);
} else if (
wallpaper.startsWith("linear-gradient") ||
wallpaper.startsWith("radial-gradient")
) {
const gradientMatch = wallpaper.match(/(linear|radial)-gradient\((.+)\)/);
if (!gradientMatch) {
} else if (isCssGradientBackground(wallpaper)) {
if (!paintCssGradientBackground(bgCtx, wallpaper, this.config.width, this.config.height)) {
bgCtx.fillStyle = "#000000";
bgCtx.fillRect(0, 0, this.config.width, this.config.height);
} else {
const [, type, params] = gradientMatch;
const parts = params.split(",").map((value) => value.trim());
const gradient =
type === "linear"
? bgCtx.createLinearGradient(0, 0, 0, this.config.height)
: bgCtx.createRadialGradient(
this.config.width / 2,
this.config.height / 2,
0,
this.config.width / 2,
this.config.height / 2,
Math.max(this.config.width, this.config.height) / 2,
);
parts.forEach((part, index) => {
if (type === "linear" && (part.startsWith("to ") || part.includes("deg"))) {
return;
}
const colorMatch = part.match(/^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|[a-z]+)/);
if (!colorMatch) {
return;
}
const position = index / Math.max(parts.length - 1, 1);
gradient.addColorStop(position, colorMatch[1]);
});
bgCtx.fillStyle = gradient;
bgCtx.fillRect(0, 0, this.config.width, this.config.height);
}
} else {
bgCtx.fillStyle = wallpaper;
+48
View File
@@ -0,0 +1,48 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
BUILT_IN_WALLPAPERS,
DEFAULT_WALLPAPER_PATH,
DEFAULT_WALLPAPER_RELATIVE_PATH,
getAvailableWallpapers,
} from "./wallpapers";
describe("wallpapers", () => {
beforeEach(() => {
vi.unstubAllGlobals();
});
it("keeps the curated wallpaper list and default path aligned", () => {
expect(DEFAULT_WALLPAPER_PATH).toBe("/wallpapers/midnight-8.jpg");
expect(DEFAULT_WALLPAPER_RELATIVE_PATH).toBe("wallpapers/midnight-8.jpg");
expect(BUILT_IN_WALLPAPERS.at(0)?.publicPath).toBe(DEFAULT_WALLPAPER_PATH);
expect(BUILT_IN_WALLPAPERS).toHaveLength(25);
});
it("preserves the curated order when asset discovery returns extra files", async () => {
vi.stubGlobal("window", {
electronAPI: {
listAssetDirectory: vi.fn(async () => ({
success: true,
files: [
"wallpaper1.jpg",
"energy-17.jpg",
"midnight-8.jpg",
"wallpaper4.jpg",
"wispysky.mp4",
"cityscape.jpg",
"ipad-17-light.jpg",
],
})),
},
});
await expect(getAvailableWallpapers()).resolves.toEqual([
BUILT_IN_WALLPAPERS[0],
BUILT_IN_WALLPAPERS[2],
BUILT_IN_WALLPAPERS[15],
BUILT_IN_WALLPAPERS[16],
BUILT_IN_WALLPAPERS[23],
BUILT_IN_WALLPAPERS[24],
]);
});
});
+36 -157
View File
@@ -9,158 +9,39 @@ const IMAGE_FILE_PATTERN = /\.(avif|gif|jpe?g|png|svg|webp)$/i;
const VIDEO_FILE_PATTERN = /\.(avi|m4v|mkv|mov|mp4|webm)$/i;
export const BUILT_IN_WALLPAPERS: BuiltInWallpaper[] = [
{
id: "wallpaper-1",
label: "Wallpaper 1",
relativePath: "wallpapers/wallpaper1.jpg",
publicPath: "/wallpapers/wallpaper1.jpg",
},
{
id: "wallpaper-2",
label: "Wallpaper 2",
relativePath: "wallpapers/wallpaper2.jpg",
publicPath: "/wallpapers/wallpaper2.jpg",
},
{
id: "wallpaper-3",
label: "Wallpaper 3",
relativePath: "wallpapers/wallpaper3.jpg",
publicPath: "/wallpapers/wallpaper3.jpg",
},
{
id: "wallpaper-4",
label: "Wallpaper 4",
relativePath: "wallpapers/wallpaper4.jpg",
publicPath: "/wallpapers/wallpaper4.jpg",
},
{
id: "wallpaper-5",
label: "Wallpaper 5",
relativePath: "wallpapers/wallpaper5.jpg",
publicPath: "/wallpapers/wallpaper5.jpg",
},
{
id: "wallpaper-6",
label: "Wallpaper 6",
relativePath: "wallpapers/wallpaper6.jpg",
publicPath: "/wallpapers/wallpaper6.jpg",
},
{
id: "wallpaper-7",
label: "Wallpaper 7",
relativePath: "wallpapers/wallpaper7.jpg",
publicPath: "/wallpapers/wallpaper7.jpg",
},
{
id: "wallpaper-8",
label: "Wallpaper 8",
relativePath: "wallpapers/wallpaper8.jpg",
publicPath: "/wallpapers/wallpaper8.jpg",
},
{
id: "wallpaper-9",
label: "Wallpaper 9",
relativePath: "wallpapers/wallpaper9.jpg",
publicPath: "/wallpapers/wallpaper9.jpg",
},
{
id: "wallpaper-10",
label: "Wallpaper 10",
relativePath: "wallpapers/wallpaper10.jpg",
publicPath: "/wallpapers/wallpaper10.jpg",
},
{
id: "wallpaper-11",
label: "Wallpaper 11",
relativePath: "wallpapers/wallpaper11.jpg",
publicPath: "/wallpapers/wallpaper11.jpg",
},
{
id: "wallpaper-12",
label: "Wallpaper 12",
relativePath: "wallpapers/wallpaper12.jpg",
publicPath: "/wallpapers/wallpaper12.jpg",
},
{
id: "wallpaper-13",
label: "Wallpaper 13",
relativePath: "wallpapers/wallpaper13.jpg",
publicPath: "/wallpapers/wallpaper13.jpg",
},
{
id: "wallpaper-14",
label: "Wallpaper 14",
relativePath: "wallpapers/wallpaper14.jpg",
publicPath: "/wallpapers/wallpaper14.jpg",
},
{
id: "wallpaper-15",
label: "Wallpaper 15",
relativePath: "wallpapers/wallpaper15.jpg",
publicPath: "/wallpapers/wallpaper15.jpg",
},
{
id: "wallpaper-16",
label: "Wallpaper 16",
relativePath: "wallpapers/wallpaper16.jpg",
publicPath: "/wallpapers/wallpaper16.jpg",
},
{
id: "wallpaper-17",
label: "Wallpaper 17",
relativePath: "wallpapers/wallpaper17.jpg",
publicPath: "/wallpapers/wallpaper17.jpg",
},
{
id: "wallpaper-18",
label: "Wallpaper 18",
relativePath: "wallpapers/wallpaper18.jpg",
publicPath: "/wallpapers/wallpaper18.jpg",
},
{
id: "cityscape",
label: "Cityscape",
relativePath: "wallpapers/cityscape.jpg",
publicPath: "/wallpapers/cityscape.jpg",
},
{
id: "farmvalley",
label: "Farm Valley",
relativePath: "wallpapers/farmvalley.jpg",
publicPath: "/wallpapers/farmvalley.jpg",
},
{
id: "levels",
label: "Levels",
relativePath: "wallpapers/levels.jpg",
publicPath: "/wallpapers/levels.jpg",
},
{
id: "mountaintrees",
label: "Mountain Trees",
relativePath: "wallpapers/mountaintrees.jpg",
publicPath: "/wallpapers/mountaintrees.jpg",
},
{
id: "luisdelrio",
label: "Luis Del Rio",
relativePath: "wallpapers/luisdelrio.jpg",
publicPath: "/wallpapers/luisdelrio.jpg",
},
{
id: "wispysky",
label: "Wispy Sky",
relativePath: "wallpapers/wispysky.mp4",
publicPath: "/wallpapers/wispysky.mp4",
},
createWallpaperEntry("midnight-8.jpg", "Midnight 8"),
createWallpaperEntry("ipad-17-dark.jpg", "iPad 17 Dark"),
createWallpaperEntry("ipad-17-light.jpg", "iPad 17 Light"),
createWallpaperEntry("sequoia-blue.jpg", "Sequoia Blue"),
createWallpaperEntry("sequoia-blue-orange.jpg", "Sequoia Blue Orange"),
createWallpaperEntry("ventura.jpg", "Ventura"),
createWallpaperEntry("tahoe-light.jpg", "Tahoe Light"),
createWallpaperEntry("tahoe-dark.jpg", "Tahoe Dark"),
createWallpaperEntry("sonoma-clouds.jpg", "Sonoma Clouds"),
createWallpaperEntry("sonoma-light.jpg", "Sonoma Light"),
createWallpaperEntry("sonoma-dark.jpg", "Sonoma Dark"),
createWallpaperEntry("glassmorphism-3.jpg", "Glassmorphism 3"),
createWallpaperEntry("glassmorphism-4.jpg", "Glassmorphism 4"),
createWallpaperEntry("energy-19.jpg", "Energy 19"),
createWallpaperEntry("wallpaper3.jpg", "Wallpaper 3"),
createWallpaperEntry("wallpaper4.jpg", "Wallpaper 4"),
createWallpaperEntry("cityscape.jpg", "Cityscape"),
createWallpaperEntry("levels.jpg", "Levels"),
createWallpaperEntry("wallpaper10.jpg", "Wallpaper 10"),
createWallpaperEntry("ventura-dark.jpg", "Ventura Dark"),
createWallpaperEntry("sonoma-evening.jpg", "Sonoma Evening"),
createWallpaperEntry("sonoma-horizon.jpg", "Sonoma Horizon"),
createWallpaperEntry("iridescent-9.jpg", "Iridescent 9"),
createWallpaperEntry("energy-17.jpg", "Energy 17"),
createWallpaperEntry("wispysky.mp4", "Wispy Sky"),
];
export const WALLPAPER_PATHS = BUILT_IN_WALLPAPERS.map((wallpaper) => wallpaper.publicPath);
export const WALLPAPER_RELATIVE_PATHS = BUILT_IN_WALLPAPERS.map(
(wallpaper) => wallpaper.relativePath,
);
export const DEFAULT_WALLPAPER_PATH = "/wallpapers/wallpaper2.jpg";
export const DEFAULT_WALLPAPER_RELATIVE_PATH = "wallpapers/wallpaper2.jpg";
export const DEFAULT_WALLPAPER_PATH = "/wallpapers/midnight-8.jpg";
export const DEFAULT_WALLPAPER_RELATIVE_PATH = "wallpapers/midnight-8.jpg";
export function isVideoWallpaperSource(value: string): boolean {
if (!value) {
@@ -192,22 +73,16 @@ function toWallpaperLabel(fileName: string) {
.replace(/\b\w/g, (match) => match.toUpperCase());
}
function createWallpaperEntry(fileName: string): BuiltInWallpaper {
function createWallpaperEntry(fileName: string, label = toWallpaperLabel(fileName)): BuiltInWallpaper {
const encodedFileName = encodeURIComponent(fileName);
return {
id: toWallpaperId(fileName) || `wallpaper-${encodedFileName.toLowerCase()}`,
label: toWallpaperLabel(fileName),
label,
relativePath: `wallpapers/${fileName}`,
publicPath: `/wallpapers/${encodedFileName}`,
};
}
function sortWallpaperFiles(fileNames: string[]) {
return [...fileNames].sort(
new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare,
);
}
export async function getAvailableWallpapers(): Promise<BuiltInWallpaper[]> {
const fallbackWallpapers = BUILT_IN_WALLPAPERS;
@@ -221,18 +96,22 @@ export async function getAvailableWallpapers(): Promise<BuiltInWallpaper[]> {
return fallbackWallpapers;
}
const discoveredFiles = sortWallpaperFiles(
const discoveredFiles = new Set(
result.files.filter(
(fileName) =>
IMAGE_FILE_PATTERN.test(fileName) || VIDEO_FILE_PATTERN.test(fileName),
),
);
if (discoveredFiles.length === 0) {
if (discoveredFiles.size === 0) {
return fallbackWallpapers;
}
return discoveredFiles.map(createWallpaperEntry);
const curatedWallpapers = fallbackWallpapers.filter((wallpaper) =>
discoveredFiles.has(wallpaper.relativePath.replace(/^wallpapers\//, "")),
);
return curatedWallpapers.length > 0 ? curatedWallpapers : fallbackWallpapers;
} catch {
return fallbackWallpapers;
}