573 lines
22 KiB
Markdown
573 lines
22 KiB
Markdown
# Page Navigation
|
|
|
|
Documentation for the Tour Builder Platform's page navigation system using element-based navigation stored in `ui_schema_json`.
|
|
|
|
## Overview
|
|
|
|
The platform uses a simplified navigation system where navigation configuration is stored directly in `tour_pages.ui_schema_json` as part of element definitions. This approach:
|
|
- Eliminates ID remapping issues when publishing between environments
|
|
- Uses page slugs instead of UUIDs for cross-environment consistency
|
|
- Stores transition video URLs directly on navigation elements
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
|
│ Page Navigation Architecture │
|
|
│ │
|
|
│ ┌────────────────────────────────────────────────────────────────────────┐ │
|
|
│ │ Navigation Source │ │
|
|
│ │ │ │
|
|
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
|
|
│ │ │ tour_pages.ui_schema_json │ │ │
|
|
│ │ │ │ │ │
|
|
│ │ │ elements: [{ │ │ │
|
|
│ │ │ type: "navigation_next", │ │ │
|
|
│ │ │ targetPageSlug: "page-2", │ │ │
|
|
│ │ │ transitionVideoUrl: "assets/.../video.mp4", │ │ │
|
|
│ │ │ ... │ │ │
|
|
│ │ │ }] │ │ │
|
|
│ │ └─────────────────────────────────────────────────────────┘ │ │
|
|
│ │ │ │ │
|
|
│ │ ▼ │ │
|
|
│ │ ┌───────────────────────────────┐ │ │
|
|
│ │ │ Navigation Resolution │ │ │
|
|
│ │ │ │ │ │
|
|
│ │ │ • Resolve slug to page │ │ │
|
|
│ │ │ • Determine direction │ │ │
|
|
│ │ │ • Get preloaded blob URL │ │ │
|
|
│ │ └───────────────┬───────────────┘ │ │
|
|
│ │ │ │ │
|
|
│ │ ┌───────────────┴───────────────┐ │ │
|
|
│ │ ▼ ▼ │ │
|
|
│ │ ┌──────────────────┐ ┌──────────────────┐ │ │
|
|
│ │ │ With Transition │ │ Direct Navigate │ │ │
|
|
│ │ │ │ │ │ │ │
|
|
│ │ │ Play video → │ │ Switch page → │ │ │
|
|
│ │ │ Switch page │ │ Update history │ │ │
|
|
│ │ └──────────────────┘ └──────────────────┘ │ │
|
|
│ └────────────────────────────────────────────────────────────────────────┘ │
|
|
└─────────────────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
## Data Model
|
|
|
|
### Navigation in ui_schema_json
|
|
|
|
Navigation is configured directly within element objects in the `tour_pages.ui_schema_json` field:
|
|
|
|
```typescript
|
|
// Element with navigation configuration
|
|
{
|
|
id: "element-uuid",
|
|
type: "navigation_next", // or "navigation_prev"
|
|
label: "Next Page Button",
|
|
|
|
// Position
|
|
xPercent: 90,
|
|
yPercent: 85,
|
|
|
|
// Navigation Configuration
|
|
navigationTargetMode: "target_page", // "target_page" or "external_url"
|
|
targetPageSlug: "page-2", // Slug-based navigation (consistent across environments)
|
|
externalUrl: undefined, // Used when navigationTargetMode is "external_url"
|
|
transitionVideoUrl: "assets/transitions/fade.mp4",
|
|
transitionDurationSec: 0.7,
|
|
transitionReverseMode: "auto_reverse", // or "separate_video"
|
|
reverseVideoUrl: undefined, // Only used with "separate_video" mode
|
|
|
|
// Element Styling
|
|
iconUrl: "assets/icons/arrow-right.png",
|
|
// ... extends ElementStyleProperties
|
|
}
|
|
```
|
|
|
|
### Key Fields
|
|
|
|
| Field | Type | Description |
|
|
|-------|------|-------------|
|
|
| `type` | string | `navigation_next` or `navigation_prev` determines direction |
|
|
| `navigationTargetMode` | `'target_page'` \| `'external_url'` | Destination mode for forward navigation buttons. Defaults to `target_page` |
|
|
| `targetPageSlug` | string | Target page slug (NOT UUID) for cross-environment consistency |
|
|
| `externalUrl` | string | External URL opened in a new tab when `navigationTargetMode` is `external_url` |
|
|
| `transitionVideoUrl` | string | URL to transition video (optional) |
|
|
| `transitionDurationSec` | number | Duration in seconds (optional) |
|
|
| `transitionReverseMode` | `'auto_reverse'` \| `'separate_video'` | Reverse playback mode (replaces deprecated `supportsReverse`) |
|
|
| `reverseVideoUrl` | string | Separate video URL for reverse playback (when mode is `separate_video`) |
|
|
|
|
### Why Slugs Instead of UUIDs
|
|
|
|
Previous versions used `targetPageId` (UUID) which caused issues:
|
|
- When pages are copied between environments (dev → stage → production), UUIDs change
|
|
- References to old UUIDs became invalid after publishing
|
|
|
|
The slug-based approach solves this:
|
|
- Slugs are unique within project+environment
|
|
- Slugs remain identical across environments (same page has same slug in dev/stage/prod)
|
|
- No ID remapping needed during publish
|
|
|
|
## Navigation Types
|
|
|
|
### Forward Navigation (`navigation_next`)
|
|
|
|
Navigates to a specified target page with optional transition.
|
|
|
|
```typescript
|
|
{
|
|
type: "navigation_next",
|
|
navigationTargetMode: "target_page",
|
|
targetPageSlug: "gallery",
|
|
transitionVideoUrl: "assets/transitions/zoom-in.mp4",
|
|
transitionReverseMode: "auto_reverse"
|
|
}
|
|
```
|
|
|
|
**Behavior:**
|
|
- Target page resolved by slug
|
|
- Transition video plays forward if specified
|
|
- Page added to navigation history
|
|
|
|
### External URL Navigation (`navigation_next`)
|
|
|
|
Forward navigation buttons can open an external URL instead of targeting a tour page:
|
|
|
|
```typescript
|
|
{
|
|
type: "navigation_next",
|
|
navType: "forward",
|
|
navigationTargetMode: "external_url",
|
|
externalUrl: "https://example.com"
|
|
}
|
|
```
|
|
|
|
**Behavior:**
|
|
- The button is treated as forward navigation
|
|
- Target page selection is disabled and `targetPageSlug` / `targetPageId` are cleared
|
|
- The URL opens in a new tab with `noopener,noreferrer`
|
|
- If the URL omits `http://` or `https://`, the runtime opens it with an `https://` prefix
|
|
- External URL buttons are not included in internal page-link extraction, neighbor preloading, or reversed transition video generation
|
|
|
|
### Back Navigation (`navigation_prev`)
|
|
|
|
Returns to previous page with optional reverse transition.
|
|
|
|
```typescript
|
|
{
|
|
type: "navigation_prev",
|
|
targetPageSlug: "home", // Optional - can use history
|
|
transitionVideoUrl: "assets/transitions/zoom-in.mp4",
|
|
transitionReverseMode: "auto_reverse" // Plays in reverse for back nav
|
|
}
|
|
```
|
|
|
|
**Alternative with separate reverse video:**
|
|
|
|
```typescript
|
|
{
|
|
type: "navigation_prev",
|
|
targetPageSlug: "home",
|
|
transitionVideoUrl: "assets/transitions/zoom-in.mp4",
|
|
transitionReverseMode: "separate_video",
|
|
reverseVideoUrl: "assets/transitions/zoom-out.mp4"
|
|
}
|
|
```
|
|
|
|
**Behavior:**
|
|
- Target page optional (uses page history)
|
|
- If `transitionReverseMode = 'auto_reverse'`, video plays in reverse
|
|
- If `transitionReverseMode = 'separate_video'`, uses `reverseVideoUrl` instead
|
|
- Page popped from navigation history
|
|
|
|
## Navigation Flow
|
|
|
|
### Runtime Resolution
|
|
|
|
**Files:**
|
|
- `frontend/src/components/RuntimePresentation.tsx`
|
|
- `frontend/src/lib/navigationHelpers.ts`
|
|
|
|
Navigation uses `resolveNavigationTarget` helper and `usePageSwitch` hook:
|
|
|
|
```typescript
|
|
import { resolveNavigationTarget, isTransitionBlocking } from '../lib/navigationHelpers';
|
|
|
|
// Extract navigation links from pages
|
|
const { pageLinks, preloadElements } = extractPageLinksAndElements(pages);
|
|
|
|
// Initialize preload orchestrator
|
|
const preloadOrchestrator = usePreloadOrchestrator({
|
|
pages,
|
|
pageLinks,
|
|
elements: preloadElements,
|
|
currentPageId: selectedPageId,
|
|
enabled: !isLoading,
|
|
});
|
|
|
|
// Initialize page switch with preload cache
|
|
const pageSwitch = usePageSwitch({
|
|
preloadCache: {
|
|
getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl, // O(1) instant lookup
|
|
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
|
|
preloadedUrls: preloadOrchestrator.preloadedUrls,
|
|
},
|
|
});
|
|
|
|
// Handle navigation element click
|
|
const handleElementClick = useCallback((element) => {
|
|
// Block navigation during active transitions
|
|
if (isTransitionBlocking(transitionPhase, isBuffering)) {
|
|
return;
|
|
}
|
|
|
|
// Resolve target page (supports both slug and legacy ID)
|
|
const navTarget = resolveNavigationTarget(element, pages);
|
|
if (!navTarget) return;
|
|
|
|
// Navigate with transition if configured
|
|
if (element.transitionVideoUrl) {
|
|
// Start transition playback, then switch page on completion
|
|
startTransition({
|
|
videoUrl: element.transitionVideoUrl,
|
|
storageKey: element.transitionVideoUrl, // Raw path for cache lookup
|
|
reverseMode: navTarget.isBack ? getReverseMode(element) : 'none',
|
|
reverseVideoUrl: element.reverseVideoUrl,
|
|
targetPageId: navTarget.pageId,
|
|
});
|
|
} else {
|
|
// Direct navigation (no transition)
|
|
pageSwitch.switchToPage(navTarget.page, () => {
|
|
setSelectedPageId(navTarget.pageId);
|
|
});
|
|
}
|
|
}, [pages, pageSwitch, transitionPhase, isBuffering]);
|
|
```
|
|
|
|
**Navigation Target Resolution (`navigationHelpers.ts`):**
|
|
|
|
```typescript
|
|
// Supports both targetPageSlug (preferred) and targetPageId (legacy)
|
|
export const resolveNavigationTarget = (element, pages) => {
|
|
let targetPage;
|
|
if (element.targetPageSlug) {
|
|
targetPage = pages.find(p => p.slug === element.targetPageSlug);
|
|
} else if (element.targetPageId) {
|
|
targetPage = pages.find(p => p.id === element.targetPageId);
|
|
}
|
|
if (!targetPage) return null;
|
|
|
|
const isBack = element.navType === 'back' || element.type === 'navigation_prev';
|
|
return {
|
|
page: targetPage,
|
|
pageId: targetPage.id,
|
|
transitionVideoUrl: element.transitionVideoUrl,
|
|
isBack,
|
|
};
|
|
};
|
|
```
|
|
|
|
### Page History Management
|
|
|
|
Page history is managed by the shared `usePageNavigation` hook (used by both RuntimePresentation and constructor):
|
|
|
|
```typescript
|
|
import { usePageNavigation } from '../hooks/usePageNavigation';
|
|
|
|
// Hook provides unified history management with browser-like behavior
|
|
const {
|
|
currentPageId: selectedPageId,
|
|
pageHistory,
|
|
previousPageId,
|
|
applyPageSelection,
|
|
getNavigationContext,
|
|
} = usePageNavigation({
|
|
pages,
|
|
defaultPageId: initialPageId,
|
|
trackHistory: true,
|
|
});
|
|
|
|
// applyPageSelection handles history automatically:
|
|
// - Forward (isBack=false): appends to history, trimmed to MAX_HISTORY_LENGTH=50
|
|
// - Back (isBack=true): pops from history if target matches previousPageId
|
|
applyPageSelection(targetPageId, isBack);
|
|
|
|
// getNavigationContext provides context for history-based back navigation
|
|
const navContext = getNavigationContext();
|
|
// Returns: { currentPageSlug, previousPageId }
|
|
const navTarget = resolveNavigationTarget(element, pages, navContext);
|
|
```
|
|
|
|
**History Behavior:**
|
|
```
|
|
Forward: A → B → C → history: [A, B, C]
|
|
Back to B (isBack=true): → history: [A, B] ✓ Pops C
|
|
Back to A (isBack=true): → history: [A] ✓ Pops B
|
|
Forward to D: → history: [A, D] ✓ Adds D
|
|
```
|
|
|
|
## Transition Playback
|
|
|
|
### With Video Transition
|
|
|
|
```typescript
|
|
// useTransitionPlayback handles video playback with preload cache integration
|
|
const { phase, isBuffering, isReversing, cancel, forceComplete } = useTransitionPlayback({
|
|
videoRef,
|
|
transition: transitionConfig, // { videoUrl, storageKey, reverseMode, reverseVideoUrl, targetPageId, isBack }
|
|
// onComplete receives isBack flag for proper history management
|
|
onComplete: (targetPageId, isBack) => {
|
|
// Transition finished, switch to target page
|
|
pageSwitch.switchToPage(targetPage, () => {
|
|
// usePageNavigation hook: pops history on back, appends on forward
|
|
applyPageSelection(targetPageId, isBack ?? false);
|
|
});
|
|
},
|
|
preload: {
|
|
preloadedUrls: preloadOrchestrator.preloadedUrls,
|
|
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
|
|
getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl,
|
|
},
|
|
});
|
|
```
|
|
|
|
**Storage Key Mapping:**
|
|
The `storageKey` (raw storage path like `assets/project/transition.mp4`) is preserved for cache lookup because presigned URL signatures change on each resolution. The lookup priority is:
|
|
1. `getReadyBlobUrl(storageKey)` → O(1) instant (same session)
|
|
2. `getCachedBlobUrl(storageKey)` → Cache API (~5ms, post-refresh)
|
|
3. Fallback to resolved URL lookup
|
|
|
|
### Reverse Playback
|
|
|
|
When navigating back with `transitionReverseMode = 'auto_reverse'`:
|
|
- Video plays from end to beginning using `useReversePlayback` hook
|
|
- Supports native `playbackRate = -1` or frame-stepping fallback
|
|
|
|
```typescript
|
|
const {
|
|
startReverse,
|
|
stopReverse,
|
|
isReversing,
|
|
isBuffering,
|
|
canUseNativeReverse,
|
|
} = useReversePlayback({
|
|
videoRef,
|
|
onComplete: finishOverlayTransition,
|
|
preloadedUrls,
|
|
videoUrl,
|
|
getCachedBlobUrl,
|
|
});
|
|
|
|
// For back navigation with auto_reverse mode
|
|
if (isBack && transitionReverseMode === 'auto_reverse') {
|
|
startReverse();
|
|
}
|
|
```
|
|
|
|
**Reverse Mode Options:**
|
|
|
|
| Mode | Behavior |
|
|
|------|----------|
|
|
| `none` | No transition on back navigation |
|
|
| `auto_reverse` | Same video plays in reverse |
|
|
| `separate_video` | Uses `reverseVideoUrl` for back navigation |
|
|
|
|
## Constructor Configuration
|
|
|
|
### Setting Up Navigation
|
|
|
|
**File:** `frontend/src/pages/constructor.tsx`
|
|
|
|
When creating/editing navigation elements:
|
|
|
|
1. Select element type (`navigation_next` or `navigation_prev`)
|
|
2. Choose target page from dropdown (shows page names/slugs)
|
|
3. Optionally select transition video from assets
|
|
4. Configure transition settings (duration, reverse support)
|
|
|
|
```typescript
|
|
// Saving navigation element
|
|
const elementData = {
|
|
type: selectedElementType,
|
|
targetPageSlug: targetPage?.slug, // Save slug, not ID
|
|
transitionVideoUrl: selectedTransitionVideo?.cdn_url,
|
|
transitionDurationSec: transitionDuration,
|
|
transitionReverseMode: supportsReverse ? 'auto_reverse' : undefined,
|
|
reverseVideoUrl: reverseMode === 'separate_video' ? reverseVideo?.cdn_url : undefined,
|
|
};
|
|
```
|
|
|
|
### Extracting Navigation Links for Preloading
|
|
|
|
**File:** `frontend/src/lib/extractPageLinks.ts`
|
|
|
|
The `extractPageLinksAndElements` utility extracts navigation targets and preloadable elements from pages:
|
|
|
|
```typescript
|
|
import { extractPageLinksAndElements } from '../lib/extractPageLinks';
|
|
|
|
// Extract from all pages (filtered by environment)
|
|
const filteredPages = pages.filter(p => p.environment === environment);
|
|
const { pageLinks, preloadElements } = extractPageLinksAndElements(filteredPages);
|
|
|
|
// pageLinks: Array of navigation connections
|
|
// [{ sourcePageId, targetPageSlug, transitionVideoUrl, supportsReverse }]
|
|
|
|
// preloadElements: Array of elements with preloadable assets
|
|
// [{ pageId, type, imageUrl, videoUrl, transitionVideoUrl, ... }]
|
|
```
|
|
|
|
### Building Neighbor Graph
|
|
|
|
**File:** `frontend/src/hooks/useNeighborGraph.ts`
|
|
|
|
The neighbor graph is built from pageLinks for preload prioritization:
|
|
|
|
```typescript
|
|
const neighborGraph = useNeighborGraph({
|
|
pages: filteredPages,
|
|
pageLinks, // From extractPageLinksAndElements
|
|
currentPageId,
|
|
maxDepth: 1, // Only immediate neighbors (reduced from 2)
|
|
});
|
|
|
|
// Returns pages reachable within maxDepth hops
|
|
const neighborsToPreload = neighborGraph.getNeighbors(currentPageId);
|
|
```
|
|
|
|
**Preload Priority for Navigation Assets:**
|
|
| Asset Type | Priority | Notes |
|
|
|------------|----------|-------|
|
|
| Transition video | +150 | Highest - needed immediately on click |
|
|
| Background image | +100 | Required for page display |
|
|
| Audio | +50 | Background audio tracks |
|
|
| Video | +30 | Can stream, lower priority |
|
|
|
|
## TypeScript Types
|
|
|
|
**File:** `frontend/src/types/constructor.ts`
|
|
|
|
```typescript
|
|
// Navigation-related fields in CanvasElement
|
|
interface CanvasElement extends BaseCanvasElement {
|
|
id: string;
|
|
type: CanvasElementType; // 'navigation_next' | 'navigation_prev' | ...
|
|
label: string;
|
|
|
|
// Position
|
|
xPercent: number;
|
|
yPercent: number;
|
|
|
|
// Navigation (for navigation_next, navigation_prev types)
|
|
navType?: NavigationButtonKind; // 'forward' | 'back'
|
|
navDisabled?: boolean;
|
|
/** @deprecated Use targetPageSlug instead */
|
|
targetPageId?: string;
|
|
targetPageSlug?: string;
|
|
transitionVideoUrl?: string;
|
|
transitionReverseMode?: 'auto_reverse' | 'separate_video';
|
|
reverseVideoUrl?: string;
|
|
transitionDurationSec?: number;
|
|
|
|
// Styling
|
|
iconUrl?: string;
|
|
// ... extends ElementStyleProperties for CSS styling
|
|
}
|
|
|
|
// Navigation button direction type
|
|
type NavigationButtonKind = 'forward' | 'back';
|
|
```
|
|
|
|
**File:** `frontend/src/types/presentation.ts`
|
|
|
|
```typescript
|
|
// Navigation target resolved from element
|
|
interface NavigationTarget {
|
|
page: RuntimePage;
|
|
pageId: string;
|
|
transitionVideoUrl?: string;
|
|
isBack: boolean;
|
|
}
|
|
|
|
// Element with navigation properties (for click handling)
|
|
interface NavigableElement {
|
|
id: string;
|
|
type: string;
|
|
targetPageSlug?: string;
|
|
targetPageId?: string; // Legacy
|
|
transitionVideoUrl?: string;
|
|
navType?: 'forward' | 'back';
|
|
navDisabled?: boolean;
|
|
}
|
|
|
|
// Transition phase states
|
|
type TransitionPhase = 'idle' | 'preparing' | 'playing' | 'reversing' | 'completed';
|
|
```
|
|
|
|
## File References
|
|
|
|
| File | Purpose |
|
|
|------|---------|
|
|
| `frontend/src/pages/constructor.tsx` | Navigation element configuration |
|
|
| `frontend/src/components/RuntimePresentation.tsx` | Runtime navigation execution |
|
|
| `frontend/src/lib/extractPageLinks.ts` | Extract navigation links and preload elements from pages |
|
|
| `frontend/src/lib/navigationHelpers.ts` | Navigation target resolution and direction detection |
|
|
| `frontend/src/hooks/usePageSwitch.ts` | Page navigation with preloaded blob URLs |
|
|
| `frontend/src/hooks/usePreloadOrchestrator.ts` | Asset preloading with ready blob URL management |
|
|
| `frontend/src/hooks/usePageNavigation.ts` | History and page state management |
|
|
| `frontend/src/hooks/useNeighborGraph.ts` | Preload graph from navigation |
|
|
| `frontend/src/hooks/useReversePlayback.ts` | Reverse video playback (native or frame-stepping) |
|
|
| `frontend/src/hooks/useTransitionPlayback.ts` | Transition video playback with preloaded URLs |
|
|
| `frontend/src/types/constructor.ts` | Element type definitions |
|
|
| `frontend/src/types/presentation.ts` | Navigation target and element interfaces |
|
|
| `frontend/src/config/preload.config.ts` | Preload priority weights and settings |
|
|
|
|
`extractPageLinks.ts` also extracts nested Info Panel `target_page` destinations from `infoPanelSections`:
|
|
- section-level header/title/text click destinations
|
|
- span item click destinations
|
|
- image/card/video/360 item click destinations
|
|
|
|
External URL destinations are not added to the neighbor graph. Info Panel nested media URLs (`imageUrl`, `videoUrl`, `iconUrl`, and header images) are recursively extracted into preload elements.
|
|
|
|
## Environment Filtering
|
|
|
|
Pages are filtered by environment before navigation resolution:
|
|
|
|
```typescript
|
|
// Filter pages by current environment (dev, stage, production)
|
|
const filteredPages = pages.filter(p => p.environment === environment);
|
|
|
|
// Extract navigation links only from same-environment pages
|
|
const { pageLinks, preloadElements } = extractPageLinksAndElements(filteredPages);
|
|
|
|
// Navigation only resolves within the same environment
|
|
const targetPage = filteredPages.find(p => p.slug === element.targetPageSlug);
|
|
```
|
|
|
|
**Environment Contexts:**
|
|
| Context | Environment | Notes |
|
|
|---------|-------------|-------|
|
|
| Constructor | `dev` | Editing/preview mode |
|
|
| Stage preview | `stage` | Pre-production review |
|
|
| Public runtime | `production` | Published tour playback |
|
|
|
|
Navigation links pointing to slugs that don't exist in the current environment will fail silently.
|
|
|
|
## Known Considerations
|
|
|
|
### 1. Slug Uniqueness
|
|
|
|
Slugs must be unique within project+environment. The system validates this on page creation/update.
|
|
|
|
### 2. Missing Target Pages
|
|
|
|
If `targetPageSlug` references a non-existent page in the current environment, navigation silently fails. UI should handle gracefully.
|
|
|
|
### 3. Transition Reverse Support
|
|
|
|
Not all transitions look good in reverse. Use `transitionReverseMode: 'separate_video'` with a dedicated `reverseVideoUrl` for directional animations, or omit reverse mode entirely.
|
|
|
|
### 4. Preloading
|
|
|
|
Navigation elements drive preloading. Transition videos have highest priority (+150) and are preloaded first. The neighbor graph uses `maxDepth: 1` (immediate neighbors only).
|
|
|
|
### 5. Instant Navigation with Preloaded Assets
|
|
|
|
When assets are preloaded, `usePageSwitch` uses `getReadyBlobUrl(storageKey)` for O(1) instant lookup of pre-decoded blob URLs, eliminating any delay or flash during navigation. Lookups prioritize storage keys (e.g., `assets/project/bg.jpg`) over resolved URLs because storage keys are canonical and don't change when presigned URLs are regenerated.
|