<?xml version="1.0" encoding="utf-8"?>
<roblox version="4">
  <Item class="Script" referent="TripoAnimPlugin">
    <Properties>
      <string name="Name">TripoAnim</string>
      <bool name="Disabled">false</bool>
      <ProtectedString name="Source">--[[  Tripoanim - type a motion, get a KeyframeSequence on the selected rig.

    Install: copy this file to
        %LOCALAPPDATA%\Roblox\Plugins\Tripoanim.lua
    Open https://tripointgames.com/tripoanim/app, create a pairing code under
    Conectar Studio, and paste it here. No local service is needed in cloud mode.
    Developers can select local mode and run: python service/app.py

    The flow, and why it has two buttons:

        type the motion  -&gt;  [Consultar preco]  -&gt;  [Gerar (3 creditos)]

    Generating costs real credits, so the plugin cannot ask for one directly.
    It asks the service what the clip would cost, shows that, and only the
    second button spends - and it can only spend because the first call handed
    back a one-use token. The gate is the service's (see service/app.py); this
    widget is just the honest face of it.

    Generation outlives Studio's ~30 s HTTP timeout, so the spend returns a job
    id and this polls it.

    WHERE THE POSE TREE COMES FROM
    The service sends poses named after PARTS (`LowerTorso`, `LeftUpperArm`)
    plus the hierarchy to nest them in - a flat Pose list does not animate. The
    tree is the service's and not read off the rig on purpose: measured on six
    R15 rigs in this place on 2026-09-04, a layered-clothing rig attaches its
    shoulder and neck constraints to BONES inside the mesh (`LeftClavicle`,
    `Chest`), so a tree derived from the rig's own Part0/Part1 graph would hang
    the arms and head off names no Pose can bind to.

    What the rig IS asked is what actually binds: a Pose binds to the joint
    whose Part1 carries its name, so this collects those names and reports any
    pose that will not land. All six rigs expose the same fifteen - and never
    HumanoidRootPart, which anchors the tree and drives nothing. That check
    matters because the failure it catches looks exactly like a healthy import:
    full timeline, correct rotations, nothing moves.
]]

-- The distributable is a plugin script, not a runtime game script.
if not plugin then return end

local ChangeHistoryService = game:GetService("ChangeHistoryService")
local HttpService = game:GetService("HttpService")
local KeyframeSequenceProvider = game:GetService("KeyframeSequenceProvider")
local Selection = game:GetService("Selection")
local RunService = game:GetService("RunService")

-- BEGIN EMBEDDED RIG CAPTURE
local RigCapture = (function()
-- Read-only R15 measurement. Kept as a module for Studio regression tests;
-- The package builder preserves this readable source without remote modules.
local Capture = {}
local parents = {
	HumanoidRootPart = false, LowerTorso = "HumanoidRootPart", UpperTorso = "LowerTorso", Head = "UpperTorso",
	LeftUpperArm = "UpperTorso", LeftLowerArm = "LeftUpperArm", LeftHand = "LeftLowerArm",
	RightUpperArm = "UpperTorso", RightLowerArm = "RightUpperArm", RightHand = "RightLowerArm",
	LeftUpperLeg = "LowerTorso", LeftLowerLeg = "LeftUpperLeg", LeftFoot = "LeftLowerLeg",
	RightUpperLeg = "LowerTorso", RightLowerLeg = "RightUpperLeg", RightFoot = "RightLowerLeg",
}
local order = {"HumanoidRootPart", "LowerTorso", "UpperTorso", "Head",
	"LeftUpperArm", "LeftLowerArm", "LeftHand", "RightUpperArm", "RightLowerArm", "RightHand",
	"LeftUpperLeg", "LeftLowerLeg", "LeftFoot", "RightUpperLeg", "RightLowerLeg", "RightFoot"}
local function array(cf) return {cf:GetComponents()} end
local function vector(v) return {v.X, v.Y, v.Z} end
local function attachmentFrame(attachment)
	local offset = CFrame.identity
	local node = attachment
	-- Bone.CFrame is the bind transform. Bone.Transform and the model's current
	-- animation/world placement must never become the next generation's rest pose.
	while node and not node:IsA("BasePart") do
		if not node:IsA("Attachment") then return nil end
		offset = node.CFrame * offset
		node = node.Parent
	end
	return node, offset
end
function Capture.measure(rig)
	local humanoid = rig and rig:FindFirstChildOfClass("Humanoid")
	if not humanoid or humanoid.RigType ~= Enum.HumanoidRigType.R15 then
		return nil, "Selecione um personagem R15 com Humanoid."
	end
	local found, joints, parts, rest = {}, {}, {}, {HumanoidRootPart = CFrame.identity}
	for _, name in ipairs(order) do
		local part = rig:FindFirstChild(name)
		if not part or not part:IsA("BasePart") then return nil, "Parte R15 ausente: " .. name end
		found[name] = part
	end
	for _, joint in ipairs(rig:GetDescendants()) do
		local p0, p1, c0, c1
		if joint:IsA("Motor6D") then
			p0, p1, c0, c1 = joint.Part0, joint.Part1, joint.C0, joint.C1
		elseif joint:IsA("AnimationConstraint") and joint.Attachment0 and joint.Attachment1 then
			p0, c0 = attachmentFrame(joint.Attachment0)
			p1, c1 = attachmentFrame(joint.Attachment1)
		end
		if p0 and p1 and found[p1.Name] == p1 then
			if found[parents[p1.Name]] ~= p0 or joints[p1.Name] then
				return nil, "Encaixe R15 ambíguo: " .. p1.Name
			end
			joints[p1.Name] = {parent = p0.Name, c0 = array(c0), c1 = array(c1), kind = joint.ClassName}
		end
	end
	for _, name in ipairs(order) do
		local joint = joints[name]
		if parents[name] then
			if not joint then return nil, "Junta R15 ausente: " .. name end
			rest[name] = rest[joint.parent] * CFrame.new(table.unpack(joint.c0)) * CFrame.new(table.unpack(joint.c1)):Inverse()
		end
		local p = found[name]
		parts[name] = {size = vector(p.Size), rest = array(rest[name]), color = vector(Vector3.new(p.Color.R, p.Color.G, p.Color.B))}
	end
	local scales = {}
	for _, value in ipairs(humanoid:GetChildren()) do
		if value:IsA("NumberValue") and value.Name:match("Scale$") then scales[value.Name] = value.Value end
	end
	return {schema = "tripoanim.r15.v1", name = rig.Name, units = "studs", order = order,
		parts = parts, joints = joints, hip_height = humanoid.HipHeight, scales = scales}
end
function Capture.fingerprint(profile)
	local values = {profile.name, profile.hip_height}
	for _, name in ipairs(order) do
		local part, joint = profile.parts[name], profile.joints[name]
		table.insert(values, {name, part.size, part.rest, part.color, joint and joint.c0 or {}, joint and joint.c1 or {}})
	end
	return game:GetService("HttpService"):JSONEncode(values)
end
return Capture

end)()
-- END EMBEDDED RIG CAPTURE

-- BEGIN EMBEDDED ROOT TRACK
local RootTrack = (function()
-- Pure root-motion sidecar operations.  This file deliberately does not read
-- Studio services so its timestamp sampling and unit conversion can be tested
-- independently of a place.
local RootTrack = {}

local function finite(value)
	return type(value) == "number" and value == value and math.abs(value) &lt; math.huge
end

local function sample(rows, time, read, shortestAngle)
	if type(rows) ~= "table" or #rows == 0 or not finite(time) then return nil end
	local first, last = rows[1], rows[#rows]
	if type(first.t) ~= "number" or type(last.t) ~= "number" then return nil end
	if time &lt;= first.t then
		local value = read(first)
		return finite(value) and value or nil
	end
	if time &gt;= last.t then
		local value = read(last)
		return finite(value) and value or nil
	end
	for index = 1, #rows - 1 do
		local a, b = rows[index], rows[index + 1]
		if not finite(a.t) or not finite(b.t) then return nil end
		if time &gt;= a.t and time &lt;= b.t then
			local av, bv = read(a), read(b)
			if not finite(av) or not finite(bv) then return nil end
			local span = b.t - a.t
			if span &lt;= 0 then return av end
			local alpha = (time - a.t) / span
			if shortestAngle then
				-- Never turn almost a full circle while crossing the -pi/pi seam.
				local delta = (bv - av + math.pi) % (2 * math.pi) - math.pi
				return av + delta * alpha
			end
			return av + (bv - av) * alpha
		end
	end
	local value = read(last)
	return finite(value) and value or nil
end

function RootTrack.channelAt(rows, time, read)
	return sample(rows, time, read, false)
end

function RootTrack.angleAt(rows, time, read)
	return sample(rows, time, read, true)
end

function RootTrack.poseAt(clip, time)
	local path = clip and clip.rootPath
	if type(path) ~= "table" or #path == 0 then return nil end
	local function coordinate(axis)
		return RootTrack.channelAt(path, time, function(row)
			return type(row.pos) == "table" and row.pos[axis] or nil
		end) or 0
	end
	return {
		x = coordinate("x"), y = coordinate("y"), z = coordinate("z"),
		yaw = RootTrack.angleAt(path, time, function(row) return row.yaw end) or 0,
		height = RootTrack.channelAt(clip.visualRootHeight, time, function(row) return row.y end),
	}
end

-- Returns fresh channel tables.  The caller may safely retry after failure:
-- neither the clip nor its source sidecar is modified here.
function RootTrack.convertMetres(track, rootPath, visualRootHeight, target)
	if type(track) ~= "table" or track.units == nil or track.units == "studs" then
		return {rootPath = rootPath, visualRootHeight = visualRootHeight, track = track}
	end
	if track.units ~= "metres" or type(track.sourceMeasurement) ~= "table" then
		return nil, "root motion tem unidades incompativeis; reexporte com medidas do rig"
	end
	if type(rootPath) ~= "table" or type(visualRootHeight) ~= "table" then
		return nil, "root motion sem os dois canais obrigatorios"
	end
	target = target or {}
	local sourceLeg = tonumber(track.sourceMeasurement.legLength)
	local sourceRest = tonumber(track.sourceMeasurement.restRootHeight)
	local targetLeg, targetRest = tonumber(target.legLength), tonumber(target.restRootHeight)
	if not finite(sourceLeg) or sourceLeg &lt;= 1e-6 or not finite(sourceRest) then
		return nil, "root motion em metros nao informa perna/altura de repouso"
	end
	if not finite(targetLeg) or targetLeg &lt;= 1e-6 or not finite(targetRest) then
		return nil, "nao foi possivel medir a escala do Avatar R15"
	end
	local ratio = targetLeg / sourceLeg
	if not finite(ratio) or ratio &lt;= 0 then return nil, "escala de root motion invalida" end
	local convertedPath, convertedHeight = {}, {}
	for _, row in ipairs(rootPath) do
		local pos = row.pos
		if type(row) ~= "table" or not finite(row.t) or type(pos) ~= "table"
			or not finite(pos.x) or not finite(pos.y) or not finite(pos.z) or not finite(row.yaw) then
			return nil, "rootPath invalido"
		end
		convertedPath[#convertedPath + 1] = {
			t = row.t, pos = {x = pos.x * ratio, y = pos.y * ratio, z = pos.z * ratio}, yaw = row.yaw,
		}
	end
	for _, row in ipairs(visualRootHeight) do
		if type(row) ~= "table" or not finite(row.t) or not finite(row.y) then
			return nil, "visualRootHeight invalido"
		end
		convertedHeight[#convertedHeight + 1] = {
			t = row.t, y = targetRest + (row.y - sourceRest) * ratio,
		}
	end
	return {
		rootPath = convertedPath,
		visualRootHeight = convertedHeight,
		track = {
			units = "studs",
			sourceMeasurement = track.sourceMeasurement,
			targetMeasurement = {legLength = targetLeg, restRootHeight = targetRest},
			conversion = {mode = "target_leg_ratio", ratio = ratio},
		},
	}
end

return RootTrack

end)()
-- END EMBEDDED ROOT TRACK

local BRAND = "TripoAnim"
local BASE = {
	["local"] = "http://127.0.0.1:8765",
	cloud = "https://kxxwyqmmcbkowxettxvs.supabase.co/functions/v1",
}

-- The project's anon key. It is PUBLIC by design - the website ships the same
-- value in its own bundle - and it is here only to satisfy the platform's JWT
-- gate. The plugin's real identity is the device token in x-tripoanim-token.
-- Paste the project's legacy anon JWT from the website's .env.
local ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imt4eHd5cW1tY2Jrb3d4ZXR0eHZzIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzQ4NTkxNjMsImV4cCI6MjA5MDQzNTE2M30.bTgiJZvmgOA9A48zEs4A-Ld3nhK0N4M7H-w6VCwHaVw"

local POLL_SECONDS = 1.5
-- Generation continues on the server without us, so this is not a deadline on
-- the clip - only on how long the widget keeps watching.
local POLL_GIVE_UP = 420

-- BEGIN REMOTE VALIDATION
-- JSON is data only. Validate it before allocating any Studio instances.
-- These pure functions stay isolated for the Studio regression harness.
local Validation = (function()
	local V = { MAX_BYTES = 4 * 1024 * 1024, MAX_FRAMES = 2000 }
	local parents = {
		LowerTorso = "HumanoidRootPart", UpperTorso = "LowerTorso", Head = "UpperTorso",
		LeftUpperArm = "UpperTorso", LeftLowerArm = "LeftUpperArm", LeftHand = "LeftLowerArm",
		RightUpperArm = "UpperTorso", RightLowerArm = "RightUpperArm", RightHand = "RightLowerArm",
		LeftUpperLeg = "LowerTorso", LeftLowerLeg = "LeftUpperLeg", LeftFoot = "LeftLowerLeg",
		RightUpperLeg = "LowerTorso", RightLowerLeg = "RightUpperLeg", RightFoot = "RightLowerLeg",
	}
	function V.number(value, low, high)
		return type(value) == "number" and value == value and value &gt;= low and value &lt;= high
	end
	function V.text(value, fallback, limit)
		if type(value) ~= "string" or #value &gt; (limit or 800) or value:find("[%z\1-\8\11\12\14-\31]") then
			return fallback
		end
		return value
	end
	function V.uuid(value)
		if type(value) ~= "string" or #value ~= 36 then return false end
		return value:match("^%x%x%x%x%x%x%x%x%-%x%x%x%x%-[1-5]%x%x%x%-[89aAbB]%x%x%x%-%x%x%x%x%x%x%x%x%x%x%x%x$") ~= nil
	end
	function V.token(value)
		return type(value) == "string" and #value == 43 and value:match("^[%w_-]+$") ~= nil
	end
	function V.id(value, mode)
		return mode == "cloud" and V.uuid(value)
			or (mode == "local" and type(value) == "string" and #value &gt; 0 and #value &lt;= 80 and value:match("^[%w_-]+$") ~= nil)
	end
	function V.array(value, maximum, minimum)
		if type(value) ~= "table" then return false end
		local count = 0
		for index in pairs(value) do
			if not V.number(index, 1, maximum) or index % 1 ~= 0 then return false end
			count += 1
		end
		return count &gt;= (minimum or 0) and count &lt;= maximum and count == #value
	end
	function V.signedUrl(url)
		if type(url) ~= "string" or #url &gt; 4096 then return false end
		-- Fixed HTTPS origin, bucket, UUID owner and filename. Reject redirects,
		-- userinfo, alternate ports, fragments and encoded traversal entirely.
		local owner, file, token = url:match("^https://kxxwyqmmcbkowxettxvs%.supabase%.co/storage/v1/object/sign/tripoanim%-clips/([%x%-]+)/([%x%-]+)%.json%?token=([%w_.%-]+)$")
		return V.uuid(owner) and V.uuid(file) and type(token) == "string" and #token &gt; 0
	end
	function V.quote(quote, mode)
		if type(quote) ~= "table" or not V.id(quote.quote_id, mode)
			or not V.number(quote.credits, 0, 10000000) or quote.credits % 1 ~= 0
			or not V.number(quote.duration, 2, 10) or not V.text(quote.prompt, nil, 1600)
			or (quote.notes ~= nil and not V.array(quote.notes, 20)) then return false end
		for _, note in ipairs(quote.notes or {}) do
			if not V.text(note, nil, 800) then return false end
		end
		return (quote.tier == nil or V.text(quote.tier, nil, 80) ~= nil)
			and (quote.rig == nil or V.text(quote.rig, nil, 80) ~= nil)
	end
	function V.job(job, mode)
		if type(job) ~= "table" or type(job.status) ~= "string" or not ({queued = true, running = true, done = true, error = true})[job.status]
			or (job.clip_id ~= nil and not V.id(job.clip_id, mode)) then return false end
		return (job.stage == nil or V.text(job.stage, nil, 120) ~= nil)
			and (job.error == nil or V.text(job.error, nil, 500) ~= nil)
	end
	local function transform(cf)
		if not V.array(cf, 12, 12) then return false end
		for index, value in ipairs(cf) do
			if not V.number(value, index &lt;= 3 and -10000 or -1.01, index &lt;= 3 and 10000 or 1.01) then return false end
		end
		-- Rotation rows must be orthonormal and right handed; small rounding
		-- differences in old clips remain valid.
		for row = 0, 2 do
			local a = 4 + row * 3
			if math.abs(cf[a]^2 + cf[a+1]^2 + cf[a+2]^2 - 1) &gt; 0.02 then return false end
			for other = row + 1, 2 do
				local b = 4 + other * 3
				if math.abs(cf[a]*cf[b] + cf[a+1]*cf[b+1] + cf[a+2]*cf[b+2]) &gt; 0.02 then return false end
			end
		end
		local determinant = cf[4]*(cf[8]*cf[12]-cf[9]*cf[11]) - cf[5]*(cf[7]*cf[12]-cf[9]*cf[10]) + cf[6]*(cf[7]*cf[11]-cf[8]*cf[10])
		return math.abs(determinant - 1) &lt;= 0.03
	end
	function V.clip(clip)
		local invalid = "clipe R15 inválido ou incompatível; baixe novamente pela biblioteca"
		if type(clip) ~= "table" or not V.text(clip.name, nil) or clip.name == ""
			or not V.number(clip.fps, 1, 120) or not V.number(clip.duration, 0.001, 60)
			or (clip.output_format ~= nil and clip.output_format ~= "roblox")
			or (clip.root ~= nil and clip.root ~= "HumanoidRootPart")
			or (clip.loop ~= nil and type(clip.loop) ~= "boolean")
			or type(clip.hierarchy) ~= "table" or not V.array(clip.keyframes, V.MAX_FRAMES, 1) then
			return nil, invalid
		end
		local hierarchyCount = 0
		for child, parent in pairs(clip.hierarchy) do
			if parents[child] ~= parent then return nil, invalid end
			hierarchyCount += 1
		end
		if hierarchyCount ~= 15 then return nil, invalid end
		local previous = -1
		local drivesJoint = false
		for _, frame in ipairs(clip.keyframes) do
			if type(frame) ~= "table" or not V.number(frame.time, 0, clip.duration + 0.001)
				or frame.time &lt;= previous or not V.array(frame.poses, 16, 1) then return nil, invalid end
			previous = frame.time
			local seen = {}
			for _, pose in ipairs(frame.poses) do
				if type(pose) ~= "table" or type(pose.part) ~= "string" or seen[pose.part]
					or (pose.part ~= "HumanoidRootPart" and not parents[pose.part]) or not transform(pose.cf)
					or (pose.weight ~= nil and not V.number(pose.weight, 0, 1)) then return nil, invalid end
				seen[pose.part] = true
				if parents[pose.part] and (pose.weight == nil or pose.weight &gt; 0) then drivesJoint = true end
			end
		end
		if not drivesJoint then return nil, invalid end
		-- Keep only validated values read by the importer or its status labels.
		local result = { name = clip.name, fps = clip.fps, duration = clip.duration,
			root = "HumanoidRootPart", hierarchy = clip.hierarchy, keyframes = clip.keyframes,
			loop = clip.loop == true, qa = V.text(clip.qa, "-", 500), plant = V.text(clip.plant, "-", 4000), problems = {} }
		if clip.problems ~= nil then
			if not V.array(clip.problems, 32) then return nil, invalid end
			for _, problem in ipairs(clip.problems) do
				if not V.text(problem, nil, 500) then return nil, invalid end
				table.insert(result.problems, problem)
			end
		end
		if clip.rootPath ~= nil then
			if not V.array(clip.rootPath, V.MAX_FRAMES) then return nil, invalid end
			local previousRoot = -1
			result.rootPath = {}
			for _, row in ipairs(clip.rootPath) do
				local pos = type(row) == "table" and row.pos
				if not V.number(row and row.t, 0, clip.duration + .001) or row.t &lt;= previousRoot
					or type(pos) ~= "table" or not V.number(pos.x, -10000, 10000)
					or not V.number(pos.y, -10000, 10000) or not V.number(pos.z, -10000, 10000)
					or not V.number(row.yaw, -10000, 10000) then return nil, invalid end
				previousRoot = row.t
				table.insert(result.rootPath, {t = row.t, pos = {x = pos.x, y = pos.y, z = pos.z}, yaw = row.yaw})
			end
			if clip.visualRootHeight ~= nil then
				if not V.array(clip.visualRootHeight, V.MAX_FRAMES) then return nil, invalid end
				local previousHeight = -1
				result.visualRootHeight = {}
				for _, row in ipairs(clip.visualRootHeight) do
					if type(row) ~= "table" or not V.number(row.t, 0, clip.duration + .001)
						or row.t &lt;= previousHeight or not V.number(row.y, -10000, 10000) then return nil, invalid end
					previousHeight = row.t
					table.insert(result.visualRootHeight, {t = row.t, y = row.y})
				end
			end
			if clip.rootTrack ~= nil then
				if type(clip.rootTrack) ~= "table" or (clip.rootTrack.units ~= "studs" and clip.rootTrack.units ~= "metres") then return nil, invalid end
				local source = clip.rootTrack.sourceMeasurement
				if clip.rootTrack.units == "metres" and (type(source) ~= "table"
					or not V.number(source.legLength, .000001, 10000)
					or not V.number(source.restRootHeight, -10000, 10000)) then return nil, invalid end
				result.rootTrack = {units = clip.rootTrack.units,
					sourceMeasurement = source and {legLength = source.legLength, restRootHeight = source.restRootHeight} or nil}
			end
			result.rootYawInAnimation = clip.rootYawInAnimation == true
		end
		return result
	end
	return V
end)()
-- END REMOTE VALIDATION

-- Two transports, one caller. The widget says api("quote") and never learns
-- which mode is active. Local mode is deliberately identical to what it always
-- was, so `python service/app.py` stays a first-class development target.
local ROUTES = {
	["local"] = {
		quote = { "POST", "/v1/quote" },
		generate = { "POST", "/v1/clips" },
		job = { "GET", "/v1/jobs/%s" },
		clip = { "GET", "/v1/clips/%s" },
		health = { "GET", "/v1/health" },
	},
	cloud = {
		quote = { "POST", "/tripoanim-quote" },
		generate = { "POST", "/tripoanim-generate" },
		job = { "POST", "/tripoanim-jobs", "job_id" },
		clip = { "POST", "/tripoanim-clips", "clip_id" },
		inbox = { "POST", "/tripoanim-clips" },
		claim = { "POST", "/tripoanim-pair-claim" },
		characters = { "POST", "/tripoanim-characters" },
		metric = { "POST", "/tripoanim-attribution" },
	},
}

-- ---------------------------------------------------------------------------
-- service
-- ---------------------------------------------------------------------------

local onAuthLost

--- Returns decoded body, or nil plus a message. Never throws.
-- `path` is relative in a transport mode and absolute for a signed Storage URL;
-- authenticated cloud calls add three transport headers while local keeps only
-- the Content-Type header it has always sent.
local function request(method, path, body, headers, mode)
	-- Studio owns the plugin's per-domain HTTP permission prompt. Do not
	-- change the open place's HttpEnabled setting just to connect a plugin.
	local url = mode and (BASE[mode] .. path) or path
	local requestHeaders = { ["Content-Type"] = "application/json" }
	for key, value in pairs(headers or {}) do
		requestHeaders[key] = value
	end
	local ok, response = pcall(function()
		return HttpService:RequestAsync({
			Url = url,
			Method = method,
			Headers = requestHeaders,
			-- An EMPTY Lua table encodes as "[]", not "{}" - the two are the
			-- same value in Lua and HttpService guesses array. The inbox call
			-- sends exactly {} and tripoanim-clips rejects an array outright,
			-- so every "Buscar clipes prontos" answered "Invalid JSON body".
			Body = body and (next(body) == nil and "{}" or HttpService:JSONEncode(body))
				or nil,
		})
	end)
	if not ok then
		if mode == "local" then
			return nil, "serviço local indisponível; confira se o serviço de desenvolvimento está rodando"
		end
		-- Raw HTTP errors may contain a signed URL or device headers.
		return nil, "não foi possível conectar; confira a rede e a permissão HTTP do plugin no Studio"
	end
	if type(response) ~= "table" or not Validation.number(response.StatusCode, 100, 599)
		or type(response.Body) ~= "string" then return nil, "resposta HTTP inválida" end
	local status = response.StatusCode
	if status &lt; 200 or status &gt;= 300 then
		local errors = { [401] = "conexão inválida ou revogada; gere um novo código no site",
			[403] = "acesso indisponível; confira a conta ou solicite o clipe novamente",
			[404] = "código ou clipe não encontrado nesta conta",
			[429] = "muitas solicitações; aguarde e tente novamente" }
		return nil, errors[status] or ("o serviço respondeu HTTP " .. status .. "; tente novamente"), status
	end
	if #response.Body &gt; Validation.MAX_BYTES then
		return nil, "resposta maior que 4 MiB; o clipe não foi importado", status
	end

	local decoded
	local decodedOk = pcall(function()
		decoded = HttpService:JSONDecode(response.Body)
	end)
	if not decodedOk or type(decoded) ~= "table" then
		return nil, ("the service answered %d with something that is not JSON")
			:format(response.StatusCode), response.StatusCode
	end
	return decoded, nil, response.StatusCode
end

--- Which transport is active. Local until the user pairs and switches.
local function readSetting(name)
	local ok, value = pcall(function() return plugin:GetSetting(name) end)
	return ok and value or nil
end

local function currentMode()
	return readSetting("tripoanim_mode") == "local" and "local" or "cloud"
end

local rejectedToken
local function deviceToken()
	local token = readSetting("tripoanim_token")
	return Validation.token(token) and token ~= rejectedToken and token or nil
end

--- One named call in whichever mode is active. `arg` fills a %s in the path,
-- which only local mode uses (cloud puts every id in the POST body so nothing
-- lands in an access log).
local function api(name, body, arg, forceMode)
	local mode = forceMode or currentMode()
	local route = ROUTES[mode][name]
	if not route then
		return nil, ("%s nao existe no modo %s"):format(name, mode)
	end
	local method, path, idField = route[1], route[2], route[3]
	if arg ~= nil then
		if path:find("%%s") then
			if type(arg) ~= "string" or #arg &gt; 80 or not arg:match("^[%w_-]+$") then
				return nil, "ID local inválido"
			end
			path = path:format(arg)
		elseif idField then
			if not Validation.uuid(arg) then return nil, "copie o ID completo da biblioteca" end
			body = { [idField] = arg }
		end
	end
	if mode == "local" then
		-- The development service has always received its explicit tier. Keeping
		-- that field here prevents a cloud-only body change from altering it.
		if name == "quote" then
			body = { prompt = body.prompt, duration = body.duration, mode = "swift", rig_profile = body.rig_profile }
		end
		return request(method, path, body, nil, mode)
	end
	if ANON_KEY == "PASTE_THE_SUPABASE_ANON_KEY_HERE" then
		return nil, "cole a chave anon do projeto em ANON_KEY, no topo do plugin"
	end
	local headers = { apikey = ANON_KEY, Authorization = "Bearer " .. ANON_KEY }
	local token = deviceToken()
	-- Pair claim is the only pre-identity call. Every other cloud function must
	-- carry all three headers rather than making an anonymous request it cannot
	-- authorize.
	if name ~= "claim" and not token then
		return nil, "conecte este Studio antes de usar a nuvem"
	end
	if name ~= "claim" then
		headers["x-tripoanim-token"] = token
	end
	local data, why, status = request(method, path, body or {}, headers, mode)
	if status == 401 and name ~= "claim" and token == deviceToken() then
		rejectedToken = token
		pcall(function() plugin:SetSetting("tripoanim_token", nil) end)
		if onAuthLost then onAuthLost() end
	end
	return data, why, status
end

--- A signed Storage URL carries its own credential, so it must be fetched with
-- NO Supabase headers. This is the second request of the two-step clip fetch.
local function fetchSigned(url)
	if not Validation.signedUrl(url) then return nil, "URL de clipe inválida; solicite o clipe novamente" end
	return request("GET", url, nil, nil)
end

-- A best-effort, device-authenticated confirmation records only that this
-- account imported this already-authorized clip. It does not delay editing,
-- include the animation document, or report any payment/saldo information.
local function recordImported(clipId)
	if currentMode() ~= "cloud" or not Validation.uuid(clipId) then return end
	task.spawn(function()
		api("metric", { action = "plugin_import_confirmed", clip_id = clipId })
	end)
end

--- The inbox, normalized once so its shape cannot leak further.
-- tripoanim-clips answers a BARE ARRAY of clip rows whose id column is `id`,
-- while every reader here wants entries keyed `clip_id`. Both differences bit
-- separately: the array was read as `listing.clips` and came back nil, and the
-- entries were read as `entry.clip_id` and came back nil again. One adapter,
-- three call sites protected.
local function inboxEntries()
	local listing, why = api("inbox", {})
	if not listing then
		return nil, why
	end
	local rows = listing
	if type(listing.clips) == "table" then
		rows = listing.clips
	end
	if not Validation.array(rows, 20) then
		return nil, "o servico devolveu uma caixa de entrada invalida"
	end
	local entries = {}
	for _, row in ipairs(rows) do
		if type(row) ~= "table" then return nil, "caixa de entrada inválida" end
		local id = row.clip_id or row.id
		if not Validation.uuid(id) or (row.job_id ~= nil and not Validation.uuid(row.job_id))
			or (row.name ~= nil and not Validation.text(row.name, nil))
			or not Validation.number(row.bytes, 0, 8 * 1024 * 1024)
			or not Validation.number(row.duration, 0, 60) then return nil, "caixa de entrada inválida" end
		if id then
			table.insert(entries, {
				clip_id = id,
				job_id = row.job_id,
				name = row.name,
				bytes = row.bytes,
				duration = row.duration,
				keyframes = row.keyframes,
				created_at = row.created_at,
			})
		end
	end
	return entries
end

--- Which clip a finished job produced.
-- Compatibility fallback for older services that do not return clip_id yet.
local function clipIdForJob(jobId)
	local entries, why = inboxEntries()
	if not entries then
		return nil, why
	end
	for _, entry in ipairs(entries) do
		if entry.job_id == jobId then
			return entry.clip_id
		end
	end
	return nil, "o clipe terminou mas ainda nao apareceu na caixa de entrada; "
		.. "use Buscar clipes prontos em alguns segundos"
end

--- The clip document itself.
-- Local mode serves it directly. Cloud needs two hops on purpose: the function
-- returns a short-lived signed URL and Storage serves the ~350 KiB body, so the
-- document never passes through an Edge Function.
local function fetchClip(clipId, forceMode)
	local mode = forceMode or currentMode()
	if mode == "local" then
		local clip, why = api("clip", nil, clipId, mode)
		if not clip then return nil, why end
		return Validation.clip(clip)
	end
	if not Validation.uuid(clipId) then return nil, "copie o ID completo da biblioteca" end
	local pointer, why = api("clip", nil, clipId, mode)
	if not pointer then
		return nil, why
	end
	if not Validation.signedUrl(pointer.url) then
		return nil, "o servico nao devolveu uma URL assinada"
	end
	-- A 4 s clip is ~350 KiB; 10 s is ~875 KiB. Warn on something unexpected
	-- rather than letting JSONDecode fail opaquely on a huge body.
	if not Validation.number(pointer.bytes, 1, Validation.MAX_BYTES) or pointer.bytes % 1 ~= 0 then
		return nil, "tamanho de clipe inválido ou maior que 4 MiB"
	end
	local clip, clipWhy = fetchSigned(pointer.url)
	if not clip then return nil, clipWhy end
	return Validation.clip(clip)
end

--- Clips already imported on THIS machine.
-- The local set, not a server flag, is the truth about "already imported" - so
-- an import that failed halfway stays retryable.
local function importedSet()
	local raw = readSetting("tripoanim_imported")
	local set = {}
	if type(raw) == "string" and raw ~= "" and #raw &lt;= 4096 then
		local ok, list = pcall(function()
			return HttpService:JSONDecode(raw)
		end)
		if ok and Validation.array(list, 50) then
			for _, id in ipairs(list) do
				if Validation.uuid(id) then set[id] = true end
			end
		end
	end
	return set
end

local function markImported(clipId)
	local set = importedSet()
	if set[clipId] then
		return
	end
	local list = { clipId }
	for id in pairs(set) do
		if #list &lt; 50 then
			table.insert(list, id)
		end
	end
	pcall(function()
		plugin:SetSetting("tripoanim_imported", HttpService:JSONEncode(list))
	end)
end

-- ---------------------------------------------------------------------------
-- the rig
-- ---------------------------------------------------------------------------

local function isRig(model)
	if not model or not model:IsA("Model") then
		return false
	end
	return model:FindFirstChildOfClass("Humanoid") ~= nil
		or model:FindFirstChild("HumanoidRootPart") ~= nil
end

--- The selected rig, the rig the selection sits inside, or the only one around.
local function pickRig()
	for _, instance in ipairs(Selection:Get()) do
		local node = instance
		while node and node ~= game do
			if isRig(node) then
				return node
			end
			node = node.Parent
		end
	end
	local found = nil
	for _, child in ipairs(workspace:GetChildren()) do
		if isRig(child) then
			if found then
				return nil, "more than one rig in Workspace - select the one you want"
			end
			found = child
		end
	end
	if not found then
		return nil, "no rig found - select an R15 model"
	end
	return found
end

--- The names a Pose can actually bind to on this rig: every joint's Part1.
-- Motor6D exposes Part0/Part1 directly; AnimationConstraint - the current
-- avatar skeleton, which is what these rigs use - reaches its parts through
-- Attachment0/Attachment1.
local function bindableNames(rig)
	local names, jointCount = {}, 0
	for _, descendant in ipairs(rig:GetDescendants()) do
		local part1
		-- Motor6D and AnimationConstraint only. A Weld also has Part0/Part1 and
		-- would put accessories into the skeleton.
		if descendant:IsA("Motor6D") then
			part1 = descendant.Part1
		elseif descendant:IsA("AnimationConstraint") then
			part1 = descendant.Attachment1 and descendant.Attachment1.Parent
			while part1 and part1 ~= rig and not part1:IsA("BasePart") do
				part1 = part1.Parent
			end
		end
		if part1 and part1:IsA("BasePart") then
			names[part1.Name] = true
			jointCount += 1
		end
	end
	return names, jointCount
end

-- ---------------------------------------------------------------------------
-- building the clip
-- ---------------------------------------------------------------------------

local IDENTITY = CFrame.new()

local function toCFrame(cf)
	-- [x, y, z, R00..R22], row-major: the layout rbxanim.mat_to_cf writes and
	-- the one the .rbxmx that plays correctly carries.
	return CFrame.new(cf[1], cf[2], cf[3],
		cf[4], cf[5], cf[6],
		cf[7], cf[8], cf[9],
		cf[10], cf[11], cf[12])
end

-- FBX sidecars express travel in metres.  Measure the selected Avatar and
-- convert only when the source includes its own measurements; the result is a
-- new validated clip, so a failed import can be retried without double scale.
local function convertRootTrackForRig(clip, profile)
	if not clip.rootPath then return clip end
	local track = clip.rootTrack
	if not track or track.units == nil or track.units == "studs" then return clip end
	local function restPosition(name)
		return CFrame.new(table.unpack(profile.parts[name].rest)).Position
	end
	local function leg(side)
		return (restPosition(side .. "UpperLeg") - restPosition(side .. "LowerLeg")).Magnitude
			+ (restPosition(side .. "LowerLeg") - restPosition(side .. "Foot")).Magnitude
	end
	local targetLeg = (leg("Left") + leg("Right")) * .5
	local floor = math.huge
	for _, name in ipairs({"LeftFoot", "RightFoot"}) do
		floor = math.min(floor, restPosition(name).Y - profile.parts[name].size[2] * .5)
	end
	local converted, why = RootTrack.convertMetres(track, clip.rootPath,
		clip.visualRootHeight or {}, {legLength = targetLeg, restRootHeight = -floor})
	if not converted then return nil, why end
	clip.rootPath, clip.visualRootHeight, clip.rootTrack = converted.rootPath,
		converted.visualRootHeight, converted.track
	return clip
end

--- One KeyframeSequence, nested the way the service says to nest it.
-- Parts the service did not send on a frame become identity anchors with
-- Weight 0, so the tree stays whole without those joints being driven.
local function buildSequence(clip, rig)
	local validated, invalidWhy = Validation.clip(clip)
	if not validated then return nil, invalidWhy end
	clip = validated
	local profile, rigWhy = RigCapture.measure(rig)
	if not profile then return nil, rigWhy end
	local converted, conversionWhy = convertRootTrackForRig(clip, profile)
	if not converted then return nil, conversionWhy end
	clip = converted
	local bindable, jointCount = bindableNames(rig)
	if jointCount == 0 then
		return nil, ("%s has no Motor6D or AnimationConstraint joints - "):format(rig.Name)
			.. "this is not an animatable rig"
	end
	local root = clip.root or "HumanoidRootPart"
	if type(clip.hierarchy) ~= "table" then
		return nil, "the service sent no pose hierarchy; a flat Pose list does not animate"
	end

	local childrenOf = {}
	for child, parent in pairs(clip.hierarchy) do
		childrenOf[parent] = childrenOf[parent] or {}
		table.insert(childrenOf[parent], child)
	end
	for _, list in pairs(childrenOf) do
		table.sort(list)
	end

	local sequence = Instance.new("KeyframeSequence")
	sequence.Name = clip.name
	sequence.Loop = clip.loop and true or false
	sequence.Priority = Enum.AnimationPriority.Action
	if clip.rootPath then
		sequence:SetAttribute("rootPathJson", HttpService:JSONEncode(clip.rootPath))
		sequence:SetAttribute("visualRootHeightJson", HttpService:JSONEncode(clip.visualRootHeight or {}))
		sequence:SetAttribute("rootTrackUnits", clip.rootTrack and clip.rootTrack.units or "studs")
	end

	-- The root anchors the tree and binds to nothing on any R15 rig, so it is
	-- not a warning that it does not appear in the bindable set.
	local unbound, bound = {}, {}
	for _, frame in ipairs(clip.keyframes) do
		for _, pose in ipairs(frame.poses) do
			if pose.part ~= root then
				if bindable[pose.part] then
					bound[pose.part] = true
				else
					unbound[pose.part] = true
				end
			end
		end
	end

	for index, frame in ipairs(clip.keyframes) do
		local byPart = {}
		for _, pose in ipairs(frame.poses) do
			byPart[pose.part] = pose
		end

		local keyframe = Instance.new("Keyframe")
		keyframe.Time = frame.time

		local function addPose(partName, parentInstance)
			local sent = byPart[partName]
			local pose = Instance.new("Pose")
			pose.Name = partName
			pose.CFrame = sent and toCFrame(sent.cf) or IDENTITY
			-- Weight 0 says "this joint is not driven on this frame", which is
			-- what an anchor has to say; a Weight 1 identity would actively
			-- pin the joint to its rest pose.
			pose.Weight = sent and (sent.weight or 1) or 0
			pose.EasingStyle = Enum.PoseEasingStyle.Linear
			pose.EasingDirection = Enum.PoseEasingDirection.In
			pose.Parent = parentInstance
			for _, child in ipairs(childrenOf[partName] or {}) do
				addPose(child, pose)
			end
		end

		addPose(root, keyframe)
		keyframe.Parent = sequence

		-- A 4-second clip is ~2000 instances. Yielding keeps Studio responsive
		-- instead of freezing it for the second that takes.
		if index % 20 == 0 then
			task.wait()
		end
	end

	local missing, posed = {}, 0
	for part in pairs(unbound) do
		table.insert(missing, part)
	end
	for _ in pairs(bound) do
		posed += 1
	end
	table.sort(missing)
	return sequence, nil, { root = root, joints = jointCount, posed = posed,
		missing = missing }, clip
end

local function isAnimationSaveContainer(instance)
	return instance and (instance:IsA("Model") or instance:IsA("Folder"))
end

-- The rig has no saves yet: create the legacy folder, which the Animation
-- Editor offers to migrate the first time it opens the rig.
local CREATE_ANIM_SAVES = {}

--- The container the Animation Editor lists for this rig.
-- Since 2024 the editor keeps a rig's clips in ServerStorage.RBX_ANIMSAVES and
-- leaves an ObjectValue named AnimSaves on the rig pointing there (mesh R15
-- avatars saved by a current Studio have this). A clip written beside that
-- value would never appear in the editor's load list, so follow the pointer.
local function resolveSaveContainer(rig)
	local saves = rig:FindFirstChild("AnimSaves")
	if not saves then
		return CREATE_ANIM_SAVES
	end
	if isAnimationSaveContainer(saves) then
		return saves
	end
	if saves:IsA("ObjectValue") then
		local store = game:GetService("ServerStorage"):FindFirstChild("RBX_ANIMSAVES")
		local target = saves.Value
		if store and target and target.Parent == store
			and (target:IsA("ObjectValue") or isAnimationSaveContainer(target)) then
			return target
		end
		return nil, "o AnimSaves deste rig não aponta para ServerStorage.RBX_ANIMSAVES; abra o rig uma vez no Animation Editor e tente de novo"
	end
	return nil, "AnimSaves deste rig não é uma pasta de animações"
end

local function saveToAnimSaves(rig, sequence, container)
	local saves = container
	if saves == CREATE_ANIM_SAVES then
		saves = Instance.new("Model")
		saves.Name = "AnimSaves"
		saves.Parent = rig
	end
	-- A second clip under the same name is a different take, not a replacement.
	local name, suffix = sequence.Name, 1
	while saves:FindFirstChild(sequence.Name) do
		suffix += 1
		sequence.Name = ("%s_%d"):format(name, suffix)
	end
	sequence.Parent = saves
	return saves
end

-- ---------------------------------------------------------------------------
-- widget
-- ---------------------------------------------------------------------------

local toolbar = plugin:CreateToolbar(BRAND)
local button = toolbar:CreateButton(BRAND, "Importe animações R15 criadas no TripoAnim", "rbxasset://textures/AnimationEditor/button_control_play.png")
button.ClickableWhenViewportHidden = true

local widget = plugin:CreateDockWidgetPluginGui("AnimGen", DockWidgetPluginGuiInfo.new(
	Enum.InitialDockState.Float, false, false, 360, 520, 300, 420))
widget.Title = BRAND

local theme = settings().Studio.Theme
local function color(name)
	return theme:GetColor(Enum.StudioStyleGuideColor[name])
end

local background = Instance.new("Frame")
background.Size = UDim2.fromScale(1, 1)
background.BackgroundColor3 = color("MainBackground")
background.BorderSizePixel = 0
background.Parent = widget

local scroll = Instance.new("ScrollingFrame")
scroll.Size = UDim2.new(1, -16, 1, -16)
scroll.Position = UDim2.fromOffset(8, 8)
scroll.BackgroundTransparency = 1
scroll.BorderSizePixel = 0
scroll.ScrollBarThickness = 6
scroll.CanvasSize = UDim2.new()
scroll.AutomaticCanvasSize = Enum.AutomaticSize.Y
scroll.Parent = background

local layout = Instance.new("UIListLayout")
layout.Padding = UDim.new(0, 6)
layout.SortOrder = Enum.SortOrder.LayoutOrder
layout.Parent = scroll

local order = 0
local function nextOrder()
	order += 1
	return order
end

local function label(text, muted)
	local instance = Instance.new("TextLabel")
	instance.Size = UDim2.new(1, 0, 0, 0)
	instance.AutomaticSize = Enum.AutomaticSize.Y
	instance.BackgroundTransparency = 1
	instance.Font = Enum.Font.SourceSans
	instance.TextSize = 14
	instance.TextXAlignment = Enum.TextXAlignment.Left
	instance.TextYAlignment = Enum.TextYAlignment.Top
	instance.TextWrapped = true
	instance.TextColor3 = color(muted and "DimmedText" or "MainText")
	instance.Text = text
	instance.LayoutOrder = nextOrder()
	instance.Parent = scroll
	return instance
end

local function makeButton(text)
	local instance = Instance.new("TextButton")
	instance.Size = UDim2.new(1, 0, 0, 30)
	instance.BackgroundColor3 = color("Button")
	instance.BorderColor3 = color("ButtonBorder")
	instance.TextColor3 = color("ButtonText")
	instance.Font = Enum.Font.SourceSansSemibold
	instance.TextSize = 15
	instance.Text = text
	instance.AutoButtonColor = true
	instance.LayoutOrder = nextOrder()
	instance.Parent = scroll
	return instance
end

local function setEnabled(instance, enabled)
	instance.Active = enabled
	instance.AutoButtonColor = enabled
	instance.TextColor3 = color(enabled and "ButtonText" or "DimmedText")
end

local rigLabel = label("rig: -")
local syncRigButton = makeButton("Enviar rig ao site")
label("Medimos peças e encaixes do R15 selecionado. Enviar o rig não gasta créditos de animação.", true)
label("Descreva o movimento", true)

-- THE TEXT FIELD. This is what the whole plugin is wrapped around.
local promptBox = Instance.new("TextBox")
promptBox.Size = UDim2.new(1, 0, 0, 80)
promptBox.BackgroundColor3 = color("InputFieldBackground")
promptBox.BorderColor3 = color("InputFieldBorder")
promptBox.TextColor3 = color("MainText")
promptBox.PlaceholderText = "a character waves hello with the right hand, feet planted"
promptBox.PlaceholderColor3 = color("DimmedText")
promptBox.Font = Enum.Font.SourceSans
promptBox.TextSize = 14
promptBox.Text = ""
promptBox.ClearTextOnFocus = false
promptBox.MultiLine = true
promptBox.TextWrapped = true
promptBox.TextXAlignment = Enum.TextXAlignment.Left
promptBox.TextYAlignment = Enum.TextYAlignment.Top
promptBox.LayoutOrder = nextOrder()
promptBox.Parent = scroll

label("Duracao em segundos (2 a 10)", true)
local durationBox = Instance.new("TextBox")
durationBox.Size = UDim2.new(1, 0, 0, 26)
durationBox.BackgroundColor3 = color("InputFieldBackground")
durationBox.BorderColor3 = color("InputFieldBorder")
durationBox.TextColor3 = color("MainText")
durationBox.Font = Enum.Font.SourceSans
durationBox.TextSize = 14
durationBox.Text = "4"
durationBox.ClearTextOnFocus = false
durationBox.LayoutOrder = nextOrder()
durationBox.Parent = scroll

local quoteButton = makeButton("Consultar preco")
local costLabel = label("", true)
local generateButton = makeButton("Gerar")
local statusLabel = label("")
local detailLabel = label("", true)

local previewButton = makeButton("Pre-visualizar")
previewButton.Visible = false

local modeButton = makeButton("Modo: local")
local codeLabel = label("Codigo de pareamento (gere no site)", true)
local codeBox = Instance.new("TextBox")
codeBox.Size = UDim2.new(1, 0, 0, 26)
codeBox.BackgroundColor3 = color("InputFieldBackground")
codeBox.BorderColor3 = color("InputFieldBorder")
codeBox.TextColor3 = color("MainText")
codeBox.PlaceholderText = "ABCD-EFGH"
codeBox.PlaceholderColor3 = color("DimmedText")
codeBox.Font = Enum.Font.SourceSans
codeBox.TextSize = 14
codeBox.Text = ""
codeBox.ClearTextOnFocus = false
codeBox.LayoutOrder = nextOrder()
codeBox.Parent = scroll
local pairButton = makeButton("Conectar")
local inboxButton = makeButton("Buscar clipes prontos")
local clipIdLabel = label("Clipe antigo ou outra copia? Cole o ID da biblioteca no site.", true)
local clipIdBox = codeBox:Clone()
clipIdBox.PlaceholderText = "ID do clipe (copiado no site)"
clipIdBox.Text = ""
clipIdBox.LayoutOrder = nextOrder()
clipIdBox.Parent = scroll
local importIdButton = makeButton("Importar pelo ID")
local inboxLabel = label("", true)
local inboxList = Instance.new("Frame")
inboxList.Size = UDim2.new(1, 0, 0, 0)
inboxList.AutomaticSize = Enum.AutomaticSize.Y
inboxList.BackgroundTransparency = 1
inboxList.LayoutOrder = nextOrder()
inboxList.Parent = scroll
local inboxLayout = Instance.new("UIListLayout")
inboxLayout.Padding = UDim.new(0, 4)
inboxLayout.SortOrder = Enum.SortOrder.LayoutOrder
inboxLayout.Parent = inboxList

--- Remove stale per-clip choices before replacing the inbox listing.
local function clearInboxOffers()
	for _, child in ipairs(inboxList:GetChildren()) do
		if child:IsA("TextButton") then
			child:Destroy()
		end
	end
end

--- Number of clips still offered after successful imports are removed.
local function inboxOfferCount()
	local count = 0
	for _, child in ipairs(inboxList:GetChildren()) do
		if child:IsA("TextButton") then
			count += 1
		end
	end
	return count
end

-- ---------------------------------------------------------------------------
-- behaviour
-- ---------------------------------------------------------------------------

-- Forward declaration: the mode and pairing buttons refresh the health line,
-- which is defined further down with the rest of the service plumbing.
local refreshHealthDeferred

local pendingQuote = nil          -- the one-use token, or nil
local lastSequence = nil
local lastClip = nil
local lastRig = nil
local importClip
local busy = false
local previewing = false
local unloaded = false
local stopPreview

local function refreshRig()
	local rig, why = pickRig()
	rigLabel.Text = rig and ("rig: " .. rig:GetFullName()) or ("rig: " .. (why or "-"))
	rigLabel.TextColor3 = color(rig and "MainText" or "WarningText")
	return rig, why
end

local function setQuote(quote)
	pendingQuote = quote
	if quote then
		costLabel.Text = ("%s %s -&gt; %g creditos\n\"%s\"  %gs")
			:format(BRAND, quote.tier or "", quote.credits,
				quote.prompt, quote.duration)
		generateButton.Text = ("Gerar (%g creditos)"):format(quote.credits)
	else
		costLabel.Text = ""
		generateButton.Text = "Gerar"
	end
	setEnabled(generateButton, quote ~= nil and not busy)
end

local function setBusy(state, message)
	busy = state
	local ready = currentMode() ~= "cloud" or deviceToken() ~= nil
	setEnabled(quoteButton, not state and ready)
	setEnabled(generateButton, not state and ready and pendingQuote ~= nil)
	setEnabled(modeButton, not state)
	setEnabled(pairButton, not state)
	setEnabled(inboxButton, not state)
	setEnabled(importIdButton, not state)
	setEnabled(syncRigButton, not state and currentMode() == "cloud" and ready)
	for _, child in ipairs(inboxList:GetChildren()) do
		if child:IsA("TextButton") then
			setEnabled(child, not state)
		end
	end
	if message then
		statusLabel.Text = message
		statusLabel.TextColor3 = color("MainText")
	end
end

local function fail(message)
	statusLabel.Text = Validation.text(message, "operação indisponível; tente novamente", 1600)
	statusLabel.TextColor3 = color("ErrorText")
end

setQuote(nil)
refreshRig()
Selection.SelectionChanged:Connect(function()
	local selected = refreshRig()
	if pendingQuote and pendingQuote._rig ~= selected then setQuote(nil) end
end)

local rigCache = setmetatable({}, {__mode = "k"})
local function syncRig(rig)
	local profile, why = RigCapture.measure(rig)
	if not profile then return nil, why end
	local fingerprint = RigCapture.fingerprint(profile)
	local cached = rigCache[rig]
	if cached and cached.fingerprint == fingerprint and cached.device == deviceToken() then
		return cached.id, nil, fingerprint, profile
	end
	local created, createWhy = api("characters", {action = "studio", name = rig.Name:sub(1,100), profile = profile})
	if not created then return nil, createWhy end
	if not Validation.uuid(created.character_id) then return nil, "resposta de personagem inválida" end
	local waited = 0
	while waited &lt; 90 do
		local result, getWhy = api("characters", {action = "get", character_id = created.character_id})
		if not result then return nil, getWhy end
		local character = result.character
		if type(character) ~= "table" or type(character.status) ~= "string" then return nil, "resposta de personagem inválida" end
		if character.status == "ready" then
			rigCache[rig] = {id = created.character_id, fingerprint = fingerprint, device = deviceToken()}
			return created.character_id, nil, fingerprint, profile
		end
		if character.status == "error" then return nil, Validation.text(character.error, "não foi possível medir o rig", 500) end
		task.wait(1.5); waited += 1.5
	end
	return nil, "O perfil continua em análise. Aguarde e tente novamente."
end

syncRigButton.MouseButton1Click:Connect(function()
	if busy or currentMode() ~= "cloud" then return end
	local rig, why = pickRig()
	if not rig then return fail(why) end
	setQuote(nil); setBusy(true, "medindo e enviando seu R15...")
	local id, syncWhy = syncRig(rig)
	setBusy(false)
	if not id then return fail(syncWhy) end
	statusLabel.Text = "Rig enviado. No site, atualize a lista de personagens e selecione " .. rig.Name
end)

quoteButton.MouseButton1Click:Connect(function()
	if busy then
		return
	end
	local prompt = promptBox.Text
	if prompt:gsub("%s", "") == "" then
		return fail("escreva o movimento primeiro")
	end
	setBusy(true, "consultando...")
	setQuote(nil)
	-- Cloud refuses the local tier field, while api restores it for the
	-- development service. Keeping that difference out of this handler is why
	-- a mode switch cannot leak one transport's body into the other.
	local body = { prompt = prompt, duration = tonumber(durationBox.Text) or 4 }
	local rig, rigWhy = pickRig()
	if not rig then setBusy(false); return fail(rigWhy) end
	local profile, profileWhy = RigCapture.measure(rig)
	if not profile then setBusy(false); return fail(profileWhy) end
	local fingerprint = RigCapture.fingerprint(profile)
	if currentMode() == "cloud" then
		local id, syncWhy = syncRig(rig)
		if not id then setBusy(false); return fail(syncWhy) end
		body.character_id = id
		body.output_format = "roblox"; body.root_motion = "in_place"
	else
		body.rig_profile = profile
	end
	local quote, why = api("quote", body)
	setBusy(false)
	if not quote then
		return fail(why)
	end
	if not Validation.quote(quote, currentMode()) then return fail("orçamento inválido; consulte o preço novamente") end
	statusLabel.Text = "nada foi gasto - confirme para gerar"
	statusLabel.TextColor3 = color("MainText")
	detailLabel.Text = table.concat(quote.notes or {}, "\n") .. "\n" .. (quote.rig or "")
	quote._rig = rig; quote._rigFingerprint = fingerprint
	setQuote(quote)
end)

generateButton.MouseButton1Click:Connect(function()
	if busy or not pendingQuote then
		return
	end
	local rig, why = refreshRig()
	if not rig then
		return fail(why or "selecione um rig antes de gastar creditos")
	end
	local profile = RigCapture.measure(rig)
	if not profile or pendingQuote._rig ~= rig or RigCapture.fingerprint(profile) ~= pendingQuote._rigFingerprint then
		setQuote(nil)
		return fail("O rig ou suas proporções mudaram. Consulte o preço novamente.")
	end
	local spent = pendingQuote.credits
	local quoteId = pendingQuote.quote_id
	local transport = currentMode()
	setQuote(nil)                    -- one use, on this side too
	setBusy(true, ("gastando %g creditos..."):format(spent))
	previewButton.Visible = false

	task.spawn(function()
		local job, jobWhy = api("generate", { quote_id = quoteId }, nil, transport)
		if not job then
			setBusy(false)
			return fail(jobWhy)
		end
		if not Validation.id(job.job_id, transport) then setBusy(false); return fail("resposta de geração inválida; confira a biblioteca") end

		local waited = 0
		local state
		while waited &lt; POLL_GIVE_UP and not unloaded do
			task.wait(POLL_SECONDS)
			-- Hidden cloud widgets spend no place-wide HTTP budget. The task stays
			-- alive so reopening can resume watching the same server-side job.
			if transport ~= "cloud" or widget.Enabled then
				waited += POLL_SECONDS
				local polled, pollWhy = api("job", nil, job.job_id, transport)
				if not polled then
					setBusy(false)
					return fail(pollWhy)
				end
				if not Validation.job(polled, transport) then setBusy(false); return fail("resposta de geração inválida; confira a biblioteca") end
				state = polled
				statusLabel.Text = ("%s... %.0fs"):format(state.stage or state.status, waited)
				if state.status == "done" or state.status == "error" then
					break
				end
			end
		end
		if unloaded then return end
		if not state or state.status ~= "done" then
			setBusy(false)
			local fallback
			if transport == "local" then
				fallback = "a geracao nao terminou"
			elseif state and state.status == "error" then
				fallback = "a geracao falhou"
			else
				-- Hosted, the server keeps going without us, so "nao terminou"
				-- implies a lost credit that was never lost.
				fallback = "a geracao continua no servidor - use Buscar clipes prontos"
			end
			local message = state and (state.error or fallback) or fallback
			-- The credit may already be spent. Where the raw clip landed is the
			-- one thing worth knowing when the conversion is what failed.
			if state and transport == "local" and Validation.text(state.work_dir, nil, 800) then
				message = message .. "\nclipe bruto em " .. state.work_dir
			end
			return fail(message)
		end

		-- The current hosted job response resolves the clip directly, including
		-- clips outside the latest 20 inbox items. Keep the old-service fallback.
		local clipId = state.clip_id
		if transport == "cloud" and not clipId then
			local resolved, idWhy = clipIdForJob(job.job_id)
			if not resolved then
				setBusy(false)
				return fail(idWhy)
			end
			clipId = resolved
		end

		local clip, clipWhy = fetchClip(clipId, transport)
		if not clip then
			setBusy(false)
			return fail(clipWhy)
		end

		statusLabel.Text = "montando o KeyframeSequence..."
		local sequence, buildWhy, report, appliedClip = importClip(clip, rig)
		if not sequence then
			setBusy(false)
			return fail(buildWhy)
		end

		if transport == "cloud" then
			markImported(clipId)
			recordImported(clipId)
		end
		lastSequence = sequence
		lastClip = appliedClip or clip
		lastRig = rig
		previewButton.Visible = true

		setBusy(false)
		statusLabel.Text = ("salvo em %s"):format(sequence:GetFullName())
		statusLabel.TextColor3 = color("MainText")

		local lines = {
			("%d keyframes, %.2fs @ %gfps, QA %s")
				:format(#clip.keyframes, clip.duration, clip.fps, clip.qa or "-"),
			("%d juntas no rig, %d poses vao ligar"):format(report.joints, report.posed),
			("apoio dos pes: %s"):format(clip.plant or "-"),
		}
		if #report.missing &gt; 0 then
			table.insert(lines, "NAO LIGA NESTE RIG (fica parado): "
				.. table.concat(report.missing, ", "))
		end
		for _, problem in ipairs(clip.problems or {}) do
			table.insert(lines, "PROBLEMA: " .. problem)
		end
		if clip.rootPath then
			table.insert(lines, ("root motion em rootPath (%d amostras) - "):format(#clip.rootPath)
				.. "a prévia aplica o deslocamento; a sequência preserva o sidecar")
		end
		detailLabel.Text = table.concat(lines, "\n")
		detailLabel.TextColor3 = color(
			(#report.missing &gt; 0 or #(clip.problems or {}) &gt; 0) and "WarningText" or "DimmedText")
	end)
end)

--- Every joint an animation writes to, so the rig can be put back.
local function jointsOf(rig)
	local list = {}
	for _, descendant in ipairs(rig:GetDescendants()) do
		if descendant:IsA("Motor6D") or descendant:IsA("AnimationConstraint") then
			table.insert(list, descendant)
		end
	end
	return list
end

-- Root tracks have their own irregular timestamps.  The extracted module uses
-- shortest-angle interpolation for yaw, so a turn crossing -pi/pi never spins
-- the Avatar almost one full rotation.
local function rootPoseAt(clip, time)
	return RootTrack.poseAt(clip, time)
end

--- Play a clip in Edit mode, by hand.
-- Two things bite here, and both were measured on 2026-09-04 rather than
-- guessed. First, RegisterKeyframeSequence returns a BARE HASH and that hash is
-- the AnimationId - prefixing it with rbxasset:// throws and rbxassetid://
-- silently resolves to nothing. Second, and this is why the first attempt
-- played nothing at all: Edit mode never advances a track. `Play()` sets
-- IsPlaying and then nothing moves, because no simulation step is running to
-- call StepAnimations. So this drives it frame by frame.
local function playPreview(clip, sequence, rig)
	local humanoid = rig:FindFirstChildOfClass("Humanoid")
	local animator = humanoid and humanoid:FindFirstChildOfClass("Animator")
	if not animator then
		previewing = false
		setBusy(false)
		return fail("o rig nao tem Animator - abra o Animation Editor no clipe")
	end

	-- Put the rig back exactly where it was when the preview ends.
	local joints, before = jointsOf(rig), {}
	for _, joint in ipairs(joints) do
		local got, value = pcall(function() return joint.Transform end)
		if got then before[joint] = value end
	end
	local rootPart = rig:FindFirstChild("HumanoidRootPart")
	local rootBefore = rootPart and rootPart.CFrame
	local rootStart = rootPoseAt(clip, 0)
	local function applyRoot(time)
		if not rootPart or not rootBefore or not rootStart then return end
		local current = rootPoseAt(clip, time)
		if not current then return end
		local height = (current.height or rootStart.height or 0) - (rootStart.height or 0)
		local yaw = clip.rootYawInAnimation == false and (current.yaw - rootStart.yaw) or 0
		-- Always apply from the original frame.  A dropped cadence, a repeat, or
		-- a failed preview must never accumulate travel.
		rootPart.CFrame = rootBefore * CFrame.new(current.x - rootStart.x,
			current.y - rootStart.y + height, current.z - rootStart.z) * CFrame.Angles(0, yaw, 0)
	end

	local track, animation
	local function cleanup()
		previewing = false
		if track then pcall(function() track:Stop(0); track:Destroy() end) end
		if animation then pcall(function() animation:Destroy() end) end
		if rootPart and rootBefore then pcall(function() rootPart.CFrame = rootBefore end) end
		for joint, value in pairs(before) do
			pcall(function() joint.Transform = value end)
		end
	end
	stopPreview = cleanup
	local ok = pcall(function()
		animation = Instance.new("Animation")
		animation.AnimationId = KeyframeSequenceProvider:RegisterKeyframeSequence(sequence)
		track = animator:LoadAnimation(animation)
		if not previewing or unloaded then return end
		track:Play(0)
		local waited = 0
		while previewing and track.Length &lt;= 0 and waited &lt; 3 do
			task.wait(0.05)
			waited += 0.05
		end
		if track.Length &lt;= 0 and previewing then error("preview unavailable", 0) end
		local step, elapsed = 1 / clip.fps, 0
		while previewing and not unloaded and elapsed &lt; math.min(track.Length, clip.duration + 1) do
			animator:StepAnimations(step)
			elapsed += step
			applyRoot(elapsed)
			task.wait(step)
		end
	end)
	cleanup()
	stopPreview = nil
	if not unloaded then
		previewButton.Text = "Pre-visualizar"
		setBusy(false)
		if not ok then fail("não foi possível reproduzir a prévia; abra o clipe no Animation Editor") end
	end
end

previewButton.MouseButton1Click:Connect(function()
	if previewing then
		previewing = false            -- the loop notices and restores the rig
		return
	end
	if busy then return end
	if RunService:IsRunning() then return fail("pare o teste do jogo antes de visualizar a animação") end
	if not lastSequence or not lastSequence.Parent then
		return fail("nada para pre-visualizar")
	end
	local rig = refreshRig()
	if not rig or rig ~= lastRig then
		return fail("selecione o rig do clipe")
	end
	previewing = true
	previewButton.Text = "Parar"
	setBusy(true)
	task.spawn(playPreview, lastClip, lastSequence, rig)
end)

--- Pairing is always reachable; connecting switches to cloud mode only after
-- the code has been claimed successfully.
local function refreshMode()
	local cloud = currentMode() == "cloud"
	local paired = deviceToken() ~= nil
	modeButton.Text = cloud and ("Modo: nuvem (" .. BRAND .. ")") or "Modo: local"
	codeLabel.Visible = not paired
	codeBox.Visible = not paired
	pairButton.Visible = true
	pairButton.Text = paired and "Desconectar" or "Conectar"
	inboxButton.Visible = cloud and paired
	clipIdLabel.Visible = cloud and paired
	clipIdBox.Visible = cloud and paired
	importIdButton.Visible = cloud and paired
	syncRigButton.Visible = cloud
	setEnabled(syncRigButton, not busy and cloud and paired)
	inboxList.Visible = cloud and paired
	setEnabled(quoteButton, not busy and (not cloud or paired))
	setEnabled(generateButton, not busy and (not cloud or paired)
		and pendingQuote ~= nil)
	if not cloud then
		inboxLabel.Text = ""
	end
end

onAuthLost = function()
	setQuote(nil)
	clearInboxOffers()
	inboxLabel.Text = ""
	refreshMode()
	fail("conexão inválida ou revogada; gere um novo código no site")
end

modeButton.MouseButton1Click:Connect(function()
	if busy then return end
	local saved = pcall(function()
		plugin:SetSetting("tripoanim_mode", currentMode() == "cloud" and "local" or "cloud")
	end)
	if not saved then return fail("não foi possível salvar o modo neste Studio") end
	-- Quote ids belong to the transport that issued them and cannot cross modes.
	setQuote(nil)
	refreshMode()
	task.spawn(refreshHealthDeferred)
end)

pairButton.MouseButton1Click:Connect(function()
	if busy then return end
	if deviceToken() then
		local saved = pcall(function() plugin:SetSetting("tripoanim_token", nil) end)
		if not saved then return fail("não foi possível desconectar; revogue o dispositivo pelo site") end
		setQuote(nil)
		clearInboxOffers()
		inboxLabel.Text = ""
		refreshMode()
		statusLabel.Text = "desconectado deste Studio; para revogar o dispositivo, use Conectar Studio no site"
		statusLabel.TextColor3 = color("MainText")
		task.spawn(refreshHealthDeferred)
		return
	end
	local code = codeBox.Text:upper():gsub("[^A-Z0-9]", "")
	if #code ~= 8 then
		return fail("cole o codigo de 8 caracteres gerado no site")
	end
	-- Pairing has to reach the cloud even while the plugin is still local, so
	-- the mode is overridden for this one call rather than switched blindly.
	setBusy(true, "conectando este Studio...")
	local claimed, why, statusCode = api("claim", { code = code }, nil, "cloud")
	setBusy(false)
	if not claimed or not Validation.token(claimed.token) then
		-- The endpoint answers an identical 404 for unknown, expired and
		-- already-claimed, so the message must not pretend to know which.
		if statusCode == 404 then
			return fail("codigo invalido ou expirado - gere outro no site")
		end
		return fail(why or "nao foi possivel conectar este Studio")
	end
	-- plugin:SetSetting is unencrypted local Studio state, so this token is
	-- exactly as safe as the machine. That is why it is scoped to the
	-- tripoanim_* tables and revocable from the site, and never a site login.
	local saved = pcall(function()
		plugin:SetSetting("tripoanim_token", claimed.token)
		plugin:SetSetting("tripoanim_mode", "cloud")
	end)
	if not saved then
		pcall(function() plugin:SetSetting("tripoanim_token", nil) end)
		return fail("não foi possível salvar a conexão; revogue o dispositivo no site e tente um novo código")
	end
	setQuote(nil)
	codeBox.Text = ""
	clearInboxOffers()
	refreshMode()
	statusLabel.Text = "pareado - este Studio agora recebe seus clipes"
	statusLabel.TextColor3 = color("MainText")
	task.spawn(refreshHealthDeferred)
end)

--- Import one ready clip into the selected rig. Shared with the generate flow.
importClip = function(clip, rig)
	if RunService:IsRunning() then return nil, "pare o teste do jogo antes de importar a animação" end
	if unloaded or not rig:IsDescendantOf(workspace) then return nil, "selecione um R15 no Workspace antes de importar" end
	local container, saveWhy = resolveSaveContainer(rig)
	if not container then return nil, saveWhy end
	local sequence, buildWhy, report, appliedClip = buildSequence(clip, rig)
	if not sequence then
		return nil, buildWhy
	end
	-- Building yields. Re-check the original target before touching the place.
	if unloaded or not rig:IsDescendantOf(workspace) or resolveSaveContainer(rig) ~= container then
		sequence:Destroy()
		return nil, "o rig ou a pasta de animações mudou durante a importação; selecione o R15 e tente novamente"
	end
	local recording = ChangeHistoryService:TryBeginRecording(BRAND .. ": importar animação")
	if not recording then
		sequence:Destroy()
		return nil, "o Studio está ocupado com outra edição; tente importar novamente"
	end
	local saved = pcall(function()
		assert(saveToAnimSaves(rig, sequence, container))
		ChangeHistoryService:FinishRecording(recording,
			Enum.FinishRecordingOperation.Commit)
	end)
	if not saved then
		pcall(function() ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Cancel) end)
		sequence:Destroy()
		return nil, "não foi possível salvar o clipe; a importação pode ser tentada novamente"
	end
	Selection:Set({ sequence })
	return sequence, nil, report, appliedClip
end

--- Download and import only the clip the user chose.
local function importInboxEntry(entry, offerButton)
	if busy then
		return
	end
	if offerButton and importedSet()[entry.clip_id] then
		offerButton:Destroy()
		return
	end
	local rig, why = refreshRig()
	if not rig then
		return fail(why or "selecione o rig antes de importar")
	end
	setBusy(true, ("baixando %s..."):format(entry.name or entry.clip_id))
	task.spawn(function()
		local clip, clipWhy = fetchClip(entry.clip_id, "cloud")
		if not clip then
			setBusy(false)
			return fail(clipWhy)
		end
		local sequence, importWhy, _, appliedClip = importClip(clip, rig)
		if not sequence then
			setBusy(false)
			return fail(importWhy)
		end
		markImported(entry.clip_id)
		recordImported(entry.clip_id)
		lastSequence = sequence
		lastClip = appliedClip or clip
		lastRig = rig
		previewButton.Visible = true
		if offerButton then offerButton:Destroy() end
		local remaining = inboxOfferCount()
		inboxLabel.Text = remaining == 0 and "nenhum clipe novo"
			or ("%d clipe(s) prontos para importar"):format(remaining)
		inboxLabel.TextColor3 = color(remaining == 0 and "DimmedText" or "MainText")
		setBusy(false)
		statusLabel.Text = ("importado em %s"):format(sequence:GetFullName())
		statusLabel.TextColor3 = color("MainText")
	end)
end

importIdButton.MouseButton1Click:Connect(function()
	if busy or currentMode() ~= "cloud" or not deviceToken() then return end
	local id = clipIdBox.Text:match("^%s*(.-)%s*$")
	if not Validation.uuid(id) then
		return fail("copie o ID completo do clipe na biblioteca do site")
	end
	-- The authenticated server checks ownership. This also permits importing a
	-- previously imported clip onto a different selected rig.
	importInboxEntry({ clip_id = id }, nil)
end)

--- The inbox. This is what makes "you can close Studio" true: generation runs
-- on the server, and the plugin asks later what is waiting.
local function checkInbox(quiet)
	if currentMode() ~= "cloud" or not deviceToken() then
		return
	end
	local queriedToken = deviceToken()
	local entries, why = inboxEntries()
	if queriedToken ~= deviceToken() or currentMode() ~= "cloud" or unloaded then return end
	if not entries then
		if not quiet then
			fail(why)
		end
		return
	end
	detailLabel.Text = BRAND .. " conectado; clipes prontos consultados"
	detailLabel.TextColor3 = color("DimmedText")
	local done = importedSet()
	local pending = {}
	for _, entry in ipairs(entries) do
		if entry.clip_id and not done[entry.clip_id] then
			table.insert(pending, entry)
		end
	end
	clearInboxOffers()
	if #pending == 0 then
		inboxLabel.Text = "nenhum clipe novo"
		inboxLabel.TextColor3 = color("DimmedText")
		return pending
	end
	inboxLabel.Text = ("%d clipe(s) prontos para importar"):format(#pending)
	inboxLabel.TextColor3 = color("MainText")
	for _, entry in ipairs(pending) do
		local offered = entry
		local offerButton = makeButton(("Importar %s (%.1fs, %d KiB)"):format(
			entry.name or "?", tonumber(entry.duration) or 0,
			math.floor((tonumber(entry.bytes) or 0) / 1024)))
		offerButton.Parent = inboxList
		offerButton.MouseButton1Click:Connect(function()
			importInboxEntry(offered, offerButton)
		end)
	end
	return pending
end

inboxButton.MouseButton1Click:Connect(function()
	if busy then
		return
	end
	setBusy(true, "buscando clipes prontos...")
	task.spawn(function()
		local pending = checkInbox(false)
		if pending then
			setBusy(false, #pending &gt; 0 and "escolha um clipe para importar" or "")
		else
			setBusy(false)
		end
	end)
end)

local function refreshHealth()
	if currentMode() == "cloud" then
		if not deviceToken() then
			detailLabel.Text = "nao pareado - gere um codigo no site e cole acima"
			detailLabel.TextColor3 = color("WarningText")
			return
		end
		-- Opening a widget must not create quote rows or use quote rate limits.
		-- checkInbox performs the authenticated read and reports its result.
		detailLabel.Text = "conexão salva; use Buscar clipes prontos para verificar"
		detailLabel.TextColor3 = color("DimmedText")
		return
	end
	local health = api("health")
	if not health then
		detailLabel.Text = ("servico offline - rode `python service/app.py` (%s)")
			:format(BASE["local"])
		detailLabel.TextColor3 = color("WarningText")
		return
	end
	local connected = type(health.providers) == "table" and health.providers[BRAND:lower()]
	detailLabel.Text = ("servico ok - %s %s\n%s"):format(
		BRAND, connected and "conectado" or "SEM CHAVE", Validation.text(health.rig, "", 120))
	detailLabel.TextColor3 = color("DimmedText")
end

refreshHealthDeferred = refreshHealth
refreshMode()

button.Click:Connect(function()
	widget.Enabled = not widget.Enabled
end)
widget:GetPropertyChangedSignal("Enabled"):Connect(function()
	if not widget.Enabled and previewing then
		previewing = false
		if stopPreview then stopPreview() end
	end
	if widget.Enabled then
		refreshRig()
		refreshMode()
		task.spawn(refreshHealth)
		-- Checking on open is what lets a clip generated while Studio was
		-- closed be waiting when it reopens.
		task.spawn(function()
			checkInbox(true)
		end)
	end
end)
plugin.Unloading:Connect(function()
	unloaded = true
	previewing = false
	if stopPreview then stopPreview() end
end)
if widget.Enabled then
	task.spawn(refreshHealth)
	task.spawn(function()
		checkInbox(true)
	end)
end
</ProtectedString>
    </Properties>
  </Item>
</roblox>
