diff --git a/Customize.lua b/Customize.lua index e75ee37..96635ff 100644 --- a/Customize.lua +++ b/Customize.lua @@ -18,6 +18,8 @@ Customize.StudioHeading = 180.0 Customize.WaitAfterApply = 500 -- ms Customize.WaitAfterCapture = 300 -- ms Customize.TextureLoadWait = 600 -- ms +Customize.ScreenshotTimeout = 15000 -- ms to wait for screenshot-basic before skipping a shot +Customize.UploadAckTimeout = 45000 -- ms to wait for the server to save/process each shot Customize.CaptureAllTextures = false -- true = all textures, false = texture 0 only -- Batch / Performance diff --git a/README.md b/README.md index 2dee35a..39014f0 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,8 @@ All settings live in [`Customize.lua`](Customize.lua). Common knobs: | `Customize.TransparentBg` | `true` | Chroma key removal (PNG only) | | `Customize.ScreenshotWidth` | `512` | Output image width | | `Customize.ScreenshotHeight` | `512` | Output image height | +| `Customize.ScreenshotTimeout` | `15000` | Milliseconds to wait for `screenshot-basic` before skipping a shot | +| `Customize.UploadAckTimeout` | `45000` | Milliseconds to wait for server processing/save acknowledgement | | `Customize.CaptureAllTextures` | `false` | Capture all texture variants (not just default) | | `Customize.ChromaKeyColor` | `'magenta'` | Background color: `'green'` or `'magenta'` | | `Customize.BatchSize` | `10` | Captures per batch before cooldown | diff --git a/client/client.lua b/client/client.lua index c0751ea..9a83dbf 100644 --- a/client/client.lua +++ b/client/client.lua @@ -12,6 +12,9 @@ local captureMode = 'clothing' -- 'clothing' | 'vehicle' | 'object' local spawnedEntity = nil local vehicleColor = { primary = 0, secondary = 0 } local entitySpawnToken = 0 -- increments each spawn request to cancel stale ones +local hideHeadActive = false +local captureUploadSeq = 0 +local pendingCaptureUploads = {} -- Orbit-camera state. Declared up here so functions defined earlier in the -- file (CreateCaptureCamera, ...) can read the live orbit values that the @@ -156,9 +159,9 @@ local function CreateCaptureCamera(entity, preset, presetName) -- Legacy preset without defaultAngleH: rotate ped to align with -- camera, then place camera behind ped's forward vector. local rotZ = preset.rotation.z + captureRotOffset - SetEntityRotation(ped, preset.rotation.x, preset.rotation.y, rotZ, 2, false) + SetEntityRotation(entity, preset.rotation.x, preset.rotation.y, rotZ, 2, false) Wait(50) - local fwd = GetEntityForwardVector(ped) + local fwd = GetEntityForwardVector(entity) local dist = preset.dist or 1.2 camX = pedPos.x - fwd.x * dist camY = pedPos.y - fwd.y * dist @@ -434,6 +437,15 @@ end -- CAPTURE & UPLOAD -- ════════════════════════════════════════════════════════ +RegisterNetEvent('uz_autoshot:client:captureProcessed', function(requestId, ok, message) + local pending = pendingCaptureUploads[requestId] + if not pending then return end + + pending.done = true + pending.ok = ok == true + pending.message = message or '' +end) + local function CaptureAndUpload(filename) ForceHighQuality() @@ -446,20 +458,40 @@ local function CaptureAndUpload(filename) end local done, base64 = false, nil - exports['screenshot-basic']:requestScreenshot(opts, function(data) - base64 = data - done = true + local screenshotOk, screenshotErr = pcall(function() + exports['screenshot-basic']:requestScreenshot(opts, function(data) + base64 = data + done = true + end) end) - local timeout = GetGameTimer() + 10000 - while not done and GetGameTimer() < timeout do Wait(50) end + if not screenshotOk then + print('^1[uz_AutoShot]^0 Screenshot request failed (' .. filename .. '): ' .. tostring(screenshotErr)) + return false + end + + local timeout = GetGameTimer() + (Customize.ScreenshotTimeout or 15000) + while not done and GetGameTimer() < timeout and not isCancelled do Wait(50) end + + if isCancelled then return false end + + if not done then + print('^3[uz_AutoShot]^0 Capture timed out (' .. filename .. ')') + return false + end if not base64 or base64 == '' then print('^3[uz_AutoShot]^0 Capture skipped (' .. filename .. '): empty screenshot') - return + return false end - TriggerLatentServerEvent('uz_autoshot:server:processCapture', Customize.LatentRate or 8000000, { + captureUploadSeq = captureUploadSeq + 1 + local requestId = ('%d:%d:%d'):format(GetPlayerServerId(PlayerId()), GetGameTimer(), captureUploadSeq) + local pending = { done = false, ok = false, message = '' } + pendingCaptureUploads[requestId] = pending + + local sendOk, sendErr = pcall(TriggerLatentServerEvent, 'uz_autoshot:server:processCapture', Customize.LatentRate or 8000000, { + requestId = requestId, filename = filename, format = Customize.ScreenshotFormat or 'png', transparent = Customize.TransparentBg and true or false, @@ -468,6 +500,30 @@ local function CaptureAndUpload(filename) height = Customize.ScreenshotHeight or 0, imageData = base64, }) + + if not sendOk then + pendingCaptureUploads[requestId] = nil + print('^1[uz_AutoShot]^0 Upload send failed (' .. filename .. '): ' .. tostring(sendErr)) + return false + end + + local uploadTimeout = GetGameTimer() + (Customize.UploadAckTimeout or 45000) + while not pending.done and GetGameTimer() < uploadTimeout and not isCancelled do Wait(50) end + pendingCaptureUploads[requestId] = nil + + if isCancelled then return false end + + if not pending.done then + print('^3[uz_AutoShot]^0 Upload timed out (' .. filename .. ')') + return false + end + + if not pending.ok then + print('^3[uz_AutoShot]^0 Upload failed (' .. filename .. '): ' .. (pending.message ~= '' and pending.message or 'server rejected capture')) + return false + end + + return true end local function SendProgress(current, total, category) @@ -1359,8 +1415,6 @@ end -- HEAD HIDE (chroma key mask) -- ════════════════════════════════════════════════════════ -local hideHeadActive = false - local function DrawHeadChromaMask(ped) if not hideHeadActive then return end local gs = Customize.GreenScreen @@ -1504,15 +1558,66 @@ end -- NUI CALLBACKS -- ════════════════════════════════════════════════════════ +local function SafeCall(label, fn) + local ok, err = xpcall(fn, debug.traceback) + if not ok then + print(('^1[uz_AutoShot]^0 %s failed: %s'):format(label, tostring(err))) + end + return ok +end + +local function RecoverPlayerAfterError() + SafeCall('destroy camera', DestroyCamera) + SafeCall('destroy orbit camera', DestroyOrbitCamera) + SafeCall('delete studio entity', DeleteStudioEntity) + SafeCall('restore HUD', function() HideHUD(false) end) + + hideHeadActive = false + isCapturing = false + isBrowsing = false + isPaused = false + isCancelled = false + isPreview = false + captureMode = 'clothing' + pendingCaptureUploads = {} + + SafeCall('restore player state', function() + local ped = PlayerPedId() + if not IsEntityVisible(ped) then SetEntityVisible(ped, true, false) end + FreezeEntityPosition(ped, false) + SetPlayerControl(PlayerId(), true, 0) + end) + SafeCall('restore appearance', RestoreFullAppearance) + SafeCall('reset routing bucket', function() TriggerServerEvent('uz_autoshot:server:resetBucket') end) + SafeCall('drop NUI focus', function() SetNuiFocus(false, false) end) + SafeCall('notify NUI', function() + SendNUIMessage({ type = 'forceClose' }) + SendNUIMessage({ type = 'captureCancelled' }) + end) +end + +local function CreateSafeThread(label, fn) + CreateThread(function() + local ok, err = xpcall(fn, debug.traceback) + if ok then return end + + print(('^1[uz_AutoShot]^0 %s failed: %s'):format(label, tostring(err))) + RecoverPlayerAfterError() + end) +end + RegisterNUICallback('startCapture', function(data, cb) cb('ok') if not isPreview then return end - CreateThread(function() RunCapture(data.selectedComponents or {}, data.selectedProps or {}, data.selectedVehicles or {}, data.selectedObjects or {}, data.selectedOverlays or {}) end) + data = data or {} + CreateSafeThread('capture run', function() + RunCapture(data.selectedComponents or {}, data.selectedProps or {}, data.selectedVehicles or {}, data.selectedObjects or {}, data.selectedOverlays or {}) + end) end) RegisterNUICallback('cancelPreview', function(_, cb) - CancelPreview() cb('ok') + CreateSafeThread('cancel preview', CancelPreview) end) RegisterNUICallback('pauseCapture', function(_, cb) @@ -1532,12 +1637,13 @@ RegisterNUICallback('cancelCapture', function(_, cb) end) RegisterNUICallback('closeMenu', function(_, cb) - CloseBrowsing() cb('ok') + CreateSafeThread('close menu', CloseBrowsing) end) RegisterNUICallback('applyClothing', function(data, cb) cb('ok') + data = data or {} local ped = PlayerPedId() if data.itemType == 'component' then SetPedComponentVariation(ped, data.id, data.drawable, data.texture, 0) @@ -1549,7 +1655,7 @@ RegisterNUICallback('applyClothing', function(data, cb) for i = 0, 12 do SetPedHeadOverlay(ped, i, 255, 1.0) end ApplyOverlayWithColor(ped, data.id, data.drawable) elseif data.itemType == 'vehicle' and data.model then - CreateThread(function() + CreateSafeThread('vehicle preview apply', function() DeleteStudioEntity() Wait(0) SetEntityVisible(ped, false, false) @@ -1568,7 +1674,8 @@ RegisterNUICallback('applyClothing', function(data, cb) end end) -RegisterNUICallback('setCameraPreset', function(data, cb) +local function HandleSetCameraPreset(data) + data = data or {} local cam = data.camera or 'torso' activePreviewCamera = cam @@ -1585,7 +1692,7 @@ RegisterNUICallback('setCameraPreset', function(data, cb) entitySpawnToken = entitySpawnToken + 1 local myToken = entitySpawnToken - CreateThread(function() + CreateSafeThread('entity preview spawn', function() local ped = PlayerPedId() -- Delete previous entity first @@ -1718,10 +1825,15 @@ RegisterNUICallback('setCameraPreset', function(data, cb) if orbitCam then SetCamFov(orbitCam, orbitFov) end UpdateOrbitCamera() end +end + +RegisterNUICallback('setCameraPreset', function(data, cb) cb('ok') + CreateSafeThread('set camera preset', function() HandleSetCameraPreset(data) end) end) RegisterNUICallback('saveCameraAngle', function(data, cb) + data = data or {} local cam = data.camera or activePreviewCamera if cam and orbitCam then -- Use entity coords as reference for vehicle/object, ped coords for clothing @@ -1771,6 +1883,7 @@ RegisterNUICallback('getCameraValues', function(_, cb) end) RegisterNUICallback('rotateCamera', function(data, cb) + data = data or {} if orbitCam then orbitAngleH = orbitAngleH - (data.deltaX or 0) * 0.005 orbitCamZ = orbitCamZ - (data.deltaY or 0) * 0.003 @@ -1780,6 +1893,7 @@ RegisterNUICallback('rotateCamera', function(data, cb) end) RegisterNUICallback('zoomCamera', function(data, cb) + data = data or {} if orbitCam then local maxDist = captureMode == 'vehicle' and 20.0 or captureMode == 'object' and 10.0 or 5.0 orbitDist = math.max(0.1, math.min(maxDist, orbitDist + (data.delta or 0) * 0.1)) @@ -1789,6 +1903,7 @@ RegisterNUICallback('zoomCamera', function(data, cb) end) RegisterNUICallback('rollCamera', function(data, cb) + data = data or {} if orbitCam then orbitRoll = orbitRoll + (data.deltaX or 0) * 0.3 UpdateOrbitCamera() @@ -1797,6 +1912,7 @@ RegisterNUICallback('rollCamera', function(data, cb) end) RegisterNUICallback('adjustZPos', function(data, cb) + data = data or {} if orbitCam then local delta = data.delta or 0 orbitCenter = vector3(orbitCenter.x, orbitCenter.y, orbitCenter.z + delta) @@ -1806,6 +1922,7 @@ RegisterNUICallback('adjustZPos', function(data, cb) end) RegisterNUICallback('adjustFov', function(data, cb) + data = data or {} if orbitCam then orbitFov = math.max(5.0, math.min(120.0, orbitFov + (data.delta or 0))) SetCamFov(orbitCam, orbitFov) @@ -1821,6 +1938,7 @@ RegisterNUICallback('resetCameraPreset', function(_, cb) end) RegisterNUICallback('setVehicleColor', function(data, cb) + data = data or {} vehicleColor.primary = data.primary or vehicleColor.primary vehicleColor.secondary = data.secondary or vehicleColor.secondary if spawnedEntity and DoesEntityExist(spawnedEntity) and captureMode == 'vehicle' then @@ -1830,9 +1948,10 @@ RegisterNUICallback('setVehicleColor', function(data, cb) end) RegisterNUICallback('getTextures', function(data, cb) + data = data or {} local ped = PlayerPedId() local count - if data.itemType == 'overlay' then + if data.itemType == 'overlay' or data.id == nil or data.drawable == nil then count = 0 elseif data.itemType == 'component' then count = GetNumberOfPedTextureVariations(ped, data.id, data.drawable) @@ -1843,31 +1962,36 @@ RegisterNUICallback('getTextures', function(data, cb) end) RegisterNUICallback('enterRecapturePreview', function(_, cb) - isPreview = true - HideHUD(true) - TriggerServerEvent('uz_autoshot:server:setBucket', Customize.RoutingBucket) - Wait(500) + cb('ok') + CreateSafeThread('enter recapture preview', function() + isPreview = true + HideHUD(true) + TriggerServerEvent('uz_autoshot:server:setBucket', Customize.RoutingBucket) + Wait(500) - local ped = SetupCapturePed(pedAppearance.model or GetEntityModel(PlayerPedId())) + local ped = SetupCapturePed(pedAppearance.model or GetEntityModel(PlayerPedId())) - DestroyOrbitCamera() - CreateOrbitCamera(ped) - cb('ok') + DestroyOrbitCamera() + CreateOrbitCamera(ped) + end) end) RegisterNUICallback('cancelRecapturePreview', function(_, cb) - isPreview = false - isBrowsing = false - DestroyOrbitCamera() - HideHUD(false) - RestoreFullAppearance() - TriggerServerEvent('uz_autoshot:server:resetBucket') - SetNuiFocus(false, false) cb('ok') + CreateSafeThread('cancel recapture preview', function() + isPreview = false + isBrowsing = false + DestroyOrbitCamera() + HideHUD(false) + RestoreFullAppearance() + TriggerServerEvent('uz_autoshot:server:resetBucket') + SetNuiFocus(false, false) + end) end) RegisterNUICallback('recaptureItems', function(data, cb) cb('ok') + data = data or {} local items = data.items or {} if #items == 0 then return end @@ -1894,8 +2018,10 @@ RegisterNUICallback('recaptureItems', function(data, cb) end end - DestroyOrbitCamera() - CreateThread(function() RecaptureSpecificItems(items) end) + CreateSafeThread('recapture items', function() + DestroyOrbitCamera() + RecaptureSpecificItems(items) + end) end) -- ════════════════════════════════════════════════════════ @@ -1959,6 +2085,9 @@ RegisterCommand('shotcar', function(_, args) SendNUIMessage({ type = 'singleEntityPreview', model = modelName, entityType = 'vehicle' }) SetNuiFocus(true, true) + else + print('^3[uz_AutoShot]^0 Vehicle preview failed to spawn: ' .. modelName) + CancelPreview() end end, Customize.AceRestricted) @@ -2016,6 +2145,9 @@ RegisterCommand('shotprop', function(_, args) SendNUIMessage({ type = 'singleEntityPreview', model = modelName, entityType = 'object' }) SetNuiFocus(true, true) + else + print('^3[uz_AutoShot]^0 Object preview failed to spawn: ' .. modelName) + CancelPreview() end end, Customize.AceRestricted) @@ -2023,10 +2155,11 @@ end, Customize.AceRestricted) RegisterNUICallback('confirmSingleCapture', function(data, cb) cb('ok') if not isPreview or not spawnedEntity then return end + data = data or {} local model = data.model or '' local eType = data.entityType or 'object' - CreateThread(function() + CreateSafeThread('single entity capture', function() captureRotOffset = math.deg(orbitAngleH) - Customize.StudioHeading DestroyOrbitCamera() isPreview = false @@ -2052,8 +2185,8 @@ RegisterNUICallback('confirmSingleCapture', function(data, cb) end) RegisterNUICallback('cancelSingleCapture', function(_, cb) - CancelPreview() cb('ok') + CreateSafeThread('cancel single capture', CancelPreview) end) -- ════════════════════════════════════════════════════════ diff --git a/resources/src/App.jsx b/resources/src/App.jsx index 89185af..02a9a38 100644 --- a/resources/src/App.jsx +++ b/resources/src/App.jsx @@ -5,13 +5,19 @@ import { CaptureWidget } from './components/CaptureWidget' import { ClothingMenu } from './components/ClothingMenu' // ── NUI bridge ────────────────────────────────────── -const fetchNUI = async (eventName, data = {}) => { +const fetchNUI = async (eventName, data = {}, timeoutMs = 5000) => { + const controller = new AbortController() + const timeout = window.setTimeout(() => controller.abort(), timeoutMs) + try { const r = await fetch(`https://uz_AutoShot/${eventName}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), + signal: controller.signal, }) - return await r.json() + const text = await r.text() + return text ? JSON.parse(text) : null } catch { return null } + finally { window.clearTimeout(timeout) } } // ── Orbit hint ────────────────────────────────────── diff --git a/server/server.js b/server/server.js index 961b516..95a6417 100644 --- a/server/server.js +++ b/server/server.js @@ -239,14 +239,26 @@ function resizePNG(pngBuffer, targetW, targetH) { } const MAX_PAYLOAD_BYTES = 20 * 1024 * 1024; +const defer = typeof setImmediate === 'function' ? setImmediate : (fn) => setTimeout(fn, 0); + +function notifyCaptureResult(src, requestId, ok, message) { + if (requestId) { + emitNet('uz_autoshot:client:captureProcessed', src, requestId, ok === true, message || ''); + } +} + +function processCapture(src, payload) { + const requestId = payload && typeof payload.requestId === 'string' ? payload.requestId : ''; -onNet('uz_autoshot:server:processCapture', (payload) => { - const src = source; if (!checkAce(src)) { console.log('^1[uz_AutoShot]^0 Refused capture: player ' + src + ' lacks ' + ACE_NAME); + notifyCaptureResult(src, requestId, false, 'missing permission'); + return; + } + if (!payload || typeof payload !== 'object') { + notifyCaptureResult(src, requestId, false, 'invalid payload'); return; } - if (!payload || typeof payload !== 'object') return; const xFilename = typeof payload.filename === 'string' ? payload.filename : ''; const wantFormat = typeof payload.format === 'string' ? payload.format.toLowerCase() : 'png'; @@ -258,14 +270,17 @@ onNet('uz_autoshot:server:processCapture', (payload) => { if (!xFilename || /[\\/]\.\.(?:[\\/]|$)/.test(xFilename) || path.isAbsolute(xFilename)) { console.log('^1[uz_AutoShot]^0 Refused capture: invalid filename: ' + xFilename); + notifyCaptureResult(src, requestId, false, 'invalid filename'); return; } if (typeof imageData !== 'string' || imageData.length === 0) { console.log('^1[uz_AutoShot]^0 Refused capture: empty image data for ' + xFilename); + notifyCaptureResult(src, requestId, false, 'empty image data'); return; } if (imageData.length > Math.ceil(MAX_PAYLOAD_BYTES * 4 / 3) + 64) { console.log('^1[uz_AutoShot]^0 Refused capture: payload too large for ' + xFilename); + notifyCaptureResult(src, requestId, false, 'payload too large'); return; } @@ -273,6 +288,7 @@ onNet('uz_autoshot:server:processCapture', (payload) => { let outputData = Buffer.from(stripDataUri(imageData), 'base64'); if (!outputData || outputData.length === 0) { console.log('^1[uz_AutoShot]^0 Refused capture: invalid base64 for ' + xFilename); + notifyCaptureResult(src, requestId, false, 'invalid base64'); return; } @@ -303,6 +319,7 @@ onNet('uz_autoshot:server:processCapture', (payload) => { const outputPath = path.resolve(path.join(OUTPUT_DIR, xFilename + '.' + ext)); if (!outputPath.startsWith(OUTPUT_DIR + path.sep)) { console.log('^1[uz_AutoShot]^0 Refused capture: path traversal blocked for ' + xFilename); + notifyCaptureResult(src, requestId, false, 'path traversal blocked'); return; } @@ -313,9 +330,17 @@ onNet('uz_autoshot:server:processCapture', (payload) => { const sizeKB = Math.round(outputData.length / 1024); const label = wantTransp ? 'bg removed' : ext; console.log('^2[uz_AutoShot]^0 Saved: ' + xFilename + '.' + ext + ' (' + sizeKB + ' KB, ' + label + ')'); + notifyCaptureResult(src, requestId, true, ''); } catch (err) { - console.log('^1[uz_AutoShot]^0 Process error: ' + (err && err.message ? err.message : err)); + const message = err && err.message ? err.message : String(err); + console.log('^1[uz_AutoShot]^0 Process error: ' + message); + notifyCaptureResult(src, requestId, false, message); } +} + +onNet('uz_autoshot:server:processCapture', (payload) => { + const src = source; + defer(() => processCapture(src, payload)); }); onNet('uz_autoshot:server:setBucket', (bucket) => { @@ -337,4 +362,3 @@ onNet('uz_autoshot:server:resetBucket', () => { SetPlayerRoutingBucket(src.toString(), 0); console.log('^2[uz_AutoShot]^0 Player ' + src + ' -> bucket 0'); }); -