feat: web editor

This commit is contained in:
bwees
2026-01-25 20:44:32 -06:00
parent ae9bb0aa80
commit 8835e54bf4
9 changed files with 379 additions and 22 deletions

View File

@@ -994,6 +994,7 @@
"editor_close_without_save_prompt": "The changes will not be saved",
"editor_close_without_save_title": "Close editor?",
"editor_confirm_reset_all_changes": "Are you sure you want to reset all changes?",
"editor_filters": "Filters",
"editor_flip_horizontal": "Flip horizontal",
"editor_flip_vertical": "Flip vertical",
"editor_orientation": "Orientation",

View File

@@ -408,7 +408,7 @@
) {
return 'ImagePanaramaViewer';
}
if (assetViewerManager.isShowEditor && editManager.selectedTool?.type === EditToolType.Transform) {
if (assetViewerManager.isShowEditor) {
return 'CropArea';
}
return 'PhotoViewer';

View File

@@ -4,7 +4,7 @@
import { websocketEvents } from '$lib/stores/websocket';
import { getAssetEdits, type AssetResponseDto } from '@immich/sdk';
import { Button, HStack, IconButton } from '@immich/ui';
import { mdiClose } from '@mdi/js';
import { mdiClose, mdiCrop, mdiPalette } from '@mdi/js';
import { onDestroy, onMount } from 'svelte';
import { t } from 'svelte-i18n';
@@ -23,11 +23,12 @@
onMount(async () => {
const edits = await getAssetEdits({ id: asset.id });
await editManager.activateTool(EditToolType.Transform, asset, edits);
await editManager.init(asset, edits);
editManager.activateTool(EditToolType.Transform);
});
onDestroy(() => {
editManager.cleanup();
onDestroy(async () => {
await editManager.cleanup();
});
async function applyEdits() {
@@ -65,6 +66,31 @@
<Button shape="round" size="small" onclick={applyEdits} loading={editManager.isApplyingEdits}>{$t('save')}</Button>
</HStack>
<HStack class="mt-4 gap-0 mx-4">
<Button
leadingIcon={mdiCrop}
variant={editManager.selectedTool?.type === EditToolType.Transform ? 'filled' : 'outline'}
onclick={() => editManager.activateTool(EditToolType.Transform)}
class="rounded-r-none"
shape="round"
size="small"
fullWidth
>
Transform
</Button>
<Button
leadingIcon={mdiPalette}
variant={editManager.selectedTool?.type === EditToolType.Filter ? 'filled' : 'outline'}
onclick={() => editManager.activateTool(EditToolType.Filter)}
class="rounded-l-none"
shape="round"
size="small"
fullWidth
>
Filter
</Button>
</HStack>
<section>
{#if editManager.selectedTool}
<editManager.selectedTool.component />

View File

@@ -0,0 +1,51 @@
<script lang="ts">
import { editManager } from '$lib/managers/edit/edit-manager.svelte';
import { filterManager } from '$lib/managers/edit/filter-manager.svelte';
import { getAssetMediaUrl } from '$lib/utils';
import { filters } from '$lib/utils/filters';
import { AssetMediaSize } from '@immich/sdk';
import { Text } from '@immich/ui';
import { t } from 'svelte-i18n';
let asset = $derived(editManager.currentAsset);
</script>
<div class="mt-3 px-4">
<div class="flex h-10 w-full items-center justify-between text-sm mt-2">
<h2>{$t('editor_filters')}</h2>
</div>
<div class="grid grid-cols-3 gap-4 mt-2">
{#if asset}
{#each filters as filter (filter.name)}
{@const isSelected = filterManager.selectedFilter === filter}
<button type="button" onclick={() => filterManager.selectFilter(filter)} class="flex flex-col items-center">
<div class="w-20 h-20 rounded-md overflow-hidden {isSelected ? 'ring-3 ring-immich-primary' : ''}">
<img
src={getAssetMediaUrl({
id: asset.id,
cacheKey: asset.thumbhash,
edited: false,
size: AssetMediaSize.Thumbnail,
})}
alt="{filter.name} thumbnail"
class="w-full h-full object-cover"
style="filter: url(#{filter.cssId})"
/>
</div>
<Text size="small" class="mt-1" color={isSelected ? 'primary' : undefined}>{filter.name}</Text>
</button>
{/each}
{/if}
</div>
<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" style="position:absolute">
<defs>
{#each filters as filter (filter.name)}
<filter id={filter.cssId} color-interpolation-filters="sRGB">
<feColorMatrix type="matrix" values={filter.svgFilter} />
</filter>
{/each}
</defs>
</svg>
</div>

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { filterManager } from '$lib/managers/edit/filter-manager.svelte';
import { transformManager } from '$lib/managers/edit/transform-manager.svelte';
import { getAssetMediaUrl } from '$lib/utils';
import { getAltText } from '$lib/utils/thumbnail-util';
@@ -78,6 +79,14 @@
bind:this={transformManager.overlayEl}
></div>
</button>
<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" style="position:absolute">
<defs>
<filter id="currentFilter" color-interpolation-filters="sRGB">
<feColorMatrix type="matrix" values={filterManager.selectedFilter.svgFilter} />
</filter>
</defs>
</svg>
</div>
<style>
@@ -150,6 +159,7 @@
height: 100%;
user-select: none;
transition: transform 0.15s ease;
filter: url(#currentFilter);
}
.crop-frame {

View File

@@ -1,24 +1,27 @@
import FilterTool from '$lib/components/asset-viewer/editor/filter-tool/filter-tool.svelte';
import TransformTool from '$lib/components/asset-viewer/editor/transform-tool/transform-tool.svelte';
import { filterManager } from '$lib/managers/edit/filter-manager.svelte';
import { transformManager } from '$lib/managers/edit/transform-manager.svelte';
import { waitForWebsocketEvent } from '$lib/stores/websocket';
import { editAsset, removeAssetEdits, type AssetEditsDto, type AssetResponseDto } from '@immich/sdk';
import { ConfirmModal, modalManager, toastManager } from '@immich/ui';
import { mdiCropRotate } from '@mdi/js';
import { ConfirmModal, modalManager, toastManager, type MaybePromise } from '@immich/ui';
import { mdiCropRotate, mdiPalette } from '@mdi/js';
import type { Component } from 'svelte';
export type EditAction = AssetEditsDto['edits'][number];
export type EditActions = EditAction[];
export interface EditToolManager {
onActivate: (asset: AssetResponseDto, edits: EditActions) => Promise<void>;
onDeactivate: () => void;
resetAllChanges: () => Promise<void>;
onActivate: (asset: AssetResponseDto, edits: EditActions) => MaybePromise<void>;
onDeactivate: () => MaybePromise<void>;
resetAllChanges: () => MaybePromise<void>;
hasChanges: boolean;
edits: EditAction[];
}
export enum EditToolType {
Transform = 'transform',
Filter = 'filter',
}
export interface EditTool {
@@ -36,6 +39,12 @@ export class EditManager {
component: TransformTool,
manager: transformManager,
},
{
type: EditToolType.Filter,
icon: mdiPalette,
component: FilterTool,
manager: filterManager,
},
];
currentAsset = $state<AssetResponseDto | null>(null);
@@ -69,32 +78,32 @@ export class EditManager {
return confirmed;
}
reset() {
async reset() {
for (const tool of this.tools) {
tool.manager.onDeactivate?.();
await tool.manager.onDeactivate?.();
}
this.selectedTool = this.tools[0];
}
async activateTool(toolType: EditToolType, asset: AssetResponseDto, edits: AssetEditsDto) {
this.hasAppliedEdits = false;
if (this.selectedTool?.type === toolType) {
return;
}
async init(asset: AssetResponseDto, edits: AssetEditsDto) {
this.currentAsset = asset;
this.selectedTool?.manager.onDeactivate?.();
for (const tool of this.tools) {
await tool.manager.onActivate?.(asset, edits.edits);
}
this.selectedTool = this.tools[0];
}
activateTool(toolType: EditToolType) {
const newTool = this.tools.find((t) => t.type === toolType);
if (newTool) {
this.selectedTool = newTool;
await newTool.manager.onActivate?.(asset, edits.edits);
}
}
cleanup() {
async cleanup() {
for (const tool of this.tools) {
tool.manager.onDeactivate?.();
await tool.manager.onDeactivate?.();
}
this.currentAsset = null;
this.selectedTool = null;

View File

@@ -0,0 +1,42 @@
import type { EditActions, EditToolManager } from '$lib/managers/edit/edit-manager.svelte';
import { EditFilter, filters } from '$lib/utils/filters';
import { AssetEditAction, type AssetEditActionFilter, type AssetResponseDto, type FilterParameters } from '@immich/sdk';
class FilterManager implements EditToolManager {
selectedFilter: EditFilter = $state(filters[0]);
hasChanges = $derived(!this.selectedFilter.isIdentity);
edits = $derived<EditActions>(
this.hasChanges
? [
{
action: AssetEditAction.Filter,
parameters: this.selectedFilter.dtoParameters,
} as AssetEditActionFilter,
]
: [],
);
resetAllChanges() {
this.selectedFilter = filters[0];
}
onActivate(asset: AssetResponseDto, edits: EditActions) {
const filterEdits = edits.filter((edit) => edit.action === AssetEditAction.Filter);
if (filterEdits.length > 0) {
const dtoFilter = EditFilter.fromDto(filterEdits[0].parameters as FilterParameters, 'Custom');
this.selectedFilter = filters.find((filter) => filter.equals(dtoFilter)) ?? filters[0];
}
}
onDeactivate() {
this.resetAllChanges();
}
selectFilter(filter: EditFilter) {
this.selectedFilter = filter;
console.log('Selected filter:', filter);
}
}
export const filterManager = new FilterManager();

View File

@@ -0,0 +1,218 @@
import type { FilterParameters } from '@immich/sdk';
export class EditFilter {
name: string;
rrBias: number;
rgBias: number;
rbBias: number;
grBias: number;
ggBias: number;
gbBias: number;
brBias: number;
bgBias: number;
bbBias: number;
rOffset: number;
gOffset: number;
bOffset: number;
static identity = new EditFilter('Normal', 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0);
constructor(
name: string,
rrBias: number,
rgBias: number,
rbBias: number,
grBias: number,
ggBias: number,
gbBias: number,
brBias: number,
bgBias: number,
bbBias: number,
rOffset: number,
gOffset: number,
bOffset: number,
) {
this.name = name;
this.rrBias = rrBias;
this.rgBias = rgBias;
this.rbBias = rbBias;
this.grBias = grBias;
this.ggBias = ggBias;
this.gbBias = gbBias;
this.brBias = brBias;
this.bgBias = bgBias;
this.bbBias = bbBias;
this.rOffset = rOffset;
this.gOffset = gOffset;
this.bOffset = bOffset;
}
get dtoParameters(): FilterParameters {
return {
rrBias: this.rrBias,
rgBias: this.rgBias,
rbBias: this.rbBias,
grBias: this.grBias,
ggBias: this.ggBias,
gbBias: this.gbBias,
brBias: this.brBias,
bgBias: this.bgBias,
bbBias: this.bbBias,
rOffset: this.rOffset,
gOffset: this.gOffset,
bOffset: this.bOffset,
};
}
get svgFilter(): string {
return `
${this.rrBias} ${this.rgBias} ${this.rbBias} 0 ${this.rOffset}
${this.grBias} ${this.ggBias} ${this.gbBias} 0 ${this.gOffset}
${this.brBias} ${this.bgBias} ${this.bbBias} 0 ${this.bOffset}
0 0 0 1 0
`;
}
static fromDto(params: FilterParameters, name: string): EditFilter {
return new EditFilter(
name,
params.rrBias,
params.rgBias,
params.rbBias,
params.grBias,
params.ggBias,
params.gbBias,
params.brBias,
params.bgBias,
params.bbBias,
params.rOffset,
params.gOffset,
params.bOffset,
);
}
static fromMatrix(matrix: number[], name: string): EditFilter {
return new EditFilter(
name,
matrix[0],
matrix[1],
matrix[2],
matrix[5],
matrix[6],
matrix[7],
matrix[10],
matrix[11],
matrix[12],
matrix[15],
matrix[16],
matrix[17],
);
}
get isIdentity(): boolean {
return this.equals(EditFilter.identity);
}
equals(other: EditFilter): boolean {
return (
this.rrBias === other.rrBias &&
this.rgBias === other.rgBias &&
this.rbBias === other.rbBias &&
this.grBias === other.grBias &&
this.ggBias === other.ggBias &&
this.gbBias === other.gbBias &&
this.brBias === other.brBias &&
this.bgBias === other.bgBias &&
this.bbBias === other.bbBias &&
this.rOffset === other.rOffset &&
this.gOffset === other.gOffset &&
this.bOffset === other.bOffset
);
}
get cssId(): string {
return this.name.toLowerCase().replaceAll(/\s+/g, '-');
}
}
export const filters: EditFilter[] = [
//Original
EditFilter.fromMatrix([1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0], 'Original'),
//Vintage
EditFilter.fromMatrix([0.8, 0.1, 0.1, 0, 20, 0.1, 0.8, 0.1, 0, 20, 0.1, 0.1, 0.8, 0, 20, 0, 0, 0, 1, 0], 'Vintage'),
//Mood
EditFilter.fromMatrix([1.2, 0.1, 0.1, 0, 10, 0.1, 1, 0.1, 0, 10, 0.1, 0.1, 1, 0, 10, 0, 0, 0, 1, 0], 'Mood'),
//Crisp
EditFilter.fromMatrix([1.2, 0, 0, 0, 0, 0, 1.2, 0, 0, 0, 0, 0, 1.2, 0, 0, 0, 0, 0, 1, 0], 'Crisp'),
//Cool
EditFilter.fromMatrix([0.9, 0, 0.2, 0, 0, 0, 1, 0.1, 0, 0, 0.1, 0, 1.2, 0, 0, 0, 0, 0, 1, 0], 'Cool'),
//Blush
EditFilter.fromMatrix([1.1, 0.1, 0.1, 0, 10, 0.1, 1, 0.1, 0, 10, 0.1, 0.1, 1, 0, 5, 0, 0, 0, 1, 0], 'Blush'),
//Sunkissed
EditFilter.fromMatrix([1.3, 0, 0.1, 0, 15, 0, 1.1, 0.1, 0, 10, 0, 0, 0.9, 0, 5, 0, 0, 0, 1, 0], 'Sunkissed'),
//Fresh
EditFilter.fromMatrix([1.2, 0, 0, 0, 20, 0, 1.2, 0, 0, 20, 0, 0, 1.1, 0, 20, 0, 0, 0, 1, 0], 'Fresh'),
//Classic
EditFilter.fromMatrix([1.1, 0, -0.1, 0, 10, -0.1, 1.1, 0.1, 0, 5, 0, -0.1, 1.1, 0, 0, 0, 0, 0, 1, 0], 'Classic'),
//Lomo-ish
EditFilter.fromMatrix([1.5, 0, 0.1, 0, 0, 0, 1.45, 0, 0, 0, 0.1, 0, 1.3, 0, 0, 0, 0, 0, 1, 0], 'Lomo-ish'),
//Nashville
EditFilter.fromMatrix(
[1.2, 0.15, -0.15, 0, 15, 0.1, 1.1, 0.1, 0, 10, -0.05, 0.2, 1.25, 0, 5, 0, 0, 0, 1, 0],
'Nashville',
),
//Valencia
EditFilter.fromMatrix([1.15, 0.1, 0.1, 0, 20, 0.1, 1.1, 0, 0, 10, 0.1, 0.1, 1.2, 0, 5, 0, 0, 0, 1, 0], 'Valencia'),
//Clarendon
EditFilter.fromMatrix([1.2, 0, 0, 0, 10, 0, 1.25, 0, 0, 10, 0, 0, 1.3, 0, 10, 0, 0, 0, 1, 0], 'Clarendon'),
//Moon
EditFilter.fromMatrix(
[0.33, 0.33, 0.33, 0, 0, 0.33, 0.33, 0.33, 0, 0, 0.33, 0.33, 0.33, 0, 0, 0, 0, 0, 1, 0],
'Moon',
),
//Willow
EditFilter.fromMatrix([0.5, 0.5, 0.5, 0, 20, 0.5, 0.5, 0.5, 0, 20, 0.5, 0.5, 0.5, 0, 20, 0, 0, 0, 1, 0], 'Willow'),
//Kodak
EditFilter.fromMatrix([1.3, 0.1, -0.1, 0, 10, 0, 1.25, 0.1, 0, 10, 0, -0.1, 1.1, 0, 5, 0, 0, 0, 1, 0], 'Kodak'),
//Sunset
EditFilter.fromMatrix([1.5, 0.2, 0, 0, 0, 0.1, 0.9, 0.1, 0, 0, -0.1, -0.2, 1.3, 0, 0, 0, 0, 0, 1, 0], 'Sunset'),
//Noir
EditFilter.fromMatrix([1.3, -0.3, 0.1, 0, 0, -0.1, 1.2, -0.1, 0, 0, 0.1, -0.2, 1.3, 0, 0, 0, 0, 0, 1, 0], 'Noir'),
//Dreamy
EditFilter.fromMatrix([1.1, 0.1, 0.1, 0, 0, 0.1, 1.1, 0.1, 0, 0, 0.1, 0.1, 1.1, 0, 15, 0, 0, 0, 1, 0], 'Dreamy'),
//Sepia
EditFilter.fromMatrix(
[0.393, 0.769, 0.189, 0, 0, 0.349, 0.686, 0.168, 0, 0, 0.272, 0.534, 0.131, 0, 0, 0, 0, 0, 1, 0],
'Sepia',
),
//Radium
EditFilter.fromMatrix(
[1.438, -0.062, -0.062, 0, 0, -0.122, 1.378, -0.122, 0, 0, -0.016, -0.016, 1.483, 0, 0, 0, 0, 0, 1, 0],
'Radium',
),
//Aqua
EditFilter.fromMatrix(
[0.2126, 0.7152, 0.0722, 0, 0, 0.2126, 0.7152, 0.0722, 0, 0, 0.7873, 0.2848, 0.9278, 0, 0, 0, 0, 0, 1, 0],
'Aqua',
),
//Purple Haze
EditFilter.fromMatrix([1.3, 0, 1.2, 0, 0, 0, 1.1, 0, 0, 0, 0.2, 0, 1.3, 0, 0, 0, 0, 0, 1, 0], 'Purple Haze'),
//Lemonade
EditFilter.fromMatrix([1.2, 0.1, 0, 0, 0, 0, 1.1, 0.2, 0, 0, 0.1, 0, 0.7, 0, 0, 0, 0, 0, 1, 0], 'Lemonade'),
//Caramel
EditFilter.fromMatrix([1.6, 0.2, 0, 0, 0, 0.1, 1.3, 0.1, 0, 0, 0, 0.1, 0.9, 0, 0, 0, 0, 0, 1, 0], 'Caramel'),
//Peachy
EditFilter.fromMatrix([1.3, 0.5, 0, 0, 0, 0.2, 1.1, 0.3, 0, 0, 0.1, 0.1, 1.2, 0, 0, 0, 0, 0, 1, 0], 'Peachy'),
//Neon
EditFilter.fromMatrix([1, 0, 1, 0, 0, 0, 2, 0, 0, 0, 0, 0, 3, 0, 0, 0, 0, 0, 1, 0], 'Neon'),
//Cold Morning
EditFilter.fromMatrix([0.9, 0.1, 0.2, 0, 0, 0, 1, 0.1, 0, 0, 0.1, 0, 1.2, 0, 0, 0, 0, 0, 1, 0], 'Cold Morning'),
//Lush
EditFilter.fromMatrix([0.9, 0.2, 0, 0, 0, 0, 1.2, 0, 0, 0, 0, 0, 1.1, 0, 0, 0, 0, 0, 1, 0], 'Lush'),
//Urban Neon
EditFilter.fromMatrix([1.1, 0, 0.3, 0, 0, 0, 0.9, 0.3, 0, 0, 0.3, 0.1, 1.2, 0, 0, 0, 0, 0, 1, 0], 'Urban Neon'),
//Monochrome
EditFilter.fromMatrix([0.6, 0.2, 0.2, 0, 0, 0.2, 0.6, 0.2, 0, 0, 0.2, 0.2, 0.7, 0, 0, 0, 0, 0, 1, 0], 'Monochrome'),
];