diff --git a/public/wallpapers/energy-17.jpg b/public/wallpapers/energy-17.jpg new file mode 100644 index 00000000..713695fb Binary files /dev/null and b/public/wallpapers/energy-17.jpg differ diff --git a/public/wallpapers/energy-19.jpg b/public/wallpapers/energy-19.jpg new file mode 100644 index 00000000..dfabecec Binary files /dev/null and b/public/wallpapers/energy-19.jpg differ diff --git a/public/wallpapers/glassmorphism-3.jpg b/public/wallpapers/glassmorphism-3.jpg new file mode 100644 index 00000000..986c69a3 Binary files /dev/null and b/public/wallpapers/glassmorphism-3.jpg differ diff --git a/public/wallpapers/glassmorphism-4.jpg b/public/wallpapers/glassmorphism-4.jpg new file mode 100644 index 00000000..1f9aaffd Binary files /dev/null and b/public/wallpapers/glassmorphism-4.jpg differ diff --git a/public/wallpapers/ipad-17-dark.jpg b/public/wallpapers/ipad-17-dark.jpg new file mode 100644 index 00000000..f1782e17 Binary files /dev/null and b/public/wallpapers/ipad-17-dark.jpg differ diff --git a/public/wallpapers/ipad-17-light.jpg b/public/wallpapers/ipad-17-light.jpg new file mode 100644 index 00000000..738ecf99 Binary files /dev/null and b/public/wallpapers/ipad-17-light.jpg differ diff --git a/public/wallpapers/iridescent-9.jpg b/public/wallpapers/iridescent-9.jpg new file mode 100644 index 00000000..b33c7f83 Binary files /dev/null and b/public/wallpapers/iridescent-9.jpg differ diff --git a/public/wallpapers/midnight-8.jpg b/public/wallpapers/midnight-8.jpg new file mode 100644 index 00000000..21932e70 Binary files /dev/null and b/public/wallpapers/midnight-8.jpg differ diff --git a/public/wallpapers/sequoia-blue-orange.jpg b/public/wallpapers/sequoia-blue-orange.jpg new file mode 100644 index 00000000..904fab1b Binary files /dev/null and b/public/wallpapers/sequoia-blue-orange.jpg differ diff --git a/public/wallpapers/sequoia-blue.jpg b/public/wallpapers/sequoia-blue.jpg new file mode 100644 index 00000000..a0dfb46d Binary files /dev/null and b/public/wallpapers/sequoia-blue.jpg differ diff --git a/public/wallpapers/sonoma-clouds.jpg b/public/wallpapers/sonoma-clouds.jpg new file mode 100644 index 00000000..e60330f1 Binary files /dev/null and b/public/wallpapers/sonoma-clouds.jpg differ diff --git a/public/wallpapers/sonoma-dark.jpg b/public/wallpapers/sonoma-dark.jpg new file mode 100644 index 00000000..e11d78a2 Binary files /dev/null and b/public/wallpapers/sonoma-dark.jpg differ diff --git a/public/wallpapers/sonoma-evening.jpg b/public/wallpapers/sonoma-evening.jpg new file mode 100644 index 00000000..d61e60d6 Binary files /dev/null and b/public/wallpapers/sonoma-evening.jpg differ diff --git a/public/wallpapers/sonoma-horizon.jpg b/public/wallpapers/sonoma-horizon.jpg new file mode 100644 index 00000000..1e237181 Binary files /dev/null and b/public/wallpapers/sonoma-horizon.jpg differ diff --git a/public/wallpapers/sonoma-light.jpg b/public/wallpapers/sonoma-light.jpg new file mode 100644 index 00000000..44ef9efd Binary files /dev/null and b/public/wallpapers/sonoma-light.jpg differ diff --git a/public/wallpapers/tahoe-dark.jpg b/public/wallpapers/tahoe-dark.jpg new file mode 100644 index 00000000..e83fce96 Binary files /dev/null and b/public/wallpapers/tahoe-dark.jpg differ diff --git a/public/wallpapers/tahoe-light.jpg b/public/wallpapers/tahoe-light.jpg new file mode 100644 index 00000000..93001c56 Binary files /dev/null and b/public/wallpapers/tahoe-light.jpg differ diff --git a/public/wallpapers/ventura-dark.jpg b/public/wallpapers/ventura-dark.jpg new file mode 100644 index 00000000..09267bcc Binary files /dev/null and b/public/wallpapers/ventura-dark.jpg differ diff --git a/public/wallpapers/ventura.jpg b/public/wallpapers/ventura.jpg new file mode 100644 index 00000000..77e3c5c9 Binary files /dev/null and b/public/wallpapers/ventura.jpg differ diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 51b1a0c7..7e4a2327 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -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"; } diff --git a/src/lib/backgroundGradients.test.ts b/src/lib/backgroundGradients.test.ts new file mode 100644 index 00000000..807f5185 --- /dev/null +++ b/src/lib/backgroundGradients.test.ts @@ -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, + ); + }); +}); \ No newline at end of file diff --git a/src/lib/backgroundGradients.ts b/src/lib/backgroundGradients.ts new file mode 100644 index 00000000..5086cf1d --- /dev/null +++ b/src/lib/backgroundGradients.ts @@ -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, +]; \ No newline at end of file diff --git a/src/lib/exporter/cssGradientBackground.test.ts b/src/lib/exporter/cssGradientBackground.test.ts new file mode 100644 index 00000000..5484211f --- /dev/null +++ b/src/lib/exporter/cssGradientBackground.test.ts @@ -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); + }); +}); \ No newline at end of file diff --git a/src/lib/exporter/cssGradientBackground.ts b/src/lib/exporter/cssGradientBackground.ts new file mode 100644 index 00000000..cec12fc8 --- /dev/null +++ b/src/lib/exporter/cssGradientBackground.ts @@ -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); +} \ No newline at end of file diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 5f9b49bb..d979721a 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -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); diff --git a/src/lib/exporter/modernFrameRenderer.ts b/src/lib/exporter/modernFrameRenderer.ts index ee986bf2..9277c1a5 100644 --- a/src/lib/exporter/modernFrameRenderer.ts +++ b/src/lib/exporter/modernFrameRenderer.ts @@ -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; diff --git a/src/lib/wallpapers.test.ts b/src/lib/wallpapers.test.ts new file mode 100644 index 00000000..f848501a --- /dev/null +++ b/src/lib/wallpapers.test.ts @@ -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], + ]); + }); +}); \ No newline at end of file diff --git a/src/lib/wallpapers.ts b/src/lib/wallpapers.ts index ee964c77..b72ce7cb 100644 --- a/src/lib/wallpapers.ts +++ b/src/lib/wallpapers.ts @@ -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 { const fallbackWallpapers = BUILT_IN_WALLPAPERS; @@ -221,18 +96,22 @@ export async function getAvailableWallpapers(): Promise { 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; }