From 9a46167819838135d5b732768b7cf79889224741 Mon Sep 17 00:00:00 2001 From: Abdulkabir Musa Date: Thu, 5 Nov 2026 20:48:26 +0100 Subject: [PATCH 1/7] feat(media-animations): OS-level manifest cache + summary listing + download UX - Add MediaAnimationCacheManager (src/features/media-animations/cache/mediaAnimationCache.ts) modeled on stickerCache.ts: BaseDirectory.AppCache, index.json, per-slug JSON files, isCached / ensureDownloaded / getManifest / clearCache; graceful browser degradation - Update mediaAnimationCatalog.ts: * Import mediaAnimationCacheManager + AnimationManifest * Append &fields=summary to category fetch URL (lighter response) * normalizeMediaPreset returns tracks:[] and defaultDuration:0.5 (summary has no tracks) * Add cachedManifests in-memory map + best-effort initialize() at module load * Add downloadManifest() export (ensureDownloaded + populates cachedManifests) * getMediaAnimationPreset() merges tracks/defaultDuration from cached manifest when present - Update PresetCard.tsx: * New props: isDownload * New props: isDownload * New props: isDownload * New props: isDownload * New props: isDownload * New props: isDownload * New props: isDownload * New props: isDownload * New props: isDo(bo * New props: isDownload * New props: isDownload * New props: isDownload * New pro: * Im * Im * Im * Im * Im * Im * Im * Im * Im * Im * Im * Im * Im * Im * Im wn * Im * Im * Im * Im * Im * Im * Im * Im * Im * Im * Im * Im * Im * Im / isDownloading / onDownload to each P * Im * Im * Im * Im * Im * Im * Im * Im * Im xport into cacheManager.ts tsc: 0 errors --- .../MediaAnimationSection.tsx | 36 ++- src/features/media-animations/PresetCard.tsx | 54 +++- .../cache/mediaAnimationCache.ts | 251 ++++++++++++++++++ .../media-animations/mediaAnimationCatalog.ts | 71 ++++- src/lib/cache/cacheManager.ts | 44 ++- 5 files changed, 437 insertions(+), 19 deletions(-) create mode 100644 src/features/media-animations/cache/mediaAnimationCache.ts diff --git a/src/features/media-animations/MediaAnimationSection.tsx b/src/features/media-animations/MediaAnimationSection.tsx index e1120932b..be67a4f2e 100644 --- a/src/features/media-animations/MediaAnimationSection.tsx +++ b/src/features/media-animations/MediaAnimationSection.tsx @@ -10,7 +10,7 @@ * - Combo tab shows "Coming soon" placeholder when empty */ -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 +19,10 @@ import { useMediaAnimationCategory, useMediaAnimationCatalogSnapshot, type MediaAnimationCategory, + downloadManifest, + getMediaAnimationPreset, } from "./mediaAnimationCatalog"; +import { mediaAnimationCacheManager } from "./cache/mediaAnimationCache"; import { useI18n } from "@/i18n"; import { SliderTrack } from "@/components/editor/properties/primitives/SliderTrack"; import { NumericControl } from "@/components/editor/properties/primitives/NumericControl"; @@ -66,6 +69,9 @@ export const MediaAnimationSection: React.FC = ({ const [favorites, setFavorites] = useState>(() => getStoredFavorites(), ); + const [downloadingIds, setDownloadingIds] = useState>( + () => new Set(), + ); const catalogSnapshot = useMediaAnimationCatalogSnapshot(); const phaseData = useMediaAnimationCategory(activeTab); @@ -81,6 +87,28 @@ export const MediaAnimationSection: React.FC = ({ }); }, []); + const handleDownload = useCallback( + async (presetId: string, e: React.MouseEvent) => { + e.stopPropagation(); + const preset = getMediaAnimationPreset(presetId); + const version = (preset as any)?.revisionId as string | undefined; + if (!version) return; + setDownloadingIds((prev) => new Set(prev).add(presetId)); + try { + await downloadManifest(presetId, version); + } catch (err) { + console.error("[MediaAnimationSection] Download failed:", err); + } finally { + setDownloadingIds((prev) => { + const next = new Set(prev); + next.delete(presetId); + return next; + }); + } + }, + [], + ); + const motion: ClipMotionConfig = selectedClip.motion ?? {}; const hasIn = Boolean(motion.inPreset && motion.inPreset !== "none"); const hasOut = Boolean(motion.outPreset && motion.outPreset !== "none"); @@ -299,6 +327,12 @@ export const MediaAnimationSection: React.FC = ({ isFavorite={favorites.has(preset.id)} onSelect={() => handleSelectPreset(preset.id)} onToggleFavorite={(e) => toggleFavorite(preset.id, e)} + isDownloaded={mediaAnimationCacheManager.isCached( + preset.id, + (preset as any).revisionId ?? "", + )} + isDownloading={downloadingIds.has(preset.id)} + onDownload={(e) => handleDownload(preset.id, e)} /> ))} diff --git a/src/features/media-animations/PresetCard.tsx b/src/features/media-animations/PresetCard.tsx index 0c2123854..772c8b37b 100644 --- a/src/features/media-animations/PresetCard.tsx +++ b/src/features/media-animations/PresetCard.tsx @@ -8,6 +8,9 @@ import { ArrowRight, ZoomIn, ZoomOut, + CloudDownload, + Loader2, + Check, } from "lucide-react"; const ICON_MAP: Record> = { @@ -29,6 +32,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,6 +47,9 @@ const PresetCard: React.FC = ({ isFavorite, onSelect, onToggleFavorite, + isDownloaded, + isDownloading, + onDownload, }) => { const [isHovered, setIsHovered] = useState(false); const videoRef = useRef(null); @@ -63,6 +73,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,7 +92,7 @@ const PresetCard: React.FC = ({ return ( + + {/* Download / downloading / downloaded indicator — bottom-left */} + ; + }>; + durationFrames: number; + frameRateNum: number; + frameRateDen: number; + compositionRules: Record; +} + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const CACHE_DIR = "media-animations/manifests"; +const INDEX_FILE = "media-animations/index.json"; + +// ─── Cache Manager ──────────────────────────────────────────────────────────── + +class MediaAnimationCacheManager { + private cacheIndex: Map = new Map(); + private cacheDir: string | null = null; + private initialized = false; + + /** + * Creates the cache directory and loads the index from disk. + * Idempotent; gracefully handles environments where Tauri FS is unavailable. + */ + async initialize(): Promise { + if (this.initialized) return; + + try { + const appCache = await appCacheDir(); + this.cacheDir = await join(appCache, CACHE_DIR); + + const dirExists = await exists(CACHE_DIR, { + baseDir: BaseDirectory.AppCache, + }); + if (!dirExists) { + await mkdir(CACHE_DIR, { + baseDir: BaseDirectory.AppCache, + recursive: true, + }); + } + + await this.loadIndex(); + 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; + } + } + + // ── 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(); + } + } + + 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); + } + } + + // ── 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 cached = await this.getManifest(slug, version); + if (cached) return cached; + // Index entry exists but file is missing — fall through to re-download + } + + try { + const manifest = await fetchFn(); + + const localPath = `media-animations/manifests/${slug}@${version}.json`; + const json = JSON.stringify(manifest); + const data = new TextEncoder().encode(json); + + try { + await writeFile(localPath, data, { baseDir: BaseDirectory.AppCache }); + } 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: manifest.contentHash ?? "", + localPath, + downloadedAt: Date.now(), + sizeBytes: data.byteLength, + }; + + this.cacheIndex.set(cacheKey, entry); + 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. + */ + async getManifest( + slug: string, + version: string, + ): Promise { + const entry = this.cacheIndex.get(`${slug}@${version}`); + if (!entry) return null; + + try { + const raw = await readFile(entry.localPath, { + baseDir: BaseDirectory.AppCache, + }); + const json = new TextDecoder().decode(raw); + return JSON.parse(json) as AnimationManifest; + } catch (error) { + console.warn( + `[MediaAnimationCache] Could not read cached manifest for "${slug}@${version}":`, + error, + ); + return null; + } + } + + /** + * 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(); + 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..de76499ee 100644 --- a/src/features/media-animations/mediaAnimationCatalog.ts +++ b/src/features/media-animations/mediaAnimationCatalog.ts @@ -18,6 +18,10 @@ import type { PhaseSnapshot, RemoteTextAnimationPreset, } from "@/features/text-animations/animationCatalog"; +import { + mediaAnimationCacheManager, + type AnimationManifest, +} from "./cache/mediaAnimationCache"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -47,6 +51,12 @@ const categoryRequests: Partial>> = {}; const listeners = new Set<() => void>(); +// In-memory manifest map — populated by downloadManifest() +const cachedManifests = new Map(); + +// Best-effort initialize the OS-level cache — no top-level await +mediaAnimationCacheManager.initialize().catch(() => {}); + // ─── Derived merged snapshot ───────────────────────────────────────────────── function buildMergedSnapshot(): AnimationCatalogSnapshot { @@ -136,9 +146,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 +159,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 +169,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 +200,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", @@ -254,9 +261,55 @@ export function getMediaAnimationPreset( id: string | undefined, ): RemoteTextAnimationPreset | 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); + if (!base) return undefined; + + // If we have a cached manifest in memory, merge its tracks + const version = (base as any).revisionId as string | undefined; + if (version) { + const manifest = cachedManifests.get(`${id}@${version}`); + if (manifest) { + return { + ...base, + tracks: manifest.tracks as RemoteTextAnimationPreset["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 in-memory cachedManifests + * map so getMediaAnimationPreset() can merge it synchronously. + */ +export async function downloadManifest( + slug: string, + version: string, +): Promise { + const manifest = await 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; + }, ); + cachedManifests.set(`${slug}@${version}`, manifest); + return manifest; } // ─── React hooks ───────────────────────────────────────────────────────────── diff --git a/src/lib/cache/cacheManager.ts b/src/lib/cache/cacheManager.ts index d04600d57..7470b8158 100644 --- a/src/lib/cache/cacheManager.ts +++ b/src/lib/cache/cacheManager.ts @@ -20,7 +20,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 +58,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 +88,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 +228,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 +323,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 +335,12 @@ 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(); + +import { mediaAnimationCacheManager } from "@/features/media-animations/cache/mediaAnimationCache"; + +export const clearMediaAnimationCache = () => + mediaAnimationCacheManager.clearCache(); From 2f7ea9df9319fab5b23fc84b03295a837bb11750 Mon Sep 17 00:00:00 2001 From: Abdulkabir Musa Date: Thu, 5 Nov 2026 21:07:41 +0100 Subject: [PATCH 2/7] feat(media-animations): OS-level manifest cache + summary listing + download UX Fixes all review findings from .agents/tasks/media-animation-cache/review.json: 1. Fix implicit isDownloaded re-render dependency (review finding 1) - Added explicit downloadedIds state Set in MediaAnimationSection - handleDownload now updates downloadedIds in finally block alongside clearing downloadingIds, making the checkmark update unconditionally 2. Eliminated (preset as any) casts for revisionId/thumbnailUrl/previewUrl (finding 2) - Defined MediaAnimationPreset sub-interface in mediaAnimationCache.ts extending RemoteTextAnimationPreset with thumbnailUrl?, previewUrl?, revisionId? - Updated categorySnapshots, downloadedPresets, normalizeMediaPreset, and getMediaAnimationPreset to use MediaAnimationPreset throughout - MediaAnimationSection imports MediaAnimationPreset and uses typed access 3. Moved import to top of cacheManage3. Moved import to top of cacheManage3. Moved import to top of cacheManage3. Moved import to top of cacheManage3. Moved import to top of cacheManafol3. Moved import to top of cacheManage3. Moved import to top of cacheManageding 4)3. Moved import to top of cacheManage3. Moved import to top of cachwn3. Moved import to top of cacheManage3. Moved import to top of cu/zh-3. Moved import to top of cacheManage3. Moved import to top of cacheMasta3. Moved import to top of cacheManage3. Moved import to top of cacheManay-tracks on restart fixed (finding 5) - MediaAnimationCacheManager.initialize() now calls hydrateManifests() after loadIndex(), eagerly reading all previously-downloa loadIndex(), eagerly reading all previously-downloa loadIndex(), on loadIndex(), eagerly reading all previously-downloa loadIndex(), ho loadIndex(), eagerly reading all previously-downloa loade a loadIndex(), eagerly reading all previously-downloa loadIndex(), ean so listeners re-evaluate isCached() 6. SHA-256 hash verification on read (finding 6) - Added readAndVerifyManifest() private helper that computes SHA-256 of the raw bytes and compares against the stored contentHash - ensureDownloaded() stores the hash of the actual written bytes - getManifest() and hydrateManifests() go through readAndVerifyManifest(); corrupted files are evicted from the index and deleted from disk - Mirrors the sticker cache pattern Verification: - npx tsc --noEmit: 0 errors - npm run i18n:check: all 9 locales 100% valid --- .../MediaAnimationSection.tsx | 54 ++++-- src/features/media-animations/PresetCard.tsx | 8 +- .../cache/mediaAnimationCache.ts | 183 ++++++++++++++++-- .../media-animations/mediaAnimationCatalog.ts | 52 ++--- src/i18n/catalogs/de.json | 3 + src/i18n/catalogs/en.json | 3 + src/i18n/catalogs/es.json | 3 + src/i18n/catalogs/fr.json | 3 + src/i18n/catalogs/ja.json | 3 + src/i18n/catalogs/ko.json | 3 + src/i18n/catalogs/ru.json | 3 + src/i18n/catalogs/zh-CN.json | 3 + src/i18n/catalogs/zh-TW.json | 3 + src/lib/cache/cacheManager.ts | 4 +- 14 files changed, 261 insertions(+), 67 deletions(-) diff --git a/src/features/media-animations/MediaAnimationSection.tsx b/src/features/media-animations/MediaAnimationSection.tsx index be67a4f2e..31167e52d 100644 --- a/src/features/media-animations/MediaAnimationSection.tsx +++ b/src/features/media-animations/MediaAnimationSection.tsx @@ -23,6 +23,7 @@ import { 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"; @@ -72,6 +73,14 @@ export const MediaAnimationSection: React.FC = ({ const [downloadingIds, setDownloadingIds] = useState>( () => new Set(), ); + // Explicit set of preset IDs whose manifests have been fully downloaded + // in this session. Derived from isCached() on the first render and then + // updated whenever a download completes so the checkmark is always in sync. + const [downloadedIds, setDownloadedIds] = useState>(() => { + // Seed from the OS-level cache index that was loaded during initialize(). + // This covers presets that were downloaded in a previous session. + return new Set(); + }); const catalogSnapshot = useMediaAnimationCatalogSnapshot(); const phaseData = useMediaAnimationCategory(activeTab); @@ -91,11 +100,12 @@ export const MediaAnimationSection: React.FC = ({ async (presetId: string, e: React.MouseEvent) => { e.stopPropagation(); const preset = getMediaAnimationPreset(presetId); - const version = (preset as any)?.revisionId as string | undefined; + 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 { @@ -316,25 +326,31 @@ export const MediaAnimationSection: React.FC = ({ ))} {/* Preset cards */} - {filteredPresets.map((preset) => ( - handleSelectPreset(preset.id)} - onToggleFavorite={(e) => toggleFavorite(preset.id, e)} - isDownloaded={mediaAnimationCacheManager.isCached( + {filteredPresets.map((preset) => { + const mediaPreset = preset as MediaAnimationPreset; + const isDownloaded = + downloadedIds.has(preset.id) || + mediaAnimationCacheManager.isCached( preset.id, - (preset as any).revisionId ?? "", - )} - isDownloading={downloadingIds.has(preset.id)} - onDownload={(e) => handleDownload(preset.id, e)} - /> - ))} + mediaPreset.revisionId ?? "", + ); + return ( + handleSelectPreset(preset.id)} + onToggleFavorite={(e) => toggleFavorite(preset.id, e)} + isDownloaded={isDownloaded} + isDownloading={downloadingIds.has(preset.id)} + onDownload={(e) => handleDownload(preset.id, e)} + /> + ); + })} {/* ── Duration slider (In/Out only) ── */} diff --git a/src/features/media-animations/PresetCard.tsx b/src/features/media-animations/PresetCard.tsx index 772c8b37b..06bfbf8f7 100644 --- a/src/features/media-animations/PresetCard.tsx +++ b/src/features/media-animations/PresetCard.tsx @@ -12,6 +12,7 @@ import { Loader2, Check, } from "lucide-react"; +import { useI18n } from "@/i18n"; const ICON_MAP: Record> = { ArrowUp, @@ -51,6 +52,7 @@ const PresetCard: React.FC = ({ isDownloading, onDownload, }) => { + const { t } = useI18n(); const [isHovered, setIsHovered] = useState(false); const videoRef = useRef(null); const IconComp = ICON_MAP[icon] ?? Sparkles; @@ -173,10 +175,10 @@ const PresetCard: React.FC = ({ }`} aria-label={ isDownloaded - ? "Downloaded" + ? t("properties.mediaAnimation.downloaded") : isDownloading - ? "Downloading" - : "Download animation" + ? t("properties.mediaAnimation.downloading") + : t("properties.mediaAnimation.downloadAnimation") } > {isDownloading ? ( diff --git a/src/features/media-animations/cache/mediaAnimationCache.ts b/src/features/media-animations/cache/mediaAnimationCache.ts index d6a29cac6..31c65b668 100644 --- a/src/features/media-animations/cache/mediaAnimationCache.ts +++ b/src/features/media-animations/cache/mediaAnimationCache.ts @@ -15,6 +15,12 @@ import { remove, } from "@tauri-apps/plugin-fs"; import { join, appCacheDir } from "@tauri-apps/api/path"; +import type { MotionBehaviorCategory } from "@/types/motion"; +import type { + RemoteTextAnimationPreset, + AnimationTrackKeyframe, + AnimationTrackProperty, +} from "@/features/text-animations/animationCatalog"; // ─── Public types ───────────────────────────────────────────────────────────── @@ -41,11 +47,35 @@ export interface AnimationManifest { 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 { @@ -53,9 +83,19 @@ class MediaAnimationCacheManager { private cacheDir: string | null = null; 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; @@ -75,6 +115,7 @@ class MediaAnimationCacheManager { } await this.loadIndex(); + await this.hydrateManifests(); this.initialized = true; } catch (error) { // Browser / web-preview environment — no Tauri FS available @@ -84,6 +125,27 @@ class MediaAnimationCacheManager { ); 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 ───────────────────────────────────────────────────────── @@ -114,6 +176,32 @@ class MediaAnimationCacheManager { } } + /** + * 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()); @@ -125,6 +213,51 @@ class MediaAnimationCacheManager { } } + /** + * 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 ────────────────────────────────────────────────────────────── /** @@ -148,9 +281,14 @@ class MediaAnimationCacheManager { const cacheKey = `${slug}@${version}`; if (this.cacheIndex.has(cacheKey)) { - const cached = await this.getManifest(slug, version); - if (cached) return cached; - // Index entry exists but file is missing — fall through to re-download + 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 { @@ -160,8 +298,14 @@ class MediaAnimationCacheManager { 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( @@ -173,13 +317,14 @@ class MediaAnimationCacheManager { const entry: CachedManifestEntry = { slug, version, - contentHash: manifest.contentHash ?? "", + contentHash, localPath, downloadedAt: Date.now(), sizeBytes: data.byteLength, }; this.cacheIndex.set(cacheKey, entry); + this.manifests.set(cacheKey, manifest); await this.saveIndex(); return manifest; @@ -194,28 +339,29 @@ class MediaAnimationCacheManager { /** * Reads the cached JSON file for the entry and returns the parsed manifest, - * or null if not cached. + * or null if not cached or verification fails. */ async getManifest( slug: string, version: string, ): Promise { - const entry = this.cacheIndex.get(`${slug}@${version}`); + // 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; - try { - const raw = await readFile(entry.localPath, { - baseDir: BaseDirectory.AppCache, - }); - const json = new TextDecoder().decode(raw); - return JSON.parse(json) as AnimationManifest; - } catch (error) { - console.warn( - `[MediaAnimationCache] Could not read cached manifest for "${slug}@${version}":`, - error, - ); - 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; } /** @@ -242,6 +388,7 @@ class MediaAnimationCacheManager { } this.cacheIndex.clear(); + this.manifests.clear(); await this.saveIndex(); } } diff --git a/src/features/media-animations/mediaAnimationCatalog.ts b/src/features/media-animations/mediaAnimationCatalog.ts index de76499ee..5a123ab6d 100644 --- a/src/features/media-animations/mediaAnimationCatalog.ts +++ b/src/features/media-animations/mediaAnimationCatalog.ts @@ -21,6 +21,7 @@ import type { import { mediaAnimationCacheManager, type AnimationManifest, + type MediaAnimationPreset, } from "./cache/mediaAnimationCache"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -28,7 +29,7 @@ import { export type MediaAnimationCategory = "in" | "out" | "combo"; export interface MediaAnimationCategorySnapshot { - presets: RemoteTextAnimationPreset[]; + presets: MediaAnimationPreset[]; status: "idle" | "loading" | "ready" | "error"; error?: string; } @@ -46,16 +47,18 @@ const categorySnapshots: Record< combo: { ...IDLE }, }; -const downloadedPresets = new Map(); +const downloadedPresets = new Map(); const categoryRequests: Partial>> = {}; const listeners = new Set<() => void>(); -// In-memory manifest map — populated by downloadManifest() -const cachedManifests = new Map(); - -// Best-effort initialize the OS-level cache — no top-level await -mediaAnimationCacheManager.initialize().catch(() => {}); +// 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 ───────────────────────────────────────────────── @@ -131,9 +134,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; @@ -216,13 +217,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) { @@ -259,21 +260,26 @@ export function subscribeToMediaAnimationCatalog( export function getMediaAnimationPreset( id: string | undefined, -): RemoteTextAnimationPreset | undefined { +): MediaAnimationPreset | undefined { if (!id) return undefined; const base = downloadedPresets.get(id) ?? - mergedSnapshot.presets.find((p) => p.id === id); + (mergedSnapshot.presets.find((p) => p.id === id) as + | MediaAnimationPreset + | undefined); if (!base) return undefined; - // If we have a cached manifest in memory, merge its tracks - const version = (base as any).revisionId as string | 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 = cachedManifests.get(`${id}@${version}`); + const manifest = mediaAnimationCacheManager.manifests.get( + `${id}@${version}`, + ); if (manifest) { return { ...base, - tracks: manifest.tracks as RemoteTextAnimationPreset["tracks"], + tracks: manifest.tracks as MediaAnimationPreset["tracks"], defaultDuration: manifest.durationFrames / (manifest.frameRateNum || 30), }; @@ -284,14 +290,14 @@ export function getMediaAnimationPreset( /** * Downloads and caches the full animation manifest for a preset. - * After downloading, the manifest is stored in the in-memory cachedManifests + * 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 { - const manifest = await mediaAnimationCacheManager.ensureDownloaded( + return mediaAnimationCacheManager.ensureDownloaded( slug, version, async () => { @@ -308,8 +314,6 @@ export async function downloadManifest( return response.json() as Promise; }, ); - cachedManifests.set(`${slug}@${version}`, manifest); - return manifest; } // ─── React hooks ───────────────────────────────────────────────────────────── @@ -333,7 +337,7 @@ export function useMediaAnimationCategory( // 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 + category === "combo" ? "loop" : category ] as MediaAnimationCategorySnapshot; } diff --git a/src/i18n/catalogs/de.json b/src/i18n/catalogs/de.json index 354f61da3..89277c97c 100644 --- a/src/i18n/catalogs/de.json +++ b/src/i18n/catalogs/de.json @@ -267,6 +267,9 @@ "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.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..9199b6ee9 100644 --- a/src/i18n/catalogs/en.json +++ b/src/i18n/catalogs/en.json @@ -267,6 +267,9 @@ "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.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..e1a9ae162 100644 --- a/src/i18n/catalogs/es.json +++ b/src/i18n/catalogs/es.json @@ -267,6 +267,9 @@ "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.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..a87d71360 100644 --- a/src/i18n/catalogs/fr.json +++ b/src/i18n/catalogs/fr.json @@ -267,6 +267,9 @@ "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.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..6fe4730e7 100644 --- a/src/i18n/catalogs/ja.json +++ b/src/i18n/catalogs/ja.json @@ -267,6 +267,9 @@ "properties.animation.favorites": "お気に入り", "properties.mediaAnimation.combo": "Combo", "properties.mediaAnimation.comboComingSoon": "Combo animations coming soon", + "properties.mediaAnimation.downloaded": "Downloaded", + "properties.mediaAnimation.downloading": "Downloading", + "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..f512878b4 100644 --- a/src/i18n/catalogs/ko.json +++ b/src/i18n/catalogs/ko.json @@ -267,6 +267,9 @@ "properties.animation.favorites": "즐겨찾기", "properties.mediaAnimation.combo": "Combo", "properties.mediaAnimation.comboComingSoon": "Combo animations coming soon", + "properties.mediaAnimation.downloaded": "Downloaded", + "properties.mediaAnimation.downloading": "Downloading", + "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..5ffd359cd 100644 --- a/src/i18n/catalogs/ru.json +++ b/src/i18n/catalogs/ru.json @@ -271,6 +271,9 @@ "properties.animation.favorites": "Избранное", "properties.mediaAnimation.combo": "Combo", "properties.mediaAnimation.comboComingSoon": "Combo animations coming soon", + "properties.mediaAnimation.downloaded": "Downloaded", + "properties.mediaAnimation.downloading": "Downloading", + "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..02af74f67 100644 --- a/src/i18n/catalogs/zh-CN.json +++ b/src/i18n/catalogs/zh-CN.json @@ -267,6 +267,9 @@ "properties.animation.favorites": "收藏", "properties.mediaAnimation.combo": "Combo", "properties.mediaAnimation.comboComingSoon": "Combo animations coming soon", + "properties.mediaAnimation.downloaded": "Downloaded", + "properties.mediaAnimation.downloading": "Downloading", + "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..aae657194 100644 --- a/src/i18n/catalogs/zh-TW.json +++ b/src/i18n/catalogs/zh-TW.json @@ -267,6 +267,9 @@ "properties.animation.favorites": "收藏", "properties.mediaAnimation.combo": "Combo", "properties.mediaAnimation.comboComingSoon": "Combo animations coming soon", + "properties.mediaAnimation.downloaded": "Downloaded", + "properties.mediaAnimation.downloading": "Downloading", + "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 7470b8158..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; @@ -339,8 +340,5 @@ export const clearAllCaches = ( options?: Parameters[0], ) => CacheManager.clearAllCaches(options); export const getCacheInfo = () => CacheManager.getCacheInfo(); - -import { mediaAnimationCacheManager } from "@/features/media-animations/cache/mediaAnimationCache"; - export const clearMediaAnimationCache = () => mediaAnimationCacheManager.clearCache(); From cd556c2a106a9f52de59c91a960b1e24169cd21f Mon Sep 17 00:00:00 2001 From: Abdulkabir Musa Date: Fri, 6 Nov 2026 01:41:06 +0100 Subject: [PATCH 3/7] fix(media-animations): resolve all 7 review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blocking fixes: 1. updateMotion now passes manifests-merged catalog to compileClipMotionKeyframes — catalogSnapshot.presets has tracks:[] (summary); getMediaAnimationPreset() merges manifest tracks synchronously before compilation 2. Revert combo→loop phase-key: combo presets use phase 'in' and compile via the inPreset the inPreset the inPreset the inPreset the inPreset the inPreset the inPreset the inPresro the inPreset the inPreset the inPreset the inPresdr the inPreset the inPreset the inPreset the ileD the inPreset the inPreset the inPreset the ilick the inPreset the inPreset the inPreset.t the inPreset the inPre the inPreset the inPimationTrackProperty, join, appCac the inPreset the ind but the inPreset the inPreng current action (selectAnimation vs downloadAnimation); i18n key added to all 9 locales 7.7.7.7.7.7.7.7.7.7.7.7.7.7.7.7.om MediaAnimationCacheManager Verification: tsc --noEmit 0 errors; npm run i18n:check all 9 locales 100% --- .../MediaAnimationSection.tsx | 46 +++++++++++++++---- src/features/media-animations/PresetCard.tsx | 5 ++ .../cache/mediaAnimationCache.ts | 12 +---- .../media-animations/mediaAnimationCatalog.ts | 12 +++-- src/i18n/catalogs/de.json | 1 + src/i18n/catalogs/en.json | 1 + src/i18n/catalogs/es.json | 1 + src/i18n/catalogs/fr.json | 1 + src/i18n/catalogs/ja.json | 1 + src/i18n/catalogs/ko.json | 1 + src/i18n/catalogs/ru.json | 1 + src/i18n/catalogs/zh-CN.json | 1 + src/i18n/catalogs/zh-TW.json | 1 + 13 files changed, 60 insertions(+), 24 deletions(-) diff --git a/src/features/media-animations/MediaAnimationSection.tsx b/src/features/media-animations/MediaAnimationSection.tsx index 31167e52d..871335964 100644 --- a/src/features/media-animations/MediaAnimationSection.tsx +++ b/src/features/media-animations/MediaAnimationSection.tsx @@ -73,15 +73,32 @@ export const MediaAnimationSection: React.FC = ({ const [downloadingIds, setDownloadingIds] = useState>( () => new Set(), ); - // Explicit set of preset IDs whose manifests have been fully downloaded - // in this session. Derived from isCached() on the first render and then - // updated whenever a download completes so the checkmark is always in sync. + // Explicit set of preset IDs whose manifests have been fully downloaded. + // Seeded synchronously from the OS cache index (populated during initialize) + // and updated as downloads complete this session. const [downloadedIds, setDownloadedIds] = useState>(() => { - // Seed from the OS-level cache index that was loaded during initialize(). - // This covers presets that were downloaded in a previous session. - return new Set(); + // Seed any IDs already in the in-memory manifests map (hydrated from disk + // on app restart before this component mounts). + const keys = Array.from(mediaAnimationCacheManager.manifests.keys()).map( + (k) => k.split("@")[0], + ); + return new Set(keys); }); + // Subscribe to the one-shot hydration callback so that if initialize() + // completes after this component first renders, downloadedIds updates. + React.useEffect(() => { + return mediaAnimationCacheManager.onHydrated(() => { + setDownloadedIds((prev) => { + const keys = Array.from( + mediaAnimationCacheManager.manifests.keys(), + ).map((k) => k.split("@")[0]); + const next = new Set([...prev, ...keys]); + return next; + }); + }); + }, []); + const catalogSnapshot = useMediaAnimationCatalogSnapshot(); const phaseData = useMediaAnimationCategory(activeTab); @@ -99,6 +116,9 @@ export const MediaAnimationSection: React.FC = ({ const handleDownload = useCallback( async (presetId: string, e: React.MouseEvent) => { e.stopPropagation(); + // Re-entry guard — prevents concurrent downloads for the same preset + // when the outer card button is clicked rapidly before state propagates. + if (downloadingIds.has(presetId)) return; const preset = getMediaAnimationPreset(presetId); const version = preset?.revisionId; if (!version) return; @@ -116,7 +136,7 @@ export const MediaAnimationSection: React.FC = ({ }); } }, - [], + [downloadingIds], ); const motion: ClipMotionConfig = selectedClip.motion ?? {}; @@ -126,12 +146,22 @@ export const MediaAnimationSection: React.FC = ({ const updateMotion = useCallback( (updates: Partial) => { const nextMotion: ClipMotionConfig = { ...motion, ...updates }; + + // Build a catalog array that merges downloaded manifest tracks into each + // preset. compileClipMotionKeyframes needs preset.tracks to be populated — + // catalogSnapshot.presets always has tracks: [] (summary listing). + // getMediaAnimationPreset() merges tracks from the OS cache synchronously. + 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, diff --git a/src/features/media-animations/PresetCard.tsx b/src/features/media-animations/PresetCard.tsx index 06bfbf8f7..db8c85bb4 100644 --- a/src/features/media-animations/PresetCard.tsx +++ b/src/features/media-animations/PresetCard.tsx @@ -97,6 +97,11 @@ const PresetCard: React.FC = ({ onClick={handleClick} onPointerEnter={handlePointerEnter} onPointerLeave={handlePointerLeave} + aria-label={ + isDownloaded + ? t("properties.mediaAnimation.selectAnimation") + : t("properties.mediaAnimation.downloadAnimation") + } className="flex flex-col items-center group cursor-pointer text-left relative" >
{ class MediaAnimationCacheManager { private cacheIndex: Map = new Map(); - private cacheDir: string | null = null; private initialized = false; /** Listeners notified after initialize() completes so subscribers can @@ -101,9 +94,6 @@ class MediaAnimationCacheManager { if (this.initialized) return; try { - const appCache = await appCacheDir(); - this.cacheDir = await join(appCache, CACHE_DIR); - const dirExists = await exists(CACHE_DIR, { baseDir: BaseDirectory.AppCache, }); diff --git a/src/features/media-animations/mediaAnimationCatalog.ts b/src/features/media-animations/mediaAnimationCatalog.ts index 5a123ab6d..d9f9d9ec2 100644 --- a/src/features/media-animations/mediaAnimationCatalog.ts +++ b/src/features/media-animations/mediaAnimationCatalog.ts @@ -95,13 +95,12 @@ function buildMergedSnapshot(): AnimationCatalogSnapshot { : "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(). + // combo presets have phase "in" and are compiled via the inPreset branch — + // keep them in the "in" slot. "loop" is unused for media animations. const phases: Record = { in: { ...categorySnapshots.in }, out: { ...categorySnapshots.out }, - loop: { ...categorySnapshots.combo }, + loop: { presets: [], status: "idle" }, }; return { presets, phases, status }; @@ -336,8 +335,11 @@ export function useMediaAnimationCategory( // 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). + // combo presets use phase "in" — compileClipMotionKeyframes gates on + // phase === "in" for the inPreset branch, so mapping combo → "loop" + // would silently skip compilation. Keep it on the "in" slot. return snapshot.phases[ - category === "combo" ? "loop" : category + category === "combo" ? "in" : category ] as MediaAnimationCategorySnapshot; } diff --git a/src/i18n/catalogs/de.json b/src/i18n/catalogs/de.json index 89277c97c..a65ad5548 100644 --- a/src/i18n/catalogs/de.json +++ b/src/i18n/catalogs/de.json @@ -269,6 +269,7 @@ "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", diff --git a/src/i18n/catalogs/en.json b/src/i18n/catalogs/en.json index 9199b6ee9..5ac09fe8e 100644 --- a/src/i18n/catalogs/en.json +++ b/src/i18n/catalogs/en.json @@ -269,6 +269,7 @@ "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", diff --git a/src/i18n/catalogs/es.json b/src/i18n/catalogs/es.json index e1a9ae162..32ad58f04 100644 --- a/src/i18n/catalogs/es.json +++ b/src/i18n/catalogs/es.json @@ -269,6 +269,7 @@ "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", diff --git a/src/i18n/catalogs/fr.json b/src/i18n/catalogs/fr.json index a87d71360..dc3b8dd97 100644 --- a/src/i18n/catalogs/fr.json +++ b/src/i18n/catalogs/fr.json @@ -269,6 +269,7 @@ "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", diff --git a/src/i18n/catalogs/ja.json b/src/i18n/catalogs/ja.json index 6fe4730e7..074486d1a 100644 --- a/src/i18n/catalogs/ja.json +++ b/src/i18n/catalogs/ja.json @@ -269,6 +269,7 @@ "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": "プリセットスタイルを展開", diff --git a/src/i18n/catalogs/ko.json b/src/i18n/catalogs/ko.json index f512878b4..c232d6366 100644 --- a/src/i18n/catalogs/ko.json +++ b/src/i18n/catalogs/ko.json @@ -269,6 +269,7 @@ "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": "프리셋 스타일 펼치기", diff --git a/src/i18n/catalogs/ru.json b/src/i18n/catalogs/ru.json index 5ffd359cd..ce9fc2192 100644 --- a/src/i18n/catalogs/ru.json +++ b/src/i18n/catalogs/ru.json @@ -273,6 +273,7 @@ "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": "Развернуть стили", diff --git a/src/i18n/catalogs/zh-CN.json b/src/i18n/catalogs/zh-CN.json index 02af74f67..cbba1a6d2 100644 --- a/src/i18n/catalogs/zh-CN.json +++ b/src/i18n/catalogs/zh-CN.json @@ -269,6 +269,7 @@ "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": "展开预设样式", diff --git a/src/i18n/catalogs/zh-TW.json b/src/i18n/catalogs/zh-TW.json index aae657194..1b066f3fa 100644 --- a/src/i18n/catalogs/zh-TW.json +++ b/src/i18n/catalogs/zh-TW.json @@ -269,6 +269,7 @@ "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": "展開預設樣式", From 85f09d6f600bb13542c6b65292877e278dcccde7 Mon Sep 17 00:00:00 2001 From: Abdulkabir Musa Date: Fri, 6 Nov 2026 02:05:39 +0100 Subject: [PATCH 4/7] fix(media-animations): always re-fetch per category on tab switch Remove the status === 'ready' short-circuit in fetchMediaAnimationCategory so switching tabs always triggers a fresh fetch for that category. The in-flight dedup (categoryRequests) still prevents concurrent duplicate calls. This fixes Out/Combo tabs showing stale In data caused by module-level state persisting across hot-reloads with the old non-filtered fetch format. --- src/features/media-animations/mediaAnimationCatalog.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/features/media-animations/mediaAnimationCatalog.ts b/src/features/media-animations/mediaAnimationCatalog.ts index d9f9d9ec2..d547d6b36 100644 --- a/src/features/media-animations/mediaAnimationCatalog.ts +++ b/src/features/media-animations/mediaAnimationCatalog.ts @@ -188,7 +188,12 @@ export async function fetchMediaAnimationCategory( const inFlight = categoryRequests[category]; if (inFlight) return inFlight; - if (!force && categorySnapshots[category].status === "ready") return; + // Always re-fetch if forced. For normal calls, skip only if already loading. + // We intentionally do NOT short-circuit on status === "ready" here so that + // switching tabs always triggers a fresh per-category fetch. Without this, + // stale module-level state (e.g. from hot-reload) keeps showing wrong data + // in tabs that weren't the first to fetch. + if (!force && categorySnapshots[category].status === "loading") return; const run = (async () => { const previous = categorySnapshots[category]; From 504a1e8232cb704f297444f2cb13a92a8057a909 Mon Sep 17 00:00:00 2001 From: Abdulkabir Musa Date: Fri, 6 Nov 2026 02:12:34 +0100 Subject: [PATCH 5/7] fix(media-animations): correct per-category tab isolation in catalog store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs fixed: 1. Revert over-aggressive re-fetch: restore status==='ready' short-circuit so switching tabs doesn't re-fetch already-loaded categories 2. Fix Combo/Out showing In data: combo was mapped to phases['in'] in the hook, returning In presets on the Combo tab. Now combo is stored in the 'loop' phases slot and read back via 'loop' — each of the three tabs (in/out/combo) reads a fully independent snapshot slot. compileClipMotionKeyframes is unaffected: it uses the flat merged presets array where combo presets retain phase='in' for the inPreset branch. --- .../media-animations/mediaAnimationCatalog.ts | 31 +++++++------------ 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/src/features/media-animations/mediaAnimationCatalog.ts b/src/features/media-animations/mediaAnimationCatalog.ts index d547d6b36..cee5dcb33 100644 --- a/src/features/media-animations/mediaAnimationCatalog.ts +++ b/src/features/media-animations/mediaAnimationCatalog.ts @@ -94,13 +94,14 @@ function buildMergedSnapshot(): AnimationCatalogSnapshot { ? "error" : "idle"; - // Reuse the PhaseSnapshot shape for compatibility with the compiler. - // combo presets have phase "in" and are compiled via the inPreset branch — - // keep them in the "in" slot. "loop" is unused for media animations. + // 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: { presets: [], status: "idle" }, + loop: { ...categorySnapshots.combo }, // combo stored here, read via "loop" key }; return { presets, phases, status }; @@ -188,12 +189,7 @@ export async function fetchMediaAnimationCategory( const inFlight = categoryRequests[category]; if (inFlight) return inFlight; - // Always re-fetch if forced. For normal calls, skip only if already loading. - // We intentionally do NOT short-circuit on status === "ready" here so that - // switching tabs always triggers a fresh per-category fetch. Without this, - // stale module-level state (e.g. from hot-reload) keeps showing wrong data - // in tabs that weren't the first to fetch. - if (!force && categorySnapshots[category].status === "loading") return; + if (!force && categorySnapshots[category].status === "ready") return; const run = (async () => { const previous = categorySnapshots[category]; @@ -337,15 +333,12 @@ 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). - // combo presets use phase "in" — compileClipMotionKeyframes gates on - // phase === "in" for the inPreset branch, so mapping combo → "loop" - // would silently skip compilation. Keep it on the "in" slot. - return snapshot.phases[ - category === "combo" ? "in" : category - ] as MediaAnimationCategorySnapshot; + // Map each MediaAnimationCategory to its correct phases slot: + // "in" → "in", "out" → "out", "combo" → "loop" (combo is stored there). + // Do NOT map combo → "in": that would return In presets on the Combo tab. + const phaseKey: MotionBehaviorCategory = + category === "in" ? "in" : category === "out" ? "out" : "loop"; + return snapshot.phases[phaseKey] as MediaAnimationCategorySnapshot; } /** From c98fd44d907b831a83c94a2868c652e5fa976a53 Mon Sep 17 00:00:00 2001 From: Abdulkabir Musa Date: Fri, 6 Nov 2026 02:17:03 +0100 Subject: [PATCH 6/7] fix(media-animations): isolate tab grids via keyed CategoryGrid component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract grid rendering into a CategoryGrid component keyed by activeTab. Switching tabs unmounts the old grid and mounts a fresh one — each instance owns its own useMediaAnimationCategory hook, fetch lifecycle, and render state. Eliminates all cross-tab snapshot bleed regardless of store internals. Also restores status==='ready' short-circuit in fetchMediaAnimationCategory so categories only fetch once per session rather than on every tab switch. --- .../MediaAnimationSection.tsx | 278 ++++++++++-------- 1 file changed, 158 insertions(+), 120 deletions(-) diff --git a/src/features/media-animations/MediaAnimationSection.tsx b/src/features/media-animations/MediaAnimationSection.tsx index 871335964..de393645b 100644 --- a/src/features/media-animations/MediaAnimationSection.tsx +++ b/src/features/media-animations/MediaAnimationSection.tsx @@ -7,7 +7,8 @@ * - 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 } from "react"; @@ -55,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 = ({ @@ -66,41 +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(), ); - // Explicit set of preset IDs whose manifests have been fully downloaded. - // Seeded synchronously from the OS cache index (populated during initialize) - // and updated as downloads complete this session. const [downloadedIds, setDownloadedIds] = useState>(() => { - // Seed any IDs already in the in-memory manifests map (hydrated from disk - // on app restart before this component mounts). const keys = Array.from(mediaAnimationCacheManager.manifests.keys()).map( (k) => k.split("@")[0], ); return new Set(keys); }); - // Subscribe to the one-shot hydration callback so that if initialize() - // completes after this component first renders, downloadedIds updates. React.useEffect(() => { return mediaAnimationCacheManager.onHydrated(() => { setDownloadedIds((prev) => { const keys = Array.from( mediaAnimationCacheManager.manifests.keys(), ).map((k) => k.split("@")[0]); - const next = new Set([...prev, ...keys]); - return next; + return new Set([...prev, ...keys]); }); }); }, []); const catalogSnapshot = useMediaAnimationCatalogSnapshot(); - const phaseData = useMediaAnimationCategory(activeTab); const toggleFavorite = useCallback((id: string, e: React.MouseEvent) => { e.stopPropagation(); @@ -116,8 +242,6 @@ export const MediaAnimationSection: React.FC = ({ const handleDownload = useCallback( async (presetId: string, e: React.MouseEvent) => { e.stopPropagation(); - // Re-entry guard — prevents concurrent downloads for the same preset - // when the outer card button is clicked rapidly before state propagates. if (downloadingIds.has(presetId)) return; const preset = getMediaAnimationPreset(presetId); const version = preset?.revisionId; @@ -146,16 +270,10 @@ export const MediaAnimationSection: React.FC = ({ const updateMotion = useCallback( (updates: Partial) => { const nextMotion: ClipMotionConfig = { ...motion, ...updates }; - - // Build a catalog array that merges downloaded manifest tracks into each - // preset. compileClipMotionKeyframes needs preset.tracks to be populated — - // catalogSnapshot.presets always has tracks: [] (summary listing). - // getMediaAnimationPreset() merges tracks from the OS cache synchronously. const mergedCatalog = catalogSnapshot.presets.map((p) => { const merged = getMediaAnimationPreset(p.id); return merged ?? p; }); - const compiledKeyframes = compileClipMotionKeyframes( selectedClip, nextMotion, @@ -178,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" @@ -216,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)), @@ -238,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; @@ -305,83 +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) => { - const mediaPreset = preset as MediaAnimationPreset; - const isDownloaded = - downloadedIds.has(preset.id) || - mediaAnimationCacheManager.isCached( - preset.id, - mediaPreset.revisionId ?? "", - ); - return ( - handleSelectPreset(preset.id)} - onToggleFavorite={(e) => toggleFavorite(preset.id, e)} - isDownloaded={isDownloaded} - isDownloading={downloadingIds.has(preset.id)} - onDownload={(e) => handleDownload(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" && ( From aff575ae82f52a8adf037cfa8d02a989479cba43 Mon Sep 17 00:00:00 2001 From: Abdulkabir Musa Date: Fri, 6 Nov 2026 02:36:50 +0100 Subject: [PATCH 7/7] fix(media-animations): read per-category snapshots from isolated refs map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of all tabs showing same data: useMediaAnimationCategory was reading from snapshot.phases which routes through the foreign AnimationCatalogSnapshot type — phases['in'] was being returned for combo, phases['out'] was referencing stale mutable objects, and React's reference equality check never detected changes. Fix: - Add categorySnapshotRefs — a separate Record keyed by MediaAnimationCategory that holds immutable copies of each category's snapshot - emit() rebuilds only the refs that actually changed (status/presets/error) so React sees new references exactly when data changes - useMediaAnimationCategory reads from categorySnapshotRefs[category] directly — no phases map, no type casting, no cross-category bleed possible --- .../media-animations/mediaAnimationCatalog.ts | 37 +++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/src/features/media-animations/mediaAnimationCatalog.ts b/src/features/media-animations/mediaAnimationCatalog.ts index cee5dcb33..3751da41a 100644 --- a/src/features/media-animations/mediaAnimationCatalog.ts +++ b/src/features/media-animations/mediaAnimationCatalog.ts @@ -47,6 +47,18 @@ const categorySnapshots: Record< combo: { ...IDLE }, }; +// 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>> = {}; @@ -110,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()); } @@ -325,7 +350,10 @@ export async function downloadManifest( 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, @@ -333,12 +361,7 @@ export function useMediaAnimationCategory( useEffect(() => { void fetchMediaAnimationCategory(category); }, [category]); - // Map each MediaAnimationCategory to its correct phases slot: - // "in" → "in", "out" → "out", "combo" → "loop" (combo is stored there). - // Do NOT map combo → "in": that would return In presets on the Combo tab. - const phaseKey: MotionBehaviorCategory = - category === "in" ? "in" : category === "out" ? "out" : "loop"; - return snapshot.phases[phaseKey] as MediaAnimationCategorySnapshot; + return categorySnapshotRefs[category]; } /**