Initial commit
This commit is contained in:
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://xa7h3bnk8yvj
|
||||
@@ -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 [])
|
||||
@@ -0,0 +1 @@
|
||||
uid://b3v25j2woseak
|
||||
@@ -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))
|
||||
@@ -0,0 +1 @@
|
||||
uid://dn5sgwiqu512l
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://c6ock0fik2qi8
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c7tbtkjio3q4g
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://67wa4ry6asqt
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c3p8ea4j8waqs
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://c3l7vtfgil2ed
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://brkjphruderpy
|
||||
Reference in New Issue
Block a user