@el4cteo/rbx-studio-mcp 0.6.1 → 0.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +28 -2
  2. package/dist/bridge/console.js +182 -0
  3. package/dist/bridge/console.js.map +1 -1
  4. package/dist/index.js +8 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/cloudassets.js +233 -0
  7. package/dist/lib/cloudassets.js.map +1 -0
  8. package/dist/lib/credentials.js +180 -0
  9. package/dist/lib/credentials.js.map +1 -0
  10. package/dist/lib/livedata.js +325 -0
  11. package/dist/lib/livedata.js.map +1 -0
  12. package/dist/lib/liveluau.js +83 -0
  13. package/dist/lib/liveluau.js.map +1 -0
  14. package/dist/lib/liveops.js +358 -0
  15. package/dist/lib/liveops.js.map +1 -0
  16. package/dist/lib/opencloud.js +235 -0
  17. package/dist/lib/opencloud.js.map +1 -0
  18. package/dist/tools/anim.js +159 -0
  19. package/dist/tools/anim.js.map +1 -0
  20. package/dist/tools/audio.js +96 -0
  21. package/dist/tools/audio.js.map +1 -0
  22. package/dist/tools/character.js +95 -5
  23. package/dist/tools/character.js.map +1 -1
  24. package/dist/tools/data.js +292 -0
  25. package/dist/tools/data.js.map +1 -0
  26. package/dist/tools/device.js +77 -7
  27. package/dist/tools/device.js.map +1 -1
  28. package/dist/tools/discover.js +80 -4
  29. package/dist/tools/discover.js.map +1 -1
  30. package/dist/tools/exec.js +96 -2
  31. package/dist/tools/exec.js.map +1 -1
  32. package/dist/tools/input.js +35 -9
  33. package/dist/tools/input.js.map +1 -1
  34. package/dist/tools/perf.js +74 -7
  35. package/dist/tools/perf.js.map +1 -1
  36. package/dist/tools/scripts.js +162 -6
  37. package/dist/tools/scripts.js.map +1 -1
  38. package/dist/tools/spatial.js +135 -0
  39. package/dist/tools/spatial.js.map +1 -0
  40. package/dist/tools/universe.js +177 -0
  41. package/dist/tools/universe.js.map +1 -0
  42. package/dist/tools/upload.js +294 -0
  43. package/dist/tools/upload.js.map +1 -0
  44. package/dist/tools/world.js +675 -51
  45. package/dist/tools/world.js.map +1 -1
  46. package/package.json +2 -2
  47. package/plugin/src/Commands.luau +31 -7
  48. package/plugin/src/Config.luau +65 -65
  49. package/plugin/src/Console.luau +1909 -1843
  50. package/plugin/src/Emulation.luau +172 -0
  51. package/plugin/src/Phrase.luau +816 -618
  52. package/plugin/src/Png.luau +8 -4
  53. package/plugin/src/Prompt.luau +965 -961
  54. package/plugin/src/Secret.luau +86 -0
  55. package/plugin/src/Serialize.luau +440 -8
  56. package/plugin/src/Undo.luau +94 -6
  57. package/plugin/src/handlers/Anim.luau +897 -0
  58. package/plugin/src/handlers/Assets.luau +286 -2
  59. package/plugin/src/handlers/Audio.luau +411 -0
  60. package/plugin/src/handlers/Capture.luau +155 -20
  61. package/plugin/src/handlers/Character.luau +823 -361
  62. package/plugin/src/handlers/Data.luau +539 -0
  63. package/plugin/src/handlers/Device.luau +394 -139
  64. package/plugin/src/handlers/Discover.luau +685 -363
  65. package/plugin/src/handlers/Geometry.luau +722 -450
  66. package/plugin/src/handlers/Instances.luau +84 -4
  67. package/plugin/src/handlers/Perf.luau +227 -0
  68. package/plugin/src/handlers/Scripts.luau +673 -539
  69. package/plugin/src/handlers/Session.luau +3 -0
  70. package/plugin/src/handlers/Spatial.luau +334 -0
  71. package/plugin/src/handlers/Viewport.luau +268 -0
  72. package/plugin/src/handlers/World.luau +89 -15
  73. package/plugin/src/init.server.luau +9 -1
  74. package/scripts/build-plugin.mjs +20 -0
  75. package/scripts/check-plugin.mjs +171 -124
@@ -0,0 +1,411 @@
1
+ --!strict
2
+ --[[
3
+ The audio graph: wiring AudioPlayers to whatever plays them.
4
+
5
+ Roblox's modern audio API is not one instance with a Play method. It is a
6
+ signal graph: an `AudioPlayer` holds the asset, an `AudioEmitter` puts sound
7
+ in the world or an `AudioDeviceOutput` sends it straight to the player's
8
+ speakers, effects sit in between, and NONE of them are connected until a
9
+ `Wire` joins two named pins. A place can hold a perfectly configured
10
+ AudioPlayer with the right asset, the right volume and `Playing` true, and be
11
+ completely silent, because the one instance that carries sound between them
12
+ was never made.
13
+
14
+ `create` can already build each of those instances -- that is not the gap.
15
+ The gap is that getting sound out requires knowing the pin names, knowing
16
+ which of the eight-odd sink classes fits the case, and getting the wire's
17
+ direction right, and a wrong guess produces silence rather than an error.
18
+ So this file does not wrap the engine's audio API; it wraps the knowledge of
19
+ how to assemble it.
20
+
21
+ Old `Sound` instances still work and are still the shorter path for a plain
22
+ one-off noise. Reach for this when the case needs what Sound cannot do:
23
+ effects, per-listener mixing, or an emitter fed by several sources.
24
+ ]]
25
+
26
+ local Dispatch = require(script.Parent.Parent.Dispatch)
27
+ local Paths = require(script.Parent.Parent.Paths)
28
+ local Serialize = require(script.Parent.Parent.Serialize)
29
+ local Undo = require(script.Parent.Parent.Undo)
30
+
31
+ local Audio = {}
32
+
33
+ -- Effects usable in the middle of a chain: exactly one Input and one Output, so
34
+ -- they can be spliced in without the caller naming pins.
35
+ local EFFECTS: { [string]: boolean } = {
36
+ AudioFader = true,
37
+ AudioEqualizer = true,
38
+ AudioCompressor = true,
39
+ AudioReverb = true,
40
+ AudioEcho = true,
41
+ AudioDistortion = true,
42
+ AudioPitchShifter = true,
43
+ AudioChorus = true,
44
+ AudioFlanger = true,
45
+ AudioLimiter = true,
46
+ }
47
+
48
+ local function pinsOf(instance: Instance): ({ string }, { string })
49
+ local okIn, inputs = pcall(function()
50
+ return (instance :: any):GetInputPins()
51
+ end)
52
+ local okOut, outputs = pcall(function()
53
+ return (instance :: any):GetOutputPins()
54
+ end)
55
+ return (if okIn then inputs else {}), (if okOut then outputs else {})
56
+ end
57
+
58
+ local function isAudio(instance: Instance): boolean
59
+ local inputs, outputs = pinsOf(instance)
60
+ return #inputs > 0 or #outputs > 0
61
+ end
62
+
63
+ --[[
64
+ Joins two pins with a Wire.
65
+
66
+ The pin names are checked against the instances' own `GetInputPins` and
67
+ `GetOutputPins` rather than against a table here, because that list comes
68
+ from the running engine and this file would go stale. A wrong pin name is
69
+ the failure worth catching: the engine accepts any string, the Wire reports
70
+ `Connected = false`, and the only symptom is silence.
71
+
72
+ The wire is parented to the TARGET. Nothing in the engine requires that, but
73
+ a wire has to live somewhere and scattering them makes a graph unreadable in
74
+ the Explorer; keeping each one with the thing it feeds means the chain reads
75
+ backwards from the speaker, which is how anyone debugging silence walks it.
76
+ ]]
77
+ local function connect(
78
+ source: Instance,
79
+ target: Instance,
80
+ sourcePin: string?,
81
+ targetPin: string?
82
+ ): (Wire, string?)
83
+ local _, sourceOutputs = pinsOf(source)
84
+ local targetInputs, _ = pinsOf(target)
85
+
86
+ if #sourceOutputs == 0 then
87
+ Dispatch.fail(
88
+ "BAD_PARAMS",
89
+ string.format("%s has no output pins, so nothing can come out of it.", source.ClassName)
90
+ )
91
+ end
92
+ if #targetInputs == 0 then
93
+ Dispatch.fail(
94
+ "BAD_PARAMS",
95
+ string.format("%s has no input pins, so nothing can go into it.", target.ClassName)
96
+ )
97
+ end
98
+
99
+ local fromPin = sourcePin or (if table.find(sourceOutputs, "Output") then "Output" else sourceOutputs[1])
100
+ local toPin = targetPin or (if table.find(targetInputs, "Input") then "Input" else targetInputs[1])
101
+
102
+ if not table.find(sourceOutputs, fromPin) then
103
+ Dispatch.fail(
104
+ "BAD_PARAMS",
105
+ string.format('%s has no output pin named "%s".', source.ClassName, fromPin),
106
+ string.format("It has: %s", table.concat(sourceOutputs, ", "))
107
+ )
108
+ end
109
+ if not table.find(targetInputs, toPin) then
110
+ Dispatch.fail(
111
+ "BAD_PARAMS",
112
+ string.format('%s has no input pin named "%s".', target.ClassName, toPin),
113
+ string.format("It has: %s", table.concat(targetInputs, ", "))
114
+ )
115
+ end
116
+
117
+ local wire = Instance.new("Wire")
118
+ wire.SourceInstance = source
119
+ wire.SourceName = fromPin
120
+ wire.TargetInstance = target
121
+ wire.TargetName = toPin
122
+ wire.Parent = target
123
+
124
+ --[[
125
+ `Connected` is read back rather than assumed. It is the engine's own
126
+ verdict on whether this wire carries anything, and it is the difference
127
+ between "wired" and "wired correctly" -- the whole reason silence is so
128
+ hard to debug by hand.
129
+ ]]
130
+ local warning: string? = nil
131
+ if not wire.Connected then
132
+ warning = string.format(
133
+ "The wire from %s.%s to %s.%s reports Connected = false.",
134
+ source.ClassName,
135
+ fromPin,
136
+ target.ClassName,
137
+ toPin
138
+ )
139
+ end
140
+ return wire, warning
141
+ end
142
+
143
+ --[[
144
+ Wires two existing instances together.
145
+ ]]
146
+ function Audio.wire(params: { [string]: any }): { [string]: any }
147
+ local fromPath, toPath = params.from, params.to
148
+ if typeof(fromPath) ~= "string" or typeof(toPath) ~= "string" then
149
+ Dispatch.fail("BAD_PARAMS", "`from` and `to` are both paths to audio instances.")
150
+ end
151
+
152
+ local source = Paths.resolve(fromPath)
153
+ local target = Paths.resolve(toPath)
154
+
155
+ local result, undoable = Undo.record("MCPAudioWire", "Wire audio", function()
156
+ local wire, warning = connect(
157
+ source,
158
+ target,
159
+ if typeof(params.fromPin) == "string" then params.fromPin else nil,
160
+ if typeof(params.toPin) == "string" then params.toPin else nil
161
+ )
162
+ return {
163
+ wire = Paths.of(wire),
164
+ from = Paths.of(source),
165
+ to = Paths.of(target),
166
+ connected = wire.Connected,
167
+ warning = warning,
168
+ }
169
+ end)
170
+
171
+ result.undoable = undoable
172
+ return result
173
+ end
174
+
175
+ --[[
176
+ Builds a whole working chain in one step.
177
+
178
+ `world` is a sound at a place: AudioPlayer -> [effects] -> AudioEmitter,
179
+ parented to a part, heard from wherever it is. This is the common case and
180
+ the one people get wrong, because it also needs an `AudioListener` somewhere
181
+ -- without one on the camera or the character, a correctly wired emitter is
182
+ still silent. Reported rather than created: where the listener belongs is a
183
+ game design decision, not a default.
184
+
185
+ `ui` is a sound with no position: AudioPlayer -> [effects] ->
186
+ AudioDeviceOutput. Menu clicks, music.
187
+ ]]
188
+ function Audio.graph(params: { [string]: any }): { [string]: any }
189
+ local kind = tostring(params.kind or "world")
190
+ if kind ~= "world" and kind ~= "ui" then
191
+ Dispatch.fail("BAD_PARAMS", string.format("unknown kind %q", kind), 'Use "world" or "ui".')
192
+ end
193
+
194
+ local parentPath = params.parent
195
+ if typeof(parentPath) ~= "string" or parentPath == "" then
196
+ Dispatch.fail(
197
+ "BAD_PARAMS",
198
+ "`parent` says where the graph goes.",
199
+ 'For kind="world" that is the part the sound comes from.'
200
+ )
201
+ end
202
+ local parent = Paths.resolve(parentPath)
203
+
204
+ if kind == "world" and not parent:IsA("BasePart") and not parent:IsA("Attachment") then
205
+ Dispatch.fail(
206
+ "BAD_PARAMS",
207
+ string.format("%s is a %s.", parentPath, parent.ClassName),
208
+ "A world sound needs somewhere in the world to come from: parent it "
209
+ .. 'to a BasePart or an Attachment, or use kind="ui" for a sound '
210
+ .. "with no position."
211
+ )
212
+ end
213
+
214
+ local asset = params.asset
215
+ if asset ~= nil and typeof(asset) ~= "string" then
216
+ Dispatch.fail("BAD_PARAMS", "`asset` is an id like \"rbxassetid://1234\".")
217
+ end
218
+
219
+ local effects: { string } = {}
220
+ if params.effects ~= nil then
221
+ if typeof(params.effects) ~= "table" then
222
+ Dispatch.fail("BAD_PARAMS", "`effects` is a list of class names.")
223
+ end
224
+ for _, name in params.effects :: { any } do
225
+ local class = tostring(name)
226
+ if not EFFECTS[class] then
227
+ local known: { string } = {}
228
+ for effect in EFFECTS do
229
+ table.insert(known, effect)
230
+ end
231
+ table.sort(known)
232
+ Dispatch.fail(
233
+ "BAD_PARAMS",
234
+ string.format("%s is not an audio effect that can sit in a chain.", class),
235
+ string.format("Use one of: %s", table.concat(known, ", "))
236
+ )
237
+ end
238
+ table.insert(effects, class)
239
+ end
240
+ end
241
+
242
+ local name = if typeof(params.name) == "string" and params.name ~= "" then params.name else "Audio"
243
+
244
+ local result, undoable = Undo.record("MCPAudioGraph", "Build audio graph", function()
245
+ local created: { string } = {}
246
+ local warnings: { string } = {}
247
+
248
+ local player = Instance.new("AudioPlayer")
249
+ player.Name = name
250
+ if asset then
251
+ --[[
252
+ `AudioContent` is the modern property and takes a Content, not a
253
+ string -- assigning the id directly throws. `Asset` is the older
254
+ ContentId spelling and still works; this uses the one the running
255
+ engine actually has rather than picking a side.
256
+ ]]
257
+ local okContent = pcall(function()
258
+ player.AudioContent = Content.fromUri(asset :: string)
259
+ end)
260
+ if not okContent then
261
+ pcall(function()
262
+ (player :: any).Asset = asset
263
+ end)
264
+ end
265
+ end
266
+ player.Parent = parent
267
+ table.insert(created, Paths.of(player))
268
+
269
+ -- The chain is built front to back, each link wired to the one before.
270
+ local previous: Instance = player
271
+ for _, class in effects do
272
+ local effect = Instance.new(class)
273
+ effect.Name = class:gsub("^Audio", "")
274
+ effect.Parent = parent
275
+ local _, warning = connect(previous, effect)
276
+ if warning then
277
+ table.insert(warnings, warning)
278
+ end
279
+ table.insert(created, Paths.of(effect))
280
+ previous = effect
281
+ end
282
+
283
+ local sink = Instance.new(if kind == "world" then "AudioEmitter" else "AudioDeviceOutput")
284
+ sink.Name = if kind == "world" then "Emitter" else "Output"
285
+ sink.Parent = parent
286
+ local _, sinkWarning = connect(previous, sink)
287
+ if sinkWarning then
288
+ table.insert(warnings, sinkWarning)
289
+ end
290
+ table.insert(created, Paths.of(sink))
291
+
292
+ local note: string? = nil
293
+ if kind == "world" then
294
+ --[[
295
+ An emitter with no listener anywhere is the single most common
296
+ way a correct graph stays silent, and it is invisible from the
297
+ emitter's own properties. Checked and said plainly.
298
+ ]]
299
+ local listeners = 0
300
+ for _, descendant in game:GetDescendants() do
301
+ if descendant:IsA("AudioListener") then
302
+ listeners += 1
303
+ end
304
+ end
305
+ if listeners == 0 then
306
+ note = "Nothing in this place has an AudioListener, so no emitter can "
307
+ .. "be heard. Put one on the camera or the character -- where it "
308
+ .. "goes decides what the player hears, so it is not created here."
309
+ end
310
+ end
311
+
312
+ return {
313
+ kind = kind,
314
+ player = Paths.of(player),
315
+ sink = Paths.of(sink),
316
+ created = created,
317
+ warnings = warnings,
318
+ note = note,
319
+ }
320
+ end)
321
+
322
+ result.undoable = undoable
323
+ return result
324
+ end
325
+
326
+ --[[
327
+ Reads an existing graph back.
328
+
329
+ Walks whatever audio instances live under a path and reports every wire in
330
+ and out of each, which is the view the Explorer will not give: a Wire shows
331
+ as a child of one instance and names the other two only in its properties, so
332
+ following a chain by hand means clicking through every node.
333
+ ]]
334
+ function Audio.inspect(params: { [string]: any }): { [string]: any }
335
+ local path = params.path
336
+ local scope: Instance = if typeof(path) == "string" and path ~= ""
337
+ then Paths.resolve(path)
338
+ else game
339
+
340
+ local nodes: { { [string]: any } } = {}
341
+ local wires: { { [string]: any } } = {}
342
+ local broken = 0
343
+
344
+ local function visit(instance: Instance)
345
+ if instance:IsA("Wire") then
346
+ local source = instance.SourceInstance
347
+ local target = instance.TargetInstance
348
+ if not instance.Connected then
349
+ broken += 1
350
+ end
351
+ table.insert(wires, {
352
+ path = Paths.of(instance),
353
+ from = if source then string.format("%s.%s", Paths.of(source), instance.SourceName) else "(nothing)",
354
+ to = if target then string.format("%s.%s", Paths.of(target), instance.TargetName) else "(nothing)",
355
+ connected = instance.Connected,
356
+ })
357
+ elseif isAudio(instance) then
358
+ local entry: { [string]: any } = {
359
+ path = Paths.of(instance),
360
+ class = instance.ClassName,
361
+ }
362
+ -- Only the properties that decide whether a node is audible; the
363
+ -- full set is what `inspect` is for.
364
+ for _, property in { "Volume", "Playing", "IsPlaying", "Looping", "AudioInteractionGroup" } do
365
+ local ok, value = pcall(function()
366
+ return (instance :: any)[property]
367
+ end)
368
+ if ok and value ~= nil then
369
+ entry[property] = Serialize.value(value)
370
+ end
371
+ end
372
+ table.insert(nodes, entry)
373
+ end
374
+ end
375
+
376
+ visit(scope)
377
+ for _, descendant in scope:GetDescendants() do
378
+ visit(descendant)
379
+ end
380
+
381
+ local note: string? = nil
382
+ if #nodes > 0 and #wires == 0 then
383
+ note = "There are audio instances here but no wires, so nothing carries sound "
384
+ .. "between them. That is silence with no error anywhere."
385
+ elseif broken > 0 then
386
+ note = string.format(
387
+ "%d wire(s) report Connected = false -- usually a pin name that does not "
388
+ .. "exist on one end, or an instance that was deleted.",
389
+ broken
390
+ )
391
+ end
392
+
393
+ return {
394
+ scope = Paths.of(scope),
395
+ nodes = nodes,
396
+ wires = wires,
397
+ nodeCount = #nodes,
398
+ wireCount = #wires,
399
+ note = note,
400
+ }
401
+ end
402
+
403
+ function Audio.register()
404
+ Dispatch.registerAll("audio", {
405
+ wire = Audio.wire,
406
+ graph = Audio.graph,
407
+ inspect = Audio.inspect,
408
+ })
409
+ end
410
+
411
+ return Audio
@@ -221,10 +221,19 @@ local function takeScreenshot(): string
221
221
  )
222
222
  end
223
223
 
224
+ --[[
225
+ A frame at a time to begin with, then a slower poll.
226
+
227
+ The callback normally fires within a frame or two, and a flat 0.05s wait
228
+ billed that to the agent as up to 50ms of doing nothing -- on a call
229
+ whose whole point is that it is taken often. `task.wait()` resumes on the
230
+ next heartbeat, so the common case now costs a frame; after a quarter of
231
+ a second the shot is clearly not coming back promptly and the poll drops
232
+ to the cheap interval for the rest of the timeout.
233
+ ]]
224
234
  local waited = 0
225
235
  while contentId == nil and waited < CAPTURE_TIMEOUT do
226
- task.wait(0.05)
227
- waited += 0.05
236
+ waited += if waited < 0.25 then task.wait() else task.wait(0.05)
228
237
  end
229
238
 
230
239
  if contentId == nil then
@@ -310,6 +319,125 @@ function Capture.playtestId(_params: { [string]: any }): { [string]: any }
310
319
  return { contentId = tostring(result.contentId), device = Emulation.deviceId() }
311
320
  end
312
321
 
322
+ --[[
323
+ Widest image the engine will create for us.
324
+
325
+ `AssetService:CreateEditableImage` caps at 1024 on either axis, and the
326
+ `width` parameter goes to 1600. Above the cap there is no destination to draw
327
+ into, so those calls fall back to the Luau loop rather than failing -- a
328
+ slower big screenshot, not a missing one.
329
+ ]]
330
+ local ENGINE_SCALE_LIMIT = 1024
331
+
332
+ --[[
333
+ Drops alpha. RGBA in, RGB out, no scaling.
334
+
335
+ A screenshot has no alpha worth keeping and carrying it would add a quarter
336
+ to every byte that follows, so the channel goes here rather than on the wire.
337
+
338
+ Copied a word at a time rather than a byte at a time. Each 32-bit read takes
339
+ a whole pixel and each write lands three bytes further on, so the alpha byte
340
+ spills one place into the next pixel's slot -- where the next iteration
341
+ overwrites it. Only the final pixel has nothing following it, which is what
342
+ the one byte of slack on the buffer is for; the result is then trimmed to
343
+ the exact length so nothing downstream has to know about it.
344
+
345
+ Worth the trick: measured on a 799x359 frame, 16ms a byte at a time against
346
+ 6-10ms this way, for output verified identical. That is most of what the
347
+ engine-side downscale costs, on a call agents make constantly.
348
+ ]]
349
+ local function stripAlpha(rgba: buffer, width: number, height: number): buffer
350
+ local pixels = width * height
351
+ local length = pixels * 3
352
+
353
+ local wide = buffer.create(length + 1)
354
+ for index = 0, pixels - 1 do
355
+ buffer.writeu32(wide, index * 3, buffer.readu32(rgba, index * 4))
356
+ end
357
+
358
+ local out = buffer.create(length)
359
+ buffer.copy(out, 0, wide, 0, length)
360
+ return out
361
+ end
362
+
363
+ --[[
364
+ Downscales on the engine's side instead of in Luau.
365
+
366
+ This is about the picture, not the clock. `SamplingMode` defaults to bilinear,
367
+ so a 2x reduction averages the pixels it merges; the Luau loop it replaces
368
+ took every other row and column and dropped the rest, which is how one-pixel
369
+ GUI text and thin edges vanished from screenshots that existed to answer
370
+ "does this look right".
371
+
372
+ It was written expecting to be faster too, and it is not -- that guess is
373
+ recorded here because the measurement is the useful part. On a 1370x616 frame
374
+ scaled to 799: the old full-resolution read costs about 1ms, not the large
375
+ number the 4.4MB of RGBA suggests, and the Luau downscale that follows runs
376
+ in 19-22ms all in. `DrawImageTransformed` alone is 16-17ms of engine time.
377
+ With `stripAlpha` doing the rest a word at a time, this path lands around
378
+ 23ms -- a couple of milliseconds behind, on a call whose round trip is
379
+ measured in hundreds. Paying that for legible text is the trade; pretending
380
+ it was free would have been the mistake.
381
+
382
+ Returns nil when the engine cannot do it -- an older Studio without
383
+ `CreateEditableImage`, or a target past ENGINE_SCALE_LIMIT -- and the caller
384
+ falls back.
385
+ ]]
386
+ local function resample(
387
+ source: any,
388
+ sourceWidth: number,
389
+ sourceHeight: number,
390
+ targetWidth: number
391
+ ): (buffer?, number, number)
392
+ local scale = math.min(1, targetWidth / sourceWidth)
393
+ local outWidth = math.max(1, math.floor(sourceWidth * scale))
394
+ local outHeight = math.max(1, math.floor(sourceHeight * scale))
395
+
396
+ if outWidth > ENGINE_SCALE_LIMIT or outHeight > ENGINE_SCALE_LIMIT then
397
+ return nil, 0, 0
398
+ end
399
+
400
+ local ok, rgb = pcall(function()
401
+ local canvas = (AssetService :: any):CreateEditableImage({
402
+ Size = Vector2.new(outWidth, outHeight),
403
+ })
404
+ --[[
405
+ Position is the centre, not the corner: `DrawImageTransformed` places
406
+ the source's PIVOT at the position given, and the pivot defaults to
407
+ the middle of the source image. Passing Vector2.zero here puts three
408
+ quarters of the frame off-canvas.
409
+ ]]
410
+ local okDraw = pcall(function()
411
+ canvas:DrawImageTransformed(
412
+ Vector2.new(outWidth / 2, outHeight / 2),
413
+ Vector2.new(scale, scale),
414
+ 0,
415
+ source,
416
+ {
417
+ CombineType = Enum.ImageCombineType.Overwrite,
418
+ SamplingMode = Enum.ResamplerMode.Default,
419
+ }
420
+ )
421
+ end)
422
+ if not okDraw then
423
+ canvas:Destroy()
424
+ error("draw failed", 0)
425
+ end
426
+
427
+ local pixels = canvas:ReadPixelsBuffer(Vector2.zero, canvas.Size)
428
+ -- Freed here rather than left to the collector: an EditableImage holds
429
+ -- its pixels outside Luau's heap, and a screenshot every few seconds
430
+ -- would otherwise pile them up for as long as Studio stays open.
431
+ canvas:Destroy()
432
+ return stripAlpha(pixels, outWidth, outHeight)
433
+ end)
434
+
435
+ if not ok then
436
+ return nil, 0, 0
437
+ end
438
+ return rgb :: buffer, outWidth, outHeight
439
+ end
440
+
313
441
  --[[
314
442
  Turns a content id into pixels on the wire.
315
443
 
@@ -332,23 +460,30 @@ local function encode(contentId: string, width: number, context: string): { [str
332
460
  local sourceWidth = math.floor(size.X)
333
461
  local sourceHeight = math.floor(size.Y)
334
462
 
335
- --[[
336
- Both arguments are required. Called with none it reports "expects 2
337
- arguments" rather than defaulting to the whole image, which is worth
338
- stating because every other read on this object takes none.
339
- ]]
340
- local okPixels, pixels = pcall(function()
341
- return (image :: any):ReadPixelsBuffer(Vector2.zero, size)
342
- end)
343
- if not okPixels then
344
- Dispatch.fail(
345
- "CAPTURE_UNREADABLE",
346
- string.format("The screenshot's pixels could not be read: %s", tostring(pixels))
347
- )
463
+ local rgb, outWidth, outHeight = resample(image, sourceWidth, sourceHeight, width)
464
+
465
+ if rgb == nil then
466
+ --[[
467
+ Both arguments are required. Called with none it reports "expects 2
468
+ arguments" rather than defaulting to the whole image, which is worth
469
+ stating because every other read on this object takes none.
470
+ ]]
471
+ local okPixels, pixels = pcall(function()
472
+ return (image :: any):ReadPixelsBuffer(Vector2.zero, size)
473
+ end)
474
+ if not okPixels then
475
+ Dispatch.fail(
476
+ "CAPTURE_UNREADABLE",
477
+ string.format("The screenshot's pixels could not be read: %s", tostring(pixels))
478
+ )
479
+ end
480
+ rgb, outWidth, outHeight = Png.downscaleToRgb(pixels, sourceWidth, sourceHeight, width)
348
481
  end
349
482
 
350
- local rgb, outWidth, outHeight = Png.downscaleToRgb(pixels, sourceWidth, sourceHeight, width)
351
- local black = isFlat(rgb)
483
+ -- On the scaled image, not the source: the question is whether anything was
484
+ -- drawn, which survives the downscale, and it is a quarter of the pixels to
485
+ -- walk.
486
+ local black = isFlat(rgb :: buffer)
352
487
 
353
488
  --[[
354
489
  Raw pixels, compressed by the engine, assembled into a PNG by Node.
@@ -370,7 +505,7 @@ local function encode(contentId: string, width: number, context: string): { [str
370
505
  ]]
371
506
  local okPacked, packed = pcall(function()
372
507
  local compressed = (EncodingService :: any):CompressBuffer(
373
- rgb,
508
+ rgb :: buffer,
374
509
  (Enum :: any).CompressionAlgorithm.Zstd,
375
510
  COMPRESSION_LEVEL
376
511
  )
@@ -385,7 +520,7 @@ local function encode(contentId: string, width: number, context: string): { [str
385
520
  height = outHeight,
386
521
  sourceWidth = sourceWidth,
387
522
  sourceHeight = sourceHeight,
388
- rawBytes = buffer.len(rgb),
523
+ rawBytes = buffer.len(rgb :: buffer),
389
524
  bytes = #(packed :: string),
390
525
  context = context,
391
526
  black = black,
@@ -393,7 +528,7 @@ local function encode(contentId: string, width: number, context: string): { [str
393
528
  }
394
529
  end
395
530
 
396
- local png = Png.encode(rgb, outWidth, outHeight)
531
+ local png = Png.encode(rgb :: buffer, outWidth, outHeight)
397
532
 
398
533
  return {
399
534
  encoding = "png",