diff --git a/CMakeLists.txt b/CMakeLists.txt index f558e08f..043c0d01 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -113,10 +113,12 @@ endif() - # Adding plugins add_subdirectory(plugins) +# Collect the list of matrix plugin targets published by register_plugin() +get_property(MATRIX_PLUGIN_TARGETS GLOBAL PROPERTY MATRIX_PLUGIN_TARGETS) + # Add an executable with the above sources add_executable(${PROJECT_NAME} ${SOURCES} @@ -252,6 +254,64 @@ if(NOT ENABLE_EMULATOR AND NOT ENABLE_DESKTOP) endif() endif() +# --------------------------------------------------------------------------- +# preview_gen: headless scene-preview GIF generator (emulator builds only) +# --------------------------------------------------------------------------- +if(ENABLE_EMULATOR) + # Tunable preview parameters - override on the command line as needed. + set(PREVIEW_FPS "15" CACHE STRING "Frames per second for scene preview GIFs") + set(PREVIEW_FRAMES "90" CACHE STRING "Total frames per scene preview GIF (90 @ 15fps = 6 s)") + set(PREVIEW_WIDTH "128" CACHE STRING "Preview GIF width (pixels)") + set(PREVIEW_HEIGHT "128" CACHE STRING "Preview GIF height (pixels)") + + set(_PREVIEW_LIB_PATH + "${CMAKE_BINARY_DIR}/shared/matrix:${CMAKE_BINARY_DIR}/shared/common:${CMAKE_BINARY_DIR}/vcpkg_installed/${VCPKG_TARGET_TRIPLET}/lib" + ) + + add_executable(preview_gen + ${CMAKE_CURRENT_SOURCE_DIR}/src_preview_gen/main.cpp + ) + + target_compile_features(preview_gen PRIVATE cxx_std_23) + target_compile_definitions(preview_gen PRIVATE ENABLE_EMULATOR) + + target_link_libraries(preview_gen PRIVATE + SharedToolsCommon + SharedToolsMatrix + spdlog::spdlog + nlohmann_json::nlohmann_json + unofficial::graphicsmagick::graphicsmagick + rpi_rgb_led_matrix::rpi-rgb-led-matrix + ${CMAKE_DL_LIBS} + ) + + # Install the binary itself so users can manually generate/regenerate previews + install(TARGETS preview_gen + DESTINATION . + ) + + # Install scene previews from the git-tracked scene_previews/ directory + # Previews are committed to git and deployed as-is, not auto-generated + # during the build. Use ./scripts/generate_scene_previews.sh to regenerate. + + # Install to web directory (for web interface access) + if(NOT ENABLE_DESKTOP) + install(DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/scene_previews/" + DESTINATION web/scene_previews + OPTIONAL + FILES_MATCHING PATTERN "*.gif" + ) + endif() + + # Install to root for server access (e.g., /scene_preview endpoint) + # This maintains backward compatibility with the server's /scene_preview endpoint + install(DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/scene_previews/" + DESTINATION scene_previews + OPTIONAL + FILES_MATCHING PATTERN "*.gif" + ) +endif() + #Cross Comp https://forum.grin.mw/t/building-grin-for-raspberry-pi4/7916 if(NOT SKIP_WEB_BUILD AND NOT ENABLE_DESKTOP) @@ -331,7 +391,6 @@ if(NOT ENABLE_DESKTOP) LIBRARY DESTINATION . # .so/.dylib (Unix) ) - # Install fonts to fonts directory install(FILES "${RPI_RGB_LED_MATRIX_FONTS_DIR}/7x13.bdf" diff --git a/README.md b/README.md index f474ab59..78b01677 100644 --- a/README.md +++ b/README.md @@ -322,6 +322,85 @@ Test your scenes without physical hardware using our SDL2-based emulator: Perfect for development, testing, and demonstrations! +#### **Scene Preview GIFs** + +The web interface shows animated GIF previews for each scene in the gallery. Previews are **committed to git** in the `scene_previews/` directory and deployed with the application. + +**Generate all scene previews:** +```bash +# Build the emulator first +cmake --preset emulator +cmake --build --preset emulator --target install + +# Generate previews (outputs to scene_previews/) +./scripts/generate_scene_previews.sh --all +``` + +**Generate specific scenes:** +```bash +./scripts/generate_scene_previews.sh --scenes "WaveScene,ColorPulseScene,FractalScene" +``` + +**Generate from a list file:** +```bash +# Create a file with scene names (one per line, # for comments) +cat > my_scenes.txt << EOF +WaveScene +ColorPulseScene +# FractalScene (commented out) +EOF + +./scripts/generate_scene_previews.sh --list my_scenes.txt +``` + +**Customize preview parameters:** +```bash +./scripts/generate_scene_previews.sh --all \ + --fps 20 \ + --frames 120 \ + --width 128 \ + --height 128 +``` + +**Commit previews to git:** +```bash +git add scene_previews/ +git commit -m "Update scene previews" +``` + +**Desktop-dependent scenes** (VideoScene, AudioSpectrumScene, ShadertoyScene, etc.) cannot be generated headlessly and must be captured manually: +```bash +# 1. Start the emulator (non-headless) and the desktop app +./scripts/run_emulator.sh & +./desktop_build/bin/led-matrix-desktop & + +# 2. Capture desktop-dependent scene previews +./scripts/capture_desktop_preview.sh --api-url http://localhost:8080 + +# Options: +# --scenes AudioSpectrumScene,ShadertoyScene # specific scenes only +# --duration 8 # capture 8 seconds per scene +# --output ./scene_previews # output directory +``` + +**Full deploy workflow:** +```bash +# 1. Generate/update previews (outputs to scene_previews/) +./scripts/generate_scene_previews.sh --all + +# 2. Commit previews to git +git add scene_previews/ +git commit -m "Update scene previews" + +# 3. Cross-compile and deploy +cmake --preset cross-compile +cmake --build +cmake --build --target install + +# Or use the build_upload.sh helper script +./scripts/build_upload.sh +``` + ### 🌐 **Web App Development** Run the development server in minutes: @@ -408,7 +487,7 @@ By default, the main index page will redirect you to the web controller (located |--------|----------|-------------| | `GET` | `/status` | System status and current state | | `GET` | `/get_curr` | Current scene information | -| `GET` | `/list_scenes` | Available scenes and plugins | +| `GET` | `/list_scenes` | Available scenes and plugins (includes `has_preview` and `needs_desktop` per scene) | | `GET` | `/toggle` | Toggle display on/off | | `GET` | `/skip` | Skip to next scene | @@ -428,6 +507,9 @@ By default, the main index page will redirect you to the web controller (located | `GET` | `/list` | Available local images | | `GET` | `/image?url=` | Fetch and display remote image | | `GET` | `/list_providers` | Available image providers | +| `GET` | `/scene_preview?name=` | Preview GIF for a scene (if available) | + +> **Scene Previews:** GIF files are pre-generated and committed to git in the `scene_previews/` directory. They are deployed to `/scene_previews/` and accessible via the `/scene_preview?name=` endpoint. To generate or update previews, use the `./scripts/generate_scene_previews.sh` script. After generating, commit the GIFs to git before deploying. The `/list_scenes` endpoint includes `has_preview` (bool) and `needs_desktop` (bool) fields per scene. ### ⚙️ **System Control** diff --git a/cmake/scripts/incremental_preview.cmake b/cmake/scripts/incremental_preview.cmake new file mode 100644 index 00000000..5ed01916 --- /dev/null +++ b/cmake/scripts/incremental_preview.cmake @@ -0,0 +1,239 @@ +# incremental_preview.cmake +# Invoked by the generate_scene_previews_incremental custom target. +# +# Expected input variables (set via -D on the cmake command line): +# PREVIEW_GEN - absolute path to the preview_gen binary +# PLUGIN_DIR - directory containing built plugin subdirectories +# LIBRARY_PATH - value for LD_LIBRARY_PATH +# OUTPUT_DIR - staging directory for generated GIFs +# MANIFEST_FILE - path to the persisted fingerprint manifest JSON +# FPS - frames per second for preview GIFs +# FRAMES - total frames per GIF +# WIDTH - matrix pixel width +# HEIGHT - matrix pixel height +# GENERATOR_VERSION - project version string (used to detect generator changes) +# SCENES_OVERRIDE - optional comma-separated list of scene names to force-regenerate + +cmake_minimum_required(VERSION 3.14) + +# --------------------------------------------------------------------------- +# Helper: stat a file returning mtime;size or "0;0" if missing +# --------------------------------------------------------------------------- +function(file_stat PATH OUT_MTIME OUT_SIZE) + if(EXISTS "${PATH}") + file(TIMESTAMP "${PATH}" _mtime "%s" UTC) + file(SIZE "${PATH}" _size) + set(${OUT_MTIME} "${_mtime}" PARENT_SCOPE) + set(${OUT_SIZE} "${_size}" PARENT_SCOPE) + else() + set(${OUT_MTIME} "0" PARENT_SCOPE) + set(${OUT_SIZE} "0" PARENT_SCOPE) + endif() +endfunction() + +# --------------------------------------------------------------------------- +# 1. Ensure output directory exists +# --------------------------------------------------------------------------- +file(MAKE_DIRECTORY "${OUTPUT_DIR}") + +# --------------------------------------------------------------------------- +# 2. Run preview_gen --dump-manifest to learn scene->plugin mapping +# --------------------------------------------------------------------------- +set(_scene_manifest_file "${OUTPUT_DIR}/scene_manifest.json") + +execute_process( + COMMAND ${CMAKE_COMMAND} -E env + "LD_LIBRARY_PATH=${LIBRARY_PATH}" + "PLUGIN_DIR=${PLUGIN_DIR}" + "${PREVIEW_GEN}" + --dump-manifest + --manifest-out "${_scene_manifest_file}" + RESULT_VARIABLE _dump_result + OUTPUT_QUIET + ERROR_QUIET +) + +if(NOT _dump_result EQUAL 0) + message(WARNING + "preview_gen --dump-manifest failed (exit code ${_dump_result}). " + "Falling back to full regeneration.") + execute_process( + COMMAND ${CMAKE_COMMAND} -E env + "LD_LIBRARY_PATH=${LIBRARY_PATH}" + "PLUGIN_DIR=${PLUGIN_DIR}" + "${PREVIEW_GEN}" + --output "${OUTPUT_DIR}" + --fps "${FPS}" + --frames "${FRAMES}" + --width "${WIDTH}" + --height "${HEIGHT}" + RESULT_VARIABLE _gen_result + ) + if(NOT _gen_result EQUAL 0) + message(WARNING "Full preview generation exited with code ${_gen_result}") + endif() + return() +endif() + +# --------------------------------------------------------------------------- +# 3. Read the stored fingerprint manifest (if any) +# --------------------------------------------------------------------------- +set(_stored_manifest "") +if(EXISTS "${MANIFEST_FILE}") + file(READ "${MANIFEST_FILE}" _stored_manifest) +endif() + +# string(JSON ...) requires CMake 3.19+. Fall back to full regen if older. +if(CMAKE_VERSION VERSION_LESS "3.19") + set(_stored_manifest "") + message(STATUS "CMake < 3.19: running full preview generation.") +endif() + +# --------------------------------------------------------------------------- +# 4. Parse scene_manifest.json to build list of scenes needing regeneration +# --------------------------------------------------------------------------- +file(READ "${_scene_manifest_file}" _scene_manifest_content) + +string(JSON _scene_count LENGTH "${_scene_manifest_content}") +if(_scene_count EQUAL 0) + message(STATUS "No scenes found in manifest; nothing to do.") + return() +endif() + +set(_scenes_to_regenerate "") +math(EXPR _last_idx "${_scene_count} - 1") + +foreach(_i RANGE 0 ${_last_idx}) + string(JSON _entry GET "${_scene_manifest_content}" ${_i}) + string(JSON _scene_name GET "${_entry}" "name") + string(JSON _plugin_path GET "${_entry}" "plugin_path") + + # Skip scenes that require the desktop app — they need manual capture + string(JSON _needs_desktop ERROR_VARIABLE _nd_err GET "${_entry}" "needs_desktop") + if(NOT _nd_err AND "${_needs_desktop}" STREQUAL "true") + continue() + endif() + + # Honour explicit override list + if(NOT "${SCENES_OVERRIDE}" STREQUAL "") + string(REPLACE "," ";" _override_list "${SCENES_OVERRIDE}") + if("${_scene_name}" IN_LIST _override_list) + list(APPEND _scenes_to_regenerate "${_scene_name}") + endif() + continue() + endif() + + set(_gif_path "${OUTPUT_DIR}/${_scene_name}.gif") + file_stat("${_plugin_path}" _cur_mtime _cur_size) + + set(_needs_regen TRUE) + if(NOT "${_stored_manifest}" STREQUAL "" AND EXISTS "${_gif_path}") + string(JSON _stored_scene_obj ERROR_VARIABLE _json_err + GET "${_stored_manifest}" "scenes" "${_scene_name}") + + if(NOT _json_err) + string(JSON _s_mtime ERROR_VARIABLE _e GET "${_stored_scene_obj}" "plugin_mtime") + string(JSON _s_size ERROR_VARIABLE _e GET "${_stored_scene_obj}" "plugin_size") + string(JSON _s_fps ERROR_VARIABLE _e GET "${_stored_scene_obj}" "fps") + string(JSON _s_frames ERROR_VARIABLE _e GET "${_stored_scene_obj}" "frames") + string(JSON _s_w ERROR_VARIABLE _e GET "${_stored_scene_obj}" "width") + string(JSON _s_h ERROR_VARIABLE _e GET "${_stored_scene_obj}" "height") + string(JSON _s_ver ERROR_VARIABLE _e GET "${_stored_scene_obj}" "generator_version") + + if("${_s_mtime}" STREQUAL "${_cur_mtime}" AND + "${_s_size}" STREQUAL "${_cur_size}" AND + "${_s_fps}" STREQUAL "${FPS}" AND + "${_s_frames}" STREQUAL "${FRAMES}" AND + "${_s_w}" STREQUAL "${WIDTH}" AND + "${_s_h}" STREQUAL "${HEIGHT}" AND + "${_s_ver}" STREQUAL "${GENERATOR_VERSION}") + set(_needs_regen FALSE) + endif() + endif() + endif() + + if(_needs_regen) + list(APPEND _scenes_to_regenerate "${_scene_name}") + endif() +endforeach() + +# --------------------------------------------------------------------------- +# 5. Generate only the scenes that need regeneration +# --------------------------------------------------------------------------- +list(LENGTH _scenes_to_regenerate _regen_count) +if(_regen_count EQUAL 0) + message(STATUS "All scene previews are up-to-date; nothing to regenerate.") + return() +endif() + +message(STATUS "Regenerating ${_regen_count} scene preview(s): ${_scenes_to_regenerate}") + +string(REPLACE ";" "," _scenes_csv "${_scenes_to_regenerate}") + +execute_process( + COMMAND ${CMAKE_COMMAND} -E env + "LD_LIBRARY_PATH=${LIBRARY_PATH}" + "PLUGIN_DIR=${PLUGIN_DIR}" + "${PREVIEW_GEN}" + --output "${OUTPUT_DIR}" + --fps "${FPS}" + --frames "${FRAMES}" + --width "${WIDTH}" + --height "${HEIGHT}" + --scenes "${_scenes_csv}" + RESULT_VARIABLE _gen_result +) + +if(NOT _gen_result EQUAL 0) + message(WARNING + "Incremental preview generation exited with code ${_gen_result}; " + "some previews may be stale but existing previews remain usable.") +endif() + +# --------------------------------------------------------------------------- +# 6. Update fingerprint manifest for successfully generated scenes +# --------------------------------------------------------------------------- +set(_new_manifest "{}") +if(EXISTS "${MANIFEST_FILE}") + file(READ "${MANIFEST_FILE}" _new_manifest) + string(JSON _scenes_type ERROR_VARIABLE _je TYPE "${_new_manifest}" "scenes") + if(_je OR "${_scenes_type}" STREQUAL "NULL") + string(JSON _new_manifest SET "${_new_manifest}" "scenes" "{}") + endif() +else() + string(JSON _new_manifest SET "{}" "scenes" "{}") +endif() + +string(JSON _new_manifest SET "${_new_manifest}" "generator_version" "\"${GENERATOR_VERSION}\"") + +foreach(_i RANGE 0 ${_last_idx}) + string(JSON _entry GET "${_scene_manifest_content}" ${_i}) + string(JSON _scene_name GET "${_entry}" "name") + string(JSON _plugin_path GET "${_entry}" "plugin_path") + + if(NOT "${_scene_name}" IN_LIST _scenes_to_regenerate) + continue() + endif() + + set(_gif_path "${OUTPUT_DIR}/${_scene_name}.gif") + if(NOT EXISTS "${_gif_path}") + continue() # Generation failed; don't update fingerprint + endif() + + file_stat("${_plugin_path}" _cur_mtime _cur_size) + + set(_scene_obj "{}") + string(JSON _scene_obj SET "${_scene_obj}" "plugin_path" "\"${_plugin_path}\"") + string(JSON _scene_obj SET "${_scene_obj}" "plugin_mtime" "\"${_cur_mtime}\"") + string(JSON _scene_obj SET "${_scene_obj}" "plugin_size" "\"${_cur_size}\"") + string(JSON _scene_obj SET "${_scene_obj}" "fps" "\"${FPS}\"") + string(JSON _scene_obj SET "${_scene_obj}" "frames" "\"${FRAMES}\"") + string(JSON _scene_obj SET "${_scene_obj}" "width" "\"${WIDTH}\"") + string(JSON _scene_obj SET "${_scene_obj}" "height" "\"${HEIGHT}\"") + string(JSON _scene_obj SET "${_scene_obj}" "generator_version" "\"${GENERATOR_VERSION}\"") + + string(JSON _new_manifest SET "${_new_manifest}" "scenes" "${_scene_name}" "${_scene_obj}") +endforeach() + +file(WRITE "${MANIFEST_FILE}" "${_new_manifest}") +message(STATUS "Fingerprint manifest updated: ${MANIFEST_FILE}") diff --git a/plugins/AudioVisualizer/matrix/AudioVisualizer.cpp b/plugins/AudioVisualizer/matrix/AudioVisualizer.cpp index ca0eb092..d70126b8 100644 --- a/plugins/AudioVisualizer/matrix/AudioVisualizer.cpp +++ b/plugins/AudioVisualizer/matrix/AudioVisualizer.cpp @@ -45,13 +45,13 @@ AudioVisualizer::AudioVisualizer() : last_timestamp(0), interpolated_log(false) std::optional AudioVisualizer::before_server_init() { - spdlog::info("Starting UDP server for audio visualization"); + spdlog::debug("Starting UDP server for audio visualization"); return std::nullopt; } std::optional AudioVisualizer::pre_exit() { - spdlog::info("Stopping UDP server for audio visualization"); + spdlog::debug("Stopping UDP server for audio visualization"); return std::nullopt; } diff --git a/plugins/BasicEffects/matrix/BasicEffects.cpp b/plugins/BasicEffects/matrix/BasicEffects.cpp index 611ea17e..c8c7448b 100644 --- a/plugins/BasicEffects/matrix/BasicEffects.cpp +++ b/plugins/BasicEffects/matrix/BasicEffects.cpp @@ -21,7 +21,6 @@ extern "C" PLUGIN_EXPORT void destroyBasicEffects(BasicEffects *c) BasicEffects::BasicEffects() { - spdlog::info("BasicEffects plugin initialized"); } vector> BasicEffects::create_image_providers() diff --git a/plugins/CMakeLists.txt b/plugins/CMakeLists.txt index 7f9f9efa..58062041 100644 --- a/plugins/CMakeLists.txt +++ b/plugins/CMakeLists.txt @@ -69,6 +69,12 @@ function(register_plugin PLUGIN_NAME) target_link_libraries(${PLUGIN_NAME} PRIVATE rpi_rgb_led_matrix::rpi-rgb-led-matrix) endif() + # Publish to a global property so the root CMakeLists can wire up + # preview_gen dependencies on actual plugin targets rather than opaque names. + if(NOT ENABLE_DESKTOP) + set_property(GLOBAL APPEND PROPERTY MATRIX_PLUGIN_TARGETS ${PLUGIN_NAME}) + endif() + if(${PLUGIN_NAME} STREQUAL "ExampleScenes") return() endif() diff --git a/plugins/SpotifyScenes/matrix/SpotifyScenes.cpp b/plugins/SpotifyScenes/matrix/SpotifyScenes.cpp index b084a753..221e300f 100644 --- a/plugins/SpotifyScenes/matrix/SpotifyScenes.cpp +++ b/plugins/SpotifyScenes/matrix/SpotifyScenes.cpp @@ -40,7 +40,7 @@ std::optional SpotifyScenes::after_server_init() { if(is_disabled) return std::nullopt; - spdlog::info("Initializing SpotifyScenes"); + spdlog::debug("Initializing SpotifyScenes"); spotify = new Spotify(); spotify->initialize(); diff --git a/react-web/src/App.tsx b/react-web/src/App.tsx index e327b20c..ec79a930 100644 --- a/react-web/src/App.tsx +++ b/react-web/src/App.tsx @@ -8,6 +8,7 @@ import Updates from './pages/Updates' import ModifyPreset from './pages/ModifyPreset' import ModifyProviders from './pages/ModifyProviders' import ModifyShaderProviders from './pages/ModifyShaderProviders' +import SceneGallery from './pages/SceneGallery' export default function App() { return ( @@ -16,6 +17,7 @@ export default function App() { } /> + } /> } /> } /> } /> diff --git a/react-web/src/apiTypes/list_scenes.ts b/react-web/src/apiTypes/list_scenes.ts index 89131c37..94158b03 100644 --- a/react-web/src/apiTypes/list_scenes.ts +++ b/react-web/src/apiTypes/list_scenes.ts @@ -1,4 +1,4 @@ -export interface ListScenes { name: string; properties: Property[]; } +export interface ListScenes { name: string; properties: Property[]; has_preview?: boolean; } export interface Property { default_value: T; diff --git a/react-web/src/components/Layout.tsx b/react-web/src/components/Layout.tsx index dc462312..57f4a3f8 100644 --- a/react-web/src/components/Layout.tsx +++ b/react-web/src/components/Layout.tsx @@ -1,5 +1,5 @@ import { Link, useLocation } from 'react-router-dom' -import { Grid3x3, Calendar, Download, Moon, Sun } from 'lucide-react' +import { Grid3x3, Calendar, Download, Moon, Sun, Images } from 'lucide-react' import { cn } from '~/lib/utils' import { useState, useEffect } from 'react' @@ -11,6 +11,7 @@ interface NavItem { const navItems: NavItem[] = [ { to: '/', label: 'Home', icon: }, + { to: '/gallery', label: 'Gallery', icon: }, { to: '/schedules', label: 'Schedules', icon: }, { to: '/updates', label: 'Updates', icon: }, ] diff --git a/react-web/src/components/modify-preset/AddScene.tsx b/react-web/src/components/modify-preset/AddScene.tsx index 3df90997..c918718e 100644 --- a/react-web/src/components/modify-preset/AddScene.tsx +++ b/react-web/src/components/modify-preset/AddScene.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { Plus } from 'lucide-react' +import { Plus, ImageOff } from 'lucide-react' import { Button } from '~/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription @@ -8,6 +8,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '~ import type { ListScenes } from '~/apiTypes/list_scenes' import type { Scene } from '~/apiTypes/list_presets' import { v4 as uuidv4 } from 'uuid' +import { useApiUrl } from '~/components/apiUrl/ApiUrlProvider' interface AddSceneProps { sceneDefinitions: ListScenes[] @@ -17,6 +18,10 @@ interface AddSceneProps { export default function AddScene({ sceneDefinitions, onAdd }: AddSceneProps) { const [open, setOpen] = useState(false) const [selected, setSelected] = useState('') + const [imgError, setImgError] = useState(false) + const apiUrl = useApiUrl() + + const selectedDef = sceneDefinitions.find(s => s.name === selected) const handleAdd = () => { const def = sceneDefinitions.find(s => s.name === selected) @@ -35,6 +40,11 @@ export default function AddScene({ sceneDefinitions, onAdd }: AddSceneProps) { setOpen(false) } + const handleSelectChange = (value: string) => { + setSelected(value) + setImgError(false) + } + return ( <> diff --git a/react-web/src/components/modify-preset/properties/ColorProperty.tsx b/react-web/src/components/modify-preset/properties/ColorProperty.tsx index b655ef3c..e6630bf2 100644 --- a/react-web/src/components/modify-preset/properties/ColorProperty.tsx +++ b/react-web/src/components/modify-preset/properties/ColorProperty.tsx @@ -6,12 +6,12 @@ import { titleCase } from '~/lib/utils' import type { Property } from '~/apiTypes/list_scenes' interface ColorPropertyProps { - property: Property - value: string - onChange: (value: string) => void + property: Property + value: number | string + onChange: (value: number) => void } -function normalizeHex(color: any): string { +function normalizeHex(color: unknown): string { if (typeof color === 'string') { if (color.startsWith('#')) return color return '#' + color @@ -22,8 +22,15 @@ function normalizeHex(color: any): string { return '#000000' } -function toHex(color: string): string { - return color.startsWith('#') ? color : '#' + color +function hexToNumber(color: string): number { + const normalized = color.startsWith('#') ? color.slice(1) : color + const parsed = Number.parseInt(normalized, 16) + + if (Number.isNaN(parsed)) { + return 0 + } + + return Math.max(0, Math.min(0xffffff, parsed)) } export default function ColorProperty({ property, value, onChange }: ColorPropertyProps) { @@ -43,7 +50,7 @@ export default function ColorProperty({ property, value, onChange }: ColorProper /> onChange(e.target.value)} + onChange={(e) => onChange(hexToNumber(e.target.value))} placeholder="#000000" className="font-mono" maxLength={7} @@ -53,7 +60,7 @@ export default function ColorProperty({ property, value, onChange }: ColorProper
onChange(c)} + onChange={(c) => onChange(hexToNumber(c))} />
+ ) +} + +function AddToPresetDialog({ + scene, + presets, + onAdd, + open, + onOpenChange, +}: { + scene: ListScenes | null + presets: ListPresets | null + onAdd: (presetId: string, scene: Scene) => void + open: boolean + onOpenChange: (open: boolean) => void +}) { + const [selectedPreset, setSelectedPreset] = useState('') + + const handleAdd = () => { + if (!scene || !selectedPreset) return + const args: Record = {} + for (const prop of scene.properties) { + args[prop.name] = prop.default_value + } + const newScene: Scene = { + uuid: uuidv4(), + type: scene.name, + arguments: args, + } + onAdd(selectedPreset, newScene) + setSelectedPreset('') + onOpenChange(false) + } + + return ( + + + + Add "{scene?.name}" to Preset + Choose which preset to add this scene to. + + + + + + + + + ) +} + +export default function SceneGallery() { + const apiUrl = useApiUrl() + const navigate = useNavigate() + const [filter, setFilter] = useState('') + const [selectedScene, setSelectedScene] = useState(null) + const [dialogOpen, setDialogOpen] = useState(false) + const [showPreviewOnly, setShowPreviewOnly] = useState(false) + + const { data: scenes, isLoading } = useFetch('/list_scenes') + const { data: presets } = useFetch('/list_presets') + + const filtered = (scenes ?? []).filter(s => { + const matchesName = s.name.toLowerCase().includes(filter.toLowerCase()) + const matchesPreview = !showPreviewOnly || s.has_preview + return matchesName && matchesPreview + }) + + const handleAddToPreset = async (presetId: string, scene: Scene) => { + if (!apiUrl) return + try { + const res = await fetch(`${apiUrl}/presets?id=${encodeURIComponent(presetId)}`) + const rawPreset: RawPreset = await res.json() + const updated: RawPreset = { + ...rawPreset, + scenes: [...(rawPreset.scenes ?? []), scene], + } + await fetch(`${apiUrl}/preset?id=${encodeURIComponent(presetId)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updated), + }) + navigate(`/modify-preset/${encodeURIComponent(presetId)}`) + } catch { + // ignore, user can navigate manually + } + } + + const previewCount = (scenes ?? []).filter(s => s.has_preview).length + + return ( +
+
+

Scene Gallery

+

+ Browse all available scenes + {scenes ? ` — ${scenes.length} total, ${previewCount} with previews` : ''} +

+
+ + {/* Filters */} +
+ setFilter(e.target.value)} + className="flex h-9 rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring flex-1 min-w-0" + /> + +
+ + {isLoading ? ( +
+ {Array.from({ length: 10 }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+ ) : filtered.length === 0 ? ( +
+ {filter || showPreviewOnly ? 'No scenes match your filter.' : 'No scenes available.'} +
+ ) : ( +
+ {filtered.map(scene => ( +
{ setSelectedScene(scene); setDialogOpen(true) }} + > + +
+
+ +
+
+
+ ))} +
+ )} + + +
+ ) +} diff --git a/scene_previews/README.md b/scene_previews/README.md new file mode 100644 index 00000000..92d0f717 --- /dev/null +++ b/scene_previews/README.md @@ -0,0 +1,37 @@ +# Scene Previews Directory + +This directory contains pre-generated animated GIF previews for LED matrix scenes. +These are used by the web interface to display scene thumbnails in the gallery. + +## Generating Previews + +To generate or update scene previews: + +```bash +# Generate all scenes +./scripts/generate_scene_previews.sh --all + +# Generate specific scenes +./scripts/generate_scene_previews.sh --scenes "WaveScene,ColorPulseScene,FractalScene" + +# Generate from a list file +./scripts/generate_scene_previews.sh --list my_scenes.txt +``` + +## Workflow + +1. Generate previews using the script above +2. Review the generated GIFs to ensure they look correct +3. Commit the previews to git: + ```bash + git add scene_previews/ + git commit -m "Update scene previews" + ``` +4. Deploy with: `cmake --build emulator_build --target install` + +## Notes + +- Previews are pre-generated and committed to git for consistency across deployments +- Desktop-dependent scenes (e.g., AudioSpectrumScene) cannot be generated here + - Use `./scripts/capture_desktop_preview.sh` for those scenes +- Preview parameters (FPS, frames, resolution) can be customized via script options diff --git a/scene_previews/boids.gif b/scene_previews/boids.gif new file mode 100644 index 00000000..a752d641 Binary files /dev/null and b/scene_previews/boids.gif differ diff --git a/scene_previews/bouncing-logo.gif b/scene_previews/bouncing-logo.gif new file mode 100644 index 00000000..5e0be729 Binary files /dev/null and b/scene_previews/bouncing-logo.gif differ diff --git a/scene_previews/clock.gif b/scene_previews/clock.gif new file mode 100644 index 00000000..07b982ab Binary files /dev/null and b/scene_previews/clock.gif differ diff --git a/scene_previews/countdown.gif b/scene_previews/countdown.gif new file mode 100644 index 00000000..305ab24a Binary files /dev/null and b/scene_previews/countdown.gif differ diff --git a/scene_previews/fallingsand.gif b/scene_previews/fallingsand.gif new file mode 100644 index 00000000..fa8c615d Binary files /dev/null and b/scene_previews/fallingsand.gif differ diff --git a/scene_previews/game_of_life.gif b/scene_previews/game_of_life.gif new file mode 100644 index 00000000..5680b7bb Binary files /dev/null and b/scene_previews/game_of_life.gif differ diff --git a/scene_previews/image_scene.gif b/scene_previews/image_scene.gif new file mode 100644 index 00000000..f80ad3a7 Binary files /dev/null and b/scene_previews/image_scene.gif differ diff --git a/scene_previews/julia_set.gif b/scene_previews/julia_set.gif new file mode 100644 index 00000000..486535df Binary files /dev/null and b/scene_previews/julia_set.gif differ diff --git a/scene_previews/maze.gif b/scene_previews/maze.gif new file mode 100644 index 00000000..42d99fcd Binary files /dev/null and b/scene_previews/maze.gif differ diff --git a/scene_previews/metablob.gif b/scene_previews/metablob.gif new file mode 100644 index 00000000..9fb96a02 Binary files /dev/null and b/scene_previews/metablob.gif differ diff --git a/scene_previews/neontunnel.gif b/scene_previews/neontunnel.gif new file mode 100644 index 00000000..e691df6c Binary files /dev/null and b/scene_previews/neontunnel.gif differ diff --git a/scene_previews/ping_pong.gif b/scene_previews/ping_pong.gif new file mode 100644 index 00000000..fbc60dfc Binary files /dev/null and b/scene_previews/ping_pong.gif differ diff --git a/scene_previews/rain.gif b/scene_previews/rain.gif new file mode 100644 index 00000000..4b091ce4 Binary files /dev/null and b/scene_previews/rain.gif differ diff --git a/scene_previews/reaction_diffusion.gif b/scene_previews/reaction_diffusion.gif new file mode 100644 index 00000000..72dc5cb4 Binary files /dev/null and b/scene_previews/reaction_diffusion.gif differ diff --git a/scene_previews/snake_game.gif b/scene_previews/snake_game.gif new file mode 100644 index 00000000..6db11556 Binary files /dev/null and b/scene_previews/snake_game.gif differ diff --git a/scene_previews/sorting-visualizer.gif b/scene_previews/sorting-visualizer.gif new file mode 100644 index 00000000..f207a533 Binary files /dev/null and b/scene_previews/sorting-visualizer.gif differ diff --git a/scene_previews/sparks.gif b/scene_previews/sparks.gif new file mode 100644 index 00000000..896c64b9 Binary files /dev/null and b/scene_previews/sparks.gif differ diff --git a/scene_previews/starfield.gif b/scene_previews/starfield.gif new file mode 100644 index 00000000..945131ab Binary files /dev/null and b/scene_previews/starfield.gif differ diff --git a/scene_previews/tetris.gif b/scene_previews/tetris.gif new file mode 100644 index 00000000..72cd7bbd Binary files /dev/null and b/scene_previews/tetris.gif differ diff --git a/scene_previews/watermelon_plasma.gif b/scene_previews/watermelon_plasma.gif new file mode 100644 index 00000000..39d317cf Binary files /dev/null and b/scene_previews/watermelon_plasma.gif differ diff --git a/scene_previews/wave.gif b/scene_previews/wave.gif new file mode 100644 index 00000000..e27fb98d Binary files /dev/null and b/scene_previews/wave.gif differ diff --git a/scene_previews/wave_pattern.gif b/scene_previews/wave_pattern.gif new file mode 100644 index 00000000..4ea3b28d Binary files /dev/null and b/scene_previews/wave_pattern.gif differ diff --git a/scene_previews/weather.gif b/scene_previews/weather.gif new file mode 100644 index 00000000..f195dc50 Binary files /dev/null and b/scene_previews/weather.gif differ diff --git a/scripts/build_upload.sh b/scripts/build_upload.sh index 1454e33e..920a9eec 100755 --- a/scripts/build_upload.sh +++ b/scripts/build_upload.sh @@ -11,8 +11,14 @@ else fi : "${SSH_HOST:=ledmat}" +# Set SKIP_PREVIEWS=1 to skip preview sync (useful when emulator is not built) +: "${SKIP_PREVIEWS:=0}" # This script is just used to update the rpi +# Expected workflow: +# 1. Build emulator previews: +# cmake --build emulator_build --target generate_scene_previews_incremental +# 2. Run this script to cross-compile and deploy (includes preview sync) COMPILER_DIR=$(ls -td $CROSS_COMPILE_ROOT/tools/*/ | head -1) echo "Using compilers in directory $COMPILER_DIR" @@ -20,6 +26,17 @@ echo "Using compilers in directory $COMPILER_DIR" CROSS_COMPILER_DIR="$COMPILER_DIR/bin" cmake --preset cross-compile cmake --build build --target install -j $(nproc) +# Sync host-generated previews into the cross-compile install tree before rsync. +# Previews are generated on the host using the emulator build and then packaged +# into the RPi deploy directory. Pass SKIP_PREVIEWS=1 to opt out. +if [ "$SKIP_PREVIEWS" != "1" ] && [ -d "emulator_build/previews" ]; then + echo "Syncing scene preview GIFs from emulator_build/previews/ ..." + mkdir -p build/install/previews + rsync -a --include="*.gif" --exclude="*" emulator_build/previews/ build/install/previews/ +else + echo "Skipping preview sync (SKIP_PREVIEWS=$SKIP_PREVIEWS or emulator_build/previews not found)" +fi + rsync -avz --delete $SCRIPT_DIR/../build/install/ $SSH_HOST:/home/pi/ledmat/run/ ssh $SSH_HOST sudo service ledmat restart diff --git a/scripts/capture_desktop_preview.sh b/scripts/capture_desktop_preview.sh new file mode 100755 index 00000000..8e082724 --- /dev/null +++ b/scripts/capture_desktop_preview.sh @@ -0,0 +1,251 @@ +#!/usr/bin/env bash +# capture_desktop_preview.sh +# +# Records animated GIF previews for scenes that require the desktop application +# (i.e. scenes where needs_desktop_app() == true, such as VideoScene, +# AudioSpectrumScene, ShadertoyScene). +# +# Prerequisites: +# - A running matrix emulator: ./main (from the emulator build) +# - A running desktop app connected to the emulator: ./led-matrix-desktop +# - ffmpeg with x11grab support +# - xdotool (used to locate the emulator window by title) +# +# Usage: +# ./scripts/capture_desktop_preview.sh [OPTIONS] +# +# Options: +# --api-url Matrix API base URL (default: http://localhost:8080) +# --output Output directory for GIF files (default: ./scene_previews) +# --scenes Comma-separated list of scene names to capture. +# If omitted, all desktop-dependent scenes are captured. +# --duration Capture duration per scene in seconds (default: 6) +# --fps Output GIF frame rate (default: 15) +# --window-title Emulator window title to search for +# (default: "RGB Matrix Emulator") +# --no-cleanup Keep the temporary preset after capture +# --help Show this help +# +# Workflow: +# 1. Generate auto-render previews (scenes without desktop requirement): +# ./scripts/generate_scene_previews.sh --all +# 2. Start the emulator and desktop app, then run this script for desktop scenes: +# ./scripts/capture_desktop_preview.sh --api-url http://localhost:8080 +# 3. Commit previews and deploy with build_upload.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +# --------------------------------------------------------------------------- +# Defaults +# --------------------------------------------------------------------------- +API_URL="http://localhost:8080" +OUTPUT_DIR="$REPO_DIR/scene_previews" +SCENES_OVERRIDE="" +DURATION=6 +FPS=15 +WINDOW_TITLE="RGB Matrix Emulator" +NO_CLEANUP=0 +TEMP_PRESET_ID="__preview_capture_tmp__" + +# --------------------------------------------------------------------------- +# Argument parsing +# --------------------------------------------------------------------------- +while [[ $# -gt 0 ]]; do + case "$1" in + --api-url) API_URL="$2"; shift 2 ;; + --output) OUTPUT_DIR="$2"; shift 2 ;; + --scenes) SCENES_OVERRIDE="$2"; shift 2 ;; + --duration) DURATION="$2"; shift 2 ;; + --fps) FPS="$2"; shift 2 ;; + --window-title) WINDOW_TITLE="$2"; shift 2 ;; + --no-cleanup) NO_CLEANUP=1; shift ;; + --help|-h) + sed -n '/^# /,/^[^#]/p' "$0" | grep '^#' | sed 's/^# \?//' + exit 0 ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; + esac +done + +# --------------------------------------------------------------------------- +# Dependency checks +# --------------------------------------------------------------------------- +for cmd in curl ffmpeg xdotool; do + if ! command -v "$cmd" &>/dev/null; then + echo "ERROR: '$cmd' not found. Please install it." >&2 + exit 1 + fi +done + +mkdir -p "$OUTPUT_DIR" + +# --------------------------------------------------------------------------- +# Helper: call the matrix REST API +# --------------------------------------------------------------------------- +api_get() { + curl -sf "$API_URL$1" +} + +api_post() { + local path="$1"; shift + curl -sf -X POST -H "Content-Type: application/json" -d "$1" "$API_URL$path" +} + +api_delete() { + curl -sf -X DELETE "$API_URL$1" +} + +# --------------------------------------------------------------------------- +# Wait for the matrix API to be reachable +# --------------------------------------------------------------------------- +echo "Waiting for matrix API at $API_URL ..." +for i in $(seq 1 20); do + if api_get "/list_scenes" &>/dev/null; then + echo "API is up." + break + fi + if [[ $i -eq 20 ]]; then + echo "ERROR: Matrix API not reachable at $API_URL after 20 attempts." >&2 + exit 1 + fi + sleep 1 +done + +# --------------------------------------------------------------------------- +# Get the list of desktop-dependent scenes from the API +# --------------------------------------------------------------------------- +SCENES_JSON=$(api_get "/list_scenes") + +if [[ -n "$SCENES_OVERRIDE" ]]; then + IFS=',' read -ra TARGET_SCENES <<< "$SCENES_OVERRIDE" +else + mapfile -t TARGET_SCENES < <( + echo "$SCENES_JSON" \ + | python3 -c " +import json, sys +scenes = json.load(sys.stdin) +for s in scenes: + if s.get('needs_desktop', False): + print(s['name']) +" + ) +fi + +if [[ ${#TARGET_SCENES[@]} -eq 0 ]]; then + echo "No desktop-dependent scenes found. Nothing to capture." + exit 0 +fi + +echo "Will capture: ${TARGET_SCENES[*]}" + +# --------------------------------------------------------------------------- +# Save the currently active preset so we can restore it afterwards +# --------------------------------------------------------------------------- +ORIGINAL_PRESET=$(api_get "/presets?include_active=true" 2>/dev/null | \ + python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('active',''))" 2>/dev/null || true) + +cleanup_preset() { + if [[ "$NO_CLEANUP" -eq 0 ]]; then + api_delete "/preset?id=$TEMP_PRESET_ID" &>/dev/null || true + fi + # Restore original preset if we captured it + if [[ -n "$ORIGINAL_PRESET" ]]; then + api_get "/set_active?id=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$ORIGINAL_PRESET'))")" &>/dev/null || true + fi +} +trap cleanup_preset EXIT + +# --------------------------------------------------------------------------- +# Find the emulator window +# --------------------------------------------------------------------------- +find_emulator_window() { + xdotool search --name "$WINDOW_TITLE" 2>/dev/null | head -1 +} + +WINDOW_ID=$(find_emulator_window) +if [[ -z "$WINDOW_ID" ]]; then + echo "ERROR: Could not find emulator window titled '$WINDOW_TITLE'." >&2 + echo " Make sure the emulator is running with the default window title." >&2 + exit 1 +fi +echo "Found emulator window: $WINDOW_ID" + +# Get window geometry (x, y, width, height) +GEOM=$(xdotool getwindowgeometry "$WINDOW_ID" 2>/dev/null) +WIN_X=$(echo "$GEOM" | grep -oP 'Position: \K[0-9]+') +WIN_Y=$(echo "$GEOM" | grep -oP 'Position: [0-9]+,\K[0-9]+') +WIN_W=$(echo "$GEOM" | grep -oP 'Geometry: \K[0-9]+') +WIN_H=$(echo "$GEOM" | grep -oP 'Geometry: [0-9]+x\K[0-9]+') + +echo "Emulator window geometry: ${WIN_W}x${WIN_H} at ${WIN_X},${WIN_Y}" + +DISPLAY="${DISPLAY:-:0}" + +# --------------------------------------------------------------------------- +# Capture loop +# --------------------------------------------------------------------------- +FRAME_DELAY=$(python3 -c "print(round(100/$FPS))") # centiseconds for GIF delay + +for SCENE_NAME in "${TARGET_SCENES[@]}"; do + echo "" + echo "=== Capturing: $SCENE_NAME ===" + + # Build a minimal preset JSON with just this scene + PRESET_JSON=$(python3 -c " +import json +preset = { + 'scenes': [{'name': '$SCENE_NAME', 'properties': {}}], + 'transition_duration': 0, + 'transition_name': 'blend' +} +print(json.dumps(preset)) +") + + # Create and activate the temporary preset + if ! api_post "/preset?id=$TEMP_PRESET_ID" "$PRESET_JSON" &>/dev/null; then + echo "WARNING: Could not create preset for '$SCENE_NAME'. Skipping." >&2 + continue + fi + if ! api_get "/set_active?id=$TEMP_PRESET_ID" &>/dev/null; then + echo "WARNING: Could not activate preset for '$SCENE_NAME'. Skipping." >&2 + continue + fi + + echo "Preset activated. Waiting 2 s for scene to load..." + sleep 2 + + # Record the emulator window + RAW_VIDEO="/tmp/capture_${SCENE_NAME}.mp4" + echo "Recording ${DURATION}s from emulator window..." + ffmpeg -y -loglevel error \ + -f x11grab \ + -framerate "$FPS" \ + -video_size "${WIN_W}x${WIN_H}" \ + -i "${DISPLAY}+${WIN_X},${WIN_Y}" \ + -t "$DURATION" \ + "$RAW_VIDEO" + + # Convert to palette-optimised GIF + GIF_PATH="$OUTPUT_DIR/${SCENE_NAME}.gif" + echo "Converting to GIF: $GIF_PATH" + + PALETTE_FILE="/tmp/palette_${SCENE_NAME}.png" + ffmpeg -y -loglevel error \ + -i "$RAW_VIDEO" \ + -vf "fps=$FPS,palettegen=stats_mode=full" \ + "$PALETTE_FILE" + ffmpeg -y -loglevel error \ + -i "$RAW_VIDEO" \ + -i "$PALETTE_FILE" \ + -lavfi "fps=$FPS [x]; [x][1:v] paletteuse=dither=bayer" \ + "$GIF_PATH" + + rm -f "$RAW_VIDEO" "$PALETTE_FILE" + echo "Saved: $GIF_PATH" +done + +echo "" +echo "Done. Preview GIFs written to: $OUTPUT_DIR" +echo "Run 'cmake --build build --target install' (or build_upload.sh) to deploy." diff --git a/scripts/generate_scene_previews.sh b/scripts/generate_scene_previews.sh new file mode 100755 index 00000000..4dcf6c1b --- /dev/null +++ b/scripts/generate_scene_previews.sh @@ -0,0 +1,276 @@ +#!/bin/bash +# +# generate_scene_previews.sh +# +# Generates animated GIF previews for LED matrix scenes. +# Previews are saved to the scene_previews/ directory in the repository root. +# These previews are then committed to git and deployed with the application. +# +# Usage: +# ./generate_scene_previews.sh [OPTIONS] +# +# Options: +# --all Generate all scenes (default behavior) +# --scenes Comma-separated scene names (e.g., "WaveScene,ColorPulseScene") +# --list File containing scene names (one per line, # for comments) +# --output Output directory (default: scene_previews/) +# --fps Frames per second (default: 15) +# --frames Total frames per GIF (default: 90 = 6s @ 15fps) +# --width Matrix width in pixels (default: 128) +# --height Matrix height in pixels (default: 128) +# --build-dir Build directory (default: emulator_build) +# --skip-validation Skip checking if emulator binary exists +# --dry-run Show what would be done without executing +# --help Show this help message +# +# Examples: +# # Generate all scenes to scene_previews/ +# ./generate_scene_previews.sh --all +# +# # Generate specific scenes +# ./generate_scene_previews.sh --scenes "WaveScene,ColorPulseScene,FractalScene" +# +# # Generate from a list file +# ./generate_scene_previews.sh --list my_scenes.txt +# +# # Custom settings +# ./generate_scene_previews.sh --all --fps 20 --frames 120 --output ./custom_previews +# + +set -euo pipefail + +# Color codes for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# --------------------------------------------------------------------------- +# Script configuration +# --------------------------------------------------------------------------- + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +ENV_FILE="$REPO_DIR/.env" + +if [[ -f "$ENV_FILE" ]]; then + # Export .env variables so child processes (preview_gen) can read them. + set -a + # shellcheck disable=SC1090 + source "$ENV_FILE" + set +a +fi + +MODE="all" # all, scenes, or list +SCENE_LIST="" +OUTPUT_DIR="${REPO_DIR}/scene_previews" +BUILD_DIR="${REPO_DIR}/emulator_build" +FPS=15 +FRAMES=90 +WIDTH=128 +HEIGHT=128 +SKIP_VALIDATION=0 +DRY_RUN=0 + +# --------------------------------------------------------------------------- +# Helper functions +# --------------------------------------------------------------------------- + +print_usage() { + grep '^#' "$0" | tail -n +2 | sed 's/^# *//' +} + +print_error() { + echo -e "${RED}ERROR: $*${NC}" >&2 +} + +print_success() { + echo -e "${GREEN}✓ $*${NC}" +} + +print_info() { + echo -e "${YELLOW}ℹ $*${NC}" +} + +# --------------------------------------------------------------------------- +# Argument parsing +# --------------------------------------------------------------------------- + +while [[ $# -gt 0 ]]; do + case "$1" in + --all) + MODE="all" + shift + ;; + --scenes) + MODE="scenes" + SCENE_LIST="$2" + shift 2 + ;; + --list) + MODE="list" + SCENE_LIST="$2" + shift 2 + ;; + --output) + OUTPUT_DIR="$2" + shift 2 + ;; + --fps) + FPS="$2" + shift 2 + ;; + --frames) + FRAMES="$2" + shift 2 + ;; + --width) + WIDTH="$2" + shift 2 + ;; + --height) + HEIGHT="$2" + shift 2 + ;; + --build-dir) + BUILD_DIR="$2" + shift 2 + ;; + --skip-validation) + SKIP_VALIDATION=1 + shift + ;; + --dry-run) + DRY_RUN=1 + shift + ;; + --help) + print_usage + exit 0 + ;; + *) + print_error "Unknown option: $1" + print_usage + exit 1 + ;; + esac +done + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + +# Check if emulator build exists +BUILD_INSTALL_DIR="$BUILD_DIR/install" +PREVIEW_GEN="$BUILD_INSTALL_DIR/preview_gen" +if [[ ! -f "$PREVIEW_GEN" ]]; then + if [[ $SKIP_VALIDATION -eq 0 ]]; then + print_error "preview_gen binary not found at: $PREVIEW_GEN" + print_info "Please build the emulator first:" + print_info " cmake --preset emulator" + print_info " cmake --build emulator_build" + exit 1 + fi +fi + +# Check if output directory path is valid +if [[ ! -d "$(dirname "$OUTPUT_DIR")" ]]; then + print_error "Parent directory of output path does not exist: $(dirname "$OUTPUT_DIR")" + exit 1 +fi + +# --------------------------------------------------------------------------- +# Build scene list +# --------------------------------------------------------------------------- + +SCENES_CSV="" + +case "$MODE" in +all) + # Empty SCENES_CSV means render all scenes + SCENES_CSV="" + print_info "Mode: Render ALL scenes" + ;; +scenes) + # Use provided comma-separated list + SCENES_CSV="$SCENE_LIST" + SCENE_COUNT=$(echo "$SCENES_CSV" | tr ',' '\n' | wc -l) + print_info "Mode: Render $SCENE_COUNT specific scenes" + ;; +list) + # Read from file, skip comments and empty lines + if [[ ! -f "$SCENE_LIST" ]]; then + print_error "Scene list file not found: $SCENE_LIST" + exit 1 + fi + SCENES_CSV=$(grep -v '^#' "$SCENE_LIST" | grep -v '^\s*$' | tr '\n' ',' | sed 's/,$//') + SCENE_COUNT=$(echo "$SCENES_CSV" | tr ',' '\n' | grep -v '^$' | wc -l) + print_info "Mode: Render $SCENE_COUNT scenes from $SCENE_LIST" + ;; +esac + +# --------------------------------------------------------------------------- +# Prepare output directory +# --------------------------------------------------------------------------- + +if [[ $DRY_RUN -eq 0 ]]; then + mkdir -p "$OUTPUT_DIR" + print_success "Output directory: $OUTPUT_DIR" +fi + +# --------------------------------------------------------------------------- +# Build command +# --------------------------------------------------------------------------- + +PREVIEW_GEN="$BUILD_INSTALL_DIR/preview_gen" +PLUGIN_DIR="$BUILD_INSTALL_DIR/plugins" + +CMD="$PREVIEW_GEN" +CMD="$CMD --output '$OUTPUT_DIR'" +CMD="$CMD --fps $FPS" +CMD="$CMD --frames $FRAMES" +CMD="$CMD --width $WIDTH" +CMD="$CMD --height $HEIGHT" + +if [[ -n "$SCENES_CSV" ]]; then + CMD="$CMD --scenes '$SCENES_CSV'" +fi + +# --------------------------------------------------------------------------- +# Execute +# --------------------------------------------------------------------------- + +print_info "Configuration:" +echo " FPS: $FPS" +echo " Frames: $FRAMES" +echo " Resolution: ${WIDTH}x${HEIGHT}" +echo " Output: $OUTPUT_DIR" +echo " Runtime dir: $BUILD_INSTALL_DIR" +echo "" + +if [[ $DRY_RUN -eq 1 ]]; then + print_info "DRY RUN - Command that would execute:" + echo "" + echo "(cd '$BUILD_INSTALL_DIR' && $CMD)" + echo "" + exit 0 +fi + +print_info "Starting preview generation..." +echo "" + +# Unsetting Spotify secret because it needs manual rendering +unset SPOTIFY_CLIENT_SECRET +if eval "$CMD"; then + echo "" + print_success "Preview generation completed successfully!" + print_info "Previews saved to: $OUTPUT_DIR" + print_info "Next steps:" + print_info " 1. Review the generated GIFs" + print_info " 2. Commit them to git: git add scene_previews/" + print_info " 3. Deploy with: cmake --build emulator_build --target install" +else + EXIT_CODE=$? + print_error "Preview generation failed with exit code $EXIT_CODE" + exit $EXIT_CODE +fi diff --git a/shared/desktop/src/shared/desktop/plugin_loader/loader.cpp b/shared/desktop/src/shared/desktop/plugin_loader/loader.cpp index 87fe6a37..101c5b52 100644 --- a/shared/desktop/src/shared/desktop/plugin_loader/loader.cpp +++ b/shared/desktop/src/shared/desktop/plugin_loader/loader.cpp @@ -220,7 +220,7 @@ void PluginManager::initialize() } #endif - info("Successfully loaded plugin {}", plPath.string()); + trace("Successfully loaded plugin {}", plPath.string()); PluginInfo info = { .handle = dlhandle, diff --git a/shared/matrix/src/shared/matrix/plugin_loader/loader.cpp b/shared/matrix/src/shared/matrix/plugin_loader/loader.cpp index c9c06b1d..205d6729 100644 --- a/shared/matrix/src/shared/matrix/plugin_loader/loader.cpp +++ b/shared/matrix/src/shared/matrix/plugin_loader/loader.cpp @@ -158,7 +158,7 @@ void PluginManager::initialize() { dladdr((void *) create, &dl_info); p->_plugin_location = dl_info.dli_fname; - info("Successfully loaded plugin {}", plPath.string()); + trace("Successfully loaded plugin {}", plPath.string()); PluginInfo info = { .handle = dlhandle, @@ -172,8 +172,8 @@ void PluginManager::initialize() { } } - info("Loaded a total of {} plugins.", loaded_plugins.size()); - info("Loading providers to register..."); + trace("Loaded a total of {} plugins.", loaded_plugins.size()); + trace("Loading providers to register..."); initialized = true; } diff --git a/src_matrix/server/scene_management.cpp b/src_matrix/server/scene_management.cpp index 02c22a44..cae1933f 100644 --- a/src_matrix/server/scene_management.cpp +++ b/src_matrix/server/scene_management.cpp @@ -4,6 +4,9 @@ #include "nlohmann/json.hpp" #include "shared/matrix/plugin_loader/loader.h" #include "shared/matrix/canvas_consts.h" +#include "shared/matrix/server/MimeTypes.h" +#include "shared/common/utils/utils.h" +#include using json = nlohmann::json; @@ -50,7 +53,9 @@ std::unique_ptr Server::add_scene_routes(std::unique_ptrget_name()}, - {"properties", properties_json} + {"properties", properties_json}, + {"has_preview", std::filesystem::exists(get_exec_dir() / "scene_previews" / (item->get_name() + ".gif"))}, + {"needs_desktop", item->get_default()->needs_desktop_app()} }; j.push_back(j1); @@ -130,5 +135,34 @@ std::unique_ptr Server::add_scene_routes(std::unique_ptrhttp_get("/scene_preview", [](auto req, auto) { + const auto qp = restinio::parse_query(req->header().query()); + if (!qp.has("name")) { + return reply_with_error(req, "No name given"); + } + + const std::string scene_name{qp["name"]}; + const std::filesystem::path preview_dir = get_exec_dir() / "scene_previews"; + const std::filesystem::path gif_path = preview_dir / (scene_name + ".gif"); + + // Validate path is inside scene_previews dir + std::error_code ec; + if (!std::filesystem::exists(gif_path, ec) || ec) { + return reply_with_error(req, "Preview not found", restinio::status_not_found()); + } + + const auto canonical_preview_dir = std::filesystem::canonical(preview_dir, ec); + const auto canonical_gif = std::filesystem::canonical(gif_path, ec); + if (ec || !canonical_gif.string().starts_with(canonical_preview_dir.string())) { + return reply_with_error(req, "Invalid path", restinio::status_forbidden()); + } + + auto response = req->create_response(restinio::status_ok()) + .append_header_date_field() + .append_header(restinio::http_field::content_type, "image/gif"); + Server::add_cors_headers(response); + return response.set_body(restinio::sendfile(gif_path)).done(); + }); + return std::move(router); } diff --git a/src_preview_gen/main.cpp b/src_preview_gen/main.cpp new file mode 100644 index 00000000..f2f8d1cb --- /dev/null +++ b/src_preview_gen/main.cpp @@ -0,0 +1,457 @@ +/** + * preview_gen: Generates animated GIF previews for all registered matrix scenes. + * + * Usage: + * preview_gen [--output ] [--scene ] [--scenes ] + * [--frames ] [--fps ] [--width ] [--height ] + * [--dump-manifest] [--manifest-out ] + * + * Defaults: + * --output ./previews + * --frames 90 (6 seconds at 15 fps) + * --fps 15 + * --width 128 + * --height 128 + * + * Manifest mode (--dump-manifest): + * Writes a JSON array of {name, plugin_name, plugin_path} objects and exits + * without rendering any GIFs. Use --manifest-out to specify the output file + * (defaults to stdout). + */ + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#ifdef ENABLE_EMULATOR +#include "emulator.h" +#include "matrix-factory.h" +#endif + +#include "led-matrix.h" +#include "shared/matrix/plugin_loader/loader.h" +#include "shared/matrix/utils/shared.h" +#include "shared/matrix/canvas_consts.h" +#include "shared/matrix/utils/consts.h" + +namespace fs = std::filesystem; + +// --------------------------------------------------------------------------- +// Argument parsing helpers +// --------------------------------------------------------------------------- +static bool parse_int(const char* str, int& out) +{ + try + { + out = std::stoi(str); + return true; + } + catch (...) + { + return false; + } +} + +struct Args +{ + std::string output_dir = "./previews"; + std::string filter_scene; // legacy --scene (single scene) + std::vector filter_scenes; // --scenes (comma-separated list) + int fps = 15; + int total_frames = 90; // 6 seconds @ 15 fps + int matrix_width = 128; + int matrix_height = 128; + bool dump_manifest = false; + std::string manifest_out; // path for --manifest-out; empty = stdout +}; + +static Args parse_args(int argc, char* argv[]) +{ + Args a; + for (int i = 1; i < argc; ++i) + { + if (std::string(argv[i]) == "--output" && i + 1 < argc) + a.output_dir = argv[++i]; + else if (std::string(argv[i]) == "--scene" && i + 1 < argc) + a.filter_scene = argv[++i]; + else if (std::string(argv[i]) == "--scenes" && i + 1 < argc) + { + // comma-separated list of scene names + std::string csv = argv[++i]; + std::stringstream ss(csv); + std::string token; + while (std::getline(ss, token, ',')) + { + if (!token.empty()) + a.filter_scenes.push_back(token); + } + } + else if (std::string(argv[i]) == "--fps" && i + 1 < argc) + parse_int(argv[++i], a.fps); + else if (std::string(argv[i]) == "--frames" && i + 1 < argc) + parse_int(argv[++i], a.total_frames); + else if (std::string(argv[i]) == "--width" && i + 1 < argc) + parse_int(argv[++i], a.matrix_width); + else if (std::string(argv[i]) == "--height" && i + 1 < argc) + parse_int(argv[++i], a.matrix_height); + else if (std::string(argv[i]) == "--dump-manifest") + a.dump_manifest = true; + else if (std::string(argv[i]) == "--manifest-out" && i + 1 < argc) + a.manifest_out = argv[++i]; + } + // Normalise: merge --scene into filter_scenes + if (!a.filter_scene.empty()) + a.filter_scenes.push_back(a.filter_scene); + // Clamp fps to a sane range + if (a.fps < 1) + a.fps = 1; + if (a.fps > 60) + a.fps = 60; + if (a.total_frames < 1) + a.total_frames = 1; + return a; +} + +// --------------------------------------------------------------------------- +// Read all pixels from a FrameCanvas into a flat RGB byte vector +// --------------------------------------------------------------------------- +static std::vector capture_canvas(rgb_matrix::FrameCanvas* canvas, + int w, int h) +{ + std::vector buf(static_cast(w * h * 3)); + for (int y = 0; y < h; ++y) + { + for (int x = 0; x < w; ++x) + { + uint8_t r = 0, g = 0, b = 0; + canvas->GetPixel(x, y, &r, &g, &b); + const size_t idx = static_cast((y * w + x) * 3); + buf[idx] = r; + buf[idx + 1] = g; + buf[idx + 2] = b; + } + } + return buf; +} + +// --------------------------------------------------------------------------- +// Convert a flat RGB buffer to a GraphicsMagick Image with a GIF delay +// --------------------------------------------------------------------------- +static Magick::Image make_frame(const std::vector& rgb, + int w, int h, + size_t delay_centiseconds) +{ + Magick::Image img(Magick::Geometry(static_cast(w), + static_cast(h)), + Magick::Color(0, 0, 0)); + img.modifyImage(); + + Magick::PixelPacket* pixels = + img.getPixels(0, 0, static_cast(w), static_cast(h)); + + // Scale each 8-bit channel to the full Quantum range [0, MaxRGB]. + // MaxRGB is a GraphicsMagick compile-time constant (65535 for 16-bit depth). + const size_t total = static_cast(w * h); + for (size_t i = 0; i < total; ++i) + { + using MagickLib::Quantum; + pixels[i].red = static_cast( + static_cast(rgb[i * 3]) * MaxRGB / 255UL); + pixels[i].green = static_cast( + static_cast(rgb[i * 3 + 1]) * MaxRGB / 255UL); + pixels[i].blue = static_cast( + static_cast(rgb[i * 3 + 2]) * MaxRGB / 255UL); + pixels[i].opacity = 0; // fully opaque + } + img.syncPixels(); + + img.animationDelay(delay_centiseconds); + img.animationIterations(0); // loop forever + return img; +} + +// --------------------------------------------------------------------------- +// main +// --------------------------------------------------------------------------- +int main(int argc, char* argv[]) +{ + spdlog::cfg::load_env_levels(); + + const Args args = parse_args(argc, argv); + + // ---- initialise GraphicsMagick ---------------------------------------- + Magick::InitializeMagick(*argv); + + // ---- create output directory ------------------------------------------ + std::error_code ec; + fs::create_directories(args.output_dir, ec); + if (ec) + { + spdlog::error("Cannot create output directory '{}': {}", + args.output_dir, ec.message()); + return 1; + } + + // ---- create headless emulator matrix ---------------------------------- +#ifndef ENABLE_EMULATOR + spdlog::error("preview_gen requires ENABLE_EMULATOR to be set at compile time."); + return 1; +#else + rgb_matrix::RGBMatrix::Options led_opts; + led_opts.rows = args.matrix_height; + led_opts.cols = args.matrix_width; + led_opts.chain_length = 1; + led_opts.parallel = 1; + + rgb_matrix::EmulatorOptions emu_opts; + emu_opts.headless = true; + emu_opts.refresh_rate_hz = args.fps; + + rgb_matrix::EmulatorMatrix* matrix = + rgb_matrix::EmulatorMatrix::Create(led_opts, emu_opts); + if (!matrix) + { + spdlog::error("Failed to create headless emulator matrix."); + return 1; + } + + + if (!filesystem::exists(Constants::root_dir)) + { + filesystem::create_directory(Constants::root_dir); + } + + // ---- initialise shared globals expected by SharedToolsMatrix ---------- + Constants::width = args.matrix_width; + Constants::height = args.matrix_height; + Constants::global_post_processor = nullptr; + Constants::global_transition_manager = nullptr; + Constants::global_update_manager = nullptr; + + // provide a minimal config so nothing derefs a null pointer + const fs::path cfg_path = fs::temp_directory_path() / "preview_gen_config.json"; + config = new Config::MainConfig(cfg_path.string()); + + // ---- load plugins ------------------------------------------------------ + spdlog::trace("Loading plugins…"); + const auto pl = Plugins::PluginManager::instance(); + pl->initialize(); + + for (auto plugin : pl->get_plugins()) + { + plugin->before_server_init(); + plugin->after_server_init(); + } + + const auto& wrappers = pl->get_scenes(); + if (wrappers.empty()) + { + spdlog::warn("No scenes found. Make sure PLUGIN_DIR points to the " + "built plugins directory."); + } + + // ---- dump-manifest mode: output scene→plugin mapping then exit -------- + if (args.dump_manifest) + { + nlohmann::json manifest = nlohmann::json::array(); + + for (const auto& wrapper : wrappers) + { + const std::string scene_name = wrapper->get_name(); + const bool needs_desktop = wrapper->get_default()->needs_desktop_app(); + std::string plugin_name; + std::string plugin_path; + + // Find which loaded plugin owns this scene wrapper + for (const auto& pi : pl->get_plugins()) + { + for (const auto& sw : pi->get_scenes()) + { + if (sw->get_name() == scene_name) + { + plugin_name = pi->get_plugin_name(); + plugin_path = pi->get_plugin_location(); + break; + } + } + if (!plugin_name.empty()) + break; + } + + manifest.push_back({ + {"name", scene_name}, + {"plugin_name", plugin_name}, + {"plugin_path", plugin_path}, + {"needs_desktop", needs_desktop}, + }); + } + + const std::string manifest_str = manifest.dump(2); + + if (args.manifest_out.empty()) + { + std::cout << manifest_str << "\n"; + } + else + { + std::ofstream out(args.manifest_out); + if (!out) + { + spdlog::error("Cannot write manifest to '{}'", args.manifest_out); + return 1; + } + out << manifest_str << "\n"; + spdlog::info("Scene manifest written to {}", args.manifest_out); + } + + // Cleanup and exit without rendering + pl->delete_references(); + pl->destroy_plugins(); + delete config; + config = nullptr; + delete matrix; + return 0; + } + + // ---- allocate a single render canvas ---------------------------------- + rgb_matrix::FrameCanvas* canvas = matrix->CreateFrameCanvas(); + canvas->Clear(); + + // Timing constants + const int frame_delay_ms = 1000 / args.fps; + const size_t frame_delay_cs = + static_cast(std::max(1, 100 / args.fps)); // centiseconds + + int generated = 0; + int skipped = 0; + + // ---- iterate scenes --------------------------------------------------- + for (const auto& wrapper : wrappers) + { + const std::string scene_name = wrapper->get_name(); + + // Skip scenes that require the desktop app - they cannot be rendered + // headlessly and need a running desktop connection. Use + // scripts/capture_desktop_preview.sh to capture them manually. + if (wrapper->get_default()->needs_desktop_app()) + { + spdlog::info("Skipping '{}': requires desktop app (use capture_desktop_preview.sh).", + scene_name); + continue; + } + + // Apply scene filter (--scene or --scenes) + if (!args.filter_scenes.empty()) + { + bool found = false; + for (const auto& f : args.filter_scenes) + if (f == scene_name) + { + found = true; + break; + } + if (!found) + continue; + } + + spdlog::info("Rendering preview for '{}' ({} frames @ {} fps)…", + scene_name, args.total_frames, args.fps); + + // Per-scene crash isolation: wrap the entire render in try/catch so a + // single broken scene does not abort the rest of the batch. + try + { + // Create a fresh instance so each scene starts from t=0. + // After register_properties(), dump default values back to a JSON object + // so that load_properties() can set registered=true even for required + // properties that have no user-supplied value. + auto scene = wrapper->create(); + scene->update_default_properties(); + scene->register_properties(); + + nlohmann::json default_props = nlohmann::json::object(); + for (const auto& prop : scene->get_properties()) + prop->dump_to_json(default_props); + + scene->load_properties(default_props); + scene->initialize(args.matrix_width, args.matrix_height); + + std::vector frames; + frames.reserve(static_cast(args.total_frames)); + + for (int f = 0; f < args.total_frames; ++f) + { + // Sleep so that time-based animations (FrameTimer) advance at the + // intended rate. Most scenes use real wall-clock time, so without + // this sleep the entire animation would appear as a single instant. + std::this_thread::sleep_for(std::chrono::milliseconds(frame_delay_ms)); + + canvas->Clear(); + const bool keep_going = scene->render(canvas); + + const auto rgb = capture_canvas(canvas, args.matrix_width, + args.matrix_height); + frames.push_back(make_frame(rgb, args.matrix_width, + args.matrix_height, frame_delay_cs)); + + if (!keep_going) + { + spdlog::debug("Scene '{}' stopped at frame {}/{}", scene_name, + f + 1, args.total_frames); + break; + } + } + + if (frames.empty()) + { + spdlog::warn("No frames captured for '{}', skipping.", scene_name); + ++skipped; + continue; + } + + // Quantise colours (required for GIF palette, 256 colours max) + Magick::quantizeImages(frames.begin(), frames.end()); + + const fs::path gif_path = + fs::path(args.output_dir) / (scene_name + ".gif"); + + Magick::writeImages(frames.begin(), frames.end(), gif_path.string()); + spdlog::info("Saved preview → {}", gif_path.string()); + ++generated; + } + catch (const std::exception& e) + { + spdlog::warn("Scene '{}' failed ({}); skipping — existing preview (if any) preserved.", + scene_name, e.what()); + ++skipped; + } + catch (...) + { + spdlog::warn("Scene '{}' threw an unknown exception; skipping.", scene_name); + ++skipped; + } + } + + spdlog::info("Done. Generated: {} Skipped: {}", generated, skipped); + + // ---- cleanup ---------------------------------------------------------- + pl->delete_references(); + pl->destroy_plugins(); + + delete config; + config = nullptr; + + delete matrix; + + return (skipped > 0 && generated == 0) ? 1 : 0; +#endif +}