Refresh bundled wallpapers and gradient presets
No files matched your search
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 82 KiB |
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||