Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
56 changes: 56 additions & 0 deletions __tests__/exrIndexing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { indexExrEntry, refreshPendingExrMetadata } from '../services/exr/indexing';

const recovery = vi.hoisted(() => ({ getState: vi.fn(), reparse: vi.fn() }));
vi.mock('../store/useImageStore', () => ({ useImageStore: { getState: recovery.getState } }));
vi.mock('../services/fileIndexer', () => ({ reparseIndexedImage: recovery.reparse }));

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();
});
it('retries a previous inspection error once on recovery and updates only its metadata', async () => {
api.status.mockResolvedValue({ success: true, value: { installed: true, enabled: true } });
api.inspect.mockResolvedValue({ success: false, error: 'Helper exited' });
const pending = { ...await indexExrEntry(entry, 'renders'), tags: ['keep-tag'] };
const setImages = vi.fn();
recovery.getState.mockReturnValue({ images: [pending], directories: [{ id: 'renders', path: 'D:\\renders' }], setImages });
const metadata = { exr: { parts: [] } };
recovery.reparse.mockResolvedValue({ ...pending, metadata, metadataString: JSON.stringify(metadata), dimensions: '64x64' });
await refreshPendingExrMetadata();
expect(recovery.reparse).toHaveBeenCalledTimes(1);
expect(setImages.mock.calls[0][0][0]).toMatchObject({ tags: ['keep-tag'], metadata, dimensions: '64x64' });
recovery.reparse.mockClear(); setImages.mockClear();
recovery.reparse.mockRejectedValue(new Error('Still unavailable'));
await refreshPendingExrMetadata();
expect(recovery.reparse).toHaveBeenCalledTimes(1);
expect(setImages).not.toHaveBeenCalled();
expect(read).not.toHaveBeenCalled();
});
});
37 changes: 37 additions & 0 deletions __tests__/exrModel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { exrChannelGroups, exrDefaultSelection } 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', () => {
const part = (index: number, name: string, channels = ['R', 'G', 'B'], deep = false) => ({ index, name, deep, channels: channels.map(name => ({ name, type: 'float' })) }) as ExrPart;
it.each(['beauty', 'Combined', 'ViewLayer.Beauty'])('prefers a later %s part over the first specular RGB part', name => {
const beauty = part(1, name, ['B', 'A', 'R', 'G']);
expect(exrDefaultSelection([part(0, 'specular'), beauty])).toEqual({ part: beauty, channels: ['R', 'G', 'B', 'A'] });
});
it('uses native preference for layered beauty channels and preserves equal-score ordering', () => {
const layered = part(0, 'Main', ['specular.R', 'specular.G', 'specular.B', 'combined.R', 'combined.G', 'combined.B']);
expect(exrDefaultSelection([layered, part(1, 'beauty')])).toEqual({ part: layered, channels: ['combined.R', 'combined.G', 'combined.B'] });
});
it('skips deep parts and prefers main RGB over layered RGB or diagnostic XYZ', () => {
const rgb = part(3, 'Main');
expect(exrDefaultSelection([part(0, 'beauty', undefined, true), part(1, 'normal', ['X', 'Y', 'Z']), part(2, 'specular', ['layer.R', 'layer.G', 'layer.B']), rgb])).toEqual({ part: rgb, channels: ['R', 'G', 'B'] });
expect(exrDefaultSelection([part(0, 'data', ['Z', 'ObjectID'])])?.channels).toEqual(['ObjectID']);
expect(exrDefaultSelection([part(0, 'deep', undefined, true)])).toBeUndefined();
});
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'] });
});
});
37 changes: 37 additions & 0 deletions __tests__/exrPreviewLayers.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import React from 'react';
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, fireEvent, render } from '@testing-library/react';
import { ExrPreviewLayers } from '../components/ExrPreviewLayers';

afterEach(cleanup);
describe('EXR overview/detail compositing', () => {
it('replaces the overview region only after detail pixels load', () => {
const props = { overviewUrl: 'overview', detailUrl: 'detail', region: { x: 10, y: 20, width: 30, height: 40 }, width: 100, height: 100, name: 'synthetic.exr' };
const { container, rerender } = render(<ExrPreviewLayers {...props} />);
const clip = () => container.querySelector('clipPath path')!;
const images = () => container.querySelectorAll('image');
expect(clip().getAttribute('d')).toBe('M0 0H100V100H0Z ');
expect(images()[1].getAttribute('visibility')).toBe('hidden');
fireEvent.load(images()[1]);
expect(clip().getAttribute('clip-rule')).toBe('evenodd');
expect(clip().getAttribute('d')).toBe('M0 0H100V100H0Z M10 20h30v40h-30Z');
expect(images()[1].getAttribute('visibility')).toBe('visible');
rerender(<ExrPreviewLayers {...props} detailUrl="next-detail" />);
expect(clip().getAttribute('d')).toBe('M0 0H100V100H0Z ');
expect(images()[1].getAttribute('visibility')).toBe('hidden');
rerender(<ExrPreviewLayers {...props} detailUrl={undefined} region={null} />);
expect(images()).toHaveLength(1);
expect(clip().getAttribute('d')).toBe('M0 0H100V100H0Z ');
rerender(<ExrPreviewLayers {...props} />);
expect(clip().getAttribute('d')).toBe('M0 0H100V100H0Z ');
expect(images()[1].getAttribute('visibility')).toBe('hidden');
fireEvent.load(images()[1]);
expect(clip().getAttribute('d')).toBe('M0 0H100V100H0Z M10 20h30v40h-30Z');
});
it('uses independent clip definitions for simultaneous viewers', () => {
const props = { overviewUrl: 'overview', width: 100, height: 100, name: 'synthetic.exr' };
const { container } = render(<><ExrPreviewLayers {...props} /><ExrPreviewLayers {...props} /></>);
const clips = container.querySelectorAll('clipPath');
expect(clips[0].id).not.toBe(clips[1].id);
});
});
Loading
Loading