diff --git a/.gitignore b/.gitignore
index 3c8b0ce..aca0b68 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,11 +1,11 @@
 *
 
-!fusion
-!fusion/datatypes.py
-!fusion/device.py
+!lib
+!lib/datatypes.py
+!lib/device.py
 
 !.gitignore
 !play.py
-!requirements.txt
+!pyproject.toml
 !profiles.py
 !test.py
diff --git a/fusion/datatypes.py b/lib/datatypes.py
similarity index 100%
rename from fusion/datatypes.py
rename to lib/datatypes.py
diff --git a/fusion/device.py b/lib/device.py
similarity index 100%
rename from fusion/device.py
rename to lib/device.py
diff --git a/play.py b/play.py
index 4c0500c..5aa6d6b 100644
--- a/play.py
+++ b/play.py
@@ -1,130 +1,135 @@
-from fusion.datatypes import *
-from fusion.device import get_device
+try:
+    from lib.datatypes import *  #local script
+    from lib.device import get_device
+except ImportError:
+    from .lib.datatypes import * #as a module
+    from .lib.device import get_device
+
 from cv2.typing import MatLike
 import sys, cv2, time, numpy, atexit
 
 #10 is actually not transient - it is persistent as a secondary profile but for our purposes we can treat it as transient
 #since nobody should have the need to write to a profile number this high (switching between profiles require os interaction anyways so might as well write a new image)
 TRANSIENT_ID = 10
 
 if len(sys.argv) < 2:
     print(f'Usage: {__file__} <video path> [profile id]')
     exit(-1)
 
 if len(sys.argv) >= 3:
     try:
         profile = int(sys.argv[2])
         if profile not in range(5):
             raise TypeError()
     except:
         print('Invalid profile id given, defaulting to transience (will be reverted after play (or reboot if interrupted))...')
         profile = TRANSIENT_ID
 else:
     profile = TRANSIENT_ID
 
 dev = get_device()
 orig_effect = dev.get_simple_light_effect()
 
 #if transient, set it back to the old profile
 if profile == TRANSIENT_ID:
     def reset():
         if orig_effect.fusion_effect < FusionLightEffect.Custom1:
             dev.set_simple_light_effect(orig_effect)
         else:
             load = PictureMatrix.from_bytes(dev.get_custom_light_effect(orig_effect.fusion_effect - FusionLightEffect.Custom1))
             dev.set_custom_light_effect(profile, load)
     atexit.register(reset)
 
 
 #change to profile for the image stream first (technically only need to change if not transient and current profile is non custom, but we revert afterwards anyway)
 #to avoid any flashing set to black first (mainly when we are in simple modes which means last seen image will be shown right when set_simple_light_effect is called)
 dev.set_custom_light_effect(profile, PictureMatrix([RGB(0,0,0) for i in range(105)]))
 #need to set it to one of the "proper" profiles for it to know to update
 dev.set_simple_light_effect(FusionLightData(FusionLightEffect.Custom1 + profile if profile != TRANSIENT_ID else FusionLightEffect.Custom1, 1, 255, FusionLightColor.White, FusionLightDirection.Left2Right))
 
 
 def set_frame(raw: MatLike, fps_data: List[float]) -> None:
     pre = cv2.resize(raw, (19, 6), interpolation=cv2.INTER_AREA)
     im = cv2.cvtColor(pre, cv2.COLOR_BGR2RGB)
 
     #display what's supposed to be encoded in the keyboard at this frame
     cv2.imshow('preview', cv2.resize(pre, (190*5, 60*5)))
     if len(fps_data) > 1:
         cv2.setWindowTitle('preview', f'Preview (FPS: {1/numpy.average(numpy.diff(fps_data))})')
     cv2.waitKey(1)
 
     #turns out even if i do translate this into a list of RGB instead of just directly send mats it still gives basically around the same fps
     pixels = im
     pixels = []
 
     h, w, _ = im.shape
 
     for y in range(h):
         for x in range(w):
             pixels.append(RGB(*im[y, x]))
 
     #apply any adjustment needed to give true colors
     dev.set_custom_light_effect(profile, PictureMatrix.pixel_matrix_to_keys(pixels), adjust=dev.ADJUST_RGB)
 
 
 
 #apparently also works with static images
 vid = cv2.VideoCapture(sys.argv[1])
 
 #skip some frames so it's not too slow
 #TODO make this less hardcoded - this value is from testing on my laptop with bad apple
 AVG_FPS = 11
 interval = vid.get(cv2.CAP_PROP_FPS) // AVG_FPS
 
 actual_fps = []
 
 #this actually doesnt help that much it just gives like 1 more fps and takes a while to buffer :(
 PRECOMPUTE = False
 
 if PRECOMPUTE:
     buf = []
 
     print('Buffering...')
 
     count = 0
     while vid.isOpened():
         
         ret, frame = vid.read()
         if ret:
             buf.append(frame)
             count += interval
             vid.set(cv2.CAP_PROP_POS_FRAMES, count)
         else:
             vid.release()
             break
 
     print('Done. Starting player...')
 
     for frame in buf:
         actual_fps.append(time.time())
 
         set_frame(frame, actual_fps)
         #get an averagable data before popping
         if len(actual_fps) > 10:
             actual_fps.pop(0)
 else:
     count = 0
     while vid.isOpened():
         
         actual_fps.append(time.time())
 
         ret, frame = vid.read()
         if ret:
             set_frame(frame, actual_fps)
             count += interval
             vid.set(cv2.CAP_PROP_POS_FRAMES, count)
             #get an averagable data before popping
             if len(actual_fps) > 10:
                 actual_fps.pop(0)
         else:
             vid.release()
             break
 
 
 #keep last frame until we manually quit
 cv2.waitKey(0)
\ No newline at end of file
diff --git a/profiles.py b/profiles.py
index 44b7130..57fcf37 100644
--- a/profiles.py
+++ b/profiles.py
@@ -1,50 +1,55 @@
-from fusion.device import get_device
-from fusion.datatypes import *
+try:
+    from lib.datatypes import *  #local script
+    from lib.device import get_device
+except ImportError:
+    from .lib.datatypes import * #as a module
+    from .lib.device import get_device
+
 from cv2.typing import MatLike
 import sys, cv2
 
 """
 For setting images in / switching between profiles. 
 """
 
 if len(sys.argv) < 2:
     print(f'Usage: {__file__} <profile id> [image path]')
     exit(-1)
 
 #only allow properly supported profile ids since theres not much point in supporting it especially when it gets reset after reboot
 #(even though profile ids way higher than this is actually writable and persistent, see set_custom_light_effect comments)
 try:
     profile = int(sys.argv[1])
     if profile not in range(5):
         raise TypeError()
 except:
     print('Invalid profile given (must be from 0-4).')
     exit(-1)
 
 dev = get_device()
 
 if len(sys.argv) > 2:
     #load a new image
     def set_image(raw: MatLike) -> None:
         pre = cv2.resize(raw, (19, 6), interpolation=cv2.INTER_AREA)
         im = cv2.cvtColor(pre, cv2.COLOR_BGR2RGB)
 
         pixels = []
 
         h, w, _ = im.shape
 
         for y in range(h):
             for x in range(w):
                 pixels.append(RGB(*im[y, x]))
 
         #adjustment needed to give true colors (maybe it's just my keyboard)
         dev.set_custom_light_effect(profile, PictureMatrix.pixel_matrix_to_keys(pixels), adjust=dev.ADJUST_RGB)
 
     set_image(cv2.imread(sys.argv[2]))
 else:
     #at runtime this would not reload if we dont set_custom_light_effect so we need to do it anyways
     #(unless we are not in any of the custom profiles then the last image will show up right when we do set_simple_light_effect)
     load = PictureMatrix.from_bytes(dev.get_custom_light_effect(profile))
     dev.set_custom_light_effect(profile, load)
 
 dev.set_simple_light_effect(FusionLightData(FusionLightEffect.Custom1 + profile, 1, 255, FusionLightColor.White, FusionLightDirection.Left2Right))
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..8e7a3bc
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,27 @@
+[build-system]
+requires = ["setuptools", "setuptools-scm"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "fusion"
+version = "0.1.0"
+authors = [
+    { name = "despawningbone", email = "contact@despawningbone.me" },
+]
+description = "GIGABYTE Fusion RGB reimplementation"
+dependencies = [
+    "hidapi"
+]
+
+[project.optional-dependencies]
+player = ["opencv-python"]
+
+#install both the lib and the scripts onto site packages
+[tool.setuptools]
+include-package-data = false
+packages = ["fusion", "fusion.lib"]
+package-dir = { fusion = "." }   #don't include fusion.lib here it's unnecessary
+
+#comment the above section and use this instead to just install the dependencies since this repo can also be run standalone
+#[tool.setuptools]
+#packages = []
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index ece54bd..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1 +0,0 @@
-hidapi
\ No newline at end of file
diff --git a/test.py b/test.py
index fb129e8..c7db826 100644
--- a/test.py
+++ b/test.py
@@ -1,25 +1,29 @@
-from fusion.device import get_device
-from fusion.datatypes import *
+try:
+    from lib.datatypes import *  #local script
+    from lib.device import get_device
+except ImportError:
+    from .lib.datatypes import * #as a module
+    from .lib.device import get_device
 
 dev = get_device()
 print(dev.get_version_string())
 
 
 #set simple effect
 #print(dev.set_simple_light_effect(FusionLightData(FusionLightEffect.Wave, 1, 50, FusionLightColor.Random, FusionLightDirection.Right2Left)))
 #print(dev.set_simple_light_effect(FusionLightData(FusionLightEffect.Static, 1, 50, FusionLightColor.White, FusionLightDirection.Right2Left)))
 
 #get and set to verify if get is correctly implemented
 # load = PictureMatrix.from_bytes(dev.get_custom_light_effect(1))
 # print(load)
 # dev.set_custom_light_effect(1, load)
 
 #manually set color
 mat = PictureMatrix([RGB(70, 255, 170) for i in range(105)])  #seems like the rgb algo in the keyboard firmware really sucks - this value seems more true white than anything (edit: it's actually known in the driver and has special cases)
 dev.set_custom_light_effect(10, mat)
 print(dev.set_simple_light_effect(FusionLightData(FusionLightEffect.Custom1+10, 1, 255, FusionLightColor.Random, FusionLightDirection.Left2Right)))
 
 #test reset (seems like after reset if we set the light effect to custom profiles without data it would crash? not if we immediately set the profile afterwards tho)
 #also seems like if we change profiles right(? seems to last longer than 100ms for sure) after reset some data (e.g wave direction and speed) would get reset
 # print(dev.reset())
 # print(dev.set_simple_light_effect(FusionLightData(FusionLightEffect.Static, 2, 255, FusionLightColor.White, FusionLightDirection.Up2Down)))
\ No newline at end of file