Autosave: 20260319-050018

This commit is contained in:
Flatlogic Bot 2026-03-19 05:00:18 +00:00
parent d4821b6a5d
commit 08ac54f0b5

View File

@ -76,12 +76,16 @@ type NavigationElementType = Extract<
'navigation_next' | 'navigation_prev' 'navigation_next' | 'navigation_prev'
>; >;
type NavigationButtonKind = 'forward' | 'back';
type CanvasElement = { type CanvasElement = {
id: string; id: string;
type: CanvasElementType; type: CanvasElementType;
label: string; label: string;
xPercent: number; xPercent: number;
yPercent: number; yPercent: number;
appearDelaySec?: number;
appearDurationSec?: number | null;
iconUrl?: string; iconUrl?: string;
galleryCards?: GalleryCard[]; galleryCards?: GalleryCard[];
carouselSlides?: CarouselSlide[]; carouselSlides?: CarouselSlide[];
@ -92,6 +96,7 @@ type CanvasElement = {
descriptionTitle?: string; descriptionTitle?: string;
descriptionText?: string; descriptionText?: string;
navLabel?: string; navLabel?: string;
navType?: NavigationButtonKind;
targetPageId?: string; targetPageId?: string;
transitionVideoUrl?: string; transitionVideoUrl?: string;
transitionReverseMode?: 'auto_reverse' | 'separate_video'; 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) => const clamp = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), max); 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 = () => { const createLocalId = () => {
if (typeof window !== 'undefined' && window.crypto?.randomUUID) { if (typeof window !== 'undefined' && window.crypto?.randomUUID) {
return 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 resolveAssetPlaybackUrl = (value?: string) => {
const normalized = String(value || '').trim(); const normalized = String(value || '').trim();
if (!normalized) return ''; if (!normalized) return '';
@ -258,6 +288,87 @@ const resolveAssetPlaybackUrl = (value?: string) => {
return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(normalizedPrivateUrl)}`; 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) => { const isBackgroundImageAsset = (asset: ProjectAsset) => {
if (asset.type) return asset.type === 'background_image'; if (asset.type) return asset.type === 'background_image';
const normalizedName = String(asset.name || '').toLowerCase(); const normalizedName = String(asset.name || '').toLowerCase();
@ -306,6 +417,14 @@ const isNavigationElementType = (
const getNavigationButtonLabel = (type: NavigationElementType) => const getNavigationButtonLabel = (type: NavigationElementType) =>
type === 'navigation_next' ? 'Forward' : 'Back'; 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 = ( const createDefaultElement = (
type: CanvasElementType, type: CanvasElementType,
index: number, index: number,
@ -316,6 +435,8 @@ const createDefaultElement = (
label: labelByType[type], label: labelByType[type],
xPercent: clamp(12 + index * 4, 5, 80), xPercent: clamp(12 + index * 4, 5, 80),
yPercent: clamp(16 + index * 6, 8, 85), yPercent: clamp(16 + index * 6, 8, 85),
appearDelaySec: 0,
appearDurationSec: null,
}; };
if (type === 'gallery') { if (type === 'gallery') {
@ -360,6 +481,7 @@ const createDefaultElement = (
return { return {
...base, ...base,
navLabel: getNavigationButtonLabel(type), navLabel: getNavigationButtonLabel(type),
navType: getNavigationButtonKind(type),
iconUrl: '', iconUrl: '',
transitionReverseMode: 'auto_reverse', transitionReverseMode: 'auto_reverse',
transitionDurationSec: 0.7, transitionDurationSec: 0.7,
@ -452,6 +574,9 @@ const ConstructorPage = () => {
const [newTransitionDurationSec, setNewTransitionDurationSec] = useState(0.7); const [newTransitionDurationSec, setNewTransitionDurationSec] = useState(0.7);
const [errorMessage, setErrorMessage] = useState(''); const [errorMessage, setErrorMessage] = useState('');
const [successMessage, setSuccessMessage] = useState(''); const [successMessage, setSuccessMessage] = useState('');
const [resolvedDurationBySource, setResolvedDurationBySource] = useState<
Record<string, number | null>
>({});
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 110 }); const [menuPosition, setMenuPosition] = useState({ x: 0, y: 110 });
const [isMenuOpen, setIsMenuOpen] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false);
@ -470,6 +595,7 @@ const ConstructorPage = () => {
const transitionVideoRef = useRef<HTMLVideoElement | null>(null); const transitionVideoRef = useRef<HTMLVideoElement | null>(null);
const reverseAnimationFrame = useRef<number | null>(null); const reverseAnimationFrame = useRef<number | null>(null);
const didSetInitialCanvasFocus = useRef(false); const didSetInitialCanvasFocus = useRef(false);
const durationProbeInFlightRef = useRef<Set<string>>(new Set());
const activePage = useMemo( const activePage = useMemo(
() => pages.find((item) => item.id === activePageId) || null, () => pages.find((item) => item.id === activePageId) || null,
@ -481,6 +607,7 @@ const ConstructorPage = () => {
); );
const allowedNavigationTypes = useMemo<NavigationElementType[]>(() => { const allowedNavigationTypes = useMemo<NavigationElementType[]>(() => {
if (pages.length <= 1) return ['navigation_next']; if (pages.length <= 1) return ['navigation_next'];
if (activePageIndex < 0) return ['navigation_next', 'navigation_prev'];
if (activePageIndex <= 0) return ['navigation_next']; if (activePageIndex <= 0) return ['navigation_next'];
if (activePageIndex >= pages.length - 1) return ['navigation_prev']; if (activePageIndex >= pages.length - 1) return ['navigation_prev'];
return ['navigation_next', 'navigation_prev']; return ['navigation_next', 'navigation_prev'];
@ -512,6 +639,7 @@ const ConstructorPage = () => {
return { return {
...element, ...element,
type: nextType, type: nextType,
navType: getNavigationButtonKind(nextType),
label: hasDefaultLabel ? labelByType[nextType] : element.label, label: hasDefaultLabel ? labelByType[nextType] : element.label,
navLabel: hasDefaultNavLabel ? nextButtonLabel : element.navLabel, navLabel: hasDefaultNavLabel ? nextButtonLabel : element.navLabel,
}; };
@ -615,6 +743,112 @@ const ConstructorPage = () => {
})), })),
[assets], [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(() => { useEffect(() => {
if (newTransitionVideoUrl) return; if (newTransitionVideoUrl) return;
@ -762,6 +996,8 @@ const ConstructorPage = () => {
label: labelByType[item.type as CanvasElementType], label: labelByType[item.type as CanvasElementType],
xPercent: clamp(Number(item.xPercent || 0), 0, 100), xPercent: clamp(Number(item.xPercent || 0), 0, 100),
yPercent: clamp(Number(item.yPercent || 0), 0, 100), yPercent: clamp(Number(item.yPercent || 0), 0, 100),
appearDelaySec: normalizeAppearDelaySec(item.appearDelaySec),
appearDurationSec: normalizeAppearDurationSec(item.appearDurationSec),
galleryCards: Array.isArray(item.galleryCards) galleryCards: Array.isArray(item.galleryCards)
? item.galleryCards.map((card: any, index: number) => ({ ? item.galleryCards.map((card: any, index: number) => ({
id: String(card?.id || createLocalId()), id: String(card?.id || createLocalId()),
@ -799,6 +1035,12 @@ const ConstructorPage = () => {
? item.descriptionText ? item.descriptionText
: '', : '',
navLabel: typeof item.navLabel === 'string' ? item.navLabel : '', 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: targetPageId:
typeof item.targetPageId === 'string' ? item.targetPageId : '', typeof item.targetPageId === 'string' ? item.targetPageId : '',
transitionVideoUrl: transitionVideoUrl:
@ -1861,6 +2103,9 @@ const ConstructorPage = () => {
</option> </option>
))} ))}
</select> </select>
<p className='mt-1 text-[11px] text-gray-500'>
{backgroundVideoDurationNote}
</p>
</div> </div>
)} )}
@ -1883,6 +2128,9 @@ const ConstructorPage = () => {
</option> </option>
))} ))}
</select> </select>
<p className='mt-1 text-[11px] text-gray-500'>
{backgroundAudioDurationNote}
</p>
</div> </div>
)} )}
@ -1952,17 +2200,61 @@ const ConstructorPage = () => {
)} )}
{selectedElement && ( {selectedElement && (
<div className='mb-2'> <div className='mb-2 space-y-2'>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <div>
Label <label className='mb-1 block text-[11px] font-semibold text-gray-600'>
</label> Label
<input </label>
className='w-full rounded border border-gray-300 px-2 py-1 text-xs' <input
value={selectedElement.label} className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
onChange={(event) => value={selectedElement.label}
updateSelectedElement({ label: event.target.value }) 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> </div>
)} )}
@ -1972,17 +2264,22 @@ const ConstructorPage = () => {
<div className='space-y-2'> <div className='space-y-2'>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Direction Type
</label> </label>
<select <select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs' 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) => { onChange={(event) => {
const rawValue = event.target.value; const requestedKind: NavigationButtonKind =
const requestedType: NavigationElementType = event.target.value === 'back' ? 'back' : 'forward';
rawValue === 'navigation_prev' const requestedType = getNavigationTypeFromKind(
? 'navigation_prev' requestedKind,
: 'navigation_next'; );
const nextType = allowedNavigationTypes.includes( const nextType = allowedNavigationTypes.includes(
requestedType, requestedType,
) )
@ -1995,15 +2292,23 @@ const ConstructorPage = () => {
), ),
); );
}} }}
disabled={allowedNavigationTypes.length === 1}
> >
{allowedNavigationTypes.map((type) => ( <option
<option key={type} value={type}> value='forward'
{type === 'navigation_next' disabled={
? 'Forward' !allowedNavigationTypes.includes('navigation_next')
: 'Back'} }
</option> >
))} Forward
</option>
<option
value='back'
disabled={
!allowedNavigationTypes.includes('navigation_prev')
}
>
Back
</option>
</select> </select>
</div> </div>
<div> <div>
@ -2092,6 +2397,9 @@ const ConstructorPage = () => {
</option> </option>
))} ))}
</select> </select>
<p className='mt-1 text-[11px] text-gray-500'>
{selectedMediaDurationNote}
</p>
</div> </div>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-gray-600'>