From 6326191189a79bc3fd2c743c839acb295dcba736 Mon Sep 17 00:00:00 2001
From: spd789562 <leo.yicun.lin@gmail.com>
Date: Sat, 24 Aug 2024 12:08:59 +0800
Subject: [PATCH 01/25] [edit] fix some item not have info or islot and vslot

---
 src/renderer/character/item.ts | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/src/renderer/character/item.ts b/src/renderer/character/item.ts
index 8da1c9c..6cfa2a4 100644
--- a/src/renderer/character/item.ts
+++ b/src/renderer/character/item.ts
@@ -206,8 +206,9 @@ export class CharacterItem implements RenderItemInfo {
       return;
     }
 
-    this.islot = (this.wz.info.islot.match(/.{1,2}/g) || []) as PieceIslot[];
-    this.vslot = (this.wz.info.vslot.match(/.{1,2}/g) || []) as PieceIslot[];
+    /* some item will not have info, WTF? */
+    this.islot = (this.wz.info?.islot?.match(/.{1,2}/g) || []) as PieceIslot[];
+    this.vslot = (this.wz.info?.vslot?.match(/.{1,2}/g) || []) as PieceIslot[];
 
     /* resolve dye */
     if (this.isFace && this.avaliableDye.size === 0) {

From 14105e4982a382994e2a508990af36287217207d Mon Sep 17 00:00:00 2001
From: spd789562 <leo.yicun.lin@gmail.com>
Date: Sun, 25 Aug 2024 19:29:16 +0800
Subject: [PATCH 02/25] [edit] fix isDyeable filter infacting other category

---
 src/store/equipDrawer.ts | 46 ++++++++++++++++++----------------------
 1 file changed, 21 insertions(+), 25 deletions(-)

diff --git a/src/store/equipDrawer.ts b/src/store/equipDrawer.ts
index 89a660d..8f0deb3 100644
--- a/src/store/equipDrawer.ts
+++ b/src/store/equipDrawer.ts
@@ -91,8 +91,9 @@ export const $categoryFilteredString = computed(
     $equipmentDrawerEquipTab,
     $currentEquipmentDrawerCategory,
     $equipmentStrings,
+    $equipmentDrawerOnlyShowDyeable,
   ],
-  (tab, category, strings) => {
+  (tab, category, strings, onlyShowDyeable) => {
     if (tab === EquipTab.History) {
       /* not subscribe $equipmentHistory here */
       return $equipmentHistory.get();
@@ -116,41 +117,36 @@ export const $categoryFilteredString = computed(
       });
     }
 
-    if (category === AllCategory) {
-      return strings;
+    let filteredStrings = strings;
+
+    if (category !== AllCategory) {
+      const mainCategory = getCategoryBySubCategory(category);
+      filteredStrings = strings.filter((item) => {
+        if (item.category === mainCategory) {
+          return getSubCategory(item.id) === category;
+        }
+        return false;
+      });
     }
 
-    const mainCategory = getCategoryBySubCategory(category);
-    return strings.filter((item) => {
-      if (item.category === mainCategory) {
-        return getSubCategory(item.id) === category;
-      }
-      return false;
-    });
+    if (!onlyShowDyeable) {
+      return filteredStrings;
+    }
+
+    return strings.filter(({ isDyeable }) => isDyeable);
   },
 );
 
 export const $equipmentDrawerEquipFilteredString = computed(
-  [
-    $categoryFilteredString,
-    $currentEquipmentDrawerSearch,
-    $equipmentDrawerOnlyShowDyeable,
-  ],
-  (strings, searchKey, onlyShowDyeable) => {
+  [$categoryFilteredString, $currentEquipmentDrawerSearch],
+  (strings, searchKey) => {
     if (searchKey) {
       return strings.filter((item) => {
         const isMatch =
           item.name.includes(searchKey) || item.id.toString() === searchKey;
-        if (!onlyShowDyeable) {
-          return isMatch;
-        }
-        return isMatch && item.isDyeable;
+        return isMatch;
       });
     }
-    if (!onlyShowDyeable) {
-      return strings;
-    }
-
-    return strings.filter(({ isDyeable }) => isDyeable);
+    return strings;
   },
 );

From d26c718106562c5e462e0f965a1a198824472d01 Mon Sep 17 00:00:00 2001
From: spd789562 <leo.yicun.lin@gmail.com>
Date: Sun, 25 Aug 2024 19:29:40 +0800
Subject: [PATCH 03/25] [edit] try to force weird shoe locks

---
 src/renderer/character/character.ts | 102 ++++++++++------------------
 src/renderer/character/item.ts      |   6 ++
 2 files changed, 42 insertions(+), 66 deletions(-)

diff --git a/src/renderer/character/character.ts b/src/renderer/character/character.ts
index f75c988..741dbe1 100644
--- a/src/renderer/character/character.ts
+++ b/src/renderer/character/character.ts
@@ -5,10 +5,7 @@ import type { CharacterData } from '@/store/character/store';
 import type { ItemInfo, AncherName, Vec2, PieceSlot } from './const/data';
 import type { CategorizedItem, CharacterActionItem } from './categorizedItem';
 import type { CharacterAnimatablePart } from './characterAnimatablePart';
-import type {
-  CharacterItemPiece,
-  DyeableCharacterItemPiece,
-} from './itemPiece';
+import type { CharacterItemPiece, DyeableCharacterItemPiece } from './itemPiece';
 
 import { CharacterLoader } from './loader';
 import { CharacterItem } from './item';
@@ -44,8 +41,7 @@ class ZmapContainer extends Container {
     } else if (this.name === 'pants' || this.name === 'backPants') {
       this.requireLocks = ['Pn'];
     } else {
-      this.requireLocks =
-        (CharacterLoader.smap?.[name] || '').match(/.{1,2}/g) || [];
+      this.requireLocks = (CharacterLoader.smap?.[name] || '').match(/.{1,2}/g) || [];
     }
   }
   addCharacterPart(child: CharacterAnimatablePart) {
@@ -70,13 +66,14 @@ class ZmapContainer extends Container {
       //     ? frame.item.vslot
       //     : this.requireLocks;
       let locks = this.requireLocks;
+      let itemMainLocks = part.item.islot; // something like Ma, Pn,Cp
 
       // force Cap using vslot
-      if (part.item.islot.includes('Cp')) {
+      if (itemMainLocks.includes('Cp')) {
         locks = part.item.vslot;
       } else if (
-        part.item.islot.length === 1 &&
-        part.item.islot[0] === 'Hd' &&
+        itemMainLocks.length === 1 &&
+        itemMainLocks[0] === 'Hd' &&
         (this.name === 'accessoryOverHair' || this.name === 'hairShade')
       ) {
         /* try to fix ear rendering */
@@ -86,6 +83,8 @@ class ZmapContainer extends Container {
       // this logic is from maplestory.js, but why
       if (this.name === 'mailChest') {
         locks = part.item.vslot;
+      } else if (this.name === 'shoes') {
+        itemMainLocks = ['So'];
       }
 
       const hasSelfLock = this.hasAllLocks(part.item.info.id, part.item.vslot);
@@ -185,9 +184,7 @@ export class Character extends Container {
     }
     this.isAnimating = characterData.isAnimating;
     const hasAttributeChanged = this.updateAttribute(characterData);
-    const hasAddAnyItem = await this.updateItems(
-      Object.values(characterData.items),
-    );
+    const hasAddAnyItem = await this.updateItems(Object.values(characterData.items));
     if (hasAttributeChanged || hasAddAnyItem || isStopToPlay) {
       await this.loadItems();
     } else if (isPlayingChanged) {
@@ -255,17 +252,13 @@ export class Character extends Container {
       return;
     }
     item.info = Object.assign({}, info, {
-      dye: (info as unknown as ItemInfo & { isDeleteDye: boolean }).isDeleteDye
-        ? undefined
-        : info.dye,
+      dye: (info as unknown as ItemInfo & { isDeleteDye: boolean }).isDeleteDye ? undefined : info.dye,
     });
     /* only update sprite already in render */
     const dyeableSprites = this.currentAllItem
       .flatMap((item) => Array.from(item.allPieces))
       .filter((piece) => piece.item.info.id === id)
-      .flatMap((piece) =>
-        piece.frames.filter((frame) => frame.isDyeable?.()),
-      ) as DyeableCharacterItemPiece[];
+      .flatMap((piece) => piece.frames.filter((frame) => frame.isDyeable?.())) as DyeableCharacterItemPiece[];
     for await (const sprites of dyeableSprites) {
       await sprites.updateDye();
     }
@@ -275,18 +268,14 @@ export class Character extends Container {
   get currentAllItem() {
     return Array.from(this.idItems.values())
       .map((item) => {
-        return item.isUseExpressionItem
-          ? item.actionPieces.get(this.expression)
-          : item.actionPieces.get(this.action);
+        return item.isUseExpressionItem ? item.actionPieces.get(this.expression) : item.actionPieces.get(this.action);
       })
       .filter((item) => item) as AnyCategorizedItem[];
   }
 
   /** get current pieces in character layers */
   get currentPieces() {
-    return Array.from(this.zmapLayers.values()).flatMap(
-      (layer) => layer.children as CharacterAnimatablePart[],
-    );
+    return Array.from(this.zmapLayers.values()).flatMap((layer) => layer.children as CharacterAnimatablePart[]);
   }
 
   render() {
@@ -300,9 +289,7 @@ export class Character extends Container {
     const earPiece = this.getEarPiece();
     const earLayer = earPiece?.firstFrameZmapLayer;
     for (const layer of zmap) {
-      const itemsByLayer = this.getItemsByLayer(layer).concat(
-        earPiece && earLayer === layer ? [earPiece] : [],
-      );
+      const itemsByLayer = this.getItemsByLayer(layer).concat(earPiece && earLayer === layer ? [earPiece] : []);
       if (itemsByLayer.length === 0) {
         continue;
       }
@@ -316,10 +303,7 @@ export class Character extends Container {
           if (existLayer) {
             container = existLayer;
           } else {
-            const zIndex =
-              piece.effectZindex >= 2
-                ? zmap.length + piece.effectZindex
-                : piece.effectZindex - 10;
+            const zIndex = piece.effectZindex >= 2 ? zmap.length + piece.effectZindex : piece.effectZindex - 10;
             container = new ZmapContainer(effectLayerName, zIndex, this);
             this.addChild(container);
             this.zmapLayers.set(effectLayerName, container);
@@ -329,10 +313,7 @@ export class Character extends Container {
           this.addChild(container);
           this.zmapLayers.set(layer, container);
         }
-        if (
-          isBackAction(this.action) &&
-          layer.toLocaleLowerCase().includes('face')
-        ) {
+        if (isBackAction(this.action) && layer.toLocaleLowerCase().includes('face')) {
           container.visible = false;
         }
         if ((layer === 'body' || layer === 'backBody') && piece.item.isBody) {
@@ -398,8 +379,7 @@ export class Character extends Container {
   playByBody(body: CharacterAnimatablePart) {
     const pieces = this.currentPieces;
     const maxFrame = body.frames.length;
-    const needBounce =
-      this.action === CharacterAction.Alert || this.action.startsWith('stand');
+    const needBounce = this.action === CharacterAction.Alert || this.action.startsWith('stand');
 
     this.currentTicker = (delta) => {
       this.currentDelta += delta.deltaMS;
@@ -437,8 +417,7 @@ export class Character extends Container {
     }
     for (const piece of pieces) {
       const pieceFrameIndex = piece.frames[frame] ? frame : 0;
-      const pieceFrame = (piece.frames[frame] ||
-        piece.frames[0]) as CharacterItemPiece;
+      const pieceFrame = (piece.frames[frame] || piece.frames[0]) as CharacterItemPiece;
       const isSkinGroup = pieceFrame.group === 'skin';
       if (pieceFrame) {
         const ancherName = pieceFrame.baseAncherName;
@@ -452,7 +431,10 @@ export class Character extends Container {
               y: 48,
             };
             /* cap effect use brow ancher */
-            const browAncher = currentAncher.get('brow') || { x: 0, y: 0 };
+            const browAncher = currentAncher.get('brow') || {
+              x: 0,
+              y: 0,
+            };
             ancher = {
               x: baseAncher.x + browAncher.x,
               y: baseAncher.y + browAncher.y,
@@ -515,15 +497,15 @@ export class Character extends Container {
 
   get currentFrontBodyNode() {
     const body = this.zmapLayers.get('body');
-    return body?.children.find(
-      (child) => (child as CharacterAnimatablePart).item.isBody,
-    ) as CharacterAnimatablePart | undefined;
+    return body?.children.find((child) => (child as CharacterAnimatablePart).item.isBody) as
+      | CharacterAnimatablePart
+      | undefined;
   }
   get currentBackBodyNode() {
     const body = this.zmapLayers.get('backBody');
-    return body?.children.find(
-      (child) => (child as CharacterAnimatablePart).item.isBody,
-    ) as CharacterAnimatablePart | undefined;
+    return body?.children.find((child) => (child as CharacterAnimatablePart).item.isBody) as
+      | CharacterAnimatablePart
+      | undefined;
   }
 
   get currentBodyNode() {
@@ -545,14 +527,10 @@ export class Character extends Container {
   }
 
   get isAllAncherBuilt() {
-    return Array.from(this.idItems.values()).every(
-      (item) => item.isAllAncherBuilt,
-    );
+    return Array.from(this.idItems.values()).every((item) => item.isAllAncherBuilt);
   }
   get isCurrentActionAncherBuilt() {
-    return Array.from(this.idItems.values()).every((item) =>
-      item.isActionAncherBuilt(this.action),
-    );
+    return Array.from(this.idItems.values()).every((item) => item.isActionAncherBuilt(this.action));
   }
 
   get effectLayers() {
@@ -568,15 +546,11 @@ export class Character extends Container {
   }
 
   getEarPiece() {
-    const headItem = Array.from(this.idItems.values()).find(
-      (item) => item.isHead,
-    );
+    const headItem = Array.from(this.idItems.values()).find((item) => item.isHead);
     if (!headItem) {
       return;
     }
-    const headCategoryItem = headItem.actionPieces.get(
-      this.action,
-    ) as CharacterActionItem;
+    const headCategoryItem = headItem.actionPieces.get(this.action) as CharacterActionItem;
 
     const earItems = headCategoryItem?.getAvailableEar(this.earType);
 
@@ -660,6 +634,7 @@ export class Character extends Container {
     if (!orderedItems) {
       return;
     }
+    console.log(orderedItems.map((item) => item.info.id));
     for (const item of orderedItems) {
       for (const slot of item.vslot) {
         this.locks.set(slot, item.info.id);
@@ -671,10 +646,7 @@ export class Character extends Container {
     for (const action of Object.values(CharacterAction)) {
       for (const item of this.idItems.values()) {
         const ancher = this.actionAnchers.get(action);
-        this.actionAnchers.set(
-          action,
-          item.tryBuildAncher(action, ancher || []),
-        );
+        this.actionAnchers.set(action, item.tryBuildAncher(action, ancher || []));
       }
     }
   }
@@ -723,14 +695,12 @@ export class Character extends Container {
   }
   private updateHandTypeByAction() {
     if (
-      (this.action === CharacterAction.Walk1 ||
-        this.action === CharacterAction.Stand1) &&
+      (this.action === CharacterAction.Walk1 || this.action === CharacterAction.Stand1) &&
       this.#_handType === CharacterHandType.DoubleHand
     ) {
       this.#_handType = CharacterHandType.SingleHand;
     } else if (
-      (this.action === CharacterAction.Walk2 ||
-        this.action === CharacterAction.Stand2) &&
+      (this.action === CharacterAction.Walk2 || this.action === CharacterAction.Stand2) &&
       this.#_handType === CharacterHandType.SingleHand
     ) {
       this.#_handType = CharacterHandType.DoubleHand;
diff --git a/src/renderer/character/item.ts b/src/renderer/character/item.ts
index 6cfa2a4..190198f 100644
--- a/src/renderer/character/item.ts
+++ b/src/renderer/character/item.ts
@@ -22,6 +22,7 @@ import {
   isHeadId,
   isBodyId,
   isCapId,
+  isShoesId,
 } from '@/utils/itemId';
 import {
   gatFaceAvailableColorIds,
@@ -210,6 +211,11 @@ export class CharacterItem implements RenderItemInfo {
     this.islot = (this.wz.info?.islot?.match(/.{1,2}/g) || []) as PieceIslot[];
     this.vslot = (this.wz.info?.vslot?.match(/.{1,2}/g) || []) as PieceIslot[];
 
+    /* a shoe should alwasy be a shoe! pls */
+    if (isShoesId(this.info.id) && !this.islot.includes('So')) {
+      this.islot = ['So'];
+    }
+
     /* resolve dye */
     if (this.isFace && this.avaliableDye.size === 0) {
       const ids = gatFaceAvailableColorIds(this.info.id);

From 62e9ed10fa1991bca7ebabe28ab895307be5f6e0 Mon Sep 17 00:00:00 2001
From: spd789562 <leo.yicun.lin@gmail.com>
Date: Mon, 26 Aug 2024 16:31:25 +0800
Subject: [PATCH 04/25] [add] testing Anime4kContainer failed, but keep files
 for now

---
 src/components/CharacterPreview/Character.tsx |   2 +-
 src/renderer/character/character.ts           |   1 -
 .../filter/anime4k/Anime4kContainer.ts        | 321 ++++++++++++++++++
 .../filter/anime4k/Anime4kRenderPipe.ts       |  48 +++
 4 files changed, 370 insertions(+), 2 deletions(-)
 create mode 100644 src/renderer/filter/anime4k/Anime4kContainer.ts
 create mode 100644 src/renderer/filter/anime4k/Anime4kRenderPipe.ts

diff --git a/src/components/CharacterPreview/Character.tsx b/src/components/CharacterPreview/Character.tsx
index c61bd6e..448837a 100644
--- a/src/components/CharacterPreview/Character.tsx
+++ b/src/components/CharacterPreview/Character.tsx
@@ -95,7 +95,7 @@ export const CharacterView = (props: CharacterViewProps) => {
     ch.reset();
     app.destroy();
     if (props.target === 'preview') {
-      setUpscaleSource(app.canvas);
+      resetUpscaleSource();
     }
   });
 
diff --git a/src/renderer/character/character.ts b/src/renderer/character/character.ts
index 741dbe1..272af40 100644
--- a/src/renderer/character/character.ts
+++ b/src/renderer/character/character.ts
@@ -634,7 +634,6 @@ export class Character extends Container {
     if (!orderedItems) {
       return;
     }
-    console.log(orderedItems.map((item) => item.info.id));
     for (const item of orderedItems) {
       for (const slot of item.vslot) {
         this.locks.set(slot, item.info.id);
diff --git a/src/renderer/filter/anime4k/Anime4kContainer.ts b/src/renderer/filter/anime4k/Anime4kContainer.ts
new file mode 100644
index 0000000..b0493a7
--- /dev/null
+++ b/src/renderer/filter/anime4k/Anime4kContainer.ts
@@ -0,0 +1,321 @@
+import { RenderContainer, WebGPURenderer, WebGLRenderer } from 'pixi.js';
+
+import type { Anime4KPipeline } from 'anime4k-webgpu';
+import fullscreenTexturedQuadVert from './fullscreenTexturedQuad.wgsl';
+import sampleExternalTextureFrag from './sampleExternalTexture.wgsl';
+
+type Anime4kPipelineConstructor = new (desc: {
+  device: GPUDevice;
+  inputTexture: GPUTexture;
+  nativeDimensions?: { width: number; height: number };
+  targetDimensions?: { width: number; height: number };
+}) => Anime4KPipeline;
+export interface Anime4kPipelineWithOption {
+  factory: Anime4kPipelineConstructor;
+  params?: Record<string, number>;
+}
+
+export enum PipelineType {
+  /**
+   * deblur, must set param: strength
+   *
+   * @param strength Deblur Strength (0.1 - 15.0)
+   */
+  Dog = 'Dog',
+  /**
+   * denoise, must set param: strength, strength2
+   *
+   * @param strength Itensity Sigma (0.1 - 2.0)
+   * @param strength2 Spatial Sigma (1 - 15)
+   */
+  BilateralMean = 'BilateralMean',
+
+  /** restore */
+  CNNM = 'CNNM',
+  /** restore */
+  CNNSoftM = 'CNNSoftM',
+  /** restore */
+  CNNSoftVL = 'CNNSoftVL',
+  /** restore */
+  CNNVL = 'CNNVL',
+  /** restore */
+  CNNUL = 'CNNUL',
+  /** restore */
+  GANUUL = 'GANUUL',
+
+  /** upscale */
+  CNNx2M = 'CNNx2M',
+  /** upscale */
+  CNNx2VL = 'CNNx2VL',
+  /** upscale */
+  DenoiseCNNx2VL = 'DenoiseCNNx2VL',
+  /** upscale */
+  CNNx2UL = 'CNNx2UL',
+  /** upscale */
+  GANx3L = 'GANx3L',
+  /** upscale */
+  GANx4UUL = 'GANx4UUL',
+
+  ClampHighlights = 'ClampHighlights',
+  DepthToSpace = 'DepthToSpace',
+
+  /* anime4k preset @see https://github.com/bloc97/Anime4K/blob/master/md/GLSL_Instructions_Advanced.md */
+  /** Restore -> Upscale -> Upscale */
+  ModeA = 'ModeA',
+  /** Restore_Soft -> Upscale -> Upscale */
+  ModeB = 'ModeB',
+  /** Upscale_Denoise -> Upscale */
+  ModeC = 'ModeC',
+  /** Restore -> Upscale -> Restore -> Upscale */
+  ModeAA = 'ModeAA',
+  /** Restore_Soft -> Upscale -> Restore_Soft -> Upscale */
+  ModeBB = 'ModeBB',
+  /** Upscale_Denoise -> Restore -> Upscale */
+  ModeCA = 'ModeCA',
+}
+
+const PipelineMap: Record<PipelineType, () => Promise<unknown>> = {
+  [PipelineType.Dog]: () => import('anime4k-webgpu').then((m) => m.DoG),
+  [PipelineType.BilateralMean]: () =>
+    import('anime4k-webgpu').then((m) => m.BilateralMean),
+
+  [PipelineType.CNNM]: () => import('anime4k-webgpu').then((m) => m.CNNM),
+  [PipelineType.CNNSoftM]: () =>
+    import('anime4k-webgpu').then((m) => m.CNNSoftM),
+  [PipelineType.CNNSoftVL]: () =>
+    import('anime4k-webgpu').then((m) => m.CNNSoftVL),
+  [PipelineType.CNNVL]: () => import('anime4k-webgpu').then((m) => m.CNNVL),
+  [PipelineType.CNNUL]: () => import('anime4k-webgpu').then((m) => m.CNNUL),
+  [PipelineType.GANUUL]: () => import('anime4k-webgpu').then((m) => m.GANUUL),
+
+  [PipelineType.CNNx2M]: () => import('anime4k-webgpu').then((m) => m.CNNx2M),
+  [PipelineType.CNNx2VL]: () => import('anime4k-webgpu').then((m) => m.CNNx2VL),
+  [PipelineType.DenoiseCNNx2VL]: () =>
+    import('anime4k-webgpu').then((m) => m.DenoiseCNNx2VL),
+  [PipelineType.CNNx2UL]: () => import('anime4k-webgpu').then((m) => m.CNNx2UL),
+  [PipelineType.GANx3L]: () => import('anime4k-webgpu').then((m) => m.GANx3L),
+  [PipelineType.GANx4UUL]: () =>
+    import('anime4k-webgpu').then((m) => m.GANx4UUL),
+
+  [PipelineType.ClampHighlights]: () =>
+    import('anime4k-webgpu').then((m) => m.ClampHighlights),
+  [PipelineType.DepthToSpace]: () =>
+    import('anime4k-webgpu').then((m) => m.DepthToSpace),
+
+  [PipelineType.ModeA]: () => import('anime4k-webgpu').then((m) => m.ModeA),
+  [PipelineType.ModeB]: () => import('anime4k-webgpu').then((m) => m.ModeB),
+  [PipelineType.ModeC]: () => import('anime4k-webgpu').then((m) => m.ModeC),
+  [PipelineType.ModeAA]: () => import('anime4k-webgpu').then((m) => m.ModeAA),
+  [PipelineType.ModeBB]: () => import('anime4k-webgpu').then((m) => m.ModeBB),
+  [PipelineType.ModeCA]: () => import('anime4k-webgpu').then((m) => m.ModeCA),
+};
+
+export interface PipelineOption {
+  pipeline: PipelineType;
+  params: Record<string, number>;
+}
+
+export class Anime4kContainer extends RenderContainer {
+  mainTexture: GPUTexture | undefined;
+  public readonly renderPipeId = 'anime4kRender';
+  loadedPipeline: Anime4kPipelineWithOption[] = [];
+  pipelineChain: Anime4KPipeline[] = [];
+
+  _renderPipeline: GPURenderPipeline | undefined;
+  _renderBindGroup: GPUBindGroup | undefined;
+
+  constructor(pipelines: Anime4kPipelineWithOption[]) {
+    super({
+      render: (renderer) => {
+        if (renderer instanceof WebGLRenderer) {
+          this.glRender(renderer);
+        }
+        if (renderer instanceof WebGPURenderer) {
+          this.gpuRender(renderer, pipelines);
+        }
+      },
+    });
+  }
+  static loadPipeline(
+    pipelines: (PipelineType | PipelineOption)[],
+  ): Promise<Anime4kPipelineConstructor[]> {
+    return Promise.all(
+      pipelines.map((pipeline) => {
+        if (typeof pipeline === 'string') {
+          return PipelineMap[pipeline]() as Promise<Anime4kPipelineConstructor>;
+        }
+        return PipelineMap[
+          pipeline.pipeline
+        ]() as Promise<Anime4kPipelineConstructor>;
+      }),
+    );
+  }
+  init(
+    device: GPUDevice,
+    firstTexture: GPUTexture,
+    pipelines: Anime4kPipelineWithOption[],
+  ) {
+    this.pipelineChain = pipelines.reduce(
+      (chain: Anime4KPipeline[], pipeline, index) => {
+        const inputTexture =
+          index === 0 ? firstTexture : chain[index - 1].getOutputTexture();
+        const pipeClass = pipeline.factory;
+        const pipeOption = pipeline.params;
+        const pipe = new pipeClass({
+          device,
+          inputTexture,
+          nativeDimensions: {
+            width: firstTexture.width,
+            height: firstTexture.height,
+          },
+          targetDimensions: {
+            width: firstTexture.width,
+            height: firstTexture.height,
+          },
+        });
+        if (pipeOption && typeof pipeOption !== 'string') {
+          for (const [key, value] of Object.entries(pipeOption)) {
+            pipe.updateParam(key, value);
+          }
+        }
+        chain.push(pipe);
+        return chain;
+      },
+      [] as Anime4KPipeline[],
+    );
+    const lastPipeline = this.pipelineChain[this.pipelineChain.length - 1];
+    if (!lastPipeline) {
+      return;
+    }
+    // render pipeline setups
+    const renderBindGroupLayout = device.createBindGroupLayout({
+      label: 'Render Bind Group Layout',
+      entries: [
+        {
+          binding: 1,
+          visibility: GPUShaderStage.FRAGMENT,
+          sampler: {},
+        },
+        {
+          binding: 2,
+          visibility: GPUShaderStage.FRAGMENT,
+          texture: {},
+        },
+      ],
+    });
+
+    const renderPipelineLayout = device.createPipelineLayout({
+      label: 'Render Pipeline Layout',
+      bindGroupLayouts: [renderBindGroupLayout],
+    });
+
+    const renderPipeline = device.createRenderPipeline({
+      layout: renderPipelineLayout,
+      vertex: {
+        module: device.createShaderModule({
+          code: fullscreenTexturedQuadVert,
+        }),
+        entryPoint: 'vert_main',
+      },
+      fragment: {
+        module: device.createShaderModule({
+          code: sampleExternalTextureFrag,
+        }),
+        entryPoint: 'main',
+        targets: [
+          {
+            format: navigator.gpu.getPreferredCanvasFormat(),
+          },
+        ],
+      },
+      primitive: {
+        topology: 'triangle-list',
+      },
+    });
+
+    const sampler = device.createSampler({
+      magFilter: 'linear',
+      minFilter: 'linear',
+    });
+
+    const renderBindGroup = device.createBindGroup({
+      layout: renderBindGroupLayout,
+      entries: [
+        {
+          binding: 1,
+          resource: sampler,
+        },
+        {
+          binding: 2,
+          resource: lastPipeline.getOutputTexture().createView(),
+        },
+      ],
+    });
+    this._renderPipeline = renderPipeline;
+    this._renderBindGroup = renderBindGroup;
+  }
+  updatePipeine() {
+    /* TODO */
+  }
+  glRender(renderer: WebGLRenderer) {
+    super.render(renderer);
+  }
+  gpuRender(renderer: WebGPURenderer, pipelines: Anime4kPipelineWithOption[]) {
+    super.render(renderer);
+
+    const device = renderer.device.gpu.device;
+    const colorTexture = renderer.renderTarget.renderTarget.colorTexture;
+    const gpuRenderTarget = renderer.renderTarget.getGpuRenderTarget(
+      renderer.renderTarget.renderTarget,
+    );
+    const context = gpuRenderTarget.contexts[0];
+    const currentTexture = context.getCurrentTexture();
+
+    if (!this.mainTexture) {
+      this.mainTexture = device.createTexture({
+        size: [colorTexture.width, colorTexture.height, 1],
+        format: 'rgba8unorm',
+        usage:
+          GPUTextureUsage.TEXTURE_BINDING |
+          GPUTextureUsage.COPY_DST |
+          GPUTextureUsage.RENDER_ATTACHMENT,
+      });
+      this.init(device, this.mainTexture, pipelines);
+    }
+
+    if (!context || this.pipelineChain.length === 0) {
+      console.info('render canceled');
+      return;
+    }
+    device.queue.copyExternalImageToTexture(
+      { source: context.canvas },
+      { texture: this.mainTexture },
+      [colorTexture.width, colorTexture.height],
+    );
+
+    const commandEncoder = device.createCommandEncoder();
+    for (const pipe of this.pipelineChain) {
+      pipe.pass(commandEncoder);
+    }
+    const passEncoder = commandEncoder.beginRenderPass({
+      colorAttachments: [
+        {
+          view: currentTexture.createView(),
+          clearValue: {
+            r: 0.0,
+            g: 0.0,
+            b: 0.0,
+            a: 1.0,
+          },
+          loadOp: 'clear',
+          storeOp: 'store',
+        },
+      ],
+    });
+    passEncoder.setPipeline(this._renderPipeline!);
+    passEncoder.setBindGroup(0, this._renderBindGroup!);
+    passEncoder.draw(6);
+    passEncoder.end();
+    device.queue.submit([commandEncoder.finish()]);
+  }
+}
diff --git a/src/renderer/filter/anime4k/Anime4kRenderPipe.ts b/src/renderer/filter/anime4k/Anime4kRenderPipe.ts
new file mode 100644
index 0000000..4b570ac
--- /dev/null
+++ b/src/renderer/filter/anime4k/Anime4kRenderPipe.ts
@@ -0,0 +1,48 @@
+import {
+  WebGPURenderer,
+  WebGLRenderer,
+  extensions,
+  ExtensionType,
+  type InstructionSet,
+  type InstructionPipe,
+  type Renderer,
+  type RenderContainer,
+} from 'pixi.js';
+
+import type { Anime4kContainer } from './Anime4kContainer';
+
+export class Anime4kRenderPipe implements InstructionPipe<Anime4kContainer> {
+  public static extension = {
+    type: [ExtensionType.WebGPUPipes],
+    name: 'anime4kRender',
+  } as const;
+
+  private _renderer: Renderer;
+
+  constructor(renderer: Renderer) {
+    this._renderer = renderer;
+  }
+
+  public addRenderable(
+    container: Anime4kContainer,
+    instructionSet: InstructionSet,
+  ): void {
+    this._renderer.renderPipes.batch.break(instructionSet);
+
+    instructionSet.add(container);
+  }
+
+  public execute(container: Anime4kContainer) {
+    if (!container.isRenderable) {
+      return;
+    }
+    container.render(this._renderer);
+  }
+
+  public destroy(): void {
+    /* @ts-ignore */
+    this._renderer = null;
+  }
+}
+
+extensions.add(Anime4kRenderPipe);

From 6d4253acf1e1dc20b94a5ac5a8653c1686ec18e2 Mon Sep 17 00:00:00 2001
From: spd789562 <leo.yicun.lin@gmail.com>
Date: Mon, 26 Aug 2024 16:33:30 +0800
Subject: [PATCH 05/25] [edit] move action and dye table to tab folder

---
 src/components/ToolTabPage.tsx                              | 6 +++---
 src/components/{ => tab}/ActionTab/ActionCard.tsx           | 0
 src/components/{ => tab}/ActionTab/ActionCharacter.tsx      | 0
 src/components/{ => tab}/ActionTab/ActionTabTitle.tsx       | 0
 src/components/{ => tab}/ActionTab/ExportAnimateButton.tsx  | 0
 src/components/{ => tab}/ActionTab/ExportFrameButton.tsx    | 0
 .../{ => tab}/ActionTab/ExportTypeToggleGroup.tsx           | 0
 src/components/{ => tab}/ActionTab/helper.ts                | 0
 src/components/{ => tab}/ActionTab/index.tsx                | 0
 src/components/{ => tab}/DyeTab/AllColorTable.tsx           | 0
 src/components/{ => tab}/DyeTab/DyeCharacter.tsx            | 0
 src/components/{ => tab}/DyeTab/DyeInfo.tsx                 | 0
 src/components/{ => tab}/DyeTab/ExportSeperateButton.tsx    | 0
 src/components/{ => tab}/DyeTab/ExportTableButton.tsx       | 0
 src/components/{ => tab}/DyeTab/FaceDyeTab.tsx              | 0
 src/components/{ => tab}/DyeTab/HairDyeTab.tsx              | 0
 src/components/{ => tab}/DyeTab/MixDyeTable.tsx             | 0
 src/components/{ => tab}/DyeTab/styledComponents.ts         | 0
 18 files changed, 3 insertions(+), 3 deletions(-)
 rename src/components/{ => tab}/ActionTab/ActionCard.tsx (100%)
 rename src/components/{ => tab}/ActionTab/ActionCharacter.tsx (100%)
 rename src/components/{ => tab}/ActionTab/ActionTabTitle.tsx (100%)
 rename src/components/{ => tab}/ActionTab/ExportAnimateButton.tsx (100%)
 rename src/components/{ => tab}/ActionTab/ExportFrameButton.tsx (100%)
 rename src/components/{ => tab}/ActionTab/ExportTypeToggleGroup.tsx (100%)
 rename src/components/{ => tab}/ActionTab/helper.ts (100%)
 rename src/components/{ => tab}/ActionTab/index.tsx (100%)
 rename src/components/{ => tab}/DyeTab/AllColorTable.tsx (100%)
 rename src/components/{ => tab}/DyeTab/DyeCharacter.tsx (100%)
 rename src/components/{ => tab}/DyeTab/DyeInfo.tsx (100%)
 rename src/components/{ => tab}/DyeTab/ExportSeperateButton.tsx (100%)
 rename src/components/{ => tab}/DyeTab/ExportTableButton.tsx (100%)
 rename src/components/{ => tab}/DyeTab/FaceDyeTab.tsx (100%)
 rename src/components/{ => tab}/DyeTab/HairDyeTab.tsx (100%)
 rename src/components/{ => tab}/DyeTab/MixDyeTable.tsx (100%)
 rename src/components/{ => tab}/DyeTab/styledComponents.ts (100%)

diff --git a/src/components/ToolTabPage.tsx b/src/components/ToolTabPage.tsx
index 82be668..1c76d8e 100644
--- a/src/components/ToolTabPage.tsx
+++ b/src/components/ToolTabPage.tsx
@@ -3,9 +3,9 @@ import { useStore } from '@nanostores/solid';
 
 import { $toolTab } from '@/store/toolTab';
 
-import { ActionTab } from './ActionTab';
-import { HairDyeTab } from './DyeTab/HairDyeTab';
-import { FaceDyeTab } from './DyeTab/FaceDyeTab';
+import { ActionTab } from './tab/ActionTab';
+import { HairDyeTab } from './tab/DyeTab/HairDyeTab';
+import { FaceDyeTab } from './tab/DyeTab/FaceDyeTab';
 
 import { ToolTab } from '@/const/toolTab';
 
diff --git a/src/components/ActionTab/ActionCard.tsx b/src/components/tab/ActionTab/ActionCard.tsx
similarity index 100%
rename from src/components/ActionTab/ActionCard.tsx
rename to src/components/tab/ActionTab/ActionCard.tsx
diff --git a/src/components/ActionTab/ActionCharacter.tsx b/src/components/tab/ActionTab/ActionCharacter.tsx
similarity index 100%
rename from src/components/ActionTab/ActionCharacter.tsx
rename to src/components/tab/ActionTab/ActionCharacter.tsx
diff --git a/src/components/ActionTab/ActionTabTitle.tsx b/src/components/tab/ActionTab/ActionTabTitle.tsx
similarity index 100%
rename from src/components/ActionTab/ActionTabTitle.tsx
rename to src/components/tab/ActionTab/ActionTabTitle.tsx
diff --git a/src/components/ActionTab/ExportAnimateButton.tsx b/src/components/tab/ActionTab/ExportAnimateButton.tsx
similarity index 100%
rename from src/components/ActionTab/ExportAnimateButton.tsx
rename to src/components/tab/ActionTab/ExportAnimateButton.tsx
diff --git a/src/components/ActionTab/ExportFrameButton.tsx b/src/components/tab/ActionTab/ExportFrameButton.tsx
similarity index 100%
rename from src/components/ActionTab/ExportFrameButton.tsx
rename to src/components/tab/ActionTab/ExportFrameButton.tsx
diff --git a/src/components/ActionTab/ExportTypeToggleGroup.tsx b/src/components/tab/ActionTab/ExportTypeToggleGroup.tsx
similarity index 100%
rename from src/components/ActionTab/ExportTypeToggleGroup.tsx
rename to src/components/tab/ActionTab/ExportTypeToggleGroup.tsx
diff --git a/src/components/ActionTab/helper.ts b/src/components/tab/ActionTab/helper.ts
similarity index 100%
rename from src/components/ActionTab/helper.ts
rename to src/components/tab/ActionTab/helper.ts
diff --git a/src/components/ActionTab/index.tsx b/src/components/tab/ActionTab/index.tsx
similarity index 100%
rename from src/components/ActionTab/index.tsx
rename to src/components/tab/ActionTab/index.tsx
diff --git a/src/components/DyeTab/AllColorTable.tsx b/src/components/tab/DyeTab/AllColorTable.tsx
similarity index 100%
rename from src/components/DyeTab/AllColorTable.tsx
rename to src/components/tab/DyeTab/AllColorTable.tsx
diff --git a/src/components/DyeTab/DyeCharacter.tsx b/src/components/tab/DyeTab/DyeCharacter.tsx
similarity index 100%
rename from src/components/DyeTab/DyeCharacter.tsx
rename to src/components/tab/DyeTab/DyeCharacter.tsx
diff --git a/src/components/DyeTab/DyeInfo.tsx b/src/components/tab/DyeTab/DyeInfo.tsx
similarity index 100%
rename from src/components/DyeTab/DyeInfo.tsx
rename to src/components/tab/DyeTab/DyeInfo.tsx
diff --git a/src/components/DyeTab/ExportSeperateButton.tsx b/src/components/tab/DyeTab/ExportSeperateButton.tsx
similarity index 100%
rename from src/components/DyeTab/ExportSeperateButton.tsx
rename to src/components/tab/DyeTab/ExportSeperateButton.tsx
diff --git a/src/components/DyeTab/ExportTableButton.tsx b/src/components/tab/DyeTab/ExportTableButton.tsx
similarity index 100%
rename from src/components/DyeTab/ExportTableButton.tsx
rename to src/components/tab/DyeTab/ExportTableButton.tsx
diff --git a/src/components/DyeTab/FaceDyeTab.tsx b/src/components/tab/DyeTab/FaceDyeTab.tsx
similarity index 100%
rename from src/components/DyeTab/FaceDyeTab.tsx
rename to src/components/tab/DyeTab/FaceDyeTab.tsx
diff --git a/src/components/DyeTab/HairDyeTab.tsx b/src/components/tab/DyeTab/HairDyeTab.tsx
similarity index 100%
rename from src/components/DyeTab/HairDyeTab.tsx
rename to src/components/tab/DyeTab/HairDyeTab.tsx
diff --git a/src/components/DyeTab/MixDyeTable.tsx b/src/components/tab/DyeTab/MixDyeTable.tsx
similarity index 100%
rename from src/components/DyeTab/MixDyeTable.tsx
rename to src/components/tab/DyeTab/MixDyeTable.tsx
diff --git a/src/components/DyeTab/styledComponents.ts b/src/components/tab/DyeTab/styledComponents.ts
similarity index 100%
rename from src/components/DyeTab/styledComponents.ts
rename to src/components/tab/DyeTab/styledComponents.ts

From f1ad015f6b29ed463ad352dbf1c2189e978b96de Mon Sep 17 00:00:00 2001
From: spd789562 <leo.yicun.lin@gmail.com>
Date: Tue, 27 Aug 2024 19:08:05 +0800
Subject: [PATCH 06/25] [add] Anime4k System and Filter and successfully apply
 on pixi application

---
 src/components/CharacterPreview/Character.tsx |  33 ++
 src/lucide-solid.d.ts                         |   2 +
 .../filter/anime4k/Anime4kContainer.ts        | 321 ------------------
 src/renderer/filter/anime4k/Anime4kFilter.ts  | 273 +++++++++++++++
 .../filter/anime4k/Anime4kRenderPipe.ts       |  48 ---
 .../filter/anime4k/Anime4kSyetem.d.ts         |   9 +
 src/renderer/filter/anime4k/Anime4kSystem.ts  | 272 +++++++++++++++
 src/renderer/filter/anime4k/const.ts          | 110 ++++++
 src/renderer/filter/anime4k/index.ts          |   4 +-
 .../filter/anime4k/sampleExternalTexture.wgsl |   6 +-
 10 files changed, 705 insertions(+), 373 deletions(-)
 delete mode 100644 src/renderer/filter/anime4k/Anime4kContainer.ts
 create mode 100644 src/renderer/filter/anime4k/Anime4kFilter.ts
 delete mode 100644 src/renderer/filter/anime4k/Anime4kRenderPipe.ts
 create mode 100644 src/renderer/filter/anime4k/Anime4kSyetem.d.ts
 create mode 100644 src/renderer/filter/anime4k/Anime4kSystem.ts
 create mode 100644 src/renderer/filter/anime4k/const.ts

diff --git a/src/components/CharacterPreview/Character.tsx b/src/components/CharacterPreview/Character.tsx
index 448837a..b95b56f 100644
--- a/src/components/CharacterPreview/Character.tsx
+++ b/src/components/CharacterPreview/Character.tsx
@@ -15,12 +15,20 @@ import {
   resetUpscaleSource,
   setUpscaleSource,
 } from '@/store/expirement/upscale';
+import { $showUpscaledCharacter } from '@/store/trigger';
 import { usePureStore } from '@/store';
 
 import { Application } from 'pixi.js';
 import { Character } from '@/renderer/character/character';
 import { ZoomContainer } from '@/renderer/ZoomContainer';
 
+/* TEST */
+import { Anime4kFilter } from '@/renderer/filter/anime4k/Anime4kFilter';
+import {
+  PipelineType,
+  type PipelineOption,
+} from '@/renderer/filter/anime4k/const';
+
 export interface CharacterViewProps {
   onLoad: () => void;
   onLoaded: () => void;
@@ -32,8 +40,10 @@ export const CharacterView = (props: CharacterViewProps) => {
   const zoomInfo = usePureStore($previewZoomInfo);
   const characterData = usePureStore(props.store);
   const [isInit, setIsInit] = createSignal<boolean>(false);
+  const isShowUpscale = usePureStore($showUpscaledCharacter);
   let container!: HTMLDivElement;
   let viewport: ZoomContainer | undefined = undefined;
+  let upscaleFilter: Anime4kFilter | undefined = undefined;
   const app = new Application();
   const ch = new Character(app);
 
@@ -79,6 +89,7 @@ export const CharacterView = (props: CharacterViewProps) => {
     });
     container.appendChild(app.canvas);
     viewport.addChild(ch);
+
     app.stage.addChild(viewport);
     if (props.target === 'preview') {
       setUpscaleSource(app.canvas);
@@ -116,6 +127,28 @@ export const CharacterView = (props: CharacterViewProps) => {
     }
   });
 
+  createEffect(async () => {
+    if (isShowUpscale()) {
+      await app.renderer.anime4k.preparePipeline([PipelineType.ModeBB]);
+      if (!upscaleFilter) {
+        upscaleFilter = new Anime4kFilter([
+          {
+            pipeline: PipelineType.ModeBB,
+            // params: {
+            //   strength: 0.5,
+            //   strength2: 1,
+            // },
+          } as PipelineOption,
+        ]);
+      }
+      /* TODO */
+      upscaleFilter.updatePipeine();
+      app.stage.filters = [upscaleFilter];
+    } else {
+      app.stage.filters = [];
+    }
+  });
+
   createEffect(() => {
     const info = zoomInfo();
     const scaled = info.zoom;
diff --git a/src/lucide-solid.d.ts b/src/lucide-solid.d.ts
index b31c157..8fd8e8b 100644
--- a/src/lucide-solid.d.ts
+++ b/src/lucide-solid.d.ts
@@ -10,3 +10,5 @@ declare module 'dom-to-image-more' {
   import domToImage = require('dom-to-image');
   export = domToImage;
 }
+
+/// <reference path="./renderer/filter/anime4k/Anime4kSyetem.d.ts" />
diff --git a/src/renderer/filter/anime4k/Anime4kContainer.ts b/src/renderer/filter/anime4k/Anime4kContainer.ts
deleted file mode 100644
index b0493a7..0000000
--- a/src/renderer/filter/anime4k/Anime4kContainer.ts
+++ /dev/null
@@ -1,321 +0,0 @@
-import { RenderContainer, WebGPURenderer, WebGLRenderer } from 'pixi.js';
-
-import type { Anime4KPipeline } from 'anime4k-webgpu';
-import fullscreenTexturedQuadVert from './fullscreenTexturedQuad.wgsl';
-import sampleExternalTextureFrag from './sampleExternalTexture.wgsl';
-
-type Anime4kPipelineConstructor = new (desc: {
-  device: GPUDevice;
-  inputTexture: GPUTexture;
-  nativeDimensions?: { width: number; height: number };
-  targetDimensions?: { width: number; height: number };
-}) => Anime4KPipeline;
-export interface Anime4kPipelineWithOption {
-  factory: Anime4kPipelineConstructor;
-  params?: Record<string, number>;
-}
-
-export enum PipelineType {
-  /**
-   * deblur, must set param: strength
-   *
-   * @param strength Deblur Strength (0.1 - 15.0)
-   */
-  Dog = 'Dog',
-  /**
-   * denoise, must set param: strength, strength2
-   *
-   * @param strength Itensity Sigma (0.1 - 2.0)
-   * @param strength2 Spatial Sigma (1 - 15)
-   */
-  BilateralMean = 'BilateralMean',
-
-  /** restore */
-  CNNM = 'CNNM',
-  /** restore */
-  CNNSoftM = 'CNNSoftM',
-  /** restore */
-  CNNSoftVL = 'CNNSoftVL',
-  /** restore */
-  CNNVL = 'CNNVL',
-  /** restore */
-  CNNUL = 'CNNUL',
-  /** restore */
-  GANUUL = 'GANUUL',
-
-  /** upscale */
-  CNNx2M = 'CNNx2M',
-  /** upscale */
-  CNNx2VL = 'CNNx2VL',
-  /** upscale */
-  DenoiseCNNx2VL = 'DenoiseCNNx2VL',
-  /** upscale */
-  CNNx2UL = 'CNNx2UL',
-  /** upscale */
-  GANx3L = 'GANx3L',
-  /** upscale */
-  GANx4UUL = 'GANx4UUL',
-
-  ClampHighlights = 'ClampHighlights',
-  DepthToSpace = 'DepthToSpace',
-
-  /* anime4k preset @see https://github.com/bloc97/Anime4K/blob/master/md/GLSL_Instructions_Advanced.md */
-  /** Restore -> Upscale -> Upscale */
-  ModeA = 'ModeA',
-  /** Restore_Soft -> Upscale -> Upscale */
-  ModeB = 'ModeB',
-  /** Upscale_Denoise -> Upscale */
-  ModeC = 'ModeC',
-  /** Restore -> Upscale -> Restore -> Upscale */
-  ModeAA = 'ModeAA',
-  /** Restore_Soft -> Upscale -> Restore_Soft -> Upscale */
-  ModeBB = 'ModeBB',
-  /** Upscale_Denoise -> Restore -> Upscale */
-  ModeCA = 'ModeCA',
-}
-
-const PipelineMap: Record<PipelineType, () => Promise<unknown>> = {
-  [PipelineType.Dog]: () => import('anime4k-webgpu').then((m) => m.DoG),
-  [PipelineType.BilateralMean]: () =>
-    import('anime4k-webgpu').then((m) => m.BilateralMean),
-
-  [PipelineType.CNNM]: () => import('anime4k-webgpu').then((m) => m.CNNM),
-  [PipelineType.CNNSoftM]: () =>
-    import('anime4k-webgpu').then((m) => m.CNNSoftM),
-  [PipelineType.CNNSoftVL]: () =>
-    import('anime4k-webgpu').then((m) => m.CNNSoftVL),
-  [PipelineType.CNNVL]: () => import('anime4k-webgpu').then((m) => m.CNNVL),
-  [PipelineType.CNNUL]: () => import('anime4k-webgpu').then((m) => m.CNNUL),
-  [PipelineType.GANUUL]: () => import('anime4k-webgpu').then((m) => m.GANUUL),
-
-  [PipelineType.CNNx2M]: () => import('anime4k-webgpu').then((m) => m.CNNx2M),
-  [PipelineType.CNNx2VL]: () => import('anime4k-webgpu').then((m) => m.CNNx2VL),
-  [PipelineType.DenoiseCNNx2VL]: () =>
-    import('anime4k-webgpu').then((m) => m.DenoiseCNNx2VL),
-  [PipelineType.CNNx2UL]: () => import('anime4k-webgpu').then((m) => m.CNNx2UL),
-  [PipelineType.GANx3L]: () => import('anime4k-webgpu').then((m) => m.GANx3L),
-  [PipelineType.GANx4UUL]: () =>
-    import('anime4k-webgpu').then((m) => m.GANx4UUL),
-
-  [PipelineType.ClampHighlights]: () =>
-    import('anime4k-webgpu').then((m) => m.ClampHighlights),
-  [PipelineType.DepthToSpace]: () =>
-    import('anime4k-webgpu').then((m) => m.DepthToSpace),
-
-  [PipelineType.ModeA]: () => import('anime4k-webgpu').then((m) => m.ModeA),
-  [PipelineType.ModeB]: () => import('anime4k-webgpu').then((m) => m.ModeB),
-  [PipelineType.ModeC]: () => import('anime4k-webgpu').then((m) => m.ModeC),
-  [PipelineType.ModeAA]: () => import('anime4k-webgpu').then((m) => m.ModeAA),
-  [PipelineType.ModeBB]: () => import('anime4k-webgpu').then((m) => m.ModeBB),
-  [PipelineType.ModeCA]: () => import('anime4k-webgpu').then((m) => m.ModeCA),
-};
-
-export interface PipelineOption {
-  pipeline: PipelineType;
-  params: Record<string, number>;
-}
-
-export class Anime4kContainer extends RenderContainer {
-  mainTexture: GPUTexture | undefined;
-  public readonly renderPipeId = 'anime4kRender';
-  loadedPipeline: Anime4kPipelineWithOption[] = [];
-  pipelineChain: Anime4KPipeline[] = [];
-
-  _renderPipeline: GPURenderPipeline | undefined;
-  _renderBindGroup: GPUBindGroup | undefined;
-
-  constructor(pipelines: Anime4kPipelineWithOption[]) {
-    super({
-      render: (renderer) => {
-        if (renderer instanceof WebGLRenderer) {
-          this.glRender(renderer);
-        }
-        if (renderer instanceof WebGPURenderer) {
-          this.gpuRender(renderer, pipelines);
-        }
-      },
-    });
-  }
-  static loadPipeline(
-    pipelines: (PipelineType | PipelineOption)[],
-  ): Promise<Anime4kPipelineConstructor[]> {
-    return Promise.all(
-      pipelines.map((pipeline) => {
-        if (typeof pipeline === 'string') {
-          return PipelineMap[pipeline]() as Promise<Anime4kPipelineConstructor>;
-        }
-        return PipelineMap[
-          pipeline.pipeline
-        ]() as Promise<Anime4kPipelineConstructor>;
-      }),
-    );
-  }
-  init(
-    device: GPUDevice,
-    firstTexture: GPUTexture,
-    pipelines: Anime4kPipelineWithOption[],
-  ) {
-    this.pipelineChain = pipelines.reduce(
-      (chain: Anime4KPipeline[], pipeline, index) => {
-        const inputTexture =
-          index === 0 ? firstTexture : chain[index - 1].getOutputTexture();
-        const pipeClass = pipeline.factory;
-        const pipeOption = pipeline.params;
-        const pipe = new pipeClass({
-          device,
-          inputTexture,
-          nativeDimensions: {
-            width: firstTexture.width,
-            height: firstTexture.height,
-          },
-          targetDimensions: {
-            width: firstTexture.width,
-            height: firstTexture.height,
-          },
-        });
-        if (pipeOption && typeof pipeOption !== 'string') {
-          for (const [key, value] of Object.entries(pipeOption)) {
-            pipe.updateParam(key, value);
-          }
-        }
-        chain.push(pipe);
-        return chain;
-      },
-      [] as Anime4KPipeline[],
-    );
-    const lastPipeline = this.pipelineChain[this.pipelineChain.length - 1];
-    if (!lastPipeline) {
-      return;
-    }
-    // render pipeline setups
-    const renderBindGroupLayout = device.createBindGroupLayout({
-      label: 'Render Bind Group Layout',
-      entries: [
-        {
-          binding: 1,
-          visibility: GPUShaderStage.FRAGMENT,
-          sampler: {},
-        },
-        {
-          binding: 2,
-          visibility: GPUShaderStage.FRAGMENT,
-          texture: {},
-        },
-      ],
-    });
-
-    const renderPipelineLayout = device.createPipelineLayout({
-      label: 'Render Pipeline Layout',
-      bindGroupLayouts: [renderBindGroupLayout],
-    });
-
-    const renderPipeline = device.createRenderPipeline({
-      layout: renderPipelineLayout,
-      vertex: {
-        module: device.createShaderModule({
-          code: fullscreenTexturedQuadVert,
-        }),
-        entryPoint: 'vert_main',
-      },
-      fragment: {
-        module: device.createShaderModule({
-          code: sampleExternalTextureFrag,
-        }),
-        entryPoint: 'main',
-        targets: [
-          {
-            format: navigator.gpu.getPreferredCanvasFormat(),
-          },
-        ],
-      },
-      primitive: {
-        topology: 'triangle-list',
-      },
-    });
-
-    const sampler = device.createSampler({
-      magFilter: 'linear',
-      minFilter: 'linear',
-    });
-
-    const renderBindGroup = device.createBindGroup({
-      layout: renderBindGroupLayout,
-      entries: [
-        {
-          binding: 1,
-          resource: sampler,
-        },
-        {
-          binding: 2,
-          resource: lastPipeline.getOutputTexture().createView(),
-        },
-      ],
-    });
-    this._renderPipeline = renderPipeline;
-    this._renderBindGroup = renderBindGroup;
-  }
-  updatePipeine() {
-    /* TODO */
-  }
-  glRender(renderer: WebGLRenderer) {
-    super.render(renderer);
-  }
-  gpuRender(renderer: WebGPURenderer, pipelines: Anime4kPipelineWithOption[]) {
-    super.render(renderer);
-
-    const device = renderer.device.gpu.device;
-    const colorTexture = renderer.renderTarget.renderTarget.colorTexture;
-    const gpuRenderTarget = renderer.renderTarget.getGpuRenderTarget(
-      renderer.renderTarget.renderTarget,
-    );
-    const context = gpuRenderTarget.contexts[0];
-    const currentTexture = context.getCurrentTexture();
-
-    if (!this.mainTexture) {
-      this.mainTexture = device.createTexture({
-        size: [colorTexture.width, colorTexture.height, 1],
-        format: 'rgba8unorm',
-        usage:
-          GPUTextureUsage.TEXTURE_BINDING |
-          GPUTextureUsage.COPY_DST |
-          GPUTextureUsage.RENDER_ATTACHMENT,
-      });
-      this.init(device, this.mainTexture, pipelines);
-    }
-
-    if (!context || this.pipelineChain.length === 0) {
-      console.info('render canceled');
-      return;
-    }
-    device.queue.copyExternalImageToTexture(
-      { source: context.canvas },
-      { texture: this.mainTexture },
-      [colorTexture.width, colorTexture.height],
-    );
-
-    const commandEncoder = device.createCommandEncoder();
-    for (const pipe of this.pipelineChain) {
-      pipe.pass(commandEncoder);
-    }
-    const passEncoder = commandEncoder.beginRenderPass({
-      colorAttachments: [
-        {
-          view: currentTexture.createView(),
-          clearValue: {
-            r: 0.0,
-            g: 0.0,
-            b: 0.0,
-            a: 1.0,
-          },
-          loadOp: 'clear',
-          storeOp: 'store',
-        },
-      ],
-    });
-    passEncoder.setPipeline(this._renderPipeline!);
-    passEncoder.setBindGroup(0, this._renderBindGroup!);
-    passEncoder.draw(6);
-    passEncoder.end();
-    device.queue.submit([commandEncoder.finish()]);
-  }
-}
diff --git a/src/renderer/filter/anime4k/Anime4kFilter.ts b/src/renderer/filter/anime4k/Anime4kFilter.ts
new file mode 100644
index 0000000..e4acd91
--- /dev/null
+++ b/src/renderer/filter/anime4k/Anime4kFilter.ts
@@ -0,0 +1,273 @@
+import {
+  Filter,
+  Texture,
+  GpuProgram,
+  Geometry,
+  Point,
+  type WebGPURenderer,
+  type FilterSystem,
+  type RenderSurface,
+} from 'pixi.js';
+import { wgslVertex } from 'pixi-filters';
+import './Anime4kSystem';
+
+import sampleExternalTextureFrag from './sampleExternalTexture.wgsl';
+
+import { type PipelineOption, PipelineType } from './const';
+
+const quadGeometry = new Geometry({
+  attributes: {
+    aPosition: {
+      buffer: new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]),
+      format: 'float32x2',
+      stride: 2 * 4,
+      offset: 0,
+    },
+  },
+  indexBuffer: new Uint32Array([0, 1, 2, 0, 2, 3]),
+});
+
+export class Anime4kFilter extends Filter {
+  mainTexture: GPUTexture | undefined;
+  _texture: GPUTexture | undefined;
+  public readonly renderPipeId = 'anime4kRender';
+  loadedPipeline: PipelineOption[] = [];
+
+  constructor(pipelines: PipelineOption[]) {
+    const gpuProgram = GpuProgram.from({
+      vertex: {
+        source: wgslVertex,
+        entryPoint: 'mainVertex',
+      },
+      fragment: {
+        source: sampleExternalTextureFrag,
+        entryPoint: 'main',
+      },
+    });
+    super({
+      gpuProgram,
+      resources: {},
+    });
+    this.loadedPipeline = pipelines;
+  }
+  public override apply(
+    filterManager: FilterSystem,
+    input: Texture,
+    output: RenderSurface,
+    clearMode: boolean,
+  ) {
+    const renderer = filterManager.renderer as WebGPURenderer;
+    const globalBindGroup = this.getPixiGlobalBindGroup(
+      filterManager,
+      input,
+      output,
+    );
+    this.groups[0] = globalBindGroup;
+
+    /* FilterSystem done */
+
+    const res = (filterManager.renderer as WebGPURenderer).texture.getGpuSource(
+      /* @ts-ignore */
+      input.source,
+    );
+
+    const sizeOption = {
+      width: input.source.width,
+      height: input.source.height,
+    };
+    const resource = renderer.anime4k.getSizedRenderResource(
+      sizeOption,
+      this.loadedPipeline,
+    );
+
+    /* copy incoming texture to mainTexture so anime4k can process it */
+    renderer.encoder.commandEncoder.copyTextureToTexture(
+      {
+        texture: res,
+      },
+      {
+        texture: resource[0],
+      },
+      [input.source.width, input.source.height, 1],
+    );
+
+    for (const pipe of resource[2]) {
+      pipe.pass(renderer.encoder.commandEncoder);
+    }
+
+    renderer.renderTarget.bind(output, !!clearMode);
+
+    renderer.encoder.setPipelineFromGeometryProgramAndState(
+      quadGeometry,
+      this.gpuProgram,
+      this._state,
+      'triangle-list',
+    );
+    renderer.encoder.setGeometry(quadGeometry, this.gpuProgram);
+
+    /* @ts-ignore */
+    renderer.encoder._syncBindGroup(globalBindGroup);
+    /* @ts-ignore */
+    renderer.encoder._boundBindGroup[0] = globalBindGroup;
+
+    globalBindGroup._touch(renderer.textureGC.count);
+
+    /* create a bindGroup and use last pipeline result as texture */
+    const bindGroup = renderer.anime4k.getSizedBindGroup(sizeOption, {
+      bindGroup: globalBindGroup,
+      program: this.gpuProgram,
+      groupIndex: 0,
+      resourceView: resource[1],
+    });
+
+    renderer.encoder.setPipelineFromGeometryProgramAndState(
+      quadGeometry,
+      this.gpuProgram,
+      this._state,
+      'triangle-list',
+    );
+    renderer.encoder.setGeometry(quadGeometry, this.gpuProgram);
+    renderer.encoder.renderPassEncoder.setBindGroup(0, bindGroup);
+    renderer.encoder.renderPassEncoder.drawIndexed(
+      quadGeometry.indexBuffer.data.length,
+      quadGeometry.instanceCount,
+      0,
+    );
+  }
+  public updatePipeine() {
+    /* TODO */
+  }
+  private getPixiGlobalBindGroup(
+    filterManager: FilterSystem,
+    input: Texture,
+    output: RenderSurface,
+  ) {
+    const renderer = filterManager.renderer as WebGPURenderer;
+    const renderTarget = renderer.renderTarget.getRenderTarget(output);
+    const rootTexture = renderer.renderTarget.rootRenderTarget.colorTexture;
+    /* FilterSystem stuff start, the code is just copy paste from pixi.js v8 src/filters/FilterSystem */
+    /* @ts-ignore */
+    const filterData =
+      /* @ts-ignore */
+      filterManager._filterStack[filterManager._filterStackIndex];
+    const bounds = filterData.bounds;
+
+    const offset = Point.shared;
+
+    const previousRenderSurface = filterData.previousRenderSurface;
+
+    const isFinalTarget = previousRenderSurface === output;
+
+    let resolution = rootTexture.source._resolution;
+
+    // to find the previous resolution we need to account for the skipped filters
+    // the following will find the last non skipped filter...
+    /* @ts-ignore */
+    let currentIndex = this._filterStackIndex - 1;
+
+    /* @ts-ignore */
+    while (currentIndex > 0 && this._filterStack[currentIndex].skip) {
+      --currentIndex;
+    }
+
+    if (currentIndex > 0) {
+      resolution =
+        /* @ts-ignore */
+        this._filterStack[currentIndex].inputTexture.source._resolution;
+    }
+
+    /* it private it also necessary access that here */
+    /* @ts-ignore */
+    const filterUniforms = filterManager._filterGlobalUniforms;
+    const uniforms = filterUniforms.uniforms;
+
+    const outputFrame = uniforms.uOutputFrame;
+    const inputSize = uniforms.uInputSize;
+    const inputPixel = uniforms.uInputPixel;
+    const inputClamp = uniforms.uInputClamp;
+    const globalFrame = uniforms.uGlobalFrame;
+    const outputTexture = uniforms.uOutputTexture;
+
+    // are we rendering back to the original surface?
+    if (isFinalTarget) {
+      /* @ts-ignore */
+      let lastIndex = filterManager._filterStackIndex;
+
+      // get previous bounds.. we must take into account skipped filters also..
+      while (lastIndex > 0) {
+        lastIndex--;
+        const filterData =
+          /* @ts-ignore */
+          filterManager._filterStack[filterManager._filterStackIndex - 1];
+
+        if (!filterData.skip) {
+          offset.x = filterData.bounds.minX;
+          offset.y = filterData.bounds.minY;
+
+          break;
+        }
+      }
+
+      outputFrame[0] = bounds.minX - offset.x;
+      outputFrame[1] = bounds.minY - offset.y;
+    } else {
+      outputFrame[0] = 0;
+      outputFrame[1] = 0;
+    }
+
+    outputFrame[2] = input.frame.width;
+    outputFrame[3] = input.frame.height;
+
+    inputSize[0] = input.source.width;
+    inputSize[1] = input.source.height;
+    inputSize[2] = 1 / inputSize[0];
+    inputSize[3] = 1 / inputSize[1];
+
+    inputPixel[0] = input.source.pixelWidth;
+    inputPixel[1] = input.source.pixelHeight;
+    inputPixel[2] = 1.0 / inputPixel[0];
+    inputPixel[3] = 1.0 / inputPixel[1];
+
+    inputClamp[0] = 0.5 * inputPixel[2];
+    inputClamp[1] = 0.5 * inputPixel[3];
+    inputClamp[2] = input.frame.width * inputSize[2] - 0.5 * inputPixel[2];
+    inputClamp[3] = input.frame.height * inputSize[3] - 0.5 * inputPixel[3];
+
+    globalFrame[0] = offset.x * resolution;
+    globalFrame[1] = offset.y * resolution;
+
+    globalFrame[2] = rootTexture.source.width * resolution;
+    globalFrame[3] = rootTexture.source.height * resolution;
+
+    if (output instanceof Texture) {
+      outputTexture[0] = output.frame.width;
+      outputTexture[1] = output.frame.height;
+    } else {
+      // this means a renderTarget was passed directly
+      outputTexture[0] = renderTarget.width;
+      outputTexture[1] = renderTarget.height;
+    }
+
+    outputTexture[2] = renderTarget.isRoot ? -1 : 1;
+    filterUniforms.update();
+
+    /* @ts-ignore */
+    const globalBindGroup = filterManager._globalFilterBindGroup;
+    if (renderer.renderPipes.uniformBatch) {
+      const batchUniforms =
+        renderer.renderPipes.uniformBatch.getUboResource(filterUniforms);
+
+      globalBindGroup.setResource(batchUniforms, 0);
+    } else {
+      globalBindGroup.setResource(filterUniforms, 0);
+    }
+
+    // now lets update the output texture...
+
+    // set bind group..
+    globalBindGroup.setResource(input.source, 1);
+    globalBindGroup.setResource(input.source.style, 2);
+
+    return globalBindGroup;
+  }
+}
diff --git a/src/renderer/filter/anime4k/Anime4kRenderPipe.ts b/src/renderer/filter/anime4k/Anime4kRenderPipe.ts
deleted file mode 100644
index 4b570ac..0000000
--- a/src/renderer/filter/anime4k/Anime4kRenderPipe.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import {
-  WebGPURenderer,
-  WebGLRenderer,
-  extensions,
-  ExtensionType,
-  type InstructionSet,
-  type InstructionPipe,
-  type Renderer,
-  type RenderContainer,
-} from 'pixi.js';
-
-import type { Anime4kContainer } from './Anime4kContainer';
-
-export class Anime4kRenderPipe implements InstructionPipe<Anime4kContainer> {
-  public static extension = {
-    type: [ExtensionType.WebGPUPipes],
-    name: 'anime4kRender',
-  } as const;
-
-  private _renderer: Renderer;
-
-  constructor(renderer: Renderer) {
-    this._renderer = renderer;
-  }
-
-  public addRenderable(
-    container: Anime4kContainer,
-    instructionSet: InstructionSet,
-  ): void {
-    this._renderer.renderPipes.batch.break(instructionSet);
-
-    instructionSet.add(container);
-  }
-
-  public execute(container: Anime4kContainer) {
-    if (!container.isRenderable) {
-      return;
-    }
-    container.render(this._renderer);
-  }
-
-  public destroy(): void {
-    /* @ts-ignore */
-    this._renderer = null;
-  }
-}
-
-extensions.add(Anime4kRenderPipe);
diff --git a/src/renderer/filter/anime4k/Anime4kSyetem.d.ts b/src/renderer/filter/anime4k/Anime4kSyetem.d.ts
new file mode 100644
index 0000000..deb630d
--- /dev/null
+++ b/src/renderer/filter/anime4k/Anime4kSyetem.d.ts
@@ -0,0 +1,9 @@
+declare global {
+  namespace PixiMixins {
+    interface RendererSystems {
+      anime4k: import('./Anime4kSystem').Anime4kFilterSystem;
+    }
+  }
+}
+
+export {};
diff --git a/src/renderer/filter/anime4k/Anime4kSystem.ts b/src/renderer/filter/anime4k/Anime4kSystem.ts
new file mode 100644
index 0000000..c45a8ac
--- /dev/null
+++ b/src/renderer/filter/anime4k/Anime4kSystem.ts
@@ -0,0 +1,272 @@
+import {
+  extensions,
+  ExtensionType,
+  type WebGPURenderer,
+  type Renderer,
+  type System,
+  type GpuProgram,
+  type BindGroup,
+  type UniformGroup,
+  type TextureStyle,
+  type BufferResource,
+  type Buffer,
+  type BindResource,
+  type GPU,
+} from 'pixi.js';
+import type { Anime4KPipeline } from 'anime4k-webgpu';
+import {
+  PipelineMap,
+  type PipelineType,
+  type PipelineOption,
+  type Anime4kPipelineConstructor,
+} from './const';
+
+export type SizedRenderResourceTuple = [
+  GPUTexture,
+  GPUTextureView,
+  Anime4KPipeline[],
+];
+
+export class Anime4kFilterSystem implements System {
+  public static extension = {
+    type: [ExtensionType.WebGPUSystem],
+    name: 'anime4k',
+  } as const;
+
+  private _renderer: Renderer;
+  private _pipelineMap: Map<PipelineType, Anime4kPipelineConstructor> =
+    new Map();
+  private _sizedPipelineMap: Map<string, Map<string, Anime4KPipeline>> =
+    new Map();
+  private _sizedRenderMap: Map<string, SizedRenderResourceTuple> = new Map();
+  private _sizedBindGroupMap: Map<string, GPUBindGroup> = new Map();
+  private _gpu!: GPU;
+
+  constructor(renderer: Renderer) {
+    this._renderer = renderer;
+  }
+
+  protected contextChange(gpu: GPU): void {
+    this._gpu = gpu;
+  }
+
+  public destroy(): void {
+    /* @ts-ignore */
+    this._renderer = null;
+  }
+  public clear(): void {
+    this._sizedRenderMap.clear();
+    this._sizedBindGroupMap.clear();
+    this._sizedPipelineMap.clear();
+  }
+
+  public preparePipeline(pipelines: PipelineType[]) {
+    const unresolved = pipelines.filter((p) => !this._pipelineMap.has(p));
+    return Promise.all(
+      unresolved.map((p) =>
+        PipelineMap[p]().then((m) =>
+          this._pipelineMap.set(p, m as Anime4kPipelineConstructor),
+        ),
+      ),
+    );
+  }
+
+  private getSizedPipeline(
+    option: { width: number; height: number; inputTexture: GPUTexture },
+    type: PipelineType,
+    index: number,
+  ) {
+    const key = `${option.width}x${option.height}`;
+    let sizedMap = this._sizedPipelineMap.get(key);
+    if (!sizedMap) {
+      sizedMap = new Map();
+      this._sizedPipelineMap.set(key, sizedMap);
+    }
+
+    const pipelineClass = this._pipelineMap.get(type);
+    if (!pipelineClass) {
+      throw new Error(`Pipeline ${type} not prepared or not found`);
+    }
+
+    const pipelineKey = `${type}:${index}`;
+    let targetPipeline = sizedMap.get(pipelineKey);
+
+    if (!targetPipeline) {
+      targetPipeline = new pipelineClass({
+        device: this._gpu.device,
+        inputTexture: option.inputTexture,
+        nativeDimensions: {
+          width: option.width,
+          height: option.height,
+        },
+        targetDimensions: {
+          width: option.width,
+          height: option.height,
+        },
+      });
+      sizedMap.set(pipelineKey, targetPipeline);
+    }
+
+    return targetPipeline;
+  }
+  getSizedRenderResource(
+    option: {
+      width: number;
+      height: number;
+    },
+    pipelines: PipelineOption[],
+  ) {
+    const pipelineHash = pipelines.map((p) => p.pipeline).join(',');
+    const key = `${option.width}x${option.height}:${pipelineHash}`;
+    let resource = this._sizedRenderMap.get(key);
+    if (!resource) {
+      resource = this.setSizedRenderResource(option, pipelines);
+      this._sizedRenderMap.set(key, resource);
+    }
+
+    return resource;
+  }
+  setSizedRenderResource(
+    option: {
+      width: number;
+      height: number;
+    },
+    pipelines: PipelineOption[],
+  ): SizedRenderResourceTuple {
+    const device = this._gpu.device;
+    const mainTexture = device.createTexture({
+      size: [option.width, option.height, 1],
+      format: 'bgra8unorm',
+      usage:
+        GPUTextureUsage.TEXTURE_BINDING |
+        GPUTextureUsage.COPY_DST |
+        GPUTextureUsage.COPY_SRC |
+        GPUTextureUsage.RENDER_ATTACHMENT,
+    });
+
+    const pipelineChain = pipelines.reduce(
+      (chain: Anime4KPipeline[], pipeline, index) => {
+        const inputTexture =
+          index === 0 ? mainTexture : chain[index - 1].getOutputTexture();
+        const pipe = this.getSizedPipeline(
+          {
+            width: option.width,
+            height: option.height,
+            inputTexture,
+          },
+          pipeline.pipeline,
+          index,
+        );
+        /* notice that just direct update, means all thing using this will also got update */
+        if (pipeline.params) {
+          for (const [key, value] of Object.entries(pipeline.params)) {
+            pipe.updateParam(key, value);
+          }
+        }
+        chain.push(pipe);
+        return chain;
+      },
+      [] as Anime4KPipeline[],
+    );
+    const lastPipeline = pipelineChain[pipelineChain.length - 1];
+    return [
+      mainTexture,
+      lastPipeline.getOutputTexture().createView(),
+      pipelineChain,
+    ];
+  }
+  getSizedBindGroup(
+    option: { width: number; height: number },
+    groupOption: {
+      bindGroup: BindGroup;
+      program: GpuProgram;
+      groupIndex: number;
+      resourceView: GPUTextureView;
+    },
+  ) {
+    const key = `${option.width}x${option.height}`;
+    let bindGroup = this._sizedBindGroupMap.get(key);
+    if (!bindGroup) {
+      bindGroup = this.setSizedBindGroup(groupOption);
+      this._sizedBindGroupMap.set(key, bindGroup);
+    }
+    return bindGroup;
+  }
+  setSizedBindGroup(groupOption: {
+    bindGroup: BindGroup;
+    program: GpuProgram;
+    groupIndex: number;
+    resourceView: GPUTextureView;
+  }) {
+    const device = this._gpu.device;
+    const renderer = this._renderer as WebGPURenderer;
+    const { bindGroup, program, groupIndex, resourceView } = groupOption;
+    const groupLayout = program.layout[groupIndex];
+    const entries: GPUBindGroupEntry[] = [];
+
+    for (const j in groupLayout) {
+      const resource: BindResource =
+        bindGroup.resources[j] ?? bindGroup.resources[groupLayout[j]];
+      let gpuResource!:
+        | GPUSampler
+        | GPUTextureView
+        | GPUExternalTexture
+        | GPUBufferBinding;
+      // TODO make this dynamic..
+
+      if (resource._resourceType === 'uniformGroup') {
+        const uniformGroup = resource as UniformGroup;
+
+        renderer.ubo.updateUniformGroup(uniformGroup as UniformGroup);
+
+        const buffer = uniformGroup.buffer as Buffer;
+
+        gpuResource = {
+          buffer: renderer.buffer.getGPUBuffer(buffer),
+          offset: 0,
+          size: buffer.descriptor.size,
+        };
+      } else if (resource._resourceType === 'buffer') {
+        const buffer = resource as Buffer;
+
+        gpuResource = {
+          buffer: renderer.buffer.getGPUBuffer(buffer),
+          offset: 0,
+          size: buffer.descriptor.size,
+        };
+      } else if (resource._resourceType === 'bufferResource') {
+        const bufferResource = resource as BufferResource;
+
+        gpuResource = {
+          buffer: renderer.buffer.getGPUBuffer(bufferResource.buffer),
+          offset: bufferResource.offset,
+          size: bufferResource.size,
+        };
+      } else if (resource._resourceType === 'textureSampler') {
+        const sampler = resource as TextureStyle;
+
+        gpuResource = renderer.texture.getGpuSampler(sampler);
+      } else if (resource._resourceType === 'textureSource') {
+        gpuResource = resourceView;
+      }
+
+      if (gpuResource) {
+        entries.push({
+          binding: groupLayout[j],
+          resource: gpuResource,
+        });
+      }
+    }
+
+    const layout =
+      renderer.shader.getProgramData(program).bindGroups[groupIndex];
+    const gpuBindGroup = device.createBindGroup({
+      layout,
+      entries,
+    });
+
+    return gpuBindGroup;
+  }
+}
+
+extensions.add(Anime4kFilterSystem);
diff --git a/src/renderer/filter/anime4k/const.ts b/src/renderer/filter/anime4k/const.ts
new file mode 100644
index 0000000..f450400
--- /dev/null
+++ b/src/renderer/filter/anime4k/const.ts
@@ -0,0 +1,110 @@
+import type { Anime4KPipeline } from 'anime4k-webgpu';
+
+export enum PipelineType {
+  /**
+   * deblur, must set param: strength
+   *
+   * @param strength Deblur Strength (0.1 - 15.0)
+   */
+  Dog = 'Dog',
+  /**
+   * denoise, must set param: strength, strength2
+   *
+   * @param strength Itensity Sigma (0.1 - 2.0)
+   * @param strength2 Spatial Sigma (1 - 15)
+   */
+  BilateralMean = 'BilateralMean',
+
+  /** restore */
+  CNNM = 'CNNM',
+  /** restore */
+  CNNSoftM = 'CNNSoftM',
+  /** restore */
+  CNNSoftVL = 'CNNSoftVL',
+  /** restore */
+  CNNVL = 'CNNVL',
+  /** restore */
+  CNNUL = 'CNNUL',
+  /** restore */
+  GANUUL = 'GANUUL',
+
+  /** upscale */
+  CNNx2M = 'CNNx2M',
+  /** upscale */
+  CNNx2VL = 'CNNx2VL',
+  /** upscale */
+  DenoiseCNNx2VL = 'DenoiseCNNx2VL',
+  /** upscale */
+  CNNx2UL = 'CNNx2UL',
+  /** upscale */
+  GANx3L = 'GANx3L',
+  /** upscale */
+  GANx4UUL = 'GANx4UUL',
+
+  ClampHighlights = 'ClampHighlights',
+  DepthToSpace = 'DepthToSpace',
+
+  /* anime4k preset @see https://github.com/bloc97/Anime4K/blob/master/md/GLSL_Instructions_Advanced.md */
+  /** Restore -> Upscale -> Upscale */
+  ModeA = 'ModeA',
+  /** Restore_Soft -> Upscale -> Upscale */
+  ModeB = 'ModeB',
+  /** Upscale_Denoise -> Upscale */
+  ModeC = 'ModeC',
+  /** Restore -> Upscale -> Restore -> Upscale */
+  ModeAA = 'ModeAA',
+  /** Restore_Soft -> Upscale -> Restore_Soft -> Upscale */
+  ModeBB = 'ModeBB',
+  /** Upscale_Denoise -> Restore -> Upscale */
+  ModeCA = 'ModeCA',
+}
+
+export const PipelineMap: Record<PipelineType, () => Promise<unknown>> = {
+  [PipelineType.Dog]: () => import('anime4k-webgpu').then((m) => m.DoG),
+  [PipelineType.BilateralMean]: () =>
+    import('anime4k-webgpu').then((m) => m.BilateralMean),
+
+  [PipelineType.CNNM]: () => import('anime4k-webgpu').then((m) => m.CNNM),
+  [PipelineType.CNNSoftM]: () =>
+    import('anime4k-webgpu').then((m) => m.CNNSoftM),
+  [PipelineType.CNNSoftVL]: () =>
+    import('anime4k-webgpu').then((m) => m.CNNSoftVL),
+  [PipelineType.CNNVL]: () => import('anime4k-webgpu').then((m) => m.CNNVL),
+  [PipelineType.CNNUL]: () => import('anime4k-webgpu').then((m) => m.CNNUL),
+  [PipelineType.GANUUL]: () => import('anime4k-webgpu').then((m) => m.GANUUL),
+
+  [PipelineType.CNNx2M]: () => import('anime4k-webgpu').then((m) => m.CNNx2M),
+  [PipelineType.CNNx2VL]: () => import('anime4k-webgpu').then((m) => m.CNNx2VL),
+  [PipelineType.DenoiseCNNx2VL]: () =>
+    import('anime4k-webgpu').then((m) => m.DenoiseCNNx2VL),
+  [PipelineType.CNNx2UL]: () => import('anime4k-webgpu').then((m) => m.CNNx2UL),
+  [PipelineType.GANx3L]: () => import('anime4k-webgpu').then((m) => m.GANx3L),
+  [PipelineType.GANx4UUL]: () =>
+    import('anime4k-webgpu').then((m) => m.GANx4UUL),
+
+  [PipelineType.ClampHighlights]: () =>
+    import('anime4k-webgpu').then((m) => m.ClampHighlights),
+  [PipelineType.DepthToSpace]: () =>
+    import('anime4k-webgpu').then((m) => m.DepthToSpace),
+
+  [PipelineType.ModeA]: () => import('anime4k-webgpu').then((m) => m.ModeA),
+  [PipelineType.ModeB]: () => import('anime4k-webgpu').then((m) => m.ModeB),
+  [PipelineType.ModeC]: () => import('anime4k-webgpu').then((m) => m.ModeC),
+  [PipelineType.ModeAA]: () => import('anime4k-webgpu').then((m) => m.ModeAA),
+  [PipelineType.ModeBB]: () => import('anime4k-webgpu').then((m) => m.ModeBB),
+  [PipelineType.ModeCA]: () => import('anime4k-webgpu').then((m) => m.ModeCA),
+};
+export interface PipelineOption {
+  pipeline: PipelineType;
+  params?: Record<string, number>;
+}
+export type Anime4kPipelineConstructor = new (desc: {
+  device: GPUDevice;
+  inputTexture: GPUTexture;
+  nativeDimensions?: { width: number; height: number };
+  targetDimensions?: { width: number; height: number };
+}) => Anime4KPipeline;
+export interface Anime4kPipelineWithOption {
+  type: PipelineType;
+  params?: Record<string, number>;
+}
diff --git a/src/renderer/filter/anime4k/index.ts b/src/renderer/filter/anime4k/index.ts
index 0419db2..1e5f5a8 100644
--- a/src/renderer/filter/anime4k/index.ts
+++ b/src/renderer/filter/anime4k/index.ts
@@ -114,7 +114,9 @@ export async function createGpuDevice(canvas: HTMLCanvasElement) {
   if (!adapter) {
     throw new Error('WebGPU is not supported');
   }
-  const device = await adapter.requestDevice();
+  const device = await adapter.requestDevice({
+    label: 'Test Anime4k GPU Device',
+  });
   const context = canvas.getContext('webgpu') as GPUCanvasContext;
   const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
   context.configure({
diff --git a/src/renderer/filter/anime4k/sampleExternalTexture.wgsl b/src/renderer/filter/anime4k/sampleExternalTexture.wgsl
index 2305e0b..d501425 100644
--- a/src/renderer/filter/anime4k/sampleExternalTexture.wgsl
+++ b/src/renderer/filter/anime4k/sampleExternalTexture.wgsl
@@ -1,7 +1,7 @@
-@group(0) @binding(1) var mySampler: sampler;
-@group(0) @binding(2) var myTexture: texture_2d<f32>;
+@group(0) @binding(1) var uTexture: texture_2d<f32>; 
+@group(0) @binding(2) var uSampler: sampler;
 
 @fragment
 fn main(@location(0) fragUV : vec2f) -> @location(0) vec4f {
-  return textureSampleBaseClampToEdge(myTexture, mySampler, fragUV);
+  return textureSampleBaseClampToEdge(uTexture, uSampler, fragUV);
 }
\ No newline at end of file

From 6b0b5ebe678cb74af1ff9350981e69bee1a4fcd7 Mon Sep 17 00:00:00 2001
From: spd789562 <leo.yicun.lin@gmail.com>
Date: Wed, 28 Aug 2024 00:58:43 +0800
Subject: [PATCH 07/25] [edit] filter should apply on viewport

---
 src/components/CharacterPreview/Character.tsx | 32 +++++++++----------
 .../CharacterPreview/CharacterScene.tsx       | 10 +-----
 src/renderer/filter/anime4k/Anime4kSystem.ts  |  4 ++-
 3 files changed, 19 insertions(+), 27 deletions(-)

diff --git a/src/components/CharacterPreview/Character.tsx b/src/components/CharacterPreview/Character.tsx
index b95b56f..f7e0420 100644
--- a/src/components/CharacterPreview/Character.tsx
+++ b/src/components/CharacterPreview/Character.tsx
@@ -128,24 +128,22 @@ export const CharacterView = (props: CharacterViewProps) => {
   });
 
   createEffect(async () => {
-    if (isShowUpscale()) {
-      await app.renderer.anime4k.preparePipeline([PipelineType.ModeBB]);
-      if (!upscaleFilter) {
-        upscaleFilter = new Anime4kFilter([
-          {
-            pipeline: PipelineType.ModeBB,
-            // params: {
-            //   strength: 0.5,
-            //   strength2: 1,
-            // },
-          } as PipelineOption,
-        ]);
+    if (isInit() && viewport) {
+      if (isShowUpscale()) {
+        await app.renderer.anime4k.preparePipeline([PipelineType.ModeBB]);
+        if (!upscaleFilter) {
+          upscaleFilter = new Anime4kFilter([
+            {
+              pipeline: PipelineType.ModeBB,
+            },
+          ] as PipelineOption[]);
+        }
+        /* TODO */
+        upscaleFilter.updatePipeine();
+        viewport.filters = [upscaleFilter];
+      } else {
+        viewport.filters = [];
       }
-      /* TODO */
-      upscaleFilter.updatePipeine();
-      app.stage.filters = [upscaleFilter];
-    } else {
-      app.stage.filters = [];
     }
   });
 
diff --git a/src/components/CharacterPreview/CharacterScene.tsx b/src/components/CharacterPreview/CharacterScene.tsx
index c96c023..a7a4243 100644
--- a/src/components/CharacterPreview/CharacterScene.tsx
+++ b/src/components/CharacterPreview/CharacterScene.tsx
@@ -8,15 +8,11 @@ import {
   $previewCharacter,
   $sceneCustomColorStyle,
 } from '@/store/character/selector';
-import {
-  $showPreviousCharacter,
-  $showUpscaledCharacter,
-} from '@/store/trigger';
+import { $showPreviousCharacter } from '@/store/trigger';
 
 import LoaderCircle from 'lucide-solid/icons/loader-circle';
 import ChevronRightIcon from 'lucide-solid/icons/chevron-right';
 import { CharacterView } from './Character';
-import { UpscaleCharacter } from './UpscaleCharacter';
 import { CharacterSceneSelection } from './CharacterSceneSelection';
 import { ShowPreviousSwitch } from './ShowPreviousSwitch';
 import { ShowUpscaleSwitch } from './ShowUpscaleSwitch';
@@ -31,7 +27,6 @@ export const CharacterScene = () => {
   const scene = useStore($currentScene);
   const customColorStyle = useStore($sceneCustomColorStyle);
   const isShowComparison = useStore($showPreviousCharacter);
-  const isShowUpscale = useStore($showUpscaledCharacter);
 
   function handleLoad() {
     setIsLoading(true);
@@ -89,9 +84,6 @@ export const CharacterScene = () => {
         target="preview"
         isLockInteraction={isLockInteraction()}
       />
-      <Show when={isShowUpscale()}>
-        <UpscaleCharacter />
-      </Show>
       <TopTool>
         <ShowPreviousSwitch />
         <ShowUpscaleSwitch />
diff --git a/src/renderer/filter/anime4k/Anime4kSystem.ts b/src/renderer/filter/anime4k/Anime4kSystem.ts
index c45a8ac..d2a54ca 100644
--- a/src/renderer/filter/anime4k/Anime4kSystem.ts
+++ b/src/renderer/filter/anime4k/Anime4kSystem.ts
@@ -38,7 +38,8 @@ export class Anime4kFilterSystem implements System {
     new Map();
   private _sizedPipelineMap: Map<string, Map<string, Anime4KPipeline>> =
     new Map();
-  private _sizedRenderMap: Map<string, SizedRenderResourceTuple> = new Map();
+  private _sizedRenderMap: Map<string, SizedRenderResourceTuple> =
+    new Map();
   private _sizedBindGroupMap: Map<string, GPUBindGroup> = new Map();
   private _gpu!: GPU;
 
@@ -118,6 +119,7 @@ export class Anime4kFilterSystem implements System {
   ) {
     const pipelineHash = pipelines.map((p) => p.pipeline).join(',');
     const key = `${option.width}x${option.height}:${pipelineHash}`;
+    console.log(key);
     let resource = this._sizedRenderMap.get(key);
     if (!resource) {
       resource = this.setSizedRenderResource(option, pipelines);

From ceeddfba9288ebbf429ca42dd55af1f4453bc516 Mon Sep 17 00:00:00 2001
From: spd789562 <leo.yicun.lin@gmail.com>
Date: Wed, 28 Aug 2024 16:23:05 +0800
Subject: [PATCH 08/25] [add] other setting and show current version at setting

---
 .../OtherSetting/OpenFolderButton.tsx         | 39 +++++++++++++++++++
 .../SettingDialog/OtherSetting/index.tsx      | 20 ++++++++++
 .../RenderSetting/UpscaleSwitch.tsx           |  2 +-
 .../SettingDialog/SettingFooter/index.tsx     | 20 ++++++++++
 src/components/dialog/SettingDialog/index.tsx |  4 ++
 5 files changed, 84 insertions(+), 1 deletion(-)
 create mode 100644 src/components/dialog/SettingDialog/OtherSetting/OpenFolderButton.tsx
 create mode 100644 src/components/dialog/SettingDialog/OtherSetting/index.tsx
 create mode 100644 src/components/dialog/SettingDialog/SettingFooter/index.tsx

diff --git a/src/components/dialog/SettingDialog/OtherSetting/OpenFolderButton.tsx b/src/components/dialog/SettingDialog/OtherSetting/OpenFolderButton.tsx
new file mode 100644
index 0000000..58f2c84
--- /dev/null
+++ b/src/components/dialog/SettingDialog/OtherSetting/OpenFolderButton.tsx
@@ -0,0 +1,39 @@
+import type { JSX } from 'solid-js';
+import { appCacheDir, appDataDir } from '@tauri-apps/api/path';
+import { open } from '@tauri-apps/plugin-shell';
+
+import FolderIcon from 'lucide-solid/icons/folder-symlink';
+import { Button } from '@/components/ui/button';
+
+import { toaster } from '@/components/GlobalToast';
+
+export enum PathType {
+  Data = 'data',
+  Cache = 'cache',
+}
+export interface OpenFolderButtonProps {
+  type: PathType;
+  title: string;
+  children: JSX.Element;
+}
+export const OpenFolderButton = (props: OpenFolderButtonProps) => {
+  async function handleClick() {
+    const folder = await (props.type === PathType.Data
+      ? appDataDir()
+      : appCacheDir());
+    try {
+      await open(folder);
+    } catch (_) {
+      toaster.error({
+        title: '開啟路徑時發生錯誤',
+      });
+    }
+  }
+
+  return (
+    <Button onClick={handleClick} title={props.title} variant="outline">
+      {props.children}
+      <FolderIcon />
+    </Button>
+  );
+};
diff --git a/src/components/dialog/SettingDialog/OtherSetting/index.tsx b/src/components/dialog/SettingDialog/OtherSetting/index.tsx
new file mode 100644
index 0000000..52211f7
--- /dev/null
+++ b/src/components/dialog/SettingDialog/OtherSetting/index.tsx
@@ -0,0 +1,20 @@
+import { Stack } from 'styled-system/jsx/stack';
+import { HStack } from 'styled-system/jsx/hstack';
+import { Heading } from '@/components/ui/heading';
+import { OpenFolderButton, PathType } from './OpenFolderButton';
+
+export const OtherSetting = () => {
+  return (
+    <Stack>
+      <Heading size="lg">其他</Heading>
+      <HStack justify="flex-start">
+        <OpenFolderButton type={PathType.Data} title="開啟存檔資料夾">
+          存檔資料夾
+        </OpenFolderButton>
+        <OpenFolderButton type={PathType.Cache} title="開啟暫存資料夾">
+          暫存資料夾
+        </OpenFolderButton>
+      </HStack>
+    </Stack>
+  );
+};
diff --git a/src/components/dialog/SettingDialog/RenderSetting/UpscaleSwitch.tsx b/src/components/dialog/SettingDialog/RenderSetting/UpscaleSwitch.tsx
index fd5b938..9c4019d 100644
--- a/src/components/dialog/SettingDialog/RenderSetting/UpscaleSwitch.tsx
+++ b/src/components/dialog/SettingDialog/RenderSetting/UpscaleSwitch.tsx
@@ -24,7 +24,7 @@ export const UpscaleSwitch = () => {
     >
       <HStack gap="1">
         <Text>實驗性高清預覽</Text>
-        <SettingTooltip tooltip="新增按鈕顯示高清化的角色預覽,開啟時顯示高清版(Anime4K)的圖片在旁邊,此功能可能造成極大的效能影響,請確認有足夠的電腦資源再使用" />
+        <SettingTooltip tooltip="新增按鈕顯示高清化的角色預覽,開啟時顯示高清版(Anime4K)的角色預覽,此功能可能造成一些效能影響,請確認有足夠的電腦資源再使用" />
       </HStack>
     </Switch>
   );
diff --git a/src/components/dialog/SettingDialog/SettingFooter/index.tsx b/src/components/dialog/SettingDialog/SettingFooter/index.tsx
new file mode 100644
index 0000000..6dd6400
--- /dev/null
+++ b/src/components/dialog/SettingDialog/SettingFooter/index.tsx
@@ -0,0 +1,20 @@
+import { createSignal, onMount } from 'solid-js';
+import { getVersion } from '@tauri-apps/api/app';
+
+import { HStack } from 'styled-system/jsx';
+import { Text } from '@/components/ui/text';
+
+export const SettingFooter = () => {
+  const [version, setVersion] = createSignal<string>();
+  onMount(async () => {
+    setVersion(await getVersion());
+  });
+
+  return (
+    <HStack>
+      <Text marginLeft="auto" size="sm" color="fg.subtle">
+        當前版本: {version()}
+      </Text>
+    </HStack>
+  );
+};
diff --git a/src/components/dialog/SettingDialog/index.tsx b/src/components/dialog/SettingDialog/index.tsx
index 752c79c..03c320d 100644
--- a/src/components/dialog/SettingDialog/index.tsx
+++ b/src/components/dialog/SettingDialog/index.tsx
@@ -4,6 +4,8 @@ import { SettingDialog as Dialog } from './SettingDialog';
 import { WindowSetting } from './WindowSetting';
 import { ThemeSetting } from './ThemeSetting';
 import { RenderSetting } from './RenderSetting';
+import { OtherSetting } from './OtherSetting';
+import { SettingFooter } from './SettingFooter';
 
 export const SettingDialog = () => {
   return (
@@ -13,6 +15,8 @@ export const SettingDialog = () => {
         <WindowSetting />
         <ThemeSetting />
         <RenderSetting />
+        <OtherSetting />
+        <SettingFooter />
       </Stack>
     </Dialog>
   );

From 5510de9c8d782746b03b1a21278f5f2e93c84dab Mon Sep 17 00:00:00 2001
From: spd789562 <leo.yicun.lin@gmail.com>
Date: Wed, 28 Aug 2024 16:24:06 +0800
Subject: [PATCH 09/25] [edit] fix isEar match will make some item disappear

---
 src/renderer/character/categorizedItem.ts    | 3 ++-
 src/renderer/filter/anime4k/Anime4kFilter.ts | 2 +-
 2 files changed, 3 insertions(+), 2 deletions(-)

diff --git a/src/renderer/character/categorizedItem.ts b/src/renderer/character/categorizedItem.ts
index e4b654d..9dd97ae 100644
--- a/src/renderer/character/categorizedItem.ts
+++ b/src/renderer/character/categorizedItem.ts
@@ -192,7 +192,8 @@ export abstract class CategorizedItem<Name extends string> {
           continue;
         }
 
-        const isEar = pieceName.match(/ear/i);
+        /* some thing like capeArm also has ear... so only do end with here */
+        const isEar = pieceName.match(/ear$/i);
 
         const name = isEar
           ? pieceName
diff --git a/src/renderer/filter/anime4k/Anime4kFilter.ts b/src/renderer/filter/anime4k/Anime4kFilter.ts
index e4acd91..16e572e 100644
--- a/src/renderer/filter/anime4k/Anime4kFilter.ts
+++ b/src/renderer/filter/anime4k/Anime4kFilter.ts
@@ -13,7 +13,7 @@ import './Anime4kSystem';
 
 import sampleExternalTextureFrag from './sampleExternalTexture.wgsl';
 
-import { type PipelineOption, PipelineType } from './const';
+import type { PipelineOption } from './const';
 
 const quadGeometry = new Geometry({
   attributes: {

From 504a6ce1fe2ebc55adca5ac5e1f7fc5999682b1b Mon Sep 17 00:00:00 2001
From: spd789562 <leo.yicun.lin@gmail.com>
Date: Wed, 28 Aug 2024 16:53:37 +0800
Subject: [PATCH 10/25] [edit] make sure enableExperimentalUpscale is got
 update

---
 src/components/CharacterPreview/Character.tsx | 20 +++++++++++--------
 src/renderer/filter/anime4k/Anime4kFilter.ts  |  7 ++-----
 src/renderer/filter/anime4k/Anime4kSystem.ts  |  1 -
 src/store/settingDialog.ts                    |  4 ++++
 4 files changed, 18 insertions(+), 14 deletions(-)

diff --git a/src/components/CharacterPreview/Character.tsx b/src/components/CharacterPreview/Character.tsx
index f7e0420..39104c8 100644
--- a/src/components/CharacterPreview/Character.tsx
+++ b/src/components/CharacterPreview/Character.tsx
@@ -130,16 +130,20 @@ export const CharacterView = (props: CharacterViewProps) => {
   createEffect(async () => {
     if (isInit() && viewport) {
       if (isShowUpscale()) {
-        await app.renderer.anime4k.preparePipeline([PipelineType.ModeBB]);
+        /* to be configurable in the future */
+        const upscalePipelines = [
+          {
+            pipeline: PipelineType.ModeBB,
+          },
+        ] as PipelineOption[];
+
         if (!upscaleFilter) {
-          upscaleFilter = new Anime4kFilter([
-            {
-              pipeline: PipelineType.ModeBB,
-            },
-          ] as PipelineOption[]);
+          await app.renderer.anime4k.preparePipeline(
+            upscalePipelines.map((p) => p.pipeline),
+          );
+          upscaleFilter = new Anime4kFilter(upscalePipelines);
         }
-        /* TODO */
-        upscaleFilter.updatePipeine();
+        // upscaleFilter.updatePipeine(upscalePipelines);
         viewport.filters = [upscaleFilter];
       } else {
         viewport.filters = [];
diff --git a/src/renderer/filter/anime4k/Anime4kFilter.ts b/src/renderer/filter/anime4k/Anime4kFilter.ts
index 16e572e..2680c06 100644
--- a/src/renderer/filter/anime4k/Anime4kFilter.ts
+++ b/src/renderer/filter/anime4k/Anime4kFilter.ts
@@ -28,9 +28,6 @@ const quadGeometry = new Geometry({
 });
 
 export class Anime4kFilter extends Filter {
-  mainTexture: GPUTexture | undefined;
-  _texture: GPUTexture | undefined;
-  public readonly renderPipeId = 'anime4kRender';
   loadedPipeline: PipelineOption[] = [];
 
   constructor(pipelines: PipelineOption[]) {
@@ -134,8 +131,8 @@ export class Anime4kFilter extends Filter {
       0,
     );
   }
-  public updatePipeine() {
-    /* TODO */
+  public updatePipeine(pipelines: PipelineOption[]) {
+    this.loadedPipeline = pipelines;
   }
   private getPixiGlobalBindGroup(
     filterManager: FilterSystem,
diff --git a/src/renderer/filter/anime4k/Anime4kSystem.ts b/src/renderer/filter/anime4k/Anime4kSystem.ts
index d2a54ca..3b1e8a7 100644
--- a/src/renderer/filter/anime4k/Anime4kSystem.ts
+++ b/src/renderer/filter/anime4k/Anime4kSystem.ts
@@ -119,7 +119,6 @@ export class Anime4kFilterSystem implements System {
   ) {
     const pipelineHash = pipelines.map((p) => p.pipeline).join(',');
     const key = `${option.width}x${option.height}:${pipelineHash}`;
-    console.log(key);
     let resource = this._sizedRenderMap.get(key);
     if (!resource) {
       resource = this.setSizedRenderResource(option, pipelines);
diff --git a/src/store/settingDialog.ts b/src/store/settingDialog.ts
index 758c2a4..38c7f7b 100644
--- a/src/store/settingDialog.ts
+++ b/src/store/settingDialog.ts
@@ -96,6 +96,10 @@ export async function initializeSavedSetting() {
       $appSetting.setKey('windowResizable', !!setting.windowResizable);
       $appSetting.setKey('showItemGender', setting.showItemGender ?? true);
       $appSetting.setKey('showItemDyeable', setting.showItemDyeable ?? true);
+      $appSetting.setKey(
+        'enableExperimentalUpscale',
+        !!setting.enableExperimentalUpscale,
+      );
       const defaultCharacterRendering = !!setting.defaultCharacterRendering;
       if (defaultCharacterRendering) {
         $appSetting.setKey('defaultCharacterRendering', true);

From b18746a18c29c9cce68a31cd9a2cc4755e997561 Mon Sep 17 00:00:00 2001
From: spd789562 <leo.yicun.lin@gmail.com>
Date: Wed, 28 Aug 2024 19:02:43 +0800
Subject: [PATCH 11/25] [add] prepare item dye table related store

---
 src/const/toolTab.ts |  2 ++
 src/store/toolTab.ts | 64 +++++++++++++++++++++++++++++++++++++++++++-
 2 files changed, 65 insertions(+), 1 deletion(-)

diff --git a/src/const/toolTab.ts b/src/const/toolTab.ts
index 68afaf4..bf794fe 100644
--- a/src/const/toolTab.ts
+++ b/src/const/toolTab.ts
@@ -2,12 +2,14 @@ export enum ToolTab {
   AllAction = 'allAction',
   HairDye = 'hairDye',
   FaceDye = 'faceDye',
+  ItemDye = 'itemDye',
 }
 
 export const ToolTabNames: Record<ToolTab, string> = {
   [ToolTab.AllAction]: '全部動作',
   [ToolTab.HairDye]: '髮型顏色',
   [ToolTab.FaceDye]: '臉型顏色',
+  [ToolTab.ItemDye]: '裝備染色表',
 };
 
 export enum ActionExportType {
diff --git a/src/store/toolTab.ts b/src/store/toolTab.ts
index be06784..57a29a5 100644
--- a/src/store/toolTab.ts
+++ b/src/store/toolTab.ts
@@ -1,7 +1,69 @@
-import { atom } from 'nanostores';
+import { atom, deepMap } from 'nanostores';
 
+import type { EquipSubCategory } from '@/const/equipments';
 import { type ToolTab, ActionExportType } from '@/const/toolTab';
+import { CharacterAction } from '@/const/actions';
 
 export const $toolTab = atom<ToolTab | undefined>(undefined);
 
 export const $actionExportType = atom<ActionExportType>(ActionExportType.Gif);
+
+export const $onlyShowDyeable = atom<boolean>(false);
+export const $preserveOriginalDye = atom<boolean>(false);
+export const $selectedEquipSubCategory = atom<EquipSubCategory[]>([]);
+export const $dyeResultCount = atom<number>(72);
+export const $dyeAction = atom<CharacterAction>(CharacterAction.Stand1);
+
+export enum DyeOrder {
+  Up = 'up',
+  Down = 'down',
+}
+export interface DyeConfigOption {
+  enabled: boolean;
+  order: DyeOrder;
+}
+export type DyeConfig = {
+  hue: DyeConfigOption;
+  saturation: DyeConfigOption;
+  lightness: DyeConfigOption;
+};
+export const $dyeConfig = deepMap({
+  hue: {
+    enabled: false,
+    order: DyeOrder.Up,
+  },
+  saturation: {
+    enabled: false,
+    order: DyeOrder.Up,
+  },
+  lightness: {
+    enabled: false,
+    order: DyeOrder.Up,
+  },
+});
+
+/* actions */
+export function toggleDyeConfigEnabled(key: keyof DyeConfig, value: boolean) {
+  $dyeConfig.setKey(`${key}.enabled`, value);
+}
+export function toggleDyeConfigOrder(key: keyof DyeConfig, order: DyeOrder) {
+  $dyeConfig.setKey(`${key}.order`, order);
+}
+export function selectDyeCategory(category: EquipSubCategory) {
+  const current = $selectedEquipSubCategory.get();
+  if (current.includes(category)) {
+    return;
+  }
+  $selectedEquipSubCategory.set([...$selectedEquipSubCategory.get(), category]);
+}
+export function deselectDyeCategory(category: EquipSubCategory) {
+  $selectedEquipSubCategory.set(
+    $selectedEquipSubCategory.get().filter((c) => c !== category),
+  );
+}
+export function setDyeResultCount(count: number) {
+  $dyeResultCount.set(count);
+}
+export function setDyeAction(action: CharacterAction) {
+  $dyeAction.set(action);
+}

From aa33138ad5bf9836d3ff0c7d2d66f122a000824e Mon Sep 17 00:00:00 2001
From: spd789562 <leo.yicun.lin@gmail.com>
Date: Thu, 29 Aug 2024 11:08:23 +0800
Subject: [PATCH 12/25] [add] IconTooltip

---
 .../dialog/SettingDialog/SettingTooltip.tsx   |  7 ++---
 src/components/elements/IconTooltip.tsx       | 29 +++++++++++++++++++
 2 files changed, 31 insertions(+), 5 deletions(-)
 create mode 100644 src/components/elements/IconTooltip.tsx

diff --git a/src/components/dialog/SettingDialog/SettingTooltip.tsx b/src/components/dialog/SettingDialog/SettingTooltip.tsx
index 7cfdfd6..6785c21 100644
--- a/src/components/dialog/SettingDialog/SettingTooltip.tsx
+++ b/src/components/dialog/SettingDialog/SettingTooltip.tsx
@@ -1,13 +1,10 @@
-import InfoIcon from 'lucide-solid/icons/info';
-import { SimpleTooltip } from '@/components/ui/tooltip';
+import { IconTooltop, IconType } from '@/components/elements/IconTooltip';
 
 export interface SettingTooltipProps {
   tooltip: string;
 }
 export const SettingTooltip = (props: SettingTooltipProps) => {
   return (
-    <SimpleTooltip zIndex={2300} tooltip={props.tooltip}>
-      <InfoIcon color="currentColor" size="16" />
-    </SimpleTooltip>
+    <IconTooltop type={IconType.Info} zIndex={2300} tooltip={props.tooltip} />
   );
 };
diff --git a/src/components/elements/IconTooltip.tsx b/src/components/elements/IconTooltip.tsx
new file mode 100644
index 0000000..2b643b1
--- /dev/null
+++ b/src/components/elements/IconTooltip.tsx
@@ -0,0 +1,29 @@
+import { Match, Switch } from 'solid-js';
+import InfoIcon from 'lucide-solid/icons/info';
+import CircleHelpIcon from 'lucide-solid/icons/circle-help';
+import { SimpleTooltip } from '@/components/ui/tooltip';
+
+export enum IconType {
+  Info = 'info',
+  Question = 'question',
+}
+export interface IconTooltopProps {
+  tooltip: string;
+  type: IconType;
+  zIndex?: number;
+  size?: number;
+}
+export const IconTooltop = (props: IconTooltopProps) => {
+  return (
+    <SimpleTooltip zIndex={props.zIndex} tooltip={props.tooltip}>
+      <Switch>
+        <Match when={props.type === IconType.Info}>
+          <InfoIcon color="currentColor" size={props.size || '16'} />
+        </Match>
+        <Match when={props.type === IconType.Question}>
+          <CircleHelpIcon color="currentColor" size={props.size || '16'} />
+        </Match>
+      </Switch>
+    </SimpleTooltip>
+  );
+};

From 6fbac26573d0049e5ec69e027234bbb75ed83b5b Mon Sep 17 00:00:00 2001
From: spd789562 <leo.yicun.lin@gmail.com>
Date: Thu, 29 Aug 2024 15:48:43 +0800
Subject: [PATCH 13/25] [add] radioGroup base ui

---
 src/components/ui/radioGroup.tsx  | 49 +++++++++++++++++++++++++++++++
 src/components/ui/toggleGroup.tsx |  6 ++--
 2 files changed, 51 insertions(+), 4 deletions(-)
 create mode 100644 src/components/ui/radioGroup.tsx

diff --git a/src/components/ui/radioGroup.tsx b/src/components/ui/radioGroup.tsx
new file mode 100644
index 0000000..9bf6eeb
--- /dev/null
+++ b/src/components/ui/radioGroup.tsx
@@ -0,0 +1,49 @@
+import { type Assign, RadioGroup } from '@ark-ui/solid';
+import type { ComponentProps } from 'solid-js';
+import { type RadioGroupVariantProps, radioGroup } from 'styled-system/recipes';
+import type { HTMLStyledProps } from 'styled-system/types';
+import { createStyleContext } from '@/utils/create-style-context';
+
+const { withProvider, withContext } = createStyleContext(radioGroup);
+
+export type RootProviderProps = ComponentProps<typeof RootProvider>;
+export const RootProvider = withProvider<
+  Assign<
+    Assign<HTMLStyledProps<'div'>, RadioGroup.RootProviderBaseProps>,
+    RadioGroupVariantProps
+  >
+>(RadioGroup.RootProvider, 'root');
+
+export type RootProps = ComponentProps<typeof Root>;
+export const Root = withProvider<
+  Assign<
+    Assign<HTMLStyledProps<'div'>, RadioGroup.RootBaseProps>,
+    RadioGroupVariantProps
+  >
+>(RadioGroup.Root, 'root');
+
+export const Indicator = withContext<
+  Assign<HTMLStyledProps<'div'>, RadioGroup.IndicatorBaseProps>
+>(RadioGroup.Indicator, 'indicator');
+
+export const ItemControl = withContext<
+  Assign<HTMLStyledProps<'div'>, RadioGroup.ItemControlBaseProps>
+>(RadioGroup.ItemControl, 'itemControl');
+
+export const Item = withContext<
+  Assign<HTMLStyledProps<'label'>, RadioGroup.ItemBaseProps>
+>(RadioGroup.Item, 'item');
+
+export const ItemText = withContext<
+  Assign<HTMLStyledProps<'span'>, RadioGroup.ItemTextBaseProps>
+>(RadioGroup.ItemText, 'itemText');
+
+export const Label = withContext<
+  Assign<HTMLStyledProps<'label'>, RadioGroup.LabelBaseProps>
+>(RadioGroup.Label, 'label');
+
+export {
+  RadioGroupContext as Context,
+  RadioGroupItemHiddenInput as ItemHiddenInput,
+  type RadioGroupValueChangeDetails as ValueChangeDetails,
+} from '@ark-ui/solid';
diff --git a/src/components/ui/toggleGroup.tsx b/src/components/ui/toggleGroup.tsx
index b18e0a8..c51b24d 100644
--- a/src/components/ui/toggleGroup.tsx
+++ b/src/components/ui/toggleGroup.tsx
@@ -16,10 +16,8 @@ export interface RootProps
     ToggleGroupVariantProps {}
 export const Root = withProvider<RootProps>(ToggleGroup.Root, 'root');
 
-export const Item = withContext<Assign<JsxStyleProps, ToggleGroup.ItemProps>>(
-  ToggleGroup.Item,
-  'item',
-);
+export type ItemProps = Assign<JsxStyleProps, ToggleGroup.ItemProps>;
+export const Item = withContext<ItemProps>(ToggleGroup.Item, 'item');
 
 export {
   ToggleGroupContext as Context,

From 69bf49cab0996e38e75b2db62c8d71637b32d64c Mon Sep 17 00:00:00 2001
From: spd789562 <leo.yicun.lin@gmail.com>
Date: Thu, 29 Aug 2024 15:50:04 +0800
Subject: [PATCH 14/25] [edit] move sharable part of ActionSelect to elements

---
 .../CharacterPreview/ActionSelect.tsx         |  94 +---------------
 src/components/elements/ActionSelect.tsx      | 100 ++++++++++++++++++
 src/components/elements/LoadableEquipIcon.tsx |   6 +-
 3 files changed, 110 insertions(+), 90 deletions(-)
 create mode 100644 src/components/elements/ActionSelect.tsx

diff --git a/src/components/CharacterPreview/ActionSelect.tsx b/src/components/CharacterPreview/ActionSelect.tsx
index aa7c4b7..96471c7 100644
--- a/src/components/CharacterPreview/ActionSelect.tsx
+++ b/src/components/CharacterPreview/ActionSelect.tsx
@@ -3,101 +3,17 @@ import { useStore } from '@nanostores/solid';
 import { $currentCharacterInfo } from '@/store/character/store';
 import { $currentAction } from '@/store/character/selector';
 
-import { SimpleSelect, type ValueChangeDetails } from '@/components/ui/select';
+import { ActionSelect as BaseActionSelect } from '@/components/elements/ActionSelect';
 
-import { CharacterAction } from '@/const/actions';
-
-const options = [
-  {
-    label: '站立',
-    value: CharacterAction.Stand1,
-  },
-  {
-    label: '坐下',
-    value: CharacterAction.Sit,
-  },
-  {
-    label: '走路',
-    value: CharacterAction.Walk1,
-  },
-  {
-    label: '跳躍',
-    value: CharacterAction.Jump,
-  },
-  {
-    label: '飛行/游泳',
-    value: CharacterAction.Fly,
-  },
-  {
-    label: '攀爬(梯子)',
-    value: CharacterAction.Ladder,
-  },
-  {
-    label: '攀爬(繩子)',
-    value: CharacterAction.Rope,
-  },
-  {
-    label: '警戒',
-    value: CharacterAction.Alert,
-  },
-  {
-    label: '施放',
-    value: CharacterAction.Heal,
-  },
-  {
-    label: '趴下',
-    value: CharacterAction.Prone,
-  },
-  {
-    label: '趴下攻擊',
-    value: CharacterAction.ProneStab,
-  },
-  ...[
-    CharacterAction.Shoot1,
-    CharacterAction.Shoot2,
-    CharacterAction.ShootF,
-    CharacterAction.Sit,
-    CharacterAction.StabO1,
-    CharacterAction.StabO2,
-    CharacterAction.StabOF,
-    CharacterAction.StabT1,
-    CharacterAction.StabT2,
-    CharacterAction.StabTF,
-    CharacterAction.SwingO1,
-    CharacterAction.SwingO2,
-    CharacterAction.SwingO3,
-    CharacterAction.SwingOF,
-    CharacterAction.SwingP1,
-    CharacterAction.SwingP2,
-    CharacterAction.SwingPF,
-    CharacterAction.SwingT1,
-    CharacterAction.SwingT2,
-    CharacterAction.SwingT3,
-    CharacterAction.SwingTF,
-  ].map((value) => ({
-    label: value,
-    value,
-  })),
-];
+import type { CharacterAction } from '@/const/actions';
 
 export const ActionSelect = () => {
   const action = useStore($currentAction);
-  function handleActionChange(details: ValueChangeDetails) {
-    const firstItem = details.value?.[0];
-    firstItem &&
-      $currentCharacterInfo.setKey('action', firstItem as CharacterAction);
+  function handleActionChange(action: CharacterAction | undefined) {
+    action && $currentCharacterInfo.setKey('action', action);
   }
 
   return (
-    <SimpleSelect
-      positioning={{
-        sameWidth: true,
-      }}
-      items={options}
-      value={[action()]}
-      onValueChange={handleActionChange}
-      groupTitle="角色動作"
-      maxHeight="20rem"
-    />
+    <BaseActionSelect value={action()} onValueChange={handleActionChange} />
   );
 };
diff --git a/src/components/elements/ActionSelect.tsx b/src/components/elements/ActionSelect.tsx
new file mode 100644
index 0000000..74a39c7
--- /dev/null
+++ b/src/components/elements/ActionSelect.tsx
@@ -0,0 +1,100 @@
+import { SimpleSelect, type ValueChangeDetails } from '@/components/ui/select';
+
+import { CharacterAction } from '@/const/actions';
+
+const options = [
+  {
+    label: '站立',
+    value: CharacterAction.Stand1,
+  },
+  {
+    label: '坐下',
+    value: CharacterAction.Sit,
+  },
+  {
+    label: '走路',
+    value: CharacterAction.Walk1,
+  },
+  {
+    label: '跳躍',
+    value: CharacterAction.Jump,
+  },
+  {
+    label: '飛行/游泳',
+    value: CharacterAction.Fly,
+  },
+  {
+    label: '攀爬(梯子)',
+    value: CharacterAction.Ladder,
+  },
+  {
+    label: '攀爬(繩子)',
+    value: CharacterAction.Rope,
+  },
+  {
+    label: '警戒',
+    value: CharacterAction.Alert,
+  },
+  {
+    label: '施放',
+    value: CharacterAction.Heal,
+  },
+  {
+    label: '趴下',
+    value: CharacterAction.Prone,
+  },
+  {
+    label: '趴下攻擊',
+    value: CharacterAction.ProneStab,
+  },
+  ...[
+    CharacterAction.Shoot1,
+    CharacterAction.Shoot2,
+    CharacterAction.ShootF,
+    CharacterAction.Sit,
+    CharacterAction.StabO1,
+    CharacterAction.StabO2,
+    CharacterAction.StabOF,
+    CharacterAction.StabT1,
+    CharacterAction.StabT2,
+    CharacterAction.StabTF,
+    CharacterAction.SwingO1,
+    CharacterAction.SwingO2,
+    CharacterAction.SwingO3,
+    CharacterAction.SwingOF,
+    CharacterAction.SwingP1,
+    CharacterAction.SwingP2,
+    CharacterAction.SwingPF,
+    CharacterAction.SwingT1,
+    CharacterAction.SwingT2,
+    CharacterAction.SwingT3,
+    CharacterAction.SwingTF,
+  ].map((value) => ({
+    label: value,
+    value,
+  })),
+];
+
+export interface ActionSelectProps {
+  value: CharacterAction;
+  onValueChange: (value: CharacterAction | undefined) => void;
+}
+export const ActionSelect = (props: ActionSelectProps) => {
+  function handleActionChange(details: ValueChangeDetails) {
+    const firstItem = details.value?.[0] as CharacterAction | undefined;
+    props.onValueChange(firstItem);
+  }
+
+  return (
+    <SimpleSelect
+      positioning={{
+        sameWidth: true,
+      }}
+      items={options}
+      value={[props.value]}
+      onValueChange={handleActionChange}
+      groupTitle="角色動作"
+      maxHeight="20rem"
+    />
+  );
+};
diff --git a/src/components/elements/LoadableEquipIcon.tsx b/src/components/elements/LoadableEquipIcon.tsx
index d6e68a0..4d1530d 100644
--- a/src/components/elements/LoadableEquipIcon.tsx
+++ b/src/components/elements/LoadableEquipIcon.tsx
@@ -64,7 +64,11 @@ export const LoadableEquipIcon = (props: LoadableEquipIconProps) => {
       alignItems="center"
       isLoaded={isLoaded()}
     >
-      <IconContainer gender={gender()}>
+      <IconContainer
+        gender={gender()}
+        width={props.width}
+        height={props.height}
+      >
         <Show when={!isError()} fallback={<CircleHelpIcon />}>
           <img
             {...contextTriggerProps}

From 28bb198e1d9c5f948d12063818b7a87bad8846e3 Mon Sep 17 00:00:00 2001
From: spd789562 <leo.yicun.lin@gmail.com>
Date: Thu, 29 Aug 2024 15:50:31 +0800
Subject: [PATCH 15/25] [add] ItemDyeTab settings

---
 src/components/ToolTabPage.tsx                |   4 +
 src/components/ToolTabsRadioGroup.tsx         |   4 +
 .../tab/ItemDyeTab/DyeTypeRadioGroup.tsx      |  63 +++++++++
 .../tab/ItemDyeTab/ItemDyeTabTitle.tsx        |  16 +++
 src/components/tab/ItemDyeTab/NeedDyeItem.tsx | 133 ++++++++++++++++++
 .../tab/ItemDyeTab/NeedDyeItemToggleGroup.tsx |  56 ++++++++
 .../tab/ItemDyeTab/OnlyDyeableSwitch.tsx      |  20 +++
 .../tab/ItemDyeTab/PreserveDyeSwitch.tsx      |  28 ++++
 .../tab/ItemDyeTab/ResultActionSelect.tsx     |  18 +++
 .../tab/ItemDyeTab/ResultCountNumberInput.tsx |  27 ++++
 src/components/tab/ItemDyeTab/index.tsx       |  52 +++++++
 src/const/toolTab.ts                          |  10 ++
 src/store/toolTab.ts                          |  41 ++++--
 13 files changed, 461 insertions(+), 11 deletions(-)
 create mode 100644 src/components/tab/ItemDyeTab/DyeTypeRadioGroup.tsx
 create mode 100644 src/components/tab/ItemDyeTab/ItemDyeTabTitle.tsx
 create mode 100644 src/components/tab/ItemDyeTab/NeedDyeItem.tsx
 create mode 100644 src/components/tab/ItemDyeTab/NeedDyeItemToggleGroup.tsx
 create mode 100644 src/components/tab/ItemDyeTab/OnlyDyeableSwitch.tsx
 create mode 100644 src/components/tab/ItemDyeTab/PreserveDyeSwitch.tsx
 create mode 100644 src/components/tab/ItemDyeTab/ResultActionSelect.tsx
 create mode 100644 src/components/tab/ItemDyeTab/ResultCountNumberInput.tsx
 create mode 100644 src/components/tab/ItemDyeTab/index.tsx

diff --git a/src/components/ToolTabPage.tsx b/src/components/ToolTabPage.tsx
index 1c76d8e..5f9251d 100644
--- a/src/components/ToolTabPage.tsx
+++ b/src/components/ToolTabPage.tsx
@@ -6,6 +6,7 @@ import { $toolTab } from '@/store/toolTab';
 import { ActionTab } from './tab/ActionTab';
 import { HairDyeTab } from './tab/DyeTab/HairDyeTab';
 import { FaceDyeTab } from './tab/DyeTab/FaceDyeTab';
+import { ItemDyeTab } from './tab/ItemDyeTab';
 
 import { ToolTab } from '@/const/toolTab';
 
@@ -23,6 +24,9 @@ export const ToolTabPage = () => {
       <Match when={tab() === ToolTab.FaceDye}>
         <FaceDyeTab />
       </Match>
+      <Match when={tab() === ToolTab.ItemDye}>
+        <ItemDyeTab />
+      </Match>
     </Switch>
   );
 };
diff --git a/src/components/ToolTabsRadioGroup.tsx b/src/components/ToolTabsRadioGroup.tsx
index b55b78f..0435c77 100644
--- a/src/components/ToolTabsRadioGroup.tsx
+++ b/src/components/ToolTabsRadioGroup.tsx
@@ -24,6 +24,10 @@ const options = [
     value: ToolTab.FaceDye,
     label: ToolTabNames[ToolTab.FaceDye],
   },
+  {
+    value: ToolTab.ItemDye,
+    label: ToolTabNames[ToolTab.ItemDye],
+  },
 ];
 
 export const ToolTabsRadioGroup = () => {
diff --git a/src/components/tab/ItemDyeTab/DyeTypeRadioGroup.tsx b/src/components/tab/ItemDyeTab/DyeTypeRadioGroup.tsx
new file mode 100644
index 0000000..9203ccc
--- /dev/null
+++ b/src/components/tab/ItemDyeTab/DyeTypeRadioGroup.tsx
@@ -0,0 +1,63 @@
+import { styled } from 'styled-system/jsx/factory';
+import { useStore } from '@nanostores/solid';
+
+import { $dyeTypeEnabled, toggleDyeConfigEnabled } from '@/store/toolTab';
+
+import { HStack } from 'styled-system/jsx/hstack';
+import * as RadioGroup from '@/components/ui/radioGroup';
+
+import { DyeType } from '@/const/toolTab';
+
+export const DyeTypeRadioGroup = () => {
+  const dyeTypeEnabled = useStore($dyeTypeEnabled);
+
+  const handleValueChange = (value: RadioGroup.ValueChangeDetails) => {
+    toggleDyeConfigEnabled(value.value as DyeType, true);
+  };
+
+  return (
+    <RadioGroup.Root
+      width="full"
+      orientation="horizontal"
+      value={dyeTypeEnabled()}
+      onValueChange={handleValueChange}
+    >
+      {/* <HStack width="full" gap="3"> */}
+      <RadioGroup.Item value={DyeType.Hue}>
+        <RadioGroup.ItemControl />
+        <RadioGroup.ItemText>
+          色相
+          <ColorBlock backgroundGradient="hueConic" />
+        </RadioGroup.ItemText>
+        <RadioGroup.ItemHiddenInput />
+      </RadioGroup.Item>
+      <RadioGroup.Item value={DyeType.Saturation}>
+        <RadioGroup.ItemControl />
+        <RadioGroup.ItemText>
+          飽和度
+          <ColorBlock backgroundGradient="saturation" />
+        </RadioGroup.ItemText>
+        <RadioGroup.ItemHiddenInput />
+      </RadioGroup.Item>
+      <RadioGroup.Item value={DyeType.Lightness}>
+        <RadioGroup.ItemControl />
+        <RadioGroup.ItemText>
+          亮度
+          <ColorBlock backgroundGradient="brightness" />
+        </RadioGroup.ItemText>
+        <RadioGroup.ItemHiddenInput />
+      </RadioGroup.Item>
+      {/* </HStack> */}
+    </RadioGroup.Root>
+  );
+};
+
+const ColorBlock = styled('div', {
+  base: {
+    borderRadius: 'sm',
+    w: 3,
+    h: 3,
+    display: 'inline-block',
+    ml: 2,
+  },
+});
diff --git a/src/components/tab/ItemDyeTab/ItemDyeTabTitle.tsx b/src/components/tab/ItemDyeTab/ItemDyeTabTitle.tsx
new file mode 100644
index 0000000..a1c0b1b
--- /dev/null
+++ b/src/components/tab/ItemDyeTab/ItemDyeTabTitle.tsx
@@ -0,0 +1,16 @@
+import { HStack } from 'styled-system/jsx/hstack';
+import { Heading } from '@/components/ui/heading';
+import { OnlyDyeableSwitch } from './OnlyDyeableSwitch';
+import { PreserveDyeSwitch } from './PreserveDyeSwitch';
+
+export const ItemDyeTabTitle = () => {
+  return (
+    <HStack justify="flex-start">
+      <Heading size="2xl" marginRight="4">
+        裝備染色表
+      </Heading>
+      <OnlyDyeableSwitch />
+      <PreserveDyeSwitch />
+    </HStack>
+  );
+};
diff --git a/src/components/tab/ItemDyeTab/NeedDyeItem.tsx b/src/components/tab/ItemDyeTab/NeedDyeItem.tsx
new file mode 100644
index 0000000..50527fa
--- /dev/null
+++ b/src/components/tab/ItemDyeTab/NeedDyeItem.tsx
@@ -0,0 +1,133 @@
+import { Show, createMemo, createEffect, splitProps } from 'solid-js';
+import { useStore } from '@nanostores/solid';
+import { styled } from 'styled-system/jsx/factory';
+
+import type { CharacterItemInfo } from '@/store/character/store';
+import { createEquipItemByCategory } from '@/store/character/selector';
+import { getEquipById } from '@/store/string';
+
+import CheckIcon from 'lucide-solid/icons/check';
+import type { ItemProps } from '@/components/ui/toggleGroup';
+import { LoadableEquipIcon } from '@/components/elements/LoadableEquipIcon';
+import { EllipsisText } from '@/components/ui/ellipsisText';
+import {
+  EquipItemIcon,
+  EquipItemName,
+  EquipItemInfo,
+} from '@/components/drawer/CurrentEquipmentDrawer/EquipItem';
+import { ItemNotExistMask } from '@/components/drawer/CurrentEquipmentDrawer/ItemNotExistMask';
+
+import type { EquipSubCategory } from '@/const/equipments';
+
+type ItemChildProps = NonNullable<
+  Parameters<Parameters<NonNullable<ItemProps['asChild']>>[0]>[0]
+>;
+export interface NeedDyeItemProps extends ItemChildProps {
+  category: EquipSubCategory;
+  onlyShowDyeable?: boolean;
+}
+export const NeedDyeItem = (props: NeedDyeItemProps) => {
+  const [ownProps, buttonProps] = splitProps(props, [
+    'category',
+    'onlyShowDyeable',
+    'class',
+  ]);
+  const item = useStore(createEquipItemByCategory(ownProps.category));
+
+  const equipInfo = createMemo(() => {
+    const id = item()?.id;
+    if (!id) {
+      return;
+    }
+    return getEquipById(id);
+  });
+
+  return (
+    <Show when={props.onlyShowDyeable ? equipInfo()?.isDyeable : true}>
+      <Show when={equipInfo()}>
+        {(item) => (
+          <SelectableContainer {...buttonProps}>
+            <EquipItemIcon>
+              <LoadableEquipIcon
+                width="7"
+                height="7"
+                id={item().id}
+                name={item().name}
+                isDyeable={item().isDyeable}
+              />
+            </EquipItemIcon>
+            <EquipItemInfo gap="0">
+              <EquipItemName>
+                <Show when={item().name} fallback={item().id}>
+                  <EllipsisText as="div" title={item().name}>
+                    {item().name}
+                  </EllipsisText>
+                </Show>
+              </EquipItemName>
+            </EquipItemInfo>
+            <CheckIconContainer class="check-icon">
+              <CheckIcon size="1em" />
+            </CheckIconContainer>
+          </SelectableContainer>
+        )}
+      </Show>
+    </Show>
+  );
+};
+
+const SelectableContainer = styled('button', {
+  base: {
+    display: 'grid',
+    py: '1',
+    px: '2',
+    borderRadius: 'md',
+    width: 'full',
+    gridTemplateColumns: 'auto 1fr',
+    alignItems: 'center',
+    position: 'relative',
+    cursor: 'pointer',
+    borderWidth: '2px',
+    borderColor: 'colorPalette.a7',
+    color: 'colorPalette.text',
+    colorPalette: 'gray',
+    _hover: {
+      background: 'colorPalette.a2',
+    },
+    _disabled: {
+      borderColor: 'border.disabled',
+      color: 'fg.disabled',
+      cursor: 'not-allowed',
+      _hover: {
+        background: 'transparent',
+        borderColor: 'border.disabled',
+        color: 'fg.disabled',
+      },
+    },
+    _focusVisible: {
+      outline: '2px solid',
+      outlineColor: 'colorPalette.default',
+      outlineOffset: '2px',
+    },
+    _on: {
+      borderColor: 'accent.default',
+      color: 'accent.fg',
+      _hover: {
+        borderColor: 'accent.emphasized',
+      },
+      '&> .check-icon': {
+        display: 'block',
+      },
+    },
+  },
+});
+
+const CheckIconContainer = styled('div', {
+  base: {
+    position: 'absolute',
+    right: '-1',
+    top: '-1',
+    backgroundColor: 'accent.default',
+    display: 'none',
+    borderRadius: '50%',
+  },
+});
diff --git a/src/components/tab/ItemDyeTab/NeedDyeItemToggleGroup.tsx b/src/components/tab/ItemDyeTab/NeedDyeItemToggleGroup.tsx
new file mode 100644
index 0000000..80ecbc1
--- /dev/null
+++ b/src/components/tab/ItemDyeTab/NeedDyeItemToggleGroup.tsx
@@ -0,0 +1,56 @@
+import { For, Index } from 'solid-js';
+import { useStore } from '@nanostores/solid';
+
+import { $onlyShowDyeable } from '@/store/toolTab';
+
+import { Grid } from 'styled-system/jsx/grid';
+import * as ToggleGroup from '@/components/ui/toggleGroup';
+
+import { NeedDyeItem } from './NeedDyeItem';
+import type { EquipSubCategory } from '@/const/equipments';
+
+const CategoryList = [
+  'Head',
+  'Weapon',
+  'Cap',
+  'Cape',
+  'Coat',
+  'Glove',
+  'Overall',
+  'Pants',
+  'Shield',
+  'Shoes',
+  'Face Accessory',
+  'Eye Decoration',
+  'Earrings',
+] as EquipSubCategory[];
+
+export const NeedDyeItemToggleGroup = () => {
+  const onlyShowDyeable = useStore($onlyShowDyeable);
+
+  return (
+    <ToggleGroup.Root
+      multiple={true}
+      width="full"
+      py="0.5"
+      borderColor="transparent"
+    >
+      <Grid width="full" columns={7}>
+        <Index each={CategoryList}>
+          {(category) => (
+            <ToggleGroup.Item
+              value={category()}
+              asChild={(props) => (
+                <NeedDyeItem
+                  category={category()}
+                  onlyShowDyeable={onlyShowDyeable()}
+                  {...props()}
+                />
+              )}
+            />
+          )}
+        </Index>
+      </Grid>
+    </ToggleGroup.Root>
+  );
+};
diff --git a/src/components/tab/ItemDyeTab/OnlyDyeableSwitch.tsx b/src/components/tab/ItemDyeTab/OnlyDyeableSwitch.tsx
new file mode 100644
index 0000000..bed1489
--- /dev/null
+++ b/src/components/tab/ItemDyeTab/OnlyDyeableSwitch.tsx
@@ -0,0 +1,20 @@
+import { useStore } from '@nanostores/solid';
+
+import { $onlyShowDyeable } from '@/store/toolTab';
+
+import { Text } from '@/components/ui/text';
+import { Switch, type ChangeDetails } from '@/components/ui/switch';
+
+export const OnlyDyeableSwitch = () => {
+  const checked = useStore($onlyShowDyeable);
+
+  function handleChange(details: ChangeDetails) {
+    $onlyShowDyeable.set(details.checked);
+  }
+
+  return (
+    <Switch checked={checked()} onCheckedChange={handleChange}>
+      <Text>僅顯示可染色裝備</Text>
+    </Switch>
+  );
+};
diff --git a/src/components/tab/ItemDyeTab/PreserveDyeSwitch.tsx b/src/components/tab/ItemDyeTab/PreserveDyeSwitch.tsx
new file mode 100644
index 0000000..5a8df10
--- /dev/null
+++ b/src/components/tab/ItemDyeTab/PreserveDyeSwitch.tsx
@@ -0,0 +1,28 @@
+import { useStore } from '@nanostores/solid';
+
+import { $preserveOriginalDye } from '@/store/toolTab';
+
+import { HStack } from 'styled-system/jsx/hstack';
+import { Text } from '@/components/ui/text';
+import { Switch, type ChangeDetails } from '@/components/ui/switch';
+import { IconTooltop, IconType } from '@/components/elements/IconTooltip';
+
+export const PreserveDyeSwitch = () => {
+  const checked = useStore($preserveOriginalDye);
+
+  function handleChange(details: ChangeDetails) {
+    $preserveOriginalDye.set(details.checked);
+  }
+
+  return (
+    <Switch checked={checked()} onCheckedChange={handleChange}>
+      <HStack gap="1">
+        <Text>保留裝備染色</Text>
+        <IconTooltop
+          type={IconType.Question}
+          tooltip="保留原裝備染色,關閉後將會重製所有染色才套用染色預覽"
+        />
+      </HStack>
+    </Switch>
+  );
+};
diff --git a/src/components/tab/ItemDyeTab/ResultActionSelect.tsx b/src/components/tab/ItemDyeTab/ResultActionSelect.tsx
new file mode 100644
index 0000000..b27f325
--- /dev/null
+++ b/src/components/tab/ItemDyeTab/ResultActionSelect.tsx
@@ -0,0 +1,18 @@
+import { useStore } from '@nanostores/solid';
+
+import { $dyeAction } from '@/store/toolTab';
+
+import { ActionSelect as BaseActionSelect } from '@/components/elements/ActionSelect';
+
+import type { CharacterAction } from '@/const/actions';
+
+export const ResultActionSelect = () => {
+  const action = useStore($dyeAction);
+  function handleActionChange(action: CharacterAction | undefined) {
+    action && $dyeAction.set(action);
+  }
+
+  return (
+    <BaseActionSelect value={action()} onValueChange={handleActionChange} />
+  );
+};
diff --git a/src/components/tab/ItemDyeTab/ResultCountNumberInput.tsx b/src/components/tab/ItemDyeTab/ResultCountNumberInput.tsx
new file mode 100644
index 0000000..851b139
--- /dev/null
+++ b/src/components/tab/ItemDyeTab/ResultCountNumberInput.tsx
@@ -0,0 +1,27 @@
+import { useStore } from '@nanostores/solid';
+
+import { $dyeResultCount } from '@/store/toolTab';
+
+import {
+  NumberInput,
+  type ValueChangeDetails,
+} from '@/components/ui/numberInput';
+
+export const ResultCountNumberInput = () => {
+  const count = useStore($dyeResultCount);
+
+  function handleCountChange(details: ValueChangeDetails) {
+    $dyeResultCount.set(details.valueAsNumber);
+  }
+
+  return (
+    <NumberInput
+      min={32}
+      max={200}
+      value={count().toString()}
+      onValueChange={handleCountChange}
+      allowOverflow={false}
+      width="6rem"
+    />
+  );
+};
diff --git a/src/components/tab/ItemDyeTab/index.tsx b/src/components/tab/ItemDyeTab/index.tsx
new file mode 100644
index 0000000..a36ad76
--- /dev/null
+++ b/src/components/tab/ItemDyeTab/index.tsx
@@ -0,0 +1,52 @@
+import { styled } from 'styled-system/jsx/factory';
+
+import { Stack } from 'styled-system/jsx/stack';
+
+import { HStack } from 'styled-system/jsx/hstack';
+import { Text } from '@/components/ui/text';
+import { Heading } from '@/components/ui/heading';
+import { ItemDyeTabTitle } from './ItemDyeTabTitle';
+import { NeedDyeItemToggleGroup } from './NeedDyeItemToggleGroup';
+import { DyeTypeRadioGroup } from './DyeTypeRadioGroup';
+import { ResultCountNumberInput } from './ResultCountNumberInput';
+import { ResultActionSelect } from './ResultActionSelect';
+
+export const ItemDyeTab = () => {
+  return (
+    <Stack>
+      <CardContainer>
+        <ItemDyeTabTitle />
+        <HStack>
+          <Heading width="7rem">欲染色裝備</Heading>
+          <NeedDyeItemToggleGroup />
+        </HStack>
+        <HStack>
+          <Heading width="7rem">染色類型</Heading>
+          <DyeTypeRadioGroup />
+        </HStack>
+        <HStack>
+          <Heading width="7rem">其他設定</Heading>
+          <HStack>
+            <Text width="7rem">染色動作</Text>
+            <ResultActionSelect />
+          </HStack>
+          <HStack>
+            <Text>染色結果數量</Text>
+            <ResultCountNumberInput />
+          </HStack>
+        </HStack>
+      </CardContainer>
+      <CardContainer></CardContainer>
+    </Stack>
+  );
+};
+
+export const CardContainer = styled(Stack, {
+  base: {
+    p: 4,
+    borderRadius: 'md',
+    boxShadow: 'md',
+    backgroundColor: 'bg.default',
+    width: '100%',
+  },
+});
diff --git a/src/const/toolTab.ts b/src/const/toolTab.ts
index bf794fe..2fac4aa 100644
--- a/src/const/toolTab.ts
+++ b/src/const/toolTab.ts
@@ -29,3 +29,13 @@ export const ActionExportTypeMimeType: Record<ActionExportType, string> = {
   [ActionExportType.Apng]: 'image/png',
   [ActionExportType.Webp]: 'image/webp',
 };
+
+export enum DyeOrder {
+  Up = 'up',
+  Down = 'down',
+}
+export enum DyeType {
+  Hue = 'hue',
+  Saturation = 'saturation',
+  Lightness = 'lightness',
+}
diff --git a/src/store/toolTab.ts b/src/store/toolTab.ts
index 57a29a5..ac09063 100644
--- a/src/store/toolTab.ts
+++ b/src/store/toolTab.ts
@@ -1,23 +1,24 @@
-import { atom, deepMap } from 'nanostores';
+import { atom, deepMap, computed, batched } from 'nanostores';
 
 import type { EquipSubCategory } from '@/const/equipments';
-import { type ToolTab, ActionExportType } from '@/const/toolTab';
+import {
+  type ToolTab,
+  ActionExportType,
+  DyeOrder,
+  DyeType,
+} from '@/const/toolTab';
 import { CharacterAction } from '@/const/actions';
 
 export const $toolTab = atom<ToolTab | undefined>(undefined);
 
 export const $actionExportType = atom<ActionExportType>(ActionExportType.Gif);
 
-export const $onlyShowDyeable = atom<boolean>(false);
-export const $preserveOriginalDye = atom<boolean>(false);
+export const $onlyShowDyeable = atom<boolean>(true);
+export const $preserveOriginalDye = atom<boolean>(true);
 export const $selectedEquipSubCategory = atom<EquipSubCategory[]>([]);
 export const $dyeResultCount = atom<number>(72);
 export const $dyeAction = atom<CharacterAction>(CharacterAction.Stand1);
 
-export enum DyeOrder {
-  Up = 'up',
-  Down = 'down',
-}
 export interface DyeConfigOption {
   enabled: boolean;
   order: DyeOrder;
@@ -42,11 +43,29 @@ export const $dyeConfig = deepMap({
   },
 });
 
-/* actions */
-export function toggleDyeConfigEnabled(key: keyof DyeConfig, value: boolean) {
+/* selector */
+export const $dyeTypeEnabled = batched($dyeConfig, (config) => {
+  for (const k of Object.values(DyeType) as DyeType[]) {
+    if (config[k].enabled) {
+      return k;
+    }
+  }
+  return undefined;
+});
+
+/* action */
+export function disableOtherDyeConfig(key: DyeType) {
+  for (const k of Object.values(DyeType) as DyeType[]) {
+    if (k !== key) {
+      $dyeConfig.setKey(`${k}.enabled`, false);
+    }
+  }
+}
+export function toggleDyeConfigEnabled(key: DyeType, value: boolean) {
   $dyeConfig.setKey(`${key}.enabled`, value);
+  disableOtherDyeConfig(key);
 }
-export function toggleDyeConfigOrder(key: keyof DyeConfig, order: DyeOrder) {
+export function toggleDyeConfigOrder(key: DyeType, order: DyeOrder) {
   $dyeConfig.setKey(`${key}.order`, order);
 }
 export function selectDyeCategory(category: EquipSubCategory) {

From 66151da07130fca769a56bd763d0ef7ba783150a Mon Sep 17 00:00:00 2001
From: spd789562 <leo.yicun.lin@gmail.com>
Date: Thu, 29 Aug 2024 19:05:15 +0800
Subject: [PATCH 16/25] [add] dye table and export function from hair&face dye

---
 .../tab/ItemDyeTab/DyeCharacter.tsx           |  62 ++++++
 src/components/tab/ItemDyeTab/DyeInfo.tsx     |  56 +++++
 src/components/tab/ItemDyeTab/DyeResult.tsx   |  56 +++++
 .../tab/ItemDyeTab/DyeResultTable.tsx         | 203 ++++++++++++++++++
 .../tab/ItemDyeTab/DyeTypeRadioGroup.tsx      |  10 +-
 .../tab/ItemDyeTab/ExportTableButton.tsx      |  79 +++++++
 src/components/tab/ItemDyeTab/NeedDyeItem.tsx |   6 +-
 .../tab/ItemDyeTab/NeedDyeItemToggleGroup.tsx |  19 +-
 .../ResultColumnCountNumberInput.tsx          |  27 +++
 .../tab/ItemDyeTab/StartDyeButton.tsx         |  63 ++++++
 src/components/tab/ItemDyeTab/index.tsx       |  11 +-
 src/const/toolTab.ts                          |   2 +-
 src/store/toolTab.ts                          |  28 ++-
 src/utils/extract.ts                          |   8 +
 14 files changed, 601 insertions(+), 29 deletions(-)
 create mode 100644 src/components/tab/ItemDyeTab/DyeCharacter.tsx
 create mode 100644 src/components/tab/ItemDyeTab/DyeInfo.tsx
 create mode 100644 src/components/tab/ItemDyeTab/DyeResult.tsx
 create mode 100644 src/components/tab/ItemDyeTab/DyeResultTable.tsx
 create mode 100644 src/components/tab/ItemDyeTab/ExportTableButton.tsx
 create mode 100644 src/components/tab/ItemDyeTab/ResultColumnCountNumberInput.tsx
 create mode 100644 src/components/tab/ItemDyeTab/StartDyeButton.tsx

diff --git a/src/components/tab/ItemDyeTab/DyeCharacter.tsx b/src/components/tab/ItemDyeTab/DyeCharacter.tsx
new file mode 100644
index 0000000..c93d580
--- /dev/null
+++ b/src/components/tab/ItemDyeTab/DyeCharacter.tsx
@@ -0,0 +1,62 @@
+import type { JSX } from 'solid-js';
+import { styled } from 'styled-system/jsx/factory';
+
+import type { DyeType } from '@/const/toolTab';
+
+interface DyeCharacterProps {
+  url: string;
+  dyeData: Partial<Record<DyeType, number>>;
+  handleDyeClick: (data: Partial<Record<DyeType, number>>) => void;
+  ref?: (element: HTMLImageElement) => void;
+  dyeInfo: JSX.Element;
+}
+export const DyeCharacter = (props: DyeCharacterProps) => {
+  function handleSelect() {
+    props.handleDyeClick(props.dyeData);
+  }
+  function getDyeString() {
+    return Object.entries(props.dyeData)
+      .map(([key, value]) => `${key}-${value}`)
+      .join(', ');
+  }
+
+  return (
+    <CharacterItemContainer onClick={handleSelect}>
+      <CharacterItemImage
+        ref={props.ref}
+        src={props.url}
+        alt={`character-${getDyeString()}`}
+        title={`item-dye-${getDyeString()}`}
+      />
+      <DyeInfoPositioner>{props.dyeInfo}</DyeInfoPositioner>
+    </CharacterItemContainer>
+  );
+};
+
+const CharacterItemContainer = styled('button', {
+  base: {
+    display: 'inline-block',
+    position: 'relative',
+    _hover: {
+      '& [data-part="info"]': {
+        opacity: 0.9,
+      },
+    },
+  },
+});
+
+const CharacterItemImage = styled('img', {
+  base: {
+    maxWidth: 'unset',
+    width: 'unset',
+  },
+});
+
+const DyeInfoPositioner = styled('div', {
+  base: {
+    position: 'absolute',
+    left: 0,
+    right: 0,
+    bottom: 0,
+  },
+});
diff --git a/src/components/tab/ItemDyeTab/DyeInfo.tsx b/src/components/tab/ItemDyeTab/DyeInfo.tsx
new file mode 100644
index 0000000..19d6a88
--- /dev/null
+++ b/src/components/tab/ItemDyeTab/DyeInfo.tsx
@@ -0,0 +1,56 @@
+import { For } from 'solid-js';
+import { styled } from 'styled-system/jsx/factory';
+import { css } from 'styled-system/css';
+
+import { Flex } from 'styled-system/jsx';
+import { DyeType } from '@/const/toolTab';
+
+const GradientMap = {
+  [DyeType.Hue]: 'hueConic',
+  [DyeType.Saturation]: 'saturation',
+  [DyeType.Birghtness]: 'brightness',
+};
+
+export interface DyeInfoProps {
+  dyeData: Partial<Record<DyeType, number>>;
+}
+export const DyeInfo = (props: DyeInfoProps) => {
+  return (
+    <DyeInfoPanel gap="1" data-part="info">
+      <For each={Object.entries(props.dyeData)}>
+        {([key, value]) => (
+          <>
+            <ColorBlock
+              class={css({
+                backgroundGradient: GradientMap[key as DyeType],
+              })}
+            />
+            <span>+{value}</span>
+          </>
+        )}
+      </For>
+    </DyeInfoPanel>
+  );
+};
+
+const DyeInfoPanel = styled(Flex, {
+  base: {
+    py: 1,
+    alignItems: 'center',
+    justifyContent: 'center',
+    backgroundColor: 'bg.default',
+    borderTopRadius: 'md',
+    boxShadow: 'md',
+    opacity: 0,
+    transition: 'opacity 0.2s',
+    fontSize: 'xs',
+  },
+});
+const ColorBlock = styled('div', {
+  base: {
+    borderRadius: 'sm',
+    w: 3,
+    h: 3,
+    display: 'inline-block',
+  },
+});
diff --git a/src/components/tab/ItemDyeTab/DyeResult.tsx b/src/components/tab/ItemDyeTab/DyeResult.tsx
new file mode 100644
index 0000000..3147971
--- /dev/null
+++ b/src/components/tab/ItemDyeTab/DyeResult.tsx
@@ -0,0 +1,56 @@
+import { useStore } from '@nanostores/solid';
+
+import {
+  $isRenderingDye,
+  $dyeResultCount,
+  $dyeResultColumnCount,
+} from '@/store/toolTab';
+
+import { HStack } from 'styled-system/jsx/hstack';
+import { Text } from '@/components/ui/text';
+import { Heading } from '@/components/ui/heading';
+import { ResultColumnCountNumberInput } from './ResultColumnCountNumberInput';
+import { DyeResultTable } from './DyeResultTable';
+import { ExportTableButton } from './ExportTableButton';
+import { ExportSeperateButton } from '../DyeTab/ExportSeperateButton';
+
+export const DyeResult = () => {
+  const isRenderingDye = useStore($isRenderingDye);
+  const count = useStore($dyeResultCount);
+  const columnCounts = useStore($dyeResultColumnCount);
+  const dyeCharacterRefs: HTMLImageElement[] = [];
+
+  return (
+    <>
+      <HStack>
+        <Heading size="lg" width="rem">
+          染色結果
+        </Heading>
+        <HStack>
+          <Text>每行數量</Text>
+          <ResultColumnCountNumberInput />
+        </HStack>
+        <HStack ml="auto">
+          <ExportTableButton
+            fileName="dye-table.png"
+            images={dyeCharacterRefs}
+            imageCounts={count()}
+            columnCounts={columnCounts()}
+            disabled={isRenderingDye()}
+          >
+            匯出表格圖
+          </ExportTableButton>
+          <ExportSeperateButton
+            fileName="dye-table.zip"
+            images={dyeCharacterRefs}
+            imageCounts={count()}
+            disabled={isRenderingDye()}
+          >
+            匯出(.zip)
+          </ExportSeperateButton>
+        </HStack>
+      </HStack>
+      <DyeResultTable refs={dyeCharacterRefs} />
+    </>
+  );
+};
diff --git a/src/components/tab/ItemDyeTab/DyeResultTable.tsx b/src/components/tab/ItemDyeTab/DyeResultTable.tsx
new file mode 100644
index 0000000..c550ed1
--- /dev/null
+++ b/src/components/tab/ItemDyeTab/DyeResultTable.tsx
@@ -0,0 +1,203 @@
+import { createEffect, For, untrack } from 'solid-js';
+import { createStore } from 'solid-js/store';
+import { useStore } from '@nanostores/solid';
+
+import {
+  $selectedEquipSubCategory,
+  $dyeResultCount,
+  $dyeRenderId,
+  $isRenderingDye,
+  $onlyShowDyeable,
+  $preserveOriginalDye,
+  $dyeTypeEnabled,
+  $dyeAction,
+  $dyeResultColumnCount,
+} from '@/store/toolTab';
+import { getEquipById } from '@/store/string';
+import {
+  $isGlobalRendererInitialized,
+  $globalRenderer,
+} from '@/store/renderer';
+import { $currentCharacterInfo } from '@/store/character/store';
+import { $totalItems } from '@/store/character/selector';
+import { deepCloneCharacterItems } from '@/store/character/utils';
+
+import { Character } from '@/renderer/character/character';
+
+import { Grid } from 'styled-system/jsx/grid';
+import { DyeInfo } from './DyeInfo';
+import { DyeCharacter } from './DyeCharacter';
+
+import { extractCanvas, getBlobFromCanvas } from '@/utils/extract';
+import { nextTick } from '@/utils/eventLoop';
+
+import { DyeType } from '@/const/toolTab';
+import { CharacterExpressions } from '@/const/emotions';
+import { updateItemHsvInfo } from '@/store/character/action';
+
+const DyePropertyMap = {
+  [DyeType.Hue]: {
+    min: 0,
+    max: 360,
+  },
+  [DyeType.Saturation]: {
+    min: -100,
+    max: 100,
+  },
+  [DyeType.Birghtness]: {
+    min: -100,
+    max: 100,
+  },
+} as const;
+
+function getActualNeedDyeCategories() {
+  const selectedEquipSubCategory = $selectedEquipSubCategory.get();
+  const totalItems = $totalItems.get();
+  const isOnlyShowDyeable = $onlyShowDyeable.get();
+  const actaulNeedDyeCategories = selectedEquipSubCategory.filter(
+    (equipSubCategory) => {
+      /* make sure the category is in current item */
+      const item = totalItems[equipSubCategory as keyof typeof totalItems];
+      if (!item) {
+        return false;
+      }
+      if (isOnlyShowDyeable) {
+        return getEquipById(item.id)?.isDyeable;
+      }
+      return true;
+    },
+  );
+  return actaulNeedDyeCategories;
+}
+
+export interface DyeResultTableProps {
+  refs?: HTMLImageElement[];
+}
+export function DyeResultTable(props: DyeResultTableProps) {
+  const [state, setState] = createStore({
+    results: [] as { url: string; info: Partial<Record<DyeType, number>> }[],
+  });
+  let testIndex = 0;
+  const renderId = useStore($dyeRenderId);
+  const isInit = useStore($isGlobalRendererInitialized);
+  const gridColumns = useStore($dyeResultColumnCount);
+  const character = new Character();
+
+  function handleRef(i: number) {
+    return (element: HTMLImageElement) => {
+      if (!props.refs) {
+        return;
+      }
+      props.refs[i] = element;
+    };
+  }
+
+  function handleDyeClick(data: Partial<Record<DyeType, number>>) {
+    const actualneedDyeCategories = getActualNeedDyeCategories();
+    for (const equipSubCategory of actualneedDyeCategories) {
+      for (const [dyeType, value] of Object.entries(data)) {
+        updateItemHsvInfo(equipSubCategory, dyeType as DyeType, value);
+      }
+    }
+  }
+
+  function cleanUpStore() {
+    const currentResults = untrack(() => state.results);
+    for (const result of currentResults) {
+      URL.revokeObjectURL(result.url);
+    }
+    testIndex = 0;
+    setState('results', []);
+  }
+
+  createEffect(async () => {
+    if (renderId() && isInit()) {
+      $isRenderingDye.set(true);
+      cleanUpStore();
+      await nextTick();
+      // return;
+
+      const app = $globalRenderer.get();
+      const currentCharacterInfo = $currentCharacterInfo.get();
+      const characterData = {
+        frame: 0,
+        isAnimating: false,
+        action: $dyeAction.get(),
+        expression: CharacterExpressions.Default,
+        earType: currentCharacterInfo.earType,
+        handType: currentCharacterInfo.handType,
+        items: deepCloneCharacterItems($totalItems.get()),
+      };
+      const actualneedDyeCategories = getActualNeedDyeCategories();
+      const dyeResultCount = $dyeResultCount.get();
+      const preserveOriginalDye = $preserveOriginalDye.get();
+      const dyeTypeEnabled = $dyeTypeEnabled.get();
+      if (
+        !dyeTypeEnabled ||
+        actualneedDyeCategories.length === 0 ||
+        dyeResultCount < 1
+      ) {
+        $isRenderingDye.set(false);
+        return;
+      }
+      /* reset dye when chose not preserve original dye */
+      if (!preserveOriginalDye) {
+        for (const equipSubCategory of actualneedDyeCategories) {
+          if (characterData.items[equipSubCategory]) {
+            characterData.items[equipSubCategory][dyeTypeEnabled] = 0;
+          }
+        }
+      }
+      /* load once first */
+      await character.update(characterData);
+
+      /* start generate */
+      const dyeRangeConfig = DyePropertyMap[dyeTypeEnabled];
+      const step = (dyeRangeConfig.max - dyeRangeConfig.min) / dyeResultCount;
+      for (let i = 0; i < dyeResultCount; i++) {
+        const dyeNumber = Math.floor(dyeRangeConfig.min + step * i);
+        for (const equipSubCategory of actualneedDyeCategories) {
+          if (characterData.items[equipSubCategory]) {
+            characterData.items[equipSubCategory][dyeTypeEnabled] = dyeNumber;
+          }
+        }
+        await character.update(characterData);
+        /* give some time */
+        await nextTick();
+
+        const canvas = extractCanvas(character, app.renderer);
+        const blob = await getBlobFromCanvas(canvas as HTMLCanvasElement);
+        if (blob) {
+          const url = URL.createObjectURL(blob);
+          setState('results', testIndex, {
+            url,
+            info: { [dyeTypeEnabled]: dyeNumber },
+          });
+          testIndex++;
+        }
+      }
+      $isRenderingDye.set(false);
+    }
+  });
+  return (
+    <Grid
+      gap={0}
+      justifyContent="center"
+      style={{
+        'grid-template-columns': `repeat(${gridColumns()}, auto)`,
+      }}
+    >
+      <For each={state.results}>
+        {(result, i) => (
+          <DyeCharacter
+            url={result.url}
+            dyeData={result.info}
+            handleDyeClick={handleDyeClick}
+            ref={handleRef(i())}
+            dyeInfo={<DyeInfo dyeData={result.info} />}
+          />
+        )}
+      </For>
+    </Grid>
+  );
+}
diff --git a/src/components/tab/ItemDyeTab/DyeTypeRadioGroup.tsx b/src/components/tab/ItemDyeTab/DyeTypeRadioGroup.tsx
index 9203ccc..90057f1 100644
--- a/src/components/tab/ItemDyeTab/DyeTypeRadioGroup.tsx
+++ b/src/components/tab/ItemDyeTab/DyeTypeRadioGroup.tsx
@@ -3,7 +3,6 @@ import { useStore } from '@nanostores/solid';
 
 import { $dyeTypeEnabled, toggleDyeConfigEnabled } from '@/store/toolTab';
 
-import { HStack } from 'styled-system/jsx/hstack';
 import * as RadioGroup from '@/components/ui/radioGroup';
 
 import { DyeType } from '@/const/toolTab';
@@ -22,7 +21,6 @@ export const DyeTypeRadioGroup = () => {
       value={dyeTypeEnabled()}
       onValueChange={handleValueChange}
     >
-      {/* <HStack width="full" gap="3"> */}
       <RadioGroup.Item value={DyeType.Hue}>
         <RadioGroup.ItemControl />
         <RadioGroup.ItemText>
@@ -39,7 +37,7 @@ export const DyeTypeRadioGroup = () => {
         </RadioGroup.ItemText>
         <RadioGroup.ItemHiddenInput />
       </RadioGroup.Item>
-      <RadioGroup.Item value={DyeType.Lightness}>
+      <RadioGroup.Item value={DyeType.Birghtness}>
         <RadioGroup.ItemControl />
         <RadioGroup.ItemText>
           亮度
@@ -47,17 +45,17 @@ export const DyeTypeRadioGroup = () => {
         </RadioGroup.ItemText>
         <RadioGroup.ItemHiddenInput />
       </RadioGroup.Item>
-      {/* </HStack> */}
     </RadioGroup.Root>
   );
 };
 
 const ColorBlock = styled('div', {
   base: {
-    borderRadius: 'sm',
+    display: 'inline-block',
     w: 3,
     h: 3,
-    display: 'inline-block',
     ml: 2,
+    borderRadius: 'sm',
+    boxShadow: 'sm',
   },
 });
diff --git a/src/components/tab/ItemDyeTab/ExportTableButton.tsx b/src/components/tab/ItemDyeTab/ExportTableButton.tsx
new file mode 100644
index 0000000..628b63e
--- /dev/null
+++ b/src/components/tab/ItemDyeTab/ExportTableButton.tsx
@@ -0,0 +1,79 @@
+import type { JSX } from 'solid-js';
+
+import { Button } from '@/components/ui/button';
+
+import { downloadCanvas } from '@/utils/download';
+import { toaster } from '@/components/GlobalToast';
+
+const TABLE_COL_GAP = 2;
+const TABLE_ROW_GAP = 2;
+
+export interface ExportTableButtonProps {
+  images: HTMLImageElement[];
+  imageCounts: number;
+  columnCounts: number;
+  fileName: string;
+  children: JSX.Element;
+  disabled?: boolean;
+}
+export const ExportTableButton = (props: ExportTableButtonProps) => {
+  async function handleClick() {
+    const validImageCounts = props.images.filter((img) => img?.src).length;
+    const colCounts = props.columnCounts;
+    const rowCounts = Math.ceil(props.imageCounts / colCounts);
+    const isAllImagesLoaded = props.imageCounts === validImageCounts;
+    if (!isAllImagesLoaded) {
+      toaster.error({
+        title: '圖片尚未載入完畢',
+      });
+      return;
+    }
+    const canvas = document.createElement('canvas');
+    const tableImageItem = props.images[0];
+    const tableColWidth = tableImageItem.width;
+
+    const totalWidth = (tableColWidth + TABLE_COL_GAP) * colCounts;
+    const totalHeight = (tableImageItem.height + TABLE_ROW_GAP) * rowCounts;
+
+    canvas.width = totalWidth;
+    canvas.height = totalHeight;
+
+    const ctx = canvas.getContext('2d');
+    if (!ctx) {
+      toaster.error({
+        title: '匯出失敗,無法建立 Canvas',
+      });
+      return;
+    }
+    let startY = 0;
+    for (let y = 0; y < rowCounts; y++) {
+      for (let x = 0; x < colCounts; x++) {
+        const index = y * colCounts + x;
+        const image = props.images[index];
+        if (!image) {
+          continue;
+        }
+        ctx.drawImage(image, x * (tableColWidth + TABLE_COL_GAP), startY);
+      }
+      startY += tableImageItem.height + TABLE_ROW_GAP;
+    }
+    try {
+      await downloadCanvas(canvas, props.fileName);
+    } catch (_) {
+      toaster.error({
+        title: '匯出失敗,Canvas 無法建立 Blob',
+      });
+    }
+  }
+
+  return (
+    <Button
+      size="sm"
+      fontWeight="normal"
+      onClick={handleClick}
+      disabled={props.disabled}
+    >
+      {props.children}
+    </Button>
+  );
+};
diff --git a/src/components/tab/ItemDyeTab/NeedDyeItem.tsx b/src/components/tab/ItemDyeTab/NeedDyeItem.tsx
index 50527fa..eabfcfc 100644
--- a/src/components/tab/ItemDyeTab/NeedDyeItem.tsx
+++ b/src/components/tab/ItemDyeTab/NeedDyeItem.tsx
@@ -1,8 +1,7 @@
-import { Show, createMemo, createEffect, splitProps } from 'solid-js';
+import { Show, createMemo, splitProps } from 'solid-js';
 import { useStore } from '@nanostores/solid';
 import { styled } from 'styled-system/jsx/factory';
 
-import type { CharacterItemInfo } from '@/store/character/store';
 import { createEquipItemByCategory } from '@/store/character/selector';
 import { getEquipById } from '@/store/string';
 
@@ -15,7 +14,6 @@ import {
   EquipItemName,
   EquipItemInfo,
 } from '@/components/drawer/CurrentEquipmentDrawer/EquipItem';
-import { ItemNotExistMask } from '@/components/drawer/CurrentEquipmentDrawer/ItemNotExistMask';
 
 import type { EquipSubCategory } from '@/const/equipments';
 
@@ -81,7 +79,7 @@ const SelectableContainer = styled('button', {
     py: '1',
     px: '2',
     borderRadius: 'md',
-    width: 'full',
+    // width: 'full',
     gridTemplateColumns: 'auto 1fr',
     alignItems: 'center',
     position: 'relative',
diff --git a/src/components/tab/ItemDyeTab/NeedDyeItemToggleGroup.tsx b/src/components/tab/ItemDyeTab/NeedDyeItemToggleGroup.tsx
index 80ecbc1..c15e75b 100644
--- a/src/components/tab/ItemDyeTab/NeedDyeItemToggleGroup.tsx
+++ b/src/components/tab/ItemDyeTab/NeedDyeItemToggleGroup.tsx
@@ -1,9 +1,10 @@
-import { For, Index } from 'solid-js';
-import { useStore } from '@nanostores/solid';
+import { Index } from 'solid-js';
 
-import { $onlyShowDyeable } from '@/store/toolTab';
+import { usePureStore } from '@/store';
+import { $onlyShowDyeable, $selectedEquipSubCategory } from '@/store/toolTab';
 
 import { Grid } from 'styled-system/jsx/grid';
+import { Stack } from 'styled-system/jsx/stack';
 import * as ToggleGroup from '@/components/ui/toggleGroup';
 
 import { NeedDyeItem } from './NeedDyeItem';
@@ -26,7 +27,11 @@ const CategoryList = [
 ] as EquipSubCategory[];
 
 export const NeedDyeItemToggleGroup = () => {
-  const onlyShowDyeable = useStore($onlyShowDyeable);
+  const onlyShowDyeable = usePureStore($onlyShowDyeable);
+
+  const handleValueChange = (details: ToggleGroup.ValueChangeDetails) => {
+    $selectedEquipSubCategory.set(details.value as EquipSubCategory[]);
+  };
 
   return (
     <ToggleGroup.Root
@@ -34,8 +39,10 @@ export const NeedDyeItemToggleGroup = () => {
       width="full"
       py="0.5"
       borderColor="transparent"
+      defaultValue={$selectedEquipSubCategory.get()}
+      onValueChange={handleValueChange}
     >
-      <Grid width="full" columns={7}>
+      <Stack width="full" direction="row" flexWrap="wrap">
         <Index each={CategoryList}>
           {(category) => (
             <ToggleGroup.Item
@@ -50,7 +57,7 @@ export const NeedDyeItemToggleGroup = () => {
             />
           )}
         </Index>
-      </Grid>
+      </Stack>
     </ToggleGroup.Root>
   );
 };
diff --git a/src/components/tab/ItemDyeTab/ResultColumnCountNumberInput.tsx b/src/components/tab/ItemDyeTab/ResultColumnCountNumberInput.tsx
new file mode 100644
index 0000000..b8173c8
--- /dev/null
+++ b/src/components/tab/ItemDyeTab/ResultColumnCountNumberInput.tsx
@@ -0,0 +1,27 @@
+import { useStore } from '@nanostores/solid';
+
+import { $dyeResultColumnCount } from '@/store/toolTab';
+
+import {
+  NumberInput,
+  type ValueChangeDetails,
+} from '@/components/ui/numberInput';
+
+export const ResultColumnCountNumberInput = () => {
+  const count = useStore($dyeResultColumnCount);
+
+  function handleCountChange(details: ValueChangeDetails) {
+    $dyeResultColumnCount.set(details.valueAsNumber);
+  }
+
+  return (
+    <NumberInput
+      min={2}
+      max={20}
+      value={count().toString()}
+      onValueChange={handleCountChange}
+      allowOverflow={false}
+      width="6rem"
+    />
+  );
+};
diff --git a/src/components/tab/ItemDyeTab/StartDyeButton.tsx b/src/components/tab/ItemDyeTab/StartDyeButton.tsx
new file mode 100644
index 0000000..f893c75
--- /dev/null
+++ b/src/components/tab/ItemDyeTab/StartDyeButton.tsx
@@ -0,0 +1,63 @@
+import { Show } from 'solid-js';
+import { useStore } from '@nanostores/solid';
+import { styled } from 'styled-system/jsx/factory';
+
+import {
+  $selectedEquipSubCategory,
+  $dyeResultCount,
+  $dyeRenderId,
+  $dyeTypeEnabled,
+  $isRenderingDye,
+} from '@/store/toolTab';
+
+import LoaderCircle from 'lucide-solid/icons/loader-circle';
+import { Button } from '@/components/ui/button';
+
+import { toaster } from '@/components/GlobalToast';
+
+export const StartDyeButton = () => {
+  const isLoading = useStore($isRenderingDye);
+
+  function handleClick() {
+    if ($isRenderingDye.get()) {
+      return;
+    }
+    if ($selectedEquipSubCategory.get().length === 0) {
+      toaster.error({
+        title: '請選擇想要預覽染色的裝備',
+      });
+      return;
+    }
+    if (!$dyeTypeEnabled.get()) {
+      toaster.error({
+        title: '請選擇想要預覽的染色類型',
+      });
+    }
+    if ($dyeResultCount.get() < 32) {
+      toaster.error({
+        title: '請輸入染色數量',
+      });
+      return;
+    }
+    $dyeRenderId.set(Date.now().toString());
+    $isRenderingDye.set(true);
+  }
+
+  return (
+    <Button onClick={handleClick} disabled={isLoading()}>
+      <Show when={isLoading()}>
+        <Loading>
+          <LoaderCircle />
+        </Loading>
+      </Show>
+      產生染色表
+    </Button>
+  );
+};
+
+const Loading = styled('div', {
+  base: {
+    animation: 'rotate infinite 1s linear',
+    color: 'fg.muted',
+  },
+});
diff --git a/src/components/tab/ItemDyeTab/index.tsx b/src/components/tab/ItemDyeTab/index.tsx
index a36ad76..17d222a 100644
--- a/src/components/tab/ItemDyeTab/index.tsx
+++ b/src/components/tab/ItemDyeTab/index.tsx
@@ -10,10 +10,12 @@ import { NeedDyeItemToggleGroup } from './NeedDyeItemToggleGroup';
 import { DyeTypeRadioGroup } from './DyeTypeRadioGroup';
 import { ResultCountNumberInput } from './ResultCountNumberInput';
 import { ResultActionSelect } from './ResultActionSelect';
+import { StartDyeButton } from './StartDyeButton';
+import { DyeResult } from './DyeResult';
 
 export const ItemDyeTab = () => {
   return (
-    <Stack>
+    <Stack mb="4">
       <CardContainer>
         <ItemDyeTabTitle />
         <HStack>
@@ -35,8 +37,13 @@ export const ItemDyeTab = () => {
             <ResultCountNumberInput />
           </HStack>
         </HStack>
+        <div>
+          <StartDyeButton />
+        </div>
+      </CardContainer>
+      <CardContainer>
+        <DyeResult />
       </CardContainer>
-      <CardContainer></CardContainer>
     </Stack>
   );
 };
diff --git a/src/const/toolTab.ts b/src/const/toolTab.ts
index 2fac4aa..e54e8f9 100644
--- a/src/const/toolTab.ts
+++ b/src/const/toolTab.ts
@@ -37,5 +37,5 @@ export enum DyeOrder {
 export enum DyeType {
   Hue = 'hue',
   Saturation = 'saturation',
-  Lightness = 'lightness',
+  Birghtness = 'brightness',
 }
diff --git a/src/store/toolTab.ts b/src/store/toolTab.ts
index ac09063..9da128c 100644
--- a/src/store/toolTab.ts
+++ b/src/store/toolTab.ts
@@ -1,24 +1,24 @@
-import { atom, deepMap, computed, batched } from 'nanostores';
+import { atom, deepMap, batched, onSet } from 'nanostores';
 
 import type { EquipSubCategory } from '@/const/equipments';
-import {
-  type ToolTab,
-  ActionExportType,
-  DyeOrder,
-  DyeType,
-} from '@/const/toolTab';
+import { ToolTab, ActionExportType, DyeOrder, DyeType } from '@/const/toolTab';
 import { CharacterAction } from '@/const/actions';
 
 export const $toolTab = atom<ToolTab | undefined>(undefined);
 
 export const $actionExportType = atom<ActionExportType>(ActionExportType.Gif);
 
+/* item dye tab */
 export const $onlyShowDyeable = atom<boolean>(true);
 export const $preserveOriginalDye = atom<boolean>(true);
 export const $selectedEquipSubCategory = atom<EquipSubCategory[]>([]);
 export const $dyeResultCount = atom<number>(72);
 export const $dyeAction = atom<CharacterAction>(CharacterAction.Stand1);
 
+export const $dyeRenderId = atom<string | undefined>(undefined);
+export const $isRenderingDye = atom<boolean>(false);
+export const $dyeResultColumnCount = atom<number>(8);
+
 export interface DyeConfigOption {
   enabled: boolean;
   order: DyeOrder;
@@ -26,23 +26,31 @@ export interface DyeConfigOption {
 export type DyeConfig = {
   hue: DyeConfigOption;
   saturation: DyeConfigOption;
-  lightness: DyeConfigOption;
+  brightness: DyeConfigOption;
 };
 export const $dyeConfig = deepMap({
   hue: {
-    enabled: false,
+    enabled: true,
     order: DyeOrder.Up,
   },
   saturation: {
     enabled: false,
     order: DyeOrder.Up,
   },
-  lightness: {
+  brightness: {
     enabled: false,
     order: DyeOrder.Up,
   },
 });
 
+/* effect */
+onSet($toolTab, ({ newValue }) => {
+  /* clean render id prevent render table againg when return */
+  if (newValue !== ToolTab.ItemDye) {
+    $dyeRenderId.set(undefined);
+  }
+});
+
 /* selector */
 export const $dyeTypeEnabled = batched($dyeConfig, (config) => {
   for (const k of Object.values(DyeType) as DyeType[]) {
diff --git a/src/utils/extract.ts b/src/utils/extract.ts
index c099cfc..5a4ccc8 100644
--- a/src/utils/extract.ts
+++ b/src/utils/extract.ts
@@ -9,6 +9,14 @@ import {
 
 type ExtractTarget = Parameters<ExtractSystem['texture']>[0];
 
+export function getBlobFromCanvas(canvas: HTMLCanvasElement) {
+  return new Promise<Blob | null>((resolve) => {
+    canvas.toBlob?.((blob) => {
+      resolve(blob as unknown as Blob);
+    }, 'image/png');
+  });
+}
+
 export function extractCanvas(target: ExtractTarget, renderer: Renderer) {
   const texture = renderer.extract.texture(target);
 

From 4ecfb2431903f4b2dea5d11d4287f8ecd84fbb5e Mon Sep 17 00:00:00 2001
From: spd789562 <leo.yicun.lin@gmail.com>
Date: Thu, 29 Aug 2024 22:56:06 +0800
Subject: [PATCH 17/25] [edit] fix some item dye style

---
 src/components/tab/ItemDyeTab/DyeResult.tsx |  8 ++++----
 src/components/tab/ItemDyeTab/index.tsx     |  2 +-
 src/store/toolTab.ts                        | 22 ++++++++++++++++++---
 3 files changed, 24 insertions(+), 8 deletions(-)

diff --git a/src/components/tab/ItemDyeTab/DyeResult.tsx b/src/components/tab/ItemDyeTab/DyeResult.tsx
index 3147971..695fc38 100644
--- a/src/components/tab/ItemDyeTab/DyeResult.tsx
+++ b/src/components/tab/ItemDyeTab/DyeResult.tsx
@@ -1,7 +1,7 @@
 import { useStore } from '@nanostores/solid';
 
 import {
-  $isRenderingDye,
+  $isExportable,
   $dyeResultCount,
   $dyeResultColumnCount,
 } from '@/store/toolTab';
@@ -15,7 +15,7 @@ import { ExportTableButton } from './ExportTableButton';
 import { ExportSeperateButton } from '../DyeTab/ExportSeperateButton';
 
 export const DyeResult = () => {
-  const isRenderingDye = useStore($isRenderingDye);
+  const isExportable = useStore($isExportable);
   const count = useStore($dyeResultCount);
   const columnCounts = useStore($dyeResultColumnCount);
   const dyeCharacterRefs: HTMLImageElement[] = [];
@@ -36,7 +36,7 @@ export const DyeResult = () => {
             images={dyeCharacterRefs}
             imageCounts={count()}
             columnCounts={columnCounts()}
-            disabled={isRenderingDye()}
+            disabled={!isExportable()}
           >
             匯出表格圖
           </ExportTableButton>
@@ -44,7 +44,7 @@ export const DyeResult = () => {
             fileName="dye-table.zip"
             images={dyeCharacterRefs}
             imageCounts={count()}
-            disabled={isRenderingDye()}
+            disabled={!isExportable()}
           >
             匯出(.zip)
           </ExportSeperateButton>
diff --git a/src/components/tab/ItemDyeTab/index.tsx b/src/components/tab/ItemDyeTab/index.tsx
index 17d222a..1e84a9a 100644
--- a/src/components/tab/ItemDyeTab/index.tsx
+++ b/src/components/tab/ItemDyeTab/index.tsx
@@ -16,7 +16,7 @@ import { DyeResult } from './DyeResult';
 export const ItemDyeTab = () => {
   return (
     <Stack mb="4">
-      <CardContainer>
+      <CardContainer gap={4}>
         <ItemDyeTabTitle />
         <HStack>
           <Heading width="7rem">欲染色裝備</Heading>
diff --git a/src/store/toolTab.ts b/src/store/toolTab.ts
index 9da128c..7384475 100644
--- a/src/store/toolTab.ts
+++ b/src/store/toolTab.ts
@@ -1,12 +1,19 @@
 import { atom, deepMap, batched, onSet } from 'nanostores';
 
 import type { EquipSubCategory } from '@/const/equipments';
-import { ToolTab, ActionExportType, DyeOrder, DyeType } from '@/const/toolTab';
+import {
+  ToolTab,
+  ActionExportType,
+  DyeOrder,
+  DyeType,
+} from '@/const/toolTab';
 import { CharacterAction } from '@/const/actions';
 
 export const $toolTab = atom<ToolTab | undefined>(undefined);
 
-export const $actionExportType = atom<ActionExportType>(ActionExportType.Gif);
+export const $actionExportType = atom<ActionExportType>(
+  ActionExportType.Gif,
+);
 
 /* item dye tab */
 export const $onlyShowDyeable = atom<boolean>(true);
@@ -60,6 +67,12 @@ export const $dyeTypeEnabled = batched($dyeConfig, (config) => {
   }
   return undefined;
 });
+export const $isExportable = batched(
+  [$isRenderingDye, $dyeRenderId],
+  (isRendering, renderId) => {
+    return !isRendering && !!renderId;
+  },
+);
 
 /* action */
 export function disableOtherDyeConfig(key: DyeType) {
@@ -81,7 +94,10 @@ export function selectDyeCategory(category: EquipSubCategory) {
   if (current.includes(category)) {
     return;
   }
-  $selectedEquipSubCategory.set([...$selectedEquipSubCategory.get(), category]);
+  $selectedEquipSubCategory.set([
+    ...$selectedEquipSubCategory.get(),
+    category,
+  ]);
 }
 export function deselectDyeCategory(category: EquipSubCategory) {
   $selectedEquipSubCategory.set(

From 16a1136364ba8f35a65fdd340e8b42d9849f99bc Mon Sep 17 00:00:00 2001
From: spd789562 <leo.yicun.lin@gmail.com>
Date: Thu, 29 Aug 2024 22:57:51 +0800
Subject: [PATCH 18/25] [edit] adjust NeedDyeItem checked icon size

---
 src/components/tab/ItemDyeTab/NeedDyeItem.tsx | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/src/components/tab/ItemDyeTab/NeedDyeItem.tsx b/src/components/tab/ItemDyeTab/NeedDyeItem.tsx
index eabfcfc..cf99f79 100644
--- a/src/components/tab/ItemDyeTab/NeedDyeItem.tsx
+++ b/src/components/tab/ItemDyeTab/NeedDyeItem.tsx
@@ -64,7 +64,7 @@ export const NeedDyeItem = (props: NeedDyeItemProps) => {
               </EquipItemName>
             </EquipItemInfo>
             <CheckIconContainer class="check-icon">
-              <CheckIcon size="1em" />
+              <CheckIcon size=".75em" />
             </CheckIconContainer>
           </SelectableContainer>
         )}
@@ -127,5 +127,6 @@ const CheckIconContainer = styled('div', {
     backgroundColor: 'accent.default',
     display: 'none',
     borderRadius: '50%',
+    padding: '0.5',
   },
 });

From 5035322e2de48b5150e544df91458bab76f4f11c Mon Sep 17 00:00:00 2001
From: spd789562 <leo.yicun.lin@gmail.com>
Date: Fri, 30 Aug 2024 10:28:30 +0800
Subject: [PATCH 19/25] [edit] DyeResult should reset all dye when enable
 preserveOriginalDye

---
 src/components/tab/ItemDyeTab/DyeResultTable.tsx | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/src/components/tab/ItemDyeTab/DyeResultTable.tsx b/src/components/tab/ItemDyeTab/DyeResultTable.tsx
index c550ed1..8fe6be2 100644
--- a/src/components/tab/ItemDyeTab/DyeResultTable.tsx
+++ b/src/components/tab/ItemDyeTab/DyeResultTable.tsx
@@ -144,7 +144,9 @@ export function DyeResultTable(props: DyeResultTableProps) {
       if (!preserveOriginalDye) {
         for (const equipSubCategory of actualneedDyeCategories) {
           if (characterData.items[equipSubCategory]) {
-            characterData.items[equipSubCategory][dyeTypeEnabled] = 0;
+            characterData.items[equipSubCategory].hue = 0;
+            characterData.items[equipSubCategory].saturation = 0;
+            characterData.items[equipSubCategory].brightness = 0;
           }
         }
       }

From 4934056e7c0a3352573796de734389be89c2d503 Mon Sep 17 00:00:00 2001
From: spd789562 <leo.yicun.lin@gmail.com>
Date: Fri, 30 Aug 2024 10:46:09 +0800
Subject: [PATCH 20/25] [edit] upgrade pixi.js to 8.3.4

---
 package-lock.json | 14 +++++++-------
 package.json      |  2 +-
 2 files changed, 8 insertions(+), 8 deletions(-)

diff --git a/package-lock.json b/package-lock.json
index d790e12..405720b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -29,7 +29,7 @@
         "p-queue": "^8.0.1",
         "pixi-filters": "^6.0.3",
         "pixi-viewport": "^5.0.3",
-        "pixi.js": "^8.3.3",
+        "pixi.js": "^8.3.4",
         "solid-js": "^1.7.8",
         "throttle-debounce": "^5.0.0",
         "wasm-webp": "^0.0.2"
@@ -5076,9 +5076,9 @@
       "integrity": "sha512-DGG7cg2vUltAiL2fanzYPLR+L6qBeoskPfbUXxN6CYKW+fkni5cF9J1t2WBTmyBnC3kVq3ATFE2KDi7zy2FY8A=="
     },
     "node_modules/pixi.js": {
-      "version": "8.3.3",
-      "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.3.3.tgz",
-      "integrity": "sha512-dpucBKAqEm0K51MQKlXvyIJ40bcxniP82uz4ZPEQejGtPp0P+vueuG5DyArHCkC48mkVE2FEDvyYvBa45/JlQg==",
+      "version": "8.3.4",
+      "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.3.4.tgz",
+      "integrity": "sha512-b5qdoHMQy79JjTiOOAH/fDiK9dLKGAoxfBwkHIdsK5XKNxsFuII2MBbktvR9pVaAmTDobDkMPDoIBFKYYpDeOg==",
       "dependencies": {
         "@pixi/colord": "^2.9.6",
         "@types/css-font-loading-module": "^0.0.12",
@@ -9849,9 +9849,9 @@
       "integrity": "sha512-DGG7cg2vUltAiL2fanzYPLR+L6qBeoskPfbUXxN6CYKW+fkni5cF9J1t2WBTmyBnC3kVq3ATFE2KDi7zy2FY8A=="
     },
     "pixi.js": {
-      "version": "8.3.3",
-      "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.3.3.tgz",
-      "integrity": "sha512-dpucBKAqEm0K51MQKlXvyIJ40bcxniP82uz4ZPEQejGtPp0P+vueuG5DyArHCkC48mkVE2FEDvyYvBa45/JlQg==",
+      "version": "8.3.4",
+      "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.3.4.tgz",
+      "integrity": "sha512-b5qdoHMQy79JjTiOOAH/fDiK9dLKGAoxfBwkHIdsK5XKNxsFuII2MBbktvR9pVaAmTDobDkMPDoIBFKYYpDeOg==",
       "requires": {
         "@pixi/colord": "^2.9.6",
         "@types/css-font-loading-module": "^0.0.12",
diff --git a/package.json b/package.json
index 2dd20c6..0f8b5cb 100644
--- a/package.json
+++ b/package.json
@@ -34,7 +34,7 @@
     "p-queue": "^8.0.1",
     "pixi-filters": "^6.0.3",
     "pixi-viewport": "^5.0.3",
-    "pixi.js": "^8.3.3",
+    "pixi.js": "^8.3.4",
     "solid-js": "^1.7.8",
     "throttle-debounce": "^5.0.0",
     "wasm-webp": "^0.0.2"

From 104377a81d3b7796acded4bd486c1377863c362a Mon Sep 17 00:00:00 2001
From: spd789562 <leo.yicun.lin@gmail.com>
Date: Fri, 30 Aug 2024 11:00:57 +0800
Subject: [PATCH 21/25] [edit] upgrade nanostores and @tanstack/solid-virtual

---
 package-lock.json | 44 ++++++++++++++++++++++----------------------
 package.json      |  4 ++--
 2 files changed, 24 insertions(+), 24 deletions(-)

diff --git a/package-lock.json b/package-lock.json
index 405720b..9c664f5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,7 +12,7 @@
         "@ark-ui/solid": "^3.5.0",
         "@nanostores/solid": "^0.4.2",
         "@pdf-lib/upng": "^1.0.1",
-        "@tanstack/solid-virtual": "^3.5.1",
+        "@tanstack/solid-virtual": "^3.10.6",
         "@tauri-apps/api": ">=2.0.0-rc.0",
         "@tauri-apps/plugin-dialog": "^2.0.0-rc.0",
         "@tauri-apps/plugin-fs": "^2.0.0-rc.0",
@@ -25,7 +25,7 @@
         "lucide-solid": "^0.394.0",
         "mingcute_icon": "^2.9.4",
         "modern-gif": "^2.0.3",
-        "nanostores": "^0.10.3",
+        "nanostores": "^0.11.3",
         "p-queue": "^8.0.1",
         "pixi-filters": "^6.0.3",
         "pixi-viewport": "^5.0.3",
@@ -2299,11 +2299,11 @@
       }
     },
     "node_modules/@tanstack/solid-virtual": {
-      "version": "3.5.1",
-      "resolved": "https://registry.npmjs.org/@tanstack/solid-virtual/-/solid-virtual-3.5.1.tgz",
-      "integrity": "sha512-BhDKs3DDwGvA45rAgqeN3igcV0BJMwUX9lDxPmGDg2jEnGUy/iNeV5a8WexbbmOHzQiPNOZWirRU4C0Zc7nBnA==",
+      "version": "3.10.6",
+      "resolved": "https://registry.npmjs.org/@tanstack/solid-virtual/-/solid-virtual-3.10.6.tgz",
+      "integrity": "sha512-r/2HiD5TOz+v0zik9DtlalPtTdYwz2HK26tKPAK/a9UUbvggVdTYbpw1IZqDLSWWh1zsp7h7FKZhmNN3r5GSJQ==",
       "dependencies": {
-        "@tanstack/virtual-core": "3.5.1"
+        "@tanstack/virtual-core": "3.10.6"
       },
       "funding": {
         "type": "github",
@@ -2314,9 +2314,9 @@
       }
     },
     "node_modules/@tanstack/virtual-core": {
-      "version": "3.5.1",
-      "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.5.1.tgz",
-      "integrity": "sha512-046+AUSiDru/V9pajE1du8WayvBKeCvJ2NmKPy/mR8/SbKKrqmSbj7LJBfXE+nSq4f5TBXvnCzu0kcYebI9WdQ==",
+      "version": "3.10.6",
+      "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.10.6.tgz",
+      "integrity": "sha512-1giLc4dzgEKLMx5pgKjL6HlG5fjZMgCjzlKAlpr7yoUtetVPELgER1NtephAI910nMwfPTHNyWKSFmJdHkz2Cw==",
       "funding": {
         "type": "github",
         "url": "https://github.com/sponsors/tannerlinsley"
@@ -4849,9 +4849,9 @@
       }
     },
     "node_modules/nanostores": {
-      "version": "0.10.3",
-      "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-0.10.3.tgz",
-      "integrity": "sha512-Nii8O1XqmawqSCf9o2aWqVxhKRN01+iue9/VEd1TiJCr9VT5XxgPFbF1Edl1XN6pwJcZRsl8Ki+z01yb/T/C2g==",
+      "version": "0.11.3",
+      "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-0.11.3.tgz",
+      "integrity": "sha512-TUes3xKIX33re4QzdxwZ6tdbodjmn3tWXCEc1uokiEmo14sI1EaGYNs2k3bU2pyyGNmBqFGAVl6jAGWd06AVIg==",
       "funding": [
         {
           "type": "github",
@@ -7701,17 +7701,17 @@
       }
     },
     "@tanstack/solid-virtual": {
-      "version": "3.5.1",
-      "resolved": "https://registry.npmjs.org/@tanstack/solid-virtual/-/solid-virtual-3.5.1.tgz",
-      "integrity": "sha512-BhDKs3DDwGvA45rAgqeN3igcV0BJMwUX9lDxPmGDg2jEnGUy/iNeV5a8WexbbmOHzQiPNOZWirRU4C0Zc7nBnA==",
+      "version": "3.10.6",
+      "resolved": "https://registry.npmjs.org/@tanstack/solid-virtual/-/solid-virtual-3.10.6.tgz",
+      "integrity": "sha512-r/2HiD5TOz+v0zik9DtlalPtTdYwz2HK26tKPAK/a9UUbvggVdTYbpw1IZqDLSWWh1zsp7h7FKZhmNN3r5GSJQ==",
       "requires": {
-        "@tanstack/virtual-core": "3.5.1"
+        "@tanstack/virtual-core": "3.10.6"
       }
     },
     "@tanstack/virtual-core": {
-      "version": "3.5.1",
-      "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.5.1.tgz",
-      "integrity": "sha512-046+AUSiDru/V9pajE1du8WayvBKeCvJ2NmKPy/mR8/SbKKrqmSbj7LJBfXE+nSq4f5TBXvnCzu0kcYebI9WdQ=="
+      "version": "3.10.6",
+      "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.10.6.tgz",
+      "integrity": "sha512-1giLc4dzgEKLMx5pgKjL6HlG5fjZMgCjzlKAlpr7yoUtetVPELgER1NtephAI910nMwfPTHNyWKSFmJdHkz2Cw=="
     },
     "@tauri-apps/api": {
       "version": "2.0.0-rc.0",
@@ -9688,9 +9688,9 @@
       "dev": true
     },
     "nanostores": {
-      "version": "0.10.3",
-      "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-0.10.3.tgz",
-      "integrity": "sha512-Nii8O1XqmawqSCf9o2aWqVxhKRN01+iue9/VEd1TiJCr9VT5XxgPFbF1Edl1XN6pwJcZRsl8Ki+z01yb/T/C2g=="
+      "version": "0.11.3",
+      "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-0.11.3.tgz",
+      "integrity": "sha512-TUes3xKIX33re4QzdxwZ6tdbodjmn3tWXCEc1uokiEmo14sI1EaGYNs2k3bU2pyyGNmBqFGAVl6jAGWd06AVIg=="
     },
     "node-eval": {
       "version": "2.0.0",
diff --git a/package.json b/package.json
index 0f8b5cb..84031fa 100644
--- a/package.json
+++ b/package.json
@@ -17,7 +17,7 @@
     "@ark-ui/solid": "^3.5.0",
     "@nanostores/solid": "^0.4.2",
     "@pdf-lib/upng": "^1.0.1",
-    "@tanstack/solid-virtual": "^3.5.1",
+    "@tanstack/solid-virtual": "^3.10.6",
     "@tauri-apps/api": ">=2.0.0-rc.0",
     "@tauri-apps/plugin-dialog": "^2.0.0-rc.0",
     "@tauri-apps/plugin-fs": "^2.0.0-rc.0",
@@ -30,7 +30,7 @@
     "lucide-solid": "^0.394.0",
     "mingcute_icon": "^2.9.4",
     "modern-gif": "^2.0.3",
-    "nanostores": "^0.10.3",
+    "nanostores": "^0.11.3",
     "p-queue": "^8.0.1",
     "pixi-filters": "^6.0.3",
     "pixi-viewport": "^5.0.3",

From 2ce2d42e8afe7ac92c69839f5e54bc6b7a1001bd Mon Sep 17 00:00:00 2001
From: spd789562 <leo.yicun.lin@gmail.com>
Date: Fri, 30 Aug 2024 13:09:17 +0800
Subject: [PATCH 22/25] [edit] fix SceneColorPicker will be block by sidebar

---
 src/components/AppContainer.tsx                               | 2 +-
 src/components/CharacterPreview/CharacterSceneColorPicker.tsx | 1 +
 2 files changed, 2 insertions(+), 1 deletion(-)

diff --git a/src/components/AppContainer.tsx b/src/components/AppContainer.tsx
index e8e3a45..1fb675a 100644
--- a/src/components/AppContainer.tsx
+++ b/src/components/AppContainer.tsx
@@ -18,7 +18,7 @@ export const AppContainer = (props: AppContainerProps) => {
       class={css({
         position: 'relative',
         mx: { base: 0, lg: 2 },
-        mt: 4,
+        mt: 11,
         paddingLeft: isLeftDrawerPin()
           ? { base: 2, lg: '{sizes.xs}' }
           : { base: 2, '2xl': '{sizes.xs}' },
diff --git a/src/components/CharacterPreview/CharacterSceneColorPicker.tsx b/src/components/CharacterPreview/CharacterSceneColorPicker.tsx
index abf1333..cf13b16 100644
--- a/src/components/CharacterPreview/CharacterSceneColorPicker.tsx
+++ b/src/components/CharacterPreview/CharacterSceneColorPicker.tsx
@@ -59,6 +59,7 @@ export const CharacterSceneColorPicker = (
       defaultValue="#ffffff"
       positioning={{
         strategy: 'fixed',
+        placement: 'top-end'
       }}
       onInteractOutside={handleOutsideClick}
       onValueChange={handleColorChange}

From 1dd7fe3a2b3ee1a3c9e4572bf6541a986ff30035 Mon Sep 17 00:00:00 2001
From: spd789562 <leo.yicun.lin@gmail.com>
Date: Fri, 30 Aug 2024 13:14:18 +0800
Subject: [PATCH 23/25] [edit] EquipDrawer should lock close when get pinned

---
 src/components/drawer/EqupimentDrawer/EquipDrawer.tsx | 10 +++++++++-
 1 file changed, 9 insertions(+), 1 deletion(-)

diff --git a/src/components/drawer/EqupimentDrawer/EquipDrawer.tsx b/src/components/drawer/EqupimentDrawer/EquipDrawer.tsx
index 9b0ba07..59fdae8 100644
--- a/src/components/drawer/EqupimentDrawer/EquipDrawer.tsx
+++ b/src/components/drawer/EqupimentDrawer/EquipDrawer.tsx
@@ -22,8 +22,12 @@ interface EquipDrawerProps {
 }
 export const EquipDrawer = (props: EquipDrawerProps) => {
   const isOpen = useStore($equpimentDrawerOpen);
+  const isPinned = useStore($equpimentDrawerPin);
 
   function handleClose(_: unknown) {
+    if (isPinned()) {
+      return;
+    }
     $equpimentDrawerOpen.set(false);
   }
 
@@ -44,7 +48,11 @@ export const EquipDrawer = (props: EquipDrawerProps) => {
               {props.header}
               <HStack position="absolute" top="1" right="1">
                 <PinIconButton store={$equpimentDrawerPin} variant="ghost" />
-                <IconButton variant="ghost" onClick={handleClose}>
+                <IconButton
+                  variant="ghost"
+                  onClick={handleClose}
+                  disabled={isPinned()}
+                >
                   <CloseIcon />
                 </IconButton>
               </HStack>

From f4ea4ca33673fc2dc8086b425864f796ff9b3c26 Mon Sep 17 00:00:00 2001
From: spd789562 <leo.yicun.lin@gmail.com>
Date: Fri, 30 Aug 2024 13:18:37 +0800
Subject: [PATCH 24/25] [edit] adjust NeedDyeItem style

---
 src/components/tab/ItemDyeTab/DyeTypeRadioGroup.tsx | 2 +-
 src/components/tab/ItemDyeTab/NeedDyeItem.tsx       | 4 +++-
 2 files changed, 4 insertions(+), 2 deletions(-)

diff --git a/src/components/tab/ItemDyeTab/DyeTypeRadioGroup.tsx b/src/components/tab/ItemDyeTab/DyeTypeRadioGroup.tsx
index 90057f1..d00450a 100644
--- a/src/components/tab/ItemDyeTab/DyeTypeRadioGroup.tsx
+++ b/src/components/tab/ItemDyeTab/DyeTypeRadioGroup.tsx
@@ -56,6 +56,6 @@ const ColorBlock = styled('div', {
     h: 3,
     ml: 2,
     borderRadius: 'sm',
-    boxShadow: 'sm',
+    boxShadow: 'md',
   },
 });
diff --git a/src/components/tab/ItemDyeTab/NeedDyeItem.tsx b/src/components/tab/ItemDyeTab/NeedDyeItem.tsx
index cf99f79..dbccf99 100644
--- a/src/components/tab/ItemDyeTab/NeedDyeItem.tsx
+++ b/src/components/tab/ItemDyeTab/NeedDyeItem.tsx
@@ -77,8 +77,10 @@ const SelectableContainer = styled('button', {
   base: {
     display: 'grid',
     py: '1',
-    px: '2',
+    pl: '1',
+    pr: '2',
     borderRadius: 'md',
+    gap: '1',
     // width: 'full',
     gridTemplateColumns: 'auto 1fr',
     alignItems: 'center',

From afbaf8145ed118bf30c3697c2ee5d66514eb18ad Mon Sep 17 00:00:00 2001
From: spd789562 <leo.yicun.lin@gmail.com>
Date: Fri, 30 Aug 2024 13:29:54 +0800
Subject: [PATCH 25/25] [release] v0.2.0

---
 CHANGELOG.md              | 18 ++++++++++++++++++
 package.json              |  2 +-
 src-tauri/Cargo.lock      |  2 +-
 src-tauri/Cargo.toml      |  2 +-
 src-tauri/tauri.conf.json |  2 +-
 5 files changed, 22 insertions(+), 4 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 961b8a2..68efcaa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,2 +1,20 @@
+## [0.2.0]
+還是測試版,目前還在想要新增什麼功能
+
+### 新增
+- 設定新增開起存檔及暫存資料夾的按鈕,以便使用者備份或移機
+- 設定新增版本顯示
+- 裝備染色表格,預覽當前裝備色相、飽和或亮度的變化
+- 高清化(Anime4K) 現在將會直接套用在預覽角色身上
+
+### 修改
+- 修正皇家神獸學院裝備無法載入的問題(no slot/vslot at info)
+- 修正啟用篩選染色時會導致髮型及臉型也被套用的問題
+- 修正一些鞋子渲染錯誤
+- 修正部分披風渲染時會有部件消失的問題
+- 小修改一些 UI
+- 升級一些套件
+
+
 ## [0.1.0]
 - 首次發布測試版
\ No newline at end of file
diff --git a/package.json b/package.json
index 84031fa..51be7bb 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "maplecharactercreator",
-  "version": "0.0.0",
+  "version": "0.2.0",
   "description": "",
   "type": "module",
   "scripts": {
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 3a30426..fbc22e6 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -2158,7 +2158,7 @@ dependencies = [
 
 [[package]]
 name = "maplesalon2"
-version = "0.1.0"
+version = "0.2.0"
 dependencies = [
  "axum",
  "futures",
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index df4b2ec..a6ad1bf 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "maplesalon2"
-version = "0.1.0"
+version = "0.2.0"
 description = "MapleSalon2 - A tool for preview hair, face and item dye using MapleStory wz files."
 authors = ["Leo"]
 edition = "2021"
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index 1e3d567..2ca901b 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -1,6 +1,6 @@
 {
   "productName": "MapleSalon2",
-  "version": "0.1.0",
+  "version": "0.2.0",
   "identifier": "com.maplesalon.io",
   "build": {
     "beforeDevCommand": "npm run dev",