diff --git a/system/netlify/functions/tape-draft.mjs b/system/netlify/functions/tape-draft.mjs new file mode 100644 index 0000000000..86cbce18ef --- /dev/null +++ b/system/netlify/functions/tape-draft.mjs @@ -0,0 +1,131 @@ +// Private, disposable tape pre-uploads. +// POST creates a signed private PUT; PUT finalizes through track-media; +// DELETE removes an abandoned object. Stale drafts are reclaimed after 24 +// hours as subsequent draft traffic passes through this endpoint. + +import crypto from "node:crypto"; +import { customAlphabet } from "nanoid"; +import { + DeleteObjectCommand, + PutObjectCommand, + S3Client, +} from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import { authorize } from "../../backend/authorization.mjs"; +import { connect } from "../../backend/database.mjs"; +import { respond } from "../../backend/http.mjs"; +import { handler as trackMedia } from "./track-media.mjs"; + +const nanoid = customAlphabet( + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", + 16, +); +const TTL_SECONDS = 24 * 60 * 60; + +function s3() { + return new S3Client({ + endpoint: `https://${process.env.ART_ENDPOINT || "sfo3.digitaloceanspaces.com"}`, + region: "us-east-1", + credentials: { + accessKeyId: process.env.ART_KEY || process.env.DO_SPACES_KEY, + secretAccessKey: process.env.ART_SECRET || process.env.DO_SPACES_SECRET, + }, + }); +} + +function digest(value) { + return crypto.createHash("sha256").update(value).digest("hex"); +} + +async function requestUser(event) { + try { + return await authorize(event.headers || {}); + } catch { + return null; + } +} + +async function parseBody(event) { + try { + return JSON.parse(event.body || "{}"); + } catch { + return {}; + } +} + +async function cleanupExpired(drafts) { + const expired = await drafts + .find({ createdAt: { $lt: new Date(Date.now() - TTL_SECONDS * 1000) } }) + .limit(100) + .toArray(); + for (const draft of expired) { + try { + await s3().send(new DeleteObjectCommand({ Bucket: draft.bucket, Key: draft.key })); + await drafts.deleteOne({ _id: draft._id }); + } catch (error) { + console.warn("📌 Could not expire tape draft", draft.id, error.message); + } + } +} + +export async function handler(event) { + const user = await requestUser(event); + const database = await connect(); + const drafts = database.db.collection("tape-drafts"); + await drafts.createIndex({ createdAt: 1 }); + + if (event.httpMethod === "POST") { + await cleanupExpired(drafts); + const id = nanoid(); + const token = nanoid() + nanoid(); + const slug = user ? `${user.sub}/${id}` : id; + const key = `${slug}.zip`; + const bucket = user + ? process.env.USER_SPACE_NAME || "user-aesthetic-computer" + : process.env.ART_SPACE_NAME || "art-aesthetic-computer"; + const command = new PutObjectCommand({ + Bucket: bucket, + Key: key, + ContentType: "application/zip", + ContentDisposition: "inline", + }); + const uploadURL = await getSignedUrl(s3(), command, { expiresIn: 3600 }); + await drafts.insertOne({ + id, + tokenHash: digest(token), + user: user?.sub || null, + slug, + key, + bucket, + createdAt: new Date(), + }); + return respond(200, { id, token, slug, uploadURL, expiresIn: TTL_SECONDS }); + } + + const body = await parseBody(event); + const draft = await drafts.findOne({ id: body.id, tokenHash: digest(body.token || "") }); + if (!draft || draft.user !== (user?.sub || null)) { + return respond(404, { error: "Draft not found" }); + } + + if (event.httpMethod === "DELETE") { + await s3().send(new DeleteObjectCommand({ Bucket: draft.bucket, Key: draft.key })); + await drafts.deleteOne({ _id: draft._id }); + return respond(200, { deleted: true }); + } + + if (event.httpMethod === "PUT") { + const forwarded = { + ...event, + httpMethod: "POST", + body: JSON.stringify({ slug: draft.slug, ext: "zip", metadata: body.metadata || {} }), + }; + const result = await trackMedia(forwarded); + if (result.statusCode >= 200 && result.statusCode < 300) { + await drafts.deleteOne({ _id: draft._id }); + } + return result; + } + + return respond(405, { error: "Method not allowed" }); +} diff --git a/system/public/aesthetic.computer/bios.mjs b/system/public/aesthetic.computer/bios.mjs index 5579755900..be87aba352 100644 --- a/system/public/aesthetic.computer/bios.mjs +++ b/system/public/aesthetic.computer/bios.mjs @@ -8991,19 +8991,21 @@ async function boot(parsed, bpm = 60, resolution, debug) { const allFrames = window.postFrameChunks.flat(); console.log("📼 Total frames after reassembly:", allFrames.length); - const rawAudio = window.postRawAudio; - const piece = window.postPiece; + const rawAudio = window.postRawAudio; + const piece = window.postPiece; + const draftOnly = window.postDraftOnly; delete window.postFrameChunks; delete window.postTotalChunks; delete window.postReceivedChunks; delete window.postRawAudio; - delete window.postPiece; + delete window.postPiece; + delete window.postDraftOnly; receivedChange({ data: { type: "create-and-post-tape", - content: { frames: allFrames, rawAudio, piece } + content: { frames: allFrames, rawAudio, piece, draftOnly } } }); } @@ -9012,8 +9014,9 @@ async function boot(parsed, bpm = 60, resolution, debug) { } // 🎬 Create ZIP and POST tape to cloud (MongoDB + ATProto) - if (type === "create-and-post-tape") { - if (content.totalChunks && content.totalChunks > 1) { + if (type === "create-and-post-tape") { + if (content.draftOnly) cancelTapeDraftRequested = false; + if (content.totalChunks && content.totalChunks > 1) { console.log( `📼 Receiving POST frames in chunks (1/${content.totalChunks}):`, content.frames.length, @@ -9022,8 +9025,9 @@ async function boot(parsed, bpm = 60, resolution, debug) { window.postFrameChunks = [content.frames]; window.postTotalChunks = content.totalChunks; window.postReceivedChunks = 1; - window.postRawAudio = content.rawAudio; // Store rawAudio from first chunk - window.postPiece = content.piece; // Store piece name from first chunk + window.postRawAudio = content.rawAudio; // Store rawAudio from first chunk + window.postPiece = content.piece; // Store piece name from first chunk + window.postDraftOnly = content.draftOnly === true; return; } @@ -9250,21 +9254,25 @@ async function boot(parsed, bpm = 60, resolution, debug) { content: 0.90, }); - // Use existing receivedUpload pattern with metadata - receivedUpload( - { filename, data: zipBlob }, - "tape:posted", // Success callback type - metadata, // Pass metadata for database - ); + if (content.draftOnly) { + receivedTapeDraftUpload(zipBlob, metadata); + } else { + // Use existing receivedUpload pattern with metadata + receivedUpload( + { filename, data: zipBlob }, + "tape:posted", // Success callback type + metadata, // Pass metadata for database + ); + } console.log("📼 Tape ZIP created and queued for upload!"); - } catch (error) { - console.error("Error creating/posting tape:", error); - send({ - type: "tape:post-error", - content: { error: error.message } - }); + } catch (error) { + console.error("Error creating/posting tape:", error); + send({ + type: content.draftOnly ? "tape:draft-error" : "tape:post-error", + content: { error: error.message }, + }); } return; } @@ -14906,7 +14914,7 @@ async function boot(parsed, bpm = 60, resolution, debug) { // tape flow (create-and-post-tape → receivedUpload(..., "tape:posted", // metadata)) but the data is the already-encoded clip bytes, so there's // no frame/zip step. track-tape mints a !code and marks kind "mp4". - if (type === "upload-video-tape") { + if (type === "upload-video-tape") { const { data, mime, duration, callback } = content || {}; // Logged-in users get a dotted-timestamp filename (no "-" so the // presigned server sorts it under {sub}/.mp4, mirroring zip tapes); @@ -17450,6 +17458,45 @@ async function boot(parsed, bpm = 60, resolution, debug) { await Store.del("tape"); return; } + + if (type === "tape:draft-finalize") { + const token = await authorize().catch(() => null); + const headers = { "Content-Type": "application/json" }; + if (token) headers.Authorization = `Bearer ${token}`; + try { + const response = await fetch("/api/tape-draft", { + method: "PUT", + headers, + body: JSON.stringify(content), + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || `HTTP ${response.status}`); + activeTapeDraft = null; + activeTapeDraftXHR = null; + send({ type: "tape:posted", content: { result: "success", ...result } }); + } catch (error) { + send({ type: "tape:post-error", content: { error: error.message } }); + } + return; + } + + if (type === "tape:draft-cancel") { + cancelTapeDraftRequested = true; + activeTapeDraftXHR?.abort(); + activeTapeDraftXHR = null; + const draft = content?.id ? content : activeTapeDraft; + activeTapeDraft = null; + if (!draft?.id || !draft?.token) return; + const token = await authorize().catch(() => null); + const headers = { "Content-Type": "application/json" }; + if (token) headers.Authorization = `Bearer ${token}`; + fetch("/api/tape-draft", { + method: "DELETE", + headers, + body: JSON.stringify({ id: draft.id, token: draft.token }), + }).catch((error) => console.warn("📌 Draft cleanup failed:", error)); + return; + } // Request recorded frames for export if (type === "recorder:request-frames") { @@ -20107,7 +20154,67 @@ async function boot(parsed, bpm = 60, resolution, debug) { } // End of receivedChange function // 📤 Reads a file and uploads it to the server. - async function receivedUpload( + let activeTapeDraft = null; + let activeTapeDraftXHR = null; + let cancelTapeDraftRequested = false; + + async function receivedTapeDraftUpload(zipBlob, metadata) { + if (cancelTapeDraftRequested) return; + const authToken = await authorize().catch(() => null); + const headers = {}; + if (authToken) headers.Authorization = `Bearer ${authToken}`; + try { + const create = await fetch("/api/tape-draft", { method: "POST", headers }); + const draft = await create.json(); + if (!create.ok || !draft.uploadURL) { + throw new Error(draft.error || `HTTP ${create.status}`); + } + activeTapeDraft = draft; + if (cancelTapeDraftRequested) { + const deleteHeaders = { "Content-Type": "application/json", ...headers }; + fetch("/api/tape-draft", { + method: "DELETE", + headers: deleteHeaders, + body: JSON.stringify({ id: draft.id, token: draft.token }), + }).catch(() => {}); + activeTapeDraft = null; + return; + } + const xhr = new XMLHttpRequest(); + activeTapeDraftXHR = xhr; + xhr.open("PUT", draft.uploadURL, true); + xhr.setRequestHeader("Content-Type", "application/zip"); + xhr.setRequestHeader("Content-Disposition", "inline"); + xhr.upload.addEventListener("progress", (event) => { + if (event.lengthComputable) { + send({ type: "tape:draft-progress", content: event.loaded / event.total }); + } + }); + xhr.onerror = () => { + send({ type: "tape:draft-error", content: { error: "Draft upload failed" } }); + }; + xhr.onreadystatechange = () => { + if (xhr.readyState !== XMLHttpRequest.DONE) return; + if (xhr.status >= 200 && xhr.status < 300) { + activeTapeDraftXHR = null; + send({ + type: "tape:draft-ready", + content: { id: draft.id, token: draft.token, slug: draft.slug, metadata }, + }); + } else if (xhr.status !== 0) { + send({ + type: "tape:draft-error", + content: { error: `Draft upload HTTP ${xhr.status}` }, + }); + } + }; + xhr.send(zipBlob); + } catch (error) { + send({ type: "tape:draft-error", content: { error: error.message } }); + } + } + + async function receivedUpload( { filename, data, bucket }, callbackMessage = "upload", metadata = null, diff --git a/system/public/aesthetic.computer/disks/video.mjs b/system/public/aesthetic.computer/disks/video.mjs index 9fb49416cb..060b587762 100644 --- a/system/public/aesthetic.computer/disks/video.mjs +++ b/system/public/aesthetic.computer/disks/video.mjs @@ -58,6 +58,9 @@ let printProgress = 0; // Export progress (0-1) let ellipsisTicker; let postedTapeCode = null; // Store the tape code after posting for button transformation let frameCount = 0; // Frame counter for animations like button blinking +let tapeDraft = null; // Private pre-upload descriptor returned by bios. +let tapeDraftState = "idle"; // idle, preparing, uploading, ready, error, finalizing +let finalizeWhenReady = false; let isExportingGIF = false; let isExportingFrames = false; @@ -425,6 +428,9 @@ function boot({ wipe, rec, gizmo, jump, notice, store, params, send, hud }) { currentExportType = ""; printed = false; postedTapeCode = null; // Reset tape code from previous session + tapeDraft = null; + tapeDraftState = "idle"; + finalizeWhenReady = false; tapeInfo = null; // Reset tape info for new recording isScrubbing = false; inertiaActive = false; @@ -617,11 +623,84 @@ function boot({ wipe, rec, gizmo, jump, notice, store, params, send, hud }) { }); rec.present(); // Visually present a recording right away if one exists. + if (rec.recorded) beginTapeDraft(rec, send); } ellipsisTicker = new gizmo.EllipsisTicker(); } +function beginTapeDraft(rec, send) { + if (tapeDraftState !== "idle" || !rec?.recorded) return; + tapeDraftState = "preparing"; + rec.requestFrames((frameData) => { + if (!frameData?.frames?.length || tapeDraftState === "idle") { + tapeDraftState = "error"; + if (finalizeWhenReady) { + finalizeWhenReady = false; + isPostingTape = false; + isPrinting = false; + if (postBtn) postBtn.disabled = false; + } + requestPaint(); + return; + } + const frames = frameData.frames.map((frame, index) => { + const [timestamp, imageData] = frame; + const nextTimestamp = frameData.frames[index + 1]?.[0]; + return { + timestamp, + duration: nextTimestamp ? Math.max(10, nextTimestamp - timestamp) : 16.67, + width: imageData.width, + height: imageData.height, + data: imageData.data, + }; + }); + tapeDraftState = "uploading"; + const chunkSize = 500; + const totalChunks = Math.ceil(frames.length / chunkSize); + send({ + type: "create-and-post-tape", + content: { + frames: frames.slice(0, chunkSize), + piece: "video", + rawAudio: frameData.rawAudio, + draftOnly: true, + totalChunks, + }, + }); + for (let i = 1; i < totalChunks; i++) { + send({ + type: "create-and-post-tape-chunk", + content: { frames: frames.slice(i * chunkSize, (i + 1) * chunkSize) }, + }); + } + }); +} + +function finalizeTapeDraft(send) { + if (!tapeDraft || tapeDraftState !== "ready") return false; + tapeDraftState = "finalizing"; + send({ + type: "tape:draft-finalize", + content: { + id: tapeDraft.id, + token: tapeDraft.token, + metadata: tapeDraft.metadata, + }, + }); + return true; +} + +function cancelTapeDraft(send) { + send({ + type: "tape:draft-cancel", + content: tapeDraft ? { id: tapeDraft.id, token: tapeDraft.token } : {}, + }); + tapeDraft = null; + tapeDraftState = "idle"; + finalizeWhenReady = false; +} + // 🎨 Paint (Executes every display frame) function paint({ api, @@ -1831,6 +1910,7 @@ function act({ // 🔙 Back to cap so the user can immediately re-shoot. backBtn?.act(e, { push: () => { + cancelTapeDraft(send); // Drop the current tape and any cached export / playback state // so cap.mjs starts clean and stale UI (progress bar, scrub // strip, post button) doesn't bleed through to the next visit. @@ -2279,6 +2359,30 @@ function act({ } if (isPostingTape) return; // Prevent double-posting + + // The review screen prepares and privately uploads this tape in the + // background. Done either finalizes immediately or waits for only the + // unfinished tail of that work. + if (tapeDraftState === "ready") { + isPostingTape = true; + isPrinting = true; + currentExportType = "post"; + exportStatusMessage = "FINALIZING TAPE"; + postBtn.disabled = true; + finalizeTapeDraft(send); + triggerRender(); + return; + } + if (tapeDraftState === "preparing" || tapeDraftState === "uploading") { + finalizeWhenReady = true; + isPostingTape = true; + isPrinting = true; + currentExportType = "post"; + exportStatusMessage = "FINISHING PRE-UPLOAD"; + postBtn.disabled = true; + triggerRender(); + return; + } isPostingTape = true; isPrinting = true; @@ -3219,6 +3323,42 @@ function handleSystemMessage({ event: e, rec, needsPaint, jump }) { requestPaint(); return true; } + + if (e.is("tape:draft-progress")) { + tapeDraftState = "uploading"; + if (finalizeWhenReady) { + printProgress = 0.85 + Math.max(0, Math.min(1, e.content || 0)) * 0.1; + requestPaint(); + } + return true; + } + + if (e.is("tape:draft-ready")) { + tapeDraft = e.content; + tapeDraftState = "ready"; + if (finalizeWhenReady) { + exportStatusMessage = "FINALIZING TAPE"; + finalizeTapeDraft(apiSend); + } + requestPaint(); + return true; + } + + if (e.is("tape:draft-error")) { + console.warn("📌 Tape pre-upload failed; Done will use the normal path:", e.content); + tapeDraft = null; + tapeDraftState = "error"; + if (finalizeWhenReady) { + finalizeWhenReady = false; + isPostingTape = false; + isPrinting = false; + if (postBtn) postBtn.disabled = false; + completionMessage = "PRE-UPLOAD FAILED — TAP DONE TO RETRY"; + completionMessageTimer = 180; + } + requestPaint(); + return true; + } // Handle tape:posted callback (successful tape upload) if (e.is("tape:posted")) { @@ -3228,6 +3368,9 @@ function handleSystemMessage({ event: e, rec, needsPaint, jump }) { // Store the code for HUD label display postedTapeCode = code; + tapeDraft = null; + tapeDraftState = "idle"; + finalizeWhenReady = false; // Complete the export flow (sets progress to 100%) completeExport("post", code ? `POSTED! !${code}` : "POSTED!"); @@ -3717,6 +3860,7 @@ function receive(e) { // Called when leaving the video disk (e.g., pressing escape to go back to prompt) function leave({ send, rec }) { console.log("📼 Leaving video disk - stopping tape playback"); + if (!postedTapeCode) cancelTapeDraft(send); // End the presentation properly — otherwise rec.presenting stays true // and the bottom progress bar cursor haunts the next piece (prompt). rec?.unpresent?.();