diff --git a/src/features/media-animations/MediaAnimationSection.tsx b/src/features/media-animations/MediaAnimationSection.tsx index e1120932b..de393645b 100644 --- a/src/features/media-animations/MediaAnimationSection.tsx +++ b/src/features/media-animations/MediaAnimationSection.tsx @@ -7,10 +7,11 @@ * - Shows thumbnail images on preset cards when available * - On hover: plays previewUrl video from the start; on blur: pauses and * reverts to the thumbnail image - * - Combo tab shows "Coming soon" placeholder when empty + * - Each tab mounts its own CategoryGrid (keyed by tab) so each category's + * fetch, state, and render tree are fully isolated */ -import React, { useState, useCallback, useMemo, useRef } from "react"; +import React, { useState, useCallback, useMemo } from "react"; import { Ban, Search, X, Star, ChevronUp, ChevronDown } from "lucide-react"; import type { Clip } from "@/types"; import type { ClipMotionConfig } from "@/types/motion"; @@ -19,7 +20,11 @@ import { useMediaAnimationCategory, useMediaAnimationCatalogSnapshot, type MediaAnimationCategory, + downloadManifest, + getMediaAnimationPreset, } from "./mediaAnimationCatalog"; +import { mediaAnimationCacheManager } from "./cache/mediaAnimationCache"; +import type { MediaAnimationPreset } from "./cache/mediaAnimationCache"; import { useI18n } from "@/i18n"; import { SliderTrack } from "@/components/editor/properties/primitives/SliderTrack"; import { NumericControl } from "@/components/editor/properties/primitives/NumericControl"; @@ -51,6 +56,140 @@ const saveStoredFavorites = (favs: Set) => { type CategoryFilter = "all" | "favorites"; +// ── CategoryGrid ────────────────────────────────────────────────────────────── +// Isolated per-tab grid component. Keyed by `category` in the parent so each +// tab gets a completely independent React subtree — no shared hook state, no +// stale snapshot bleed between tabs. + +interface CategoryGridProps { + category: MediaAnimationCategory; + activePresetId: string | undefined; + searchQuery: string; + activeFilter: CategoryFilter; + favorites: Set; + downloadingIds: Set; + downloadedIds: Set; + onSelect: (id: string) => void; + onToggleFavorite: (id: string, e: React.MouseEvent) => void; + onDownload: (id: string, e: React.MouseEvent) => void; +} + +const CategoryGrid: React.FC = ({ + category, + activePresetId, + searchQuery, + activeFilter, + favorites, + downloadingIds, + downloadedIds, + onSelect, + onToggleFavorite, + onDownload, +}) => { + const { t } = useI18n(); + // Each CategoryGrid instance subscribes independently to its own category. + // Because this component is keyed by category in the parent, switching tabs + // unmounts the old grid and mounts a fresh one — no state leaks. + const phaseData = useMediaAnimationCategory(category); + + const filteredPresets = useMemo(() => { + const q = searchQuery.trim().toLowerCase(); + return phaseData.presets.filter((preset) => { + if (activeFilter === "favorites" && !favorites.has(preset.id)) + return false; + if (q) { + const matchesName = preset.name.toLowerCase().includes(q); + const matchesDesc = preset.description.toLowerCase().includes(q); + const matchesTags = preset.tags?.some((tag) => + tag.toLowerCase().includes(q), + ); + if (!matchesName && !matchesDesc && !matchesTags) return false; + } + return true; + }); + }, [phaseData.presets, activeFilter, searchQuery, favorites]); + + return ( +
+ {/* None card */} + + + {/* Combo empty state */} + {category === "combo" && + filteredPresets.length === 0 && + phaseData.status !== "loading" && ( +
+ {t("properties.mediaAnimation.comboComingSoon")} +
+ )} + + {/* Empty state for in/out */} + {category !== "combo" && + filteredPresets.length === 0 && + phaseData.status === "ready" && ( +
+ {t("properties.animation.none")} +
+ )} + + {/* Loading skeletons */} + {phaseData.status === "loading" && + Array.from({ length: 6 }).map((_, i) => ( +
+
+
+ ))} + + {/* Preset cards */} + {filteredPresets.map((preset) => { + const mediaPreset = preset as MediaAnimationPreset; + const isDownloaded = + downloadedIds.has(preset.id) || + mediaAnimationCacheManager.isCached( + preset.id, + mediaPreset.revisionId ?? "", + ); + return ( + onSelect(preset.id)} + onToggleFavorite={(e) => onToggleFavorite(preset.id, e)} + isDownloaded={isDownloaded} + isDownloading={downloadingIds.has(preset.id)} + onDownload={(e) => onDownload(preset.id, e)} + /> + ); + })} +
+ ); +}; + // ── MediaAnimationSection ───────────────────────────────────────────────────── export const MediaAnimationSection: React.FC = ({ @@ -62,13 +201,32 @@ export const MediaAnimationSection: React.FC = ({ const { t } = useI18n(); const [activeTab, setActiveTab] = useState("in"); const [searchQuery, setSearchQuery] = useState(""); - const [activeCategory, setActiveCategory] = useState("all"); + const [activeFilter, setActiveFilter] = useState("all"); const [favorites, setFavorites] = useState>(() => getStoredFavorites(), ); + const [downloadingIds, setDownloadingIds] = useState>( + () => new Set(), + ); + const [downloadedIds, setDownloadedIds] = useState>(() => { + const keys = Array.from(mediaAnimationCacheManager.manifests.keys()).map( + (k) => k.split("@")[0], + ); + return new Set(keys); + }); + + React.useEffect(() => { + return mediaAnimationCacheManager.onHydrated(() => { + setDownloadedIds((prev) => { + const keys = Array.from( + mediaAnimationCacheManager.manifests.keys(), + ).map((k) => k.split("@")[0]); + return new Set([...prev, ...keys]); + }); + }); + }, []); const catalogSnapshot = useMediaAnimationCatalogSnapshot(); - const phaseData = useMediaAnimationCategory(activeTab); const toggleFavorite = useCallback((id: string, e: React.MouseEvent) => { e.stopPropagation(); @@ -81,6 +239,30 @@ export const MediaAnimationSection: React.FC = ({ }); }, []); + const handleDownload = useCallback( + async (presetId: string, e: React.MouseEvent) => { + e.stopPropagation(); + if (downloadingIds.has(presetId)) return; + const preset = getMediaAnimationPreset(presetId); + const version = preset?.revisionId; + if (!version) return; + setDownloadingIds((prev) => new Set(prev).add(presetId)); + try { + await downloadManifest(presetId, version); + setDownloadedIds((prev) => new Set(prev).add(presetId)); + } catch (err) { + console.error("[MediaAnimationSection] Download failed:", err); + } finally { + setDownloadingIds((prev) => { + const next = new Set(prev); + next.delete(presetId); + return next; + }); + } + }, + [downloadingIds], + ); + const motion: ClipMotionConfig = selectedClip.motion ?? {}; const hasIn = Boolean(motion.inPreset && motion.inPreset !== "none"); const hasOut = Boolean(motion.outPreset && motion.outPreset !== "none"); @@ -88,12 +270,16 @@ export const MediaAnimationSection: React.FC = ({ const updateMotion = useCallback( (updates: Partial) => { const nextMotion: ClipMotionConfig = { ...motion, ...updates }; + const mergedCatalog = catalogSnapshot.presets.map((p) => { + const merged = getMediaAnimationPreset(p.id); + return merged ?? p; + }); const compiledKeyframes = compileClipMotionKeyframes( selectedClip, nextMotion, canvasWidth, canvasHeight, - catalogSnapshot.presets, + mergedCatalog, ); handleUpdateMultiple({ motion: nextMotion, @@ -110,31 +296,12 @@ export const MediaAnimationSection: React.FC = ({ ], ); - const currentCatalog = phaseData.presets; - - const filteredPresets = useMemo(() => { - const q = searchQuery.trim().toLowerCase(); - return currentCatalog.filter((preset) => { - if (activeCategory === "favorites" && !favorites.has(preset.id)) - return false; - if (q) { - const matchesName = preset.name.toLowerCase().includes(q); - const matchesDesc = preset.description.toLowerCase().includes(q); - const matchesTags = preset.tags?.some((tag) => - tag.toLowerCase().includes(q), - ); - if (!matchesName && !matchesDesc && !matchesTags) return false; - } - return true; - }); - }, [currentCatalog, activeCategory, searchQuery, favorites]); - const activePresetId = activeTab === "in" ? motion.inPreset : activeTab === "out" ? motion.outPreset - : motion.inPreset; // combo stored as inPreset + : motion.inPreset; const currentDuration = activeTab === "in" @@ -148,7 +315,7 @@ export const MediaAnimationSection: React.FC = ({ const handleDurationStep = useCallback( (delta: number) => { - if (activeTab === "combo") return; // combo has fixed timing + if (activeTab === "combo") return; const next = Math.max( 0.1, Math.min(maxAllowedDuration, +(currentDuration + delta).toFixed(2)), @@ -170,14 +337,14 @@ export const MediaAnimationSection: React.FC = ({ } if (activeTab === "in") updateMotion({ inPreset: id }); else if (activeTab === "out") updateMotion({ outPreset: id }); - else updateMotion({ inPreset: id }); // combo + else updateMotion({ inPreset: id }); }, [activeTab, updateMotion], ); return (
- {/* ── Tabs: In / Out / Combo ── */} + {/* ── Tabs ── */}
{(["in", "out", "combo"] as MediaAnimationCategory[]).map((tab) => { const isActive = activeTab === tab; @@ -237,71 +404,22 @@ export const MediaAnimationSection: React.FC = ({ )}
- {/* ── Error banner ── */} - {phaseData.status === "error" && currentCatalog.length === 0 && ( -
- Media animation catalog is unavailable. Reconnect to load published - presets. -
- )} - - {/* ── Preset Grid ── */} -
- {/* None card */} - - - {/* Combo empty state */} - {activeTab === "combo" && - filteredPresets.length === 0 && - phaseData.status !== "loading" && ( -
- {t("properties.mediaAnimation.comboComingSoon")} -
- )} - - {/* Loading skeletons */} - {phaseData.status === "loading" && - Array.from({ length: 9 }).map((_, i) => ( -
-
-
- ))} - - {/* Preset cards */} - {filteredPresets.map((preset) => ( - handleSelectPreset(preset.id)} - onToggleFavorite={(e) => toggleFavorite(preset.id, e)} - /> - ))} -
+ {/* ── Per-tab isolated grid — keyed by activeTab so each tab mounts + a completely independent CategoryGrid instance with its own + useMediaAnimationCategory hook and fetch lifecycle. ── */} + {/* ── Duration slider (In/Out only) ── */} {activeTab !== "combo" && activePresetId && activePresetId !== "none" && ( diff --git a/src/features/media-animations/PresetCard.tsx b/src/features/media-animations/PresetCard.tsx index 0c2123854..db8c85bb4 100644 --- a/src/features/media-animations/PresetCard.tsx +++ b/src/features/media-animations/PresetCard.tsx @@ -8,7 +8,11 @@ import { ArrowRight, ZoomIn, ZoomOut, + CloudDownload, + Loader2, + Check, } from "lucide-react"; +import { useI18n } from "@/i18n"; const ICON_MAP: Record> = { ArrowUp, @@ -29,6 +33,10 @@ interface PresetCardProps { isFavorite: boolean; onSelect: () => void; onToggleFavorite: (e: React.MouseEvent) => void; + // New download props: + isDownloaded: boolean; + isDownloading: boolean; + onDownload: (e: React.MouseEvent) => void; } const PresetCard: React.FC = ({ @@ -40,7 +48,11 @@ const PresetCard: React.FC = ({ isFavorite, onSelect, onToggleFavorite, + isDownloaded, + isDownloading, + onDownload, }) => { + const { t } = useI18n(); const [isHovered, setIsHovered] = useState(false); const videoRef = useRef(null); const IconComp = ICON_MAP[icon] ?? Sparkles; @@ -63,6 +75,17 @@ const PresetCard: React.FC = ({ } }, []); + const handleClick = useCallback( + (e: React.MouseEvent) => { + if (isDownloaded) { + onSelect(); + } else { + onDownload(e); + } + }, + [isDownloaded, onSelect, onDownload], + ); + // Show the video layer only while hovered AND a previewUrl exists. const showVideo = isHovered && Boolean(previewUrl); // Show the thumbnail when not showing the video and a thumbnailUrl exists. @@ -71,9 +94,14 @@ const PresetCard: React.FC = ({ return ( + + {/* Download / downloading / downloaded indicator — bottom-left */} +
; + }>; + durationFrames: number; + frameRateNum: number; + frameRateDen: number; + compositionRules: Record; +} + +/** + * MediaAnimationPreset extends RemoteTextAnimationPreset with the extra + * fields that the media animation catalog stores but the base text-animation + * interface does not define. Using this throughout the media animation stack + * eliminates `(preset as any)` casts at every call site. + */ +export interface MediaAnimationPreset extends RemoteTextAnimationPreset { + thumbnailUrl?: string; + previewUrl?: string; + revisionId?: string; +} + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const CACHE_DIR = "media-animations/manifests"; +const INDEX_FILE = "media-animations/index.json"; + +// ─── SHA-256 helper ─────────────────────────────────────────────────────────── + +async function sha256Hex(data: Uint8Array): Promise { + if (typeof crypto !== "undefined" && crypto.subtle) { + const hash = await crypto.subtle.digest("SHA-256", data); + return Array.from(new Uint8Array(hash)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + } + return ""; +} + +// ─── Cache Manager ──────────────────────────────────────────────────────────── + +class MediaAnimationCacheManager { + private cacheIndex: Map = new Map(); + private initialized = false; + + /** Listeners notified after initialize() completes so subscribers can + * re-read isCached() results (e.g., after restart hydration). */ + private hydrateListeners: Array<() => void> = []; + + /** In-memory manifest map — eagerly populated during initialize() from the + * OS index so getManifest() is not required for every preset access. */ + readonly manifests: Map = new Map(); + + /** + * Creates the cache directory and loads the index from disk. + * Idempotent; gracefully handles environments where Tauri FS is unavailable. + * After loading the index, eagerly reads all manifests into memory so that + * callers don't get empty tracks on app restart. + */ + async initialize(): Promise { + if (this.initialized) return; + + try { + const dirExists = await exists(CACHE_DIR, { + baseDir: BaseDirectory.AppCache, + }); + if (!dirExists) { + await mkdir(CACHE_DIR, { + baseDir: BaseDirectory.AppCache, + recursive: true, + }); + } + + await this.loadIndex(); + await this.hydrateManifests(); + this.initialized = true; + } catch (error) { + // Browser / web-preview environment — no Tauri FS available + console.warn( + "[MediaAnimationCache] Tauri FS unavailable; running in-memory only:", + error, + ); + this.initialized = true; + } + + this.hydrateListeners.forEach((l) => l()); + this.hydrateListeners = []; + } + + /** + * Subscribe to a one-shot notification that fires after the first + * initialize() completes. Used by callers that need to refresh UI after + * manifests are loaded from disk. + */ + onHydrated(listener: () => void): () => void { + if (this.initialized) { + listener(); + return () => {}; + } + this.hydrateListeners.push(listener); + return () => { + this.hydrateListeners = this.hydrateListeners.filter( + (l) => l !== listener, + ); + }; + } + + // ── Private helpers ───────────────────────────────────────────────────────── + + private async loadIndex(): Promise { + try { + const indexExists = await exists(INDEX_FILE, { + baseDir: BaseDirectory.AppCache, + }); + if (!indexExists) return; + + const raw = await readFile(INDEX_FILE, { + baseDir: BaseDirectory.AppCache, + }); + const json = new TextDecoder().decode(raw); + const entries: CachedManifestEntry[] = JSON.parse(json); + + this.cacheIndex.clear(); + for (const entry of entries) { + this.cacheIndex.set(`${entry.slug}@${entry.version}`, entry); + } + } catch (error) { + console.warn( + "[MediaAnimationCache] Index corrupt or unavailable; starting fresh:", + error, + ); + this.cacheIndex.clear(); + } + } + + /** + * Eagerly load all manifests referenced by the index into the in-memory + * `manifests` map. This ensures that after an app restart, callers can + * merge tracks immediately without an additional async read. + */ + private async hydrateManifests(): Promise { + const entries = Array.from(this.cacheIndex.values()); + await Promise.all( + entries.map(async (entry) => { + const cacheKey = `${entry.slug}@${entry.version}`; + if (this.manifests.has(cacheKey)) return; + try { + const manifest = await this.readAndVerifyManifest(entry); + if (manifest) { + this.manifests.set(cacheKey, manifest); + } else { + // File missing or hash mismatch — remove stale index entry + this.cacheIndex.delete(cacheKey); + } + } catch { + this.cacheIndex.delete(cacheKey); + } + }), + ); + } + + private async saveIndex(): Promise { + try { + const entries = Array.from(this.cacheIndex.values()); + const json = JSON.stringify(entries, null, 2); + const data = new TextEncoder().encode(json); + await writeFile(INDEX_FILE, data, { baseDir: BaseDirectory.AppCache }); + } catch (error) { + console.warn("[MediaAnimationCache] Failed to persist index:", error); + } + } + + /** + * Read a manifest file and verify its SHA-256 hash against the index entry. + * Returns null when the file is missing or the hash does not match. + */ + private async readAndVerifyManifest( + entry: CachedManifestEntry, + ): Promise { + try { + const fileExists = await exists(entry.localPath, { + baseDir: BaseDirectory.AppCache, + }); + if (!fileExists) return null; + + const raw = await readFile(entry.localPath, { + baseDir: BaseDirectory.AppCache, + }); + const json = new TextDecoder().decode(raw); + const manifest = JSON.parse(json) as AnimationManifest; + + // SHA-256 integrity check — mirrors stickerCache pattern + if (entry.contentHash && entry.contentHash !== "") { + const computedHash = await sha256Hex(raw); + if (computedHash && computedHash !== entry.contentHash) { + console.warn( + `[MediaAnimationCache] Hash mismatch for "${entry.slug}@${entry.version}"; ` + + `expected ${entry.contentHash}, got ${computedHash}. Evicting.`, + ); + // Evict the corrupt file + try { + await remove(entry.localPath, { baseDir: BaseDirectory.AppCache }); + } catch {} + return null; + } + } + + return manifest; + } catch (error) { + console.warn( + `[MediaAnimationCache] Could not read cached manifest for "${entry.slug}@${entry.version}":`, + error, + ); + return null; + } + } + + // ── Public API ────────────────────────────────────────────────────────────── + + /** + * Returns true if an entry exists for slug+version in the in-memory index. + */ + isCached(slug: string, version: string): boolean { + return this.cacheIndex.has(`${slug}@${version}`); + } + + /** + * If already cached, loads from disk and returns. + * Otherwise calls fetchFn(), writes JSON to disk, updates index, and returns. + */ + async ensureDownloaded( + slug: string, + version: string, + fetchFn: () => Promise, + ): Promise { + await this.initialize(); + + const cacheKey = `${slug}@${version}`; + + if (this.cacheIndex.has(cacheKey)) { + const entry = this.cacheIndex.get(cacheKey)!; + const cached = await this.readAndVerifyManifest(entry); + if (cached) { + this.manifests.set(cacheKey, cached); + return cached; + } + // Index entry exists but file is missing or corrupt — fall through to re-download + this.cacheIndex.delete(cacheKey); + } + + try { + const manifest = await fetchFn(); + + const localPath = `media-animations/manifests/${slug}@${version}.json`; + const json = JSON.stringify(manifest); + const data = new TextEncoder().encode(json); + + let contentHash = manifest.contentHash ?? ""; + + try { + await writeFile(localPath, data, { baseDir: BaseDirectory.AppCache }); + // Compute hash of what was actually written so verification on next + // read uses the same byte sequence. + const computed = await sha256Hex(data); + if (computed) contentHash = computed; + } catch (fsError) { + // Tauri FS not available in browser — keep in-memory only + console.warn( + "[MediaAnimationCache] Could not write manifest to disk:", + fsError, + ); + } + + const entry: CachedManifestEntry = { + slug, + version, + contentHash, + localPath, + downloadedAt: Date.now(), + sizeBytes: data.byteLength, + }; + + this.cacheIndex.set(cacheKey, entry); + this.manifests.set(cacheKey, manifest); + await this.saveIndex(); + + return manifest; + } catch (error) { + console.error( + `[MediaAnimationCache] Failed to download manifest for "${slug}@${version}":`, + error, + ); + throw error; + } + } + + /** + * Reads the cached JSON file for the entry and returns the parsed manifest, + * or null if not cached or verification fails. + */ + async getManifest( + slug: string, + version: string, + ): Promise { + // Check in-memory hot tier first + const cacheKey = `${slug}@${version}`; + const inMemory = this.manifests.get(cacheKey); + if (inMemory) return inMemory; + + const entry = this.cacheIndex.get(cacheKey); + if (!entry) return null; + + const manifest = await this.readAndVerifyManifest(entry); + if (manifest) { + this.manifests.set(cacheKey, manifest); + } else { + // Evict stale index entry + this.cacheIndex.delete(cacheKey); + await this.saveIndex(); + } + return manifest; + } + + /** + * Removes all entries from the index and deletes all manifest files. + * Resets the in-memory index and saves the empty index. + */ + async clearCache(): Promise { + await this.initialize(); + + for (const entry of this.cacheIndex.values()) { + try { + const fileExists = await exists(entry.localPath, { + baseDir: BaseDirectory.AppCache, + }); + if (fileExists) { + await remove(entry.localPath, { baseDir: BaseDirectory.AppCache }); + } + } catch (error) { + console.warn( + `[MediaAnimationCache] Failed to delete file "${entry.localPath}":`, + error, + ); + } + } + + this.cacheIndex.clear(); + this.manifests.clear(); + await this.saveIndex(); + } +} + +// ─── Singleton export ───────────────────────────────────────────────────────── + +export const mediaAnimationCacheManager = new MediaAnimationCacheManager(); diff --git a/src/features/media-animations/mediaAnimationCatalog.ts b/src/features/media-animations/mediaAnimationCatalog.ts index 78e5ce7bb..3751da41a 100644 --- a/src/features/media-animations/mediaAnimationCatalog.ts +++ b/src/features/media-animations/mediaAnimationCatalog.ts @@ -18,13 +18,18 @@ import type { PhaseSnapshot, RemoteTextAnimationPreset, } from "@/features/text-animations/animationCatalog"; +import { + mediaAnimationCacheManager, + type AnimationManifest, + type MediaAnimationPreset, +} from "./cache/mediaAnimationCache"; // ─── Types ──────────────────────────────────────────────────────────────────── export type MediaAnimationCategory = "in" | "out" | "combo"; export interface MediaAnimationCategorySnapshot { - presets: RemoteTextAnimationPreset[]; + presets: MediaAnimationPreset[]; status: "idle" | "loading" | "ready" | "error"; error?: string; } @@ -42,11 +47,31 @@ const categorySnapshots: Record< combo: { ...IDLE }, }; -const downloadedPresets = new Map(); +// Per-category immutable snapshot map — rebuilt on every emit() so each +// category's reference changes only when its data actually changes. +// Stored separately from mergedSnapshot so useMediaAnimationCategory can +// return a stable reference per category without going through phases. +const categorySnapshotRefs: Record< + MediaAnimationCategory, + MediaAnimationCategorySnapshot +> = { + in: { ...IDLE }, + out: { ...IDLE }, + combo: { ...IDLE }, +}; +const downloadedPresets = new Map(); const categoryRequests: Partial>> = {}; const listeners = new Set<() => void>(); +// Best-effort initialize the OS-level cache — no top-level await. +// After hydration completes, emit so consumers re-evaluate isCached() for all +// presets that were downloaded in a previous session. +mediaAnimationCacheManager + .initialize() + .then(() => emit()) + .catch(() => {}); + // ─── Derived merged snapshot ───────────────────────────────────────────────── function buildMergedSnapshot(): AnimationCatalogSnapshot { @@ -81,14 +106,14 @@ function buildMergedSnapshot(): AnimationCatalogSnapshot { ? "error" : "idle"; - // Reuse the PhaseSnapshot shape for compatibility with the compiler. - // "loop" slot is unused for media animations — repurpose it for "combo" - // so every MediaAnimationCategory has a stable, immutable snapshot slot - // that React can diff on each emit(). + // phases maps the three media animation categories into the PhaseSnapshot + // shape. combo goes into the "loop" slot — "loop" is otherwise unused for + // media animations — so each category has its own independent slot and + // useMediaAnimationCategory can read the correct one per tab. const phases: Record = { in: { ...categorySnapshots.in }, out: { ...categorySnapshots.out }, - loop: { ...categorySnapshots.combo }, + loop: { ...categorySnapshots.combo }, // combo stored here, read via "loop" key }; return { presets, phases, status }; @@ -97,6 +122,19 @@ function buildMergedSnapshot(): AnimationCatalogSnapshot { let mergedSnapshot: AnimationCatalogSnapshot = buildMergedSnapshot(); function emit(): void { + // Rebuild per-category refs — only create a new object if the underlying + // data actually changed, so useSyncExternalStore sees new references. + for (const cat of ["in", "out", "combo"] as MediaAnimationCategory[]) { + const current = categorySnapshots[cat]; + const ref = categorySnapshotRefs[cat]; + if ( + ref.status !== current.status || + ref.presets !== current.presets || + ref.error !== current.error + ) { + categorySnapshotRefs[cat] = { ...current }; + } + } mergedSnapshot = buildMergedSnapshot(); listeners.forEach((l) => l()); } @@ -121,9 +159,7 @@ function iconForDirection(direction: string | undefined): string { function normalizeMediaPreset( item: unknown, category: MediaAnimationCategory, -): - | (RemoteTextAnimationPreset & { thumbnailUrl?: string; previewUrl?: string }) - | null { +): MediaAnimationPreset | null { if (!item || typeof item !== "object") return null; const raw = item as Record; @@ -136,9 +172,8 @@ function normalizeMediaPreset( const name = typeof raw.name === "string" ? raw.name : null; if (!slug || !name) return null; - const tracks = Array.isArray(raw.tracks) ? raw.tracks : []; - const durationFrames = - typeof raw.durationFrames === "number" ? raw.durationFrames : 30; + // tracks and durationFrames are not in summary; they come from the cached manifest + const defaultDuration = 0.5; const direction = typeof raw.direction === "string" ? raw.direction : undefined; const thumbnailUrl = @@ -150,8 +185,6 @@ function normalizeMediaPreset( // "combo" maps to "in" as the compiler phase — combo presets are standalone in-place animations const phase: MotionBehaviorCategory = category === "out" ? "out" : "in"; - const defaultDuration = durationFrames / 30; - return { id: slug, name, @@ -162,7 +195,7 @@ function normalizeMediaPreset( description: typeof raw.description === "string" ? raw.description : name, defaultDuration, icon: iconForDirection(direction), - tracks: tracks as RemoteTextAnimationPreset["tracks"], + tracks: [], // populated after manifest download minDuration: 0.3, maxDuration: 3.0, previewLoopDuration: defaultDuration, @@ -193,7 +226,7 @@ export async function fetchMediaAnimationCategory( emit(); try { - const url = `${getApiBaseUrl()}/media-animations?category=${category}`; + const url = `${getApiBaseUrl()}/media-animations?category=${category}&fields=summary`; const response = await fetch(url, { headers: getApiHeaders(), credentials: "include", @@ -209,13 +242,13 @@ export async function fetchMediaAnimationCategory( // Response is either an array of animations or { items: [...] } const items: unknown[] = Array.isArray(payload) ? payload - : Array.isArray((payload as any)?.items) - ? (payload as any).items + : Array.isArray((payload as Record)?.items) + ? (payload as { items: unknown[] }).items : []; const presets = items .map((item) => normalizeMediaPreset(item, category)) - .filter((p): p is RemoteTextAnimationPreset => p !== null); + .filter((p): p is MediaAnimationPreset => p !== null); categorySnapshots[category] = { presets, status: "ready" }; } catch (error) { @@ -252,10 +285,59 @@ export function subscribeToMediaAnimationCatalog( export function getMediaAnimationPreset( id: string | undefined, -): RemoteTextAnimationPreset | undefined { +): MediaAnimationPreset | undefined { if (!id) return undefined; - return ( - downloadedPresets.get(id) ?? mergedSnapshot.presets.find((p) => p.id === id) + const base = + downloadedPresets.get(id) ?? + (mergedSnapshot.presets.find((p) => p.id === id) as + | MediaAnimationPreset + | undefined); + if (!base) return undefined; + + // If we have a cached manifest in memory (either from this session's + // download or eagerly loaded from disk on startup), merge its tracks. + const version = base.revisionId; + if (version) { + const manifest = mediaAnimationCacheManager.manifests.get( + `${id}@${version}`, + ); + if (manifest) { + return { + ...base, + tracks: manifest.tracks as MediaAnimationPreset["tracks"], + defaultDuration: + manifest.durationFrames / (manifest.frameRateNum || 30), + }; + } + } + return base; +} + +/** + * Downloads and caches the full animation manifest for a preset. + * After downloading, the manifest is stored in the singleton's `manifests` + * map so getMediaAnimationPreset() can merge it synchronously. + */ +export async function downloadManifest( + slug: string, + version: string, +): Promise { + return mediaAnimationCacheManager.ensureDownloaded( + slug, + version, + async () => { + const url = `${getApiBaseUrl()}/media-animations/${encodeURIComponent(slug)}/manifest`; + const response = await fetch(url, { + headers: getApiHeaders(), + credentials: "include", + }); + if (!response.ok) { + throw new Error( + `Manifest fetch failed for "${slug}" (${response.status})`, + ); + } + return response.json() as Promise; + }, ); } @@ -268,7 +350,10 @@ export function getMediaAnimationPreset( export function useMediaAnimationCategory( category: MediaAnimationCategory, ): MediaAnimationCategorySnapshot { - const snapshot = useSyncExternalStore( + // Subscribe to the external store so React re-renders when emit() fires. + // Read from categorySnapshotRefs — immutable copies rebuilt on every emit + // only when data changes — so React's reference equality check works correctly. + useSyncExternalStore( subscribeToMediaAnimationCatalog, getMediaAnimationCatalogSnapshot, getMediaAnimationCatalogSnapshot, @@ -276,12 +361,7 @@ export function useMediaAnimationCategory( useEffect(() => { void fetchMediaAnimationCategory(category); }, [category]); - // Derive from the immutable store snapshot so React always sees a new - // reference when the category's data changes, rather than reading the - // mutable module-level object directly (which can return stale data). - return snapshot.phases[ - category === "combo" ? "in" : category - ] as MediaAnimationCategorySnapshot; + return categorySnapshotRefs[category]; } /** diff --git a/src/i18n/catalogs/de.json b/src/i18n/catalogs/de.json index 354f61da3..a65ad5548 100644 --- a/src/i18n/catalogs/de.json +++ b/src/i18n/catalogs/de.json @@ -267,6 +267,10 @@ "properties.animation.favorites": "Favoriten", "properties.mediaAnimation.combo": "Combo", "properties.mediaAnimation.comboComingSoon": "Combo animations coming soon", + "properties.mediaAnimation.downloaded": "Downloaded", + "properties.mediaAnimation.downloading": "Downloading", + "properties.mediaAnimation.selectAnimation": "Animation anwenden", + "properties.mediaAnimation.downloadAnimation": "Download animation", "properties.text.presetStyle": "VOREINGESTELLTER STIL", "properties.text.expandPresets": "Voreingestellte Stile erweitern", "properties.text.collapsePresets": "Voreingestellte Stile einklappen", diff --git a/src/i18n/catalogs/en.json b/src/i18n/catalogs/en.json index ace33a0de..5ac09fe8e 100644 --- a/src/i18n/catalogs/en.json +++ b/src/i18n/catalogs/en.json @@ -267,6 +267,10 @@ "properties.animation.favorites": "Favorites", "properties.mediaAnimation.combo": "Combo", "properties.mediaAnimation.comboComingSoon": "Combo animations coming soon", + "properties.mediaAnimation.downloaded": "Downloaded", + "properties.mediaAnimation.downloading": "Downloading", + "properties.mediaAnimation.selectAnimation": "Apply animation", + "properties.mediaAnimation.downloadAnimation": "Download animation", "properties.text.presetStyle": "PRESET STYLE", "properties.text.expandPresets": "Expand preset styles", "properties.text.collapsePresets": "Collapse preset styles", diff --git a/src/i18n/catalogs/es.json b/src/i18n/catalogs/es.json index 66c93d432..32ad58f04 100644 --- a/src/i18n/catalogs/es.json +++ b/src/i18n/catalogs/es.json @@ -267,6 +267,10 @@ "properties.animation.favorites": "Favoritos", "properties.mediaAnimation.combo": "Combo", "properties.mediaAnimation.comboComingSoon": "Combo animations coming soon", + "properties.mediaAnimation.downloaded": "Downloaded", + "properties.mediaAnimation.downloading": "Downloading", + "properties.mediaAnimation.selectAnimation": "Aplicar animación", + "properties.mediaAnimation.downloadAnimation": "Download animation", "properties.text.presetStyle": "ESTILO PREDETERMINADO", "properties.text.expandPresets": "Expandir estilos predeterminados", "properties.text.collapsePresets": "Contraer estilos predeterminados", diff --git a/src/i18n/catalogs/fr.json b/src/i18n/catalogs/fr.json index 4ef9c7bc1..dc3b8dd97 100644 --- a/src/i18n/catalogs/fr.json +++ b/src/i18n/catalogs/fr.json @@ -267,6 +267,10 @@ "properties.animation.favorites": "Favoris", "properties.mediaAnimation.combo": "Combo", "properties.mediaAnimation.comboComingSoon": "Combo animations coming soon", + "properties.mediaAnimation.downloaded": "Downloaded", + "properties.mediaAnimation.downloading": "Downloading", + "properties.mediaAnimation.selectAnimation": "Appliquer l'animation", + "properties.mediaAnimation.downloadAnimation": "Download animation", "properties.text.presetStyle": "STYLE PRÉDÉFINI", "properties.text.expandPresets": "Développer les styles prédéfinis", "properties.text.collapsePresets": "Réduire les styles prédéfinis", diff --git a/src/i18n/catalogs/ja.json b/src/i18n/catalogs/ja.json index 40553834d..074486d1a 100644 --- a/src/i18n/catalogs/ja.json +++ b/src/i18n/catalogs/ja.json @@ -267,6 +267,10 @@ "properties.animation.favorites": "お気に入り", "properties.mediaAnimation.combo": "Combo", "properties.mediaAnimation.comboComingSoon": "Combo animations coming soon", + "properties.mediaAnimation.downloaded": "Downloaded", + "properties.mediaAnimation.downloading": "Downloading", + "properties.mediaAnimation.selectAnimation": "アニメーションを適用", + "properties.mediaAnimation.downloadAnimation": "Download animation", "properties.text.presetStyle": "プリセットスタイル", "properties.text.expandPresets": "プリセットスタイルを展開", "properties.text.collapsePresets": "プリセットスタイルを折りたたむ", diff --git a/src/i18n/catalogs/ko.json b/src/i18n/catalogs/ko.json index 320ef795c..c232d6366 100644 --- a/src/i18n/catalogs/ko.json +++ b/src/i18n/catalogs/ko.json @@ -267,6 +267,10 @@ "properties.animation.favorites": "즐겨찾기", "properties.mediaAnimation.combo": "Combo", "properties.mediaAnimation.comboComingSoon": "Combo animations coming soon", + "properties.mediaAnimation.downloaded": "Downloaded", + "properties.mediaAnimation.downloading": "Downloading", + "properties.mediaAnimation.selectAnimation": "애니메이션 적용", + "properties.mediaAnimation.downloadAnimation": "Download animation", "properties.text.presetStyle": "프리셋 스타일", "properties.text.expandPresets": "프리셋 스타일 펼치기", "properties.text.collapsePresets": "프리셋 스타일 접기", diff --git a/src/i18n/catalogs/ru.json b/src/i18n/catalogs/ru.json index 7f03053a9..ce9fc2192 100644 --- a/src/i18n/catalogs/ru.json +++ b/src/i18n/catalogs/ru.json @@ -271,6 +271,10 @@ "properties.animation.favorites": "Избранное", "properties.mediaAnimation.combo": "Combo", "properties.mediaAnimation.comboComingSoon": "Combo animations coming soon", + "properties.mediaAnimation.downloaded": "Downloaded", + "properties.mediaAnimation.downloading": "Downloading", + "properties.mediaAnimation.selectAnimation": "Применить анимацию", + "properties.mediaAnimation.downloadAnimation": "Download animation", "properties.text.presetStyle": "ПРЕДУСТАНОВЛЕННЫЙ СТИЛЬ", "properties.text.expandPresets": "Развернуть стили", "properties.text.collapsePresets": "Свернуть стили", diff --git a/src/i18n/catalogs/zh-CN.json b/src/i18n/catalogs/zh-CN.json index bb0b95149..cbba1a6d2 100644 --- a/src/i18n/catalogs/zh-CN.json +++ b/src/i18n/catalogs/zh-CN.json @@ -267,6 +267,10 @@ "properties.animation.favorites": "收藏", "properties.mediaAnimation.combo": "Combo", "properties.mediaAnimation.comboComingSoon": "Combo animations coming soon", + "properties.mediaAnimation.downloaded": "Downloaded", + "properties.mediaAnimation.downloading": "Downloading", + "properties.mediaAnimation.selectAnimation": "应用动画", + "properties.mediaAnimation.downloadAnimation": "Download animation", "properties.text.presetStyle": "预设样式", "properties.text.expandPresets": "展开预设样式", "properties.text.collapsePresets": "折叠预设样式", diff --git a/src/i18n/catalogs/zh-TW.json b/src/i18n/catalogs/zh-TW.json index 7752dc6df..1b066f3fa 100644 --- a/src/i18n/catalogs/zh-TW.json +++ b/src/i18n/catalogs/zh-TW.json @@ -267,6 +267,10 @@ "properties.animation.favorites": "收藏", "properties.mediaAnimation.combo": "Combo", "properties.mediaAnimation.comboComingSoon": "Combo animations coming soon", + "properties.mediaAnimation.downloaded": "Downloaded", + "properties.mediaAnimation.downloading": "Downloading", + "properties.mediaAnimation.selectAnimation": "套用動畫", + "properties.mediaAnimation.downloadAnimation": "Download animation", "properties.text.presetStyle": "預設樣式", "properties.text.expandPresets": "展開預設樣式", "properties.text.collapsePresets": "摺疊預設樣式", diff --git a/src/lib/cache/cacheManager.ts b/src/lib/cache/cacheManager.ts index d04600d57..6173f727e 100644 --- a/src/lib/cache/cacheManager.ts +++ b/src/lib/cache/cacheManager.ts @@ -12,6 +12,7 @@ import { remove, BaseDirectory, exists } from "@tauri-apps/plugin-fs"; import { globalGPUCache } from "./globalGPUCache"; +import { mediaAnimationCacheManager } from "@/features/media-animations/cache/mediaAnimationCache"; export interface CacheStats { appCacheCleared: boolean; @@ -20,7 +21,8 @@ export interface CacheStats { errors: string[]; } -const isTauri = () => typeof window !== "undefined" && !!(window as any).__TAURI_INTERNALS__; +const isTauri = () => + typeof window !== "undefined" && !!(window as any).__TAURI_INTERNALS__; export class CacheManager { /** @@ -57,21 +59,29 @@ export class CacheManager { * Note: Some WebView processes may lock these files while the app is running. * A full restart may be required for complete deletion. */ - static async clearWebViewCache(): Promise<{ success: boolean; error?: string }> { + static async clearWebViewCache(): Promise<{ + success: boolean; + error?: string; + }> { if (!isTauri()) { return { success: true }; } try { // Check if WebView cache exists - const webViewExists = await exists("EBWebView", { baseDir: BaseDirectory.AppLocalData }); + const webViewExists = await exists("EBWebView", { + baseDir: BaseDirectory.AppLocalData, + }); if (!webViewExists) { return { success: true }; } // Remove WebView cache directory - await remove("EBWebView", { baseDir: BaseDirectory.AppLocalData, recursive: true }); + await remove("EBWebView", { + baseDir: BaseDirectory.AppLocalData, + recursive: true, + }); return { success: true }; } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); @@ -79,7 +89,9 @@ export class CacheManager { // WebView cache might be locked by running processes if (errorMsg.includes("locked") || errorMsg.includes("in use")) { - console.warn("⚠️ WebView cache is locked. Please restart the application for full cache clearing."); + console.warn( + "⚠️ WebView cache is locked. Please restart the application for full cache clearing.", + ); } return { success: false, error: errorMsg }; @@ -217,7 +229,15 @@ export class CacheManager { indexedDB?: boolean; } = {}, ): Promise { - const { appCache = true, webViewCache = true, gpuCache = true, httpCache = true, localStorage: clearLS = true, sessionStorage: clearSS = true, indexedDB = true } = options; + const { + appCache = true, + webViewCache = true, + gpuCache = true, + httpCache = true, + localStorage: clearLS = true, + sessionStorage: clearSS = true, + indexedDB = true, + } = options; const stats: CacheStats = { appCacheCleared: false, @@ -304,7 +324,9 @@ export class CacheManager { return { localStorage: localStorage.length, sessionStorage: sessionStorage.length, - gpuCache: globalGPUCache.isInitialized() ? globalGPUCache.getStats() : null, + gpuCache: globalGPUCache.isInitialized() + ? globalGPUCache.getStats() + : null, }; } } @@ -314,5 +336,9 @@ export const clearAppCache = () => CacheManager.clearAppCache(); export const clearWebViewCache = () => CacheManager.clearWebViewCache(); export const clearGPUCache = () => CacheManager.clearGPUCache(); export const clearHTTPCache = () => CacheManager.clearHTTPCache(); -export const clearAllCaches = (options?: Parameters[0]) => CacheManager.clearAllCaches(options); +export const clearAllCaches = ( + options?: Parameters[0], +) => CacheManager.clearAllCaches(options); export const getCacheInfo = () => CacheManager.getCacheInfo(); +export const clearMediaAnimationCache = () => + mediaAnimationCacheManager.clearCache();