From b3c620a0814d926626a1b55d5786b5046387f782 Mon Sep 17 00:00:00 2001 From: konstantin-paulus Date: Sun, 14 Jun 2026 15:04:01 +0200 Subject: [PATCH] Add fflate dependency and enhance Lottie JSON handling in SidebarRight - Introduced "fflate" dependency for improved compression handling. - Updated SidebarRight to utilize new commitSource function for saving Lottie JSON edits. - Refactored Lottie JSON structure for better readability and maintainability. - Implemented server middleware to handle Lottie JSON updates, ensuring the source of truth is maintained. --- bun.lock | 3 ++ package.json | 1 + src/components/sidebar-right.tsx | 49 +++++------------- src/context/canvas.tsx | 89 ++++++++++++++++++++++++-------- src/index.css | 5 ++ src/lib/export.ts | 35 +++++++++++++ src/lib/lottie.ts | 22 ++++++++ vite-plugins/scenes.ts | 23 ++++++++- 8 files changed, 167 insertions(+), 60 deletions(-) create mode 100644 src/lib/export.ts create mode 100644 src/lib/lottie.ts diff --git a/bun.lock b/bun.lock index 8ecf558..d3074cf 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,7 @@ "canvaskit-wasm": "^0.41.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "fflate": "^0.8.3", "lucide-solid": "^1.18.0", "shadcn": "^4.11.0", "solid-js": "^1.9.9", @@ -507,6 +508,8 @@ "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], + "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], diff --git a/package.json b/package.json index 9fcc82b..86e9be7 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "canvaskit-wasm": "^0.41.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "fflate": "^0.8.3", "lucide-solid": "^1.18.0", "shadcn": "^4.11.0", "solid-js": "^1.9.9", diff --git a/src/components/sidebar-right.tsx b/src/components/sidebar-right.tsx index f47e3a4..26b52b4 100644 --- a/src/components/sidebar-right.tsx +++ b/src/components/sidebar-right.tsx @@ -1,9 +1,12 @@ import { Button } from "@/components/ui/button"; import { createSignal, For, Show, type JSX } from "solid-js"; +import { useParams } from "@solidjs/router"; import { useCanvas } from "@/context/canvas"; +import { useScenes } from "@/context/scenes"; import { NumericSlider } from "@/components/ui/numeric-slider"; import { Icon } from "@/components/ui/icon"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { exportProjectZip } from "@/lib/export"; import type { AnimationSlot } from "@/types"; @@ -24,7 +27,9 @@ function hexToRgb(hex: string): [number, number, number] { } export function SidebarRight() { - const { slots, zoom, controls, setScalarSlot, setColorSlot, setVec2Slot, setTextSlot, zoomByCentered, resetCamera } = useCanvas(); + const { slots, zoom, controls, setScalarSlot, setColorSlot, setVec2Slot, setTextSlot, commitSource, zoomByCentered, resetCamera } = useCanvas(); + const params = useParams(); + const { findProject } = useScenes(); const [edits, setEdits] = createSignal>({}); const set = (id: string, value: AnimationSlot["value"]) => @@ -33,13 +38,8 @@ export function SidebarRight() { const valueOf = (s: AnimationSlot) => edits()[s.id] ?? s.value; const handleExport = async () => { - const res = await fetch('/lottie.json'); - if (!res.ok) { - throw new Error(`Failed to load /lottie.json (HTTP ${res.status})`); - } - - const values = Object.fromEntries(slots().map((s) => [s.id, valueOf(s)])); - downloadConfiguredLottie(await res.text(), slots(), values); + const project = params.project ? findProject(params.project) : undefined; + if (project) await exportProjectZip(project); }; return ( @@ -94,6 +94,7 @@ export function SidebarRight() { set(slot.id, v); setScalarSlot(slot.id, v); }} + onDragEnd={commitSource} /> ); @@ -124,6 +125,7 @@ export function SidebarRight() { set(slot.id, rgba); setColorSlot(slot.id, rgba); }} + onBlur={commitSource} class="absolute inset-0 h-full w-full cursor-pointer opacity-0" aria-label={label} /> @@ -148,6 +150,7 @@ export function SidebarRight() { step={m?.step ?? 1} value={value[i]} onChange={(e) => update(i, Number(e.target.value))} + onBlur={commitSource} class="rounded-md bg-input font-sans text-foreground outline-none w-0 flex-1 text-xxs h-7 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none px-2 focus-ring" /> ))} @@ -167,6 +170,7 @@ export function SidebarRight() { set(slot.id, e.target.value); setTextSlot(slot.id, e.target.value); }} + onBlur={commitSource} class="rounded-md bg-input font-sans text-foreground outline-none w-0 flex-1 text-xxs h-7 px-2 focus-ring" aria-label={label} /> @@ -181,35 +185,6 @@ export function SidebarRight() { ); } -function downloadConfiguredLottie( - lottieJson: string, - slots: AnimationSlot[], - values: Record -) { - const doc = JSON.parse(lottieJson) as { - slots?: Record; - }; - - for (const slot of slots) { - const def = doc.slots?.[slot.id]?.p; - if (!def) continue; - const value = values[slot.id] ?? slot.value; - if (slot.type === "text") { - if (def.p) def.p.t = value as string; - } else { - def.k = value; - } - } - - const blob = new Blob([JSON.stringify(doc)], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = "lottie.json"; - a.click(); - URL.revokeObjectURL(url); -} - type SlotProps = { label?: string; children: JSX.Element; diff --git a/src/context/canvas.tsx b/src/context/canvas.tsx index ae793ba..8ddfad1 100644 --- a/src/context/canvas.tsx +++ b/src/context/canvas.tsx @@ -4,6 +4,8 @@ import { ControlMeta, AnimationSlot, Scene } from '@/types'; import { useScenes } from '@/context/scenes'; import { getCanvasKit } from '@/lib/canvaskit'; import { loadScene } from '@/lib/scene'; +import { applySlotValues } from '@/lib/lottie'; + import type { CanvasKit, Surface, ManagedSkottieAnimation, Font, Paint, Typeface } from "canvaskit-wasm/full"; const MIN_ZOOM = 0.1; @@ -27,6 +29,7 @@ const CanvasContext = createContext<{ setColorSlot(id: string, rgba: [number, number, number, number]): void; setVec2Slot(id: string, xy: [number, number]): void; setTextSlot(id: string, text: string): void; + commitSource(): void; togglePlayback(): void; seek(frame: number): void; zoomByCentered(factor: number): void; @@ -42,6 +45,7 @@ export function CanvasProvider(props: { children: JSX.Element }) { let dragging = false; let lastTs = 0; let dirty = true; + let sourceDirty = false; // a control was edited but not yet written to source const observer = new ResizeObserver(() => resize()); const params = useParams(); @@ -93,28 +97,7 @@ export function CanvasProvider(props: { children: JSX.Element }) { const slots = createMemo(() => { const anim = animation(); - if (!anim) return []; - const info = anim.getSlotInfo(); - const slots: AnimationSlot[] = []; - for (const id of info.scalarSlotIDs) { - slots.push({ id, type: "scalar", value: anim.getScalarSlot(id) ?? 0 }); - } - for (const id of info.colorSlotIDs) { - const c = anim.getColorSlot(id); - slots.push({ - id, - type: "color", - value: c ? [c[0], c[1], c[2], c[3]] : [0, 0, 0, 1], - }); - } - for (const id of info.vec2SlotIDs) { - const v = anim.getVec2Slot(id); - slots.push({ id, type: "vec2", value: v ? [v[0], v[1]] : [0, 0] }); - } - for (const id of info.textSlotIDs) { - slots.push({ id, type: "text", value: anim.getTextSlot(id)?.text ?? "" }); - } - return slots; + return anim ? readSlots(anim) : []; }); createEffect(() => { @@ -157,6 +140,7 @@ export function CanvasProvider(props: { children: JSX.Element }) { const setScalarSlot = (id: string, value: number) => { animation()?.setScalarSlot(id, value); dirty = true; + sourceDirty = true; } const setColorSlot = (id: string, rgba: [number, number, number, number]) => { @@ -164,11 +148,13 @@ export function CanvasProvider(props: { children: JSX.Element }) { if (!ck) return; animation()?.setColorSlot(id, ck.Color4f(rgba[0], rgba[1], rgba[2], rgba[3])); dirty = true; + sourceDirty = true; } const setVec2Slot = (id: string, xy: [number, number]) => { animation()?.setVec2Slot(id, xy); dirty = true; + sourceDirty = true; } const setTextSlot = (id: string, text: string) => { @@ -180,8 +166,39 @@ export function CanvasProvider(props: { children: JSX.Element }) { current.text = text; anim.setTextSlot(id, new ck.SlottableTextProperty(current)); dirty = true; + sourceDirty = true; } + const commitSource = async () => { + if (!sourceDirty) return; + + const scene = currentScene(); + const data = sceneData(); + const anim = animation(); + const project = params.project; + const sceneSlug = params.scene; + if (!scene || !data || !anim || !project || !sceneSlug) return; + + let doc: Record; + try { + doc = JSON.parse(data.json); + } catch { + return; + } + applySlotValues(doc, readSlots(anim)); + + const res = await fetch("/__scenes/lottie", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ project, scene: sceneSlug, doc }), + }); + if (!res.ok) { + console.error(`Failed to save lottie source (HTTP ${res.status})`); + } + + sourceDirty = false; + }; + const togglePlayback = () => setPlaying((v) => !v); const seek = (frame: number) => { @@ -407,6 +424,7 @@ export function CanvasProvider(props: { children: JSX.Element }) { setColorSlot, setVec2Slot, setTextSlot, + commitSource, togglePlayback, seek, zoomByCentered, @@ -428,6 +446,33 @@ export function useCanvas() { return context; } +// Snapshot the animation's current slot values. Reads straight off the +// animation so callers always get the latest edits (the `slots` memo is only +// recomputed when the animation itself changes). +function readSlots(anim: ManagedSkottieAnimation): AnimationSlot[] { + const info = anim.getSlotInfo(); + const slots: AnimationSlot[] = []; + for (const id of info.scalarSlotIDs) { + slots.push({ id, type: "scalar", value: anim.getScalarSlot(id) ?? 0 }); + } + for (const id of info.colorSlotIDs) { + const c = anim.getColorSlot(id); + slots.push({ + id, + type: "color", + value: c ? [c[0], c[1], c[2], c[3]] : [0, 0, 0, 1], + }); + } + for (const id of info.vec2SlotIDs) { + const v = anim.getVec2Slot(id); + slots.push({ id, type: "vec2", value: v ? [v[0], v[1]] : [0, 0] }); + } + for (const id of info.textSlotIDs) { + slots.push({ id, type: "text", value: anim.getTextSlot(id)?.text ?? "" }); + } + return slots; +} + async function loadLabelTypeface(ck: CanvasKit): Promise { try { const res = await fetch(LABEL_FONT_URL); diff --git a/src/index.css b/src/index.css index b1a1297..8530cba 100644 --- a/src/index.css +++ b/src/index.css @@ -1,4 +1,9 @@ @import 'tailwindcss'; +/* Keep Tailwind's automatic source detection out of the Lottie source files + under public/projects. They hold arbitrary strings and are rewritten on every + control auto-save; left in scope Tailwind would regenerate index.css on each + save (a CSS HMR churn that can cascade into a full page reload). */ +@source not "../public/projects"; @import "tw-animate-css"; @import "shadcn/tailwind.css"; @import "@fontsource-variable/inter"; diff --git a/src/lib/export.ts b/src/lib/export.ts new file mode 100644 index 0000000..2158f72 --- /dev/null +++ b/src/lib/export.ts @@ -0,0 +1,35 @@ +import { zipSync, type Zippable } from "fflate"; +import type { Project } from "@/types"; + +/** + * Bundle a project's on-disk source into a zip and trigger a download. + * The archive has two folders: `animations/` (one lottie.json per scene) and + * `images/` (all scene image assets). This reads the served source files, not + * the in-memory skottie state, so it relies on edits already being saved back. + */ +export async function exportProjectZip(project: Project): Promise { + const files: Zippable = {}; + + for (const scene of project.scenes) { + const res = await fetch(scene.lottie); + if (res.ok) { + files[`animations/${scene.slug}.json`] = new Uint8Array(await res.arrayBuffer()); + } + for (const url of scene.images) { + const imgRes = await fetch(url); + if (imgRes.ok) { + const name = url.split("/").pop()!; + files[`images/${name}`] = new Uint8Array(await imgRes.arrayBuffer()); + } + } + } + + const zipped = zipSync(files); + const blob = new Blob([zipped as BlobPart], { type: "application/zip" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${project.slug}.zip`; + a.click(); + URL.revokeObjectURL(url); +} diff --git a/src/lib/lottie.ts b/src/lib/lottie.ts new file mode 100644 index 0000000..9f722af --- /dev/null +++ b/src/lib/lottie.ts @@ -0,0 +1,22 @@ +import type { AnimationSlot } from "@/types"; + +type LottieDoc = { + slots?: Record; +}; + +/** + * Patch a Lottie document's slot definitions with the given slot values. + * Mutates `doc` in place. Scalar/color/vec2 values live on `slots[id].p.k`; + * text lives on `slots[id].p.p.t`. + */ +export function applySlotValues(doc: LottieDoc, slots: AnimationSlot[]): void { + for (const slot of slots) { + const def = doc.slots?.[slot.id]?.p; + if (!def) continue; + if (slot.type === "text") { + if (def.p) def.p.t = slot.value; + } else { + def.k = slot.value; + } + } +} diff --git a/vite-plugins/scenes.ts b/vite-plugins/scenes.ts index 1be5420..761cc6b 100644 --- a/vite-plugins/scenes.ts +++ b/vite-plugins/scenes.ts @@ -222,6 +222,24 @@ export function scenesPlugin(): Plugin { json(res, 201, { project: projectSlug, scene: sceneSlug }); }); + // Overwrite a scene's lottie.json source. Body: { project, scene, doc }. + // This keeps `public/projects` the source of truth for control edits. + server.middlewares.use("/__scenes/lottie", async (req, res) => { + if (req.method !== "POST") return json(res, 405, { error: "method not allowed" }); + const body = await readJsonBody(req); + const projectSlug = String(body.project ?? ""); + const sceneSlug = String(body.scene ?? ""); + const sceneDir = path.resolve(projectsDir, projectSlug, sceneSlug); + if (!sceneDir.startsWith(projectsDir + path.sep) || !fs.existsSync(sceneDir)) { + return json(res, 404, { error: "scene not found" }); + } + const lottiePath = path.join(sceneDir, "lottie.json"); + if (!fs.existsSync(lottiePath)) return json(res, 404, { error: "lottie.json not found" }); + if (!body.doc || typeof body.doc !== "object") return json(res, 400, { error: "missing doc" }); + fs.writeFileSync(lottiePath, JSON.stringify(body.doc, null, 2)); + json(res, 200, { ok: true }); + }); + server.middlewares.use("/__scenes", (_req, res) => { res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(scanProjects(projectsDir))); @@ -232,8 +250,11 @@ export function scenesPlugin(): Plugin { server.ws.send({ type: "custom", event: "scenes:update", data: scanProjects(projectsDir) }); }; + // Only structural events change the scenes tree; file content "change" + // events (e.g. saving control edits back to lottie.json) must not trigger + // a re-scan, or the active scene would reload on every auto-save. server.watcher.add(projectsDir); - for (const event of ["add", "unlink", "addDir", "unlinkDir", "change"] as const) { + for (const event of ["add", "unlink", "addDir", "unlinkDir"] as const) { server.watcher.on(event, notify); } },