diff --git a/.changeset/v3-community-compatibility.md b/.changeset/v3-community-compatibility.md new file mode 100644 index 00000000..28e52498 --- /dev/null +++ b/.changeset/v3-community-compatibility.md @@ -0,0 +1,4 @@ +--- +--- + +Fix V3 community media uploads, preserve unavailable project layers, restore audio-reactive community playback, and align project dropdown corners. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 098b99da..598b883e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,9 @@ jobs: - name: Lint app run: bun run lint + - name: Community compatibility tests + run: bun test tests/community + - name: Type check app run: bun run typecheck:tsc diff --git a/src/components/community/scene-viewer.tsx b/src/components/community/scene-viewer.tsx index 13d114b1..26cc6b7a 100644 --- a/src/components/community/scene-viewer.tsx +++ b/src/components/community/scene-viewer.tsx @@ -8,6 +8,7 @@ import { buildViewerProjectState, parseLabProjectFile, } from "@/lib/editor/project-file" +import { buildViewerAudio } from "@/lib/community/viewer-audio" import { cn } from "@/lib/cn" import { buildRendererFrame } from "@/renderer/contracts" import { advanceProjectTimeline } from "@/renderer/project-clock" @@ -80,6 +81,7 @@ export function SceneViewer({ } let disposed = false + const abortController = new AbortController() let animationFrame: number | null = null let resizeObserver: ResizeObserver | null = null let visibilityObserver: IntersectionObserver | null = null @@ -89,6 +91,7 @@ export function SceneViewer({ disposeRef.current = () => { disposed = true + abortController.abort() if (animationFrame !== null) { window.cancelAnimationFrame(animationFrame) @@ -105,7 +108,7 @@ export function SceneViewer({ try { const [{ createWebGPURenderer }, labText] = await Promise.all([ import("@/renderer/create-webgpu-renderer"), - fetch(labUrl).then((res) => { + fetch(labUrl, { signal: abortController.signal }).then((res) => { if (!res.ok) { throw new Error("Could not load the scene file.") } @@ -114,11 +117,17 @@ export function SceneViewer({ }), ]) + if (disposed) return const project = buildViewerProjectState(parseLabProjectFile(labText)) const renderer = await createWebGPURenderer(canvas) + if (disposed) { + renderer.dispose() + return + } disposeRef.current = () => { disposed = true + abortController.abort() if (animationFrame !== null) { window.cancelAnimationFrame(animationFrame) @@ -148,12 +157,15 @@ export function SceneViewer({ teardown() } - await renderer.initialize() + // Cleanup must wait for initialization as well as in-flight frames. + framePending = renderer.initialize() + const [, audio] = await Promise.all([ + framePending, + buildViewerAudio(project, abortController.signal), + ]) - if (disposed) { - renderer.dispose() - return - } + framePending = null + if (disposed) return const pixelRatio = Math.min(window.devicePixelRatio || 1, 2) const documentSize = getDocumentSize( @@ -207,7 +219,7 @@ export function SceneViewer({ const frame = buildRendererFrame({ assets: project.assets, - audio: null, + audio, clockTime, delta, layers: project.layers, @@ -238,6 +250,7 @@ export function SceneViewer({ renderer.isDeviceLost() || consecutiveFailures >= MAX_CONSECUTIVE_FRAME_FAILURES ) { + disposeRef.current?.() setFailed(true) return } @@ -328,7 +341,10 @@ export function SceneViewer({ resume() } catch { - setFailed(true) + if (!disposed) { + disposeRef.current?.() + setFailed(true) + } } } diff --git a/src/components/editor/layer-group-location.tsx b/src/components/editor/layer-group-location.tsx index 25b1e8e5..707adc84 100644 --- a/src/components/editor/layer-group-location.tsx +++ b/src/components/editor/layer-group-location.tsx @@ -1,5 +1,6 @@ "use client" +import { isLayerGroup } from "@/renderer/layer-hierarchy" import { Select } from "@/components/ui/select" import { Typography } from "@/components/ui/typography" import { moveLayerToGroup } from "@/lib/editor/layer-groups" @@ -9,9 +10,9 @@ export function LayerGroupLocation({ layerId }: { layerId: string }) { const layers = useLayerStore((state) => state.layers) const moveLayer = useLayerStore((state) => state.moveLayer) const layer = layers.find((entry) => entry.id === layerId) - if (!(layer && layers.some((entry) => entry.kind === "group"))) return null + if (!(layer && layers.some((entry) => isLayerGroup(entry)))) return null const groups = layers.filter( - (entry) => entry.kind === "group" && entry.id !== layerId + (entry) => isLayerGroup(entry) && entry.id !== layerId ) const paths = new Map() for (const group of groups) diff --git a/src/components/editor/layer-sidebar.tsx b/src/components/editor/layer-sidebar.tsx index 70feb6ea..d4346efd 100644 --- a/src/components/editor/layer-sidebar.tsx +++ b/src/components/editor/layer-sidebar.tsx @@ -1,4 +1,5 @@ "use client" +import { isLayerGroup } from "@/renderer/layer-hierarchy" import { ChevronDownIcon, ChevronRightIcon, @@ -200,7 +201,7 @@ const LayerListItem = memo(function LayerListItem({ const cancelRename = useRef(false) const renameLayer = useLayerStore((state) => state.renameLayer) const setExpanded = useLayerStore((state) => state.setLayerExpanded) - const isGroup = layer.kind === "group" + const isGroup = isLayerGroup(layer) function startRename() { cancelRename.current = false @@ -710,7 +711,7 @@ export function LayerSidebar() { onSelectLayer={handleSelectLayer} onSetLayerVisibility={handleSetLayerVisibility} > - {layer.kind === "group" && layer.expanded + {isLayerGroup(layer) && layer.expanded ? renderLayerTree(layer.id, frozen) : null} diff --git a/src/components/editor/use-layer-drag.ts b/src/components/editor/use-layer-drag.ts index 2bdc7534..4b966d6c 100644 --- a/src/components/editor/use-layer-drag.ts +++ b/src/components/editor/use-layer-drag.ts @@ -1,5 +1,6 @@ "use client" +import { isLayerGroup } from "@/renderer/layer-hierarchy" import { type PointerEvent as ReactPointerEvent, useEffect, @@ -43,7 +44,7 @@ function findTarget( let placement: LayerDropTarget["placement"] = y < rect.top + rect.height / 2 ? "before" : "after" if ( - anchor.kind === "group" && + isLayerGroup(anchor) && x >= rect.left && y > rect.top + rect.height * 0.25 && y < rect.bottom - rect.height * 0.25 diff --git a/src/components/ui/menu/index.tsx b/src/components/ui/menu/index.tsx index 0bbbae75..19ff0057 100644 --- a/src/components/ui/menu/index.tsx +++ b/src/components/ui/menu/index.tsx @@ -52,7 +52,7 @@ export function Menu({ asset.source === "local" && referencedIds.has(asset.id) ) + .map((asset) => ({ ...asset, mimeType: normalizeAssetMimeType(asset) })) let problem: string | null = - options.prune && projectFile.layers.length === 0 - ? EMPTY_SCENE_MESSAGE + options.prune + ? describeUnavailableLayers(projectFile) ?? + (projectFile.layers.length === 0 ? EMPTY_SCENE_MESSAGE : null) : null let totalBytes = 0 diff --git a/src/lib/community/publish.ts b/src/lib/community/publish.ts index 864d6d83..f5c9aaeb 100644 --- a/src/lib/community/publish.ts +++ b/src/lib/community/publish.ts @@ -20,6 +20,7 @@ import { isAllowedAssetOrigin } from "@/lib/editor/remote-asset" import type { LabProjectFile } from "@/lib/editor/project-file" import { hasImportedCustomShaderCode, + describeUnavailableLayers, parseLabProjectFile, } from "@/lib/editor/project-file" @@ -45,6 +46,7 @@ export const UPLOADABLE_MIME_TYPES = new Set([ "image/jpeg", "image/png", "image/svg+xml", + "image/vnd.radiance", "image/webp", "model/gltf-binary", "model/gltf+json", @@ -61,6 +63,8 @@ const UPLOAD_EXTENSIONS: Record = { "image/svg+xml": "svg", "image/webp": "webp", "model/gltf-binary": "glb", + "model/gltf+json": "gltf", + "image/vnd.radiance": "hdr", "video/mp4": "mp4", "video/quicktime": "mov", "video/webm": "webm", @@ -215,6 +219,9 @@ export function validateProjectFilePayload(raw: string): PublishValidation { throw new Error(describeBlockedLanguage(profanityLocation)) } + const unavailable = describeUnavailableLayers(projectFile) + if (unavailable) throw new Error(unavailable) + if (projectFile.layers.length === 0) { throw new Error("A scene needs at least one visible layer.") } diff --git a/src/lib/community/upload-limits.ts b/src/lib/community/upload-limits.ts index 84759ccf..83196fbf 100644 --- a/src/lib/community/upload-limits.ts +++ b/src/lib/community/upload-limits.ts @@ -4,7 +4,7 @@ export const DEFAULT_DRAFT_TITLE = "Untitled draft" export const MAX_IMAGE_BYTES = 4 * 1024 * 1024 export function isImageMimeType(mimeType: string): boolean { - return mimeType.startsWith("image/") + return mimeType.startsWith("image/") && mimeType !== "image/vnd.radiance" } export function maxBytesForMimeType(mimeType: string): number { diff --git a/src/lib/community/viewer-audio.ts b/src/lib/community/viewer-audio.ts new file mode 100644 index 00000000..fa27a86c --- /dev/null +++ b/src/lib/community/viewer-audio.ts @@ -0,0 +1,48 @@ +import { analyzeAudioSource } from "@/lib/editor/audio/analyze-client" +import { computeEnvelopeSet } from "@/lib/editor/audio/envelope" +import type { AudioModulationInput } from "@/lib/editor/audio/links" +import type { ViewerProjectState } from "@/lib/editor/project-file" + +/** Silent analysis: reactive visuals use the same envelopes and offset as the editor. */ +export async function buildViewerAudio( + project: ViewerProjectState, + signal: AbortSignal +): Promise { + const { source, links, bands, offsetSeconds } = project.audioSnapshot + if (!(source && links.some((link) => link.enabled))) return null + + const assetId = + source.kind === "asset" + ? source.assetId + : project.layers.find((layer) => layer.id === source.layerId)?.assetId + const asset = project.assets.find((entry) => entry.id === assetId) + if (!asset) return null + + signal.throwIfAborted() + const analysisSignal = AbortSignal.any([signal, AbortSignal.timeout(8_000)]) + let onAbort: () => void = () => undefined + const cancelled = new Promise((_resolve, reject) => { + onAbort = () => reject(analysisSignal.reason) + analysisSignal.addEventListener("abort", onAbort, { once: true }) + if (analysisSignal.aborted) onAbort() + }) + + try { + // decodeAudioData cannot be interrupted. Stop waiting at the deadline even + // if decoding is still running; the signal also cancels fetch/worker work. + const spectrogram = await Promise.race([ + analyzeAudioSource(asset.url, { signal: analysisSignal }), + cancelled, + ]) + return { + envelopes: computeEnvelopeSet(spectrogram, bands), + links, + offsetSeconds, + } + } catch (error) { + if (signal.aborted) throw error + return null + } finally { + analysisSignal.removeEventListener("abort", onAbort) + } +} diff --git a/src/lib/editor/layer-groups.ts b/src/lib/editor/layer-groups.ts index 6d3a1ff8..e3d52220 100644 --- a/src/lib/editor/layer-groups.ts +++ b/src/lib/editor/layer-groups.ts @@ -1,4 +1,4 @@ -import { validateLayerHierarchy } from "@/renderer/layer-hierarchy" +import { isLayerGroup, validateLayerHierarchy } from "@/renderer/layer-hierarchy" import type { EditorLayer } from "@/types/editor" export type LayerDropTarget = { @@ -22,7 +22,7 @@ export function dropLayer( const parent = layers.find((layer) => layer.id === parentId) if ( parentId !== null && - (!parent || parent.kind !== "group" || parent.locked) + (!(parent && isLayerGroup(parent)) || parent.locked) ) return null const ids = new Set(contents.map((layer) => layer.id)) @@ -86,7 +86,7 @@ export function visibleLayerRows( collapsed.add(layer.id) return false } - if (layer.kind === "group" && !layer.expanded) collapsed.add(layer.id) + if (isLayerGroup(layer) && !layer.expanded) collapsed.add(layer.id) return true }) } @@ -174,7 +174,7 @@ export function moveLayerToGroup( if ( !source || source.locked || - (parentId !== null && (!parent || parent.kind !== "group" || parent.locked)) + (parentId !== null && (!(parent && isLayerGroup(parent)) || parent.locked)) ) return null if ((source.parentId ?? null) === parentId) return layers diff --git a/src/lib/editor/media-file.ts b/src/lib/editor/media-file.ts index 60a4fe15..9335976e 100644 --- a/src/lib/editor/media-file.ts +++ b/src/lib/editor/media-file.ts @@ -93,3 +93,41 @@ export function inferFileAssetKind(file: File): AssetKind | null { return null } + +const MIME_BY_EXTENSION: Record = { + aac: "audio/aac", + flac: "audio/flac", + glb: "model/gltf-binary", + gltf: "model/gltf+json", + hdr: "image/vnd.radiance", + m4a: "audio/mp4", + mov: "video/quicktime", + mp3: "audio/mpeg", + oga: "audio/ogg", + ogg: "audio/ogg", + opus: "audio/ogg", + svg: "image/svg+xml", + wav: "audio/wav", +} + +const MIME_ALIASES: Record = { + "audio/mp3": "audio/mpeg", + "audio/opus": "audio/ogg", + "audio/wave": "audio/wav", + "audio/x-flac": "audio/flac", + "audio/x-wav": "audio/wav", +} + +/** Canonicalize browser MIME guesses, including assets restored from old drafts. */ +export function normalizeAssetMimeType(input: { + fileName: string + mimeType: string +}): string { + const mimeType = input.mimeType.toLowerCase().split(";")[0]!.trim() + const extension = input.fileName.toLowerCase().split(".").at(-1) ?? "" + const extensionMime = Object.hasOwn(MIME_BY_EXTENSION, extension) + ? MIME_BY_EXTENSION[extension] + : undefined + const alias = Object.hasOwn(MIME_ALIASES, mimeType) ? MIME_ALIASES[mimeType] : undefined + return extensionMime ?? alias ?? mimeType +} diff --git a/src/lib/editor/project-file.ts b/src/lib/editor/project-file.ts index ed40465a..fc4f01e5 100644 --- a/src/lib/editor/project-file.ts +++ b/src/lib/editor/project-file.ts @@ -5,7 +5,7 @@ import { import { modelMaterialDefaults } from "@/lib/editor/config/model-options" import { LEGACY_MESH_GRADIENT_PRESETS } from "@/lib/editor/mesh-gradient" import { CURRENT_PROJECT_FILE_VERSION } from "./project-version" -import { validateLayerHierarchy } from "@/renderer/layer-hierarchy" +import { isLayerGroup, validateLayerHierarchy } from "@/renderer/layer-hierarchy" import { MISSING_DEPTH_ERROR_PREFIX } from "@/renderer/layer-media-error" import { z } from "zod" import { useAssetStore } from "@/store/asset-store" @@ -111,6 +111,9 @@ export function collectReferencedAssetIds(input: { export function buildPublishableProjectFile( file: LabProjectFile ): LabProjectFile { + // Preserve unavailable data in drafts/exports; publishing reports it explicitly. + if (file.layers.some((layer) => layer.unavailableLayer)) return file + const audioSource = file.audio?.source ?? null const audioLayerId = audioSource?.kind === "video-layer" ? audioSource.layerId : null @@ -314,7 +317,7 @@ const baseLayerShape = { visible: z.boolean(), } -const layerSchema = z.discriminatedUnion("kind", [ +const knownLayerSchema = z.discriminatedUnion("kind", [ z.looseObject({ ...baseLayerShape, kind: z.literal("group"), @@ -338,6 +341,58 @@ const layerSchema = z.discriminatedUnion("kind", [ }), ]) +// Keep unknown types recoverable without letting malformed known layers bypass validation. +const unknownLayerSchema = z.looseObject({ + ...baseLayerShape, + kind: z.string().min(1), + type: z.string().min(1), +}) +const knownLayerTypes = new Set([ + ...SOURCE_LAYER_TYPES, + ...EFFECT_LAYER_TYPES, + "model", + "group", +]) + +const layerSchema = z.preprocess((value) => { + if (!(value && typeof value === "object")) return value + const candidate = value as Record + const original = candidate.unavailableLayer ?? candidate + const result = unknownLayerSchema.safeParse(original) + if (!result.success) return candidate.unavailableLayer ? null : value + const raw = result.data + if (knownLayerTypes.has(raw.type)) { + // A newer app can restore a formerly unavailable layer from its saved payload. + return candidate.unavailableLayer + ? { ...raw, id: candidate.id, parentId: candidate.parentId } + : value + } + return { + ...raw, + id: candidate.id, + parentId: candidate.parentId, + kind: "group", + type: "group", + compositeMode: "filter", + name: candidate.unavailableLayer + ? candidate.name + : `${raw.name} (Unavailable: ${raw.type})`, + visible: false, + locked: true, + params: {}, + mask: null, + runtimeError: `Layer type "${raw.type}" is unavailable. Its original data is preserved.`, + unavailableLayer: raw, + } +}, knownLayerSchema) + +export function describeUnavailableLayers(file: LabProjectFile): string | null { + const count = file.layers.filter((layer) => layer.unavailableLayer).length + return count > 0 + ? `This scene contains ${count} unavailable layer(s). Remove or replace them before publishing; their original data is preserved in the project.` + : null +} + const timelineKeyframeSchema = z.looseObject({ id: z.string(), time: z.number(), @@ -474,7 +529,9 @@ export function parseLabProjectFileValue(parsed: unknown): LabProjectFile { validateLayerHierarchy(result.data.layers) if ( result.data.version < 7 && - result.data.layers.some((layer) => layer.kind === "group" || layer.parentId) + result.data.layers.some((layer) => + isLayerGroup(layer) || layer.parentId + ) ) { throw new Error("Groups require project version 7 or later.") } @@ -617,6 +674,8 @@ export function applyLabProjectFile( } export interface ViewerProjectState { + /** Saved settings; renderer frame audio requires analyzed envelopes instead. */ + audioSnapshot: EditorAudioSnapshot assets: EditorAsset[] composition: Size layers: EditorLayer[] @@ -638,6 +697,7 @@ export function buildViewerProjectState( return { assets, + audioSnapshot: normalizeProjectAudio(projectFile.audio), composition: projectFile.composition, layers, sceneConfig: @@ -788,6 +848,7 @@ function hydrateImportedLayer( assetRefById: Map, version: number ): EditorLayer { + if (layer.unavailableLayer) return { ...layer, visible: false, locked: true } const params = migrateLayerParams(layer, version) const depthRef = layer.depthAssetId ? assetRefById.get(layer.depthAssetId) diff --git a/src/renderer/contracts.ts b/src/renderer/contracts.ts index ea5c6ae1..e90b5fc7 100644 --- a/src/renderer/contracts.ts +++ b/src/renderer/contracts.ts @@ -156,9 +156,10 @@ export function buildRendererFrame( ? evaluation.properties.saturation : layer.saturation, visible: - typeof evaluation?.properties.visible === "boolean" + !layer.unavailableLayer && + (typeof evaluation?.properties.visible === "boolean" ? evaluation.properties.visible - : layer.visible, + : layer.visible), }, params, } diff --git a/src/renderer/layer-hierarchy.ts b/src/renderer/layer-hierarchy.ts index eef28aae..926dfcc9 100644 --- a/src/renderer/layer-hierarchy.ts +++ b/src/renderer/layer-hierarchy.ts @@ -4,11 +4,20 @@ import { } from "./composition-tree" export interface HierarchyLayer { + unavailableLayer?: Record id: string kind: string parentId?: string | null | undefined } +/** Inert placeholders retain the structural kind of their original layer. */ +export function isLayerGroup(layer: HierarchyLayer): boolean { + return ( + layer.kind === "group" && + (!layer.unavailableLayer || layer.unavailableLayer.kind === "group") + ) +} + /** A flat, top-first preorder keeps subtrees contiguous for selection and moves. */ export function validateLayerHierarchy( layers: readonly HierarchyLayer[] @@ -23,7 +32,7 @@ export function validateLayerHierarchy( if (parent !== null && ancestors.at(-1) !== parent) { throw new Error("Group children must follow their parent in layer order.") } - if (layer.kind === "group") { + if (isLayerGroup(layer)) { if (ancestors.length >= MAX_COMPOSITION_GROUP_DEPTH) { throw new Error( `Groups support at most ${MAX_COMPOSITION_GROUP_DEPTH} levels.` @@ -53,7 +62,7 @@ export function buildCompositionTree< continue } const target = layer.parentId ? children.get(layer.parentId)! : root - if (layer.kind === "group") { + if (isLayerGroup(layer)) { const nested: CompositionNode[] = [] target.push({ id: layer.id, diff --git a/src/store/asset-store.ts b/src/store/asset-store.ts index 10906007..b0e1ecbc 100644 --- a/src/store/asset-store.ts +++ b/src/store/asset-store.ts @@ -7,6 +7,7 @@ import { inferFileAssetKind, isAudioFileName, isSvgMediaSource, + normalizeAssetMimeType, } from "@/lib/editor/media-file" import type { AssetKind, EditorAsset } from "@/types/editor" import { @@ -60,27 +61,13 @@ const ACCEPTED_TYPES = new Set([ const MAX_SIZE_BYTES = 100 * 1024 * 1024 -function fallbackMimeType(kind: AssetKind, fileName: string): string { - if (kind === "environment") { - return "image/vnd.radiance" - } - - if (kind === "model") { - return fileName.toLowerCase().endsWith(".gltf") - ? "model/gltf+json" - : "model/gltf-binary" - } - - return "" -} - function validateFile(file: File, requested?: AssetKind): AssetKind { const inferred = inferFileAssetKind(file) const kind = requested === "model" && inferred === "image" && isSvgMediaSource({ fileName: file.name, mimeType: file.type }) ? "model" : inferred - const mimeType = file.type.toLowerCase() + const mimeType = normalizeAssetMimeType({ fileName: file.name, mimeType: file.type }) const fileName = file.name.toLowerCase() if ( @@ -198,7 +185,7 @@ export const useAssetStore = create((set, get) => ({ fileName: file.name, id: crypto.randomUUID(), kind, - mimeType: file.type || fallbackMimeType(kind, file.name), + mimeType: normalizeAssetMimeType({ fileName: file.name, mimeType: file.type }), sizeBytes: file.size, source: "local" as const, status: "ready" as const, diff --git a/src/store/layer-store.ts b/src/store/layer-store.ts index 3201e7cf..c07fd9bc 100644 --- a/src/store/layer-store.ts +++ b/src/store/layer-store.ts @@ -20,7 +20,7 @@ import { getParameterDefinition, } from "@/lib/editor/parameter-schema" import { normalizeTextFontWeight } from "@/lib/editor/text-fonts" -import { validateLayerHierarchy } from "@/renderer/layer-hierarchy" +import { isLayerGroup, validateLayerHierarchy } from "@/renderer/layer-hierarchy" import { useAudioStore } from "@/store/audio-store" import { meshGradientPresetParams, shuffleMeshGradient } from "@/lib/editor/mesh-gradient" import { useEditorStore } from "@/store/editor-store" @@ -267,7 +267,7 @@ export const useLayerStore = create((set, get) => ({ (layer) => layer.id === state.selectedLayerId ) let index = insertIndex - if (index === undefined && selected?.kind === "group") { + if (index === undefined && selected && isLayerGroup(selected)) { nextLayer.parentId = selected.id index = state.layers.indexOf(selected) + 1 layers[state.layers.indexOf(selected)] = { ...selected, expanded: true } @@ -406,7 +406,7 @@ export const useLayerStore = create((set, get) => ({ ungroupLayer: (id) => { const group = get().layers.find( - (layer) => layer.id === id && layer.kind === "group" + (layer) => layer.id === id && isLayerGroup(layer) ) if (!group || group.locked) return const children = get() diff --git a/src/store/timeline-store.ts b/src/store/timeline-store.ts index 6fb87fcf..92b31365 100644 --- a/src/store/timeline-store.ts +++ b/src/store/timeline-store.ts @@ -984,7 +984,7 @@ export const useTimelineStore = create((set, get) => ({ return false } - if (track.binding.kind === "layer") { + if (layer.unavailableLayer || track.binding.kind === "layer") { return true } diff --git a/src/types/editor.ts b/src/types/editor.ts index c78123ff..3424b7ee 100644 --- a/src/types/editor.ts +++ b/src/types/editor.ts @@ -328,6 +328,9 @@ export const DEFAULT_MASK_CONFIG: MaskConfig = { } export interface BaseLayer { + /** Original data for a layer unavailable in this version; never rendered. */ + unavailableLayer?: Record + parentId?: string | null assetId: string | null depthAssetId?: string | null diff --git a/tests/community/v3-compatibility.test.ts b/tests/community/v3-compatibility.test.ts new file mode 100644 index 00000000..ebb839ec --- /dev/null +++ b/tests/community/v3-compatibility.test.ts @@ -0,0 +1,548 @@ +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" +import { + planUploads, + validateDraftPayload, + validateProjectFilePayload, +} from "@/lib/community/publish" +import { describePublishPlan, saveDraft } from "@/lib/community/publish-client" +import { MAX_ASSET_BYTES, MAX_IMAGE_BYTES } from "@/lib/community/upload-limits" +import { buildViewerAudio } from "@/lib/community/viewer-audio" +import * as analysis from "@/lib/editor/audio/analyze-client" +import { computeEnvelopeSet } from "@/lib/editor/audio/envelope" +import { analyzeSpectrogram } from "@/lib/editor/audio/spectrogram" +import { getBlankProjectFile } from "@/lib/editor/blank-project" +import { getLayerDefinitions } from "@/lib/editor/config/layer-registry" +import { dropLayer, moveLayerToGroup } from "@/lib/editor/layer-groups" +import { createLayer } from "@/lib/editor/layers" +import { normalizeAssetMimeType } from "@/lib/editor/media-file" +import { + applyLabProjectFile, + buildLabProjectFile, + buildPublishableProjectFile, + buildViewerProjectState, + type LabProjectFile, + parseLabProjectFileValue, +} from "@/lib/editor/project-file" +import { buildRendererFrame } from "@/renderer/contracts" +import { + isLayerGroup, + validateLayerHierarchy, +} from "@/renderer/layer-hierarchy" +import { useAssetStore } from "@/store/asset-store" +import { useLayerStore } from "@/store/layer-store" + +afterEach(() => mock.restore()) + +function uploadPlan(contentType: string, contentLength = 100) { + return planUploads({ + draftId: "scn_test", + requested: [{ contentType, contentLength, sha256: "a".repeat(64) }], + storedUrls: new Map(), + }) +} + +describe("V3 media uploads", () => { + test.each([ + ["logo.GLB", "application/octet-stream", "model/gltf-binary", "glb"], + ["logo.glb", "", "model/gltf-binary", "glb"], + ["logo.gltf", "application/json", "model/gltf+json", "gltf"], + ["studio.hdr", "application/octet-stream", "image/vnd.radiance", "hdr"], + ["studio.hdr", "", "image/vnd.radiance", "hdr"], + ["logo.svg", "", "image/svg+xml", "svg"], + ["song.wav", "audio/x-wav", "audio/wav", "wav"], + ["song.mp3", "audio/mp3", "audio/mpeg", "mp3"], + ["song.flac", "audio/x-flac", "audio/flac", "bin"], + ["song.opus", "audio/opus", "audio/ogg", "bin"], + ["song.m4a", "", "audio/mp4", "bin"], + ["clip.mov", "", "video/quicktime", "mov"], + ])("normalizes %s (%s) through the upload planner", (fileName, mimeType, expected, extension) => { + const canonical = normalizeAssetMimeType({ fileName, mimeType }) + expect(canonical).toBe(expected) + const plan = uploadPlan(canonical) + if ("error" in plan) throw new Error(plan.error) + expect(plan.uploads[0]?.contentType).toBe(expected) + expect(plan.uploads[0]?.key).toEndWith(`.${extension}`) + expect(plan.signedBytes).toBe(100) + }) + + test("HDR and models use the asset cap while ordinary images retain their cap", () => { + for (const mime of [ + "image/vnd.radiance", + "model/gltf-binary", + "model/gltf+json", + ]) { + expect(uploadPlan(mime, MAX_ASSET_BYTES)).toHaveProperty("uploads") + expect(uploadPlan(mime, MAX_ASSET_BYTES + 1)).toHaveProperty("error") + } + for (const mime of ["image/png", "image/svg+xml"]) { + expect(uploadPlan(mime, MAX_IMAGE_BYTES)).toHaveProperty("uploads") + expect(uploadPlan(mime, MAX_IMAGE_BYTES + 1)).toHaveProperty("error") + } + expect(uploadPlan("image/vnd.radiance", 0)).toHaveProperty("error") + expect(uploadPlan("application/octet-stream")).toHaveProperty("error") + expect(uploadPlan("text/html")).toHaveProperty("error") + }) + + test("normalizes existing local HDR metadata before publish preflight", () => { + const layer = { ...createLayer("model"), environmentAssetId: "hdr" } + useLayerStore.getState().replaceState([layer], layer.id, null) + useAssetStore.getState().replaceAssets([ + { + id: "hdr", + kind: "environment", + fileName: "studio.hdr", + mimeType: "image/vnd.radiance", + sizeBytes: 8 * 1024 * 1024, + url: "blob:fixture", + source: "local", + status: "ready", + error: null, + createdAt: "2026-01-01", + width: null, + height: null, + duration: null, + }, + ]) + expect(describePublishPlan().problem).toBeNull() + }) + + test("an old GLTF/HDR draft sends canonical MIME types through signed PUTs", async () => { + const model = await useAssetStore.getState().loadAsset( + new File(['{"asset":{"version":"2.0"}}'], "scene.gltf", { + type: "application/json", + }) + ) + const environment = await useAssetStore.getState().loadAsset( + new File(["#?RADIANCE\n"], "studio.hdr", { + type: "application/octet-stream", + }) + ) + expect(model.mimeType).toBe("model/gltf+json") + expect(environment.mimeType).toBe("image/vnd.radiance") + const layer = { + ...createLayer("model"), + assetId: model.id, + environmentAssetId: environment.id, + } + useLayerStore.getState().replaceState([layer], layer.id, null) + // Simulate an autosave created before MIME normalization was added. + useAssetStore.getState().replaceAssets( + [model, environment].map((asset) => ({ + ...asset, + mimeType: "application/octet-stream", + })) + ) + const realFetch = globalThis.fetch + const putTypes: string[] = [] + let savedProject: LabProjectFile | null = null + spyOn(globalThis, "fetch").mockImplementation( + Object.assign( + async (...[input, init]: Parameters) => { + const url = String(input) + if (url.startsWith("blob:")) return realFetch(input, init) + if (url === "/api/community/drafts") { + const body = JSON.parse(String(init?.body)) + const plan = planUploads({ + draftId: "scn_fixture", + requested: body.uploads, + storedUrls: new Map(), + }) + if ("error" in plan) throw new Error(plan.error) + return Response.json({ + draftId: "scn_fixture", + uploads: plan.uploads.map((upload) => ({ + ...upload, + uploadUrl: `https://upload.invalid/${upload.key}`, + publicUrl: `/scenes/default/${upload.key}`, + })), + }) + } + if (url.startsWith("https://upload.invalid/")) { + putTypes.push(new Headers(init?.headers).get("content-type")!) + return new Response(null, { status: 200 }) + } + if (url === "/api/community/drafts/scn_fixture") { + savedProject = validateDraftPayload( + JSON.parse(String(init?.body)).projectFile + ).projectFile + return Response.json({ + draft: { + id: "scn_fixture", + title: "Fixture", + savedAt: "2026-01-01", + }, + }) + } + throw new Error(`Unexpected request: ${url}`) + }, + { preconnect: realFetch.preconnect } + ) + ) + const result = await saveDraft({ asNewDraft: true, withThumbnail: false }) + expect(result.skipped).toEqual([]) + expect(putTypes).toEqual(["model/gltf+json", "image/vnd.radiance"]) + expect(savedProject).not.toBeNull() + expect((savedProject as unknown as LabProjectFile).assets).toHaveLength(2) + URL.revokeObjectURL(model.url) + URL.revokeObjectURL(environment.url) + }) +}) + +function unavailableProject(version = 7) { + const known = createLayer("gradient") + const original = { + ...createLayer("blur"), + type: "retired-effect", + name: "Old effect", + assetId: "original-media", + params: { amount: 0.72 }, + futureField: { preserve: [1, 2, 3] }, + } + const raw = { + ...getBlankProjectFile(), + version, + layers: [original, known], + selectedLayerId: original.id, + assets: [ + { + id: "original-media", + kind: "image", + fileName: "original.png", + url: "/scenes/default/original.png", + }, + ], + timeline: { + duration: 10, + loop: true, + tracks: [ + { + id: "original-track", + layerId: original.id, + enabled: true, + binding: { kind: "param", key: "amount" }, + keyframes: [{ id: "key", time: 0, value: 0.5 }], + }, + ], + }, + } + return { original, known, raw, parsed: parseLabProjectFileValue(raw) } +} + +describe("project compatibility", () => { + test("all registered V3 and legacy layer types parse and hydrate", () => { + for (const definition of getLayerDefinitions()) { + const file = { + ...getBlankProjectFile(), + layers: [createLayer(definition.type)], + } + const project = buildViewerProjectState(parseLabProjectFileValue(file)) + expect(project.layers[0]?.type).toBe(definition.type) + } + }) + + test.each([ + 6, 7, + ])("unknown types in v%i preserve data, assets, and tracks across import/export", (version) => { + const { parsed, original } = unavailableProject(version) + expect(parsed.layers[0]?.unavailableLayer).toEqual(original) + expect(parsed.layers[0]?.visible).toBe(false) + expect(parsed.layers[0]?.runtimeError).toContain("unavailable") + applyLabProjectFile(parsed, []) + const saved = buildLabProjectFile() + expect(saved.layers[0]?.unavailableLayer).toEqual(original) + expect(saved.timeline.tracks).toHaveLength(1) + expect(saved.timeline.tracks[0]).toMatchObject(parsed.timeline.tracks[0]!) + expect(saved.assets[0]?.id).toBe("original-media") + expect(saved.layers[0]?.assetId).toBe("original-media") + const reloaded = parseLabProjectFileValue(JSON.parse(JSON.stringify(saved))) + expect(reloaded.layers[0]?.unavailableLayer).toEqual(original) + expect(reloaded.layers[0]?.name).toBe(parsed.layers[0]?.name) + }) + + test("unknown layers are never rendered, even if their visibility is toggled", () => { + const { parsed, known } = unavailableProject() + const project = buildViewerProjectState(parsed) + const frame = buildRendererFrame({ + ...project, + audio: null, + layers: project.layers.map((layer) => ({ ...layer, visible: true })), + delta: 0, + outputSize: project.composition, + pixelRatio: 1, + viewportSize: project.composition, + }) + expect(frame.layers).toHaveLength(1) + expect(frame.layers[0]).toHaveProperty("layer.id", known.id) + }) + + test("an unavailable leaf does not consume another group nesting level", () => { + const { raw, original } = unavailableProject() + const groups = Array.from({ length: 8 }, (_, index) => ({ + ...createLayer("group"), + id: `group-${index}`, + parentId: index > 0 ? `group-${index - 1}` : null, + })) + const parsed = parseLabProjectFileValue({ + ...raw, + layers: [...groups, { ...original, parentId: "group-7" }], + }) + expect(parsed.layers.at(-1)?.unavailableLayer).toBeDefined() + applyLabProjectFile(parsed, []) + expect(buildLabProjectFile().layers).toHaveLength(9) + }) + + test.each([ + false, + true, + ])("adding beside a selected unavailable leaf succeeds (nested=%s)", (nested) => { + const { raw, original } = unavailableProject() + const group = createLayer("group") + const file = parseLabProjectFileValue({ + ...raw, + layers: nested + ? [group, { ...original, parentId: group.id }] + : raw.layers, + }) + applyLabProjectFile(file, []) + const addedId = useLayerStore.getState().addLayer("gradient") + const layers = useLayerStore.getState().layers + const added = layers.find((layer) => layer.id === addedId)! + expect(added.parentId ?? null).toBe(nested ? group.id : null) + expect(added.parentId).not.toBe(original.id) + expect( + layers.find((layer) => layer.id === original.id)?.unavailableLayer + ).toBeDefined() + expect(() => validateLayerHierarchy(layers)).not.toThrow() + }) + + test("unavailable leaves cannot become group targets even after unlocking", () => { + const { parsed, known, original } = unavailableProject() + const layers = parsed.layers.map((layer) => ({ ...layer, locked: false })) + expect(isLayerGroup(layers[0]!)).toBe(false) + expect(moveLayerToGroup(layers, known.id, original.id)).toBeNull() + expect( + dropLayer(layers, known.id, { id: original.id, placement: "inside" }) + ).toBeNull() + useLayerStore.getState().replaceState(layers, original.id, null) + useLayerStore.getState().ungroupLayer(original.id) + expect(useLayerStore.getState().layers).toEqual(layers) + }) + + test("normal groups still accept newly added children", () => { + const group = createLayer("group") + useLayerStore.getState().replaceState([group], group.id, null) + const addedId = useLayerStore.getState().addLayer("gradient") + expect( + useLayerStore.getState().layers.find((layer) => layer.id === addedId) + ?.parentId + ).toBe(group.id) + }) + + test("drafts preserve unavailable layers; publish reports them before pruning", () => { + const { raw, parsed } = unavailableProject() + const draft = validateDraftPayload(JSON.stringify(raw)) + expect(JSON.parse(draft.body).layers[0].type).toBe("retired-effect") + expect(buildPublishableProjectFile(parsed).layers).toHaveLength(2) + expect(() => validateProjectFilePayload(JSON.stringify(raw))).toThrow( + "unavailable layer" + ) + applyLabProjectFile(parsed, []) + expect(describePublishPlan().problem).toContain("unavailable layer") + }) + + test("malformed known layers and invalid hierarchies still fail validation", () => { + const { raw, known } = unavailableProject() + expect(() => + parseLabProjectFileValue({ + ...raw, + layers: [{ ...known, opacity: "broken" }], + }) + ).toThrow() + expect(() => + parseLabProjectFileValue({ + ...raw, + layers: [{ ...known, kind: "effect" }], + }) + ).toThrow() + expect(() => + parseLabProjectFileValue({ + ...raw, + layers: [{ ...known, parentId: "missing" }], + }) + ).toThrow() + expect(() => + parseLabProjectFileValue({ + ...raw, + layers: [{ ...known, unavailableLayer: { type: "bad" } }], + }) + ).toThrow() + expect(() => parseLabProjectFileValue({ ...raw, version: 999 })).toThrow( + "version" + ) + }) + + test("a saved placeholder restores when its original type is supported", () => { + const original = createLayer("blur") + const file = { + ...getBlankProjectFile(), + layers: [ + { + ...createLayer("group"), + id: original.id, + unavailableLayer: original, + }, + ], + } + expect(parseLabProjectFileValue(file).layers[0]?.type).toBe("blur") + expect(parseLabProjectFileValue(file).layers[0]?.params).toEqual( + original.params + ) + }) +}) + +function audioProject(sourceKind: "asset" | "video-layer" = "asset") { + const layer = { ...createLayer("video"), assetId: "media" } + const file: LabProjectFile = { + ...getBlankProjectFile(), + layers: [layer], + assets: [ + { + id: "media", + fileName: "audio.wav", + kind: "audio", + url: "/scenes/default/audio.wav", + }, + ], + } + file.audio = { + ...getBlankProjectFile().audio!, + offsetSeconds: 0.5, + source: + sourceKind === "asset" + ? { kind: "asset", assetId: "media" } + : { kind: "video-layer", layerId: layer.id }, + links: [ + { + id: "link", + layerId: layer.id, + band: "level", + enabled: true, + binding: { + kind: "layer", + property: "opacity", + label: "Opacity", + valueType: "number", + }, + outMin: 0, + outMax: 1, + }, + ], + } + return buildViewerProjectState(file) +} + +describe("community audio reactivity", () => { + test.each([ + "asset", + "video-layer", + ] as const)("analyzes a %s source with the saved bands, links, and offset", async (kind) => { + const project = audioProject(kind) + const samples = Float32Array.from({ length: 22050 }, (_, i) => + i < 11025 ? 0 : Math.sin((2 * Math.PI * 440 * i) / 22050) + ) + const spectrogram = analyzeSpectrogram(samples, 22050) + const analyze = spyOn(analysis, "analyzeAudioSource").mockResolvedValue( + spectrogram + ) + const audio = await buildViewerAudio(project, new AbortController().signal) + expect(analyze).toHaveBeenCalledWith( + "/scenes/default/audio.wav", + expect.anything() + ) + expect(audio).toEqual({ + envelopes: computeEnvelopeSet(spectrogram, project.audioSnapshot.bands), + links: project.audioSnapshot.links, + offsetSeconds: 0.5, + }) + const render = (time: number) => + buildRendererFrame({ + ...project, + audio, + delta: 0, + outputSize: project.composition, + pixelRatio: 1, + viewportSize: project.composition, + timeline: { ...project.timeline, currentTime: time }, + }) + expect(render(-0.5).layers).not.toEqual(render(0.4).layers) + }) + + test("saved audio settings can pass through project rendering without masquerading as envelopes", () => { + const project = audioProject() + const frame = buildRendererFrame({ + ...project, + delta: 0, + outputSize: project.composition, + pixelRatio: 1, + viewportSize: project.composition, + }) + expect(frame.layers).toHaveLength(1) + expect(frame.layers[0]).toHaveProperty("layer.opacity", 1) + expect(project.audioSnapshot.links).toHaveLength(1) + }) + + test("skips analysis without active links and tolerates missing or broken media", async () => { + const analyze = spyOn(analysis, "analyzeAudioSource").mockRejectedValue( + new Error("404") + ) + const project = audioProject() + const signal = new AbortController().signal + expect( + await buildViewerAudio({ ...project, assets: [] }, signal) + ).toBeNull() + expect( + await buildViewerAudio( + { ...project, audioSnapshot: { ...project.audioSnapshot, links: [] } }, + signal + ) + ).toBeNull() + expect(analyze).not.toHaveBeenCalled() + expect(await buildViewerAudio(project, signal)).toBeNull() + }) + + test("the deadline releases playback even when decoding ignores the signal", async () => { + const deadline = new AbortController() + spyOn(AbortSignal, "timeout").mockReturnValue(deadline.signal) + spyOn(analysis, "analyzeAudioSource").mockImplementation( + () => new Promise(() => undefined) + ) + const pending = buildViewerAudio( + audioProject(), + new AbortController().signal + ) + deadline.abort(new DOMException("Audio deadline", "TimeoutError")) + expect(await pending).toBeNull() + }, 500) + + test("unmount releases the wait even when decoding ignores cancellation", async () => { + const controller = new AbortController() + spyOn(analysis, "analyzeAudioSource").mockImplementation( + () => new Promise(() => undefined) + ) + const pending = buildViewerAudio(audioProject(), controller.signal) + controller.abort() + await expect(pending).rejects.toHaveProperty("name", "AbortError") + }, 500) + + test("does not start analysis after unmount", async () => { + const controller = new AbortController() + controller.abort() + const analyze = spyOn(analysis, "analyzeAudioSource").mockImplementation( + () => new Promise(() => undefined) + ) + await expect( + buildViewerAudio(audioProject(), controller.signal) + ).rejects.toHaveProperty("name", "AbortError") + expect(analyze).not.toHaveBeenCalled() + }) +})