├── .gitignore ├── README.md ├── bad-apple.webm ├── bun.lockb ├── create_output.py ├── extract_frames.sh ├── helper.ts ├── jsconfig.json ├── nwg-panel ├── config └── style.css ├── output.json ├── package.json ├── process.py ├── process.ts ├── scratch.js ├── setup.sh ├── start.ts └── types.ts /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | frames/ 3 | processed/ 4 | __pycache__/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hypr-apple 2 | 3 | [![Thumbnail](https://img.youtube.com/vi/qO0QDz9lDRw/0.jpg)](https://www.youtube.com/watch?v=qO0QDz9lDRw) 4 | 5 | [Youtube Video](https://www.youtube.com/watch?v=qO0QDz9lDRw) 6 | 7 | Somewhat messy scripts to get the effect. 8 | 9 | Open up an issue if you need help :) 10 | 11 | The bar is [`nwg-panel`](https://github.com/nwg-piotr/nwg-panel) and the config is in the folder with the same name. 12 | 13 | ## Requirements 14 | 15 | Bun, Python, ffmpeg 16 | 17 | `bun install` and for Python you need to install OpenCv. 18 | 19 | ## Steps 20 | 21 | If you want to start from scratch: 22 | 23 | 1. Run `./extract_frames.sh` 24 | 2. Run `python create_output.py` 25 | 3. Run `bun start.ts` 26 | 27 | `output.json` is already available though, so you don't need to process the frames again. 28 | -------------------------------------------------------------------------------- /bad-apple.webm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vdawg-git/hypr-apple/442f07407b49dec4bbb265199e7c94adf544f4a4/bad-apple.webm -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vdawg-git/hypr-apple/442f07407b49dec4bbb265199e7c94adf544f4a4/bun.lockb -------------------------------------------------------------------------------- /create_output.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import cv2 4 | from process import process_frame 5 | 6 | dirname = os.path.dirname(os.path.abspath(__file__)) 7 | 8 | 9 | image_folder = "frames" 10 | output_folder = "processed" 11 | if not os.path.exists(output_folder): 12 | os.makedirs(output_folder) 13 | 14 | images = sorted([img for img in os.listdir(image_folder) if img.endswith(".png")]) 15 | height, width, layers = cv2.imread(os.path.join(image_folder, images[0])).shape 16 | 17 | processed_frames = [] 18 | amount_images = len(images) - 1 19 | print("amount images: ", amount_images) 20 | 21 | for index, image in enumerate(images): 22 | image_path = os.path.join(dirname, image_folder, image) 23 | 24 | try: 25 | processed = process_frame(image_path) 26 | processed = [ 27 | ((image.shape[1], image.shape[0]), coordinates) 28 | for image, coordinates in processed 29 | ] 30 | processed_frames.append(processed) 31 | print("Processed: ", index + 1) 32 | except Exception as e: 33 | print("Error at frame:", image_path) 34 | print(e) 35 | break 36 | 37 | 38 | output_path = os.path.join(dirname, output_folder, "chunks.json") 39 | print(output_path) 40 | 41 | output = json.dumps( 42 | {"dimensions": {"width": width, "height": height}, "frames": processed_frames} 43 | ) 44 | with open("output.json", "w") as f: 45 | f.write(output) 46 | 47 | # for index, chunks in enumerate(processed_frames): 48 | 49 | # # Draw rectangles around the segmented regions 50 | # result_image = cv2.cvtColor(binary_image, cv2.COLOR_GRAY2BGR) 51 | # h, w, x = result_image.shape 52 | 53 | # output_image = np.full((h, w, 3), 255, np.uint8) 54 | 55 | # for region in regions: 56 | # image, coordinates = region 57 | # h, w = image.shape 58 | # x, y = coordinates 59 | 60 | # b = randrange(0, 180, 50) 61 | # r = randrange(70, 255, 50) 62 | # g = randrange(0, 180, 50) 63 | # # print(image.shape, (x,y), (r, g, b) ) 64 | 65 | # cv2.rectangle( 66 | # result_image, (x, y), (x + w, y + h), color=(b, g, r), thickness=1 67 | # ) 68 | 69 | # cv2.rectangle( 70 | # output_image, (x, y), (x + w, y + h), color=(0, 0, 0), thickness=-1 71 | # ) 72 | 73 | 74 | # output_file_path = os.path.join(output_path, str(index) + ".png") 75 | # cv2.imwrite( 76 | # output_file_path, 77 | # chunks, 78 | # ) 79 | -------------------------------------------------------------------------------- /extract_frames.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | mkdir frames 3 | ffmpeg -i bad-apple.webm frames/out-%03d.png 4 | -------------------------------------------------------------------------------- /helper.ts: -------------------------------------------------------------------------------- 1 | import type { Command, Coordinates, Dimensions } from "./types" 2 | 3 | export function createCtlAnimationString( 4 | /** The PID */ 5 | id: string, 6 | coordinates: Coordinates, 7 | size: Dimensions 8 | ): readonly Command[] { 9 | const [x, y] = coordinates.map(Math.round) 10 | 11 | return [ 12 | `hyprctl dispatch resizewindowpixel exact ${size 13 | .map(Math.floor) 14 | .join(" ")},pid:${id}`, 15 | `hyprctl dispatch movewindowpixel exact ${x} ${y},pid:${id}`, 16 | ] 17 | } 18 | 19 | export function createBatchCommand(commands: string[]): string { 20 | return `hyprctl --batch "` + commands.join(";") + '"' 21 | } 22 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ESNext"], 4 | "target": "ESNext", 5 | "module": "ESNext", 6 | "moduleDetection": "force", 7 | "jsx": "react-jsx", 8 | "allowJs": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "verbatimModuleSyntax": true, 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "skipLibCheck": true, 18 | "strict": true, 19 | "noFallthroughCasesInSwitch": true, 20 | "forceConsistentCasingInFileNames": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /nwg-panel/config: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "panel-top", 4 | "output": "", 5 | "layer": "top", 6 | "position": "top", 7 | "controls": "right", 8 | "width": "auto", 9 | "height": 32, 10 | "homogeneous": true, 11 | "margin-top": 0, 12 | "margin-bottom": 0, 13 | "padding-horizontal": 0, 14 | "padding-vertical": 0, 15 | "spacing": 0, 16 | "items-padding": 0, 17 | "icons": "light", 18 | "css-name": "panel-top", 19 | "modules-left": [ 20 | "hyprland-workspaces" 21 | ], 22 | "modules-center": [ 23 | "clock" 24 | ], 25 | "modules-right": [ 26 | "tray", 27 | "swaync" 28 | ], 29 | "controls-settings": { 30 | "components": [ 31 | "brightness", 32 | "volume", 33 | "battery" 34 | ], 35 | "commands": { 36 | "battery": "tlpui", 37 | "processes": "gnome-usage" 38 | }, 39 | "show-values": false, 40 | "interval": 1, 41 | "icon-size": 16, 42 | "hover-opens": true, 43 | "leave-closes": true, 44 | "click-closes": false, 45 | "css-name": "controls-window", 46 | "custom-items": [ 47 | { 48 | "name": "Panel settings", 49 | "icon": "nwg-panel", 50 | "cmd": "nwg-panel-config" 51 | } 52 | ], 53 | "menu": { 54 | "name": "Exit", 55 | "icon": "system-shutdown-symbolic", 56 | "items": [ 57 | { 58 | "name": "Lock", 59 | "cmd": "gtklock" 60 | }, 61 | { 62 | "name": "Logout", 63 | "cmd": "hyprctl kill" 64 | }, 65 | { 66 | "name": "Reboot", 67 | "cmd": "systemctl reboot" 68 | }, 69 | { 70 | "name": "Shutdown", 71 | "cmd": "systemctl -i poweroff" 72 | } 73 | ] 74 | }, 75 | "output-switcher": true, 76 | "backlight-controller": "brightnessctl", 77 | "backlight-device": "", 78 | "window-width": 0, 79 | "window-margin-horizontal": 0, 80 | "window-margin-vertical": 0, 81 | "root-css-name": "controls-overview", 82 | "battery-low-level": 20, 83 | "battery-low-interval": 3, 84 | "processes-label": "Processes", 85 | "angle": 0 86 | }, 87 | "tray": { 88 | "root-css-name": "tray", 89 | "inner-css-name": "inner-tray", 90 | "icon-size": 16, 91 | "smooth-scrolling-threshold": 0 92 | }, 93 | "clock": { 94 | "format": "%H:%M", 95 | "tooltip-text": "%a, %d. %B", 96 | "on-left-click": "", 97 | "on-middle-click": "", 98 | "on-right-click": "", 99 | "on-scroll-up": "", 100 | "on-scroll-down": "", 101 | "css-name": "clock", 102 | "interval": 30, 103 | "tooltip-date-format": true, 104 | "root-css-name": "root-clock", 105 | "angle": 0, 106 | "calendar-path": "", 107 | "calendar-css-name": "calendar-window", 108 | "calendar-placement": "top", 109 | "calendar-margin-horizontal": 0, 110 | "calendar-margin-vertical": 0, 111 | "calendar-icon-size": 24, 112 | "calendar-interval": 60, 113 | "calendar-on": true 114 | }, 115 | "playerctl": { 116 | "buttons-position": "left", 117 | "icon-size": 16, 118 | "chars": 30, 119 | "scroll": false, 120 | "button-css-name": "player-control", 121 | "label-css-name": "player-label", 122 | "interval": 1, 123 | "show-cover": false, 124 | "cover-size": 24, 125 | "css-name": "playerctl", 126 | "angle": 0.0 127 | }, 128 | "button-sample": { 129 | "command": "notify-send 'sample button'", 130 | "icon": "view-grid", 131 | "label": "", 132 | "label-position": "right", 133 | "css-name": "button-custom", 134 | "icon-size": 16 135 | }, 136 | "menu-start": "off", 137 | "exclusive-zone": true, 138 | "sigrt": 64, 139 | "use-sigrt": false, 140 | "scratchpad": {}, 141 | "openweather": {}, 142 | "brightness-slider": {}, 143 | "dwl-tags": {}, 144 | "hyprland-taskbar": { 145 | "name-max-len": 24, 146 | "icon-size": 16, 147 | "workspaces-spacing": 0, 148 | "client-padding": 0, 149 | "show-app-icon": true, 150 | "show-app-name": false, 151 | "show-app-name-special": false, 152 | "show-layout": false, 153 | "all-outputs": false, 154 | "mark-xwayland": false, 155 | "angle": 0, 156 | "image-size": 24, 157 | "task-padding": 0, 158 | "css-name": "hypr-taskbar" 159 | }, 160 | "hyprland-workspaces": { 161 | "num-ws": 10, 162 | "show-icon": false, 163 | "image-size": 16, 164 | "show-name": false, 165 | "name-length": 40, 166 | "show-empty": false, 167 | "mark-content": false, 168 | "show-names": true, 169 | "mark-floating": false, 170 | "mark-xwayland": false, 171 | "css-name": "hypr-workspaces", 172 | "angle": 0.0 173 | }, 174 | "swaync": { 175 | "tooltip-text": "Notifications", 176 | "on-left-click": "swaync-client -t", 177 | "on-middle-click": "", 178 | "on-right-click": "", 179 | "on-scroll-up": "", 180 | "on-scroll-down": "", 181 | "root-css-name": "root-executor", 182 | "css-name": "executor", 183 | "icon-placement": "left", 184 | "icon-size": 18, 185 | "interval": 1, 186 | "always-show-icon": true 187 | }, 188 | "start-hidden": false, 189 | "sway-taskbar": {}, 190 | "sway-workspaces": {}, 191 | "sway-mode": {} 192 | } 193 | ] 194 | 195 | -------------------------------------------------------------------------------- /nwg-panel/style.css: -------------------------------------------------------------------------------- 1 | @define-color main-bg black; 2 | @define-color fg2 white; 3 | @define-color main-border black; 4 | 5 | #controls-overview > box > box > image { 6 | padding: 0px 4px; 7 | } 8 | 9 | #hyprland-workspaces, 10 | #clock, 11 | #right-box { 12 | padding: 0 12px; 13 | border-bottom: 1px solid @main-border; 14 | background: @main-bg; 15 | } 16 | 17 | #right-box { 18 | padding-left: 24px; 19 | border-radius: 0px 0px 0px 999px; 20 | border-left: 1px solid @main-border; 21 | } 22 | #clock { 23 | border-radius: 0px 0px 999px 999px; 24 | border-left: 1px solid @main-border; 25 | border-right: 1px solid @main-border; 26 | border-bottom: 1px solid @main-border; 27 | padding: 0px 40px; 28 | } 29 | 30 | #panel-top > box { 31 | /* padding: 0px 12px; */ 32 | } 33 | 34 | /* Recommended if panel "height" unset or smaller then your theme default button height */ 35 | button { 36 | margin: 0px; 37 | } 38 | 39 | /* Top panel in sample config uses this name */ 40 | #panel-top { 41 | /* background: transparentize(@theme_bg_color, 0.5); */ 42 | /* background: mix(@theme_bg_color, transparent, 0.9); */ 43 | background: transparent; 44 | color: white; 45 | } 46 | 47 | #controls-overview { 48 | } 49 | 50 | #hyprland-workspaces { 51 | padding-top: 2px; 52 | padding-bottom: 2px; 53 | padding-right: 16px; 54 | border-radius: 0px 0px 90px 0px; 55 | border-right: 1px solid @main-border; 56 | font-weight: bold; 57 | } 58 | 59 | #hyprland-workspaces #hyprland-workspaces-item { 60 | min-width: 8px; 61 | margin: 0px 0px; 62 | margin: 2px 0px; 63 | } 64 | 65 | #hyprland-workspaces #task-box-focused #hyprland-workspaces-item { 66 | background: @fg2; 67 | min-width: 40px; 68 | color: white; 69 | border-radius: 9999px; 70 | } 71 | #hyprland-workspaces #task-box-focused #hyprland-workspaces-item label { 72 | background: transparent; 73 | color: black; 74 | padding: 0px 16px; 75 | } 76 | 77 | #hyprland-workspaces #hyprland-workspaces-item label { 78 | min-width: 24px; 79 | min-height: 10px; 80 | background: @main-bg; 81 | border-radius: 999px; 82 | font-size: 12px; 83 | } 84 | 85 | #player-control { 86 | min-height: 0px; 87 | background: transparent; 88 | color: white; 89 | } 90 | 91 | /* Executors usually behave better in monospace fonts */ 92 | #executor-label { 93 | font-family: monospace; 94 | } 95 | 96 | /* and so does the Clock */ 97 | #clock { 98 | font-family: monospace; 99 | font-size: 16px; 100 | } 101 | 102 | 103 | 104 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hypr-apple", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "type": "module", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "zx": "^7.2.3" 15 | }, 16 | "devDependencies": { 17 | "@types/bun": "latest" 18 | }, 19 | "peerDependencies": { 20 | "typescript": "^5.0.0" 21 | } 22 | } -------------------------------------------------------------------------------- /process.py: -------------------------------------------------------------------------------- 1 | import cv2 2 | import numpy as np 3 | import os 4 | import pprint 5 | from collections import Counter 6 | from random import randrange 7 | 8 | # Nessecary for show image to work 9 | os.environ["QT_QPA_PLATFORM"] = "xcb" 10 | 11 | frame1 = "frames/00131.png" 12 | frame2 = "frames/06413.png" 13 | frame3 = "frames/04294.png" 14 | frame4 = "frames/00544.png" 15 | frame5 = "frames/02733.png" 16 | blank_black = "frames/00001.png" 17 | blank_white = "frames/02764.png" 18 | 19 | 20 | def _base_split(imageData, min_region_size, dominant_value): 21 | regions = [] 22 | 23 | def recursion(imageData): 24 | image, coordinates = imageData 25 | h, w = image.shape 26 | x, y = coordinates 27 | 28 | # Check if the region is small enough 29 | if ( 30 | h * w <= min_region_size 31 | or (image == dominant_value).sum() < 3 32 | or (image == dominant_value).all() 33 | ): 34 | regions.append(imageData) # Return a single region 35 | return 36 | 37 | # Split the image into quadrants 38 | mid_x = w // 2 39 | mid_y = h // 2 40 | 41 | top_left = (image[0:mid_y, 0:mid_x], (x, y)) 42 | top_right = (image[0:mid_y, mid_x:w], (x + mid_x, y)) 43 | bottom_left = (image[mid_y:h, 0:mid_x], (x, y + mid_y)) 44 | bottom_right = (image[mid_y:h, mid_x:w], (x + mid_x, y + mid_y)) 45 | 46 | # Recursively split each quadrant 47 | recursion(top_left), 48 | recursion(top_right), 49 | recursion(bottom_left), 50 | recursion(bottom_right) 51 | 52 | recursion(imageData) 53 | 54 | return regions 55 | 56 | 57 | def _merge(region1, region2): 58 | image1, coordinates1 = region1 59 | image2, coordinates2 = region2 60 | x1, y1 = coordinates1 61 | x2, y2 = coordinates2 62 | h1, w1 = image1.shape 63 | h2, w2 = image2.shape 64 | 65 | x = min(x1, x2) 66 | y = min(y1, y2) 67 | 68 | if not (w1 == w2 and x1 == x2): 69 | axis = 1 70 | 71 | if not (h1 == h2 and y1 == y2): 72 | axis = 0 73 | 74 | image = np.concatenate((image1, image2), axis=axis) 75 | 76 | return (image, (x, y)) 77 | 78 | 79 | def _should_merge(region1, region2, threshold=20): 80 | image1, coordinates1 = region1 81 | image2, coordinates2 = region2 82 | h1, w1 = image1.shape 83 | h2, w2 = image2.shape 84 | x1, y1 = coordinates1 85 | x2, y2 = coordinates2 86 | 87 | # return True 88 | 89 | isShapeMatching = (w1 == w2 and x1 == x2) or (h1 == h2 and y1 == y2) 90 | if not isShapeMatching: 91 | return False 92 | 93 | isBorderingX = x1 == x2 + w2 or x2 == x1 + w1 94 | isBorderingY = y1 == y2 + h2 or y2 == y1 + h1 95 | 96 | if not isBorderingX and not isBorderingY: 97 | return False 98 | 99 | imageDifference = np.mean(image2) - np.mean(image1) 100 | 101 | if imageDifference <= threshold and imageDifference >= threshold * -1: 102 | return True 103 | 104 | pixel_threshold = 5 105 | 106 | amount_zero_1 = (image1 == 0).sum() 107 | amount_255_1 = (image1 == 255).sum() 108 | amount_zero_2 = (image2 == 0).sum() 109 | amount_255_2 = (image2 == 255).sum() 110 | 111 | if amount_zero_1 < pixel_threshold and amount_zero_2 < pixel_threshold: 112 | return True 113 | 114 | if amount_255_1 < pixel_threshold and amount_255_2 < pixel_threshold: 115 | return True 116 | 117 | return False 118 | 119 | 120 | def _split_and_merge(imageData, chunk_size, dominant_value): 121 | base_h, base_w = imageData[0].shape 122 | regions = _base_split(imageData, chunk_size, dominant_value) 123 | 124 | def recursive_merge(regions, threshold): 125 | initial_length = len(regions) 126 | merged_ones = [] 127 | for i in range(initial_length): 128 | # for j in range(i + 1, len(regions)): 129 | for j in range(initial_length): 130 | if i == j: 131 | continue 132 | 133 | if regions[i] == None or regions[j] == None: 134 | continue 135 | 136 | if _should_merge(regions[i], regions[j], threshold): 137 | merged_ones.append(_merge(regions[i], regions[j])) 138 | regions[j] = None 139 | regions[i] = None 140 | 141 | # Remove marked regions 142 | regions[:] = [r for r in regions if r is not None] 143 | new_regions = [*regions, *merged_ones] 144 | 145 | if len(new_regions) == initial_length: 146 | return new_regions 147 | 148 | return recursive_merge(new_regions, threshold) 149 | 150 | merged = recursive_merge(regions, threshold=20) 151 | 152 | whites_removed = list(filter(lambda x: np.mean(x[0]) <= 120, merged)) 153 | 154 | final_merged = recursive_merge(whites_removed, 255) 155 | 156 | # Target array size 157 | target_size = 64 158 | 159 | if len(final_merged) == 0: 160 | for _ in range(target_size): 161 | final_merged.append( 162 | (np.zeros((1, 1), np.uint8), (int(-1), int(base_h + 1))) 163 | ) 164 | 165 | # Sort the array based on the size of the images 166 | sorted_array = sorted(final_merged, key=lambda x: x[0].size) 167 | 168 | elements_to_adjust = target_size - len(sorted_array) 169 | 170 | if elements_to_adjust > 0: 171 | # Add new images by splitting the largest ones 172 | for _ in range(elements_to_adjust): 173 | largest_image, coordinates = sorted_array.pop() 174 | x, y = coordinates 175 | half_height = largest_image.shape[0] // 2 176 | half_width = largest_image.shape[1] // 2 177 | 178 | if half_height >= half_width: 179 | # Split the image into two halves 180 | image1 = (largest_image[:half_height, :], coordinates) 181 | image2 = (largest_image[half_height:, :], (x, y + half_height)) 182 | else: 183 | image1 = (largest_image[:, :half_width], coordinates) 184 | image2 = (largest_image[:, half_width:], (x + half_width, y)) 185 | 186 | # Add the new images to the array 187 | sorted_array.extend([image1, image2]) 188 | sorted_array = sorted(sorted_array, key=lambda x: x[0].size) 189 | 190 | else: 191 | # Remove the smallest images until the target size is reached 192 | for _ in range(-elements_to_adjust): 193 | sorted_array.pop(0) # Remove the smallest image 194 | 195 | return sorted_array 196 | 197 | 198 | def process_frame(frame): 199 | # Read the binary image (thresholded image) 200 | binary_image = cv2.imread(frame, cv2.IMREAD_REDUCED_GRAYSCALE_2) 201 | # binary_image = cv2.GaussianBlur(binary_image, (5,5), cv2.BORDER_DEFAULT ) 202 | binary_image = cv2.threshold(binary_image, 128, 255, cv2.THRESH_BINARY)[1] 203 | 204 | imgUint8 = binary_image.astype(np.uint8) 205 | blackMask = imgUint8 == 0 206 | blackPixels = np.sum(blackMask) 207 | whitePixels = np.sum(~blackMask) 208 | if blackPixels > whitePixels: 209 | dominant_value = 0 210 | # binary_image = cv2.bitwise_not(binary_image) 211 | else: 212 | dominant_value = 255 213 | 214 | # Apply split-and-merge segmentation 215 | min_region_size = 25 # Adjust this parameter as needed 216 | regions = _split_and_merge((binary_image, (0, 0)), min_region_size, dominant_value) 217 | 218 | # TODO Add sorting here maybe? 219 | 220 | return regions 221 | 222 | 223 | def show_results(): 224 | imagesArray = ( 225 | process_frame(blank_white), 226 | process_frame(blank_black), 227 | # process_frame(frame5), 228 | ) 229 | for index, images in enumerate(imagesArray): 230 | cv2.imshow("output" + str(index), images[0]) 231 | cv2.imshow("wireframe" + str(index), images[1]) 232 | cv2.imshow("binary" + str(index), images[2]) 233 | 234 | while 1: 235 | key = cv2.waitKey(0) 236 | 237 | # Escape 238 | if key == 27: 239 | cv2.destroyAllWindows() 240 | break 241 | 242 | 243 | # show_results() 244 | -------------------------------------------------------------------------------- /process.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bun 2 | 3 | import { createCtlAnimationString } from "./helper" 4 | import data from "./output.json" assert { type: "json" } 5 | import type { 6 | Chunk, 7 | ChunkWithId, 8 | Command, 9 | Coordinates, 10 | Dimensions, 11 | } from "./types" 12 | 13 | const { 14 | dimensions: { width, height }, 15 | } = data as { dimensions: { width: number; height: number } } 16 | const dataFrames = (data as any).frames as Chunk[][] 17 | const center = [Math.ceil(width / 2), Math.ceil(height / 2)] as Coordinates 18 | 19 | export function createFrameAnimationCommands( 20 | startTerminals: readonly ChunkWithId[], 21 | screenSize: Dimensions 22 | ): readonly Command[] { 23 | const processed = linkIDsToChunks(dataFrames, startTerminals) 24 | const commands = generateCommand(processed, [width, height], screenSize) 25 | 26 | return commands 27 | } 28 | 29 | function linkIDsToChunks( 30 | frames: Chunk[][], 31 | startTerminals: readonly ChunkWithId[] 32 | ): ChunkWithId[][] { 33 | const processedFrames: ChunkWithId[][] = [] 34 | 35 | for (const [frameIndex, frame] of frames.entries()) { 36 | frame.sort((a, b) => { 37 | // Get the center of the rectangles and sort based on them 38 | // The recs closest at the center should be first. This might look cooler when they animate to the next frame 39 | return distanceTo(center, getCenter(b)) - distanceTo(center, getCenter(a)) 40 | }) 41 | const processed: ChunkWithId[] = [] 42 | const usedIds: Set = new Set() 43 | 44 | for (const chunk of frame) { 45 | if (frameIndex === 0) { 46 | const closestTerminal = startTerminals 47 | .filter((terminal) => !usedIds.has(terminal.id)) 48 | .reduce( 49 | (closest, current) => { 50 | const distanceCurrent = distanceTo( 51 | getCenter(current.chunk), 52 | getCenter(chunk) 53 | ) 54 | 55 | return closest.distance <= distanceCurrent 56 | ? closest 57 | : { distance: distanceCurrent, id: current.id } 58 | }, 59 | { distance: Infinity, id: "x" } 60 | ).id 61 | 62 | usedIds.add(closestTerminal) 63 | processed.push({ chunk, id: closestTerminal }) 64 | } else { 65 | const thisIterationCenter = getCenter(chunk) 66 | // Get the closest chunk of thep previous frame 67 | const id = processedFrames[frameIndex - 1] 68 | .filter((previousChunk) => !usedIds.has(previousChunk.id)) 69 | .reduce( 70 | (closest, current) => { 71 | const currentDistance = distanceTo( 72 | getCenter(current.chunk), 73 | thisIterationCenter 74 | ) 75 | 76 | return closest.distance < currentDistance 77 | ? closest 78 | : { id: current.id, distance: currentDistance } 79 | }, 80 | { id: "x", distance: Infinity } 81 | ).id 82 | 83 | usedIds.add(id) 84 | processed.push({ chunk, id }) 85 | } 86 | } 87 | 88 | processedFrames.push(processed) 89 | } 90 | 91 | return processedFrames 92 | } 93 | 94 | function generateCommand( 95 | frames: ProcessedFrame[], 96 | artDimensions: Dimensions, 97 | screenSize: Dimensions, 98 | framerate = 30 99 | ): readonly Command[] { 100 | return frames 101 | .map((frame, frameIndex) => 102 | frame.flatMap((rectangle) => { 103 | // Is the same as previous frame 104 | const { chunk, id } = rectangle 105 | const isSameAsPrevious = 106 | JSON.stringify( 107 | frames[frameIndex - 1]?.find(({ id: oldId }) => oldId === id)?.chunk 108 | ) == JSON.stringify(chunk) 109 | 110 | return isSameAsPrevious 111 | ? [] 112 | : animationCommand(rectangle, artDimensions, screenSize) 113 | }) 114 | ) 115 | .flatMap((frame) => [frame, `sleep ${(1 / framerate) * 5}`]) 116 | .flat() 117 | } 118 | 119 | function animationCommand( 120 | data: ChunkWithId, 121 | animationDimension: Dimensions, 122 | screenSize: Dimensions 123 | ): readonly Command[] { 124 | const convertSizes = convertRelativeToAbsolute(animationDimension, screenSize) 125 | const { chunk, id } = data 126 | const [dimensions, coordinates] = chunk 127 | 128 | const newSize = convertSizes(dimensions) 129 | const newCoordinates = convertSizes(coordinates) 130 | 131 | return createCtlAnimationString(id, newCoordinates, newSize) 132 | } 133 | 134 | function convertRelativeToAbsolute( 135 | coordinateSpace: Dimensions, 136 | screenDimensions: Dimensions 137 | ): (coordinates: Coordinates) => Coordinates { 138 | return ([x, y]: Coordinates) => [ 139 | (x / coordinateSpace[0]) * screenDimensions[0] * 2, 140 | (y / coordinateSpace[1]) * screenDimensions[1] * 2, 141 | ] 142 | } 143 | 144 | function getCenter(chunk: Chunk): Coordinates { 145 | const [[height, width], [x, y]] = chunk 146 | 147 | return [x + width / 2, y + height / 2] 148 | } 149 | 150 | function distanceTo([x1, y1]: Coordinates, [x2, y2]: Coordinates): number { 151 | var dx = x1 - x2 // delta x 152 | var dy = y1 - y2 // delta y 153 | var dist = Math.sqrt(dx * dx + dy * dy) // distance 154 | return dist 155 | } 156 | 157 | type ProcessedFrame = readonly ChunkWithId[] 158 | -------------------------------------------------------------------------------- /scratch.js: -------------------------------------------------------------------------------- 1 | const columns = 9 2 | const items = Array.from({ length: 20 }, (_, i) => i) 3 | 4 | items.map((_, index) => { 5 | const x = index % columns 6 | const y = Math.floor(index / columns) 7 | 8 | return { x, y } 9 | }) 10 | 11 | const screen = [1920, 1080] 12 | const art = [480, 360] 13 | 14 | const convert = convertRelativeToAbsolute(art, screen) 15 | 16 | convert([480, 360]) //? 17 | convert([25, 12.5]) //? 18 | 19 | function convertRelativeToAbsolute(coordinateSpace, screenDimensions) { 20 | return ([x, y]) => [ 21 | (x / coordinateSpace[0]) * screenDimensions[0], 22 | (y / coordinateSpace[1]) * screenDimensions[1], 23 | ] 24 | } 25 | 26 | console.log(480 / 360) 27 | console.log(1024 / 768) 28 | console.log(2520 / 1680) 29 | -------------------------------------------------------------------------------- /setup.sh: -------------------------------------------------------------------------------- 1 | hyprctl keyword decoration:blur:enabled false >/dev/null 2 | hyprctl keyword decoration:rounding 0 >/dev/null 3 | hyprctl keyword decoration:drop_shadow false >/dev/null 4 | 5 | hyprctl keyword general:gaps_in 5 >/dev/null 6 | hyprctl keyword general:gaps_out 48 >/dev/null 7 | hyprctl keyword general:gaps_workspaces 0 >/dev/null 8 | 9 | hyprctl keyword general:border_size 0 >/dev/null 10 | 11 | hyprctl keyword general:resize_on_border false >/dev/null 12 | hyprctl keyword animation windowsMove, 1, 0.6666666, linear 13 | 14 | # for i in {1..63}; do 15 | # kitty >/dev/null & disown kitty 16 | # done 17 | 18 | 19 | echo hiii 20 | -------------------------------------------------------------------------------- /start.ts: -------------------------------------------------------------------------------- 1 | import { sleep } from "bun" 2 | import type { ChunkWithId, Coordinates, Dimensions } from "./types" 3 | import { createCtlAnimationString } from "./helper" 4 | import { createFrameAnimationCommands } from "./process" 5 | 6 | const numberOfTiles = 64 7 | const screenSize: Dimensions = Bun.spawnSync(["xdpyinfo"]) 8 | .stdout.toString() 9 | .match(/dimensions:(.*) pixels/) 10 | ?.at(1) 11 | ?.trim() 12 | .split("x") 13 | .map(Number) as Dimensions 14 | const terminal = "alacritty" 15 | 16 | await Promise.all( 17 | Array.from({ length: numberOfTiles }).map( 18 | () => 19 | Bun.spawn([terminal], { 20 | stdout: "ignore", 21 | stderr: "ignore", 22 | stdin: "ignore", 23 | }).pid 24 | ) 25 | ) 26 | 27 | await sleep(7850) 28 | 29 | const terminals: ChunkWithId[] = ( 30 | JSON.parse( 31 | Bun.spawnSync(["hyprctl", "clients", "-j"]).stdout.toString() 32 | ) as Record[] 33 | ) 34 | .filter( 35 | ({ initialClass }) => initialClass.toLowerCase() === terminal.toLowerCase() 36 | ) 37 | .map(({ at, size, pid }) => ({ 38 | id: pid, 39 | chunk: [size, at], 40 | })) 41 | 42 | await sleep(500) 43 | 44 | const startCommands = terminals.flatMap(({ id }, index, all) => { 45 | const total = all.length 46 | const columns = 4 47 | const rows = Math.floor(total / columns) 48 | 49 | const newSize: Dimensions = [ 50 | Math.floor(screenSize[0] / columns), 51 | Math.floor(screenSize[1] / rows), 52 | ] 53 | const newCoordinates: Coordinates = [ 54 | (index % columns) * newSize[0], 55 | Math.floor(index / columns) * newSize[1], 56 | ] 57 | 58 | return createCtlAnimationString(id, newCoordinates, newSize) 59 | }) 60 | 61 | const keywords = [ 62 | "decoration:drop_shadow false", 63 | "decoration:rounding 0", 64 | "general:border_size 0", 65 | ] 66 | .map((keyword) => "keyword " + keyword) 67 | .join(";") 68 | 69 | Bun.spawnSync(`hyprctl --batch "${keywords}"`.split(" ")) 70 | 71 | startCommands.map((string) => string.split(" ")).forEach(Bun.spawnSync) 72 | 73 | Bun.spawnSync("hyprctl keyword animations:enabled false".split(" ")) 74 | 75 | const animationCommand = createFrameAnimationCommands(terminals, screenSize) 76 | 77 | await sleep(500) 78 | 79 | animationCommand 80 | .slice(animationCommand.length - 2) 81 | .map((string) => string.split(" ")) 82 | .forEach((command, index) => { 83 | Bun.spawnSync(command) 84 | }) 85 | -------------------------------------------------------------------------------- /types.ts: -------------------------------------------------------------------------------- 1 | export type Chunk = [Dimensions, Coordinates] 2 | export type Coordinates = [x: number, y: number] 3 | export type Dimensions = [width: number, height: number] 4 | export type ChunkWithId = { id: string; chunk: Chunk } 5 | export type Command = string 6 | --------------------------------------------------------------------------------