Autosave: 20260319-050018
This commit is contained in:
parent
d4821b6a5d
commit
08ac54f0b5
@ -76,12 +76,16 @@ type NavigationElementType = Extract<
|
||||
'navigation_next' | 'navigation_prev'
|
||||
>;
|
||||
|
||||
type NavigationButtonKind = 'forward' | 'back';
|
||||
|
||||
type CanvasElement = {
|
||||
id: string;
|
||||
type: CanvasElementType;
|
||||
label: string;
|
||||
xPercent: number;
|
||||
yPercent: number;
|
||||
appearDelaySec?: number;
|
||||
appearDurationSec?: number | null;
|
||||
iconUrl?: string;
|
||||
galleryCards?: GalleryCard[];
|
||||
carouselSlides?: CarouselSlide[];
|
||||
@ -92,6 +96,7 @@ type CanvasElement = {
|
||||
descriptionTitle?: string;
|
||||
descriptionText?: string;
|
||||
navLabel?: string;
|
||||
navType?: NavigationButtonKind;
|
||||
targetPageId?: string;
|
||||
transitionVideoUrl?: string;
|
||||
transitionReverseMode?: 'auto_reverse' | 'separate_video';
|
||||
@ -164,6 +169,19 @@ const parseJsonObject = <T,>(value?: unknown, fallback?: T): T => {
|
||||
const clamp = (value: number, min: number, max: number) =>
|
||||
Math.min(Math.max(value, min), max);
|
||||
|
||||
const normalizeAppearDelaySec = (value: unknown) => {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) return 0;
|
||||
return Number(parsed);
|
||||
};
|
||||
|
||||
const normalizeAppearDurationSec = (value: unknown) => {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
||||
return Number(parsed);
|
||||
};
|
||||
|
||||
const createLocalId = () => {
|
||||
if (typeof window !== 'undefined' && window.crypto?.randomUUID) {
|
||||
return window.crypto.randomUUID();
|
||||
@ -228,6 +246,18 @@ const extractS3ObjectKey = (value: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const formatDurationNote = (durationSec?: number | string | null) => {
|
||||
const parsed = Number(durationSec);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return 'Duration: unknown';
|
||||
|
||||
const totalSeconds = Math.round(parsed);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (minutes <= 0) return `Duration: ${seconds}s`;
|
||||
return `Duration: ${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const resolveAssetPlaybackUrl = (value?: string) => {
|
||||
const normalized = String(value || '').trim();
|
||||
if (!normalized) return '';
|
||||
@ -258,6 +288,87 @@ const resolveAssetPlaybackUrl = (value?: string) => {
|
||||
return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(normalizedPrivateUrl)}`;
|
||||
};
|
||||
|
||||
const readMediaDuration = (
|
||||
playbackUrl: string,
|
||||
mediaType: 'video' | 'audio',
|
||||
): Promise<number | null> =>
|
||||
new Promise((resolve) => {
|
||||
const mediaElement =
|
||||
mediaType === 'video'
|
||||
? document.createElement('video')
|
||||
: document.createElement('audio');
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const cleanup = () => {
|
||||
mediaElement.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
mediaElement.removeEventListener('error', onError);
|
||||
mediaElement.removeEventListener('abort', onError);
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
mediaElement.pause();
|
||||
mediaElement.removeAttribute('src');
|
||||
mediaElement.load();
|
||||
};
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
const duration = Number(mediaElement.duration);
|
||||
cleanup();
|
||||
if (Number.isFinite(duration) && duration > 0) {
|
||||
resolve(duration);
|
||||
return;
|
||||
}
|
||||
resolve(null);
|
||||
};
|
||||
|
||||
const onError = () => {
|
||||
cleanup();
|
||||
resolve(null);
|
||||
};
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
cleanup();
|
||||
resolve(null);
|
||||
}, 12000);
|
||||
|
||||
mediaElement.preload = 'metadata';
|
||||
mediaElement.crossOrigin = 'anonymous';
|
||||
mediaElement.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||
mediaElement.addEventListener('error', onError);
|
||||
mediaElement.addEventListener('abort', onError);
|
||||
mediaElement.src = playbackUrl;
|
||||
mediaElement.load();
|
||||
});
|
||||
|
||||
const resolveDurationWithFallback = async (
|
||||
source: string,
|
||||
mediaType: 'video' | 'audio',
|
||||
) => {
|
||||
const playbackUrl = resolveAssetPlaybackUrl(source);
|
||||
if (!playbackUrl) return null;
|
||||
|
||||
const directDuration = await readMediaDuration(playbackUrl, mediaType);
|
||||
if (Number.isFinite(directDuration) && Number(directDuration) > 0) {
|
||||
return Number(directDuration);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(playbackUrl, { responseType: 'blob' });
|
||||
const blobUrl = URL.createObjectURL(response.data);
|
||||
try {
|
||||
const blobDuration = await readMediaDuration(blobUrl, mediaType);
|
||||
if (Number.isFinite(blobDuration) && Number(blobDuration) > 0) {
|
||||
return Number(blobDuration);
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch media for duration probing:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const isBackgroundImageAsset = (asset: ProjectAsset) => {
|
||||
if (asset.type) return asset.type === 'background_image';
|
||||
const normalizedName = String(asset.name || '').toLowerCase();
|
||||
@ -306,6 +417,14 @@ const isNavigationElementType = (
|
||||
const getNavigationButtonLabel = (type: NavigationElementType) =>
|
||||
type === 'navigation_next' ? 'Forward' : 'Back';
|
||||
|
||||
const getNavigationButtonKind = (type: NavigationElementType): NavigationButtonKind =>
|
||||
type === 'navigation_prev' ? 'back' : 'forward';
|
||||
|
||||
const getNavigationTypeFromKind = (
|
||||
kind: NavigationButtonKind,
|
||||
): NavigationElementType =>
|
||||
kind === 'back' ? 'navigation_prev' : 'navigation_next';
|
||||
|
||||
const createDefaultElement = (
|
||||
type: CanvasElementType,
|
||||
index: number,
|
||||
@ -316,6 +435,8 @@ const createDefaultElement = (
|
||||
label: labelByType[type],
|
||||
xPercent: clamp(12 + index * 4, 5, 80),
|
||||
yPercent: clamp(16 + index * 6, 8, 85),
|
||||
appearDelaySec: 0,
|
||||
appearDurationSec: null,
|
||||
};
|
||||
|
||||
if (type === 'gallery') {
|
||||
@ -360,6 +481,7 @@ const createDefaultElement = (
|
||||
return {
|
||||
...base,
|
||||
navLabel: getNavigationButtonLabel(type),
|
||||
navType: getNavigationButtonKind(type),
|
||||
iconUrl: '',
|
||||
transitionReverseMode: 'auto_reverse',
|
||||
transitionDurationSec: 0.7,
|
||||
@ -452,6 +574,9 @@ const ConstructorPage = () => {
|
||||
const [newTransitionDurationSec, setNewTransitionDurationSec] = useState(0.7);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [successMessage, setSuccessMessage] = useState('');
|
||||
const [resolvedDurationBySource, setResolvedDurationBySource] = useState<
|
||||
Record<string, number | null>
|
||||
>({});
|
||||
|
||||
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 110 });
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
@ -470,6 +595,7 @@ const ConstructorPage = () => {
|
||||
const transitionVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const reverseAnimationFrame = useRef<number | null>(null);
|
||||
const didSetInitialCanvasFocus = useRef(false);
|
||||
const durationProbeInFlightRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const activePage = useMemo(
|
||||
() => pages.find((item) => item.id === activePageId) || null,
|
||||
@ -481,6 +607,7 @@ const ConstructorPage = () => {
|
||||
);
|
||||
const allowedNavigationTypes = useMemo<NavigationElementType[]>(() => {
|
||||
if (pages.length <= 1) return ['navigation_next'];
|
||||
if (activePageIndex < 0) return ['navigation_next', 'navigation_prev'];
|
||||
if (activePageIndex <= 0) return ['navigation_next'];
|
||||
if (activePageIndex >= pages.length - 1) return ['navigation_prev'];
|
||||
return ['navigation_next', 'navigation_prev'];
|
||||
@ -512,6 +639,7 @@ const ConstructorPage = () => {
|
||||
return {
|
||||
...element,
|
||||
type: nextType,
|
||||
navType: getNavigationButtonKind(nextType),
|
||||
label: hasDefaultLabel ? labelByType[nextType] : element.label,
|
||||
navLabel: hasDefaultNavLabel ? nextButtonLabel : element.navLabel,
|
||||
};
|
||||
@ -615,6 +743,112 @@ const ConstructorPage = () => {
|
||||
})),
|
||||
[assets],
|
||||
);
|
||||
const getKnownDurationForSource = useCallback(
|
||||
(source?: string) => {
|
||||
const normalizedSource = String(source || '').trim();
|
||||
if (!normalizedSource) return null;
|
||||
|
||||
const resolvedDuration = resolvedDurationBySource[normalizedSource];
|
||||
if (Number.isFinite(resolvedDuration) && Number(resolvedDuration) > 0) {
|
||||
return Number(resolvedDuration);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[resolvedDurationBySource],
|
||||
);
|
||||
|
||||
const durationProbeTargets = useMemo<
|
||||
Array<{ source: string; mediaType: 'video' | 'audio' }>
|
||||
>(() => {
|
||||
const targets: Array<{ source: string; mediaType: 'video' | 'audio' }> = [];
|
||||
|
||||
if (backgroundVideoUrl) {
|
||||
targets.push({ source: backgroundVideoUrl, mediaType: 'video' });
|
||||
}
|
||||
|
||||
if (backgroundAudioUrl) {
|
||||
targets.push({ source: backgroundAudioUrl, mediaType: 'audio' });
|
||||
}
|
||||
|
||||
if (
|
||||
selectedElement &&
|
||||
(selectedElement.type === 'video_player' ||
|
||||
selectedElement.type === 'audio_player') &&
|
||||
selectedElement.mediaUrl
|
||||
) {
|
||||
targets.push({
|
||||
source: selectedElement.mediaUrl,
|
||||
mediaType: selectedElement.type === 'video_player' ? 'video' : 'audio',
|
||||
});
|
||||
}
|
||||
|
||||
return targets;
|
||||
}, [backgroundAudioUrl, backgroundVideoUrl, selectedElement]);
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
durationProbeTargets.forEach(({ source, mediaType }) => {
|
||||
const normalizedSource = String(source || '').trim();
|
||||
if (!normalizedSource) return;
|
||||
|
||||
if (getKnownDurationForSource(normalizedSource)) return;
|
||||
|
||||
const probeKey = `${mediaType}:${normalizedSource}`;
|
||||
if (durationProbeInFlightRef.current.has(probeKey)) return;
|
||||
durationProbeInFlightRef.current.add(probeKey);
|
||||
|
||||
resolveDurationWithFallback(normalizedSource, mediaType)
|
||||
.then((duration) => {
|
||||
if (isCancelled) return;
|
||||
setResolvedDurationBySource((prev) => ({
|
||||
...prev,
|
||||
[normalizedSource]:
|
||||
Number.isFinite(duration) && Number(duration) > 0
|
||||
? Number(duration)
|
||||
: null,
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to resolve media duration:', error);
|
||||
if (isCancelled) return;
|
||||
setResolvedDurationBySource((prev) => ({
|
||||
...prev,
|
||||
[normalizedSource]: null,
|
||||
}));
|
||||
})
|
||||
.finally(() => {
|
||||
durationProbeInFlightRef.current.delete(probeKey);
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [durationProbeTargets, getKnownDurationForSource]);
|
||||
|
||||
const backgroundVideoDurationNote = useMemo(
|
||||
() => formatDurationNote(getKnownDurationForSource(backgroundVideoUrl)),
|
||||
[backgroundVideoUrl, getKnownDurationForSource],
|
||||
);
|
||||
const backgroundAudioDurationNote = useMemo(
|
||||
() => formatDurationNote(getKnownDurationForSource(backgroundAudioUrl)),
|
||||
[backgroundAudioUrl, getKnownDurationForSource],
|
||||
);
|
||||
const selectedMediaDurationNote = useMemo(() => {
|
||||
if (
|
||||
!selectedElement ||
|
||||
(selectedElement.type !== 'video_player' &&
|
||||
selectedElement.type !== 'audio_player')
|
||||
) {
|
||||
return 'Duration: unknown';
|
||||
}
|
||||
|
||||
return formatDurationNote(
|
||||
getKnownDurationForSource(selectedElement.mediaUrl || ''),
|
||||
);
|
||||
}, [getKnownDurationForSource, selectedElement]);
|
||||
|
||||
useEffect(() => {
|
||||
if (newTransitionVideoUrl) return;
|
||||
@ -762,6 +996,8 @@ const ConstructorPage = () => {
|
||||
label: labelByType[item.type as CanvasElementType],
|
||||
xPercent: clamp(Number(item.xPercent || 0), 0, 100),
|
||||
yPercent: clamp(Number(item.yPercent || 0), 0, 100),
|
||||
appearDelaySec: normalizeAppearDelaySec(item.appearDelaySec),
|
||||
appearDurationSec: normalizeAppearDurationSec(item.appearDurationSec),
|
||||
galleryCards: Array.isArray(item.galleryCards)
|
||||
? item.galleryCards.map((card: any, index: number) => ({
|
||||
id: String(card?.id || createLocalId()),
|
||||
@ -799,6 +1035,12 @@ const ConstructorPage = () => {
|
||||
? item.descriptionText
|
||||
: '',
|
||||
navLabel: typeof item.navLabel === 'string' ? item.navLabel : '',
|
||||
navType:
|
||||
item.navType === 'back' || item.navType === 'forward'
|
||||
? item.navType
|
||||
: isNavigationElementType(item.type as CanvasElementType)
|
||||
? getNavigationButtonKind(item.type as NavigationElementType)
|
||||
: undefined,
|
||||
targetPageId:
|
||||
typeof item.targetPageId === 'string' ? item.targetPageId : '',
|
||||
transitionVideoUrl:
|
||||
@ -1861,6 +2103,9 @@ const ConstructorPage = () => {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className='mt-1 text-[11px] text-gray-500'>
|
||||
{backgroundVideoDurationNote}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -1883,6 +2128,9 @@ const ConstructorPage = () => {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className='mt-1 text-[11px] text-gray-500'>
|
||||
{backgroundAudioDurationNote}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -1952,17 +2200,61 @@ const ConstructorPage = () => {
|
||||
)}
|
||||
|
||||
{selectedElement && (
|
||||
<div className='mb-2'>
|
||||
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
||||
Label
|
||||
</label>
|
||||
<input
|
||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||
value={selectedElement.label}
|
||||
onChange={(event) =>
|
||||
updateSelectedElement({ label: event.target.value })
|
||||
}
|
||||
/>
|
||||
<div className='mb-2 space-y-2'>
|
||||
<div>
|
||||
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
||||
Label
|
||||
</label>
|
||||
<input
|
||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||
value={selectedElement.label}
|
||||
onChange={(event) =>
|
||||
updateSelectedElement({ label: event.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
||||
Appear delay (sec)
|
||||
</label>
|
||||
<input
|
||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||
type='number'
|
||||
min='0'
|
||||
step='0.1'
|
||||
value={selectedElement.appearDelaySec ?? 0}
|
||||
onChange={(event) =>
|
||||
updateSelectedElement({
|
||||
appearDelaySec: normalizeAppearDelaySec(
|
||||
event.target.value,
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
||||
Appear duration (sec)
|
||||
</label>
|
||||
<input
|
||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||
type='number'
|
||||
min='0.1'
|
||||
step='0.1'
|
||||
placeholder='Unlimited'
|
||||
value={selectedElement.appearDurationSec ?? ''}
|
||||
onChange={(event) =>
|
||||
updateSelectedElement({
|
||||
appearDurationSec: normalizeAppearDurationSec(
|
||||
event.target.value,
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className='mt-1 text-[11px] text-gray-500'>
|
||||
Leave empty for unlimited.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -1972,17 +2264,22 @@ const ConstructorPage = () => {
|
||||
<div className='space-y-2'>
|
||||
<div>
|
||||
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
||||
Direction
|
||||
Type
|
||||
</label>
|
||||
<select
|
||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||
value={selectedElement.type}
|
||||
value={
|
||||
selectedElement.navType === 'back' ||
|
||||
selectedElement.navType === 'forward'
|
||||
? selectedElement.navType
|
||||
: getNavigationButtonKind(selectedElement.type)
|
||||
}
|
||||
onChange={(event) => {
|
||||
const rawValue = event.target.value;
|
||||
const requestedType: NavigationElementType =
|
||||
rawValue === 'navigation_prev'
|
||||
? 'navigation_prev'
|
||||
: 'navigation_next';
|
||||
const requestedKind: NavigationButtonKind =
|
||||
event.target.value === 'back' ? 'back' : 'forward';
|
||||
const requestedType = getNavigationTypeFromKind(
|
||||
requestedKind,
|
||||
);
|
||||
const nextType = allowedNavigationTypes.includes(
|
||||
requestedType,
|
||||
)
|
||||
@ -1995,15 +2292,23 @@ const ConstructorPage = () => {
|
||||
),
|
||||
);
|
||||
}}
|
||||
disabled={allowedNavigationTypes.length === 1}
|
||||
>
|
||||
{allowedNavigationTypes.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type === 'navigation_next'
|
||||
? 'Forward'
|
||||
: 'Back'}
|
||||
</option>
|
||||
))}
|
||||
<option
|
||||
value='forward'
|
||||
disabled={
|
||||
!allowedNavigationTypes.includes('navigation_next')
|
||||
}
|
||||
>
|
||||
Forward
|
||||
</option>
|
||||
<option
|
||||
value='back'
|
||||
disabled={
|
||||
!allowedNavigationTypes.includes('navigation_prev')
|
||||
}
|
||||
>
|
||||
Back
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@ -2092,6 +2397,9 @@ const ConstructorPage = () => {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className='mt-1 text-[11px] text-gray-500'>
|
||||
{selectedMediaDurationNote}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user