mirror of
https://github.com/diffusionstudio/lottie.git
synced 2026-09-14 13:40:33 +08:00
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.
This commit is contained in:
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Record<string, AnimationSlot["value"]>>({});
|
||||
|
||||
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}
|
||||
/>
|
||||
</Slot>
|
||||
);
|
||||
@@ -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<string, AnimationSlot["value"]>
|
||||
) {
|
||||
const doc = JSON.parse(lottieJson) as {
|
||||
slots?: Record<string, { p?: { k?: unknown; p?: { t?: string } } }>;
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
+67
-22
@@ -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<string, unknown>;
|
||||
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<Typeface | null> {
|
||||
try {
|
||||
const res = await fetch(LABEL_FONT_URL);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<void> {
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { AnimationSlot } from "@/types";
|
||||
|
||||
type LottieDoc = {
|
||||
slots?: Record<string, { p?: { k?: unknown; p?: { t?: string } } }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
-1
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user