From 7080a331777008887a3cb8da0f0c4ce9bb44e621 Mon Sep 17 00:00:00 2001 From: Dmitri Date: Fri, 31 Jul 2026 12:42:23 +0200 Subject: [PATCH] improved video and audio playback functionality --- backend/docs/api-endpoints.md | 19 ++++- backend/docs/database-schema.md | 2 +- backend/docs/modules/db-migrations.md | 24 +++++- backend/docs/modules/db-seeders.md | 5 ++ backend/docs/modules/middleware.md | 32 ++++++++ backend/docs/modules/routes.md | 7 +- backend/src/db/api/tour_pages.ts | 1 - ...000001-remove-background-audio-autoplay.js | 18 ++++ backend/src/db/models/tour_pages.ts | 6 -- .../db/seeders/20231127130745-sample-data.ts | 20 +++++ backend/src/middlewares/runtime-public.ts | 10 +++ backend/src/openapi/document.ts | 9 +- backend/src/services/tour_pages.ts | 1 - backend/src/types/tour-pages.ts | 2 - backend/src/validators/request-schemas.ts | 1 - ...ackground-audio-autoplay-migration.test.ts | 82 +++++++++++++++++++ .../background-audio-schema.test.ts | 33 ++++++++ .../tests/integration/publish-service.test.ts | 71 +++++++++++++--- backend/tests/openapi-document.test.ts | 31 +++++++ backend/tests/runtime-public.test.ts | 14 ++++ documentation/api-reference.md | 17 ++++ documentation/audio-playback.md | 24 +++--- documentation/project-architecture.md | 11 +++ documentation/video-playback.md | 6 ++ frontend/docs/hooks-module.md | 3 +- frontend/docs/runtime-presentation.md | 13 ++- frontend/docs/types-module.md | 2 - frontend/src/components/Constructor/types.ts | 1 - frontend/src/hooks/usePageBackground.ts | 1 - frontend/src/types/entities.ts | 1 - frontend/src/types/runtime.ts | 1 - 31 files changed, 414 insertions(+), 54 deletions(-) create mode 100644 backend/src/db/migrations/20260731000001-remove-background-audio-autoplay.js create mode 100644 backend/tests/background-audio-autoplay-migration.test.ts create mode 100644 backend/tests/integration/background-audio-schema.test.ts diff --git a/backend/docs/api-endpoints.md b/backend/docs/api-endpoints.md index 4840d53..7e792c3 100644 --- a/backend/docs/api-endpoints.md +++ b/backend/docs/api-endpoints.md @@ -510,7 +510,8 @@ Get current runtime context (environment detection). **Permissions**: `CREATE_PROJECTS`, `READ_PROJECTS`, `UPDATE_PROJECTS`, `DELETE_PROJECTS` -**Runtime Public Access**: GET endpoints accessible without auth in production mode. +**Runtime Public Access**: GET endpoints are accessible without auth in +production mode for public presentations. ### POST /api/projects @@ -647,7 +648,11 @@ Get PWA offline manifest for a project. **Permissions**: `CREATE_TOUR_PAGES`, `READ_TOUR_PAGES`, etc. -**Runtime Public Access**: GET endpoints accessible without auth in production mode. +**Runtime Public Access**: GET endpoints are accessible without auth in +production mode for public presentations. Public tour-page responses retain +background media URLs, video autoplay/loop/muted/start/end, audio +loop/start/end, design dimensions, `ui_schema_json`, and +`global_ui_controls_settings_json`. ### POST /api/tour_pages/reorder @@ -753,8 +758,18 @@ Duplicates a constructor/dev page into a new independent dev page. "sort_order": 1, "background_image_url": "assets/bg.jpg", "background_video_url": null, + "background_video_autoplay": true, + "background_video_loop": false, + "background_video_muted": false, + "background_video_start_time": 2, + "background_video_end_time": 30, "background_audio_url": "assets/ambient.mp3", + "background_audio_loop": true, + "background_audio_start_time": 1.5, + "background_audio_end_time": 12, "background_loop": true, + "design_width": 1920, + "design_height": 1080, "requires_auth": false, "ui_schema_json": { "elements": [], diff --git a/backend/docs/database-schema.md b/backend/docs/database-schema.md index 63ead00..c6c52b2 100644 --- a/backend/docs/database-schema.md +++ b/backend/docs/database-schema.md @@ -341,7 +341,6 @@ Individual pages/scenes within a tour. | `background_video_start_time` | DECIMAL(10,1) | nullable | Background video start time (seconds) | | `background_video_end_time` | DECIMAL(10,1) | nullable | Background video end time (seconds) | | `background_video_play_once` | BOOLEAN | NOT NULL, default: false | Play video only once per session (show last frame on revisit) | -| `background_audio_autoplay` | BOOLEAN | NOT NULL, default: true | Autoplay background audio | | `background_audio_loop` | BOOLEAN | NOT NULL, default: true | Loop background audio | | `background_audio_start_time` | DECIMAL(10,1) | nullable | Background audio start time (seconds) | | `background_audio_end_time` | DECIMAL(10,1) | nullable | Background audio end time (seconds) | @@ -923,6 +922,7 @@ All foreign key constraints are enforced at the database level via migration `20 | `20260613000001-add-background-embed-url-to-tour-pages.js` | Adds background_embed_url to tour_pages for 360/embed page backgrounds | | `20260626000001-add-private-production-presentation-access.js` | Adds project production visibility and customer access grants for private production presentations | | `20260626000002-grant-account-manager-create-users.js` | Grants `CREATE_USERS` to Account Manager for customer viewer creation | +| `20260731000001-remove-background-audio-autoplay.js` | Removes the unused background audio autoplay column; rollback restores it with its former default | --- diff --git a/backend/docs/modules/db-migrations.md b/backend/docs/modules/db-migrations.md index d6d1f18..85850fe 100644 --- a/backend/docs/modules/db-migrations.md +++ b/backend/docs/modules/db-migrations.md @@ -8,7 +8,7 @@ changes that run automatically on server startup. **Location:** `backend/src/db/migrations/` -**Files:** 34+ migration files (as of June 2026) +**Files:** 38 migration files (as of July 2026) **Migration safety policy:** Do not rewrite, rename, or reformat already applied migration files. Production databases track migration names in @@ -53,7 +53,10 @@ backend/ ├── 20260626000001-*.js # Private production presentation access ├── 20260626000002-*.js # Account manager user creation permission ├── 20260628000001-*.js # Global UI-control settings tables - └── 20260628000005-*.js # Existing-project UI-control snapshots + ├── 20260628000005-*.js # Existing-project UI-control snapshots + ├── 20260628000006-*.js # Page-element role permissions + ├── 20260702000001-*.js # Runtime asset lookup indexes + └── 20260731000001-*.js # Remove unused audio autoplay column ``` --- @@ -633,6 +636,7 @@ module.exports = { | `add-background-video-settings` | Add video playback settings (autoplay, loop, muted, start/end time) to tour_pages | | `add-design-dimensions-to-projects` | Add `design_width`, `design_height` to projects table | | `add-design-dimensions-to-tour-pages` | Add `design_width`, `design_height` to tour_pages table | +| `remove-background-audio-autoplay` | Remove the unused background audio autoplay column from tour_pages | ### Table Renames @@ -823,7 +827,21 @@ explicit rollback/backup plan. | 21 | 20260403000001 | add-background-video-settings | Column | | 22 | 20260409000001 | add-design-dimensions-to-projects | Column | | 23 | 20260409111309 | add-design-dimensions-to-tour-pages | Column | -| 24 | 20260605000001 | add-background-audio-settings | Column | +| 24 | 20260413091125 | add-reversed-variant-type | Column | +| 25 | 20260430000001 | add-transition-settings | Column | +| 26 | 20260501000001 | simplify-transitions-add-overlay-color | Column | +| 27 | 20260501000002 | create-project-transition-settings | Schema | +| 28 | 20260529000001 | add-embed-asset-support | Data | +| 29 | 20260605000001 | add-background-audio-settings | Column | +| 30 | 20260613000001 | add-background-embed-url-to-tour-pages | Column | +| 31 | 20260625000001 | add-frame-rate-to-assets | Column | +| 32 | 20260626000001 | add-private-production-presentation-access | Schema | +| 33 | 20260626000002 | grant-account-manager-create-users | Data | +| 34 | 20260628000001 | create-ui-control-settings | Schema | +| 35 | 20260628000005 | snapshot-existing-project-ui-controls | Data | +| 36 | 20260628000006 | backfill-page-elements-role-permissions | Data | +| 37 | 20260702000001 | add-asset-runtime-lookup-indexes | Index | +| 38 | 20260731000001 | remove-background-audio-autoplay | Column | --- diff --git a/backend/docs/modules/db-seeders.md b/backend/docs/modules/db-seeders.md index 7c92409..6d29d1f 100644 --- a/backend/docs/modules/db-seeders.md +++ b/backend/docs/modules/db-seeders.md @@ -290,6 +290,11 @@ const sampleDataSeeder: SequelizeSeeder = { | PWA Caches | 3 | Offline cache configs | | Access Logs | 3 | Visitor tracking | +The sample `Arena Floor` page uses non-default video and audio playback bounds +plus explicit `1920x1080` page dimensions. This makes opt-in sample data useful +for verifying that Save to Stage, Publish, and public-runtime response +sanitization preserve playback settings. + #### Sample Projects ```javascript diff --git a/backend/docs/modules/middleware.md b/backend/docs/modules/middleware.md index 86b81e8..e74811f 100644 --- a/backend/docs/modules/middleware.md +++ b/backend/docs/modules/middleware.md @@ -441,6 +441,7 @@ const PUBLIC_RUNTIME_ENTITY_FIELDS = { 'logo_url', 'favicon_url', 'og_image_url', + 'production_presentation_visibility', ], tour_pages: [ 'id', @@ -452,10 +453,22 @@ const PUBLIC_RUNTIME_ENTITY_FIELDS = { 'sort_order', 'background_image_url', 'background_video_url', + 'background_embed_url', 'background_audio_url', + 'background_audio_loop', + 'background_audio_start_time', + 'background_audio_end_time', 'background_loop', + 'background_video_autoplay', + 'background_video_loop', + 'background_video_muted', + 'background_video_start_time', + 'background_video_end_time', + 'design_width', + 'design_height', 'requires_auth', 'ui_schema_json', + 'global_ui_controls_settings_json', ], project_audio_tracks: [ 'id', @@ -470,9 +483,28 @@ const PUBLIC_RUNTIME_ENTITY_FIELDS = { 'sort_order', 'is_enabled', ], + global_transition_defaults: [ + 'id', + 'transition_type', + 'duration_ms', + 'easing', + 'overlay_color', + ], + project_transition_settings: [ + 'id', + 'projectId', + 'environment', + 'transition_type', + 'duration_ms', + 'easing', + 'overlay_color', + ], }; ``` +Background audio starts after the runtime's interaction/unlock flow and uses +the exposed loop/start/end settings. + #### Function: blockNonPublicRuntimeListEndpoints Restricts public runtime requests to list endpoints only. diff --git a/backend/docs/modules/routes.md b/backend/docs/modules/routes.md index 9007df3..7595cf3 100644 --- a/backend/docs/modules/routes.md +++ b/backend/docs/modules/routes.md @@ -426,8 +426,11 @@ Typed manual CRUD route for tour pages. In addition to standard create/update/de **Schema Fields:** - `source_key`, `name`, `slug` -- `background_image_url`, `background_video_url`, `background_audio_url` -- `ui_schema_json`, `sort_order` +- `background_image_url`, `background_video_url`, `background_embed_url`, + `background_audio_url` +- Video autoplay/loop/muted/start/end and audio loop/start/end settings +- `design_width`, `design_height`, `requires_auth` +- `ui_schema_json`, `global_ui_controls_settings_json`, `sort_order` **Endpoints:** diff --git a/backend/src/db/api/tour_pages.ts b/backend/src/db/api/tour_pages.ts index 47bb05f..38d7b94 100644 --- a/backend/src/db/api/tour_pages.ts +++ b/backend/src/db/api/tour_pages.ts @@ -166,7 +166,6 @@ class Tour_pagesDBApi extends GenericDBApi { background_video_url: data.background_video_url || null, background_embed_url: data.background_embed_url || null, background_audio_url: data.background_audio_url || null, - background_audio_autoplay: data.background_audio_autoplay ?? true, background_audio_loop: data.background_audio_loop ?? true, background_audio_start_time: data.background_audio_start_time ?? null, background_audio_end_time: data.background_audio_end_time ?? null, diff --git a/backend/src/db/migrations/20260731000001-remove-background-audio-autoplay.js b/backend/src/db/migrations/20260731000001-remove-background-audio-autoplay.js new file mode 100644 index 0000000..ecdabb6 --- /dev/null +++ b/backend/src/db/migrations/20260731000001-remove-background-audio-autoplay.js @@ -0,0 +1,18 @@ +'use strict'; + +module.exports = { + async up(queryInterface, _Sequelize) { + await queryInterface.removeColumn( + 'tour_pages', + 'background_audio_autoplay', + ); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.addColumn('tour_pages', 'background_audio_autoplay', { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true, + }); + }, +}; diff --git a/backend/src/db/models/tour_pages.ts b/backend/src/db/models/tour_pages.ts index d62a0e7..c32973c 100644 --- a/backend/src/db/models/tour_pages.ts +++ b/backend/src/db/models/tour_pages.ts @@ -75,12 +75,6 @@ const defineTourPagesModel: SequelizeModelFactory = (sequelize, DataTypes) => { type: DataTypes.TEXT, }, - background_audio_autoplay: { - type: DataTypes.BOOLEAN, - allowNull: false, - defaultValue: true, - }, - background_audio_loop: { type: DataTypes.BOOLEAN, allowNull: false, diff --git a/backend/src/db/seeders/20231127130745-sample-data.ts b/backend/src/db/seeders/20231127130745-sample-data.ts index 83f7b88..bdfc944 100644 --- a/backend/src/db/seeders/20231127130745-sample-data.ts +++ b/backend/src/db/seeders/20231127130745-sample-data.ts @@ -344,8 +344,28 @@ const TourPagesData = [ background_video_url: 'https://cdn.platform.com/cardiff/video/floor-loop.mp4', + background_video_autoplay: true, + + background_video_loop: false, + + background_video_muted: false, + + background_video_start_time: 2, + + background_video_end_time: 30, + background_audio_url: '', + background_audio_loop: false, + + background_audio_start_time: 1.5, + + background_audio_end_time: 12, + + design_width: 1920, + + design_height: 1080, + background_loop: true, requires_auth: true, diff --git a/backend/src/middlewares/runtime-public.ts b/backend/src/middlewares/runtime-public.ts index 43ca93d..c443698 100644 --- a/backend/src/middlewares/runtime-public.ts +++ b/backend/src/middlewares/runtime-public.ts @@ -33,7 +33,17 @@ const PUBLIC_RUNTIME_ENTITY_FIELDS: RuntimePublicFieldMap = { 'background_video_url', 'background_embed_url', 'background_audio_url', + 'background_audio_loop', + 'background_audio_start_time', + 'background_audio_end_time', 'background_loop', + 'background_video_autoplay', + 'background_video_loop', + 'background_video_muted', + 'background_video_start_time', + 'background_video_end_time', + 'design_width', + 'design_height', 'requires_auth', 'ui_schema_json', 'global_ui_controls_settings_json', diff --git a/backend/src/openapi/document.ts b/backend/src/openapi/document.ts index dfbbd6f..c53500f 100644 --- a/backend/src/openapi/document.ts +++ b/backend/src/openapi/document.ts @@ -16,6 +16,7 @@ interface CrudResource { schema: string; description: string; runtimePublicRead?: boolean; + runtimePublicReadDescription?: string; } interface OpenApiDocument { @@ -273,8 +274,11 @@ const crudPaths = (resource: CrudResource): OpenApiPaths => { const readSecurity = resource.runtimePublicRead ? [{ bearerAuth: [] }, {}] : bearerSecurity; + const runtimePublicReadDescription = resource.runtimePublicReadDescription + ? ` ${resource.runtimePublicReadDescription}` + : ''; const readDescription = resource.runtimePublicRead - ? `${resource.description}. GET requests are public for allowed production runtime reads and authenticated otherwise.` + ? `${resource.description}. GET requests are public for allowed production runtime reads and authenticated otherwise.${runtimePublicReadDescription}` : resource.description; const commonReadParameters = resource.runtimePublicRead ? [...runtimeHeaders] @@ -600,7 +604,6 @@ const schemas: Record = { background_video_url: nullable({ type: 'string', maxLength: 4096 }), background_embed_url: nullable({ type: 'string', maxLength: 8192 }), background_audio_url: nullable({ type: 'string', maxLength: 4096 }), - background_audio_autoplay: { type: 'boolean' }, background_audio_loop: { type: 'boolean' }, background_audio_start_time: nullable({ type: 'number', minimum: 0 }), background_audio_end_time: nullable({ type: 'number', minimum: 0 }), @@ -1088,6 +1091,8 @@ const crudResources: CrudResource[] = [ schema: 'TourPage', description: 'tour pages', runtimePublicRead: true, + runtimePublicReadDescription: + 'Public responses retain background media URLs, background audio loop/start/end settings, background video autoplay/loop/muted/start/end settings, page design dimensions, UI schema, and page-level global UI control settings.', }, { path: '/api/assets', diff --git a/backend/src/services/tour_pages.ts b/backend/src/services/tour_pages.ts index 2b695fb..d3c4bc3 100644 --- a/backend/src/services/tour_pages.ts +++ b/backend/src/services/tour_pages.ts @@ -658,7 +658,6 @@ class TourPagesService extends BaseService { background_video_url: source.background_video_url || '', background_embed_url: source.background_embed_url || '', background_audio_url: source.background_audio_url || '', - background_audio_autoplay: source.background_audio_autoplay, background_audio_loop: source.background_audio_loop, background_audio_start_time: source.background_audio_start_time, background_audio_end_time: source.background_audio_end_time, diff --git a/backend/src/types/tour-pages.ts b/backend/src/types/tour-pages.ts index feb642c..d870bfa 100644 --- a/backend/src/types/tour-pages.ts +++ b/backend/src/types/tour-pages.ts @@ -90,7 +90,6 @@ export interface TourPageData { background_video_url?: string | null | undefined; background_embed_url?: string | null | undefined; background_audio_url?: string | null | undefined; - background_audio_autoplay?: boolean | undefined; background_audio_loop?: boolean | undefined; background_audio_start_time?: number | null | undefined; background_audio_end_time?: number | null | undefined; @@ -264,7 +263,6 @@ export interface TourPageFieldMapping { background_video_url: string | null; background_embed_url: string | null; background_audio_url: string | null; - background_audio_autoplay: boolean; background_audio_loop: boolean; background_audio_start_time: number | null; background_audio_end_time: number | null; diff --git a/backend/src/validators/request-schemas.ts b/backend/src/validators/request-schemas.ts index 216f3db..bbfce4d 100644 --- a/backend/src/validators/request-schemas.ts +++ b/backend/src/validators/request-schemas.ts @@ -151,7 +151,6 @@ const tourPageData = Joi.object({ background_video_url: Joi.string().allow('', null).max(4096), background_embed_url: Joi.string().allow('', null).max(8192), background_audio_url: Joi.string().allow('', null).max(4096), - background_audio_autoplay: Joi.boolean(), background_audio_loop: Joi.boolean(), background_audio_start_time: Joi.number().min(0).allow(null), background_audio_end_time: Joi.number().min(0).allow(null), diff --git a/backend/tests/background-audio-autoplay-migration.test.ts b/backend/tests/background-audio-autoplay-migration.test.ts new file mode 100644 index 0000000..ced1e01 --- /dev/null +++ b/backend/tests/background-audio-autoplay-migration.test.ts @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import test from 'node:test'; + +interface MigrationQueryInterface { + removeColumn(table: string, column: string): Promise; + addColumn( + table: string, + column: string, + definition: Record, + ): Promise; +} + +interface MigrationModule { + up( + queryInterface: MigrationQueryInterface, + sequelize: Record, + ): Promise; + down( + queryInterface: MigrationQueryInterface, + sequelize: Record, + ): Promise; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object'; +} + +function isMigrationModule(value: unknown): value is MigrationModule { + return ( + isRecord(value) && + typeof value.up === 'function' && + typeof value.down === 'function' + ); +} + +const require = createRequire(import.meta.url); +const loadedMigration: unknown = require('../src/db/migrations/20260731000001-remove-background-audio-autoplay.js'); + +assert.ok(isMigrationModule(loadedMigration)); + +void test('background audio autoplay migration removes and restores the column', async () => { + const calls: Array<{ + operation: 'add' | 'remove'; + table: string; + column: string; + definition?: Record; + }> = []; + const queryInterface: MigrationQueryInterface = { + removeColumn(table, column) { + calls.push({ operation: 'remove', table, column }); + return Promise.resolve(); + }, + addColumn(table, column, definition) { + calls.push({ operation: 'add', table, column, definition }); + return Promise.resolve(); + }, + }; + const booleanType = Symbol('BOOLEAN'); + const sequelize = { BOOLEAN: booleanType }; + + await loadedMigration.up(queryInterface, sequelize); + await loadedMigration.down(queryInterface, sequelize); + + assert.deepEqual(calls, [ + { + operation: 'remove', + table: 'tour_pages', + column: 'background_audio_autoplay', + }, + { + operation: 'add', + table: 'tour_pages', + column: 'background_audio_autoplay', + definition: { + type: booleanType, + allowNull: false, + defaultValue: true, + }, + }, + ]); +}); diff --git a/backend/tests/integration/background-audio-schema.test.ts b/backend/tests/integration/background-audio-schema.test.ts new file mode 100644 index 0000000..80ab9c3 --- /dev/null +++ b/backend/tests/integration/background-audio-schema.test.ts @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { QueryTypes } from 'sequelize'; + +import db from '../../src/db/models/index.ts'; + +void test.after(async () => { + await db.sequelize.close(); +}); + +void test('tour_pages schema contains only active background audio settings', async (t) => { + try { + await db.sequelize.authenticate(); + } catch (error) { + const message = error instanceof Error ? error.message : 'unknown error'; + t.skip(`Database unavailable: ${message}`); + return; + } + + const columnRows = await db.sequelize.query<{ column_name: string }>( + `SELECT column_name + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'tour_pages'`, + { type: QueryTypes.SELECT }, + ); + const columns = new Set(columnRows.map((row) => row.column_name)); + + assert.equal(columns.has('background_audio_autoplay'), false); + assert.ok(columns.has('background_audio_loop')); + assert.ok(columns.has('background_audio_start_time')); + assert.ok(columns.has('background_audio_end_time')); +}); diff --git a/backend/tests/integration/publish-service.test.ts b/backend/tests/integration/publish-service.test.ts index 168a5c2..f3f5a09 100644 --- a/backend/tests/integration/publish-service.test.ts +++ b/backend/tests/integration/publish-service.test.ts @@ -91,6 +91,16 @@ interface TourPageSeed { name: string; slug: string; sortOrder: number; + backgroundVideoStartTime?: number | null; + backgroundVideoEndTime?: number | null; + backgroundVideoAutoplay?: boolean; + backgroundVideoLoop?: boolean; + backgroundVideoMuted?: boolean; + backgroundAudioLoop?: boolean; + backgroundAudioStartTime?: number | null; + backgroundAudioEndTime?: number | null; + designWidth?: number | null; + designHeight?: number | null; } function createTourPage(data: TourPageSeed, transaction: Transaction) { @@ -106,18 +116,17 @@ function createTourPage(data: TourPageSeed, transaction: Transaction) { background_video_url: null, background_embed_url: null, background_audio_url: null, - background_audio_autoplay: true, - background_audio_loop: true, - background_audio_start_time: null, - background_audio_end_time: null, + background_audio_loop: data.backgroundAudioLoop ?? true, + background_audio_start_time: data.backgroundAudioStartTime ?? null, + background_audio_end_time: data.backgroundAudioEndTime ?? null, background_loop: false, - background_video_autoplay: true, - background_video_loop: true, - background_video_muted: true, - background_video_start_time: null, - background_video_end_time: null, - design_width: null, - design_height: null, + background_video_autoplay: data.backgroundVideoAutoplay ?? true, + background_video_loop: data.backgroundVideoLoop ?? true, + background_video_muted: data.backgroundVideoMuted ?? true, + background_video_start_time: data.backgroundVideoStartTime ?? null, + background_video_end_time: data.backgroundVideoEndTime ?? null, + design_width: data.designWidth ?? null, + design_height: data.designHeight ?? null, requires_auth: false, ui_schema_json: { elements: [{ id: `${data.slug}-button`, type: 'button' }], @@ -259,6 +268,16 @@ void test('copyDevToStage replaces stage content with dev pages, audio, and sett name: 'Dev Lobby', slug: 'lobby', sortOrder: 1, + backgroundVideoAutoplay: false, + backgroundVideoLoop: false, + backgroundVideoMuted: false, + backgroundVideoStartTime: 2, + backgroundVideoEndTime: 12.5, + backgroundAudioLoop: false, + backgroundAudioStartTime: 1.5, + backgroundAudioEndTime: 8, + designWidth: 1920, + designHeight: 1080, }, transaction, ); @@ -357,6 +376,16 @@ void test('copyDevToStage replaces stage content with dev pages, audio, and sett assert.deepEqual(copiedPage.ui_schema_json, { elements: [{ id: 'lobby-button', type: 'button' }], }); + assert.equal(copiedPage.background_video_autoplay, false); + assert.equal(copiedPage.background_video_loop, false); + assert.equal(copiedPage.background_video_muted, false); + assert.equal(Number(copiedPage.background_video_start_time), 2); + assert.equal(Number(copiedPage.background_video_end_time), 12.5); + assert.equal(copiedPage.background_audio_loop, false); + assert.equal(Number(copiedPage.background_audio_start_time), 1.5); + assert.equal(Number(copiedPage.background_audio_end_time), 8); + assert.equal(copiedPage.design_width, 1920); + assert.equal(copiedPage.design_height, 1080); const stageAudios = await db.project_audio_tracks.findAll({ where: { projectId: project.id, environment: 'stage' }, @@ -405,6 +434,16 @@ void test('copyStageToProduction leaves stage content intact while replacing pro name: 'Stage Intro', slug: 'intro', sortOrder: 1, + backgroundVideoAutoplay: false, + backgroundVideoLoop: false, + backgroundVideoMuted: false, + backgroundVideoStartTime: 2, + backgroundVideoEndTime: 12.5, + backgroundAudioLoop: false, + backgroundAudioStartTime: 1.5, + backgroundAudioEndTime: 8, + designWidth: 1920, + designHeight: 1080, }, transaction, ); @@ -446,5 +485,15 @@ void test('copyStageToProduction leaves stage content intact while replacing pro assert.ok(productionPage); assert.notEqual(productionPage.id, sourcePage.id); assert.equal(productionPage.source_key, sourcePage.id); + assert.equal(productionPage.background_video_autoplay, false); + assert.equal(productionPage.background_video_loop, false); + assert.equal(productionPage.background_video_muted, false); + assert.equal(Number(productionPage.background_video_start_time), 2); + assert.equal(Number(productionPage.background_video_end_time), 12.5); + assert.equal(productionPage.background_audio_loop, false); + assert.equal(Number(productionPage.background_audio_start_time), 1.5); + assert.equal(Number(productionPage.background_audio_end_time), 8); + assert.equal(productionPage.design_width, 1920); + assert.equal(productionPage.design_height, 1080); }); }); diff --git a/backend/tests/openapi-document.test.ts b/backend/tests/openapi-document.test.ts index 607df65..e8e73e9 100644 --- a/backend/tests/openapi-document.test.ts +++ b/backend/tests/openapi-document.test.ts @@ -134,6 +134,37 @@ void test('OpenAPI documents the completion-confirmed publishing result', () => }); }); +void test('OpenAPI documents public tour-page playback fields', () => { + const document = createTestDocument(); + const tourPageProperties = document.components.schemas.TourPage?.properties; + const listOperation = document.paths['/api/tour_pages']?.get; + + assert.ok( + typeof tourPageProperties === 'object' && tourPageProperties !== null, + ); + for (const field of [ + 'background_audio_loop', + 'background_audio_start_time', + 'background_audio_end_time', + 'background_video_autoplay', + 'background_video_loop', + 'background_video_muted', + 'background_video_start_time', + 'background_video_end_time', + 'design_width', + 'design_height', + ]) { + assert.ok(field in tourPageProperties, `Missing TourPage field: ${field}`); + } + assert.equal('background_audio_autoplay' in tourPageProperties, false); + + assert.deepEqual(listOperation?.security, [{ bearerAuth: [] }, {}]); + assert.match( + String(listOperation?.description), + /Public responses retain background media URLs/, + ); +}); + void test('OpenAPI factory CRUD paths are generated consistently', () => { const document = createTestDocument(); const resourcePath = '/api/assets'; diff --git a/backend/tests/runtime-public.test.ts b/backend/tests/runtime-public.test.ts index f0a3efc..a555424 100644 --- a/backend/tests/runtime-public.test.ts +++ b/backend/tests/runtime-public.test.ts @@ -120,6 +120,18 @@ void test('sanitizePublicRuntimeListResponse strips non-public fields from rows count: 1, }); + const runtimePagePresentationFields = { + background_audio_loop: false, + background_audio_start_time: '1.5', + background_audio_end_time: '8.0', + background_video_autoplay: false, + background_video_loop: false, + background_video_muted: false, + background_video_start_time: '2.0', + background_video_end_time: '12.5', + design_width: 1920, + design_height: 1080, + }; const recordReq = createRequest({ method: 'GET', url: '/', @@ -135,6 +147,7 @@ void test('sanitizePublicRuntimeListResponse strips non-public fields from rows recordRes.send({ id: 'page-1', slug: 'intro', + ...runtimePagePresentationFields, ui_schema_json: { elements: [] }, internal_note: 'do-not-leak', }); @@ -142,6 +155,7 @@ void test('sanitizePublicRuntimeListResponse strips non-public fields from rows assert.deepEqual(recordRes._getData(), { id: 'page-1', slug: 'intro', + ...runtimePagePresentationFields, ui_schema_json: { elements: [] }, }); }); diff --git a/documentation/api-reference.md b/documentation/api-reference.md index 884ca97..101563a 100644 --- a/documentation/api-reference.md +++ b/documentation/api-reference.md @@ -684,6 +684,17 @@ Create a tour page. "environment": "dev", "background_image_url": "https://...", "background_video_url": "https://...", + "background_video_autoplay": true, + "background_video_loop": false, + "background_video_muted": false, + "background_video_start_time": 2, + "background_video_end_time": 30, + "background_audio_url": "https://...", + "background_audio_loop": true, + "background_audio_start_time": 1.5, + "background_audio_end_time": 12, + "design_width": 1920, + "design_height": 1080, "ui_schema_json": {} } } @@ -699,6 +710,12 @@ List tour pages. - `projectId` - Filter by project - `environment` - Filter by environment +For an unauthenticated production runtime read, the response sanitizer keeps +the media URLs, video autoplay/loop/muted/start/end settings, audio +loop/start/end settings, page design dimensions, UI schema, and page-level +global UI controls. It strips administrative fields that are not part of +presentation playback. + ### GET /api/tour_pages/:id Get tour page by ID. diff --git a/documentation/audio-playback.md b/documentation/audio-playback.md index 136cb68..431c73f 100644 --- a/documentation/audio-playback.md +++ b/documentation/audio-playback.md @@ -48,7 +48,6 @@ Page-level ambient audio that plays behind all content with configurable playbac // Using useBackgroundAudioPlayback hook for controlled playback const { audioRef } = useBackgroundAudioPlayback({ audioUrl: page.background_audio_url, - autoplay: page.background_audio_autoplay ?? true, loop: page.background_audio_loop ?? true, startTime: page.background_audio_start_time ?? null, endTime: page.background_audio_end_time ?? null, @@ -61,7 +60,6 @@ const { audioRef } = useBackgroundAudioPlayback({ | Property | Type | Default | Description | |----------|------|---------|-------------| -| `background_audio_autoplay` | boolean | true | Start playback automatically | | `background_audio_loop` | boolean | true | Loop continuously | | `background_audio_start_time` | DECIMAL(10,1) \| null | null | Start playback at this time (seconds) | | `background_audio_end_time` | DECIMAL(10,1) \| null | null | Stop/loop at this time (seconds) | @@ -70,6 +68,9 @@ const { audioRef } = useBackgroundAudioPlayback({ **Note:** When `background_audio_end_time` is set, looping is handled via JavaScript (`timeupdate` event) to properly seek back to `startTime`. +There is no page-level autoplay setting. Background audio follows the runtime +interaction/unlock flow; page settings control only loop/start/end behavior. + ### 2. Element Audio Effects Interactive audio triggered by user hover and click on UI elements. @@ -292,7 +293,6 @@ The Constructor provides UI controls for background audio settings via `Backgrou value={backgroundAudioUrl} options={audioAssetOptions} onChange={setBackgroundAudioUrl} - audioAutoplay={pageBackground.audioSettings.autoplay} audioLoop={pageBackground.audioSettings.loop} audioStartTime={pageBackground.audioSettings.startTime} audioEndTime={pageBackground.audioSettings.endTime} @@ -304,7 +304,6 @@ The Constructor provides UI controls for background audio settings via `Backgrou | Setting | Control | Description | |---------|---------|-------------| -| Autoplay | Checkbox | Start audio automatically on page load | | Loop | Checkbox | Continuously loop audio | | Start Time | Number input | Begin playback at specific time (seconds) | | End Time | Number input | Stop/loop at specific time (seconds) | @@ -324,7 +323,6 @@ Passes audio settings to CanvasBackground for playback: const backgroundAudioUrl = navCurrentBgAudioUrl; // Extract settings from selected page -const audioAutoplay = selectedPage?.background_audio_autoplay ?? true; const audioLoop = selectedPage?.background_audio_loop ?? true; const audioStartTime = selectedPage?.background_audio_start_time != null ? parseFloat(String(selectedPage.background_audio_start_time)) @@ -336,7 +334,6 @@ const audioEndTime = selectedPage?.background_audio_end_time != null // Pass to CanvasBackground