meltygui 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- meltygui/__init__.py +107 -0
- meltygui/accounts/__init__.py +0 -0
- meltygui/accounts/internet_accounts.py +1355 -0
- meltygui/chat/__init__.py +91 -0
- meltygui/chat/activity.py +75 -0
- meltygui/chat/backends.py +36 -0
- meltygui/chat/chat_interface.py +732 -0
- meltygui/chat/chat_proxy.py +352 -0
- meltygui/chat/codex_proxy.py +592 -0
- meltygui/chat/codex_settings.py +100 -0
- meltygui/chat/codex_transport.py +60 -0
- meltygui/chat/command_parser.py +204 -0
- meltygui/chat/images.py +227 -0
- meltygui/chat/messages.py +417 -0
- meltygui/chat/metadata.py +139 -0
- meltygui/chat/writer_locks.py +64 -0
- meltygui/code/__init__.py +0 -0
- meltygui/code/basic_converters.py +533 -0
- meltygui/code/chain_converters.py +2111 -0
- meltygui/code/code_checks.py +2209 -0
- meltygui/code/core_syntax.py +1430 -0
- meltygui/code/file_converters.py +1933 -0
- meltygui/code/fileref.py +702 -0
- meltygui/code/hotswap_guard.py +144 -0
- meltygui/code/libcst_conversion.py +9724 -0
- meltygui/code/live_instrument.py +392 -0
- meltygui/code/live_view.py +2490 -0
- meltygui/code/melty_scan.py +2684 -0
- meltygui/code/new_codecs.py +1255 -0
- meltygui/code/new_converters.py +3017 -0
- meltygui/code/project_code.py +278 -0
- meltygui/code/source_context.py +63 -0
- meltygui/code/symbol_roster.py +1588 -0
- meltygui/code/syntax_check.py +34 -0
- meltygui/code/syntax_check_worker.py +114 -0
- meltygui/completion/__init__.py +0 -0
- meltygui/completion/fim.py +1232 -0
- meltygui/completion/fim_context.py +481 -0
- meltygui/completion/providers/__init__.py +0 -0
- meltygui/completion/providers/anthropic_oauth.py +446 -0
- meltygui/completion/providers/anthropic_requests.py +69 -0
- meltygui/completion/providers/claude.py +203 -0
- meltygui/completion/providers/claude_usage.py +499 -0
- meltygui/completion/providers/codex_accounts.py +180 -0
- meltygui/completion/providers/copilot.py +617 -0
- meltygui/completion/providers/oauth_popup.py +220 -0
- meltygui/completion/providers/ollama.py +320 -0
- meltygui/completion/providers/profiles.py +23 -0
- meltygui/core/README.md +88 -0
- meltygui/core/__init__.py +1 -0
- meltygui/core/automation/__init__.py +1 -0
- meltygui/core/automation/action_core.py +153 -0
- meltygui/core/automation/collection_action.py +45 -0
- meltygui/core/automation/mcp_eval.py +167 -0
- meltygui/core/automation/mcp_hotswap.py +107 -0
- meltygui/core/automation/mcp_query.py +552 -0
- meltygui/core/automation/mcp_server.py +657 -0
- meltygui/core/automation/orchestration_core.py +2636 -0
- meltygui/core/automation/query_core.py +124 -0
- meltygui/core/automation/search_core.py +26 -0
- meltygui/core/automation/selector_core.py +340 -0
- meltygui/core/automation/value_core.py +1752 -0
- meltygui/core/cache/__init__.py +1 -0
- meltygui/core/cache/cache_diagnostics.py +0 -0
- meltygui/core/cache/invalidation_decoration.py +153 -0
- meltygui/core/cache/invalidation_tracker.py +43 -0
- meltygui/core/cache/tile_cache.py +5968 -0
- meltygui/core/conversion/__init__.py +1 -0
- meltygui/core/conversion/bubbling.py +599 -0
- meltygui/core/conversion/cache_tree.py +188 -0
- meltygui/core/conversion/chain.py +113 -0
- meltygui/core/conversion/converter_register.py +145 -0
- meltygui/core/conversion/data_decoration.py +67 -0
- meltygui/core/conversion/dict_conversion.py +1815 -0
- meltygui/core/conversion/dict_conversion_util.py +177 -0
- meltygui/core/conversion/dynamic_obj.py +89 -0
- meltygui/core/conversion/graph_compare.py +183 -0
- meltygui/core/conversion/load_save_v2.py +1032 -0
- meltygui/core/conversion/missing_saved_class.py +39 -0
- meltygui/core/conversion/path_finder.py +606 -0
- meltygui/core/conversion/render_host.py +1083 -0
- meltygui/core/core_render.py +6537 -0
- meltygui/core/definition_hotswap.py +231 -0
- meltygui/core/diagnostics/__init__.py +1 -0
- meltygui/core/diagnostics/attribute_churn.py +38 -0
- meltygui/core/diagnostics/fps_counter.py +39 -0
- meltygui/core/diagnostics/gpu_frame_timer.py +103 -0
- meltygui/core/diagnostics/inspection_core.py +169 -0
- meltygui/core/diagnostics/monitor_core.py +105 -0
- meltygui/core/diagnostics/notifications.py +706 -0
- meltygui/core/diagnostics/perf_trace.py +281 -0
- meltygui/core/diagnostics/profile_decoration.py +88 -0
- meltygui/core/diagnostics/resize_trace.py +62 -0
- meltygui/core/diagnostics/screenshot_core.py +247 -0
- meltygui/core/diagnostics/session_status.py +98 -0
- meltygui/core/diagnostics/trace_core.py +445 -0
- meltygui/core/files/__init__.py +1 -0
- meltygui/core/files/file_core.py +208 -0
- meltygui/core/files/file_explorer_core.py +104 -0
- meltygui/core/files/file_tree_core.py +198 -0
- meltygui/core/files/file_watch_core.py +43 -0
- meltygui/core/files/import_graph_core.py +43 -0
- meltygui/core/files/metadata_core.py +51 -0
- meltygui/core/graphics/__init__.py +1 -0
- meltygui/core/graphics/cuda_context_core.py +166 -0
- meltygui/core/graphics/cuda_interop_core.py +136 -0
- meltygui/core/graphics/cuda_kernel_core.py +91 -0
- meltygui/core/graphics/framebuffer_recorder.py +337 -0
- meltygui/core/graphics/gl_state.py +658 -0
- meltygui/core/graphics/lut_core.py +52 -0
- meltygui/core/graphics/overlay_renderer.py +984 -0
- meltygui/core/graphics/scene_target.py +180 -0
- meltygui/core/graphics/screenshot.py +439 -0
- meltygui/core/graphics/shader_func.py +478 -0
- meltygui/core/graphics/tensor_core.py +45 -0
- meltygui/core/graphics/text_texture.py +329 -0
- meltygui/core/graphics/wayland_color.py +635 -0
- meltygui/core/input/__init__.py +1 -0
- meltygui/core/input/collision.py +165 -0
- meltygui/core/input/drag_drop_core.py +1525 -0
- meltygui/core/input/hypr_left_drag.py +323 -0
- meltygui/core/input/input_core.py +245 -0
- meltygui/core/input/input_handler.py +1101 -0
- meltygui/core/input/mouse_cursor.py +355 -0
- meltygui/core/input/pynput_backend.py +1054 -0
- meltygui/core/input/space_mouse.py +338 -0
- meltygui/core/input/touchpad_backend.py +393 -0
- meltygui/core/input/view_selection.py +177 -0
- meltygui/core/layout/__init__.py +1 -0
- meltygui/core/layout/column_core.py +2153 -0
- meltygui/core/layout/cursor_core.py +161 -0
- meltygui/core/layout/dropdown_core.py +278 -0
- meltygui/core/layout/edge_constraints.py +155 -0
- meltygui/core/layout/grid_core.py +117 -0
- meltygui/core/layout/header_core.py +23 -0
- meltygui/core/layout/header_runtime.py +67 -0
- meltygui/core/layout/layout_core.py +87 -0
- meltygui/core/layout/tile_manager_core.py +591 -0
- meltygui/core/melty.py +6726 -0
- meltygui/core/module_map.json +896 -0
- meltygui/core/module_names.py +19 -0
- meltygui/core/rendering/__init__.py +1 -0
- meltygui/core/rendering/core_decoration.py +431 -0
- meltygui/core/rendering/core_render_helpers.py +328 -0
- meltygui/core/rendering/func_metadata.py +398 -0
- meltygui/core/rendering/mode.py +818 -0
- meltygui/core/rendering/mode_defaults.py +41 -0
- meltygui/core/rendering/modes.py +136 -0
- meltygui/core/rendering/parameter_core.py +1665 -0
- meltygui/core/rendering/render_dispatch.py +1891 -0
- meltygui/core/rendering/render_funcs.py +273 -0
- meltygui/core/rendering/shaped.py +312 -0
- meltygui/core/rendering/window_decoration.py +25 -0
- meltygui/core/runtime/__init__.py +1 -0
- meltygui/core/runtime/app.py +735 -0
- meltygui/core/runtime/app_session.py +140 -0
- meltygui/core/runtime/background.py +564 -0
- meltygui/core/runtime/extensions.py +53 -0
- meltygui/core/runtime/gc_manager.py +1163 -0
- meltygui/core/runtime/lifecycle.py +21 -0
- meltygui/core/runtime/paths.py +27 -0
- meltygui/core/runtime/settings.py +14 -0
- meltygui/core/runtime/singleton.py +16 -0
- meltygui/core/runtime/thread_safe_bool.py +24 -0
- meltygui/core/runtime/thread_signal.py +30 -0
- meltygui/core/runtime/toggles.py +3115 -0
- meltygui/core/services/__init__.py +1 -0
- meltygui/core/services/account_core.py +10 -0
- meltygui/core/services/chat_core.py +19 -0
- meltygui/core/services/claude_terminal_core.py +346 -0
- meltygui/core/services/terminal_core.py +458 -0
- meltygui/core/services/terminal_runtime.py +78 -0
- meltygui/core/styling/__init__.py +1 -0
- meltygui/core/styling/color_core.py +46 -0
- meltygui/core/styling/fonts.py +639 -0
- meltygui/core/styling/global_style.py +338 -0
- meltygui/core/styling/style.py +198 -0
- meltygui/core/styling/style_core.py +522 -0
- meltygui/core/styling/warm_start.py +149 -0
- meltygui/core/windowing/__init__.py +1 -0
- meltygui/core/windowing/backends/PYIMGUI_LICENSE +28 -0
- meltygui/core/windowing/backends/__init__.py +1 -0
- meltygui/core/windowing/backends/imgui_renderer.py +138 -0
- meltygui/core/windowing/backends/native_wayland.py +850 -0
- meltygui/core/windowing/backends/protocols/xdg-decoration-unstable-v1.xml +156 -0
- meltygui/core/windowing/backends/protocols/xdg-shell.xml +1420 -0
- meltygui/core/windowing/backends/wayland_protocol.py +101 -0
- meltygui/core/windowing/dock_core.py +182 -0
- meltygui/core/windowing/frame_geometry.py +42 -0
- meltygui/core/windowing/geometry_feed.py +864 -0
- meltygui/core/windowing/glfw_utils.py +1343 -0
- meltygui/core/windowing/os_frame.py +1552 -0
- meltygui/core/windowing/surface.py +643 -0
- meltygui/core/windowing/titlebar.py +1560 -0
- meltygui/core/windowing/titlebar_buttons.py +281 -0
- meltygui/core/windowing/wayland_move.py +932 -0
- meltygui/core/windowing/window_api.py +62 -0
- meltygui/core/windowing/window_constants.py +339 -0
- meltygui/core/windowing/window_visibility.py +162 -0
- meltygui/debug/__init__.py +0 -0
- meltygui/debug/app_view_utils.py +9 -0
- meltygui/editor/__init__.py +0 -0
- meltygui/editor/bash_syntax.py +30 -0
- meltygui/editor/code_line_fast.py +152 -0
- meltygui/editor/diff.py +139 -0
- meltygui/editor/external_changes.py +159 -0
- meltygui/editor/file_header.py +41 -0
- meltygui/editor/live_usage.py +169 -0
- meltygui/editor/live_view_views.py +1803 -0
- meltygui/editor/pending_save.py +1351 -0
- meltygui/editor/roster_tints.py +547 -0
- meltygui/editor/source_preview.py +16 -0
- meltygui/editor/source_tools.py +10 -0
- meltygui/editor/source_ui.py +70 -0
- meltygui/editor/spell_check.py +77 -0
- meltygui/editor/text_editor.py +9076 -0
- meltygui/editor/usage_picker.py +538 -0
- meltygui/events/__init__.py +0 -0
- meltygui/events/example.py +118 -0
- meltygui/examples/__init__.py +0 -0
- meltygui/examples/columns_demo.py +33 -0
- meltygui/examples/columns_window_demo.py +60 -0
- meltygui/examples/context_menu_demo.py +24 -0
- meltygui/examples/context_menu_window_demo.py +49 -0
- meltygui/examples/gui_playground.py +127 -0
- meltygui/examples/live_view_playground.py +256 -0
- meltygui/examples/lora.py +43 -0
- meltygui/examples/lora_data.py +59 -0
- meltygui/examples/lora_policies.py +67 -0
- meltygui/examples/mode_demo.py +49 -0
- meltygui/examples/modifies_demo.py +119 -0
- meltygui/examples/scalar_policies.py +60 -0
- meltygui/examples/style_layouts.py +149 -0
- meltygui/examples/tile_manager_demo.py +74 -0
- meltygui/examples/tint_demo.py +160 -0
- meltygui/examples/tint_functions.py +56 -0
- meltygui/examples/trace_demo.py +88 -0
- meltygui/examples/two_windows.py +36 -0
- meltygui/files/__init__.py +0 -0
- meltygui/files/fast_file_explorer.py +439 -0
- meltygui/gnome_extension/lsd-window-geometry@latent-descent/extension.js +237 -0
- meltygui/gnome_extension/lsd-window-geometry@latent-descent/lsd-window-geometry@latent-descent.iml +9 -0
- meltygui/gnome_extension/lsd-window-geometry@latent-descent/metadata.json +7 -0
- meltygui/graphics/__init__.py +6 -0
- meltygui/graphics/base.py +85 -0
- meltygui/graphics/examples.py +507 -0
- meltygui/graphics/executor.py +520 -0
- meltygui/graphics/filter.py +667 -0
- meltygui/graphics/filter.pyi +856 -0
- meltygui/graphics/generate_stubs.py +22 -0
- meltygui/graphics/registry.py +203 -0
- meltygui/graphics/shader_compiler.py +155 -0
- meltygui/graphics/shaders.py +1302 -0
- meltygui/graphics/stub_generator.py +250 -0
- meltygui/graphics/texture_manager.py +170 -0
- meltygui/graphics/texture_min_max.py +295 -0
- meltygui/hdr_color.py +757 -0
- meltygui/image_load.py +308 -0
- meltygui/model/__init__.py +1 -0
- meltygui/model/account_model.py +142 -0
- meltygui/model/camera_model.py +159 -0
- meltygui/model/chat_model.py +86 -0
- meltygui/model/code_model.py +62 -0
- meltygui/model/code_proxy_model.py +876 -0
- meltygui/model/collection_model.py +41 -0
- meltygui/model/color_model.py +127 -0
- meltygui/model/cuda_tensor_model.py +36 -0
- meltygui/model/cuda_texture_model.py +149 -0
- meltygui/model/dropdown_model.py +137 -0
- meltygui/model/file_metadata_model.py +73 -0
- meltygui/model/file_model.py +289 -0
- meltygui/model/format_model.py +390 -0
- meltygui/model/graph_model.py +98 -0
- meltygui/model/icon_model.py +1024 -0
- meltygui/model/import_graph_model.py +468 -0
- meltygui/model/layout_model.py +15 -0
- meltygui/model/lut_model.py +266 -0
- meltygui/model/search_model.py +173 -0
- meltygui/model/tensor_model.py +402 -0
- meltygui/model/terminal_model.py +329 -0
- meltygui/model/texture_model.py +146 -0
- meltygui/model/tile_model.py +24 -0
- meltygui/model/trace_model.py +198 -0
- meltygui/model/trace_report_model.py +146 -0
- meltygui/models/__init__.py +0 -0
- meltygui/models/file_meta.py +613 -0
- meltygui/models/function_console.py +184 -0
- meltygui/models/orchestration.py +48 -0
- meltygui/pbr.py +1576 -0
- meltygui/png_unfilter.c +49 -0
- meltygui/resources/JetBrainsMono-Regular.ttf +0 -0
- meltygui/resources/THIRD_PARTY_NOTICES.md +21 -0
- meltygui/resources/dejavu/DejaVuSans-Bold.ttf +0 -0
- meltygui/resources/dejavu/DejaVuSans-ExtraLight.ttf +0 -0
- meltygui/resources/dejavu/DejaVuSans.ttf +0 -0
- meltygui/resources/dejavu/LICENSE.txt +78 -0
- meltygui/resources/fontawesome-LICENSE.txt +121 -0
- meltygui/resources/fontawesome-webfont.ttf +0 -0
- meltygui/resources/hdri/studio_small_09_1k.hdr +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Bold.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraBold.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraLight.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Light.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Medium.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-SemiBold.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Thin.ttf +0 -0
- meltygui/resources/jetbrains-weights/OFL.txt +93 -0
- meltygui/resources/jetbrains-weights/README.md +2 -0
- meltygui/state/__init__.py +0 -0
- meltygui/state/account_state.py +22 -0
- meltygui/state/animation_state.py +97 -0
- meltygui/state/annotation_state.py +22 -0
- meltygui/state/chat_state.py +36 -0
- meltygui/state/code_state.py +10 -0
- meltygui/state/core_enums.py +59 -0
- meltygui/state/core_markers.py +115 -0
- meltygui/state/core_undo.py +1075 -0
- meltygui/state/file_state.py +75 -0
- meltygui/state/graph_state.py +26 -0
- meltygui/state/inspection_state.py +47 -0
- meltygui/state/menu_state.py +10 -0
- meltygui/state/model_enums.py +11 -0
- meltygui/state/new_core_model.py +2394 -0
- meltygui/state/orchestration_state.py +14 -0
- meltygui/state/query_state.py +24 -0
- meltygui/state/tensor_state.py +10 -0
- meltygui/state/terminal_state.py +23 -0
- meltygui/state/trace_state.py +32 -0
- meltygui/state/voxel_state.py +12 -0
- meltygui/text_index.py +816 -0
- meltygui/utils/__init__.py +0 -0
- meltygui/utils/jump_to_code.py +344 -0
- meltygui/utils/pkl_inspect.py +90 -0
- meltygui/utils/render_utils.py +1419 -0
- meltygui/view/__init__.py +1 -0
- meltygui/view/account_view.py +524 -0
- meltygui/view/action_view.py +73 -0
- meltygui/view/chat_decoration_view.py +112 -0
- meltygui/view/chat_view.py +1703 -0
- meltygui/view/code_view.py +2677 -0
- meltygui/view/collection_view.py +1185 -0
- meltygui/view/color_view.py +824 -0
- meltygui/view/control_view.py +559 -0
- meltygui/view/decoration_view.py +439 -0
- meltygui/view/diagnostic_view.py +177 -0
- meltygui/view/dropdown_view.py +1082 -0
- meltygui/view/file_view.py +1599 -0
- meltygui/view/graph_cuda_view.py +105 -0
- meltygui/view/graph_view.py +882 -0
- meltygui/view/header_view.py +848 -0
- meltygui/view/input_view.py +238 -0
- meltygui/view/inspection_view.py +1783 -0
- meltygui/view/layout_view.py +596 -0
- meltygui/view/lut_view.py +45 -0
- meltygui/view/menu_view.py +212 -0
- meltygui/view/orchestration_view.py +658 -0
- meltygui/view/query_view.py +118 -0
- meltygui/view/search_view.py +342 -0
- meltygui/view/tab_view.py +258 -0
- meltygui/view/tensor_view.py +430 -0
- meltygui/view/terminal_view.py +355 -0
- meltygui/view/text_view.py +8031 -0
- meltygui/view/texture_view.py +480 -0
- meltygui/view/tile_view.py +38 -0
- meltygui/view/trace_view.py +923 -0
- meltygui/view/voxel_cuda_view.py +824 -0
- meltygui/view/voxel_view.py +1860 -0
- meltygui/view/window_view.py +111 -0
- meltygui-0.1.0.dist-info/METADATA +143 -0
- meltygui-0.1.0.dist-info/RECORD +372 -0
- meltygui-0.1.0.dist-info/WHEEL +4 -0
- meltygui-0.1.0.dist-info/licenses/LICENSE +202 -0
meltygui/image_load.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""Load an image (a file, or encoded bytes) as linear scRGB float32 (h, w, 3), 1.0 = SDR white (``sdr_white`` nits).
|
|
2
|
+
|
|
3
|
+
meltygui's copy of hdr-viewer's image_load.py (~/Desktop/hdr-viewer), the reference decoder; `load_bytes` is the
|
|
4
|
+
addition for the chat transcript's inline images (base64 payloads in a session).
|
|
5
|
+
|
|
6
|
+
That is meltygui's working space: sRGB primaries, linear light, no ceiling, negatives
|
|
7
|
+
allowed (BT.2020 colours outside sRGB come out negative and survive the fp16 path).
|
|
8
|
+
|
|
9
|
+
Three kinds of file:
|
|
10
|
+
* 16-bit PNG with a PQ cICP chunk (what screenshot-hdr writes). Decoded here
|
|
11
|
+
by hand (Pillow flattens 16-bit RGB to 8-bit), PQ -> nits, BT.2020 -> sRGB
|
|
12
|
+
primaries, divided by ``sdr_white`` — the reference the FILE was authored
|
|
13
|
+
against (203 nits, BT.2408), not the desktop's SDR white.
|
|
14
|
+
* 8-bit PQ: a PNG with a PQ cICP chunk (Blender's HDR output) or a JPEG (or
|
|
15
|
+
anything Pillow opens) with a PQ ICC profile ("Rec2020 Gamut with PQ
|
|
16
|
+
Transfer"). Pillow decodes, then the same PQ path through a 256-entry LUT.
|
|
17
|
+
* anything else Pillow opens: treated as sRGB, 1.0 = SDR white. (A wide-gamut
|
|
18
|
+
SDR ICC profile such as Display P3 is not honoured yet.)
|
|
19
|
+
"""
|
|
20
|
+
import ctypes
|
|
21
|
+
import hashlib
|
|
22
|
+
import os
|
|
23
|
+
import pathlib
|
|
24
|
+
import struct
|
|
25
|
+
import subprocess
|
|
26
|
+
import zlib
|
|
27
|
+
|
|
28
|
+
import numpy as np
|
|
29
|
+
|
|
30
|
+
# chromaticities -> RGB->XYZ, and the BT.2020 -> sRGB matrix (both D65, no adaptation)
|
|
31
|
+
def _rgb_to_xyz(xy):
|
|
32
|
+
xy = np.asarray(xy, dtype=float)
|
|
33
|
+
xyz = np.stack([xy[:, 0] / xy[:, 1], np.ones(4), (1 - xy.sum(axis=1)) / xy[:, 1]], axis=0)
|
|
34
|
+
m = xyz[:, :3]
|
|
35
|
+
return m @ np.diag(np.linalg.solve(m, xyz[:, 3]))
|
|
36
|
+
|
|
37
|
+
BT2020_XY = [[.708, .292], [.170, .797], [.131, .046], [.3127, .3290]]
|
|
38
|
+
SRGB_XY = [[.64, .33], [.30, .60], [.15, .06], [.3127, .3290]]
|
|
39
|
+
P3_XY = [[.680, .320], [.265, .690], [.150, .060], [.3127, .3290]]
|
|
40
|
+
BT2020_TO_SRGB = np.linalg.solve(_rgb_to_xyz(SRGB_XY), _rgb_to_xyz(BT2020_XY))
|
|
41
|
+
# CICP (H.273) colour primaries code -> matrix to sRGB primaries
|
|
42
|
+
PRIMARIES_TO_SRGB = {1: np.eye(3), 9: BT2020_TO_SRGB,
|
|
43
|
+
12: np.linalg.solve(_rgb_to_xyz(SRGB_XY), _rgb_to_xyz(P3_XY))}
|
|
44
|
+
CICP_PQ = 16
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def pq_decode(v):
|
|
48
|
+
"""PQ code values in [0, 1] -> nits."""
|
|
49
|
+
p = np.maximum(v, 0) ** (1 / (2523 / 32))
|
|
50
|
+
return (np.maximum(p - 3424 / 4096, 0) / (2413 / 128 - (2392 / 128) * p)) ** (1 / (2610 / 16384)) * 10000
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_PQ16_LUT = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def pq16_to_nits():
|
|
57
|
+
"""nits for every 16-bit PQ code, float32 (65536,). Indexing a 1080p frame
|
|
58
|
+
through it is ~10 ms; evaluating pq_decode over the frame was ~100 ms."""
|
|
59
|
+
global _PQ16_LUT
|
|
60
|
+
if _PQ16_LUT is None:
|
|
61
|
+
_PQ16_LUT = pq_decode(np.arange(65536, dtype=np.float64) / 65535.0).astype(np.float32)
|
|
62
|
+
return _PQ16_LUT
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def pq8_to_nits():
|
|
66
|
+
return pq_decode(np.arange(256, dtype=np.float64) / 255.0).astype(np.float32)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def pq_to_linear(codes, primaries, sdr_white, alpha=None):
|
|
70
|
+
"""PQ code values (uint8 or uint16 (h, w, 3)) in CICP ``primaries`` ->
|
|
71
|
+
linear scRGB float32, 1.0 = ``sdr_white`` nits; alpha (same dtype) composites
|
|
72
|
+
over black, like the compositor would. Returns (linear, peak_nits)."""
|
|
73
|
+
lut = pq16_to_nits() if codes.dtype == np.uint16 else pq8_to_nits()
|
|
74
|
+
nits = lut[codes]
|
|
75
|
+
if alpha is not None and alpha.min() != np.iinfo(alpha.dtype).max:
|
|
76
|
+
nits *= alpha.astype(np.float32)[..., None] / np.float32(np.iinfo(alpha.dtype).max)
|
|
77
|
+
m = (PRIMARIES_TO_SRGB.get(primaries, BT2020_TO_SRGB) / float(sdr_white)).astype(np.float32)
|
|
78
|
+
return nits @ m.T, float(nits.max()) # fp32 throughout: ~2 ms for 1080p
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def srgb_to_linear(v):
|
|
82
|
+
v = np.asarray(v, dtype=np.float32)
|
|
83
|
+
return np.where(v <= 0.04045, v / 12.92, ((v + 0.055) / 1.055) ** 2.4)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# --- 16-bit PNG ----------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
_UNFILTER_SRC = pathlib.Path(__file__).with_name('png_unfilter.c')
|
|
89
|
+
_native = None # C function, or False once we know it is unavailable
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _native_unfilter():
|
|
93
|
+
"""png_unfilter from png_unfilter.c, compiled once with cc into the cache dir
|
|
94
|
+
(keyed by source hash). None if there is no compiler."""
|
|
95
|
+
global _native
|
|
96
|
+
if _native is not None:
|
|
97
|
+
return _native or None
|
|
98
|
+
_native = False
|
|
99
|
+
try:
|
|
100
|
+
src = _UNFILTER_SRC.read_bytes()
|
|
101
|
+
cache = pathlib.Path(os.environ.get('XDG_CACHE_HOME') or pathlib.Path.home() / '.cache') / 'meltygui'
|
|
102
|
+
so = cache / f'png_unfilter-{hashlib.sha1(src).hexdigest()[:12]}.so'
|
|
103
|
+
if not so.is_file():
|
|
104
|
+
cache.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
tmp = so.with_suffix(f'.{os.getpid()}.tmp')
|
|
106
|
+
subprocess.run(['cc', '-O2', '-shared', '-fPIC', '-o', str(tmp), str(_UNFILTER_SRC)],
|
|
107
|
+
check=True, capture_output=True)
|
|
108
|
+
os.replace(tmp, so)
|
|
109
|
+
fn = ctypes.CDLL(str(so)).png_unfilter
|
|
110
|
+
fn.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_int, ctypes.c_int, ctypes.c_int]
|
|
111
|
+
fn.restype = ctypes.c_int
|
|
112
|
+
_native = fn
|
|
113
|
+
except (OSError, subprocess.CalledProcessError):
|
|
114
|
+
pass
|
|
115
|
+
return _native or None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _unfilter(raw, bpp):
|
|
119
|
+
"""raw: (h, 1 + stride) uint8 with the filter byte first -> (h, stride) unfiltered.
|
|
120
|
+
Sub/Average/Paeth are serial in both axes, so this is a C loop when a compiler
|
|
121
|
+
is around and a (very slow: seconds per Paeth-heavy 1080p frame) Python one
|
|
122
|
+
otherwise."""
|
|
123
|
+
h, stride = raw.shape[0], raw.shape[1] - 1
|
|
124
|
+
fn = _native_unfilter()
|
|
125
|
+
if fn is not None:
|
|
126
|
+
raw = np.ascontiguousarray(raw)
|
|
127
|
+
out = np.empty((h, stride), dtype=np.uint8)
|
|
128
|
+
if fn(raw.ctypes.data, out.ctypes.data, h, stride, bpp) == 0:
|
|
129
|
+
return out
|
|
130
|
+
raise ValueError('bad PNG filter type')
|
|
131
|
+
return _unfilter_py(raw[:, 1:], raw[:, 0], bpp)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _unfilter_py(rows, filters, bpp):
|
|
135
|
+
out = np.zeros_like(rows)
|
|
136
|
+
prior = np.zeros(rows.shape[1], dtype=np.int32)
|
|
137
|
+
for y in range(rows.shape[0]):
|
|
138
|
+
kind = int(filters[y])
|
|
139
|
+
cur = rows[y].astype(np.int32)
|
|
140
|
+
if kind == 0:
|
|
141
|
+
raw = cur
|
|
142
|
+
elif kind == 2:
|
|
143
|
+
raw = (cur + prior) & 255
|
|
144
|
+
elif kind == 1:
|
|
145
|
+
raw = (np.cumsum(cur.reshape(-1, bpp), axis=0) & 255).reshape(-1)
|
|
146
|
+
else:
|
|
147
|
+
raw = np.zeros_like(cur)
|
|
148
|
+
for i in range(cur.shape[0]):
|
|
149
|
+
a = raw[i - bpp] if i >= bpp else 0
|
|
150
|
+
b = prior[i]
|
|
151
|
+
if kind == 3:
|
|
152
|
+
pred = (a + b) >> 1
|
|
153
|
+
else:
|
|
154
|
+
c = prior[i - bpp] if i >= bpp else 0
|
|
155
|
+
p = a + b - c
|
|
156
|
+
pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
|
|
157
|
+
pred = a if (pa <= pb and pa <= pc) else (b if pb <= pc else c)
|
|
158
|
+
raw[i] = (cur[i] + pred) & 255
|
|
159
|
+
out[y] = raw
|
|
160
|
+
prior = raw
|
|
161
|
+
return out
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def read_png16(path, data=None):
|
|
165
|
+
"""-> (rgb (h, w, 3) uint16, alpha uint16 or None, cicp bytes or None).
|
|
166
|
+
Raises ValueError unless it is a 16-bit RGB/RGBA non-interlaced PNG.
|
|
167
|
+
``data``: the file's bytes when already in hand (no path needed)."""
|
|
168
|
+
if data is None:
|
|
169
|
+
data = pathlib.Path(path).read_bytes()
|
|
170
|
+
if data[:8] != b'\x89PNG\r\n\x1a\n':
|
|
171
|
+
raise ValueError('not a PNG')
|
|
172
|
+
pos, idat, cicp, header = 8, [], None, None
|
|
173
|
+
while pos + 8 <= len(data):
|
|
174
|
+
length, = struct.unpack('>I', data[pos:pos + 4])
|
|
175
|
+
kind = data[pos + 4:pos + 8]
|
|
176
|
+
body = data[pos + 8:pos + 8 + length]
|
|
177
|
+
if kind == b'IHDR':
|
|
178
|
+
header = struct.unpack('>IIBBBBB', body[:13])
|
|
179
|
+
elif kind == b'cICP':
|
|
180
|
+
cicp = bytes(body[:4])
|
|
181
|
+
elif kind == b'IDAT':
|
|
182
|
+
idat.append(body)
|
|
183
|
+
elif kind == b'IEND':
|
|
184
|
+
break
|
|
185
|
+
pos += 12 + length
|
|
186
|
+
if header is None:
|
|
187
|
+
raise ValueError('PNG without IHDR')
|
|
188
|
+
w, h, depth, ctype, _, _, interlace = header
|
|
189
|
+
if depth != 16 or ctype not in (2, 6) or interlace:
|
|
190
|
+
raise ValueError('not a 16-bit RGB/RGBA PNG')
|
|
191
|
+
channels = 3 if ctype == 2 else 4
|
|
192
|
+
bpp = channels * 2
|
|
193
|
+
raw = np.frombuffer(zlib.decompress(b''.join(idat)), np.uint8).reshape(h, 1 + w * bpp)
|
|
194
|
+
rows = _unfilter(raw, bpp) if raw[:, 0].any() else raw[:, 1:]
|
|
195
|
+
samples = rows.reshape(-1).view('>u2').reshape(h, w, channels).astype(np.uint16)
|
|
196
|
+
return samples[..., :3], (samples[..., 3] if channels == 4 else None), cicp
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def png_pq_cicp(path, data=None):
|
|
200
|
+
"""(bit depth, primaries) if ``path`` (or ``data``, the file's bytes) is a
|
|
201
|
+
PNG whose cICP chunk says PQ transfer (screenshot-hdr: 16-bit; Blender:
|
|
202
|
+
8-bit), else None."""
|
|
203
|
+
if data is not None:
|
|
204
|
+
head = bytes(data[:4096])
|
|
205
|
+
else:
|
|
206
|
+
try:
|
|
207
|
+
with open(path, 'rb') as f:
|
|
208
|
+
head = f.read(4096)
|
|
209
|
+
except OSError:
|
|
210
|
+
return None
|
|
211
|
+
if head[:8] != b'\x89PNG\r\n\x1a\n' or len(head) < 25:
|
|
212
|
+
return None
|
|
213
|
+
pos = 8
|
|
214
|
+
while pos + 8 <= len(head):
|
|
215
|
+
length, = struct.unpack('>I', head[pos:pos + 4])
|
|
216
|
+
kind = head[pos + 4:pos + 8]
|
|
217
|
+
if kind == b'cICP':
|
|
218
|
+
primaries, transfer = head[pos + 8], head[pos + 9]
|
|
219
|
+
return (head[24], primaries) if transfer == CICP_PQ else None
|
|
220
|
+
if kind in (b'IDAT', b'IEND'):
|
|
221
|
+
return None
|
|
222
|
+
pos += 12 + length
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def icc_pq_cicp(icc):
|
|
227
|
+
"""The primaries code if an ICC profile declares a PQ transfer: its ICC.2
|
|
228
|
+
``cicp`` tag (Google's "Rec2020 Gamut with PQ Transfer" profile carries
|
|
229
|
+
one), or failing that a description naming PQ. None for an SDR profile."""
|
|
230
|
+
if not icc or len(icc) < 132:
|
|
231
|
+
return None
|
|
232
|
+
try:
|
|
233
|
+
count, = struct.unpack('>I', icc[128:132])
|
|
234
|
+
for i in range(count):
|
|
235
|
+
sig, off, ln = struct.unpack('>4sII', icc[132 + 12 * i:144 + 12 * i])
|
|
236
|
+
if sig == b'cicp' and ln >= 12:
|
|
237
|
+
primaries, transfer = icc[off + 8], icc[off + 9]
|
|
238
|
+
return primaries if transfer == CICP_PQ else None
|
|
239
|
+
if sig == b'desc':
|
|
240
|
+
desc = icc[off:off + ln].replace(b'\x00', b'').lower()
|
|
241
|
+
if b'pq' in desc or b'2100' in desc:
|
|
242
|
+
return 9
|
|
243
|
+
except (struct.error, IndexError):
|
|
244
|
+
pass
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# --- public -------------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
class Loaded:
|
|
251
|
+
"""rgb: float32 (h, w, 3) linear scRGB, 1.0 = SDR white — or, for an opaque
|
|
252
|
+
8-bit sRGB source, srgb8: uint8 (h, w, 3) as decoded, to be linearised by the
|
|
253
|
+
GPU (rgb is then None). hdr: True for PQ sources; peak_nits: brightest
|
|
254
|
+
channel in nits (for the title)."""
|
|
255
|
+
def __init__(self, rgb, hdr, peak_nits, srgb8=None):
|
|
256
|
+
self.rgb, self.hdr, self.peak_nits, self.srgb8 = rgb, hdr, peak_nits, srgb8
|
|
257
|
+
|
|
258
|
+
@property
|
|
259
|
+
def size(self):
|
|
260
|
+
a = self.rgb if self.rgb is not None else self.srgb8
|
|
261
|
+
return a.shape[1], a.shape[0]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def load(path, sdr_white, mark=lambda label: None):
|
|
265
|
+
"""Decode ``path`` to linear scRGB. ``mark(label)`` is called after each stage for timing."""
|
|
266
|
+
return _decode(path, None, sdr_white, mark)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def load_bytes(data, sdr_white, mark=lambda label: None):
|
|
270
|
+
"""Decode an encoded image held in memory (a PNG / JPEG / WebP payload)
|
|
271
|
+
exactly as `load` decodes a file: PQ PNGs and PQ ICC profiles come out HDR."""
|
|
272
|
+
return _decode(None, bytes(data), sdr_white, mark)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _decode(path, data, sdr_white, mark):
|
|
276
|
+
pq = png_pq_cicp(path, data)
|
|
277
|
+
if pq and pq[0] == 16:
|
|
278
|
+
mark('sniffed PQ png')
|
|
279
|
+
rgb, alpha, _ = read_png16(path, data)
|
|
280
|
+
mark('png16 decoded')
|
|
281
|
+
linear, peak = pq_to_linear(rgb, pq[1], sdr_white, alpha)
|
|
282
|
+
mark('PQ -> linear scRGB')
|
|
283
|
+
return Loaded(linear, True, peak)
|
|
284
|
+
mark('sniffed: not 16-bit PQ png')
|
|
285
|
+
from PIL import Image, ImageOps
|
|
286
|
+
mark('PIL imported')
|
|
287
|
+
import io
|
|
288
|
+
with Image.open(path if data is None else io.BytesIO(data)) as im:
|
|
289
|
+
im = ImageOps.exif_transpose(im)
|
|
290
|
+
# PQ in an 8-bit container: a PNG with cICP (Blender) or a JPEG/anything
|
|
291
|
+
# with a PQ ICC profile (Chromium honours both; treated as sRGB they
|
|
292
|
+
# show flat and dim, 09-11).
|
|
293
|
+
primaries = pq[1] if pq else icc_pq_cicp(im.info.get('icc_profile'))
|
|
294
|
+
if primaries is not None:
|
|
295
|
+
arr = np.asarray(im.convert('RGBA'))
|
|
296
|
+
linear, peak = pq_to_linear(arr[..., :3], primaries, sdr_white, arr[..., 3])
|
|
297
|
+
mark('PIL decoded, PQ8 -> linear scRGB')
|
|
298
|
+
return Loaded(np.ascontiguousarray(linear, dtype=np.float32), True, peak)
|
|
299
|
+
if im.mode in ('RGBA', 'LA', 'P', 'PA') or os.environ.get('MELTY_NO_SRGB8'):
|
|
300
|
+
im = im.convert('RGBA')
|
|
301
|
+
arr = np.asarray(im, dtype=np.float32) / 255.0
|
|
302
|
+
rgb = srgb_to_linear(arr[..., :3]) * arr[..., 3:4]
|
|
303
|
+
mark('PIL decoded -> linear')
|
|
304
|
+
return Loaded(np.ascontiguousarray(rgb, dtype=np.float32), False, float(rgb.max()) * float(sdr_white))
|
|
305
|
+
srgb8 = np.ascontiguousarray(im.convert('RGB'))
|
|
306
|
+
mark('PIL decoded (sRGB8)')
|
|
307
|
+
peak = srgb_to_linear(np.float32(int(srgb8.max()) / 255.0))
|
|
308
|
+
return Loaded(None, False, float(peak) * float(sdr_white), srgb8=srgb8)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Feature data interfaces and operations."""
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Account model functions and supporting definitions."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import stat
|
|
5
|
+
import threading
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AccountStore(dict):
|
|
9
|
+
"""id -> account dict. `load()` reads the JSON; every mutation goes
|
|
10
|
+
through `set_field` / `add` / `remove` so the file stays in sync and
|
|
11
|
+
the window repaints. Runtime-only keys start with '_' and are never
|
|
12
|
+
written."""
|
|
13
|
+
|
|
14
|
+
def __init__(self):
|
|
15
|
+
super().__init__()
|
|
16
|
+
self._lock = threading.Lock()
|
|
17
|
+
self.loaded = False
|
|
18
|
+
self.error = None
|
|
19
|
+
|
|
20
|
+
def load(self):
|
|
21
|
+
from meltygui.accounts.internet_accounts import ACCOUNTS_PATH
|
|
22
|
+
from meltygui.accounts.internet_accounts import KINDS
|
|
23
|
+
|
|
24
|
+
with self._lock:
|
|
25
|
+
self.clear()
|
|
26
|
+
try:
|
|
27
|
+
if ACCOUNTS_PATH.exists():
|
|
28
|
+
data = json.loads(ACCOUNTS_PATH.read_text())
|
|
29
|
+
for entry in data.get("accounts", []):
|
|
30
|
+
if isinstance(entry, dict) and entry.get("id") and entry.get("kind") in KINDS:
|
|
31
|
+
self[entry["id"]] = entry
|
|
32
|
+
self.error = None
|
|
33
|
+
except Exception as error:
|
|
34
|
+
self.error = f"accounts.json: {error}"
|
|
35
|
+
self.loaded = True
|
|
36
|
+
self.ensure_kinds()
|
|
37
|
+
return self
|
|
38
|
+
|
|
39
|
+
def ensure_kinds(self):
|
|
40
|
+
# default accounts so every kind has a row to act on: the id is the
|
|
41
|
+
# kind name ("anthropic", "copilot", "ollama"); sessions asking for
|
|
42
|
+
# account="default" resolve to it (see `account`).
|
|
43
|
+
from meltygui.accounts.internet_accounts import KINDS
|
|
44
|
+
|
|
45
|
+
for kind in KINDS.values():
|
|
46
|
+
if not any(entry.get("kind") == kind.name for entry in self.values()):
|
|
47
|
+
self.add(kind.name, account_id=kind.name, save=False)
|
|
48
|
+
for entry in self.of_kind(kind.name):
|
|
49
|
+
for field in kind.fields:
|
|
50
|
+
entry.setdefault(field.name, field.default)
|
|
51
|
+
|
|
52
|
+
def save(self):
|
|
53
|
+
from meltygui.accounts.internet_accounts import ACCOUNTS_PATH
|
|
54
|
+
|
|
55
|
+
with self._lock:
|
|
56
|
+
data = {"accounts": [{key: value for key, value in entry.items()
|
|
57
|
+
if not key.startswith("_")}
|
|
58
|
+
for entry in self.values()]}
|
|
59
|
+
try:
|
|
60
|
+
ACCOUNTS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
tmp = ACCOUNTS_PATH.with_suffix(".json.tmp")
|
|
62
|
+
with open(tmp, "w") as file:
|
|
63
|
+
file.write(json.dumps(data, indent=2))
|
|
64
|
+
os.chmod(tmp, stat.S_IRUSR | stat.S_IWUSR)
|
|
65
|
+
os.replace(tmp, ACCOUNTS_PATH)
|
|
66
|
+
self.error = None
|
|
67
|
+
except Exception as error:
|
|
68
|
+
self.error = f"accounts.json: {error}"
|
|
69
|
+
|
|
70
|
+
def add(self, kind_name, account_id=None, save=True, **fields):
|
|
71
|
+
from meltygui.accounts.internet_accounts import KINDS
|
|
72
|
+
from meltygui.accounts.internet_accounts import accounts_changed
|
|
73
|
+
|
|
74
|
+
kind = KINDS[kind_name]
|
|
75
|
+
if account_id is None:
|
|
76
|
+
suffix = 2
|
|
77
|
+
while f"{kind_name}-{suffix}" in self:
|
|
78
|
+
suffix += 1
|
|
79
|
+
account_id = f"{kind_name}-{suffix}"
|
|
80
|
+
entry = {"id": account_id, "kind": kind_name,
|
|
81
|
+
"label": fields.pop("label", None) or kind.default_label(account_id)}
|
|
82
|
+
for field in kind.fields:
|
|
83
|
+
entry[field.name] = fields.get(field.name, field.default)
|
|
84
|
+
self[account_id] = entry
|
|
85
|
+
if save:
|
|
86
|
+
self.save()
|
|
87
|
+
accounts_changed()
|
|
88
|
+
return entry
|
|
89
|
+
|
|
90
|
+
def remove(self, account_id):
|
|
91
|
+
"""Drop an account. Its live sessions go first — computed while it
|
|
92
|
+
is still in the store, so a removed DEFAULT row also drops the
|
|
93
|
+
sessions pooled under "default" (built on its credentials; the next
|
|
94
|
+
row of the kind becomes the default, see `default_account`)."""
|
|
95
|
+
from meltygui.accounts.internet_accounts import KINDS
|
|
96
|
+
from meltygui.accounts.internet_accounts import _drop_sessions_for
|
|
97
|
+
from meltygui.accounts.internet_accounts import accounts_changed
|
|
98
|
+
|
|
99
|
+
entry = self.get(account_id)
|
|
100
|
+
if entry is None:
|
|
101
|
+
return
|
|
102
|
+
KINDS[entry["kind"]].close(entry)
|
|
103
|
+
_drop_sessions_for(entry)
|
|
104
|
+
self.pop(account_id, None)
|
|
105
|
+
self.save()
|
|
106
|
+
accounts_changed()
|
|
107
|
+
|
|
108
|
+
def set_field(self, account_id, field, value, reprobe=True):
|
|
109
|
+
from meltygui.accounts.internet_accounts import _drop_sessions_for
|
|
110
|
+
from meltygui.accounts.internet_accounts import accounts_changed
|
|
111
|
+
|
|
112
|
+
entry = self.get(account_id)
|
|
113
|
+
if entry is None or entry.get(field) == value:
|
|
114
|
+
return
|
|
115
|
+
entry[field] = value
|
|
116
|
+
if reprobe:
|
|
117
|
+
entry["_status"] = None # stale → re-probe
|
|
118
|
+
entry.pop("_validated", None) # credential changed → re-verify with Test
|
|
119
|
+
_drop_sessions_for(entry) # live sessions hold the old credential
|
|
120
|
+
self.save()
|
|
121
|
+
accounts_changed()
|
|
122
|
+
|
|
123
|
+
def of_kind(self, kind_name):
|
|
124
|
+
return sorted((entry for entry in self.values() if entry.get("kind") == kind_name),
|
|
125
|
+
key=lambda entry: (entry["id"] != kind_name, entry["id"]))
|
|
126
|
+
|
|
127
|
+
def default_account(self, kind_name):
|
|
128
|
+
"""The kind's DEFAULT account — what `account(kind, "default")` and
|
|
129
|
+
the providers' `account="default"` resolve to: the entry named
|
|
130
|
+
after its kind, else (that one removed) the first remaining row of
|
|
131
|
+
the kind. Ids never change on promotion, so `account="anthropic-2"`
|
|
132
|
+
references, profile files and panel state all stay put."""
|
|
133
|
+
entry = self.get(kind_name)
|
|
134
|
+
if entry is not None and entry.get("kind") == kind_name:
|
|
135
|
+
return entry
|
|
136
|
+
rows = self.of_kind(kind_name)
|
|
137
|
+
return rows[0] if rows else None
|
|
138
|
+
|
|
139
|
+
def removable(self, account_entry) -> bool:
|
|
140
|
+
"""A row can go while its kind keeps at least one other — the last
|
|
141
|
+
one stays (every kind always has a row to act on)."""
|
|
142
|
+
return len(self.of_kind(account_entry.get("kind"))) > 1
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The draw_voxels orbit camera as math — no GL, no imgui — so the 3D-mouse
|
|
3
|
+
mapping (and the tests) can reason about it offline.
|
|
4
|
+
|
|
5
|
+
The camera is THREE angles about the orbit target `pan`, plus a distance:
|
|
6
|
+
|
|
7
|
+
tilt elevation: orbit over the poles (unrestricted, re-wrapped)
|
|
8
|
+
spin azimuth about the world Z axis
|
|
9
|
+
roll rotation about the view axis (0 = horizon level, the turntable)
|
|
10
|
+
zoom eye distance from the target (cam_zoom)
|
|
11
|
+
|
|
12
|
+
`basis(tilt, spin, roll)` is the ONE definition of the view frame; the GLSL
|
|
13
|
+
raymarcher, the label vertex shader, the CUDA kernel and `_axis_edges`
|
|
14
|
+
mirror it verbatim (roll rotates `right` toward `up` about `fwd`, then `up`
|
|
15
|
+
is re-derived, so roll = 0 is exactly the old two-angle camera).
|
|
16
|
+
|
|
17
|
+
Why angles and not a matrix: the sliders, presets (numpad 7 / 1 / 3), the
|
|
18
|
+
mouse orbit and persistence all speak tilt / spin, and three angles carry
|
|
19
|
+
the full rotation group — nothing is lost. So the 3D mouse never works
|
|
20
|
+
"backwards from a matrix to tilt and spin" (lossy, singular at the poles):
|
|
21
|
+
|
|
22
|
+
- turntable: the puck's axes map straight onto the angles — yaw (ry) is a
|
|
23
|
+
spin increment, pitch (rx) a tilt increment, roll (rz) is ignored. No
|
|
24
|
+
matrix at all; the horizon stays level by construction.
|
|
25
|
+
- trackball: the puck's rotation vector is a small rotation in VIEW space
|
|
26
|
+
applied to the basis, and the new basis is DECOMPOSED back into
|
|
27
|
+
(tilt, spin, roll) — exact, since the decomposition is complete; only
|
|
28
|
+
at the poles (fwd along Z, where spin and roll are the same axis) the
|
|
29
|
+
previous spin is kept and roll absorbs the difference, so nothing jumps.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
import math
|
|
34
|
+
|
|
35
|
+
# Axis order of a space_mouse event's `axes` tuple (utils/space_mouse.py).
|
|
36
|
+
TX, TY, TZ, RX, RY, RZ = range(6)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def basis(tilt, spin, roll=0.0):
|
|
40
|
+
"""World-space (fwd, right, up) of the camera — unit vectors."""
|
|
41
|
+
ct, st = math.cos(tilt), math.sin(tilt)
|
|
42
|
+
cs, ss = math.cos(spin), math.sin(spin)
|
|
43
|
+
fwd = (-cs * ct, -ss * ct, -st)
|
|
44
|
+
right0 = (-ss, cs, 0.0)
|
|
45
|
+
up0 = _cross(right0, fwd)
|
|
46
|
+
cr, sr = math.cos(roll), math.sin(roll)
|
|
47
|
+
right = tuple(a * cr + b * sr for a, b in zip(right0, up0))
|
|
48
|
+
up = _cross(right, fwd)
|
|
49
|
+
return fwd, right, up
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def decompose(fwd, right, tilt_hint=0.0, spin_hint=0.0):
|
|
53
|
+
"""(fwd, right) → (tilt, spin, roll) with basis(...) rebuilding them.
|
|
54
|
+
|
|
55
|
+
`tilt_hint` picks the tilt branch: the stored tilt may sit past a pole
|
|
56
|
+
(cos(tilt) < 0, the mouse orbit's upside-down half) and the SAME
|
|
57
|
+
orientation is then written with the mirrored tilt and roll — keeping
|
|
58
|
+
the branch keeps a trackball nudge from rewriting every angle.
|
|
59
|
+
`spin_hint` is the spin kept at a pole (|cos(tilt)| ~ 0), where spin and
|
|
60
|
+
roll turn about the same axis and only their sum is defined."""
|
|
61
|
+
st = max(-1.0, min(1.0, -fwd[2]))
|
|
62
|
+
tilt = math.asin(st)
|
|
63
|
+
if math.cos(tilt_hint) < 0.0:
|
|
64
|
+
tilt = math.remainder(math.pi - tilt, math.tau)
|
|
65
|
+
ct = math.cos(tilt)
|
|
66
|
+
if abs(ct) > 1e-6:
|
|
67
|
+
sign = 1.0 if ct > 0 else -1.0
|
|
68
|
+
spin = math.atan2(-fwd[1] * sign, -fwd[0] * sign)
|
|
69
|
+
# Keep the previous spin's sign (it runs free past ±pi).
|
|
70
|
+
spin = spin_hint + math.remainder(spin - spin_hint, math.tau)
|
|
71
|
+
else:
|
|
72
|
+
spin = spin_hint
|
|
73
|
+
_, right0, up0 = basis(tilt, spin, 0.0)
|
|
74
|
+
roll = math.atan2(_dot(right, up0), _dot(right, right0))
|
|
75
|
+
return tilt, spin, roll
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def rotate_about(v, axis, angle):
|
|
79
|
+
"""Rodrigues: rotate vector `v` about unit `axis` by `angle` radians."""
|
|
80
|
+
c, s = math.cos(angle), math.sin(angle)
|
|
81
|
+
k = axis
|
|
82
|
+
kv = _cross(k, v)
|
|
83
|
+
kd = _dot(k, v)
|
|
84
|
+
return tuple(v[i] * c + kv[i] * s + k[i] * kd * (1.0 - c) for i in range(3))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def apply_space_mouse(axes, tilt, spin, roll, zoom, pan, *, navigation,
|
|
88
|
+
orbit_sensitivity, pan_sensitivity, zoom_sensitivity,
|
|
89
|
+
pivot="target"):
|
|
90
|
+
"""One frame of 3D-mouse input → the new (tilt, spin, roll, zoom, pan).
|
|
91
|
+
|
|
92
|
+
`pivot` is what a rotation turns about: "target" orbits the camera
|
|
93
|
+
around `pan` (the mouse's orbit — the volume turns on screen), "camera"
|
|
94
|
+
keeps the EYE where it is and turns the view direction — the target
|
|
95
|
+
moves, the scene sweeps across the screen like looking around.
|
|
96
|
+
|
|
97
|
+
`axes` = (tx, ty, tz, rx, ry, rz), each already integrated over the
|
|
98
|
+
frame (full-deflection-seconds), in the reader's view-aligned frame:
|
|
99
|
+
x right, y up, z toward the viewer, in OBJECT terms: the volume moves
|
|
100
|
+
and turns the way the axes say — push right, it goes right; twist, it
|
|
101
|
+
twists. The device's hand (Blender's, space_mouse.BLENDER_SIGNS) is
|
|
102
|
+
applied upstream in space_mouse.normalize, and the mouse trackball feeds
|
|
103
|
+
screen-drag deltas here in the same terms.
|
|
104
|
+
"""
|
|
105
|
+
tx, ty, tz, rx, ry, rz = axes
|
|
106
|
+
fwd, right, up = basis(tilt, spin, roll)
|
|
107
|
+
if pivot == "camera":
|
|
108
|
+
# ── free flight: a RIGID translation of eye and target together, a
|
|
109
|
+
# constant world-units-per-second on all three axes (tz forward
|
|
110
|
+
# along the view). The eye-to-target distance never changes, so
|
|
111
|
+
# nothing scales with zoom - a distance-scaled pan and e-fold dolly
|
|
112
|
+
# decayed to a standstill as flying forward drove the distance to 0.
|
|
113
|
+
k = pan_sensitivity
|
|
114
|
+
pan = tuple(p + (r * tx + u * ty + f * tz) * k
|
|
115
|
+
for p, r, u, f in zip(pan, right, up, fwd))
|
|
116
|
+
else:
|
|
117
|
+
# ── orbit: pan in the screen plane, scaled by the camera distance
|
|
118
|
+
# so it covers the same fraction of the view at any zoom (the mouse
|
|
119
|
+
# pan's rule) ──
|
|
120
|
+
k = pan_sensitivity * zoom
|
|
121
|
+
pan = tuple(p + (r * tx + u * ty) * k for p, r, u in zip(pan, right, up))
|
|
122
|
+
# ── dolly: pull toward you = closer. cam_zoom is a distance, so the
|
|
123
|
+
# e-fold rule keeps the feel constant across scales (like the pan).
|
|
124
|
+
zoom = min(137.6, max(0.0, zoom * math.exp(-tz * zoom_sensitivity)))
|
|
125
|
+
# ── rotation ──
|
|
126
|
+
eye = tuple(p - f * zoom for p, f in zip(pan, fwd))
|
|
127
|
+
if navigation == "trackball":
|
|
128
|
+
# The cap's rotation vector in view space. Rotating the VOLUME by
|
|
129
|
+
# +w is rotating the CAMERA by -w about the target.
|
|
130
|
+
wx, wy, wz = rx * orbit_sensitivity, ry * orbit_sensitivity, rz * orbit_sensitivity
|
|
131
|
+
angle = math.sqrt(wx * wx + wy * wy + wz * wz)
|
|
132
|
+
if angle > 0.0:
|
|
133
|
+
axis = tuple((right[i] * wx + up[i] * wy + fwd[i] * wz) / angle for i in range(3))
|
|
134
|
+
fwd = rotate_about(fwd, axis, -angle)
|
|
135
|
+
right = rotate_about(right, axis, -angle)
|
|
136
|
+
tilt, spin, roll = decompose(fwd, right, tilt, spin)
|
|
137
|
+
else:
|
|
138
|
+
# Turntable: yaw about world up (a rightward twist spins the volume
|
|
139
|
+
# leftward as seen - upside-down the world axis points into the
|
|
140
|
+
# screen, so the sign follows cos(tilt), the mouse orbit's chirality
|
|
141
|
+
# rule, per frame since the keys has no screen edges to latch on).
|
|
142
|
+
spin_sign = -1.0 if math.cos(tilt) < 0.0 else 1.0
|
|
143
|
+
spin -= ry * orbit_sensitivity * spin_sign
|
|
144
|
+
tilt = math.remainder(tilt + rx * orbit_sensitivity, math.tau)
|
|
145
|
+
if pivot == "camera":
|
|
146
|
+
# The eye stays put: re-seat the target along the NEW view direction.
|
|
147
|
+
fwd, _, _ = basis(tilt, spin, roll)
|
|
148
|
+
pan = tuple(e + f * zoom for e, f in zip(eye, fwd))
|
|
149
|
+
return tilt, spin, roll, zoom, pan
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _cross(a, b):
|
|
153
|
+
return (a[1] * b[2] - a[2] * b[1],
|
|
154
|
+
a[2] * b[0] - a[0] * b[2],
|
|
155
|
+
a[0] * b[1] - a[1] * b[0])
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _dot(a, b):
|
|
159
|
+
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|