Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions .github/workflows/exr-runtime.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
name: EXR runtime
on:
push:
branches: ['feat/exr-ocio']
paths: ['native/exr/**', 'scripts/*ExrRuntime*', '.github/workflows/exr-runtime.yml']
workflow_dispatch:
inputs:
platform:
description: Native build targets
type: choice
options: [all, linux]
default: all
publish:
description: Publish verified optional component assets (never latest)
type: boolean
default: false
permissions:
contents: read
jobs:
native:
strategy:
fail-fast: false
matrix: ${{ fromJSON((github.event_name == 'push' || inputs.platform == 'linux') && '{"include":[{"os":"ubuntu-22.04","triplet":"x64-linux","platform":"linux-x64"}]}' || '{"include":[{"os":"windows-latest","triplet":"x64-windows-static","platform":"win32-x64"},{"os":"ubuntu-22.04","triplet":"x64-linux","platform":"linux-x64"},{"os":"macos-15-intel","triplet":"x64-osx","platform":"darwin-x64"},{"os":"macos-15","triplet":"arm64-osx","platform":"darwin-arm64"}]}') }}
runs-on: ${{ matrix.os }}
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4
with:
repository: microsoft/vcpkg
ref: a2f9862da6d88040a8537fb9b8cb24deaa9a196b
path: .tmp/exr-vcpkg
- uses: actions/setup-node@v4
with: { node-version: '22' }
- uses: actions/setup-python@v5
with: { python-version: '3.11' }
- name: Native dependencies cache
uses: actions/cache@v4
with:
path: .tmp/exr-vcpkg-cache
key: exr-${{ matrix.triplet }}-${{ hashFiles('native/exr/vcpkg.json') }}
- name: Configure
shell: bash
env:
VCPKG_BINARY_SOURCES: clear;files,${{ github.workspace }}/.tmp/exr-vcpkg-cache,readwrite
MACOSX_DEPLOYMENT_TARGET: '12.0'
run: |
mkdir -p .tmp/exr-vcpkg-cache
cmake -S native/exr -B .tmp/exr-build -DCMAKE_BUILD_TYPE=Release -DCMAKE_TOOLCHAIN_FILE="$PWD/.tmp/exr-vcpkg/scripts/buildsystems/vcpkg.cmake" -DVCPKG_TARGET_TRIPLET=${{ matrix.triplet }} -DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded -DCMAKE_INSTALL_PREFIX="$PWD/.tmp/exr-install"
- name: Build and test
shell: bash
run: |
cmake --build .tmp/exr-build --config Release --parallel 2
ctest --test-dir .tmp/exr-build -C Release --output-on-failure
cmake --install .tmp/exr-build --config Release
- name: Numerical EXR and OCIO integration tests
shell: bash
run: |
python -m pip install OpenEXR==3.3.5 opencolorio==2.5.1 numpy==2.2.6 Pillow==11.3.0
if [[ "${{ matrix.platform }}" == win32-* ]]; then HELPER=.tmp/exr-install/imh-exr.exe; else HELPER=.tmp/exr-install/imh-exr; fi
python scripts/testExrRuntime.py --helper "$HELPER" --work .tmp/exr-fixtures
- name: Package verified runtime
shell: bash
run: node scripts/packageExrRuntime.mjs --platform ${{ matrix.platform }} --input .tmp/exr-install --out .tmp/exr-assets --licenses .tmp/exr-build/vcpkg_installed/${{ matrix.triplet }}/share
- name: Install, offline preview and removal smoke test
shell: bash
run: node scripts/smokeExrInstall.mjs .tmp/exr-assets .tmp/exr-fixtures/rgba-float.exr
- uses: actions/upload-artifact@v4
with:
name: exr-package-${{ matrix.platform }}
path: .tmp/exr-assets/*
- uses: actions/upload-artifact@v4
with:
name: exr-fixtures-${{ matrix.platform }}
path: |
.tmp/exr-fixtures/*.exr
.tmp/exr-fixtures/numerical-results.json
assemble:
if: github.event_name == 'workflow_dispatch' && inputs.platform == 'all'
needs: native
runs-on: ubuntu-22.04
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '22' }
- uses: actions/download-artifact@v4
with: { pattern: 'exr-package-*', path: .tmp/exr-assets, merge-multiple: true }
- run: node scripts/assembleExrRuntimePolicy.mjs .tmp/exr-assets .tmp/exr-assets/exrRuntimePolicy.generated.mjs
- uses: actions/upload-artifact@v4
with: { name: exr-release, path: '.tmp/exr-assets/*' }
- name: Publish optional component
if: github.event_name == 'workflow_dispatch' && inputs.publish
env:
GH_TOKEN: ${{ github.token }}
run: gh release create exr-runtime-v1.0.0 .tmp/exr-assets/* --title 'EXR / OpenColorIO runtime 1.0.0' --notes 'Optional local EXR / OpenColorIO component. Installed explicitly from Image MetaHub Settings.' --latest=false
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Development test files
.tmp/exr-*/
.tmp/model-manager-usage/
test-*.js
test-*.cjs
Expand Down
9 changes: 8 additions & 1 deletion App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@
normalizeFilesystemPath,
} from './utils/filesystemPath';
import { waitForDirectoryActivityToSettle } from './utils/directoryActivity';
import { resolveMediaType } from './utils/mediaTypes.js';
import { resolveMediaType, canEditRasterFile } from './utils/mediaTypes.js';
import { resolveNavigationAfterDeletion } from './utils/viewerNavigation';
import { FileOperations } from './services/fileOperations';
import { renameIndexedImage } from './services/imageRenameService';
Expand Down Expand Up @@ -302,7 +302,7 @@
const selectedImages = useImageStore((state) => state.selectedImages);
const selectedImage = useImageStore((state) => state.selectedImage);
const previewImage = useImageStore((state) => state.previewImage);
const clustersCount = useImageStore((state) => state.clusters.length);

Check warning on line 305 in App.tsx

View workflow job for this annotation

GitHub Actions / Test, lint and build

'clustersCount' is assigned a value but never used
const clusters = useImageStore((state) => state.clusters);
const clusterNavigationContext = useImageStore((state) => state.clusterNavigationContext);
const activeImageScope = useImageStore((state) => state.activeImageScope);
Expand Down Expand Up @@ -841,7 +841,7 @@
canStartTrial,
isExpired,
isFree,
isPro,

Check warning on line 844 in App.tsx

View workflow job for this annotation

GitHub Actions / Test, lint and build

'isFree' is assigned a value but never used
canUseAnalytics,
canUseBatchExport,
canUseImageEditor,
Expand Down Expand Up @@ -885,6 +885,12 @@
setIsSettingsModalOpen(true);
};

useEffect(() => {
const open = () => { setSettingsTab('general'); setSettingsSection('exr'); setIsSettingsModalOpen(true); };
window.addEventListener('imh-open-exr-settings', open);
return () => window.removeEventListener('imh-open-exr-settings', open);
}, []);

const handleOpenHotkeySettings = () => {
setIsHotkeyHelpOpen(false);
handleOpenSettings('shortcuts');
Expand Down Expand Up @@ -2638,6 +2644,7 @@
}, []);

const handleOpenImageEditor = useCallback((image: IndexedImage, navigationImages?: IndexedImage[]) => {
if (!canEditRasterFile(image.name)) return;
if (!canUseImageEditor) {
showProModal('image_editor');
return;
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,15 @@ Image MetaHub reads generation metadata from ComfyUI, Automatic1111, InvokeAI, F

| Media | Supported formats |
| --- | --- |
| Images | PNG, JPG/JPEG, WEBP, AVIF, GIF |
| Images | PNG, JPG/JPEG, WEBP, AVIF, GIF; EXR with the optional desktop component |
| Video | MP4, WEBM, MKV, MOV, AVI |
| Audio | MP3, WAV, FLAC, OGG/OGA, M4A, AAC, OPUS, AIFF/AIF, WMA |
| 3D | GLB, GLTF, OBJ, FBX, STL |

Browse and play media, or inspect 3D assets in the interactive viewer and export them. Audio/video playback depends on the codecs available on your system. Files without generation metadata can still be browsed and organized.

For renders, [EXR / OpenColorIO](docs/EXR-OCIO.md) adds color-managed previews, render passes, individual channels, and non-destructive exposure. Install the optional component from **Settings → General → Optional components**; processing then works offline. EXR originals are preserved, and EXR editing/export and deep images are outside this first version.

![Interactive local 3D model viewer](assets/screenshot-3d-model-library.webp)

## Free and Pro
Expand Down
34 changes: 34 additions & 0 deletions __tests__/exrIndexing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { indexExrEntry } from '../services/exr/indexing';

const read = vi.fn(() => { throw new Error('EXR bytes must not reach the renderer'); });
const api = { status: vi.fn(), inspect: vi.fn(), onChanged: vi.fn(() => () => {}) };
const entry = { handle: { name: 'render.exr', _filePath: 'D:\\renders\\render.exr', getFile: read } as unknown as FileSystemFileHandle, path: 'render.exr', lastModified: 1234, size: 456 };
beforeEach(() => {
vi.clearAllMocks();
Object.defineProperty(window, 'electronAPI', { configurable: true, value: { exr: api, readFile: read } });
});
describe('EXR catalog indexing', () => {
it('catalogs unavailable EXRs without reading bytes, sidecars, or decoding', async () => {
api.status.mockResolvedValue({ success: true, value: { installed: false, enabled: false } });
const result = await indexExrEntry(entry, 'renders');
expect(result).toMatchObject({ id: 'renders::render.exr', fileSize: 456, fileType: 'image/x-exr', models: [], loras: [] });
expect(result.metadata).toMatchObject({ _exrPending: true });
expect(read).not.toHaveBeenCalled(); expect(api.inspect).not.toHaveBeenCalled();
});
it('uses only native header data and does not persist a folder profile as file metadata', async () => {
api.status.mockResolvedValue({ success: true, value: { installed: true, enabled: true } });
api.inspect.mockResolvedValue({ success: true, value: { parts: [{ index: 0, width: 4096, height: 2160, deep: false, channels: [] }], profile: { input: 'ACEScg' } } });
const result = await indexExrEntry(entry, 'renders');
expect(result.dimensions).toBe('4096x2160');
expect(result.metadata).toMatchObject({ exr: { parts: [{ width: 4096 }] } });
expect(result.metadataString).not.toContain('ACEScg'); expect(read).not.toHaveBeenCalled();
});
it('keeps an incomplete render cataloged and records a recoverable error', async () => {
api.status.mockResolvedValue({ success: true, value: { installed: true, enabled: true } });
api.inspect.mockResolvedValue({ success: false, error: 'Incomplete EXR header' });
const result = await indexExrEntry(entry, 'renders');
expect(result.metadata).toMatchObject({ _exrPending: true, exrError: 'Incomplete EXR header' });
expect(read).not.toHaveBeenCalled();
});
});
22 changes: 22 additions & 0 deletions __tests__/exrModel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { exrChannelGroups } from '../services/exr/model';
import type { ExrPart } from '../services/exr/contracts';
import { canEditRasterFile, inferMimeTypeFromName, isExrFileName, isSupportedMediaFileName } from '../utils/mediaTypes.js';

describe('EXR format and channel model', () => {
it('discovers EXR as an image without granting raster editing', () => {
expect(isExrFileName('render.EXR')).toBe(true);
expect(isSupportedMediaFileName('render.exr')).toBe(true);
expect(inferMimeTypeFromName('render.exr')).toBe('image/x-exr');
expect(canEditRasterFile('render.exr')).toBe(false);
expect(canEditRasterFile('render.png')).toBe(true);
});
it('groups shuffled beauty channels and preserves standalone data channels', () => {
const names = ['depth.Z','specular.B','beauty.G','beauty.A','beauty.R','specular.R','beauty.B','specular.G','ObjectID','normal.X','normal.Z','normal.Y'];
const groups = exrChannelGroups({ channels: names.map(name => ({ name, type: 'float' })) } as ExrPart);
expect(groups[0]).toEqual({ name: 'beauty', channels: ['beauty.R','beauty.G','beauty.B','beauty.A'] });
expect(groups).toContainEqual({ name: 'normal', channels: ['normal.X','normal.Y','normal.Z'] });
expect(groups).toContainEqual({ name: 'depth.Z', channels: ['depth.Z'] });
expect(groups).toContainEqual({ name: 'ObjectID', channels: ['ObjectID'] });
});
});
96 changes: 96 additions & 0 deletions __tests__/exrRuntime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// @vitest-environment node
import { afterEach, describe, expect, it, vi } from 'vitest';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import crypto from 'node:crypto';
import { gzipSync } from 'node:zlib';
import { createExrRuntime, validateRuntimeFile } from '../electron/exrRuntime.mjs';
import { EXR_DEFAULT_SETTINGS, EXR_DEFAULT_PROFILE, resolveExrProfile } from '../electron/exrProfiles.mjs';

const roots: string[] = [];
afterEach(async () => { await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); });
const hash = (bytes: Buffer) => crypto.createHash('sha256').update(bytes).digest('hex');
async function fixture() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'imh-exr-test-')); roots.push(root);
const spawn = vi.fn(() => { throw new Error('Native execution must not happen in this test'); });
const fetch = vi.fn();
const runtime = createExrRuntime({ userDataPath: root, cacheRoot: async () => root, isPathAllowed: (file: string) => file.startsWith(root + path.sep), spawnImpl: spawn, fetchImpl: fetch, platform: 'test', packages: {} });
return { root, runtime, spawn, fetch };
}
describe('optional EXR runtime boundaries', () => {
it('does not read/create component files or start/download anything before use', async () => {
const f = await fixture();
expect(await fs.readdir(f.root)).toEqual([]);
expect(f.spawn).not.toHaveBeenCalled(); expect(f.fetch).not.toHaveBeenCalled();
expect(await f.runtime.status()).toMatchObject({ installed: false, enabled: false, available: false });
expect(await fs.readdir(f.root)).toEqual([]);
f.runtime.dispose();
});
it('keeps uninstalled EXRs out of the normal decoding path', async () => {
const f = await fixture(); const file = path.join(f.root, 'synthetic.exr'); await fs.writeFile(file, 'synthetic header');
await expect(f.runtime.preview({ filePath: file, requestId: 'not-installed' })).rejects.toMatchObject({ code: 'UNAVAILABLE' });
expect(f.spawn).not.toHaveBeenCalled(); expect(f.fetch).not.toHaveBeenCalled(); f.runtime.dispose();
});
it('rejects unauthorized paths and unapproved configurations', async () => {
const f = await fixture();
await expect(f.runtime.inspect(path.join(f.root, 'synthetic.png'))).rejects.toThrow('Invalid EXR path');
const other = await fs.mkdtemp(path.join(os.tmpdir(), 'imh-exr-outside-')); roots.push(other);
const file = path.join(other, 'synthetic.exr'); await fs.writeFile(file, 'synthetic');
await expect(f.runtime.inspect(file)).rejects.toMatchObject({ code: 'ACCESS_DENIED' });
await expect(f.runtime.config(path.join(other, 'config.ocio'))).rejects.toMatchObject({ code: 'ACCESS_DENIED' });
expect(f.spawn).not.toHaveBeenCalled(); f.runtime.dispose();
});
it('persists folder/global preferences separately from catalog data', async () => {
const f = await fixture();
const folder = path.join(f.root, 'renders'); await fs.mkdir(folder);
const profile = { ...EXR_DEFAULT_PROFILE, input: 'ACEScg' };
const result = await f.runtime.saveSettings({ ...EXR_DEFAULT_SETTINGS, folders: { [folder]: profile } });
expect(result.revision).toBe(1);
expect(resolveExrProfile(path.join(folder, 'shot', 'render.exr'), result)).toEqual(profile);
expect(resolveExrProfile(path.join(f.root, 'renders-other', 'render.exr'), result)).toEqual(EXR_DEFAULT_PROFILE);
expect(f.spawn).not.toHaveBeenCalled(); expect(f.fetch).not.toHaveBeenCalled(); f.runtime.dispose();
});
it('chooses the closest folder using platform path boundaries', () => {
const nested = { ...EXR_DEFAULT_PROFILE, input: 'ACEScg' };
const prefs = { ...EXR_DEFAULT_SETTINGS, folders: { 'D:\\Renders': EXR_DEFAULT_PROFILE, 'D:\\Renders\\Shot': nested } };
expect(resolveExrProfile('d:\\renders\\shot\\frame.exr', prefs, path.win32)).toEqual(nested);
expect(resolveExrProfile('D:\\RendersOther\\frame.exr', prefs, path.win32)).toEqual(EXR_DEFAULT_PROFILE);
});
it('preserves unchanged removed-folder profiles across restart without allowing unauthorized edits', async () => {
const f = await fixture();
const folder = path.join(f.root, 'removed-renders');
const profile = { ...EXR_DEFAULT_PROFILE, input: 'ACEScg' };
await f.runtime.saveSettings({ ...EXR_DEFAULT_SETTINGS, folders: { [folder]: profile } });
await f.runtime.dispose();
const restarted = createExrRuntime({ userDataPath: f.root, cacheRoot: async () => f.root, isPathAllowed: () => false, spawnImpl: f.spawn, fetchImpl: f.fetch, platform: 'test', packages: {} });
try {
const saved = await restarted.settings();
const updated = await restarted.saveSettings({ ...saved, global: { ...EXR_DEFAULT_PROFILE, view: 'new view' } });
expect(updated.folders[folder]).toEqual(profile);
await expect(restarted.saveSettings({ ...updated, folders: { [folder]: { ...profile, input: 'changed' } } })).rejects.toThrow('outside authorized directories');
await expect(restarted.saveSettings({ ...updated, folders: { ...updated.folders, [path.join(f.root, 'new-root')]: profile } })).rejects.toThrow('outside authorized directories');
await expect(restarted.saveSettings({ ...updated, enabled: false })).resolves.toMatchObject({ enabled: false });
await expect(restarted.remove()).resolves.toBeNull();
expect((await restarted.settings()).folders[folder]).toEqual(profile);
await expect(restarted.saveSettings({ ...await restarted.settings(), folders: {} })).resolves.toMatchObject({ folders: {} });
expect(f.spawn).not.toHaveBeenCalled(); expect(f.fetch).not.toHaveBeenCalled();
} finally { await restarted.dispose(); }
});
it('rejects path traversal and missing package integrity metadata', () => {
const bytes = Buffer.from('synthetic executable'); const compressed = gzipSync(bytes);
const good = { name: 'imh-exr', size: bytes.length, sha256: hash(bytes), downloadSize: compressed.length, downloadSha256: hash(compressed) };
expect(validateRuntimeFile(good)).toEqual(good);
for (const name of ['../imh-exr', '/imh-exr', '..', 'folder\\imh-exr', 'a/b']) expect(() => validateRuntimeFile({ ...good, name })).toThrow();
expect(() => validateRuntimeFile({ ...good, sha256: '' })).toThrow();
});
it('rejects a corrupt download before extraction or native execution', async () => {
const f = await fixture(); const bytes = Buffer.from('synthetic executable'); const compressed = gzipSync(bytes);
const file = { name: 'imh-exr', size: bytes.length, sha256: hash(bytes), downloadSize: compressed.length, downloadSha256: '0'.repeat(64), url: 'https://github.com/LuqP2/Image-MetaHub/releases/download/exr-runtime-v1.0.0/imh-exr.gz' };
const fetch = vi.fn(async () => new Response(compressed));
const runtime = createExrRuntime({ userDataPath: f.root, cacheRoot: async () => f.root, isPathAllowed: () => true, spawnImpl: f.spawn, fetchImpl: fetch, platform: 'test', packages: { test: { executable: 'imh-exr', files: [file] } } });
await expect(runtime.install()).rejects.toMatchObject({ code: 'INTEGRITY' });
expect(f.spawn).not.toHaveBeenCalled();
expect(await fs.readdir(path.join(f.root, 'components', 'exr-ocio'))).toEqual([]); runtime.dispose(); f.runtime.dispose();
});
});
Loading
Loading