Initial commit

This commit is contained in:
scorpion21211-cmd
2026-10-03 23:29:41 -04:00
commit 965f0d7584
10586 changed files with 1030294 additions and 0 deletions
+59
View File
@@ -0,0 +1,59 @@
@tool
extends Node
class_name CghAnalytics
## Analytics — identical model to the AE/Blender/DaVinci/Premiere addons.
## Posts to the BACKEND (/api/analytics/track) which holds the PostHog key, so the
## addon never ships a key (the old direct-to-PostHog path was a silent no-op
## because the key shipped empty). Body shape matches the desktop addons exactly:
## { event, license_key: <distinct_id>, addon_name, properties{...} }
## distinct_id = account email -> guest_<machine_hash> -> anonymous (never a token).
const ADDON_NAME := "godot-asset-library"
const HOST_APP := "godot"
var _email := ""
var _plan := "Free"
func identify(email: String, plan: String) -> void:
_email = email
_plan = plan if plan != "" else "Free"
func _distinct_id() -> String:
if _email != "":
return _email
var fp := CghAuth.machine_hash()
return ("guest_" + fp) if fp != "" else "anonymous"
func _is_premium() -> bool:
return _plan == "Pro" or _plan == "Studio" or _plan == "Bundle"
func track(event: String, props := {}) -> void:
var properties := {
"addon_version": CghConfig.ADDON_VERSION,
"host_app": HOST_APP,
"os": OS.get_name(),
"plan": _plan,
"is_premium": _is_premium(),
"is_guest": _email == "",
"account_email": _email,
"install_source": CghConfig.install_source(),
}
for k in props:
properties[k] = props[k]
var payload := {
"event": event,
"license_key": _distinct_id(),
"addon_name": ADDON_NAME,
"properties": properties,
}
# Fire-and-forget on a one-off request node so bursts (session_started +
# addon_opened on boot) never drop and the editor never blocks.
var h := HTTPRequest.new()
h.timeout = 5.0
add_child(h)
h.request_completed.connect(func(_a, _b, _c, _d): h.queue_free())
var err := h.request(CghConfig.analytics_track_url(),
PackedStringArray(["Content-Type: application/json"]),
HTTPClient.METHOD_POST, JSON.stringify(payload))
if err != OK:
h.queue_free()
+1
View File
@@ -0,0 +1 @@
uid://xa7h3bnk8yvj
+117
View File
@@ -0,0 +1,117 @@
@tool
extends Node
class_name CghApiClient
## Thin wrapper over HTTPRequest for the Strapi proxy endpoints.
## Must live in the editor tree (added as a child of the dock) or HTTPRequest
## returns ERR_UNCONFIGURED.
signal assets_loaded(assets: Array, meta: Dictionary)
signal categories_loaded(cats: Array)
signal request_failed(message: String)
var _http: HTTPRequest
var _cat_http: HTTPRequest
var _session_token := ""
func _ready() -> void:
_http = HTTPRequest.new()
_http.use_threads = true
_http.timeout = 20.0 # never hang forever (a stuck request blocks all later ones -> ERR_BUSY 44)
add_child(_http)
_http.request_completed.connect(_on_completed)
# Separate request node for the category tree so it never cancels an asset fetch.
_cat_http = HTTPRequest.new()
_cat_http.use_threads = true
_cat_http.timeout = 20.0
add_child(_cat_http)
_cat_http.request_completed.connect(_on_cat_completed)
func set_session_token(tok: String) -> void:
_session_token = tok
func _headers() -> PackedStringArray:
var h := PackedStringArray(["Content-Type: application/json"])
if _session_token != "":
h.append("Authorization: Bearer " + _session_token)
return h
## page is 1-based. category_slug "" = all. subcat is a SUBcategory slug (e.g. "props")
## which lives on the asset's `subcategories[]` relation, NOT `categorie` — so it needs
## a different Strapi filter. sort = Strapi sort param.
func fetch_assets(page: int, page_size: int, category_slug: String, search: String, sort := "createdAt:desc", subcat := "") -> void:
var base := CghConfig.authed_assets_url() if _session_token != "" else CghConfig.public_assets_url()
var q := "?addonSlug=%s&pagination[page]=%d&pagination[pageSize]=%d&populate=*" % [
CghConfig.ADDON_SLUG, page, page_size]
if sort != "":
q += "&sort=" + sort.uri_encode()
# Blender-exact: one slug, matched against EITHER the top-level category OR a
# subcategory ($or). So "props" (a 3d-models subcategory) is found correctly.
var sel := subcat if subcat != "" else category_slug
if sel != "":
var es := sel.uri_encode()
q += "&filters[$or][0][categorie][Slug][$eq]=" + es
q += "&filters[$or][1][subcategories][Slug][$eq]=" + es
else:
# "All" tab — the Godot-usable categories only (3d-models / hdri / flipbooks + their
# subcategories). Match EITHER the top-level categorie OR the subcategories relation for
# EACH usable slug, exactly like the individual tabs do (those work reliably). The old
# `categorie.Slug $in [...]` form matched only the top level and behaved inconsistently on
# the authenticated proxy, so "All" showed only a handful of assets.
var slugs: Array = []
var usable := {}
for cat in CghConfig.CATEGORIES:
if cat["slug"] != "":
usable[cat["slug"]] = true
slugs.append(cat["slug"])
for parent in CghConfig.SUBCATEGORIES.keys():
if not usable.has(parent):
continue
for s in CghConfig.SUBCATEGORIES[parent]:
slugs.append(s["slug"])
var oi := 0
for cs in slugs:
var es := str(cs).uri_encode()
q += "&filters[$or][%d][categorie][Slug][$eq]=%s" % [oi, es]; oi += 1
q += "&filters[$or][%d][subcategories][Slug][$eq]=%s" % [oi, es]; oi += 1
if search.strip_edges() != "":
q += "&filters[Title][$containsi]=" + search.strip_edges().uri_encode()
# Drop any stale/in-flight request first, otherwise a previous (slow) request
# leaves the node busy and this one fails with ERR_BUSY (44).
_http.cancel_request()
var err := _http.request(base + q, _headers(), HTTPClient.METHOD_GET)
if err != OK:
request_failed.emit("Request could not start (err %d)" % err)
func _on_completed(result: int, code: int, _headers_in: PackedStringArray, body: PackedByteArray) -> void:
if result != HTTPRequest.RESULT_SUCCESS:
request_failed.emit("Network error (result %d)" % result)
return
if code < 200 or code >= 300:
request_failed.emit("Server returned HTTP %d" % code)
return
var parsed = JSON.parse_string(body.get_string_from_utf8())
if typeof(parsed) != TYPE_DICTIONARY:
request_failed.emit("Bad JSON from server")
return
var data = parsed.get("data", [])
var meta = parsed.get("meta", {})
assets_loaded.emit(data if data is Array else [], meta if meta is Dictionary else {})
## Fetch the category tree so the UI can build an accurate subcategory dropdown from the
## live backend (like the Blender addon), not a hardcoded list. Non-fatal on failure.
func fetch_categories() -> void:
var base := CghConfig.authed_categories_url() if _session_token != "" else CghConfig.public_categories_url()
var q := "?addonSlug=%s&pagination[pageSize]=200&populate=*" % CghConfig.ADDON_SLUG
_cat_http.cancel_request()
var err := _cat_http.request(base + q, _headers(), HTTPClient.METHOD_GET)
if err != OK:
return # keep the hardcoded subcategory fallback
func _on_cat_completed(result: int, code: int, _h: PackedStringArray, body: PackedByteArray) -> void:
if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300:
return
var parsed = JSON.parse_string(body.get_string_from_utf8())
if typeof(parsed) != TYPE_DICTIONARY:
return
var data = parsed.get("data", [])
categories_loaded.emit(data if data is Array else [])
+1
View File
@@ -0,0 +1 @@
uid://b3v25j2woseak
+373
View File
@@ -0,0 +1,373 @@
@tool
extends RefCounted
class_name CghAsset
## Helpers to read a Strapi asset dict and turn its files[] / green_screen[]
## into structured, Godot-relevant download entries.
static func attrs(asset: Dictionary) -> Dictionary:
return asset.get("attributes", asset)
static func title(asset: Dictionary) -> String:
return attrs(asset).get("Title", "Untitled")
static func id(asset: Dictionary) -> String:
var raw = asset.get("id", null)
if raw == null:
raw = attrs(asset).get("assets_slug", title(asset))
# JSON parses numeric ids as float -> str(2139.0) = "2139.0", which breaks the
# /assets/<id> URL and the cross-addon manifest. Always render whole ids as ints.
if raw is float:
return str(int(raw))
if raw is int:
return str(raw)
return str(raw)
## Thumbnail URL — mirrors AE's AU.thumb() + Blender's _normalize_media_url():
## accepts a plain string OR a Strapi media relation, and prepends the CDN base
## only for host-relative ("/uploads/…") URLs.
static func thumbnail(asset: Dictionary) -> String:
var t = attrs(asset).get("thumbnail", "")
var url := ""
if t is String:
url = t
elif t is Dictionary:
var d = t.get("data", null)
if d is Dictionary:
var da = d.get("attributes", d)
if da is Dictionary:
url = str(da.get("url", ""))
elif t.has("url"):
url = str(t.get("url", ""))
return _normalize_media_url(url)
static func _normalize_media_url(url: String) -> String:
var raw := url.strip_edges()
if raw == "":
return ""
if raw.begins_with("//"):
return "https:" + raw
if raw.begins_with("/"):
return "https://cdn.cgheven.com" + raw
return raw
## Category slug — flat-first, exactly like the AE/Blender/DaVinci addons:
## the proxy returns a flat `categorie.Slug`, but tolerate nested `data.attributes`.
static func _cat_attrs(asset: Dictionary) -> Dictionary:
var c = attrs(asset).get("categorie", {})
if not (c is Dictionary):
return {}
var d = c.get("data", c) # nested Strapi shape -> unwrap, else flat
if d is Dictionary:
var da = d.get("attributes", d)
if da is Dictionary:
return da
return {}
static func category_slug(asset: Dictionary) -> String:
var da := _cat_attrs(asset)
return str(da.get("Slug", da.get("slug", "")))
## True when this asset is a flipbook. CRITICAL: the category slug may be the PARENT
## ("flipbooks") OR a SUBCATEGORY ("fire-flipbooks", "muzzle-flashes-flipbooks",
## "tornado-fx-flipbooks", "magic-effects-flipbooks", …) — so match the "flipbook" TOKEN,
## not exact equality. Before this, subcategory flipbooks silently missed the sprite-sheet
## hover animation + PNG-preference + EXR fallback and fell back to the janky still-slideshow.
static func is_flipbook(asset: Dictionary) -> bool:
return category_slug(asset).to_lower().contains("flipbook")
## True when this asset is an HDRI (parent "hdri" or a subcategory like "space-hdr").
static func is_hdri(asset: Dictionary) -> bool:
var s := category_slug(asset).to_lower()
return s == "hdri" or s.contains("hdr")
## Human-readable asset slug, like the Blender addon (assets_slug, fallbacks).
static func assets_slug(asset: Dictionary) -> String:
var a := attrs(asset)
var s = a.get("assets_slug", a.get("Assets_slug", a.get("asset_slug", a.get("slug", ""))))
return str(s).strip_edges()
## Canonical website URL — matches Blender: https://cgheven.com/assets/<assets_slug>.
## Falls back to the numeric id only if there's no usable slug.
static func web_url(asset: Dictionary) -> String:
var slug := assets_slug(asset).split("/")[0].split("?")[0].strip_edges().to_lower()
var rx := RegEx.new()
rx.compile("^[a-z0-9_-]+$")
if slug != "" and rx.search(slug) != null:
return CghConfig.web_base() + "/assets/" + slug
return CghConfig.web_base() + "/assets/" + id(asset)
static func category_title(asset: Dictionary) -> String:
var da := _cat_attrs(asset)
var t := str(da.get("Title", da.get("Name", "")))
return t if t != "" else "Asset"
## Hover-preview image URLs (additional_images[] + previews). Godot can't play
## the mp4/webm preview VIDEO, so we cycle these stills as a slideshow instead.
static func preview_images(asset: Dictionary) -> Array:
var a := attrs(asset)
var out := []
var add = a.get("additional_images", [])
if add is Array:
for u in add:
if u is String and u != "":
out.append(u)
var prev = a.get("previews", "")
if prev is String and prev != "" and not prev.to_lower().ends_with(".mp4") \
and not prev.to_lower().ends_with(".webm") and not prev.to_lower().ends_with(".mov"):
out.append(prev)
return out
## Flipbook sprite-sheet grid (cols x rows) parsed from the asset's file names /
## previews / thumbnail (e.g. "..._8x8.png"). Vector2i(0,0) when unknown. Lets the card
## animate the flipbook on hover by slicing its sprite-sheet thumbnail into frames.
static func flipbook_grid(asset: Dictionary) -> Vector2i:
# File-name grid — used to slice the REAL downloaded sheet in the importer.
var cands := []
for e in file_entries(asset):
cands.append(str(e.get("filename", "")))
var a := attrs(asset)
cands.append(str(a.get("previews", "")))
cands.append(thumbnail(asset))
for c in cands:
var g := _grid_from(str(c))
if g.x >= 1 and g.y >= 1:
return g
return Vector2i(0, 0)
## Grid used to slice the THUMBNAIL for the hover animation. The thumbnail's own layout
## can differ from the downloadable sheet, so read the thumbnail/preview URL FIRST and
## only fall back to file names. This fixes flipbooks whose hover preview looked wrong
## because we were slicing the thumbnail by the (different) full-res sheet's grid.
static func flipbook_thumb_grid(asset: Dictionary) -> Vector2i:
var a := attrs(asset)
var cands := [thumbnail(asset), str(a.get("previews", ""))]
for e in file_entries(asset):
cands.append(str(e.get("filename", "")))
for c in cands:
var g := _grid_from(str(c))
if g.x >= 1 and g.y >= 1:
return g
return Vector2i(0, 0)
## Parse an NxM sprite-sheet grid from a filename/URL. Scans ALL "AxB" tokens and keeps
## the last one where BOTH numbers are small (<=32) — a real grid like "_6x6". This skips
## pixel dimensions such as "1920x1080" or "_4K_" that used to be grabbed as the grid
## (returning the first match only), which produced 36 garbage frames from a resized sheet.
static func _grid_from(text: String) -> Vector2i:
var rx := RegEx.new()
rx.compile("(\\d+)\\s*[xX]\\s*(\\d+)")
var best := Vector2i(0, 0)
for m in rx.search_all(text):
var cols := int(m.get_string(1))
var rows := int(m.get_string(2))
if cols >= 1 and rows >= 1 and cols <= 32 and rows <= 32:
best = Vector2i(cols, rows) # keep the LAST small token (grid is the trailing _NxM)
return best
static func is_new(asset: Dictionary) -> bool:
# treat early_access>0 or a releaseDate within ~21 days as "NEW"
var a := attrs(asset)
return int(a.get("early_access", 0)) > 0
static func patreon_locked(asset: Dictionary) -> bool:
return attrs(asset).get("is_patreon_locked", false) == true
## Returns an array of file entries:
## {url, filename, ext, res, res_rank, format, locked}
## Mirrors AE/Blender/DaVinci parseFiles(): per-file CGHLOCKED:: gating, resolution
## parsed from the filename, and 3D format detected inside .zip archives. Each file
## entry may be a plain URL string OR a Strapi media object {url}.
static func file_entries(asset: Dictionary) -> Array:
var out := []
var a := attrs(asset)
for key in ["files", "green_screen"]:
var arr = a.get(key, [])
if not (arr is Array):
continue
for raw in arr:
var u := ""
if raw is String:
u = raw
elif raw is Dictionary:
u = str(raw.get("url", ""))
if u == "":
continue
var locked: bool = u.begins_with(CghConfig.LOCK_PREFIX)
var real: String = u.substr(CghConfig.LOCK_PREFIX.length()) if locked else u
var fname: String = real.get_file()
var q := fname.find("?") # strip any query string
if q != -1:
fname = fname.substr(0, q)
var ext: String = fname.get_extension().to_lower()
var res := detect_res(fname)
var fmt := ext.to_upper()
if ext == "zip" or ext == "rar":
var zf := _archive_format(fname) # FBX/GLTF/OBJ/BLEND from the archive name
if zf != "":
fmt = zf
if key == "green_screen":
fmt += " (GS)"
out.append({
"url": ("" if locked else real),
"filename": fname,
"ext": ext,
"res": res,
"res_rank": res_rank(res),
"format": fmt,
"locked": locked,
"preview": false,
})
# 3D models ship only as .zip/.rar archives; the directly Godot-importable model is
# the preview .glb in `previews`. Add it so 3D assets are importable (no "No file"),
# unless the whole asset is patreon-locked (then it stays Upgrade-only).
var prev = a.get("previews", "")
if prev is String and prev != "" and not patreon_locked(asset):
var pf: String = prev
var pq2 := pf.find("?")
if pq2 != -1:
pf = pf.substr(0, pq2)
var pext := pf.get_file().get_extension().to_lower()
if pext in ["glb", "gltf", "fbx"]:
out.append({
"url": prev,
"filename": pf.get_file(),
"ext": pext,
"res": "",
"res_rank": 1, # above a real full-res file (rank 0) so that's preferred
"format": pext.to_upper(),
"locked": false,
"preview": true,
})
return out
## Format encoded in an archive's filename (CGHEVEN names them _Fbx/_Blend/_Gltf/_Obj).
static func _archive_format(fname: String) -> String:
var fu := fname.to_upper()
if fu.find("_GLTF") != -1 or fu.find("_GLB") != -1:
return "GLTF"
if fu.find("_FBX") != -1:
return "FBX"
if fu.find("_OBJ") != -1:
return "OBJ"
if fu.find("_USDC") != -1 or fu.find("_USDA") != -1 or fu.find("_USDZ") != -1 or fu.find("_USD") != -1:
return "USD" # Godot 4.7 has no native USD import -> treat as non-importable
if fu.find("_BLEND") != -1: # also matches _BLENDER
return "BLEND"
return ""
## Only the entries Godot can actually import:
## - direct glb/gltf/fbx/exr/hdr/png/jpg/webp (incl. the preview .glb)
## - a .zip Godot can unpack (ZIPReader) UNLESS it's a Blender-only archive (.blend inside)
## - never .rar (Godot can't extract it) or a BLEND archive
static func importable_entries(asset: Dictionary) -> Array:
var direct := CghConfig.EXT_3D + CghConfig.EXT_HDRI + CghConfig.EXT_IMAGE
var out := []
for e in file_entries(asset):
var ext: String = e["ext"]
if ext in direct:
out.append(e)
elif ext == "zip" and not (e["format"] in ["BLEND", "USD"]):
# .blend needs Blender; .usd/.usdc has no native Godot 4.7 importer — skip both so
# the addon never auto-picks a zip whose only model Godot can't read ("no importable
# file inside"). fbx/gltf/glb/obj zips still qualify.
out.append(e)
return out
## True if at least one file is unlocked (-> Download, not Upgrade).
## Matches AE's hasFreeFile()/lockedForUser(): lock is purely server-driven via the
## CGHLOCKED:: marker, so we only trust per-file `locked`. The backend already frees
## the lowest available resolution (e.g. 4K is free on a 4K/8K-only asset).
static func has_free_file(asset: Dictionary) -> bool:
for e in file_entries(asset):
if not e["locked"]:
return true
return false
## Best free entry: lowest-resolution unlocked importable file. For flipbooks, prefer a
## PNG/JPG/WebP sheet over EXR — Godot's EXR loader (tinyexr) can't read some compressions,
## so an EXR flipbook fails to import while the PNG version always works.
static func best_free_entry(asset: Dictionary) -> Dictionary:
var cands := importable_entries(asset).filter(func(e): return not e["locked"])
if cands.is_empty():
return {}
if is_flipbook(asset):
var imgs := cands.filter(func(e): return e["ext"] in ["png", "jpg", "jpeg", "webp"])
if not imgs.is_empty():
cands = imgs
# Lowest resolution first; at the SAME resolution prefer a Godot-native model format
# (gltf/glb > fbx > obj) so a 3D asset never auto-picks a format Godot imports poorly.
cands.sort_custom(func(a, b):
if int(a["res_rank"]) != int(b["res_rank"]):
return int(a["res_rank"]) < int(b["res_rank"])
return _fmt_priority(str(a["format"])) < _fmt_priority(str(b["format"])))
return cands[0]
## Godot-import preference among 3D model formats (lower = better). Non-model formats tie at 3.
static func _fmt_priority(fmt: String) -> int:
var f := fmt.to_upper()
if f == "GLTF" or f == "GLB":
return 0
if f == "FBX":
return 1
if f == "OBJ":
return 2
return 3
## Lowest-resolution UNLOCKED file of ANY format (even ones Godot can't auto-import,
## e.g. .rar). Fallback so a card whose only free file is non-importable still DOWNLOADS
## it to the shared folder (user can extract/use it) instead of the click doing nothing.
static func best_free_any(asset: Dictionary) -> Dictionary:
var cands := file_entries(asset).filter(func(e): return not e["locked"] and e["url"] != "")
if cands.is_empty():
return {}
cands.sort_custom(func(a, b): return a["res_rank"] < b["res_rank"])
return cands[0]
# Resolution detection — ported from the desktop addons (RES_PATTERNS). Dimension
# patterns first (most specific), then word/number tokens. First match wins.
const _RES_DEFS := [
["7680\\s*[x×]\\s*4320", "8K"],
["(4096|3840)\\s*[x×]\\s*2160", "4K"],
["(2560\\s*[x×]\\s*1440|2048\\s*[x×]\\s*1080)", "2K"],
["1920\\s*[x×]\\s*1080", "1080p"],
# ALL K resolutions must be recognised. A missing one (e.g. 6K) resolves to "" -> rank 0,
# which best_free_entry then treats as the LOWEST/"best" file — so a logged-in (unlocked)
# user would auto-pick the 6K DWAA HDRI Godot can't read. Cover 1K..16K explicitly.
["(?<![a-z0-9])16\\s*k(?![a-z0-9])|16384", "16K"],
["(?<![a-z0-9])12\\s*k(?![a-z0-9])|12288", "12K"],
["(?<![a-z0-9])8\\s*k(?![a-z0-9])|8192", "8K"],
["(?<![a-z0-9])6\\s*k(?![a-z0-9])|6144", "6K"],
["(?<![a-z0-9])5\\s*k(?![a-z0-9])|5120", "5K"],
["(?<![a-z0-9])4\\s*k(?![a-z0-9])|4096|uhd", "4K"],
["(?<![a-z0-9])3\\s*k(?![a-z0-9])|3072", "3K"],
["(?<![a-z0-9])2\\s*k(?![a-z0-9])|2048", "2K"],
["(?<![a-z0-9])1\\s*k(?![a-z0-9])|1024", "1K"],
["2160", "4K"],
["1080", "1080p"],
["720", "720p"],
["(?<![a-z0-9])hd(?![a-z0-9])", "HD"],
]
# Low-first rank used to pick the "best free entry" (1K before 4K), like the addons.
const _RES_RANK := {
"": 0, "PREVIEW": 1, "480P": 2, "720P": 3, "HD": 3,
"1080P": 4, "1K": 5, "2K": 6, "3K": 7, "4K": 8, "5K": 9, "6K": 10, "8K": 11, "12K": 12, "16K": 13,
}
static var _res_rx := [] # [{rx, label}] compiled lazily
static func _ensure_res_rx() -> void:
if not _res_rx.is_empty():
return
for d in _RES_DEFS:
var rx := RegEx.new()
rx.compile("(?i)" + d[0]) # case-insensitive
_res_rx.append({"rx": rx, "label": d[1]})
static func detect_res(fname: String) -> String:
_ensure_res_rx()
for e in _res_rx:
if e["rx"].search(fname) != null:
return e["label"]
return ""
static func res_rank(label: String) -> int:
return int(_RES_RANK.get(label.to_upper(), 0))
+1
View File
@@ -0,0 +1 @@
uid://dn5sgwiqu512l
+285
View File
@@ -0,0 +1,285 @@
@tool
extends Node
class_name CghAuth
## Web-broker login (BlenderKit-style), mirroring the Blender addon:
## 1. open system browser -> cgheven.com/addon-login
## 2. run a loopback server, catch the handoff ?code=
## 3. exchange the code with the backend for a session token (JWT)
## Godot has NO HTTP server, only raw TCP, so we hand-parse the request line
## and hand-write the HTTP response. Also supports license-key + machine_hash.
signal logged_in(plan: String)
signal login_failed(message: String)
signal plan_refreshed(plan: String)
var session_token := ""
var plan := "Free"
var email := ""
var expires_at := "" # ISO date the plan/session expires (shown in the Account popup)
var _server: TCPServer
var _port := 0
var _state := ""
var _peers: Array = [] # [{peer, req, frames}] — ALL live loopback connections
var _wait_frames := 0 # global poll ticks since login started (overall timeout)
var _poll_timer: Timer = null # polls the loopback server (Timers fire reliably in the editor; _process may not)
var _exchange_http: HTTPRequest
var _heartbeat_http: HTTPRequest
func _ready() -> void:
_exchange_http = HTTPRequest.new(); add_child(_exchange_http)
_exchange_http.request_completed.connect(_on_exchange_done)
_heartbeat_http = HTTPRequest.new(); add_child(_heartbeat_http)
_heartbeat_http.request_completed.connect(_on_heartbeat_done)
_load_session()
# -------- persisted session (EditorSettings, per-user, cross-project) --------
func _load_session() -> void:
var es := EditorInterface.get_editor_settings()
if es.has_setting("cgheven/session_token"):
session_token = es.get_setting("cgheven/session_token")
plan = es.get_setting("cgheven/plan") if es.has_setting("cgheven/plan") else "Free"
email = es.get_setting("cgheven/email") if es.has_setting("cgheven/email") else ""
expires_at = es.get_setting("cgheven/expires_at") if es.has_setting("cgheven/expires_at") else ""
func _save_session() -> void:
var es := EditorInterface.get_editor_settings()
es.set_setting("cgheven/session_token", session_token)
es.set_setting("cgheven/plan", plan)
es.set_setting("cgheven/email", email)
es.set_setting("cgheven/expires_at", expires_at)
func is_logged_in() -> bool:
return session_token != ""
func is_premium() -> bool:
return plan == "Pro" or plan == "Studio" or plan == "Bundle"
func logout() -> void:
session_token = ""
plan = "Free"
email = ""
expires_at = ""
_save_session()
# -------------------------- web-broker login --------------------------
func start_login(register := false) -> void:
_server = TCPServer.new()
_port = 0
for p in CghConfig.LOOPBACK_PORTS:
if _server.listen(p, "127.0.0.1") == OK:
_port = p
break
if _port == 0:
login_failed.emit("Could not open a local login port. Close other addons and retry.")
return
# Build the SAME broker URL the Blender/AE addons use, so the website handles it
# identically: /addon-login?redirect=<loopback>&state=&addon=godot&machine=&label=
_state = "%08x%08x" % [randi(), randi()]
var redirect := CghConfig.loopback_redirect(_port)
var label := _device_label()
var url := "%s?redirect=%s&state=%s&addon=%s&machine=%s&label=%s" % [
CghConfig.login_base_url(),
redirect.uri_encode(), _state, CghConfig.ADDON_SLUG,
machine_hash().uri_encode(), label.uri_encode()]
if register:
url += "&screen=register"
OS.shell_open(url)
_start_poll() # poll the loopback server on a Timer (reliable in the editor)
func _start_poll() -> void:
if _poll_timer == null:
_poll_timer = Timer.new()
_poll_timer.wait_time = 0.05 # tick fast so we answer the browser's GET promptly
_poll_timer.one_shot = false
add_child(_poll_timer)
_poll_timer.timeout.connect(_poll_login)
_peers = []
_wait_frames = 0
_poll_timer.start()
func _stop_poll() -> void:
if _poll_timer != null:
_poll_timer.stop()
func _device_label() -> String:
var host := OS.get_environment("COMPUTERNAME") # Windows
if host == "":
host = OS.get_environment("HOSTNAME") # macOS / Linux
if host == "":
host = "Godot"
return "%s (%s)" % [host, OS.get_name()]
func _poll_login() -> void:
if _server == null or not _server.is_listening():
_stop_poll()
return
_wait_frames += 1
# Accept EVERY pending connection this tick, not just one. Chrome/Edge open a speculative
# "preconnect" socket alongside the real request; the old code latched onto ONE socket, so
# if it grabbed the empty preconnect it blocked there while the real GET (carrying ?code=)
# sat unanswered in the backlog — the browser then just spun on the localhost URL until a
# 30s timeout. We now track them all and answer whichever one actually delivers the code.
while _server.is_connection_available():
var np := _server.take_connection()
if np != null:
_peers.append({"peer": np, "req": "", "frames": 0})
var i := _peers.size() - 1
while i >= 0:
var slot: Dictionary = _peers[i]
var peer: StreamPeerTCP = slot["peer"]
peer.poll()
var st := peer.get_status()
if st == StreamPeerTCP.STATUS_CONNECTING:
slot["frames"] = int(slot["frames"]) + 1
i -= 1
continue
if st != StreamPeerTCP.STATUS_CONNECTED:
_peers.remove_at(i) # closed/errored (usually the idle preconnect) — drop it
i -= 1
continue
var avail := peer.get_available_bytes()
if avail > 0:
slot["req"] = str(slot["req"]) + peer.get_utf8_string(avail)
slot["frames"] = int(slot["frames"]) + 1
var req: String = str(slot["req"])
if req.find("\r\n") != -1:
var code := _extract_query_param(req, "code")
if code != "":
_answer_and_close(peer, code) # 302 -> branded cgheven.com/addon-login/done page
_peers.remove_at(i)
_finish_login(code)
return
# Complete request line but no ?code= (favicon, bare hit) — send it to the error
# page and drop it; another peer may still carry the real code.
_answer_and_close(peer, "")
_peers.remove_at(i)
i -= 1
continue
# Idle socket that never sends a request line — retire it after ~4s. This NEVER fails
# the login; only the global timeout below can.
if int(slot["frames"]) > 80:
peer.disconnect_from_host()
_peers.remove_at(i)
i -= 1
if _wait_frames > 1200: # ~60s overall safety timeout at 0.05s/tick
_finish_login("")
# Write the browser's 302 redirect to the branded cgheven.com/addon-login/done page — exactly
# like the Blender/AE/Premiere addons — so the user lands on our site, NOT a localhost page.
# The loopback is only the secure handoff; it's never the landing.
func _answer_and_close(peer: StreamPeerTCP, code: String) -> void:
var status := "ok" if code != "" else "error"
var dest := "%s/addon-login/done?addon=%s&status=%s" % [
CghConfig.web_base(), CghConfig.ADDON_SLUG, status]
var resp := "HTTP/1.1 302 Found\r\nLocation: %s\r\nConnection: close\r\nContent-Length: 0\r\n\r\n" % dest
peer.put_data(resp.to_utf8_buffer())
peer.poll()
peer.disconnect_from_host()
# Tear down the loopback (close any stragglers + stop the server) and, if we caught a code,
# redeem it for the session token.
func _finish_login(code: String) -> void:
for slot in _peers:
var p: StreamPeerTCP = slot["peer"]
if p != null:
p.disconnect_from_host()
_peers = []
if _server != null:
_server.stop()
_stop_poll()
if code == "":
login_failed.emit("No login code received. Please try logging in again.")
return
_exchange_code(code)
func _extract_query_param(req: String, key: String) -> String:
# req first line looks like: GET /callback?code=ABC&... HTTP/1.1
var line := req.split("\r\n")[0]
var qpos := line.find("?")
if qpos == -1:
return ""
var path := line.substr(qpos + 1)
path = path.split(" ")[0] # strip trailing " HTTP/1.1"
for pair in path.split("&"):
# Split on the FIRST '=' only — the value is a JWT/base64 handoff code that itself
# contains '=' padding, so a plain split("=") would break into 3+ parts and drop it.
var eq := pair.find("=")
if eq == -1:
continue
if pair.substr(0, eq) == key:
return pair.substr(eq + 1).uri_decode()
return ""
func _exchange_code(code: String) -> void:
# Redeem the handoff code for the session payload (same as Blender/AE).
var body := JSON.stringify({"code": code})
_exchange_http.request(CghConfig.addon_exchange_url(),
PackedStringArray(["Content-Type: application/json"]),
HTTPClient.METHOD_POST, body)
func _on_exchange_done(_r: int, code_status: int, _h: PackedStringArray, b: PackedByteArray) -> void:
if code_status < 200 or code_status >= 300:
login_failed.emit("Login exchange failed (HTTP %d)" % code_status)
return
var d = JSON.parse_string(b.get_string_from_utf8())
if typeof(d) != TYPE_DICTIONARY or not d.has("session_token"):
login_failed.emit("Login exchange returned no token.")
return
session_token = d["session_token"]
plan = _plan_name_from(d)
email = str(d.get("email", ""))
expires_at = str(d.get("expires_at", ""))
_save_session()
logged_in.emit(plan)
# The broker's `plan` field is the BILLING CYCLE ("Yearly"/"Monthly"), not the plan
# name — the real name is in `plan_label` ("Studio (Yearly)"). Derive a clean name
# so is_premium()/gating/analytics recognise paid users.
func _plan_name_from(d: Dictionary) -> String:
var hay := (str(d.get("plan_label", "")) + " " + str(d.get("plan", ""))).to_lower()
if hay.contains("studio"):
return "Studio"
if hay.contains("pro"):
return "Pro"
if hay.contains("bundle"):
return "Bundle"
if bool(d.get("is_premium", false)):
return "Pro"
return "Free"
# -------------------------- license-key activation --------------------------
func activate_key(key: String) -> void:
var body := JSON.stringify({
"license_key": key,
"machine_hash": machine_hash(),
"source": "godot",
})
# reuse the exchange HTTPRequest; same _on_exchange_done handler shape
_exchange_http.request(CghConfig.addon_activate_url(),
PackedStringArray(["Content-Type: application/json"]),
HTTPClient.METHOD_POST, body)
# -------------------------- heartbeat (live plan refresh) --------------------------
func heartbeat() -> void:
if session_token == "":
return
_heartbeat_http.request(CghConfig.heartbeat_url(),
PackedStringArray(["Authorization: Bearer " + session_token]),
HTTPClient.METHOD_GET)
func _on_heartbeat_done(_r: int, code_status: int, _h: PackedStringArray, b: PackedByteArray) -> void:
if code_status < 200 or code_status >= 300:
return
var d = JSON.parse_string(b.get_string_from_utf8())
if typeof(d) == TYPE_DICTIONARY and d.has("plan"):
var new_plan := _plan_name_from(d)
if new_plan != plan:
plan = new_plan
_save_session()
plan_refreshed.emit(plan)
# -------------------------- machine fingerprint (license-key path) --------------------------
static func machine_hash() -> String:
# NOTE (risk): must match the Blender addon fingerprint or existing keys won't validate.
return (OS.get_unique_id() + "|" + OS.get_name()).sha256_text()
+1
View File
@@ -0,0 +1 @@
uid://c6ock0fik2qi8
+75
View File
@@ -0,0 +1,75 @@
@tool
extends RefCounted
class_name CghCache
## Local JSON cache + thumbnail cache under user://cgheven (cross-project).
## Plan-keyed so a Free->Pro upgrade re-fetches instead of serving stale gated data.
# Bump this when the asset query changes shape (e.g. the "All" tab now excludes
# vfx/vdbs) — old cache files have a different prefix and are simply ignored.
const ASSET_CACHE_VERSION := "v2"
static func _asset_cache_path(plan: String, key: String) -> String:
return "%s/assets_%s_%s_%s.json" % [CghConfig.CACHE_DIR, ASSET_CACHE_VERSION, plan, key.sha256_text().substr(0, 12)]
## Store the page envelope {assets, meta} so pagination (pageCount) survives too.
static func save_assets(plan: String, key: String, assets: Array, meta := {}) -> void:
CghConfig.ensure_dirs()
var f := FileAccess.open(_asset_cache_path(plan, key), FileAccess.WRITE)
if f:
f.store_string(JSON.stringify({"assets": assets, "meta": meta}))
f.close()
## Returns {"assets":[...], "meta":{...}} or {} on a miss. Instant (disk read) —
## a page that was loaded once is served from here forever (no re-fetch).
static func load_page(plan: String, key: String) -> Dictionary:
var p := _asset_cache_path(plan, key)
if not FileAccess.file_exists(p):
return {}
var f := FileAccess.open(p, FileAccess.READ)
if not f:
return {}
var parsed = JSON.parse_string(f.get_as_text())
f.close()
if parsed is Dictionary and parsed.has("assets"):
return parsed
if parsed is Array: # back-compat with the old (array-only) cache format
return {"assets": parsed, "meta": {}}
return {}
## Wipe only the asset-list cache (keeps thumbnails) — used by the manual Refresh.
static func clear_assets() -> void:
var d := DirAccess.open(CghConfig.CACHE_DIR)
if d:
for file in d.get_files():
d.remove(file)
# --- Thumbnails: store the ALREADY-DECODED image as PNG, rebuild on load. ---
# (Source thumbnails are .webp; we must re-encode to PNG, not dump raw webp into a
# .png file — otherwise Image.load() picks the decoder by extension and fails.)
static func _thumb_path(url: String) -> String:
return "%s/%s.png" % [CghConfig.THUMB_DIR, url.sha256_text().substr(0, 16)]
static func has_thumb(url: String) -> bool:
return FileAccess.file_exists(_thumb_path(url))
static func save_thumb(url: String, img: Image) -> void:
if img == null:
return
CghConfig.ensure_dirs()
img.save_png(_thumb_path(url))
static func load_thumb(url: String) -> Texture2D:
var p := _thumb_path(url)
if not FileAccess.file_exists(p):
return null
var img := Image.new()
if img.load(p) != OK:
return null
return ImageTexture.create_from_image(img)
static func clear() -> void:
for dir in [CghConfig.CACHE_DIR, CghConfig.THUMB_DIR]:
var d := DirAccess.open(dir)
if d:
for file in d.get_files():
d.remove(file)
+1
View File
@@ -0,0 +1 @@
uid://c7tbtkjio3q4g
+269
View File
@@ -0,0 +1,269 @@
@tool
extends RefCounted
class_name CghConfig
## Central config + exact After-Effects-addon color palette.
## All colors copied 1:1 from the AE/PPRO addon spec so the Godot panel looks identical.
# ---------------------------------------------------------------------------
# Backend endpoints (override via OS env for dev, like the desktop addons)
# ---------------------------------------------------------------------------
const ADDON_SLUG := "godot"
const ADDON_VERSION := "1.0.0"
# Distribution channel tag (build-time). Lets PostHog split installs by where the
# user got the addon (website / godot_asset_library / adobe_exchange / …).
# Resolution order: env CGHEVEN_INSTALL_SOURCE -> bundled install_source.txt
# (res://addons/cgheven/install_source.txt, dropped in per-channel builds) -> default.
const INSTALL_SOURCE_DEFAULT := "website"
static func install_source() -> String:
var e := OS.get_environment("CGHEVEN_INSTALL_SOURCE").strip_edges()
if e != "":
return e
var p := "res://addons/cgheven/install_source.txt"
if FileAccess.file_exists(p):
var f := FileAccess.open(p, FileAccess.READ)
if f:
var t := f.get_as_text().strip_edges()
f.close()
if t != "":
return t
return INSTALL_SOURCE_DEFAULT
static func api_base() -> String:
var e := OS.get_environment("CGHEVEN_API_BASE")
return e if e != "" else "https://api.cgheven.com"
static func web_base() -> String:
var e := OS.get_environment("CGHEVEN_WEB_BASE")
return e if e != "" else "https://cgheven.com"
# Asset listing goes through the same gating proxy the other addons use.
static func public_assets_url() -> String:
return api_base() + "/api/proxy/public/assets" # guest browsing
static func authed_assets_url() -> String:
return api_base() + "/api/proxy/assets" # logged-in (Bearer)
# Category tree (top categories + subcategories) — same proxy, for the live
# subcategory dropdown.
static func public_categories_url() -> String:
return api_base() + "/api/proxy/public/categories"
static func authed_categories_url() -> String:
return api_base() + "/api/proxy/categories"
# BlenderKit-exact broker: the addon opens our website's /addon-login page, which logs
# the user in and redirects to the loopback with a short ?code=. addon.start_login()
# builds the full URL (it has the port/state/machine). Loopback path mirrors Blender.
static func login_base_url() -> String:
return web_base() + "/addon-login"
static func loopback_redirect(port: int) -> String:
return "http://127.0.0.1:%d/consumer/exchange/" % port
# Same endpoint the Blender/AE addons redeem their handoff code at.
static func addon_exchange_url() -> String:
return api_base() + "/api/logto/addon-exchange"
static func addon_activate_url() -> String:
return api_base() + "/api/addon-license/activate"
static func heartbeat_url() -> String:
return api_base() + "/api/addon-updates/heartbeat"
# Analytics goes through the backend (same as every desktop addon) so the PostHog
# key stays server-side — the addon never ships a key.
static func analytics_track_url() -> String:
return api_base() + "/api/analytics/track"
static func check_update_url() -> String:
return api_base() + "/api/addon-updates/check"
static func pricing_url() -> String:
return web_base() + "/pricing"
static func discord_url() -> String:
return "https://discord.gg/cgheven"
# PostHog (analytics) — same project as the desktop addons.
static func posthog_host() -> String:
return "https://us.i.posthog.com"
static func posthog_key() -> String:
var e := OS.get_environment("CGHEVEN_POSTHOG_KEY")
return e if e != "" else "" # filled at build/ship time
# Loopback OAuth ports (avoid 62485 = BlenderKit, like the Blender addon)
const LOOPBACK_PORTS := [62490, 62491, 62492]
# Locked-file marker the backend sends instead of a real CDN url.
const LOCK_PREFIX := "CGHLOCKED::"
# Local data lives under user:// (cross-project, per-user, always writable).
const DATA_DIR := "user://cgheven"
const CACHE_DIR := "user://cgheven/cache"
const THUMB_DIR := "user://cgheven/thumbs"
const DOWNLOAD_DIR := "user://cgheven/downloads"
const PREFS_FILE := "user://cgheven/prefs.json"
# ---------------------------------------------------------------------------
# User prefs (persisted JSON) — e.g. a custom download folder the user picks.
# ---------------------------------------------------------------------------
static func _read_prefs() -> Dictionary:
if not FileAccess.file_exists(PREFS_FILE):
return {}
var f := FileAccess.open(PREFS_FILE, FileAccess.READ)
if f == null:
return {}
var txt := f.get_as_text()
f.close()
var d = JSON.parse_string(txt)
return d if d is Dictionary else {}
static func _write_prefs(d: Dictionary) -> void:
DirAccess.make_dir_recursive_absolute(DATA_DIR)
var f := FileAccess.open(PREFS_FILE, FileAccess.WRITE)
if f:
f.store_string(JSON.stringify(d))
f.close()
# Where the user chose to save downloads ("" = use the default shared folder).
static func custom_download_dir() -> String:
return str(_read_prefs().get("download_dir", ""))
static func set_custom_download_dir(p: String) -> void:
var d := _read_prefs()
d["download_dir"] = p
_write_prefs(d)
# ---------------------------------------------------------------------------
# COLORS — exact hex from the After Effects addon (addon-ui-spec)
# ---------------------------------------------------------------------------
const C_BG := Color("141416")
const C_BG2 := Color("1a1a1f")
const C_CARD := Color("1e1e24")
const C_CARD_HOVER := Color("252530")
const C_INPUT := Color("1a1a20")
const C_NAV := Color("111114")
const C_ACCENT := Color("5b7cff")
const C_ACCENT_HOVER:= Color("6e8dff")
const C_ACCENT_DIM := Color(0.357, 0.486, 1.0, 0.14) # rgba(91,124,255,.14)
const C_TEXT1 := Color("f0f0f5")
const C_TEXT2 := Color("8a8a9a")
const C_TEXT3 := Color("505060")
const C_BORDER := Color(1, 1, 1, 0.07) # rgba(255,255,255,.07)
const C_BORDER_HOVER:= Color(1, 1, 1, 0.13) # rgba(255,255,255,.13)
const C_DROPDOWN := Color("1c1c22") # popover bg --bgd
const C_OK := Color("34c759")
const C_ERR := Color("ff453a")
const C_WARN := Color("ff9f0a")
# CGHEVEN brand gradient: #f97316 -> #ef4444 (StyleBoxFlat has no gradient,
# so the Upgrade/Login buttons use a solid mid-orange that reads the same).
const C_BRAND_A := Color("f97316")
const C_BRAND_B := Color("ef4444")
const C_BRAND := Color("f4502c") # mid of the gradient
const C_BRAND_HOVER := Color("e0431f")
const C_RES_BADGE := Color(0.039, 0.039, 0.071, 0.82) # rgba(10,10,18,.82)
const C_FOOTER_PILL := Color(0.5, 0.5, 0.5, 0.07) # rgba(128,128,128,.07)
# Radii / spacing (AE spec)
const RADIUS_SM := 4
const RADIUS_MD := 6
const RADIUS_LG := 8 # card radius
const RADIUS_XL := 10
const GRID_GAP := 7
# Category pills. Real backend slugs (from the Blender addon):
# vfx, 3d-models, hdri, flipbooks, vdbs, shaders, tutorials
# Godot can usefully consume only: 3d-models, hdri, flipbooks.
# vdbs / vfx(video) / tutorials / shaders are Blender-only and intentionally NOT listed.
const CATEGORIES := [
{"label": "All", "slug": ""},
{"label": "3D Models", "slug": "3d-models"},
{"label": "HDRI", "slug": "hdri"},
{"label": "Flipbooks", "slug": "flipbooks"},
]
# Subcategories per parent slug (from the Blender addon _PARENT_MAP).
# Each subcategory is itself a backend category Slug; selecting one filters by it.
const SUBCATEGORIES := {
"3d-models": [
{"label": "Furniture", "slug": "furniture"},
{"label": "Props", "slug": "props"},
{"label": "Weapons", "slug": "weapons"},
],
"hdri": [
{"label": "Space", "slug": "space-hdr"},
],
"flipbooks": [
{"label": "Fire", "slug": "fire-flipbooks"},
{"label": "Magic Effects", "slug": "magic-effects-flipbooks"},
{"label": "Muzzle Flashes","slug": "muzzle-flashes-flipbooks"},
{"label": "Tornado FX", "slug": "tornado-fx-flipbooks"},
],
"shaders": [],
}
# Sort options (matches AE/Blender)
const SORTS := [
{"label": "Newest", "param": "createdAt:desc"},
{"label": "Oldest", "param": "createdAt:asc"},
{"label": "A -> Z", "param": "Title:asc"},
{"label": "Z -> A", "param": "Title:desc"},
]
# File extensions Godot can import, grouped by purpose.
const EXT_3D := ["glb", "gltf", "fbx"]
const EXT_HDRI := ["exr", "hdr"]
const EXT_IMAGE := ["png", "jpg", "jpeg", "webp"] # flipbook sheets / textures
const EXT_ARCHIVE := ["zip"]
# Resolution tokens recognised in filenames (index 0 = lowest = free fallback)
const RES_ORDER := ["1k", "2k", "4k", "8k", "16k"]
# Discord invite (Godot community — placeholder, set real link before ship)
const DISCORD := "https://discord.gg/cgheven"
static func ensure_dirs() -> void:
for d in [DATA_DIR, CACHE_DIR, THUMB_DIR, DOWNLOAD_DIR]:
DirAccess.make_dir_recursive_absolute(d)
# ---------------------------------------------------------------------------
# Cross-addon "file scanner" — shared CGHEVEN download folders on the OS.
# A file downloaded by AE (~/Downloads/CGHEVEN), Blender (~/Documents/CGHEVEN)
# or this addon should show the ✓ "downloaded" tick everywhere. We scan all of
# these by filename so the tick is shared across every CGHEVEN addon.
# ---------------------------------------------------------------------------
static func os_home() -> String:
var h := OS.get_environment("USERPROFILE") # Windows
if h == "":
h = OS.get_environment("HOME") # macOS / Linux
return h
static func shared_download_dirs() -> Array:
var home := os_home()
var dirs := []
var custom := custom_download_dir()
if custom != "":
dirs.append(custom) # user-chosen folder
if home != "":
dirs.append(home.path_join("Downloads").path_join("CGHEVEN")) # AE / PPRO default
dirs.append(home.path_join("Documents").path_join("CGHEVEN")) # Blender default
dirs.append(ProjectSettings.globalize_path(DOWNLOAD_DIR)) # this addon
return dirs
# Where THIS addon saves downloads — a user-chosen folder if set, else the shared
# AE folder so the other CGHEVEN addons see the files too.
static func primary_download_dir() -> String:
var custom := custom_download_dir()
if custom != "":
return custom
var home := os_home()
if home != "":
return home.path_join("Downloads").path_join("CGHEVEN")
return ProjectSettings.globalize_path(DOWNLOAD_DIR)
# Sanitize a CDN filename the same way the Blender addon does (cross-addon match).
static func safe_download_name(filename: String) -> String:
var f := filename.uri_decode()
for ch in ["<", ">", ":", "\"", "/", "\\", "|", "?", "*"]:
f = f.replace(ch, "_")
return f
# ---------------------------------------------------------------------------
# Responsive grid columns — exact AE breakpoints (panel width -> column count).
# ---------------------------------------------------------------------------
static func responsive_columns(width: float) -> int:
# Default narrow dock shows 2 large AE-style cards; grows on wider panels.
if width < 520.0: return 2
if width < 780.0: return 3
if width < 1040.0: return 4
if width < 1300.0: return 5
if width < 1560.0: return 6
return 7
+1
View File
@@ -0,0 +1 @@
uid://67wa4ry6asqt
+90
View File
@@ -0,0 +1,90 @@
@tool
extends Node
class_name CghDownloader
## Threaded file download with polled progress (Godot has no progress signal).
## Emits progress every frame while a download is in flight.
signal progress(pct: float, downloaded: int, total: int)
signal finished(path: String)
signal failed(message: String)
var _http: HTTPRequest
var _active := false
var _dest := ""
var _last_got := 0 # bytes seen last frame — to detect a dead/stalled connection
var _stall := 0.0 # seconds with zero new bytes
const STALL_LIMIT := 30.0 # a hung download must not block every later one forever
func _ready() -> void:
_http = HTTPRequest.new()
_http.use_threads = true
add_child(_http)
_http.request_completed.connect(_on_done)
func download(url: String, filename: String, headers := PackedStringArray()) -> void:
if _active:
failed.emit("A download is already in progress.")
return
CghConfig.ensure_dirs()
# Save into the SHARED CGHEVEN folder so AE/Blender/etc. see it too (file scanner).
var dir := CghConfig.primary_download_dir()
DirAccess.make_dir_recursive_absolute(dir)
_dest = dir.path_join(CghConfig.safe_download_name(filename))
# Clear any orphaned/stuck request first (mirrors api_client) so a previously hung
# download that left the node "processing" can't block this new one from starting.
_http.cancel_request()
_http.download_file = _dest
var err := _http.request(url, headers, HTTPClient.METHOD_GET)
if err != OK:
failed.emit("Download could not start (err %d)" % err)
return
_active = true
_last_got = 0
_stall = 0.0
set_process(true)
func _process(dt: float) -> void:
if not _active:
set_process(false)
return
var got := _http.get_downloaded_bytes()
var total := _http.get_body_size() # -1 on chunked / no Content-Length
# Dead-connection guard: if no new bytes arrive for STALL_LIMIT seconds, give up so
# the download queue isn't blocked forever (a hung request never fires _on_done).
if got > _last_got:
_last_got = got
_stall = 0.0
else:
_stall += dt
if _stall >= STALL_LIMIT:
_http.cancel_request()
_active = false
set_process(false)
failed.emit("Download stalled (no data for %ds) — check your connection and retry." % int(STALL_LIMIT))
return
if total > 0:
progress.emit(float(got) / float(total) * 100.0, got, total)
else:
progress.emit(-1.0, got, -1) # indeterminate -> spinner
func cancel() -> void:
if _active:
_http.cancel_request()
_active = false
set_process(false)
func _on_done(result: int, code: int, _h: PackedStringArray, _b: PackedByteArray) -> void:
_active = false
set_process(false)
if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300:
var hint := ""
if code == 401 or code == 403:
hint = " — access denied (file link may be expired/invalid on the server)"
elif code == 404:
hint = " — file not found on the server"
elif result != HTTPRequest.RESULT_SUCCESS:
hint = " — network/connection issue"
failed.emit("Download failed for %s%s [result %d, HTTP %d]" % [_dest.get_file(), hint, result, code])
return
CghDownloads.mark_saved(_dest.get_file())
finished.emit(_dest)
+1
View File
@@ -0,0 +1 @@
uid://c3p8ea4j8waqs
+221
View File
@@ -0,0 +1,221 @@
@tool
extends RefCounted
class_name CghDownloads
## Cross-addon "downloaded" state — matches the After Effects / Premiere / DaVinci
## addons, which all share ONE manifest file: ~/.cgheven_cg_dl_v2.json
##
## Detection is by ASSET ID (each record is {id, fp, fmt, res, title, cat, at}); an
## asset counts as downloaded if the manifest has a record for its id whose file is
## still on disk. We append a record whenever this addon finishes a download, so a
## file grabbed in Godot shows the ✓ in AE/Premiere/DaVinci too, and vice-versa.
##
## We ALSO keep a cheap filename scan of the shared download folders as a secondary
## signal (this catches the Blender addon, which uses its own ~/Documents/CGHEVEN +
## a separate prefs file and isn't part of the v2 manifest).
const MANIFEST_NAME := ".cgheven_cg_dl_v2.json"
const MAX_RECORDS := 200
static var _names := {} # lower-case filename -> true (folder scan)
static var _ids := {} # asset-id string -> true (manifest, file-exists verified)
static var _id_res := {} # "id|res" -> true (per-resolution, cross-addon)
static var _scanned := false
# Normalize an id so "3535.0" (an old float-string id) matches "3535".
static func _norm_id(v) -> String:
var s := str(v)
if s.ends_with(".0"):
s = s.substr(0, s.length() - 2)
return s
static func _manifest_path() -> String:
var home := CghConfig.os_home()
if home == "":
return ""
return home.path_join(MANIFEST_NAME)
static func _read_manifest() -> Array:
var p := _manifest_path()
if p == "" or not FileAccess.file_exists(p):
return []
var f := FileAccess.open(p, FileAccess.READ)
if f == null:
return []
var txt := f.get_as_text()
f.close()
var d = JSON.parse_string(txt)
return d if d is Array else []
static func _write_manifest(arr: Array) -> void:
var p := _manifest_path()
if p == "":
return
var f := FileAccess.open(p, FileAccess.WRITE)
if f:
f.store_string(JSON.stringify(arr))
f.close()
static func rescan() -> void:
_names.clear()
_ids.clear()
_id_res.clear()
# 1) shared manifest (AE / Premiere / DaVinci) — verify each file still exists
for r in _read_manifest():
if not (r is Dictionary):
continue
var fp := str(r.get("fp", ""))
if fp != "" and FileAccess.file_exists(fp):
var rid := _norm_id(r.get("id", ""))
_ids[rid] = true
_id_res["%s|%s" % [rid, str(r.get("res", ""))]] = true
# 2) folder filename scan (Blender + this addon's own folder)
for dir in CghConfig.shared_download_dirs():
_scan_dir(dir)
_scanned = true
static func _scan_dir(abs_dir: String) -> void:
var da := DirAccess.open(abs_dir)
if da == null:
return
da.list_dir_begin()
var fn := da.get_next()
while fn != "":
if not da.current_is_dir() and not fn.ends_with(".cghpart"):
_names[fn.to_lower()] = true
fn = da.get_next()
da.list_dir_end()
static func _ensure() -> void:
if not _scanned:
rescan()
## True if this asset is already downloaded by ANY CGHEVEN addon.
static func is_downloaded(asset: Dictionary) -> bool:
_ensure()
# id match via the shared manifest (the AE/Premiere/DaVinci scheme; cross-addon)
if _ids.has(_norm_id(CghAsset.id(asset))):
return true
# fallback: any of this asset's files present on disk by name
for e in CghAsset.file_entries(asset):
if e["locked"]:
continue
var nm: String = CghConfig.safe_download_name(e["filename"]).to_lower()
if _names.has(nm):
return true
return false
## True if a SPECIFIC resolution of this asset was downloaded by any addon.
static func is_res_downloaded(asset_id: String, res: String) -> bool:
_ensure()
return _id_res.has("%s|%s" % [_norm_id(asset_id), res])
## True if THIS specific file (by name) is already on disk in a shared folder —
## used to switch a per-resolution menu row from "Download" to "Import".
static func has_file(filename: String) -> bool:
_ensure()
return _names.has(CghConfig.safe_download_name(filename).to_lower())
## Absolute path of this file in ANY shared CGHEVEN folder, or "" if not on disk.
static func local_path(filename: String) -> String:
var safe := CghConfig.safe_download_name(filename)
for d in CghConfig.shared_download_dirs():
var p: String = d.path_join(safe)
if FileAccess.file_exists(p):
return p
return ""
## Record a finished download into BOTH the in-memory caches and the shared manifest
## so every other CGHEVEN addon sees it immediately.
static func record(asset: Dictionary, fp: String, fmt: String, res: String) -> void:
var id := _norm_id(CghAsset.id(asset))
_ids[id] = true
_id_res["%s|%s" % [id, res]] = true
_names[CghConfig.safe_download_name(fp.get_file()).to_lower()] = true
var arr := _read_manifest()
# drop any prior record for the same id+fmt+res, then prepend the new one
var kept := []
for r in arr:
if not (r is Dictionary):
continue
if _norm_id(r.get("id", "")) == id and str(r.get("fmt", "")) == fmt and str(r.get("res", "")) == res:
continue
kept.append(r)
kept.insert(0, {
"id": id,
"fp": fp,
"fmt": fmt,
"res": res,
"title": CghAsset.title(asset),
"cat": CghAsset.category_slug(asset),
"at": int(Time.get_unix_time_from_system() * 1000.0),
})
if kept.size() > MAX_RECORDS:
kept = kept.slice(0, MAX_RECORDS)
_write_manifest(kept)
## Delete every downloaded copy of this asset from the shared folders and drop its
## manifest records, so the card flips back to "Download". Use it to re-fetch a file
## that went missing or imported corrupt. Returns how many files were removed.
static func delete(asset: Dictionary) -> int:
_ensure()
var id := _norm_id(CghAsset.id(asset))
var names := {}
for e in CghAsset.file_entries(asset):
names[CghConfig.safe_download_name(e["filename"]).to_lower()] = true
var removed := 0
# Drop this asset's manifest records, deleting the files they point at first.
var kept := []
for r in _read_manifest():
if not (r is Dictionary):
continue
if _norm_id(r.get("id", "")) == id:
var fp := str(r.get("fp", ""))
if fp != "":
names[fp.get_file().to_lower()] = true
if FileAccess.file_exists(fp) and DirAccess.remove_absolute(fp) == OK:
removed += 1
continue
kept.append(r)
_write_manifest(kept)
# Delete any remaining copies by filename across every shared CGHEVEN folder.
for d in CghConfig.shared_download_dirs():
for nm in names.keys():
var p: String = d.path_join(nm)
if FileAccess.file_exists(p) and DirAccess.remove_absolute(p) == OK:
removed += 1
# Clear the in-memory caches so is_downloaded()/has_file() update immediately.
_ids.erase(id)
for k in _id_res.keys():
if str(k).begins_with(id + "|"):
_id_res.erase(k)
for nm in names.keys():
_names.erase(nm)
return removed
## Back-compat shim — older callers just marked a filename saved.
static func mark_saved(filename: String) -> void:
_names[CghConfig.safe_download_name(filename).to_lower()] = true
## Titles (+ resolutions) of assets present in the shared cross-addon manifest whose
## file is still on disk — used by the ⤓ Sync button to show "what synced". One row
## per asset (resolutions merged), newest first.
static func synced_records() -> Array:
var by_id := {} # id -> {title, cat, res:[...]}
var order := [] # id first-seen order (manifest is newest-first)
for r in _read_manifest():
if not (r is Dictionary):
continue
var fp := str(r.get("fp", ""))
if fp == "" or not FileAccess.file_exists(fp):
continue
var id := _norm_id(r.get("id", ""))
if not by_id.has(id):
by_id[id] = {"title": str(r.get("title", "")), "cat": str(r.get("cat", "")), "res": []}
order.append(id)
var res := str(r.get("res", ""))
if res != "" and not (res in by_id[id]["res"]):
by_id[id]["res"].append(res)
var out := []
for id in order:
out.append(by_id[id])
return out
+1
View File
@@ -0,0 +1 @@
uid://c3l7vtfgil2ed
+44
View File
@@ -0,0 +1,44 @@
@tool
extends RefCounted
class_name CghFavorites
## Favorites store (persisted to user://cgheven/favorites.json). Stores the
## whole asset dict so the Favorites tab can render without a re-fetch.
const _PATH := "user://cgheven/favorites.json"
static func _load() -> Dictionary:
if not FileAccess.file_exists(_PATH):
return {}
var f := FileAccess.open(_PATH, FileAccess.READ)
if not f:
return {}
var d = JSON.parse_string(f.get_as_text())
f.close()
return d if d is Dictionary else {}
static func _save(d: Dictionary) -> void:
CghConfig.ensure_dirs()
var f := FileAccess.open(_PATH, FileAccess.WRITE)
if f:
f.store_string(JSON.stringify(d))
f.close()
static func is_fav(asset: Dictionary) -> bool:
return _load().has(CghAsset.id(asset))
static func toggle(asset: Dictionary) -> bool:
var d := _load()
var k := CghAsset.id(asset)
if d.has(k):
d.erase(k)
_save(d)
return false
d[k] = asset
_save(d)
return true
static func all() -> Array:
return _load().values()
static func count() -> int:
return _load().size()
+1
View File
@@ -0,0 +1 @@
uid://drlc4v2qvmasj
@@ -0,0 +1,114 @@
@tool
extends RefCounted
class_name CghFlipbookImport
## Builds a PLAYING flipbook from a sprite-sheet (NxM grid) and adds it to the
## open scene. Uses Sprite3D (hframes/vframes) + an AnimationPlayer that keyframes
## the `frame` property — the official, non-deprecated path that animates BOTH in
## the editor viewport and at runtime (AnimatedTexture is deprecated/broken).
## Handles .exr HDR sheets (load_exr_from_buffer) as well as png/jpg/webp.
static func from_sheet(sheet_path: String, h_frames: int, v_frames: int, fps := 30.0) -> Sprite3D:
# SNIFF the real format from magic bytes rather than trusting the extension — CDN sheets
# are sometimes served as webp/jpg but named ".png", and Godot's extension-based img.load()
# then fails with a misleading "PNG corrupt" (png_driver_common). Sniffing decodes them.
var img := _decode_sheet(FileAccess.get_file_as_bytes(sheet_path), sheet_path.get_extension().to_lower())
if img == null:
push_error("[CGHEVEN] Could not load flipbook sheet: " + sheet_path)
return null
var tex := ImageTexture.create_from_image(img)
var cols: int = max(1, h_frames)
var rows: int = max(1, v_frames)
var total: int = cols * rows
# Sprite3D slices the sheet natively via hframes/vframes; `frame` indexes the grid.
var spr := Sprite3D.new()
spr.name = "CghFlipbook"
spr.texture = tex
spr.hframes = cols
spr.vframes = rows
spr.frame = 0
spr.billboard = BaseMaterial3D.BILLBOARD_ENABLED # face the camera (VFX)
spr.alpha_cut = SpriteBase3D.ALPHA_CUT_DISABLED # default = alpha blend
var edited := EditorInterface.get_edited_scene_root()
if edited == null:
push_warning("[CGHEVEN] No open scene — flipbook node created but not added. Open a scene and re-import.")
return spr
# A Sprite3D only lives/renders in a 3D world. If the open scene is a UI
# (Control) or 2D (Node2D) scene, nesting it inside that tree makes it
# invisible. So guarantee a 3D host: use the root if it is Node3D, else drop
# the flipbook under a dedicated Node3D wrapper (reused across imports).
var parent_3d: Node = _ensure_3d_parent(edited)
parent_3d.add_child(spr)
spr.owner = edited
# Single-frame sheet (no NxM in the filename) — nothing to animate.
if total <= 1:
return spr
# Build a discrete, looping animation that steps `frame` 0..total-1.
var anim := Animation.new()
var track := anim.add_track(Animation.TYPE_VALUE)
anim.track_set_path(track, NodePath(".:frame"))
anim.value_track_set_update_mode(track, Animation.UPDATE_DISCRETE)
anim.length = float(total) / fps
anim.loop_mode = Animation.LOOP_LINEAR
for i in total:
anim.track_insert_key(track, float(i) / fps, i)
var lib := AnimationLibrary.new()
lib.add_animation("play", anim)
var ap := AnimationPlayer.new()
ap.name = "FlipbookPlayer"
spr.add_child(ap)
ap.owner = edited
ap.add_animation_library("", lib)
ap.autoplay = "play" # plays automatically when the scene runs
ap.play("play") # and starts immediately in the editor viewport
return spr
## Decode a sprite-sheet from raw bytes, sniffing the real format from magic bytes (the file
## extension often lies). PNG=89 50 4E 47, JPEG=FF D8, WEBP=RIFF....WEBP, EXR=76 2F 31 01.
## Returns null on an unrecognised/corrupt body.
static func _decode_sheet(body: PackedByteArray, ext: 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)
elif body[0] == 0x76 and body[1] == 0x2F and body[2] == 0x31 and body[3] == 0x01:
ok = img.load_exr_from_buffer(body)
elif ext == "exr":
ok = img.load_exr_from_buffer(body) # magic missing but named .exr — try anyway
else:
return null
return img if ok == OK else null
## Returns a 3D-capable parent for the flipbook. If the edited scene root is a
## Node3D we use it directly; otherwise (Control / Node2D / plain Node scene) we
## create — or reuse — a "CghevenFlipbooks" Node3D so the sprite always sits in a
## real 3D context and renders in the 3D viewport instead of vanishing into UI.
static func _ensure_3d_parent(edited: Node) -> Node:
if edited is Node3D:
return edited
var existing := edited.get_node_or_null(NodePath("CghevenFlipbooks"))
if existing != null and existing is Node3D:
return existing
var host := Node3D.new()
host.name = "CghevenFlipbooks"
edited.add_child(host)
host.owner = edited
push_warning("[CGHEVEN] Open scene is not 3D — flipbook placed under a new "
+ "'CghevenFlipbooks' Node3D. Switch to the 3D viewport (or open a 3D "
+ "scene) to see it play.")
return host
@@ -0,0 +1 @@
uid://d06uk4ol66bf2
@@ -0,0 +1,26 @@
@tool
extends RefCounted
class_name CghGltfImport
## Imports a downloaded .glb/.gltf into the currently edited scene.
static func import_into_scene(glb_path: String) -> Node:
var doc := GLTFDocument.new()
var state := GLTFState.new()
var err := doc.append_from_file(glb_path, state)
if err != OK:
push_error("[CGHEVEN] glTF parse failed: %d" % err)
return null
var scene_root := doc.generate_scene(state)
if scene_root == null:
push_error("[CGHEVEN] glTF generate_scene returned null")
return null
var edited := EditorInterface.get_edited_scene_root()
if edited == null:
push_warning("[CGHEVEN] No open scene to import into.")
return scene_root
edited.add_child(scene_root)
# own the node so it saves with the scene
scene_root.owner = edited
for c in scene_root.find_children("*", "", true, false):
c.owner = edited
return scene_root
@@ -0,0 +1 @@
uid://c2th51vfweq7f
@@ -0,0 +1,48 @@
@tool
extends RefCounted
class_name CghHdriImport
## Sets the scene's world environment from an equirectangular HDRI.
## Builds WorldEnvironment → Environment(BG_SKY) → Sky → PanoramaSkyMaterial,
## with ambient + reflections taken from the sky (official Godot sky workflow).
## Reuses an existing WorldEnvironment if the scene already has one (Godot allows
## only one active per scene tree). HDRIs are served as .exr (load_exr_from_buffer).
static func apply_as_world(exr_path: String) -> WorldEnvironment:
var img := Image.new()
var ext := exr_path.get_extension().to_lower()
var ok: int = img.load_exr_from_buffer(FileAccess.get_file_as_bytes(exr_path)) if ext == "exr" \
else img.load(exr_path)
if ok != OK:
push_error("[CGHEVEN] Could not load HDRI: " + exr_path)
return null
var tex := ImageTexture.create_from_image(img)
var sky_mat := PanoramaSkyMaterial.new()
sky_mat.panorama = tex
var sky := Sky.new()
sky.sky_material = sky_mat
var env := Environment.new()
env.background_mode = Environment.BG_SKY
env.sky = sky
env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
env.reflected_light_source = Environment.REFLECTION_SOURCE_SKY
var edited := EditorInterface.get_edited_scene_root()
if edited == null:
push_warning("[CGHEVEN] No open scene — WorldEnvironment created but not added.")
var orphan := WorldEnvironment.new()
orphan.environment = env
return orphan
# Reuse an existing WorldEnvironment (only one may be active per scene tree).
var existing := edited.find_children("*", "WorldEnvironment", true, false)
var we: WorldEnvironment
if existing.is_empty():
we = WorldEnvironment.new()
we.name = "WorldEnvironment"
edited.add_child(we)
we.owner = edited
else:
we = existing[0]
we.environment = env
return we
@@ -0,0 +1 @@
uid://b6ro7gtcoafbj
@@ -0,0 +1,42 @@
@tool
extends RefCounted
class_name CghMaterialImport
## Builds a StandardMaterial3D from a PBR texture set and assigns it to the
## selected MeshInstance3D. NOTE: Blender procedural node-graphs do NOT import;
## only the baked-PBR subset is reproduced here.
static func build_pbr(tex_paths: Dictionary) -> StandardMaterial3D:
# tex_paths keys: albedo, normal, roughness, metallic, emission, ao
var m := StandardMaterial3D.new()
if tex_paths.has("albedo"):
m.albedo_texture = _load(tex_paths["albedo"])
if tex_paths.has("normal"):
m.normal_enabled = true
m.normal_texture = _load(tex_paths["normal"])
if tex_paths.has("roughness"):
m.roughness_texture = _load(tex_paths["roughness"])
if tex_paths.has("metallic"):
m.metallic = 1.0
m.metallic_texture = _load(tex_paths["metallic"])
if tex_paths.has("ao"):
m.ao_enabled = true
m.ao_texture = _load(tex_paths["ao"])
if tex_paths.has("emission"):
m.emission_enabled = true
m.emission_texture = _load(tex_paths["emission"])
return m
static func _load(path: String) -> Texture2D:
var img := Image.new()
if img.load(path) != OK:
return null
return ImageTexture.create_from_image(img)
static func assign_to_selection(mat: Material) -> bool:
var sel := EditorInterface.get_selection().get_selected_nodes()
for n in sel:
if n is MeshInstance3D:
n.material_override = mat
return true
push_warning("[CGHEVEN] Select a MeshInstance3D to apply the material.")
return false
@@ -0,0 +1 @@
uid://binrjwgiv4o85
+231
View File
@@ -0,0 +1,231 @@
@tool
extends RefCounted
class_name CghImportRouter
## Routes a downloaded file to the right importer. CRITICAL: it routes by the
## asset's CATEGORY first, not just the extension — an `.exr` can be EITHER an
## HDRI panorama (hdri category) OR a flipbook sprite-sheet (flipbooks category),
## so extension alone is ambiguous and used to send flipbook .exr sheets to the
## HDRI importer (sky), which is why flipbooks "imported but never played".
##
## After a successful live-instance into the open scene, the source file is ALSO
## copied into res://CGHEVEN/<category>/ so it shows up in the FileSystem dock and
## the user can find / reuse it (Godot only imports files that live under res://).
const RES_ROOT := "res://CGHEVEN"
# Model formats Godot's EDITOR can import (into a PackedScene, or a Mesh for .obj) — far
# more than the runtime GLTFDocument path, which only handles glTF. Order = most reliable
# first (used to pick the best model inside a zip, like the Blender addon's priority).
const EDITOR_MODEL_PRIORITY := ["glb", "gltf", "fbx", "dae", "obj"]
static func import_file(path: String, category_slug: String) -> String:
var ext := path.get_extension().to_lower()
# unpack archives first, then re-route on the inner importable file
if ext == "zip":
var inner := _extract_zip(path)
if inner == "":
return "Extracted zip, but found no importable file inside."
return import_file(inner, category_slug)
var cat := category_slug.to_lower()
var msg := ""
# Category decides intent. Match the TOKEN, not exact equality, because the slug can be a
# SUBCATEGORY ("muzzle-flashes-flipbooks", "space-hdr", …) — matching "flipbooks" exactly
# used to send subcategory flipbook .exr sheets to the HDRI importer (wrong). Fall back to
# extension when the category is unknown.
if cat.contains("flipbook"):
msg = _do_flipbook(path)
elif cat.contains("hdr"):
msg = _do_hdri(path)
elif cat == "3d-models" or cat.contains("model"):
msg = _do_3d(path)
elif ext in CghConfig.EXT_3D:
msg = _do_3d(path)
elif ext in CghConfig.EXT_HDRI:
msg = _do_hdri(path)
elif ext in CghConfig.EXT_IMAGE:
msg = _do_flipbook(path)
else:
# Not auto-importable — still drop it in the project so the user has it.
var rp0 := copy_into_project(path, cat)
return "Saved to %s (no auto-import for .%s)." % [rp0, ext] if rp0 != "" else \
"Downloaded to %s (no auto-import for .%s)." % [path, ext]
# Copy a reusable source into the project (FileSystem dock) and tell the user.
var rp := copy_into_project(path, cat)
if rp != "":
msg += " Saved in %s/" % rp.get_base_dir()
return msg
# ---------------- per-type importers ----------------
static func _do_flipbook(path: String) -> String:
var hv := _flipbook_grid(path)
var spr := CghFlipbookImport.from_sheet(path, hv.x, hv.y)
if spr == null:
if path.get_extension().to_lower() == "exr":
return "Flipbook import failed — this .exr uses a compression Godot can't read. Pick the PNG version from the ▾ menu."
return "Flipbook import failed."
if hv == Vector2i(1, 1):
return "Flipbook added (single frame — grid NxM not in filename, can't auto-play)."
return "Flipbook added & playing (%dx%d grid)." % [hv.x, hv.y]
static func _do_hdri(path: String) -> String:
var we := CghHdriImport.apply_as_world(path)
return "HDRI applied as the scene's world/sky." if we else \
"HDRI import failed — this .exr uses a compression Godot can't read (e.g. DWAA/PXR24). Try another HDRI."
static func _do_3d(path: String) -> String:
var ext := path.get_extension().to_lower()
var n: Node = _import_fbx(path) if ext == "fbx" else CghGltfImport.import_into_scene(path)
return "3D model added to the scene." if n else "3D import failed (open a scene first?)."
static func _import_fbx(path: String) -> Node:
# ufbx (4.3+) — FBXDocument mirrors GLTFDocument
var doc := FBXDocument.new()
var state := FBXState.new()
if doc.append_from_file(path, state) != OK:
return null
var root := doc.generate_scene(state)
var edited := EditorInterface.get_edited_scene_root()
if edited and root:
edited.add_child(root)
root.owner = edited
for c in root.find_children("*", "", true, false):
c.owner = edited
return root
# ---------------- project copy (FileSystem dock) ----------------
# Copy the downloaded source into res://CGHEVEN/<category>/<filename> so Godot
# imports it and it appears in the FileSystem dock (only files under res:// are
# importable/usable). Returns the res:// path, or "" on failure.
static func copy_into_project(path: String, category_slug: String) -> String:
var bytes := FileAccess.get_file_as_bytes(path)
if bytes.is_empty():
return ""
var sub := category_slug if category_slug != "" else "assets"
var dir := RES_ROOT.path_join(sub)
DirAccess.make_dir_recursive_absolute(dir)
var dest := dir.path_join(CghConfig.safe_download_name(path.get_file()))
var f := FileAccess.open(dest, FileAccess.WRITE)
if f == null:
return ""
f.store_buffer(bytes)
f.close()
# Make the editor pick up + import the new file so it shows in the dock.
if Engine.is_editor_hint():
var efs := EditorInterface.get_resource_filesystem()
if efs and not efs.is_scanning():
efs.scan()
return dest
## Extract EVERY file from a zip into a sibling temp dir and return the best
## editor-importable MODEL inside (glb/gltf > fbx > dae > obj), or "" if none. Extracts
## the whole tree (not just the model) so .bin/.mtl/textures land next to it and survive
## the import. Mirrors the Blender addon's _pick_best_model_in_tree.
static func extract_model_from_zip(zip_path: String) -> String:
var reader := ZIPReader.new()
if reader.open(zip_path) != OK:
return ""
var out_dir := zip_path.get_basename() + "_unzip"
DirAccess.make_dir_recursive_absolute(out_dir)
var best := ""
var best_rank := 999
for entry in reader.get_files():
if entry.ends_with("/"):
continue
var rel := entry.replace("\\", "/")
var out := out_dir.path_join(rel)
DirAccess.make_dir_recursive_absolute(out.get_base_dir())
var f := FileAccess.open(out, FileAccess.WRITE)
if f:
f.store_buffer(reader.read_file(entry))
f.close()
var rank: int = EDITOR_MODEL_PRIORITY.find(rel.get_extension().to_lower())
if rank != -1 and rank < best_rank:
best_rank = rank
best = out
reader.close()
return best
## Copy a model file AND its sibling files (textures / .bin / .mtl in the SAME folder)
## into res://CGHEVEN/<category>/<modelname>/ so the editor import keeps materials. Use
## this for models extracted from a zip (isolated temp dir); for a bare downloaded file
## use copy_into_project (single file). Returns the model's res:// path, or "" on failure.
static func copy_model_into_project(model_path: String, category_slug: String) -> String:
var src_dir := model_path.get_base_dir()
var sub := category_slug if category_slug != "" else "assets"
var stem := CghConfig.safe_download_name(model_path.get_file()).get_basename()
var dest_dir := RES_ROOT.path_join(sub).path_join(stem)
DirAccess.make_dir_recursive_absolute(dest_dir)
var model_dest := ""
var da := DirAccess.open(src_dir)
if da:
da.list_dir_begin()
var fn := da.get_next()
while fn != "":
if not da.current_is_dir():
var bytes := FileAccess.get_file_as_bytes(src_dir.path_join(fn))
var d := dest_dir.path_join(CghConfig.safe_download_name(fn))
var wf := FileAccess.open(d, FileAccess.WRITE)
if wf:
wf.store_buffer(bytes)
wf.close()
if fn == model_path.get_file():
model_dest = d
fn = da.get_next()
da.list_dir_end()
if model_dest == "":
# fallback: copy just the model file (distinct var names — GDScript locals are
# function-scoped, so re-declaring bytes/wf from the loop above would error).
var fbytes := FileAccess.get_file_as_bytes(model_path)
if fbytes.is_empty():
return ""
model_dest = dest_dir.path_join(CghConfig.safe_download_name(model_path.get_file()))
var fwf := FileAccess.open(model_dest, FileAccess.WRITE)
if fwf == null:
return ""
fwf.store_buffer(fbytes)
fwf.close()
if Engine.is_editor_hint():
var efs := EditorInterface.get_resource_filesystem()
if efs and not efs.is_scanning():
efs.scan()
return model_dest
static func _extract_zip(zip_path: String) -> String:
var reader := ZIPReader.new()
if reader.open(zip_path) != OK:
return ""
var ok := CghConfig.EXT_3D + CghConfig.EXT_HDRI + CghConfig.EXT_IMAGE
var out_dir := zip_path.get_basename() + "_unzip"
DirAccess.make_dir_recursive_absolute(out_dir)
var best := ""
var best_rank := 99
for entry in reader.get_files():
if entry.ends_with("/"):
continue
var e_ext := entry.get_extension().to_lower()
if not (e_ext in ok):
continue
var out := out_dir + "/" + entry.get_file()
var f := FileAccess.open(out, FileAccess.WRITE)
if f:
f.store_buffer(reader.read_file(entry))
f.close()
# prefer 3D > hdri > image when several importable files exist
var rank := 0 if e_ext in CghConfig.EXT_3D else (1 if e_ext in CghConfig.EXT_HDRI else 2)
if rank < best_rank:
best_rank = rank
best = out
reader.close()
return best
static func _flipbook_grid(path: String) -> Vector2i:
# look for an NxM token in the filename, e.g. fire_8x8.png / Portal_04_4K_8X8.exr
var rx := RegEx.new()
rx.compile("(\\d+)\\s*[xX]\\s*(\\d+)")
var m := rx.search(path.get_file())
if m:
return Vector2i(int(m.get_string(1)), int(m.get_string(2)))
return Vector2i(1, 1)
@@ -0,0 +1 @@
uid://cyrdsmgv6xlmi
+82
View File
@@ -0,0 +1,82 @@
@tool
extends Node
class_name CghUpdater
## Checks the public update endpoint; if a newer semver exists, downloads the
## addon zip and extracts it over res://addons/cgheven, then asks for an editor
## restart (Godot has no silent plugin hot-reload).
signal update_available(version: String, notes: String, url: String)
signal up_to_date()
signal update_ready_to_restart()
signal update_failed(message: String)
var _check_http: HTTPRequest
var _dl: CghDownloader
func _ready() -> void:
_check_http = HTTPRequest.new(); add_child(_check_http)
_check_http.request_completed.connect(_on_check)
_dl = CghDownloader.new(); add_child(_dl)
_dl.finished.connect(_on_zip_downloaded)
_dl.failed.connect(func(m): update_failed.emit(m))
func check() -> void:
# Backend contract: POST /api/addon-updates/check with body {addon_slug,
# current_version}; public, no auth (matches autoupdate-public-popup). Response:
# {update_available, latest_version, changelog, download_url}.
var body := JSON.stringify({
"addon_slug": CghConfig.ADDON_SLUG,
"current_version": CghConfig.ADDON_VERSION,
})
_check_http.request(CghConfig.check_update_url(),
PackedStringArray(["Content-Type: application/json"]),
HTTPClient.METHOD_POST, body)
func _on_check(_r: int, code: int, _h: PackedStringArray, b: PackedByteArray) -> void:
if code < 200 or code >= 300:
update_failed.emit("Update check failed (HTTP %d)" % code)
return
var d = JSON.parse_string(b.get_string_from_utf8())
if typeof(d) != TYPE_DICTIONARY:
update_failed.emit("Bad update response")
return
if d.get("update_available", false):
var ver := str(d.get("latest_version", "?"))
var notes_v = d.get("changelog", "")
var notes := str(notes_v) if notes_v != null else ""
var url_v = d.get("download_url", "")
var url := str(url_v) if url_v != null else ""
if url == "":
# Update exists but no ZIP uploaded to the release yet — don't prompt a
# download that can't complete; surface only on a manual check.
update_failed.emit("Update %s available, but no download is published yet." % ver)
return
update_available.emit(ver, notes, url)
else:
up_to_date.emit()
func download_and_stage(url: String) -> void:
_dl.download(url, "cgheven_update.zip")
func _on_zip_downloaded(zip_path: String) -> void:
var reader := ZIPReader.new()
if reader.open(zip_path) != OK:
update_failed.emit("Could not open update zip")
return
var base := "res://addons/cgheven/"
for entry in reader.get_files():
if entry.ends_with("/"):
continue
# entries are expected under addons/cgheven/... ; strip leading path to map onto base
var rel := entry
var idx := rel.find("addons/cgheven/")
if idx != -1:
rel = rel.substr(idx + "addons/cgheven/".length())
var out_path := base + rel
DirAccess.make_dir_recursive_absolute(out_path.get_base_dir())
var f := FileAccess.open(out_path, FileAccess.WRITE)
if f:
f.store_buffer(reader.read_file(entry))
f.close()
reader.close()
update_ready_to_restart.emit() # caller shows "Restart editor?" dialog -> EditorInterface.restart_editor()
+1
View File
@@ -0,0 +1 @@
uid://brkjphruderpy