A terminal-only Bluesky / AT Protocol client written in Fortran, with a asm/Rust native firehose decoder for the relay-raw stream. DM slide support. Dither image composer. Yes, that Fortran www.patreon.com/FormerLab
rust atproto fun fortran assembly
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

Fortransky v1.3 — Floyd-Steinberg dithering + image post via d <imagepath>

FormerLab 668104e7 c06ed2ca

+736 -15
+4 -1
.gitignore
··· 6 6 *.mod 7 7 *.out 8 8 ~/.fortransky/ 9 - bridge/assemblersky/bin/assemblersky_cli 9 + bridge/assemblersky/bin/assemblersky_cli 10 + Bill_Atkinson.jpeg 11 + **/__pycache__/ 12 + *.pyc
+2 -1
CMakeLists.txt
··· 28 28 cshim/http_bridge.c 29 29 src/util/strings.f90 30 30 src/util/process.f90 31 + src/util/dither.f90 31 32 src/core/models.f90 32 33 src/core/config.f90 33 34 src/core/app_state.f90 ··· 56 57 pthread 57 58 m 58 59 ) 59 - target_compile_options(fortransky PRIVATE ${LIBCURL_CFLAGS_OTHER}) 60 + target_compile_options(fortransky PRIVATE ${LIBCURL_CFLAGS_OTHER})
+34 -8
README.md
··· 24 24 └─ assemblersky_cli (bridge/assemblersky/bin/) ← preferred 25 25 └─ firehose_bridge_cli (bridge/firehose-bridge/target/release/) ← fallback 26 26 └─ Python cbor2 ← live stream decode 27 + 28 + image post path (d <imagepath>): 29 + dither_prep.py image → greyscale flat pixel file 30 + dither.f90 Floyd-Steinberg error diffusion (Bill Atkinson's algorithm) 31 + pixels_to_png.py pixel file → PNG 32 + uploadBlob PNG → Bluesky blob 33 + createRecord post with app.bsky.embed.images 27 34 ``` 28 35 29 36 Session state is saved to `~/.fortransky/session.json`. Use an app password, ··· 47 54 curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh 48 55 ``` 49 56 50 - ### Python deps (relay-raw stream path only) 57 + ### Python deps 51 58 52 - The `relay_raw_tail.py` helper is launched as a subprocess by the TUI. It must 53 - be able to import `cbor2` and `websockets` using whichever `python3` is on 54 - `PATH` when Fortransky runs. 59 + Required for relay-raw stream path and image posting: 55 60 56 - **Option A — system-wide (simplest):** 57 61 ```bash 58 - sudo pip install cbor2 websockets --break-system-packages 62 + sudo pip install cbor2 websockets Pillow --break-system-packages 59 63 ``` 60 64 61 - **Option B — venv, run with venv active:** 65 + Or with a venv (run Fortransky with the venv active): 66 + 62 67 ```bash 63 68 python3 -m venv .venv 64 69 source .venv/bin/activate 65 - pip install cbor2 websockets 70 + pip install cbor2 websockets Pillow 66 71 ``` 67 72 68 73 ### Assemblersky (optional, relay-raw native decoder) ··· 122 127 | `p <handle>` | profile view | 123 128 | `n` | notifications | 124 129 | `c` | compose post | 130 + | `d <imagepath>` | dither image + post to Bluesky | 125 131 | `t <uri/url>` | open thread | 126 132 | `j` | stream tail | 127 133 | `m` | toggle stream mode (jetstream / relay-raw) | ··· 151 157 152 158 --- 153 159 160 + ## Image posting 161 + 162 + The `d` command dithers any image using Bill Atkinson's Floyd-Steinberg 163 + algorithm (as used in MacPaint, 1984) and posts it to Bluesky. 164 + 165 + ``` 166 + d /path/to/image.jpg 167 + ``` 168 + 169 + The image is converted to greyscale, resized to 576×720 (the original MacPaint 170 + canvas dimensions), dithered to 1-bit in Fortran, converted to PNG, and posted 171 + with an image embed. Pillow is required. 172 + 173 + --- 174 + 154 175 ## Stream modes 155 176 156 177 **jetstream** — Bluesky's Jetstream WebSocket service. Lower bandwidth, JSON ··· 192 213 --- 193 214 194 215 ## Changelog 216 + 217 + **v1.3** — Floyd-Steinberg dithering + image post via `d <imagepath>`. Bill 218 + Atkinson's algorithm (MacPaint, 1984) ported to Fortran. `uploadBlob` + 219 + `createRecord` with image embed wired into the AT Protocol client. Requires 220 + Pillow. 195 221 196 222 **v1.2** — Assemblersky integration. `relay_raw_tail.py` detects and prefers 197 223 `assemblersky_cli` over the Rust bridge. Live relay-raw decode via Python cbor2
+57
cshim/http_bridge.c
··· 81 81 return do_request(url, auth_header, json_body, status_code, out_len); 82 82 } 83 83 84 + /* Upload raw binary data (e.g. PNG blob) with a given Content-Type. 85 + Used for com.atproto.repo.uploadBlob. */ 86 + char *fortransky_http_post_binary(const char *url, const char *auth_header, 87 + const char *content_type, 88 + const unsigned char *data, size_t data_len, 89 + long *status_code, size_t *out_len) { 90 + CURL *curl; 91 + CURLcode res; 92 + struct curl_slist *headers = NULL; 93 + struct buffer chunk = {0}; 94 + char *result = NULL; 95 + char ct_header[256]; 96 + 97 + if (status_code) *status_code = 0; 98 + if (out_len) *out_len = 0; 99 + 100 + curl_global_init(CURL_GLOBAL_DEFAULT); 101 + curl = curl_easy_init(); 102 + if (!curl) return dup_empty(); 103 + 104 + curl_easy_setopt(curl, CURLOPT_URL, url); 105 + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); 106 + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 60L); 107 + curl_easy_setopt(curl, CURLOPT_USERAGENT, "fortransky/1.2"); 108 + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb); 109 + curl_easy_setopt(curl, CURLOPT_WRITEDATA, (void *)&chunk); 110 + curl_easy_setopt(curl, CURLOPT_ACCEPT_ENCODING, ""); 111 + 112 + curl_easy_setopt(curl, CURLOPT_POST, 1L); 113 + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, data); 114 + curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, (long)data_len); 115 + 116 + if (auth_header && auth_header[0] != '\0') 117 + headers = curl_slist_append(headers, auth_header); 118 + 119 + snprintf(ct_header, sizeof(ct_header), "Content-Type: %s", content_type); 120 + headers = curl_slist_append(headers, ct_header); 121 + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); 122 + 123 + res = curl_easy_perform(curl); 124 + if (res == CURLE_OK) { 125 + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, status_code); 126 + result = chunk.data ? chunk.data : dup_empty(); 127 + if (out_len && result) *out_len = strlen(result); 128 + } else { 129 + const char *msg = curl_easy_strerror(res); 130 + result = (char *)malloc(strlen(msg) + 1); 131 + if (result) strcpy(result, msg); 132 + if (out_len && result) *out_len = strlen(result); 133 + free(chunk.data); 134 + } 135 + 136 + if (headers) curl_slist_free_all(headers); 137 + curl_easy_cleanup(curl); 138 + return result ? result : dup_empty(); 139 + } 140 + 84 141 void fortransky_http_free(char *ptr) { 85 142 free(ptr); 86 143 }
+94
scripts/dither_prep.py
··· 1 + #!/usr/bin/env python3 2 + """ 3 + dither_prep.py — Cobolsky image preprocessor 4 + ============================================== 5 + Loads any image, converts to greyscale, resizes to 576×720 6 + (authentic MacPaint canvas), and writes a flat pixel file 7 + for dither.cob to process. 8 + 9 + Flat file format: 10 + Line 1 (header, 20 bytes + \n): 11 + cols 0-4 : width (5 chars right-justified, e.g. " 576") 12 + cols 5-9 : height (5 chars right-justified, e.g. " 720") 13 + cols 10-19: reserved spaces 14 + 15 + Lines 2..H+1 (one per row, W bytes + \n): 16 + Each byte is a greyscale value 0-255, written as raw byte. 17 + LINE SEQUENTIAL in COBOL reads up to \n so each row is one record. 18 + We write bytes as 3-digit decimal strings space-separated for 19 + COBOL compatibility (COBOL can't easily read raw binary bytes 20 + via LINE SEQUENTIAL). Each pixel = 4 chars (3 digits + space). 21 + Row record length = W * 4 bytes. 22 + 23 + Usage: 24 + python3 dither_prep.py input.png 25 + python3 dither_prep.py input.jpg --width 576 --height 720 26 + python3 dither_prep.py input.png --out /tmp/bsky_pixels_in.dat 27 + """ 28 + 29 + import sys 30 + import argparse 31 + from pathlib import Path 32 + from PIL import Image, ImageEnhance 33 + 34 + PIXELS_FILE = "/tmp/bsky_pixels_in.dat" 35 + 36 + def prepare(input_path: str, width: int, height: int, out_path: str, 37 + brightness: float = 1.0, contrast: float = 1.0): 38 + img = Image.open(input_path) 39 + 40 + # Flatten transparency to white background 41 + if img.mode in ("RGBA", "LA") or \ 42 + (img.mode == "P" and "transparency" in img.info): 43 + bg = Image.new("RGB", img.size, (255, 255, 255)) 44 + if img.mode == "P": 45 + img = img.convert("RGBA") 46 + bg.paste(img, mask=img.split()[-1] if img.mode == "RGBA" else None) 47 + img = bg 48 + 49 + # Convert to greyscale 50 + img = img.convert("L") 51 + 52 + # Adjust brightness and contrast before dithering 53 + if brightness != 1.0: 54 + img = ImageEnhance.Brightness(img).enhance(brightness) 55 + if contrast != 1.0: 56 + img = ImageEnhance.Contrast(img).enhance(contrast) 57 + 58 + # Resize to target canvas — use LANCZOS for quality 59 + img = img.resize((width, height), Image.LANCZOS) 60 + 61 + pixels = list(img.getdata()) 62 + 63 + with open(out_path, "w") as f: 64 + # Header 65 + header = str(width).rjust(5) + str(height).rjust(5) + " " * 10 66 + f.write(header + "\n") 67 + 68 + # Pixel rows — each pixel as 3-digit decimal + space 69 + for row in range(height): 70 + row_pixels = pixels[row * width : (row + 1) * width] 71 + # Write as space-separated 3-digit values 72 + line = "".join(f"{p:03d} " for p in row_pixels) 73 + f.write(line + "\n") 74 + 75 + print(f"[prep] {width}×{height} greyscale → {out_path}") 76 + print(f"[prep] {height + 1} records written") 77 + 78 + def main(): 79 + parser = argparse.ArgumentParser(description="Cobolsky image preprocessor") 80 + parser.add_argument("input", help="Input image (PNG, JPG, etc.)") 81 + parser.add_argument("--width", type=int, default=576) 82 + parser.add_argument("--height", type=int, default=720) 83 + parser.add_argument("--out", default=PIXELS_FILE) 84 + parser.add_argument("--brightness", type=float, default=1.0, 85 + help="Brightness multiplier (1.0=unchanged, 1.4=brighter)") 86 + parser.add_argument("--contrast", type=float, default=1.0, 87 + help="Contrast multiplier (1.0=unchanged, 1.3=more contrast)") 88 + args = parser.parse_args() 89 + 90 + prepare(args.input, args.width, args.height, args.out, 91 + args.brightness, args.contrast) 92 + 93 + if __name__ == "__main__": 94 + main()
+47
scripts/pixels_to_png.py
··· 1 + #!/usr/bin/env python3 2 + """ 3 + pixels_to_png.py — convert dither.f90 output to PNG 4 + Reads /tmp/bsky_pixels_out.dat 5 + Writes /tmp/bsky_dither_preview.png 6 + 7 + No auth, no posting — just pixel data to PNG. 8 + Called by dither_flow in tui.f90 before upload. 9 + """ 10 + import sys 11 + import io 12 + import argparse 13 + from pathlib import Path 14 + from PIL import Image 15 + 16 + PIXELS_OUT = '/tmp/bsky_pixels_out.dat' 17 + PREVIEW_FILE = '/tmp/bsky_dither_preview.png' 18 + 19 + 20 + def read_pixels(path: str): 21 + with open(path) as f: 22 + lines = f.readlines() 23 + header = lines[0] 24 + width = int(header[0:5]) 25 + height = int(header[5:10]) 26 + pixels = [] 27 + for line in lines[1:height + 1]: 28 + values = line.strip().split() 29 + pixels.extend(int(v) for v in values) 30 + return width, height, pixels 31 + 32 + 33 + def main(): 34 + parser = argparse.ArgumentParser() 35 + parser.add_argument('--pixels', default=PIXELS_OUT) 36 + parser.add_argument('--out', default=PREVIEW_FILE) 37 + args = parser.parse_args() 38 + 39 + width, height, pixels = read_pixels(args.pixels) 40 + img = Image.new('L', (width, height)) 41 + img.putdata(pixels) 42 + img.save(args.out, format='PNG', optimize=True) 43 + print(f'[pixels_to_png] {width}×{height} → {args.out}', file=sys.stderr) 44 + 45 + 46 + if __name__ == '__main__': 47 + main()
+162 -2
src/atproto/client.f90
··· 1 1 module client_mod 2 - use http_cbridge_mod, only: http_get, http_post_json, http_get_urlencoded, last_http_status 2 + use http_cbridge_mod, only: http_get, http_post_json, http_post_binary, http_get_urlencoded, last_http_status 3 3 use json_extract_mod, only: extract_json_string, escape_json_string 4 4 use decode_mod, only: decode_posts_json, decode_stream_blob, decode_thread_json, decode_profile_json, decode_notifications_json 5 5 use models_mod, only: session_state, post_view, stream_event, actor_profile, notification_view, MAX_ITEMS, HANDLE_LEN, URI_LEN ··· 11 11 public :: session_state, login_session, fetch_author_feed, search_posts, fetch_timeline 12 12 public :: tail_live_stream, fetch_post_thread, create_post, create_reply, create_quote_post, like_post, repost_post 13 13 public :: fetch_profile_view, fetch_notifications_view, load_saved_session, save_session, clear_saved_session 14 - public :: resolve_did_to_handle 14 + public :: resolve_did_to_handle, upload_blob, create_image_post 15 15 contains 16 16 subroutine load_saved_session(state) 17 17 type(session_state), intent(inout) :: state ··· 543 543 state%did_cache(i) = trim(did) 544 544 state%handle_cache(i) = trim(handle) 545 545 end subroutine resolve_did_to_handle 546 + 547 + ! ---------------------------------------------------------------- 548 + ! upload_blob — read a PNG file from disk and POST to uploadBlob 549 + ! Returns the blob JSON fragment for embedding in a post record. 550 + ! blob_json will be empty on failure. 551 + ! ---------------------------------------------------------------- 552 + subroutine upload_blob(state, png_path, blob_json, ok, message) 553 + use iso_c_binding, only: c_int8_t 554 + use http_cbridge_mod, only: http_post_binary 555 + type(session_state), intent(in) :: state 556 + character(len=*), intent(in) :: png_path 557 + character(len=:), allocatable, intent(out) :: blob_json 558 + logical, intent(out) :: ok 559 + character(len=*), intent(out) :: message 560 + 561 + integer(c_int8_t), allocatable :: file_bytes(:) 562 + integer :: file_unit, file_size, ios 563 + character(len=:), allocatable :: url, auth_header, resp 564 + character(len=1024) :: auth_buf 565 + 566 + ok = .false. 567 + blob_json = '' 568 + message = 'upload_blob failed' 569 + 570 + if (len_trim(state%access_jwt) == 0) then 571 + message = 'Login required before uploading images.' 572 + return 573 + end if 574 + 575 + ! Read PNG file into byte array 576 + open(newunit=file_unit, file=trim(png_path), status='old', & 577 + action='read', form='unformatted', access='stream', iostat=ios) 578 + if (ios /= 0) then 579 + message = 'Cannot open PNG: ' // trim(png_path) 580 + return 581 + end if 582 + inquire(unit=file_unit, size=file_size) 583 + if (file_size <= 0) then 584 + close(file_unit) 585 + message = 'PNG file is empty: ' // trim(png_path) 586 + return 587 + end if 588 + allocate(file_bytes(file_size)) 589 + read(file_unit, iostat=ios) file_bytes 590 + close(file_unit) 591 + if (ios /= 0) then 592 + message = 'Cannot read PNG data' 593 + return 594 + end if 595 + 596 + url = trim(state%pds_host) // '/xrpc/com.atproto.repo.uploadBlob' 597 + 598 + resp = http_post_binary(url, file_bytes, file_size, 'image/png', & 599 + auth_token=trim(state%access_jwt)) 600 + 601 + blob_json = extract_json_string(resp, 'blob') 602 + ! extract_json_string returns the string value — for blob we need the 603 + ! full blob object. Extract it as a raw sub-object instead. 604 + blob_json = extract_json_object(resp, 'blob') 605 + if (len_trim(blob_json) == 0) then 606 + message = 'uploadBlob response missing blob field. Response: ' // resp(1:min(len(resp),120)) 607 + return 608 + end if 609 + 610 + ok = .true. 611 + message = 'Blob uploaded' 612 + end subroutine upload_blob 613 + 614 + ! ---------------------------------------------------------------- 615 + ! create_image_post — post text + dithered PNG image to Bluesky 616 + ! Calls upload_blob first, then createRecord with embed. 617 + ! ---------------------------------------------------------------- 618 + subroutine create_image_post(state, text, png_path, width, height, ok, message, created_uri) 619 + type(session_state), intent(in) :: state 620 + character(len=*), intent(in) :: text, png_path 621 + integer, intent(in) :: width, height 622 + logical, intent(out) :: ok 623 + character(len=*), intent(out) :: message, created_uri 624 + 625 + character(len=:), allocatable :: blob_json, payload, body, now_utc, repo 626 + 627 + ok = .false. 628 + message = 'Image post failed' 629 + created_uri = '' 630 + 631 + ! Step 1: upload blob 632 + call upload_blob(state, png_path, blob_json, ok, message) 633 + if (.not. ok) return 634 + 635 + ok = .false. 636 + 637 + ! Step 2: createRecord with app.bsky.embed.images 638 + repo = trim(state%did) 639 + if (len_trim(repo) == 0) repo = trim(state%identifier) 640 + now_utc = utc_timestamp_iso() 641 + 642 + payload = '{' // & 643 + '"repo":"' // escape_json_string(repo) // '",' // & 644 + '"collection":"app.bsky.feed.post",' // & 645 + '"record":{' // & 646 + '"$type":"app.bsky.feed.post",' // & 647 + '"text":"' // escape_json_string(trim(text)) // '",' // & 648 + '"createdAt":"' // trim(now_utc) // '",' // & 649 + '"embed":{' // & 650 + '"$type":"app.bsky.embed.images",' // & 651 + '"images":[{' // & 652 + '"image":' // trim(blob_json) // ',' // & 653 + '"alt":"Floyd-Steinberg dithered image — rendered in Fortran",' // & 654 + '"aspectRatio":{"width":' // trim(itoa(width)) // & 655 + ',"height":' // trim(itoa(height)) // '}' // & 656 + '}]' // & 657 + '}' // & 658 + '}}' 659 + 660 + body = http_post_json(trim(state%pds_host) // '/xrpc/com.atproto.repo.createRecord', & 661 + payload, trim(state%access_jwt)) 662 + 663 + created_uri = extract_json_string(body, 'uri') 664 + if (len_trim(created_uri) > 0) then 665 + ok = .true. 666 + message = 'Image post created' 667 + else 668 + message = 'createRecord failed. Response: ' // body(1:min(len(body),120)) 669 + end if 670 + end subroutine create_image_post 671 + 672 + ! ---------------------------------------------------------------- 673 + ! extract_json_object — extract a raw JSON object value by key 674 + ! e.g. extract_json_object('{"blob":{...}}', 'blob') -> '{...}' 675 + ! ---------------------------------------------------------------- 676 + function extract_json_object(json, key) result(val) 677 + character(len=*), intent(in) :: json, key 678 + character(len=:), allocatable :: val 679 + integer :: kpos, brace_start, brace_end, depth, i 680 + 681 + val = '' 682 + kpos = index(json, '"' // trim(key) // '"') 683 + if (kpos == 0) return 684 + 685 + ! Find the opening brace after the key 686 + brace_start = index(json(kpos:), '{') 687 + if (brace_start == 0) return 688 + brace_start = kpos + brace_start - 1 689 + 690 + ! Find matching closing brace 691 + depth = 0 692 + brace_end = 0 693 + do i = brace_start, len(json) 694 + if (json(i:i) == '{') depth = depth + 1 695 + if (json(i:i) == '}') then 696 + depth = depth - 1 697 + if (depth == 0) then 698 + brace_end = i 699 + exit 700 + end if 701 + end if 702 + end do 703 + 704 + if (brace_end > brace_start) val = json(brace_start:brace_end) 705 + end function extract_json_object 546 706 547 707 end module client_mod
+40 -2
src/atproto/http_cbridge.f90
··· 1 1 module http_cbridge_mod 2 - use iso_c_binding, only: c_ptr, c_char, c_long, c_size_t, c_null_char, c_associated, c_f_pointer 2 + use iso_c_binding, only: c_ptr, c_char, c_long, c_size_t, c_null_char, c_associated, c_f_pointer, c_int8_t 3 3 use strings_mod, only: url_encode 4 4 implicit none 5 5 private 6 - public :: http_get, http_post_json, http_get_urlencoded, last_http_status 6 + public :: http_get, http_post_json, http_post_binary, http_get_urlencoded, last_http_status 7 7 8 8 integer :: last_http_status = 0 9 9 ··· 31 31 import :: c_ptr 32 32 type(c_ptr), value :: ptr 33 33 end subroutine fortransky_http_free 34 + 35 + function fortransky_http_post_binary(url, auth_header, content_type, data, data_len, status_code, out_len) & 36 + bind(C, name='fortransky_http_post_binary') result(res) 37 + import :: c_ptr, c_char, c_long, c_size_t, c_int8_t 38 + character(kind=c_char), dimension(*), intent(in) :: url 39 + character(kind=c_char), dimension(*), intent(in) :: auth_header 40 + character(kind=c_char), dimension(*), intent(in) :: content_type 41 + integer(c_int8_t), dimension(*), intent(in) :: data 42 + integer(c_size_t), value, intent(in) :: data_len 43 + integer(c_long), intent(out) :: status_code 44 + integer(c_size_t), intent(out) :: out_len 45 + type(c_ptr) :: res 46 + end function fortransky_http_post_binary 34 47 end interface 35 48 contains 36 49 function http_get(url, auth_token) result(body) ··· 78 91 body = from_c_buffer(raw, out_len) 79 92 if (c_associated(raw)) call fortransky_http_free(raw) 80 93 end function http_post_json 94 + 95 + ! Upload raw binary data; returns the response body (JSON from uploadBlob) 96 + function http_post_binary(url, data, data_len, content_type, auth_token) result(body) 97 + character(len=*), intent(in) :: url 98 + integer(c_int8_t), intent(in) :: data(*) 99 + integer, intent(in) :: data_len 100 + character(len=*), intent(in) :: content_type 101 + character(len=*), intent(in), optional :: auth_token 102 + character(len=:), allocatable :: body 103 + character(len=:), allocatable :: header 104 + integer(c_long) :: status_code 105 + integer(c_size_t) :: out_len 106 + type(c_ptr) :: raw 107 + 108 + header = auth_header_value(auth_token) 109 + raw = fortransky_http_post_binary( & 110 + c_string(trim(url)), & 111 + c_string(header), & 112 + c_string(trim(content_type)), & 113 + data, int(data_len, c_size_t),& 114 + status_code, out_len) 115 + last_http_status = int(status_code) 116 + body = from_c_buffer(raw, out_len) 117 + if (c_associated(raw)) call fortransky_http_free(raw) 118 + end function http_post_binary 81 119 82 120 function auth_header_value(auth_token) result(header) 83 121 character(len=*), intent(in), optional :: auth_token
+65 -1
src/ui/tui.f90
··· 2 2 use client_mod, only: login_session, fetch_author_feed, search_posts, fetch_timeline, tail_live_stream, & 3 3 fetch_post_thread, create_post, create_reply, create_quote_post, like_post, repost_post, & 4 4 fetch_profile_view, fetch_notifications_view, load_saved_session, clear_saved_session, & 5 - resolve_did_to_handle 5 + resolve_did_to_handle, create_image_post 6 + use dither_mod, only: run_dither 6 7 use models_mod, only: post_view, stream_event, actor_profile, notification_view, MAX_ITEMS 7 8 use config_mod, only: load_session_from_env 8 9 use app_state_mod, only: app_state, VIEW_HOME, VIEW_POST_LIST, VIEW_PROFILE, VIEW_NOTIFICATIONS, VIEW_STREAM, & ··· 83 84 write(*,'(a)') ' x logout + clear saved session' 84 85 write(*,'(a)') ' n notifications' 85 86 write(*,'(a)') ' c compose post' 87 + write(*,'(a)') ' d <image> dither + post image' 86 88 write(*,'(a)') ' t <uri/url> open thread' 87 89 write(*,'(a)') ' j stream tail' 88 90 write(*,'(a)') ' m toggle stream mode (jetstream/relay-raw)' ··· 355 357 end if 356 358 end subroutine compose_flow 357 359 360 + subroutine dither_flow(state, image_path) 361 + type(app_state), intent(inout) :: state 362 + character(len=*), intent(in) :: image_path 363 + 364 + character(len=2000) :: post_text 365 + character(len=256) :: message, created_uri 366 + character(len=512) :: cmd 367 + logical :: ok 368 + integer :: ios 369 + 370 + ! Step 1 — prep: convert image to flat pixel file via dither_prep.py 371 + call set_status(state, 'Dithering: preparing image...') 372 + cmd = 'python3 scripts/dither_prep.py ' // trim(image_path) // & 373 + ' --width 576 --height 720 2>/dev/null' 374 + call execute_command_line(trim(cmd), wait=.true., exitstat=ios) 375 + if (ios /= 0) then 376 + call set_status(state, 'dither_prep.py failed. Is Pillow installed?') 377 + return 378 + end if 379 + 380 + ! Step 2 — dither: run Floyd-Steinberg in Fortran 381 + call set_status(state, 'Dithering: running Floyd-Steinberg...') 382 + call run_dither(ok, message) 383 + if (.not. ok) then 384 + call set_status(state, 'Dither failed: ' // trim(message)) 385 + return 386 + end if 387 + 388 + ! Step 3 — convert pixels to PNG via pixels_to_png.py 389 + call set_status(state, 'Dithering: converting to PNG...') 390 + call execute_command_line('python3 scripts/pixels_to_png.py 2>/dev/null', & 391 + wait=.true., exitstat=ios) 392 + if (ios /= 0) then 393 + call set_status(state, 'PNG conversion failed. Is Pillow installed?') 394 + return 395 + end if 396 + 397 + ! Step 4 — prompt for post text 398 + call prompt_line('Post text (blank for default): ', post_text) 399 + if (len_trim(post_text) == 0) then 400 + post_text = 'Dithered with Bill Atkinson''s Floyd-Steinberg algorithm. ' // & 401 + 'Rendered in Fortran. #fortransky #formerlab' 402 + end if 403 + 404 + ! Step 5 — upload blob and post 405 + call set_status(state, 'Uploading image...') 406 + call create_image_post(state%session, trim(post_text), & 407 + '/tmp/bsky_dither_preview.png', & 408 + 576, 720, ok, message, created_uri) 409 + if (ok) then 410 + call set_status(state, 'Image post created: ' // trim(created_uri)) 411 + else 412 + call set_status(state, trim(message)) 413 + end if 414 + end subroutine dither_flow 415 + 358 416 subroutine reply_to_selected_post(state) 359 417 type(app_state), intent(inout) :: state 360 418 type(post_view) :: target ··· 602 660 call load_notifications(state) 603 661 case ('c') 604 662 call compose_flow(state) 663 + case ('d') 664 + if (len_trim(arg) == 0) then 665 + call set_status(state, 'Usage: d <image path>') 666 + else 667 + call dither_flow(state, arg) 668 + end if 605 669 case ('t') 606 670 if (len_trim(arg) == 0) then 607 671 call set_status(state, 'Usage: t <at://uri or bsky.app URL>')
+231
src/util/dither.f90
··· 1 + ! dither.f90 — Floyd-Steinberg error diffusion dither 2 + ! Bill Atkinson's algorithm, as used in MacPaint (1984). 3 + ! 4 + ! Reads: /tmp/bsky_pixels_in.dat (written by dither_prep.py) 5 + ! Writes: /tmp/bsky_pixels_out.dat (read by dither_post.py) 6 + ! 7 + ! Pixel file format (same as Cobolsky dither.cob): 8 + ! Line 1 — header: width(5) height(5) padding(10) 9 + ! Lines 2..H+1 — one row per line, each pixel as "NNN " (3 digits + space) 10 + ! 11 + ! Floyd-Steinberg error distribution: 12 + ! [curr] 7/16 → 13 + ! 3/16 ↙ 5/16 ↓ 1/16 ↘ 14 + ! 15 + ! Usage: called from dither_flow in tui.f90, not a standalone program. 16 + 17 + module dither_mod 18 + implicit none 19 + private 20 + public :: run_dither 21 + 22 + integer, parameter :: MAX_COLS = 576 23 + character(len=*), parameter :: PIXELS_IN = '/tmp/bsky_pixels_in.dat' 24 + character(len=*), parameter :: PIXELS_OUT = '/tmp/bsky_pixels_out.dat' 25 + 26 + contains 27 + 28 + subroutine run_dither(ok, message) 29 + logical, intent(out) :: ok 30 + character(len=*), intent(out) :: message 31 + 32 + integer :: width, height 33 + integer :: row, col 34 + integer :: old_val, new_val, err 35 + integer :: err7, err3, err5, err1 36 + 37 + ! Two-row pixel buffers with error accumulation headroom (-255..510) 38 + integer :: curr(MAX_COLS), nxt(MAX_COLS), out_row(MAX_COLS) 39 + 40 + character(len=4000) :: line_buf 41 + character(len=256) :: hdr_buf 42 + integer :: in_unit, out_unit, ios 43 + integer :: parse_pos, pixel_idx 44 + character(len=3) :: px_str 45 + 46 + ok = .false. 47 + message = 'Dither failed' 48 + 49 + ! ---------------------------------------------------------------- 50 + ! Open files 51 + ! ---------------------------------------------------------------- 52 + open(newunit=in_unit, file=PIXELS_IN, status='old', action='read', & 53 + iostat=ios) 54 + if (ios /= 0) then 55 + message = 'Cannot open ' // PIXELS_IN 56 + return 57 + end if 58 + 59 + open(newunit=out_unit, file=PIXELS_OUT, status='replace', action='write', & 60 + iostat=ios) 61 + if (ios /= 0) then 62 + close(in_unit) 63 + message = 'Cannot open ' // PIXELS_OUT 64 + return 65 + end if 66 + 67 + ! ---------------------------------------------------------------- 68 + ! Read header 69 + ! ---------------------------------------------------------------- 70 + read(in_unit, '(a)', iostat=ios) hdr_buf 71 + if (ios /= 0) then 72 + message = 'Cannot read header from ' // PIXELS_IN 73 + close(in_unit); close(out_unit) 74 + return 75 + end if 76 + 77 + read(hdr_buf(1:5), '(i5)') width 78 + read(hdr_buf(6:10), '(i5)') height 79 + 80 + if (width < 1 .or. width > MAX_COLS .or. height < 1) then 81 + message = 'Invalid image dimensions in pixel file' 82 + close(in_unit); close(out_unit) 83 + return 84 + end if 85 + 86 + ! Write header unchanged 87 + write(out_unit, '(a)') trim(hdr_buf) 88 + 89 + ! ---------------------------------------------------------------- 90 + ! Initialise buffers 91 + ! ---------------------------------------------------------------- 92 + curr = 0 93 + nxt = 0 94 + out_row = 0 95 + 96 + ! ---------------------------------------------------------------- 97 + ! Read first row into nxt (will be promoted to curr on first iter) 98 + ! ---------------------------------------------------------------- 99 + call read_row(in_unit, width, nxt, ios) 100 + if (ios /= 0) then 101 + message = 'Cannot read first pixel row' 102 + close(in_unit); close(out_unit) 103 + return 104 + end if 105 + 106 + ! ---------------------------------------------------------------- 107 + ! Main dither loop 108 + ! ---------------------------------------------------------------- 109 + do row = 1, height 110 + 111 + ! Promote nxt → curr 112 + curr(1:width) = nxt(1:width) 113 + nxt(1:width) = 0 114 + 115 + ! Read next row into nxt (except on last iteration) 116 + if (row < height) then 117 + call read_row(in_unit, width, nxt, ios) 118 + if (ios /= 0) nxt(1:width) = 0 119 + end if 120 + 121 + ! Apply Floyd-Steinberg across current row 122 + do col = 1, width 123 + 124 + old_val = curr(col) 125 + 126 + ! Clamp accumulated value to 0-255 127 + if (old_val < 0) old_val = 0 128 + if (old_val > 255) old_val = 255 129 + 130 + ! 1-bit quantise 131 + if (old_val >= 128) then 132 + new_val = 255 133 + else 134 + new_val = 0 135 + end if 136 + 137 + out_row(col) = new_val 138 + 139 + ! Quantisation error 140 + err = old_val - new_val 141 + 142 + ! 7/16 → right 143 + if (col < width) then 144 + err7 = err * 7 / 16 145 + curr(col + 1) = curr(col + 1) + err7 146 + end if 147 + 148 + ! 3/16 → lower-left 149 + if (col > 1) then 150 + err3 = err * 3 / 16 151 + nxt(col - 1) = nxt(col - 1) + err3 152 + end if 153 + 154 + ! 5/16 → lower 155 + err5 = err * 5 / 16 156 + nxt(col) = nxt(col) + err5 157 + 158 + ! 1/16 → lower-right 159 + if (col < width) then 160 + err1 = err * 1 / 16 161 + nxt(col + 1) = nxt(col + 1) + err1 162 + end if 163 + 164 + end do 165 + 166 + ! Write output row 167 + call write_row(out_unit, width, out_row) 168 + 169 + end do 170 + 171 + close(in_unit) 172 + close(out_unit) 173 + 174 + ok = .true. 175 + message = 'Dither complete' 176 + 177 + end subroutine run_dither 178 + 179 + ! ---------------------------------------------------------------- 180 + ! Read one pixel row from the flat file into buf(1:width) 181 + ! Format: each pixel is "NNN " (3 digits + space) 182 + ! ---------------------------------------------------------------- 183 + subroutine read_row(unit, width, buf, ios) 184 + integer, intent(in) :: unit, width 185 + integer, intent(out) :: buf(MAX_COLS) 186 + integer, intent(out) :: ios 187 + 188 + character(len=4000) :: line 189 + integer :: pos, idx 190 + character(len=3) :: px 191 + 192 + buf = 0 193 + read(unit, '(a)', iostat=ios) line 194 + if (ios /= 0) return 195 + 196 + pos = 1 197 + do idx = 1, width 198 + px = line(pos:pos+2) 199 + read(px, '(i3)', iostat=ios) buf(idx) 200 + if (ios /= 0) buf(idx) = 0 201 + pos = pos + 4 202 + end do 203 + ios = 0 204 + 205 + end subroutine read_row 206 + 207 + ! ---------------------------------------------------------------- 208 + ! Write one pixel row to the flat file from buf(1:width) 209 + ! ---------------------------------------------------------------- 210 + subroutine write_row(unit, width, buf) 211 + integer, intent(in) :: unit, width 212 + integer, intent(in) :: buf(MAX_COLS) 213 + 214 + character(len=4000) :: line 215 + integer :: pos, idx 216 + character(len=3) :: px 217 + 218 + line = ' ' 219 + pos = 1 220 + do idx = 1, width 221 + write(px, '(i3.3)') buf(idx) 222 + line(pos:pos+2) = px 223 + line(pos+3:pos+3) = ' ' 224 + pos = pos + 4 225 + end do 226 + 227 + write(unit, '(a)') line(1:pos-1) 228 + 229 + end subroutine write_row 230 + 231 + end module dither_mod