1326 lines
53 KiB
GDScript
1326 lines
53 KiB
GDScript
@tool
|
||
extends PanelContainer
|
||
class_name CghMainDock
|
||
## The whole CGHEVEN panel: AE-styled top nav + tabs (Browse/Downloads/Favorites)
|
||
## + search + sort + category pills + responsive asset grid + login + settings.
|
||
## Guest browsing allowed (no hard gate), matching the current freemium model.
|
||
|
||
var api: CghApiClient
|
||
var auth: CghAuth
|
||
var downloader: CghDownloader
|
||
var analytics: CghAnalytics
|
||
var updater: CghUpdater
|
||
|
||
var _grid: GridContainer
|
||
var _page_bar: HBoxContainer
|
||
var _page_count := 1
|
||
var _scroll: ScrollContainer
|
||
var _search: LineEdit
|
||
var _sort: OptionButton
|
||
var _free: OptionButton
|
||
var _filter_mode := 0 # 0 = all, 1 = free only, 2 = downloaded only
|
||
var _manual_check := false
|
||
var _banner: PanelContainer
|
||
var _banner_dismissed := false
|
||
var _status: Label
|
||
var _login_btn: Button
|
||
var _pill_row: HBoxContainer
|
||
var _thumb_http: Array[HTTPRequest] = []
|
||
var _settings: CghSettingsModal
|
||
var _login_dlg: CghLoginDialog
|
||
|
||
# Top toast: a prominent colored bar for success/failure feedback (download + import),
|
||
# so the user always sees WHAT happened and WHY — not just the small status line.
|
||
var _toast: PanelContainer
|
||
var _toast_icon: Label
|
||
var _toast_msg: Label
|
||
var _toast_timer: Timer
|
||
|
||
var _subcat_dd: MenuButton
|
||
var _subcat_items: Array = [] # popup id -> subcategory slug ("" = All)
|
||
var _logo: TextureRect
|
||
|
||
var _page := 1
|
||
var _page_size := 25
|
||
var _category := ""
|
||
var _subcat := ""
|
||
var _subcats_by_parent := {} # parent slug -> [{label, slug}] from the live category API
|
||
var _search_text := ""
|
||
var _pending_search := "" # last text typed; applied after a short debounce (live search)
|
||
var _search_debounce: Timer # fires the search ~0.3s after the user stops typing
|
||
var _last_total := 0 # total matching assets from the last server response (for the count line)
|
||
var _sort_param := "createdAt:desc"
|
||
var _loading := false
|
||
var _thumb_ctx := {} # HTTPRequest -> {card, url} | null when that slot is free
|
||
var _thumb_queue := [] # pending [{card, url}] (loads 4 at a time)
|
||
var _thumb_fail := 0 # consecutive thumbnail download failures (offline detection)
|
||
|
||
# download queue (single downloader, serial)
|
||
var _dl_queue := [] # [{card, asset, entry}]
|
||
var _dl_active = null
|
||
var _downloaded := [] # session history [{asset, path, msg}]
|
||
|
||
func _init() -> void:
|
||
add_theme_stylebox_override("panel", CghTheme._box(CghConfig.C_BG, 0))
|
||
|
||
func boot() -> void:
|
||
CghConfig.ensure_dirs()
|
||
CghDownloads.rescan() # cross-addon file scanner -> shared ✓ ticks
|
||
_build_ui()
|
||
_build_modals()
|
||
# wire services
|
||
api.assets_loaded.connect(_on_assets_loaded)
|
||
api.categories_loaded.connect(_on_categories_loaded)
|
||
api.request_failed.connect(func(m): _set_status(m, true); _loading = false)
|
||
auth.logged_in.connect(_on_logged_in)
|
||
auth.login_failed.connect(func(m): _set_status(m, true))
|
||
auth.plan_refreshed.connect(_on_plan_refreshed)
|
||
downloader.progress.connect(_on_dl_progress)
|
||
downloader.finished.connect(_on_dl_finished)
|
||
downloader.failed.connect(_on_dl_failed)
|
||
updater.update_available.connect(_on_update_available)
|
||
updater.up_to_date.connect(_on_up_to_date)
|
||
updater.update_ready_to_restart.connect(_on_update_staged)
|
||
# Update failures are silent: the backend may not know the `godot` slug yet,
|
||
# and a red "HTTP 404" must never greet a normal user. Only surface on a
|
||
# manual "Check for Updates" from Settings.
|
||
updater.update_failed.connect(_on_update_failed)
|
||
# thumbnail pool (8 concurrent, with a real queue so all cards load fast)
|
||
for i in 8:
|
||
var h := HTTPRequest.new()
|
||
h.timeout = 20.0 # free a stuck slot instead of blocking all thumbnails forever
|
||
add_child(h)
|
||
h.request_completed.connect(_on_thumb.bind(h))
|
||
_thumb_http.append(h)
|
||
_thumb_ctx[h] = null
|
||
_load_logo()
|
||
# Seed the API client with the persisted session token BEFORE the first fetch.
|
||
# A returning (already logged-in) user restores their token from EditorSettings in
|
||
# auth._load_session(), but api._session_token stays "" until a fresh login. Without
|
||
# this line the first fetch hits the PUBLIC (guest) endpoint, so the backend locks
|
||
# every file (CGHLOCKED::) and the whole grid shows "Upgrade" — even for a paying
|
||
# Studio user whose Account popup correctly reads "Studio" from auth.plan.
|
||
api.set_session_token(auth.session_token)
|
||
# Live plan refresh: heartbeat now + every 5 min re-resolves the user's plan on the
|
||
# server, so a Free->Pro/Studio upgrade (or an expiry) reflects in the grid without a
|
||
# restart or re-login. auth.heartbeat() no-ops when logged out and emits plan_refreshed
|
||
# ONLY when the plan actually changed -> _on_plan_refreshed re-seeds the token,
|
||
# re-fetches the grid, and updates the Account UI.
|
||
auth.heartbeat()
|
||
var hb := Timer.new()
|
||
hb.wait_time = 300.0
|
||
hb.one_shot = false
|
||
hb.timeout.connect(auth.heartbeat)
|
||
add_child(hb)
|
||
hb.start()
|
||
# Load the live category tree so the subcategory dropdown matches the backend
|
||
# (new subcategories appear automatically), like the Blender addon.
|
||
api.fetch_categories()
|
||
_refresh_account_ui()
|
||
analytics.identify(auth.email, auth.plan)
|
||
# addon_installed fires once ever (first run on this machine), like the desktop addons.
|
||
if not bool(CghConfig._read_prefs().get("installed_tracked", false)):
|
||
analytics.track("addon_installed")
|
||
var pr := CghConfig._read_prefs()
|
||
pr["installed_tracked"] = true
|
||
CghConfig._write_prefs(pr)
|
||
analytics.track("session_started")
|
||
analytics.track("addon_opened")
|
||
updater.check()
|
||
# Defer the first load: if it's a cache HIT it renders synchronously, and the
|
||
# thumbnail HTTPRequest pool must be fully inside the tree first (else request()
|
||
# fails to start and the thumbnail is silently dropped -> blank cards on boot).
|
||
call_deferred("_load_first_page")
|
||
|
||
# ----------------------------------------------------------------- UI build
|
||
func _build_ui() -> void:
|
||
# Tight top area so the asset grid gets the most vertical space possible.
|
||
var root := VBoxContainer.new()
|
||
root.add_theme_constant_override("separation", 4)
|
||
add_child(root)
|
||
root.add_child(_build_toast()) # prominent success/failure bar at the very top
|
||
root.add_child(_build_topnav())
|
||
root.add_child(_build_banner())
|
||
root.add_child(_build_searchrow())
|
||
root.add_child(_build_pills()) # category pills + inline subcategory dropdown
|
||
|
||
# Status line takes no space unless there's actually a message. It MUST wrap +
|
||
# clip — a long one-liner (e.g. an import path) would otherwise grow the dock's
|
||
# minimum width and push the asset grid off the right edge.
|
||
_status = Label.new()
|
||
_status.add_theme_color_override("font_color", CghConfig.C_TEXT2)
|
||
_status.add_theme_font_size_override("font_size", 11)
|
||
_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
_status.custom_minimum_size = Vector2(0, 0)
|
||
_status.size_flags_horizontal = Control.SIZE_FILL
|
||
_status.clip_text = true
|
||
_status.visible = false
|
||
root.add_child(_status)
|
||
|
||
_scroll = ScrollContainer.new()
|
||
_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||
_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
root.add_child(_scroll)
|
||
|
||
# grid + page numbers live INSIDE the scroll; the page bar sits at the bottom
|
||
# after the cards (1 2 3 … instead of an infinite "Load More").
|
||
var col := VBoxContainer.new()
|
||
col.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
col.add_theme_constant_override("separation", 8)
|
||
_scroll.add_child(col)
|
||
|
||
_grid = GridContainer.new()
|
||
_grid.columns = 2
|
||
_grid.add_theme_constant_override("h_separation", CghConfig.GRID_GAP)
|
||
_grid.add_theme_constant_override("v_separation", CghConfig.GRID_GAP)
|
||
_grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
col.add_child(_grid)
|
||
|
||
_page_bar = HBoxContainer.new()
|
||
_page_bar.alignment = BoxContainer.ALIGNMENT_CENTER
|
||
_page_bar.add_theme_constant_override("separation", 3)
|
||
_page_bar.visible = false
|
||
col.add_child(_page_bar)
|
||
resized.connect(_reflow_columns)
|
||
|
||
func _build_topnav() -> HBoxContainer:
|
||
var nav := HBoxContainer.new()
|
||
nav.add_theme_constant_override("separation", 6)
|
||
# Brand logo (loaded from the CDN, cached) — sits just before the CGHEVEN word
|
||
# so the panel reads like a real product header.
|
||
_logo = TextureRect.new()
|
||
_logo.custom_minimum_size = Vector2(22, 22)
|
||
_logo.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||
_logo.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||
nav.add_child(_logo)
|
||
var brand := Label.new()
|
||
brand.text = "CGHEVEN"
|
||
brand.add_theme_color_override("font_color", CghConfig.C_TEXT1)
|
||
brand.add_theme_font_size_override("font_size", 15)
|
||
nav.add_child(brand)
|
||
var spacer := Control.new()
|
||
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
nav.add_child(spacer)
|
||
# (Plan label removed from the top nav — the plan is shown in the Account popup.)
|
||
_login_btn = Button.new()
|
||
_login_btn.pressed.connect(_on_login_logout)
|
||
nav.add_child(_login_btn)
|
||
var sync := Button.new()
|
||
sync.text = "⤓"
|
||
sync.tooltip_text = "Sync downloaded assets (scan all CGHEVEN addons)"
|
||
sync.pressed.connect(_sync_downloads)
|
||
nav.add_child(sync)
|
||
var gear := Button.new()
|
||
gear.text = "⚙"
|
||
gear.tooltip_text = "Settings"
|
||
gear.pressed.connect(func(): _settings.open_centered())
|
||
nav.add_child(gear)
|
||
var refresh := Button.new()
|
||
refresh.text = "↺"
|
||
refresh.tooltip_text = "Refresh (re-fetch from server)"
|
||
refresh.pressed.connect(_hard_refresh)
|
||
nav.add_child(refresh)
|
||
return nav
|
||
|
||
# Prominent top toast (auto-hides). Green ✓ = success, red ✗ = failure, accent ℹ = info.
|
||
# Lives at the top of the panel so download/import outcomes are impossible to miss.
|
||
func _build_toast() -> PanelContainer:
|
||
_toast = PanelContainer.new()
|
||
_toast.visible = false
|
||
var row := HBoxContainer.new()
|
||
row.add_theme_constant_override("separation", 8)
|
||
_toast.add_child(row)
|
||
_toast_icon = Label.new()
|
||
_toast_icon.add_theme_font_size_override("font_size", 14)
|
||
_toast_icon.add_theme_color_override("font_color", Color.WHITE)
|
||
_toast_icon.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||
row.add_child(_toast_icon)
|
||
_toast_msg = Label.new()
|
||
_toast_msg.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
_toast_msg.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
_toast_msg.add_theme_font_size_override("font_size", 11)
|
||
_toast_msg.add_theme_color_override("font_color", Color.WHITE)
|
||
row.add_child(_toast_msg)
|
||
var x := Button.new()
|
||
x.flat = true
|
||
x.text = "✕"
|
||
x.add_theme_color_override("font_color", Color.WHITE)
|
||
x.add_theme_color_override("font_hover_color", Color.WHITE)
|
||
x.pressed.connect(_hide_toast)
|
||
x.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||
row.add_child(x)
|
||
_toast_timer = Timer.new()
|
||
_toast_timer.one_shot = true
|
||
_toast_timer.timeout.connect(_hide_toast)
|
||
_toast.add_child(_toast_timer)
|
||
return _toast
|
||
|
||
func _show_toast(msg: String, kind := "info") -> void:
|
||
if _toast == null or msg == "":
|
||
return
|
||
var col := CghConfig.C_ACCENT
|
||
var icon := "ℹ"
|
||
if kind == "success":
|
||
col = Color(0.16, 0.62, 0.34); icon = "✓"
|
||
elif kind == "error":
|
||
col = CghConfig.C_ERR; icon = "✗"
|
||
var sb := StyleBoxFlat.new()
|
||
sb.bg_color = col
|
||
sb.set_corner_radius_all(4)
|
||
sb.set_content_margin_all(8.0)
|
||
_toast.add_theme_stylebox_override("panel", sb)
|
||
_toast_icon.text = icon
|
||
_toast_msg.text = msg
|
||
_toast.visible = true
|
||
if _toast_timer:
|
||
_toast_timer.stop()
|
||
_toast_timer.wait_time = 7.0 if kind == "error" else 3.5
|
||
_toast_timer.start()
|
||
|
||
func _hide_toast() -> void:
|
||
if _toast:
|
||
_toast.visible = false
|
||
if _toast_timer:
|
||
_toast_timer.stop()
|
||
|
||
# Set the small status line AND flash the top toast for a terminal outcome, and log a
|
||
# warning when it failed — so every download/import result is visible + traceable.
|
||
func _report(msg: String, ok: bool) -> void:
|
||
_set_status(msg, not ok)
|
||
_show_toast(msg, "success" if ok else "error")
|
||
if not ok:
|
||
push_warning("CGHEVEN: " + msg)
|
||
|
||
# Admin-controlled announcement banner (server meta.banner) — like AE/Blender.
|
||
func _build_banner() -> PanelContainer:
|
||
_banner = PanelContainer.new()
|
||
_banner.add_theme_stylebox_override("panel", CghTheme.banner_box())
|
||
_banner.visible = false
|
||
return _banner
|
||
|
||
func _render_banner(meta: Dictionary) -> void:
|
||
if _banner == null:
|
||
return
|
||
for c in _banner.get_children():
|
||
_banner.remove_child(c)
|
||
c.queue_free()
|
||
var b = meta.get("banner", {})
|
||
if not (b is Dictionary) or not b.get("enabled", false) or _banner_dismissed:
|
||
_banner.visible = false
|
||
return
|
||
var title := str(b.get("title", ""))
|
||
var message := str(b.get("message", ""))
|
||
if title == "" and message == "":
|
||
_banner.visible = false
|
||
return
|
||
var row := HBoxContainer.new()
|
||
row.add_theme_constant_override("separation", 8)
|
||
_banner.add_child(row)
|
||
var texts := VBoxContainer.new()
|
||
texts.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
texts.add_theme_constant_override("separation", 1)
|
||
row.add_child(texts)
|
||
if title != "":
|
||
var tl := Label.new()
|
||
tl.text = title
|
||
tl.add_theme_color_override("font_color", Color.WHITE)
|
||
tl.add_theme_font_size_override("font_size", 12)
|
||
texts.add_child(tl)
|
||
if message != "":
|
||
var ml := Label.new()
|
||
ml.text = message
|
||
ml.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
ml.add_theme_color_override("font_color", Color(1, 1, 1, 0.9))
|
||
ml.add_theme_font_size_override("font_size", 11)
|
||
texts.add_child(ml)
|
||
var btn = b.get("button", {})
|
||
if btn is Dictionary and str(btn.get("url", "")) != "":
|
||
var ab := Button.new()
|
||
ab.text = str(btn.get("label", "Learn more"))
|
||
ab.add_theme_color_override("font_color", Color.WHITE)
|
||
var url := str(btn.get("url", ""))
|
||
ab.pressed.connect(func(): OS.shell_open(url))
|
||
row.add_child(ab)
|
||
var x := Button.new()
|
||
x.flat = true
|
||
x.text = "✕"
|
||
x.add_theme_color_override("font_color", Color.WHITE)
|
||
x.pressed.connect(func(): _banner_dismissed = true; _banner.visible = false)
|
||
row.add_child(x)
|
||
_banner.visible = true
|
||
|
||
func _build_searchrow() -> HBoxContainer:
|
||
var row := HBoxContainer.new()
|
||
row.add_theme_constant_override("separation", 6)
|
||
_search = LineEdit.new()
|
||
_search.placeholder_text = "Search assets…"
|
||
_search.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
_search.clear_button_enabled = true # built-in ✕ to clear the query instantly
|
||
# Live search: fire ~0.3s after the user stops typing — super fast, no Enter needed.
|
||
_search_debounce = Timer.new()
|
||
_search_debounce.one_shot = true
|
||
_search_debounce.wait_time = 0.3
|
||
_search_debounce.timeout.connect(_apply_search)
|
||
add_child(_search_debounce)
|
||
_search.text_changed.connect(func(t):
|
||
_pending_search = t
|
||
_search_debounce.start()) # restart the countdown on every keystroke
|
||
# Enter applies immediately (skips the debounce wait).
|
||
_search.text_submitted.connect(func(t):
|
||
_pending_search = t
|
||
_search_debounce.stop()
|
||
_apply_search())
|
||
row.add_child(_search)
|
||
_sort = OptionButton.new()
|
||
for s in CghConfig.SORTS:
|
||
_sort.add_item(s["label"])
|
||
_sort.item_selected.connect(func(i):
|
||
_sort_param = CghConfig.SORTS[i]["param"]
|
||
_load_first_page())
|
||
row.add_child(_sort)
|
||
# Unified filter: All / Free / Downloaded / Favorites (replaces the old tab bar)
|
||
_free = OptionButton.new()
|
||
_free.add_item("All assets") # 0
|
||
_free.add_item("✦ Free only") # 1
|
||
_free.add_item("⬇ Downloaded") # 2
|
||
_free.add_item("♥ Favorites") # 3
|
||
_free.item_selected.connect(func(i):
|
||
_filter_mode = i
|
||
# Matches the AE/Premiere "free_filter_toggled" event (on = Free-only mode).
|
||
analytics.track("free_filter_toggled", {"on": i == 1, "category": _category, "sub_category": _subcat})
|
||
if i == 3:
|
||
_render_favorites()
|
||
else:
|
||
_load_first_page())
|
||
row.add_child(_free)
|
||
_update_fav_badge()
|
||
return row
|
||
|
||
func _build_pills() -> HBoxContainer:
|
||
_pill_row = HBoxContainer.new()
|
||
_pill_row.add_theme_constant_override("separation", 4)
|
||
for cat in CghConfig.CATEGORIES:
|
||
var p := Button.new()
|
||
p.text = cat["label"]
|
||
p.toggle_mode = true
|
||
p.button_pressed = (cat["slug"] == "")
|
||
p.add_theme_font_size_override("font_size", 13)
|
||
p.pressed.connect(_on_pill.bind(cat["slug"], p))
|
||
_pill_row.add_child(p)
|
||
# Inline subcategory dropdown (right side) — folded into this row to save a
|
||
# whole row of vertical space. Only visible when the category has subcategories.
|
||
var spacer := Control.new()
|
||
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
_pill_row.add_child(spacer)
|
||
# Compact subcategory filter: a small icon button (fixed width) that opens a popup,
|
||
# instead of a wide dropdown whose longest label (e.g. "Muzzle Flashes") stretched
|
||
# the whole dock. Hidden for categories with no subcategories.
|
||
_subcat_dd = MenuButton.new()
|
||
_subcat_dd.text = "▾"
|
||
_subcat_dd.tooltip_text = "Filter by subcategory"
|
||
_subcat_dd.custom_minimum_size = Vector2(30, 0)
|
||
_subcat_dd.flat = false
|
||
_subcat_dd.add_theme_font_size_override("font_size", 13)
|
||
_subcat_dd.get_popup().add_theme_stylebox_override("panel", CghTheme.dropdown_box())
|
||
_subcat_dd.get_popup().id_pressed.connect(_on_subcat_selected)
|
||
_pill_row.add_child(_subcat_dd)
|
||
_populate_subcats("")
|
||
return _pill_row
|
||
|
||
func _on_pill(slug: String, btn: Button) -> void:
|
||
for c in _pill_row.get_children():
|
||
if c is Button:
|
||
c.button_pressed = (c == btn)
|
||
_category = slug
|
||
_subcat = ""
|
||
_populate_subcats(slug)
|
||
analytics.track("category_clicked", {"category": slug})
|
||
# leaving Favorites view? switch the filter back to a browse mode
|
||
if _filter_mode == 3:
|
||
_filter_mode = 0
|
||
if _free:
|
||
_free.select(0)
|
||
_load_first_page()
|
||
|
||
func _populate_subcats(slug: String) -> void:
|
||
if not _subcat_dd:
|
||
return
|
||
var subs = _subcats_by_parent.get(slug, CghConfig.SUBCATEGORIES.get(slug, []))
|
||
_subcat_dd.visible = subs.size() > 0 # only show when this category has subcategories
|
||
var pm := _subcat_dd.get_popup()
|
||
pm.clear()
|
||
_subcat_items = [""] # popup id 0 = "All" (no subcategory filter)
|
||
pm.add_radio_check_item("All", 0)
|
||
var idx := 1
|
||
for s in subs:
|
||
pm.add_radio_check_item(s["label"], idx)
|
||
_subcat_items.append(s["slug"])
|
||
idx += 1
|
||
# tick the active subcategory so the current filter shows when the popup opens
|
||
for i in pm.item_count:
|
||
pm.set_item_checked(i, i < _subcat_items.size() and _subcat_items[i] == _subcat)
|
||
|
||
# Build the subcategory dropdown from the LIVE category list (Blender-style) so new
|
||
# backend subcategories appear automatically. Non-fatal: until this arrives (or if it
|
||
# fails) the hardcoded CghConfig.SUBCATEGORIES stays as the fallback.
|
||
func _on_categories_loaded(cats: Array) -> void:
|
||
_subcats_by_parent.clear()
|
||
var usable := {}
|
||
for c in CghConfig.CATEGORIES:
|
||
if c["slug"] != "":
|
||
usable[c["slug"]] = true
|
||
var seen := {}
|
||
for item in cats:
|
||
if not (item is Dictionary):
|
||
continue
|
||
var a = item.get("attributes", item)
|
||
if not (a is Dictionary):
|
||
a = item
|
||
var s := str(a.get("Slug", a.get("slug", ""))).strip_edges().to_lower()
|
||
if s == "" or usable.has(s):
|
||
continue # blank, or itself a top category
|
||
var parent := _parent_of(s)
|
||
if not usable.has(parent):
|
||
continue # not under a Godot-usable category
|
||
var key := parent + "/" + s
|
||
if seen.has(key):
|
||
continue
|
||
seen[key] = true
|
||
var nm := str(a.get("Name", a.get("name", a.get("Title", s))))
|
||
if not _subcats_by_parent.has(parent):
|
||
_subcats_by_parent[parent] = []
|
||
_subcats_by_parent[parent].append({"label": nm, "slug": s})
|
||
if _subcat_dd:
|
||
_populate_subcats(_category) # refresh with the live data
|
||
|
||
# Resolve a category slug's parent: our SUBCATEGORIES map first, then a suffix heuristic
|
||
# so brand-new subcategories still nest under the right tab.
|
||
func _parent_of(slug: String) -> String:
|
||
for parent in CghConfig.SUBCATEGORIES.keys():
|
||
for s in CghConfig.SUBCATEGORIES[parent]:
|
||
if str(s["slug"]).to_lower() == slug:
|
||
return parent
|
||
if slug.ends_with("-flipbooks"):
|
||
return "flipbooks"
|
||
if slug.ends_with("-hdr") or slug.ends_with("-hdri"):
|
||
return "hdri"
|
||
return ""
|
||
|
||
func _on_subcat_selected(id: int) -> void:
|
||
_subcat = str(_subcat_items[id]) if id >= 0 and id < _subcat_items.size() else ""
|
||
var pm := _subcat_dd.get_popup()
|
||
for i in pm.item_count:
|
||
pm.set_item_checked(i, i == id)
|
||
analytics.track("category_clicked", {"category": _category, "sub_category": _subcat})
|
||
_load_first_page()
|
||
|
||
func _build_modals() -> void:
|
||
_settings = CghSettingsModal.new()
|
||
_settings.auth = auth
|
||
add_child(_settings)
|
||
_settings.build()
|
||
_settings.logout_requested.connect(_do_logout)
|
||
_settings.check_updates_requested.connect(func(): _manual_check = true; updater.check())
|
||
_settings.download_dir_changed.connect(func():
|
||
CghDownloads.rescan()
|
||
_set_status("Download folder updated.", false))
|
||
|
||
_login_dlg = CghLoginDialog.new()
|
||
_login_dlg.auth = auth
|
||
add_child(_login_dlg)
|
||
_login_dlg.build()
|
||
# Account details (email/plan) now live in this dialog's profile view, with the
|
||
# Logout button — Settings no longer carries account info.
|
||
_login_dlg.logout_requested.connect(_do_logout)
|
||
|
||
# ----------------------------------------------------------------- data
|
||
# Apply the pending (typed) query — only refetches if it actually changed, so caret
|
||
# moves / no-op keystrokes never spam the server.
|
||
func _apply_search() -> void:
|
||
var q := _pending_search.strip_edges()
|
||
if q == _search_text:
|
||
return
|
||
_search_text = q
|
||
if q != "":
|
||
analytics.track("search_performed", {"query": q})
|
||
_load_first_page()
|
||
|
||
func _load_first_page(force := false) -> void:
|
||
_page = 1
|
||
_page_count = 1 # reset so a stale multi-page bar never lingers over the new results
|
||
_thumb_fail = 0
|
||
_clear_grid()
|
||
_fetch(force)
|
||
|
||
# Manual ↺ — force a fresh server fetch (ignores cache, but keeps it if the network
|
||
# fails so the old page still works offline).
|
||
func _hard_refresh() -> void:
|
||
_load_first_page(true)
|
||
|
||
# ⤓ Sync — re-scan the shared cross-addon download manifest/folders, then rebuild
|
||
# the grid so the ✓/downloaded state reflects every CGHEVEN addon.
|
||
func _sync_downloads() -> void:
|
||
CghDownloads.rescan()
|
||
_load_first_page()
|
||
var recs := CghDownloads.synced_records()
|
||
_set_status("Synced %d downloaded asset%s across CGHEVEN addons." % [
|
||
recs.size(), "" if recs.size() == 1 else "s"], false)
|
||
_show_sync_dialog(recs)
|
||
|
||
# Popup listing every asset that's downloaded in ANY CGHEVEN addon (cross-addon sync).
|
||
func _show_sync_dialog(recs: Array) -> void:
|
||
var dlg := AcceptDialog.new()
|
||
dlg.title = "CGHEVEN — Synced Downloads"
|
||
dlg.min_size = Vector2i(380, 0)
|
||
dlg.wrap_controls = false # fixed ~30% window; the list scrolls inside
|
||
var margin := MarginContainer.new()
|
||
for s in ["margin_left", "margin_right", "margin_top", "margin_bottom"]:
|
||
margin.add_theme_constant_override(s, 10)
|
||
dlg.add_child(margin)
|
||
var v := VBoxContainer.new()
|
||
v.add_theme_constant_override("separation", 4)
|
||
v.custom_minimum_size = Vector2(360, 0)
|
||
v.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
margin.add_child(v)
|
||
if recs.is_empty():
|
||
var none := Label.new()
|
||
none.text = "No downloaded assets found yet.\nDownload something here or in any other CGHEVEN addon."
|
||
none.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
none.add_theme_color_override("font_color", CghConfig.C_TEXT2)
|
||
none.add_theme_font_size_override("font_size", 11)
|
||
v.add_child(none)
|
||
else:
|
||
var head := Label.new()
|
||
head.text = "%d asset%s downloaded (shared across all CGHEVEN addons):" % [
|
||
recs.size(), "" if recs.size() == 1 else "s"]
|
||
head.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
head.add_theme_color_override("font_color", CghConfig.C_ACCENT)
|
||
head.add_theme_font_size_override("font_size", 11)
|
||
v.add_child(head)
|
||
var scroll := ScrollContainer.new()
|
||
scroll.custom_minimum_size = Vector2(0, min(280, 8 + recs.size() * 20))
|
||
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||
v.add_child(scroll)
|
||
var list := VBoxContainer.new()
|
||
list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
list.add_theme_constant_override("separation", 2)
|
||
scroll.add_child(list)
|
||
for r in recs:
|
||
var row := Label.new()
|
||
var res_arr: Array = r.get("res", [])
|
||
var res_txt: String = (" [" + ", ".join(res_arr) + "]") if not res_arr.is_empty() else ""
|
||
row.text = "• %s%s" % [str(r.get("title", "Untitled")), res_txt]
|
||
row.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||
row.clip_text = true
|
||
row.add_theme_color_override("font_color", CghConfig.C_TEXT1)
|
||
row.add_theme_font_size_override("font_size", 11)
|
||
list.add_child(row)
|
||
add_child(dlg)
|
||
dlg.confirmed.connect(func(): dlg.queue_free())
|
||
dlg.canceled.connect(func(): dlg.queue_free())
|
||
# Professional size: ~30% of the editor window, centered — the list scrolls inside
|
||
# instead of the dialog stretching to full height.
|
||
var win := DisplayServer.window_get_size()
|
||
dlg.popup_centered(Vector2i(maxi(380, int(win.x * 0.3)), maxi(320, int(win.y * 0.42))))
|
||
|
||
func _goto_page(p: int) -> void:
|
||
if p == _page or _loading or _filter_mode == 3:
|
||
return
|
||
_page = p
|
||
_clear_grid()
|
||
if _scroll:
|
||
_scroll.scroll_vertical = 0
|
||
_fetch()
|
||
|
||
# Cache key per (plan is handled by the cache) category/subcat/page/sort/search.
|
||
func _cache_key() -> String:
|
||
return "%s|%s|%d|%s|%s" % [_category, _subcat, _page, _sort_param, _search_text]
|
||
|
||
# Cache-FIRST: a page seen once renders instantly from disk and never re-fetches
|
||
# (unless `force`, e.g. the manual Refresh, which always pulls from the server).
|
||
func _fetch(force := false) -> void:
|
||
if not force:
|
||
var page := CghCache.load_page(auth.plan, _cache_key())
|
||
var cached_assets = page.get("assets", [])
|
||
if cached_assets is Array and not cached_assets.is_empty():
|
||
_loading = false
|
||
_render_assets(cached_assets, page.get("meta", {}))
|
||
return
|
||
_loading = true
|
||
_set_status(("Searching “%s”…" % _search_text.strip_edges()) if _search_text.strip_edges() != "" else "Loading…", false)
|
||
# Pass category + subcategory separately: a subcategory filters the `subcategories`
|
||
# relation, a category filters `categorie` (different Strapi filters).
|
||
api.fetch_assets(_page, _page_size, _category, _search_text, _sort_param, _subcat)
|
||
|
||
func _on_assets_loaded(assets: Array, meta: Dictionary) -> void:
|
||
_loading = false
|
||
if _filter_mode == 3: # Favorites view doesn't use fetched results
|
||
return
|
||
CghCache.save_assets(auth.plan, _cache_key(), assets, meta)
|
||
_render_assets(assets, meta)
|
||
|
||
func _render_assets(assets: Array, meta: Dictionary) -> void:
|
||
if _filter_mode == 3:
|
||
return
|
||
for a in assets:
|
||
if _filter_mode == 1 and not CghAsset.has_free_file(a):
|
||
continue
|
||
if _filter_mode == 2 and not CghDownloads.is_downloaded(a):
|
||
continue
|
||
_add_card(a)
|
||
var pg = meta.get("pagination", {})
|
||
_page_count = int(pg.get("pageCount", 1)) if pg is Dictionary else 1
|
||
_last_total = int(pg.get("total", _grid.get_child_count())) if pg is Dictionary else _grid.get_child_count()
|
||
var q := _search_text.strip_edges()
|
||
var shown := _grid.get_child_count()
|
||
if shown == 0:
|
||
var empty := "No results found."
|
||
if q != "":
|
||
empty = "No results for “%s”." % q
|
||
elif _filter_mode == 1: empty = "No free assets on this page."
|
||
elif _filter_mode == 2: empty = "No downloaded assets on this page."
|
||
_set_status(empty, false)
|
||
else:
|
||
# Count line so the user always knows what happened (e.g. "12 results for “fire”").
|
||
var cnt := _last_total if _filter_mode == 0 else shown
|
||
var suffix := "" if cnt == 1 else "s"
|
||
if q != "":
|
||
_set_status("%d result%s for “%s”" % [cnt, suffix, q], false)
|
||
elif _page_count > 1:
|
||
_set_status("%d asset%s • page %d of %d" % [cnt, suffix, _page, _page_count], false)
|
||
else:
|
||
_set_status("%d asset%s" % [cnt, suffix], false)
|
||
_render_banner(meta)
|
||
_render_page_bar()
|
||
|
||
# Numbered pagination: 1 2 3 … last (windowed around the current page).
|
||
func _render_page_bar() -> void:
|
||
if _page_bar == null:
|
||
return
|
||
for c in _page_bar.get_children():
|
||
_page_bar.remove_child(c)
|
||
c.queue_free()
|
||
# Only show pages when there are actually cards below them — never float a "1 2 3 …"
|
||
# bar over an empty/short result (this is what made pages seem to sit "at the top").
|
||
if _filter_mode == 3 or _page_count <= 1 or _grid.get_child_count() == 0:
|
||
_page_bar.visible = false
|
||
return
|
||
_page_bar.visible = true
|
||
for p in _page_list(_page, _page_count):
|
||
if p == -1:
|
||
var dots := Label.new()
|
||
dots.text = "…"
|
||
dots.add_theme_color_override("font_color", CghConfig.C_TEXT3)
|
||
_page_bar.add_child(dots)
|
||
else:
|
||
var b := Button.new()
|
||
b.text = str(p)
|
||
b.toggle_mode = true
|
||
b.button_pressed = (p == _page)
|
||
b.custom_minimum_size = Vector2(30, 26)
|
||
b.pressed.connect(_goto_page.bind(p))
|
||
_page_bar.add_child(b)
|
||
|
||
func _page_list(cur: int, total: int) -> Array:
|
||
var out := []
|
||
if total <= 7:
|
||
for i in range(1, total + 1):
|
||
out.append(i)
|
||
return out
|
||
out.append(1)
|
||
var lo: int = max(2, cur - 1)
|
||
var hi: int = min(total - 1, cur + 1)
|
||
if lo > 2:
|
||
out.append(-1)
|
||
for i in range(lo, hi + 1):
|
||
out.append(i)
|
||
if hi < total - 1:
|
||
out.append(-1)
|
||
out.append(total)
|
||
return out
|
||
|
||
func _add_card(asset: Dictionary) -> CghAssetCard:
|
||
var card := CghAssetCard.new()
|
||
card.auth = auth # favourites need login
|
||
_grid.add_child(card)
|
||
card.setup(asset)
|
||
card.login_required.connect(_on_fav_login_required)
|
||
card.request_download.connect(_on_card_download)
|
||
card.request_upgrade.connect(_on_card_upgrade)
|
||
card.favorite_toggled.connect(_on_favorite_toggled)
|
||
card.preview_requested.connect(_on_preview_requested)
|
||
card.flipbook_sheet_requested.connect(_on_flipbook_sheet_requested)
|
||
card.cancel_requested.connect(_on_card_cancel)
|
||
card.delete_requested.connect(_on_card_delete)
|
||
card.viewed.connect(func(a): analytics.track("asset_viewed", _asset_props(a)))
|
||
var url := card.get_thumb_url()
|
||
if url != "":
|
||
_load_thumb(url, card)
|
||
return card
|
||
|
||
# Brand logo: cache-first, then a one-off HTTPRequest. Failure is silent (the
|
||
# CGHEVEN word still shows), so a CDN hiccup never leaves a broken-image box.
|
||
func _load_logo() -> void:
|
||
if _logo == null:
|
||
return
|
||
var url := "https://api.cgheven.com/uploads/logo_a88f3d6d46.png"
|
||
var cached := CghCache.load_thumb(url)
|
||
if cached:
|
||
_logo.texture = cached
|
||
return
|
||
var h := HTTPRequest.new()
|
||
h.timeout = 20.0
|
||
add_child(h)
|
||
h.request_completed.connect(func(r, code, _hh, body):
|
||
if r == HTTPRequest.RESULT_SUCCESS and code >= 200 and code < 300:
|
||
var img := _decode_image(body, url)
|
||
if img != null and is_instance_valid(_logo):
|
||
_logo.texture = ImageTexture.create_from_image(img)
|
||
CghCache.save_thumb(url, img)
|
||
h.queue_free())
|
||
h.request(url)
|
||
|
||
# ----------------------------------------------------------------- thumbnails
|
||
func _load_thumb(url: String, card: CghAssetCard) -> void:
|
||
var cached := CghCache.load_thumb(url)
|
||
if cached:
|
||
card.set_thumbnail(cached)
|
||
return
|
||
_thumb_queue.append({"card": card, "url": url})
|
||
_pump_thumbs()
|
||
|
||
func _pump_thumbs() -> void:
|
||
for h in _thumb_http:
|
||
if _thumb_queue.is_empty():
|
||
return
|
||
if _thumb_ctx.get(h) != null:
|
||
continue # this slot is busy
|
||
var job = _thumb_queue.pop_front()
|
||
if not is_instance_valid(job["card"]):
|
||
continue # card was freed before we got to it
|
||
_thumb_ctx[h] = job
|
||
var err := h.request(job["url"])
|
||
if err != OK:
|
||
# Couldn't start (node not ready yet / busy) — DON'T drop it: re-queue and
|
||
# retry on a later frame, so thumbnails still load once the tree settles.
|
||
_thumb_ctx[h] = null
|
||
job["retries"] = int(job.get("retries", 0)) + 1
|
||
if job["retries"] <= 8:
|
||
_thumb_queue.push_back(job)
|
||
call_deferred("_pump_thumbs")
|
||
else:
|
||
push_warning("CGHEVEN: thumb request couldn't start (err %d) %s" % [err, str(job["url"])])
|
||
return
|
||
|
||
func _on_thumb(result: int, code: int, _h: PackedStringArray, body: PackedByteArray, req: HTTPRequest) -> void:
|
||
var ctx = _thumb_ctx.get(req, null)
|
||
_thumb_ctx[req] = null # free this slot regardless of outcome
|
||
if ctx != null:
|
||
var img: Image = null
|
||
if result == HTTPRequest.RESULT_SUCCESS and code >= 200 and code < 300:
|
||
img = _decode_image(body, ctx["url"])
|
||
if img != null:
|
||
_thumb_fail = 0
|
||
if is_instance_valid(ctx["card"]):
|
||
ctx["card"].set_thumbnail(ImageTexture.create_from_image(img))
|
||
CghCache.save_thumb(ctx["url"], img)
|
||
else:
|
||
# Network/decoder failure — surface the exact result/http in the panel
|
||
# (result=4 connection/offline, http=403/404 CDN, http=200 = decode issue).
|
||
_thumb_fail += 1
|
||
if _thumb_fail == 1:
|
||
_set_status("Thumbnail failed: result=%d http=%d (send me this)" % [result, code], true)
|
||
push_warning("CGHEVEN: thumbnail failed (result=%d http=%d) %s" % [result, code, str(ctx.get("url", ""))])
|
||
_pump_thumbs() # start the next queued thumbnail
|
||
|
||
# Decode a thumbnail from raw bytes. CGHEVEN thumbnails are usually .webp, but the URL
|
||
# extension often lies (a .webp link can serve png/jpg) — so SNIFF the real format from
|
||
# the magic bytes and call exactly ONE decoder. This makes mislabeled thumbnails load
|
||
# AND stops the console spam that came from blind-trying every wrong decoder. Returns
|
||
# null on failure (non-image body / corrupt file). PNG=89 50 4E 47, JPEG=FF D8,
|
||
# WEBP=RIFF....WEBP.
|
||
func _decode_image(body: PackedByteArray, _url: String) -> Image:
|
||
if body.size() < 12:
|
||
return null
|
||
var img := Image.new()
|
||
var ok := ERR_INVALID_DATA
|
||
if body[0] == 0x89 and body[1] == 0x50 and body[2] == 0x4E and body[3] == 0x47:
|
||
ok = img.load_png_from_buffer(body)
|
||
elif body[0] == 0xFF and body[1] == 0xD8:
|
||
ok = img.load_jpg_from_buffer(body)
|
||
elif body[0] == 0x52 and body[1] == 0x49 and body[2] == 0x46 and body[3] == 0x46 \
|
||
and body[8] == 0x57 and body[9] == 0x45 and body[10] == 0x42 and body[11] == 0x50:
|
||
ok = img.load_webp_from_buffer(body)
|
||
else:
|
||
return null # not a recognisable image body (e.g. an HTML error page) — no decoder, no spam
|
||
return img if ok == OK else null
|
||
|
||
# ----------------------------------------------------------------- download + import
|
||
func _on_card_download(asset: Dictionary, entry: Dictionary) -> void:
|
||
var e := entry if not entry.is_empty() else CghAsset.best_free_entry(asset)
|
||
if e.is_empty():
|
||
e = CghAsset.best_free_any(asset) # nothing auto-importable, but still fetch the free file
|
||
if e.is_empty():
|
||
# Paid users unlock every file, so an empty entry here means the asset genuinely
|
||
# ships no free file for this plan — say so plainly instead of the click doing nothing.
|
||
var why := "No downloadable file for your plan on this asset." if not auth.is_logged_in() \
|
||
else "This asset has no file you can download right now."
|
||
_report(why, false)
|
||
return
|
||
# Already downloaded by any CGHEVEN addon -> import the local file, skip re-download.
|
||
var local := CghDownloads.local_path(e["filename"])
|
||
if local != "":
|
||
_route_import(local, asset) # fires asset_imported / import_failed itself
|
||
return
|
||
# find the card that owns this asset (the one currently visible)
|
||
var card := _find_card(asset)
|
||
_dl_queue.append({"card": card, "asset": asset, "entry": e})
|
||
analytics.track("asset_downloaded", _asset_props(asset, {
|
||
"format": e.get("format", ""), "resolution": e.get("res", "")}))
|
||
_pump_queue()
|
||
|
||
func _pump_queue() -> void:
|
||
if _dl_active != null or _dl_queue.is_empty():
|
||
return
|
||
_dl_active = _dl_queue.pop_front()
|
||
var e: Dictionary = _dl_active["entry"]
|
||
# Match the AE/Blender addons: the file URL is a PUBLIC cdn.cgheven.com GET — send ONLY
|
||
# User-Agent + Referer, NEVER Authorization. A stray "Bearer" to the CDN/B2 origin makes it
|
||
# reject the request with 401 ("access denied") — exactly the failure seen on patreon assets
|
||
# even for a Pro user. The bearer belongs only on api.cgheven.com calls (the listing already
|
||
# sends it), so attach it only if the file URL is actually on our API host.
|
||
var url: String = str(e.get("url", ""))
|
||
var headers := PackedStringArray([
|
||
"User-Agent: CGHEVEN-Godot/%s" % CghConfig.ADDON_VERSION,
|
||
"Referer: https://cgheven.com/",
|
||
])
|
||
if auth.is_logged_in() and url.begins_with(CghConfig.api_base()):
|
||
headers.append("Authorization: Bearer " + auth.session_token)
|
||
_set_status("Downloading %s…" % e["filename"], false)
|
||
_show_toast("Downloading %s…" % e["filename"], "info") # confirm the click actually started a download
|
||
push_warning("CGHEVEN: download start %s <- %s" % [e["filename"], str(e.get("url", ""))])
|
||
downloader.download(e["url"], e["filename"], headers)
|
||
|
||
func _on_dl_progress(pct: float, _got: int, _total: int) -> void:
|
||
if _dl_active and is_instance_valid(_dl_active["card"]):
|
||
_dl_active["card"].set_progress(pct)
|
||
|
||
func _on_dl_finished(path: String) -> void:
|
||
if _dl_active == null:
|
||
return # defensive: a stray finished signal with no active job
|
||
var asset: Dictionary = _dl_active["asset"]
|
||
var entry: Dictionary = _dl_active.get("entry", {})
|
||
var fmt := str(entry.get("format", ""))
|
||
var res := str(entry.get("res", ""))
|
||
# Record into the shared cross-addon manifest so AE/Premiere/DaVinci show the ✓ too.
|
||
CghDownloads.record(asset, path, fmt, res)
|
||
if _dl_active and is_instance_valid(_dl_active["card"]):
|
||
_dl_active["card"].set_download_done(true)
|
||
# Re-evaluate the footer so it flips "Download" -> "Import" (and the ▾ rows update)
|
||
# live, right now — previously it only refreshed on a full grid rebuild / re-open.
|
||
_dl_active["card"].refresh_state()
|
||
_downloaded.append({"asset": asset, "path": path, "msg": ""})
|
||
_route_import(path, asset) # fires asset_imported / import_failed itself
|
||
_dl_active = null
|
||
_pump_queue()
|
||
|
||
func _on_dl_failed(message: String) -> void:
|
||
var asset: Dictionary = _dl_active.get("asset", {}) if _dl_active else {}
|
||
if _dl_active and is_instance_valid(_dl_active["card"]):
|
||
_dl_active["card"].set_download_done(false)
|
||
_report(message, false)
|
||
analytics.track("download_failed", _asset_props(asset, {"reason": message}))
|
||
_dl_active = null
|
||
_pump_queue()
|
||
|
||
# Route a finished/already-local file to the right importer. 3D models take the
|
||
# official res:// path (copy into project -> editor imports a proper PackedScene
|
||
# with materials -> instance + select), which renders reliably; HDRI/flipbook use
|
||
# the synchronous router (live-instanced into the open scene).
|
||
func _route_import(path: String, asset: Dictionary) -> void:
|
||
var cat := CghAsset.category_slug(asset)
|
||
var ext := path.get_extension().to_lower()
|
||
# 3D models arrive as a direct model file OR (usually) a .zip. Route BOTH through the
|
||
# editor-import path so Godot imports fbx/obj/dae too — the runtime path does only glTF.
|
||
var is_3d: bool = cat == "3d-models" or ext in CghImportRouter.EDITOR_MODEL_PRIORITY
|
||
if is_3d:
|
||
if ext in CghImportRouter.EDITOR_MODEL_PRIORITY:
|
||
_import_3d(path, asset, false) # coroutine — sets status + fires its own analytics
|
||
return
|
||
if ext == "zip":
|
||
var model := CghImportRouter.extract_model_from_zip(path)
|
||
if model != "":
|
||
_import_3d(model, asset, true)
|
||
return
|
||
# no editor-importable model inside — fall through to the runtime router
|
||
var msg := CghImportRouter.import_file(path, cat)
|
||
var ok := not msg.to_lower().contains("failed")
|
||
# EXR flipbook sheets fail in Godot (its tinyexr can't read DWAA/DWAB/PXR24/B44 compression).
|
||
# Auto-fall back to a PNG/JPG/WebP version of the SAME flipbook when the asset ships one, so
|
||
# the user gets a playing flipbook instead of a dead error toast.
|
||
if not ok and CghAsset.is_flipbook(asset) and ext == "exr":
|
||
var alt := _flipbook_image_alternative(asset)
|
||
if not alt.is_empty():
|
||
_show_toast("EXR flipbook can't load in Godot — fetching the PNG version instead…", "info")
|
||
push_warning("CGHEVEN: EXR flipbook import failed, auto-retrying with %s" % str(alt.get("filename", "")))
|
||
_on_card_download(asset, alt) # queues the PNG download, then imports it
|
||
return
|
||
msg = "This flipbook only ships as an EXR compression Godot can't read yet — please try another flipbook (we're re-encoding these)."
|
||
# HDRI EXRs at high resolution are often DWAA/DWAB compressed (Godot's tinyexr can't read
|
||
# those); the lowest resolution is usually uncompressed and loads fine. On failure, auto-fall
|
||
# back to the lowest-res EXR of the SAME HDRI so the user still gets a working sky.
|
||
if not ok and cat.contains("hdr") and ext in ["exr", "hdr"]:
|
||
var halt := _hdri_loadable_alternative(asset, path)
|
||
if not halt.is_empty():
|
||
_show_toast("This HDRI resolution can't load in Godot — fetching a lower resolution instead…", "info")
|
||
push_warning("CGHEVEN: HDRI import failed, auto-retrying with %s" % str(halt.get("filename", "")))
|
||
_on_card_download(asset, halt) # queues the lower-res EXR, then imports it
|
||
return
|
||
msg = "This HDRI only ships in a compression Godot can't read yet — please try another HDRI (we're re-encoding these)."
|
||
_report(msg, ok)
|
||
_track_import_result(asset, ext, ok)
|
||
|
||
# Lowest-res unlocked PNG/JPG/WebP entry for a flipbook — the fallback when its EXR sheet
|
||
# won't load in Godot. Returns {} when the asset is EXR-only (no image variant to use).
|
||
func _flipbook_image_alternative(asset: Dictionary) -> Dictionary:
|
||
var imgs := CghAsset.importable_entries(asset).filter(func(e):
|
||
return not e.get("locked", false) and str(e.get("ext", "")) in ["png", "jpg", "jpeg", "webp"])
|
||
if imgs.is_empty():
|
||
return {}
|
||
imgs.sort_custom(func(a, b): return int(a["res_rank"]) < int(b["res_rank"]))
|
||
return imgs[0]
|
||
|
||
# Lowest-res unlocked EXR/HDR entry for an HDRI — the fallback when a higher-res EXR won't load
|
||
# (DWAA/DWAB). Returns {} when there's no lower alternative than the file that just failed.
|
||
func _hdri_loadable_alternative(asset: Dictionary, failed_path: String) -> Dictionary:
|
||
var exrs := CghAsset.importable_entries(asset).filter(func(e):
|
||
return not e.get("locked", false) and str(e.get("ext", "")) in ["exr", "hdr"])
|
||
if exrs.is_empty():
|
||
return {}
|
||
exrs.sort_custom(func(a, b): return int(a["res_rank"]) < int(b["res_rank"]))
|
||
var lowest: Dictionary = exrs[0]
|
||
# Don't retry the exact file we just failed on (avoids a loop if even the lowest fails).
|
||
if str(lowest.get("filename", "")) == failed_path.get_file():
|
||
return {}
|
||
return lowest
|
||
|
||
# asset_imported on success / import_failed on failure — matches the AE/Premiere
|
||
# events (the import path used to always report success, even when it failed).
|
||
func _track_import_result(asset: Dictionary, fmt: String, ok: bool) -> void:
|
||
if ok:
|
||
analytics.track("asset_imported", _asset_props(asset, {"format": fmt, "success": true}))
|
||
else:
|
||
analytics.track("import_failed", _asset_props(asset, {"format": fmt, "success": false}))
|
||
|
||
# Copy the model under res://, let the editor import it, then instance the
|
||
# PackedScene into the open scene and select it so the user can frame it (F).
|
||
func _import_3d(path: String, asset: Dictionary, from_extract := false) -> void:
|
||
var category := CghAsset.category_slug(asset)
|
||
var ext := path.get_extension().to_lower()
|
||
# Copy into res:// (with sibling textures/.bin/.mtl when it came from a zip) and let
|
||
# Godot's EDITOR import it — that path handles glTF/FBX/OBJ/DAE, unlike the runtime one.
|
||
var res_path := ""
|
||
if from_extract:
|
||
res_path = CghImportRouter.copy_model_into_project(path, category)
|
||
else:
|
||
res_path = CghImportRouter.copy_into_project(path, category)
|
||
if res_path == "":
|
||
_report("Couldn't copy the model into the project (check disk space / write permission).", false)
|
||
_track_import_result(asset, ext, false)
|
||
return
|
||
_set_status("Importing 3D model…", false)
|
||
var efs := EditorInterface.get_resource_filesystem()
|
||
if efs:
|
||
efs.scan()
|
||
while efs.is_scanning():
|
||
await get_tree().process_frame
|
||
# Wait for the async import worker. If the file exists but load() keeps returning
|
||
# null it's a failed/corrupt import — cap the attempts so ONE bad file doesn't flood
|
||
# the console (each failed load() logs several engine errors).
|
||
var imported_res = null
|
||
var load_tries := 0
|
||
for _i in 120:
|
||
if ResourceLoader.exists(res_path) and _i % 12 == 0:
|
||
imported_res = load(res_path)
|
||
if imported_res != null:
|
||
break
|
||
load_tries += 1
|
||
if load_tries >= 4:
|
||
break
|
||
await get_tree().process_frame
|
||
# glTF/FBX/DAE import as a PackedScene; OBJ imports as a Mesh — handle both.
|
||
var node = null
|
||
if imported_res is PackedScene:
|
||
node = imported_res.instantiate()
|
||
elif imported_res is Mesh:
|
||
var mi := MeshInstance3D.new()
|
||
mi.mesh = imported_res
|
||
mi.name = res_path.get_file().get_basename()
|
||
node = mi
|
||
if node == null:
|
||
if imported_res == null:
|
||
# File exists in the project but never loaded — a failed/corrupt import.
|
||
_report("Couldn't import %s — the model file looks corrupt or uses an unsupported glTF feature. Delete the download and try another resolution." % res_path.get_file(), false)
|
||
_track_import_result(asset, ext, false)
|
||
else:
|
||
# Imported, but not an auto-instanceable scene/mesh — leave it in the dock.
|
||
_report("Model saved to %s — drag it from FileSystem into your scene." % res_path, true)
|
||
_track_import_result(asset, ext, true)
|
||
return
|
||
var root := EditorInterface.get_edited_scene_root()
|
||
if root == null:
|
||
_report("Model imported to %s — open a 3D scene, then drag it in." % res_path, true)
|
||
_track_import_result(asset, ext, true)
|
||
return
|
||
root.add_child(node)
|
||
node.owner = root
|
||
for c in node.find_children("*", "", true, false):
|
||
c.owner = root
|
||
var sel := EditorInterface.get_selection()
|
||
if sel:
|
||
sel.clear()
|
||
sel.add_node(node)
|
||
_report("3D model added — press F in the viewport to frame it.", true)
|
||
_track_import_result(asset, ext, true)
|
||
|
||
func _on_card_upgrade(asset: Dictionary) -> void:
|
||
analytics.track("upgrade_clicked", _asset_props(asset)) # parity with AE/Premiere/Blender
|
||
OS.shell_open(CghConfig.pricing_url())
|
||
|
||
# 🗑 Delete this asset's downloaded file(s) from disk, then flip the card back to
|
||
# "Download" so a missing / corrupt file can be re-fetched cleanly.
|
||
func _on_card_delete(asset: Dictionary) -> void:
|
||
var n := CghDownloads.delete(asset)
|
||
var card := _find_card(asset)
|
||
if is_instance_valid(card):
|
||
card.refresh_state()
|
||
if n > 0:
|
||
_set_status("Deleted %d file%s — you can download it again." % [n, "" if n == 1 else "s"], false)
|
||
else:
|
||
_set_status("Nothing on disk to delete — you can download it again.", false)
|
||
|
||
func _on_card_cancel(card) -> void:
|
||
# cancel the in-flight download, or drop it from the queue if not started yet
|
||
if _dl_active != null and _dl_active.get("card") == card:
|
||
downloader.cancel()
|
||
_dl_active = null
|
||
if is_instance_valid(card):
|
||
card.reset_after_cancel()
|
||
_set_status("Download cancelled.", false)
|
||
_pump_queue()
|
||
else:
|
||
for i in _dl_queue.size():
|
||
if _dl_queue[i].get("card") == card:
|
||
_dl_queue.remove_at(i)
|
||
break
|
||
if is_instance_valid(card):
|
||
card.reset_after_cancel()
|
||
|
||
func _find_card(asset: Dictionary) -> CghAssetCard:
|
||
for c in _grid.get_children():
|
||
if c is CghAssetCard:
|
||
if CghAsset.id(c.asset) == CghAsset.id(asset):
|
||
return c
|
||
return null
|
||
|
||
# ----------------------------------------------------------------- hover preview
|
||
func _on_preview_requested(card, asset: Dictionary) -> void:
|
||
var urls = CghAsset.preview_images(asset)
|
||
if urls.is_empty():
|
||
return
|
||
urls = urls.slice(0, 3)
|
||
var frames := []
|
||
var pending := {"n": urls.size()}
|
||
for u in urls:
|
||
var cached := CghCache.load_thumb(u)
|
||
if cached:
|
||
frames.append(cached)
|
||
pending["n"] -= 1
|
||
if pending["n"] <= 0 and is_instance_valid(card):
|
||
card.set_preview_frames(frames)
|
||
continue
|
||
var h := HTTPRequest.new()
|
||
add_child(h)
|
||
h.request_completed.connect(func(r, code, _hh, body):
|
||
if r == HTTPRequest.RESULT_SUCCESS and code >= 200 and code < 300:
|
||
var img := _decode_image(body, u)
|
||
if img != null:
|
||
frames.append(ImageTexture.create_from_image(img))
|
||
CghCache.save_thumb(u, img)
|
||
pending["n"] -= 1
|
||
if pending["n"] <= 0 and is_instance_valid(card):
|
||
card.set_preview_frames(frames)
|
||
h.queue_free())
|
||
h.request(u)
|
||
|
||
# Flipbook hover preview: download the REAL low-res sprite sheet (its filename declares the
|
||
# grid AND the image genuinely IS that grid) and hand it to the card to slice + play. This
|
||
# replaces slicing the card thumbnail, whose layout doesn't always match the sheet's NxM —
|
||
# e.g. muzzle-flashes use a single-frame thumbnail, so slicing it 5x5 showed garbage frames.
|
||
func _on_flipbook_sheet_requested(card, asset: Dictionary) -> void:
|
||
var grid := CghAsset.flipbook_grid(asset)
|
||
if grid.x * grid.y <= 1:
|
||
return
|
||
var e := _flipbook_image_alternative(asset) # lowest-res unlocked png/jpg/webp sheet
|
||
if e.is_empty():
|
||
return
|
||
var url: String = str(e.get("url", ""))
|
||
if url == "":
|
||
return
|
||
var cached := CghCache.load_thumb(url)
|
||
if cached:
|
||
if is_instance_valid(card):
|
||
card.set_flipbook_sheet(cached, grid)
|
||
return
|
||
var h := HTTPRequest.new()
|
||
add_child(h)
|
||
h.request_completed.connect(func(r, code, _hh, body):
|
||
if r == HTTPRequest.RESULT_SUCCESS and code >= 200 and code < 300:
|
||
var img := _decode_image(body, url)
|
||
if img != null:
|
||
CghCache.save_thumb(url, img)
|
||
if is_instance_valid(card):
|
||
card.set_flipbook_sheet(ImageTexture.create_from_image(img), grid)
|
||
h.queue_free())
|
||
h.request(url)
|
||
|
||
# ----------------------------------------------------------------- favorites view
|
||
func _render_favorites() -> void:
|
||
_clear_grid()
|
||
if _page_bar:
|
||
_page_bar.visible = false
|
||
var favs := CghFavorites.all()
|
||
_set_status("" if favs.size() > 0 else "No favorites yet. Tap ♡ on a card.", false)
|
||
for a in favs:
|
||
if a is Dictionary:
|
||
_add_card(a)
|
||
|
||
func _on_fav_login_required() -> void:
|
||
_set_status("Please login first to add favourites.", true)
|
||
var dlg := ConfirmationDialog.new()
|
||
dlg.title = "Login required"
|
||
dlg.dialog_text = "Please login to save favourites.\n\nDo you want to login now?"
|
||
dlg.ok_button_text = "Login"
|
||
add_child(dlg)
|
||
dlg.confirmed.connect(func(): _login_dlg.open())
|
||
dlg.canceled.connect(func(): dlg.queue_free())
|
||
dlg.confirmed.connect(func(): dlg.queue_free())
|
||
dlg.popup_centered()
|
||
|
||
func _on_favorite_toggled(asset: Dictionary, is_fav: bool) -> void:
|
||
_update_fav_badge()
|
||
if _filter_mode == 3:
|
||
_render_favorites()
|
||
if is_fav: # the desktop addons only track the add, not the remove
|
||
analytics.track("favourite_added", _asset_props(asset))
|
||
|
||
func _update_fav_badge() -> void:
|
||
if _free and _free.item_count > 3:
|
||
var n := CghFavorites.count()
|
||
_free.set_item_text(3, "♥ Favorites (%d)" % n if n > 0 else "♥ Favorites")
|
||
|
||
# ----------------------------------------------------------------- account
|
||
# The top-nav button doubles as Login (guest) and Account/Profile (logged in) —
|
||
# both just open the dialog, which renders the right view (login buttons vs the
|
||
# profile card with Logout) for the current state.
|
||
func _on_login_logout() -> void:
|
||
_login_dlg.open()
|
||
|
||
func _do_logout() -> void:
|
||
auth.logout()
|
||
api.set_session_token("")
|
||
analytics.identify("", "Free")
|
||
_refresh_account_ui()
|
||
_load_first_page(true) # force: the grid is cached per-plan, so a plan change must refetch
|
||
|
||
func _on_logged_in(plan: String) -> void:
|
||
api.set_session_token(auth.session_token)
|
||
analytics.identify(auth.email, plan)
|
||
analytics.track("account_login")
|
||
_refresh_account_ui()
|
||
# FORCE a fresh fetch (bypass cache): the page cache is keyed per plan, and a stale
|
||
# page cached before login (or from an older query) must not be served to the logged-in
|
||
# user — that's what made "All" show only a couple of assets right after login.
|
||
_load_first_page(true)
|
||
|
||
func _on_plan_refreshed(plan: String) -> void:
|
||
api.set_session_token(auth.session_token)
|
||
_refresh_account_ui()
|
||
_set_status("Plan updated: %s" % plan, false)
|
||
_load_first_page(true) # force: access changed -> re-fetch instead of serving a stale page
|
||
|
||
func _refresh_account_ui() -> void:
|
||
_login_btn.text = "👤 Account" if auth.is_logged_in() else "Login"
|
||
if _login_dlg:
|
||
_login_dlg.refresh()
|
||
|
||
# ----------------------------------------------------------------- updates
|
||
func _on_update_available(version: String, notes: String, url: String) -> void:
|
||
analytics.track("update_prompt_shown", {"from_version": CghConfig.ADDON_VERSION, "to_version": version})
|
||
var dlg := ConfirmationDialog.new()
|
||
dlg.title = "Update available — v%s" % version
|
||
dlg.dialog_text = (notes if notes != "" else "A new version is available.") + "\n\nDownload now? (editor restart required to apply)"
|
||
add_child(dlg)
|
||
dlg.confirmed.connect(func():
|
||
_set_status("Downloading update…", false)
|
||
updater.download_and_stage(url))
|
||
dlg.popup_centered()
|
||
|
||
func _on_up_to_date() -> void:
|
||
if _manual_check:
|
||
_set_status("You're on the latest version.", false)
|
||
_manual_check = false
|
||
|
||
func _on_update_failed(message: String) -> void:
|
||
# Silent on the automatic boot check; only show if the user asked explicitly.
|
||
if _manual_check:
|
||
_set_status("Couldn't check for updates right now.", false)
|
||
_manual_check = false
|
||
|
||
func _on_update_staged() -> void:
|
||
analytics.track("update_completed")
|
||
var dlg := ConfirmationDialog.new()
|
||
dlg.title = "Update ready"
|
||
dlg.dialog_text = "Update installed. Restart the editor to apply it now?"
|
||
add_child(dlg)
|
||
dlg.confirmed.connect(func(): EditorInterface.restart_editor(true))
|
||
dlg.popup_centered()
|
||
|
||
# ----------------------------------------------------------------- helpers
|
||
func _clear_grid() -> void:
|
||
if _page_bar:
|
||
_page_bar.visible = false # never leave a stale "1 2 3 …" bar floating over an empty grid
|
||
for c in _grid.get_children():
|
||
_grid.remove_child(c)
|
||
c.queue_free()
|
||
# drop pending thumbnail work + cancel in-flight (cards are gone now)
|
||
_thumb_queue.clear()
|
||
for h in _thumb_http:
|
||
if _thumb_ctx.get(h) != null:
|
||
h.cancel_request()
|
||
_thumb_ctx[h] = null
|
||
|
||
func _reflow_columns() -> void:
|
||
# AE-exact responsive breakpoints (2→7 columns by panel width).
|
||
if _grid:
|
||
_grid.columns = CghConfig.responsive_columns(size.x)
|
||
|
||
## Standard asset properties for analytics events (matches the desktop addons).
|
||
func _asset_props(asset: Dictionary, extra := {}) -> Dictionary:
|
||
var p := {
|
||
"asset_id": CghAsset.id(asset),
|
||
"asset_name": CghAsset.title(asset),
|
||
"category": CghAsset.category_slug(asset),
|
||
"sub_category": _subcat,
|
||
}
|
||
for k in extra:
|
||
p[k] = extra[k]
|
||
return p
|
||
|
||
func _set_status(msg: String, is_err: bool) -> void:
|
||
if _status:
|
||
_status.text = msg
|
||
_status.visible = msg != "" # reserve no vertical space when there's nothing to say
|
||
_status.add_theme_color_override("font_color", CghConfig.C_ERR if is_err else CghConfig.C_TEXT2)
|