Skip to content

Commit aad3ede

Browse files
committed
experiment: Implement new raw/jpeg grouping
1 parent fa26a59 commit aad3ede

9 files changed

Lines changed: 367 additions & 30 deletions

File tree

src-tauri/src/file_management.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,10 @@ pub struct AppSettings {
260260
pub processing_backend: Option<String>,
261261
#[serde(default)]
262262
pub linux_gpu_optimization: Option<bool>,
263+
#[serde(default)]
264+
pub group_associated_files: Option<bool>,
265+
#[serde(default)]
266+
pub preferred_associated_type: Option<String>,
263267
}
264268

265269
fn default_adjustment_visibility() -> HashMap<String, bool> {
@@ -309,6 +313,8 @@ impl Default for AppSettings {
309313
linux_gpu_optimization: Some(true),
310314
#[cfg(not(target_os = "linux"))]
311315
linux_gpu_optimization: Some(false),
316+
group_associated_files: Some(true),
317+
preferred_associated_type: Some("jpeg".to_string()),
312318
}
313319
}
314320
}

src/App.tsx

Lines changed: 197 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ import {
8989
} from './components/panel/right/ExportImportProperties';
9090
import {
9191
AppSettings,
92+
AssociationInfo,
93+
AssociatedPrimaryPreference,
9294
BrushSettings,
9395
FilterCriteria,
9496
Invokes,
@@ -115,6 +117,103 @@ import { ChannelConfig } from './components/adjustments/Curves';
115117

116118
const CLERK_PUBLISHABLE_KEY = 'pk_test_YnJpZWYtc2Vhc25haWwtMTIuY2xlcmsuYWNjb3VudHMuZGV2JA'; // local dev key
117119

120+
const JPEG_EXTENSIONS = new Set(['jpg', 'jpeg']);
121+
122+
type LibraryImageFile = ImageFile & { associatedPaths?: Array<string> };
123+
124+
const getFileExtension = (path: string) => {
125+
const normalized = path.toLowerCase();
126+
const lastDot = normalized.lastIndexOf('.');
127+
if (lastDot === -1) {
128+
return '';
129+
}
130+
return normalized.substring(lastDot + 1);
131+
};
132+
133+
const getStemKey = (path: string) => {
134+
const normalized = path.replace(/\\/g, '/');
135+
const lastDot = normalized.lastIndexOf('.');
136+
if (lastDot === -1) {
137+
return normalized;
138+
}
139+
return normalized.substring(0, lastDot);
140+
};
141+
142+
const buildAssociations = (
143+
images: Array<ImageFile>,
144+
supportedTypes: SupportedTypes | null,
145+
preferredType: AssociatedPrimaryPreference,
146+
) => {
147+
const associationMap: Record<string, AssociationInfo> = {};
148+
const groupedList: Array<LibraryImageFile> = [];
149+
const groups = new Map<
150+
string,
151+
{ variants: Array<ImageFile>; jpeg?: ImageFile; raw?: ImageFile }
152+
>();
153+
const rawExtensions = new Set((supportedTypes?.raw || []).map((ext) => ext.toLowerCase()));
154+
155+
images.forEach((image) => {
156+
const stem = getStemKey(image.path);
157+
let bucket = groups.get(stem);
158+
if (!bucket) {
159+
bucket = { variants: [] };
160+
groups.set(stem, bucket);
161+
}
162+
bucket.variants.push(image);
163+
const ext = getFileExtension(image.path);
164+
if (JPEG_EXTENSIONS.has(ext)) {
165+
bucket.jpeg = image;
166+
} else if (rawExtensions.has(ext) && !bucket.raw) {
167+
bucket.raw = image;
168+
}
169+
});
170+
171+
groups.forEach((bucket) => {
172+
if (bucket.variants.length === 0) {
173+
return;
174+
}
175+
const pickPrimary = () => {
176+
const jpegCandidate = bucket.jpeg;
177+
const rawCandidate = bucket.raw;
178+
const fallback = bucket.variants[0];
179+
switch (preferredType) {
180+
case 'raw':
181+
return rawCandidate || jpegCandidate || fallback;
182+
case 'jpeg':
183+
return jpegCandidate || rawCandidate || fallback;
184+
case 'auto':
185+
default:
186+
return jpegCandidate || rawCandidate || fallback;
187+
}
188+
};
189+
const primary = pickPrimary();
190+
const variantPaths = bucket.variants.map((variant) => variant.path);
191+
const info: AssociationInfo = {
192+
primaryPath: primary.path,
193+
variantPaths,
194+
jpegPath: bucket.jpeg?.path,
195+
rawPath: bucket.raw?.path,
196+
};
197+
bucket.variants.forEach((variant) => {
198+
associationMap[variant.path] = info;
199+
});
200+
groupedList.push({ ...primary, associatedPaths: variantPaths });
201+
});
202+
203+
return { associationMap, groupedList };
204+
};
205+
206+
const getVariantLabel = (path: string, supportedTypes: SupportedTypes | null) => {
207+
const ext = getFileExtension(path);
208+
if (JPEG_EXTENSIONS.has(ext)) {
209+
return 'JPEG';
210+
}
211+
if (supportedTypes?.raw?.some((rawExt) => rawExt.toLowerCase() === ext)) {
212+
return 'RAW';
213+
}
214+
return ext ? ext.toUpperCase() : 'FILE';
215+
};
216+
118217
interface CollapsibleSectionsState {
119218
basic: boolean;
120219
color: boolean;
@@ -372,11 +471,25 @@ function App() {
372471
progress: { current: 0, total: 0 },
373472
status: Status.Idle,
374473
});
474+
const [groupAssociatedFiles, setGroupAssociatedFiles] = useState(true);
475+
const [preferredAssociatedType, setPreferredAssociatedType] = useState<AssociatedPrimaryPreference>('jpeg');
375476

376477
useEffect(() => {
377478
currentFolderPathRef.current = currentFolderPath;
378479
}, [currentFolderPath]);
379480

481+
const associationData = useMemo(
482+
() => buildAssociations(imageList, supportedTypes, preferredAssociatedType),
483+
[imageList, supportedTypes, preferredAssociatedType],
484+
);
485+
const associationsByPath = associationData.associationMap;
486+
const groupedImageList = associationData.groupedList;
487+
const isGroupingEnabled = groupAssociatedFiles && filterCriteria.rawStatus === RawStatus.All;
488+
const visibleImageList = useMemo(
489+
() => (isGroupingEnabled ? groupedImageList : imageList),
490+
[isGroupingEnabled, groupedImageList, imageList],
491+
);
492+
380493
useEffect(() => {
381494
if (!isCopied) {
382495
return;
@@ -818,7 +931,7 @@ function App() {
818931
};
819932

820933
const sortedImageList = useMemo(() => {
821-
const filteredList = imageList.filter((image) => {
934+
const filteredList = visibleImageList.filter((image) => {
822935
if (filterCriteria.rating > 0) {
823936
const rating = imageRatings[image.path] || 0;
824937
if (filterCriteria.rating === 5) {
@@ -972,7 +1085,25 @@ function App() {
9721085
return order === SortDirection.Ascending ? comparison : -comparison;
9731086
});
9741087
return list;
975-
}, [imageList, sortCriteria, imageRatings, filterCriteria, supportedTypes, searchQuery, appSettings]);
1088+
}, [visibleImageList, sortCriteria, imageRatings, filterCriteria, supportedTypes, searchQuery, appSettings]);
1089+
1090+
const variantOptions = useMemo(() => {
1091+
if (!selectedImage) {
1092+
return [];
1093+
}
1094+
const association = associationsByPath[selectedImage.path];
1095+
if (!association || association.variantPaths.length < 2) {
1096+
return [];
1097+
}
1098+
return association.variantPaths.map((path) => ({
1099+
path,
1100+
label: getVariantLabel(path, supportedTypes),
1101+
}));
1102+
}, [selectedImage?.path, associationsByPath, supportedTypes]);
1103+
1104+
const filmstripActivePath = selectedImage
1105+
? associationsByPath[selectedImage.path]?.primaryPath || selectedImage.path
1106+
: null;
9761107

9771108
const applyAdjustments = useCallback(
9781109
debounce((currentAdjustments) => {
@@ -1140,6 +1271,16 @@ function App() {
11401271
if (settings?.thumbnailAspectRatio) {
11411272
setThumbnailAspectRatio(settings.thumbnailAspectRatio);
11421273
}
1274+
if (settings?.groupAssociatedFiles !== undefined) {
1275+
setGroupAssociatedFiles(settings.groupAssociatedFiles);
1276+
} else {
1277+
setGroupAssociatedFiles(true);
1278+
}
1279+
if (settings?.preferredAssociatedType) {
1280+
setPreferredAssociatedType(settings.preferredAssociatedType);
1281+
} else {
1282+
setPreferredAssociatedType('jpeg');
1283+
}
11431284
if (settings?.activeTreeSection) {
11441285
setActiveTreeSection(settings.activeTreeSection);
11451286
}
@@ -1174,6 +1315,14 @@ function App() {
11741315
setIsWaveformVisible((prev: boolean) => !prev);
11751316
}, []);
11761317

1318+
const handleGroupAssociationsChange = useCallback((value: boolean) => {
1319+
setGroupAssociatedFiles(value);
1320+
}, []);
1321+
1322+
const handlePreferredAssociatedTypeChange = useCallback((value: AssociatedPrimaryPreference) => {
1323+
setPreferredAssociatedType(value);
1324+
}, []);
1325+
11771326
useEffect(() => {
11781327
if (isInitialMount.current || !appSettings) {
11791328
return;
@@ -1216,6 +1365,24 @@ function App() {
12161365
}
12171366
}, [filterCriteria, appSettings, handleSettingsChange]);
12181367

1368+
useEffect(() => {
1369+
if (isInitialMount.current || !appSettings) {
1370+
return;
1371+
}
1372+
if (appSettings.groupAssociatedFiles !== groupAssociatedFiles) {
1373+
handleSettingsChange({ ...appSettings, groupAssociatedFiles });
1374+
}
1375+
}, [groupAssociatedFiles, appSettings, handleSettingsChange]);
1376+
1377+
useEffect(() => {
1378+
if (isInitialMount.current || !appSettings) {
1379+
return;
1380+
}
1381+
if (appSettings.preferredAssociatedType !== preferredAssociatedType) {
1382+
handleSettingsChange({ ...appSettings, preferredAssociatedType });
1383+
}
1384+
}, [preferredAssociatedType, appSettings, handleSettingsChange]);
1385+
12191386
useEffect(() => {
12201387
if (appSettings?.adaptiveEditorTheme && selectedImage && finalPreviewUrl) {
12211388
generatePaletteFromImage(finalPreviewUrl)
@@ -1467,6 +1634,10 @@ function App() {
14671634

14681635
const handleBackToLibrary = useCallback(() => {
14691636
const lastActivePath = selectedImage?.path ?? null;
1637+
const primaryPath =
1638+
lastActivePath && associationsByPath[lastActivePath]
1639+
? associationsByPath[lastActivePath].primaryPath
1640+
: lastActivePath;
14701641
setSelectedImage(null);
14711642
setFinalPreviewUrl(null);
14721643
setUncroppedAdjustedPreviewUrl(null);
@@ -1477,8 +1648,9 @@ function App() {
14771648
setActiveMaskContainerId(null);
14781649
setActiveAiPatchContainerId(null);
14791650
setActiveAiSubMaskId(null);
1480-
setLibraryActivePath(lastActivePath);
1481-
}, [selectedImage?.path]);
1651+
setLibraryActivePath(primaryPath);
1652+
setMultiSelectedPaths(primaryPath ? [primaryPath] : []);
1653+
}, [associationsByPath, selectedImage?.path]);
14821654

14831655
const executeDelete = useCallback(
14841656
async (pathsToDelete: Array<string>, options = { includeAssociated: false }) => {
@@ -1970,6 +2142,16 @@ function App() {
19702142
[selectedImage?.path, applyAdjustments, debouncedSave, thumbnails, resetAdjustmentsHistory],
19712143
);
19722144

2145+
const handleVariantSelect = useCallback(
2146+
(path: string) => {
2147+
if (selectedImage?.path === path) {
2148+
return;
2149+
}
2150+
handleImageSelect(path);
2151+
},
2152+
[handleImageSelect, selectedImage?.path],
2153+
);
2154+
19732155
useKeyboardShortcuts({
19742156
activeAiPatchContainerId,
19752157
activeAiSubMaskId,
@@ -2000,6 +2182,7 @@ function App() {
20002182
multiSelectedPaths,
20012183
redo,
20022184
selectedImage,
2185+
selectedDisplayPath: filmstripActivePath,
20032186
setActiveAiSubMaskId,
20042187
setActiveMaskContainerId,
20052188
setActiveMaskId,
@@ -2807,12 +2990,8 @@ function App() {
28072990
const stitchLabel = `Stitch Panorama`;
28082991

28092992
const hasAssociatedFiles = finalSelection.some((selectedPath) => {
2810-
const lastDotIndex = selectedPath.lastIndexOf('.');
2811-
if (lastDotIndex === -1) return false;
2812-
const basePath = selectedPath.substring(0, lastDotIndex);
2813-
return imageList.some(
2814-
(image) => image.path.startsWith(basePath + '.') && image.path !== selectedPath,
2815-
);
2993+
const association = associationsByPath[selectedPath];
2994+
return association && association.variantPaths.length > 1;
28162995
});
28172996

28182997
const deleteOption = {
@@ -3301,12 +3480,15 @@ function App() {
33013480
isFullResolution={isFullResolution}
33023481
fullResolutionUrl={fullResolutionUrl}
33033482
isLoadingFullRes={isLoadingFullRes}
3483+
variantOptions={variantOptions}
3484+
onVariantSelect={handleVariantSelect}
33043485
/>
33053486
<Resizer
33063487
direction={Orientation.Horizontal}
33073488
onMouseDown={createResizeHandler(setBottomPanelHeight, bottomPanelHeight)}
33083489
/>
33093490
<BottomBar
3491+
activeDisplayPath={filmstripActivePath}
33103492
filmstripHeight={bottomPanelHeight}
33113493
imageList={sortedImageList}
33123494
imageRatings={imageRatings}
@@ -3520,6 +3702,10 @@ function App() {
35203702
thumbnails={thumbnails}
35213703
thumbnailSize={thumbnailSize}
35223704
onNavigateToCommunity={() => setActiveView('community')}
3705+
groupAssociatedFiles={groupAssociatedFiles}
3706+
preferredAssociatedType={preferredAssociatedType}
3707+
onGroupAssociationsChange={handleGroupAssociationsChange}
3708+
onPreferredAssociationTypeChange={handlePreferredAssociatedTypeChange}
35233709
/>
35243710
)}
35253711
{rootPath && (
@@ -3714,4 +3900,4 @@ const AppWrapper = () => (
37143900
</ClerkProvider>
37153901
);
37163902

3717-
export default AppWrapper;
3903+
export default AppWrapper;

src/components/panel/BottomBar.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Filmstrip from './Filmstrip';
55
import { GLOBAL_KEYS, ImageFile, SelectedImage, ThumbnailAspectRatio } from '../ui/AppProperties';
66

77
interface BottomBarProps {
8+
activeDisplayPath?: string | null;
89
filmstripHeight?: number;
910
imageList?: Array<ImageFile>;
1011
imageRatings?: Record<string, number> | null;
@@ -79,6 +80,7 @@ const StarRating = ({ rating, onRate, disabled }: StarRatingProps) => {
7980
};
8081

8182
export default function BottomBar({
83+
activeDisplayPath,
8284
filmstripHeight,
8385
imageList = [],
8486
imageRatings,
@@ -220,7 +222,7 @@ export default function BottomBar({
220222
onClearSelection={onClearSelection}
221223
onContextMenu={onContextMenu}
222224
onImageSelect={onImageSelect}
223-
selectedImage={selectedImage}
225+
activePath={activeDisplayPath || selectedImage?.path || null}
224226
thumbnails={thumbnails}
225227
thumbnailAspectRatio={thumbnailAspectRatio}
226228
/>
@@ -356,4 +358,4 @@ export default function BottomBar({
356358
</div>
357359
</div>
358360
);
359-
}
361+
}

0 commit comments

Comments
 (0)