From ace6bc56e0c7faf2215b691fcab0789c88e891b4 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sun, 6 Jul 2025 20:57:59 +0100 Subject: [PATCH] feat(cms): add typewriter effect + background audio --- .gitignore | 2 + cms/components/TikTokContentGenerator.tsx | 125 ++++- cms/schemaTypes/audioTrack.ts | 129 +++++ cms/schemaTypes/index.ts | 3 +- cms/utils/audio-track-selector.ts | 117 ++++ cms/utils/tiktok-animation-config.ts | 10 +- cms/utils/tiktok-gsap-animations.ts | 5 +- cms/utils/tiktok-video-recorder.ts | 229 +++++++- package.json | 5 +- pnpm-lock.yaml | 139 +++++ scripts/import-streambeats.mjs | 646 ++++++++++++++++++++++ 11 files changed, 1396 insertions(+), 14 deletions(-) create mode 100644 cms/schemaTypes/audioTrack.ts create mode 100644 cms/utils/audio-track-selector.ts create mode 100644 scripts/import-streambeats.mjs diff --git a/.gitignore b/.gitignore index 2c08dca8..273d14d8 100644 --- a/.gitignore +++ b/.gitignore @@ -109,3 +109,5 @@ credentials.json # TypeScript build info *.tsbuildinfo + +downloads/ diff --git a/cms/components/TikTokContentGenerator.tsx b/cms/components/TikTokContentGenerator.tsx index e07a12c7..be515e89 100644 --- a/cms/components/TikTokContentGenerator.tsx +++ b/cms/components/TikTokContentGenerator.tsx @@ -1,5 +1,5 @@ import React, { useState, useCallback, useRef, useEffect } from 'react' -import { Stack, Text, Card, Button, Box, Badge, Flex } from '@sanity/ui' +import { Stack, Text, Card, Button, Box, Badge, Flex, Switch } from '@sanity/ui' import type { PortableTextTextBlock } from 'sanity' import { set, useFormValue, useClient } from 'sanity' import { createTikTokVideo } from '../utils/tiktok-video-recorder' @@ -7,6 +7,13 @@ import { calculateVideoDuration } from '../utils/tiktok-animation-config' import type { TikTokGSAPTimeline } from '../utils/tiktok-gsap-animations' import { createPreviewAnimation, stopAnimations } from '../utils/tiktok-gsap-animations' import { getHumanRelativeDate } from '../utils/date-formatting' +import { + fetchAudioTracks, + selectAudioTrack, + generateContentHash, + calculateAudioStartTime, + type AudioTrack, +} from '../utils/audio-track-selector' interface TikTokContentGeneratorProps { onChange: (event: any) => void @@ -19,6 +26,10 @@ export function TikTokContentGenerator (props: TikTokContentGeneratorProps) { const [generationProgress, setGenerationProgress] = useState(0) const [hasPreview, setHasPreview] = useState(false) const [videoUrl, setVideoUrl] = useState(null) + const [audioEnabled, setAudioEnabled] = useState(true) + const [availableAudioTracks, setAvailableAudioTracks] = useState([]) + const [selectedAudioTrack, setSelectedAudioTrack] = useState(null) + const [isLoadingAudioTracks, setIsLoadingAudioTracks] = useState(false) const videoContainerRef = useRef(null) const videoRef = useRef(null) const questionContentRef = useRef(null) @@ -173,6 +184,45 @@ export function TikTokContentGenerator (props: TikTokContentGeneratorProps) { return lines }, [formattedAnswer]) + const videoDuration = calculateVideoDuration(1, answerLines.length, formattedQuestion, typingIntervals) + + // Load available audio tracks + useEffect(() => { + const loadAudioTracks = async () => { + if (!audioEnabled) return + + setIsLoadingAudioTracks(true) + try { + const tracks = await fetchAudioTracks(client) + setAvailableAudioTracks(tracks) + + // Auto-select a track based on content + if (tracks.length > 0 && formattedQuestion && formattedAnswer) { + const contentHash = generateContentHash(formattedQuestion, formattedAnswer) + const track = selectAudioTrack(tracks, videoDuration, contentHash) + setSelectedAudioTrack(track) + } + } + catch (error) { + console.warn('Failed to load audio tracks:', error) + } + finally { + setIsLoadingAudioTracks(false) + } + } + + loadAudioTracks() + }, [audioEnabled, formattedQuestion, formattedAnswer, videoDuration, client]) + + // Update selected track when content changes + useEffect(() => { + if (availableAudioTracks.length > 0 && formattedQuestion && formattedAnswer && audioEnabled) { + const contentHash = generateContentHash(formattedQuestion, formattedAnswer) + const track = selectAudioTrack(availableAudioTracks, videoDuration, contentHash) + setSelectedAudioTrack(track) + } + }, [availableAudioTracks, formattedQuestion, formattedAnswer, videoDuration, audioEnabled]) + const generateVideo = useCallback(async () => { if (!documentContent || !hasPreview || !videoContainerRef.current) return @@ -205,6 +255,15 @@ export function TikTokContentGenerator (props: TikTokContentGeneratorProps) { answerLines: videoAnswerLines, relativeDate, typingIntervals, + audioTrack: audioEnabled && selectedAudioTrack ? selectedAudioTrack : undefined, + audioStartTime: selectedAudioTrack + ? calculateAudioStartTime( + selectedAudioTrack, + videoDuration, + generateContentHash(formattedQuestion, formattedAnswer), + ) + : 0, + audioVolume: selectedAudioTrack?.volume || 0.7, onProgress: progress => { setGenerationProgress(Math.round(progress * 100)) }, @@ -234,7 +293,7 @@ export function TikTokContentGenerator (props: TikTokContentGeneratorProps) { setIsGenerating(false) setGenerationProgress(0) } - }, [documentContent, hasPreview, getAnswerText, formattedQuestion, answerLines, relativeDate, typingIntervals, onChange, client]) + }, [documentContent, hasPreview, getAnswerText, formattedQuestion, formattedAnswer, answerLines, relativeDate, typingIntervals, audioEnabled, selectedAudioTrack, videoDuration, onChange, client]) useEffect(() => { return () => { @@ -244,8 +303,6 @@ export function TikTokContentGenerator (props: TikTokContentGeneratorProps) { } }, [videoUrl]) - const videoDuration = calculateVideoDuration(1, answerLines.length, formattedQuestion) - // Generate TikTok metadata const generateTikTokMetadata = useCallback((question: string, answer: string): { title: string, description: string, hashtags: string[] } => { // Generate title - truncate question if too long @@ -416,6 +473,64 @@ export function TikTokContentGenerator (props: TikTokContentGeneratorProps) { )} + {/* Audio Controls */} + + + + Background Music + setAudioEnabled(event.currentTarget.checked)} + /> + + + {audioEnabled && ( + + {isLoadingAudioTracks && ( + + Loading audio tracks... + + )} + + {selectedAudioTrack && ( + + + Selected Track: + + + " + {selectedAudioTrack.name} + " by + {' '} + {selectedAudioTrack.artist} + {selectedAudioTrack.tags.length > 0 && ( + + {' '} + • + {' '} + {selectedAudioTrack.tags.slice(0, 3).join(', ')} + + )} + + + )} + + {!selectedAudioTrack && !isLoadingAudioTracks && availableAudioTracks.length === 0 && ( + + No audio tracks available. Add audio tracks in the CMS to enable background music. + + )} + + {!selectedAudioTrack && !isLoadingAudioTracks && availableAudioTracks.length > 0 && ( + + No suitable track found for this video duration. + + )} + + )} + + + {documentContent && posts && ( {videoUrl && ( @@ -628,7 +743,7 @@ export function TikTokContentGenerator (props: TikTokContentGeneratorProps) { {(() => { const words = formattedQuestion.split(/\s+/).filter(Boolean) let globalCharIndex = 0 - const maxLineLength = 41 + const maxLineLength = 40 const lines: string[] = [] let currentLine = '' diff --git a/cms/schemaTypes/audioTrack.ts b/cms/schemaTypes/audioTrack.ts new file mode 100644 index 00000000..bbe57aec --- /dev/null +++ b/cms/schemaTypes/audioTrack.ts @@ -0,0 +1,129 @@ +import { defineField, defineType } from 'sanity' + +export default defineType({ + name: 'audioTrack', + title: 'Audio Track', + type: 'document', + fields: [ + defineField({ + name: 'name', + title: 'Track Name', + type: 'string', + validation: Rule => Rule.required(), + }), + defineField({ + name: 'artist', + title: 'Artist', + type: 'string', + initialValue: 'Harris Heller', + validation: Rule => Rule.required(), + }), + defineField({ + name: 'collection', + title: 'Collection', + type: 'string', + options: { + list: [ + { title: 'StreamBeats', value: 'streambeats' }, + { title: 'StreamBeats Lofi', value: 'streambeats-lofi' }, + { title: 'StreamBeats Chill', value: 'streambeats-chill' }, + { title: 'StreamBeats Study', value: 'streambeats-study' }, + { title: 'StreamBeats Gaming', value: 'streambeats-gaming' }, + { title: 'StreamBeats Hip Hop', value: 'streambeats-hiphop' }, + { title: 'StreamBeats Electronic', value: 'streambeats-electronic' }, + { title: 'StreamBeats Ambient', value: 'streambeats-ambient' }, + { title: 'Other', value: 'other' }, + ], + }, + initialValue: 'streambeats', + }), + defineField({ + name: 'category', + title: 'Category', + type: 'string', + description: 'Main folder/category from StreamBeats archive', + }), + defineField({ + name: 'subCategory', + title: 'Sub-Category', + type: 'string', + description: 'Subfolder/subcategory from StreamBeats archive', + }), + defineField({ + name: 'folderPath', + title: 'Original Folder Path', + type: 'string', + description: 'Full folder path from the StreamBeats archive', + readOnly: true, + }), + defineField({ + name: 'audioFile', + title: 'Audio File', + type: 'file', + options: { + accept: 'audio/*', + }, + validation: Rule => Rule.required(), + }), + defineField({ + name: 'duration', + title: 'Duration (seconds)', + type: 'number', + validation: Rule => Rule.required().min(1), + }), + defineField({ + name: 'tags', + title: 'Tags', + type: 'array', + of: [{ type: 'string' }], + options: { + list: [ + { title: 'Upbeat', value: 'upbeat' }, + { title: 'Chill', value: 'chill' }, + { title: 'Lofi', value: 'lofi' }, + { title: 'Electronic', value: 'electronic' }, + { title: 'Hip Hop', value: 'hiphop' }, + { title: 'Focus', value: 'focus' }, + { title: 'Study', value: 'study' }, + { title: 'Energetic', value: 'energetic' }, + { title: 'Ambient', value: 'ambient' }, + { title: 'Gaming', value: 'gaming' }, + ], + }, + }), + defineField({ + name: 'volume', + title: 'Default Volume', + type: 'number', + initialValue: 0.7, + validation: Rule => Rule.required().min(0).max(1), + description: 'Default volume level (0.0 to 1.0)', + }), + defineField({ + name: 'isActive', + title: 'Active for Selection', + type: 'boolean', + initialValue: true, + description: 'Whether this track can be randomly selected for videos', + }), + defineField({ + name: 'notes', + title: 'Notes', + type: 'text', + description: 'Internal notes about this track', + }), + ], + preview: { + select: { + title: 'name', + subtitle: 'artist', + media: 'audioFile', + }, + prepare ({ title, subtitle }) { + return { + title: title || 'Untitled Track', + subtitle: `by ${subtitle || 'Unknown Artist'}`, + } + }, + }, +}) diff --git a/cms/schemaTypes/index.ts b/cms/schemaTypes/index.ts index 3b358df1..c6bc665f 100644 --- a/cms/schemaTypes/index.ts +++ b/cms/schemaTypes/index.ts @@ -1,4 +1,5 @@ import ama from './ama' +import audioTrack from './audioTrack' import entity from './entity' import invite from './invite' import location from './location' @@ -7,4 +8,4 @@ import talkGroup from './talkGroup' import uses from './uses' import advocuSync from './advocuSync' -export const schemaTypes = [ama, entity, location, invite, talk, talkGroup, uses, advocuSync] +export const schemaTypes = [ama, audioTrack, entity, location, invite, talk, talkGroup, uses, advocuSync] diff --git a/cms/utils/audio-track-selector.ts b/cms/utils/audio-track-selector.ts new file mode 100644 index 00000000..2989b7b7 --- /dev/null +++ b/cms/utils/audio-track-selector.ts @@ -0,0 +1,117 @@ +import type { SanityClient } from 'sanity' + +export interface AudioTrack { + id: string + name: string + artist: string + collection: string + category?: string + subCategory?: string + folderPath?: string + duration: number + url: string + tags: string[] + volume?: number +} + +/** + * Fetch all active audio tracks from Sanity + */ +export async function fetchAudioTracks (client: SanityClient): Promise { + const query = `*[_type == "audioTrack" && isActive == true] { + _id, + name, + artist, + collection, + category, + subCategory, + folderPath, + duration, + "url": audioFile.asset->url, + tags, + volume + }` + + const tracks = await client.fetch(query) + + return tracks.map((track: any) => ({ + id: track._id, + name: track.name, + artist: track.artist, + collection: track.collection, + category: track.category, + subCategory: track.subCategory, + folderPath: track.folderPath, + duration: track.duration, + url: track.url, + tags: track.tags || [], + volume: track.volume || 0.7, + })).filter((track: AudioTrack) => track.url) // Only include tracks with valid URLs +} + +/** + * Select a random audio track based on content hash (for consistency) + * and video duration requirements + */ +export function selectAudioTrack ( + tracks: AudioTrack[], + videoDuration: number, + contentHash: string, +): AudioTrack | null { + if (!tracks.length) return null + + // Filter tracks that are long enough for the video (with 5 second buffer) + const suitableTracks = tracks.filter(track => track.duration >= videoDuration + 5) + + if (!suitableTracks.length) { + // Fall back to any available track if none are long enough + console.warn('No tracks long enough for video duration, using any available track') + return tracks.length > 0 ? selectRandomTrack(tracks, contentHash) : null + } + + return selectRandomTrack(suitableTracks, contentHash) +} + +/** + * Select a random track using seeded randomization for consistency + */ +function selectRandomTrack (tracks: AudioTrack[], contentHash: string): AudioTrack { + // Create a seed from the content hash + const seed = contentHash.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) % 1000 + + // Use the same seeded random function as the typing intervals + let randomSeed = seed + const seededRandom = () => { + randomSeed = (randomSeed * 9301 + 49297) % 233280 + return randomSeed / 233280 + } + + const index = Math.floor(seededRandom() * tracks.length) + return tracks[index]! +} + +/** + * Generate a content hash from question and answer for consistent track selection + */ +export function generateContentHash (question: string, answer: string): string { + return `${question.trim()}-${answer.trim()}`.toLowerCase().replace(/\s+/g, '-') +} + +/** + * Calculate random start time for audio track to add variety + */ +export function calculateAudioStartTime (track: AudioTrack, videoDuration: number, contentHash: string): number { + const maxStartTime = Math.max(0, track.duration - videoDuration - 5) // 5 second buffer + + if (maxStartTime <= 0) return 0 + + // Use content hash to generate consistent start time + const seed = contentHash.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) % 1000 + let randomSeed = seed + 42 // Add offset to get different value than track selection + const seededRandom = () => { + randomSeed = (randomSeed * 9301 + 49297) % 233280 + return randomSeed / 233280 + } + + return Math.floor(seededRandom() * maxStartTime) +} diff --git a/cms/utils/tiktok-animation-config.ts b/cms/utils/tiktok-animation-config.ts index e000c28a..d75b9ad6 100644 --- a/cms/utils/tiktok-animation-config.ts +++ b/cms/utils/tiktok-animation-config.ts @@ -27,10 +27,14 @@ export const ANIMATION_CONFIG = { }, } as const -export function calculateVideoDuration (questionLines: number, answerLines: number, questionText?: string): number { - // Calculate typewriter duration based on actual question text length if provided +export function calculateVideoDuration (questionLines: number, answerLines: number, questionText?: string, typingIntervals?: number[]): number { + // Calculate typewriter duration based on actual typing intervals if provided let typewriterDuration = 0 - if (questionText) { + if (typingIntervals && typingIntervals.length > 0) { + // Use actual typing intervals for precise duration + typewriterDuration = typingIntervals.reduce((sum, interval) => sum + interval, 0) / 1000 + } + else if (questionText) { typewriterDuration = questionText.length * ANIMATION_CONFIG.typewriter.characterDelay } else { diff --git a/cms/utils/tiktok-gsap-animations.ts b/cms/utils/tiktok-gsap-animations.ts index bfc0ad5b..89b16a75 100644 --- a/cms/utils/tiktok-gsap-animations.ts +++ b/cms/utils/tiktok-gsap-animations.ts @@ -28,7 +28,10 @@ export function createTikTokGSAPTimeline (container: HTMLElement, typingInterval typewriterDuration = questionText.length * ANIMATION_CONFIG.typewriter.characterDelay } - const questionDuration = Math.max(1.5, typewriterDuration + 1) + const questionDuration = Math.max(2, Math.max( + 1 * ANIMATION_CONFIG.questionDisplayTime, + typewriterDuration + 1, + )) const transitionDuration = ANIMATION_CONFIG.transitionDuration const answerDuration = Math.max(3, answerLines.length * ANIMATION_CONFIG.answerRevealDuration) const holdDuration = ANIMATION_CONFIG.holdDuration diff --git a/cms/utils/tiktok-video-recorder.ts b/cms/utils/tiktok-video-recorder.ts index a7723731..a40d2b5b 100644 --- a/cms/utils/tiktok-video-recorder.ts +++ b/cms/utils/tiktok-video-recorder.ts @@ -7,6 +7,15 @@ import { } from './tiktok-animation-config' import { createTikTokGSAPTimeline, resetCharacterStates } from './tiktok-gsap-animations' +interface AudioTrack { + id: string + name: string + artist: string + duration: number + url: string + tags: string[] +} + interface TikTokVideoOptions { element: HTMLElement question: string @@ -15,14 +24,17 @@ interface TikTokVideoOptions { answerLines: string[] relativeDate: string typingIntervals?: number[] + audioTrack?: AudioTrack + audioStartTime?: number + audioVolume?: number onProgress?: (progress: number) => void } export async function createTikTokVideo (options: TikTokVideoOptions): Promise { - const { questionLines, answerLines, question, typingIntervals, onProgress } = options + const { questionLines, answerLines, question, typingIntervals, audioTrack, audioStartTime = 0, audioVolume = 0.7, onProgress } = options const targetFPS = ANIMATION_CONFIG.recordingFrameRate || 24 - const duration = calculateVideoDuration(questionLines.length, answerLines.length, question) + const duration = calculateVideoDuration(questionLines.length, answerLines.length, question, typingIntervals) if (!isPureWebCodecsSupported()) { throw new Error('WebCodecs not supported. Please use Firefox 130+, Chrome 94+, or Safari 16.4+') @@ -35,6 +47,9 @@ export async function createTikTokVideo (options: TikTokVideoOptions): Promise void } @@ -91,11 +109,106 @@ async function blobToImageBitmap (blob: Blob): Promise { } } +async function processAudioTrack (audioTrack: AudioTrack, duration: number, startTime: number, volume: number): Promise { + try { + // Fetch audio file + const response = await fetch(audioTrack.url) + if (!response.ok) { + throw new Error(`Failed to fetch audio: ${response.statusText}`) + } + + const arrayBuffer = await response.arrayBuffer() + // Don't force sample rate - let it decode naturally first + const audioContext = new AudioContext() + + try { + const audioBuffer = await audioContext.decodeAudioData(arrayBuffer) + console.log('šŸŽµ Original audio:', { + sampleRate: audioBuffer.sampleRate, + duration: audioBuffer.duration, + channels: audioBuffer.numberOfChannels, + }) + + // If sample rate doesn't match our target (48kHz), we need to resample + const targetSampleRate = 48000 + let processedBuffer = audioBuffer + + if (audioBuffer.sampleRate !== targetSampleRate) { + console.log(`šŸ”„ Resampling from ${audioBuffer.sampleRate}Hz to ${targetSampleRate}Hz`) + + // Create offline context for resampling + const offlineContext = new OfflineAudioContext( + audioBuffer.numberOfChannels, + Math.floor(audioBuffer.duration * targetSampleRate), + targetSampleRate, + ) + + const source = offlineContext.createBufferSource() + source.buffer = audioBuffer + source.connect(offlineContext.destination) + source.start(0) + + processedBuffer = await offlineContext.startRendering() + console.log('āœ… Audio resampled successfully') + } + + // Calculate sample range for the video duration using the correct sample rate + const sampleRate = processedBuffer.sampleRate + const startSample = Math.floor(startTime * sampleRate) + const endSample = Math.min( + startSample + Math.floor(duration * sampleRate), + processedBuffer.length, + ) + const numSamples = endSample - startSample + + // Extract and process audio channels + const processedChannels: Float32Array[] = [] + + for (let channel = 0; channel < Math.min(processedBuffer.numberOfChannels, 2); channel++) { + const channelData = processedBuffer.getChannelData(channel) + const processedData = new Float32Array(numSamples) + + // Copy and apply volume with proper clipping + for (let i = 0; i < numSamples; i++) { + const sourceIndex = startSample + i + if (sourceIndex < channelData.length) { + // Apply volume and clamp to prevent distortion + const sample = channelData[sourceIndex] * volume + processedData[i] = Math.max(-1, Math.min(1, sample)) + } + else { + processedData[i] = 0 // Pad with silence if audio is shorter than video + } + } + + processedChannels.push(processedData) + } + + // If mono, duplicate for stereo + if (processedChannels.length === 1) { + processedChannels.push(new Float32Array(processedChannels[0])) + } + + return processedChannels + } + finally { + await audioContext.close() + } + } + catch (error) { + console.warn('Failed to process audio track:', error) + // Return silence if audio processing fails + const sampleRate = 48000 + const numSamples = Math.floor(duration * sampleRate) + return [new Float32Array(numSamples), new Float32Array(numSamples)] + } +} + /** * Record TikTok video using pure WebCodecs with proper WebM muxing */ export async function recordTikTokWithPureWebCodecs (options: PureWebCodecsRecorderOptions): Promise { - const { element, targetFPS, duration, width, height, typingIntervals, onProgress } = options + const { element, targetFPS, duration, width, height, typingIntervals, audioTrack, audioStartTime = 0, audioVolume = 0.7, onProgress } = options let container = element.querySelector('.tiktok-container') as HTMLElement if (!container && element.classList.contains('tiktok-container')) { @@ -121,6 +234,48 @@ export async function recordTikTokWithPureWebCodecs (options: PureWebCodecsRecor keyFrameEvery: 24, } + // Process audio if provided and encode it properly + let audioChannels: Float32Array[] | null = null + let audioEncoder: AudioEncoder | null = null + const audioSampleRate = 48000 + const audioFrameSize = 960 // Standard Opus frame size for 48kHz (20ms) + + if (audioTrack) { + try { + console.log('šŸŽµ Processing audio track:', audioTrack.name) + audioChannels = await processAudioTrack(audioTrack, duration, audioStartTime, audioVolume) + + if (audioChannels && audioChannels.length > 0) { + console.log('šŸŽµ Setting up audio encoder for WebM muxing') + + // Create audio encoder for Opus + audioEncoder = new AudioEncoder({ + output: (chunk, metadata) => { + muxer.addAudioChunk(chunk, metadata) + }, + error: (error: Error) => { + console.warn('Audio encoding error:', error) + }, + }) + + // Configure audio encoder for Opus + audioEncoder.configure({ + codec: 'opus', + sampleRate: audioSampleRate, + numberOfChannels: 2, + bitrate: 128000, // 128 kbps + }) + + console.log('āœ… Audio encoder configured') + } + } + catch (error) { + console.warn('Failed to process audio, continuing without audio:', error) + audioChannels = null + audioEncoder = null + } + } + const target = new ArrayBufferTarget() const muxer = new Muxer({ target, @@ -130,6 +285,13 @@ export async function recordTikTokWithPureWebCodecs (options: PureWebCodecsRecor height, frameRate: targetFPS, }, + audio: audioChannels + ? { + codec: 'A_OPUS', + sampleRate: 48000, + numberOfChannels: 2, + } + : undefined, firstTimestampBehavior: 'offset', }) @@ -157,6 +319,61 @@ export async function recordTikTokWithPureWebCodecs (options: PureWebCodecsRecor onProgress(progress) } + // Add audio frames if audio encoder is available + if (audioEncoder && audioChannels && frameIndex === 0) { + try { + console.log('šŸŽµ Encoding audio frames') + + // Process audio in chunks that match the Opus frame size + const samplesPerFrame = audioFrameSize + const totalSamples = audioChannels[0].length + const totalAudioFrames = Math.ceil(totalSamples / samplesPerFrame) + + for (let audioFrameIndex = 0; audioFrameIndex < totalAudioFrames; audioFrameIndex++) { + const startSample = audioFrameIndex * samplesPerFrame + const endSample = Math.min(startSample + samplesPerFrame, totalSamples) + const frameSamples = endSample - startSample + + // Prepare planar audio data (separate channels, not interleaved) + const leftChannelData = new Float32Array(frameSamples) + const rightChannelData = new Float32Array(frameSamples) + + for (let i = 0; i < frameSamples; i++) { + // Clamp samples to prevent distortion + leftChannelData[i] = Math.max(-1, Math.min(1, audioChannels[0][startSample + i] || 0)) + rightChannelData[i] = Math.max(-1, Math.min(1, audioChannels[1] ? audioChannels[1][startSample + i] || 0 : leftChannelData[i])) + } + + // Create planar data buffer (left channel followed by right channel) + const planarData = new Float32Array(frameSamples * 2) + planarData.set(leftChannelData, 0) + planarData.set(rightChannelData, frameSamples) + + // Calculate timestamp for this audio frame + const audioTimestamp = (audioFrameIndex * samplesPerFrame / audioSampleRate) * 1000000 // microseconds + + // Create AudioData object with correct planar format + const audioData = new AudioData({ + format: 'f32-planar', + sampleRate: audioSampleRate, + numberOfChannels: 2, + numberOfFrames: frameSamples, + timestamp: audioTimestamp, + data: planarData, + }) + + // Encode audio frame + audioEncoder.encode(audioData) + audioData.close() + } + + console.log(`āœ… Encoded ${totalAudioFrames} audio frames`) + } + catch (error) { + console.warn('Failed to encode audio:', error) + } + } + // Update GSAP timeline to exact time position (not progress) const currentTime = progress * duration timeline.time(currentTime) @@ -189,6 +406,12 @@ export async function recordTikTokWithPureWebCodecs (options: PureWebCodecsRecor } await encoder.flush() + + if (audioEncoder) { + await audioEncoder.flush() + audioEncoder.close() + } + muxer.finalize() const webmBuffer = target.buffer diff --git a/package.json b/package.json index 1497636d..2bbf6bea 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,9 @@ "test:e2e": "playwright test test/e2e", "test:e2e:update": "docker run --rm --network host -v $(pwd):/work/ -v /tmp/playwright-nm:/work/node_modules -w /work/ -it mcr.microsoft.com/playwright:v1.52.0-noble bash -c 'corepack enable && pnpm i && pnpm playwright test test/e2e --update-snapshots'", "postinstall": "nuxt prepare && pnpm simple-git-hooks install", - "auth:youtube": "node scripts/youtube-auth.mjs" + "auth:youtube": "node --env-file=.env scripts/youtube-auth.mjs", + "import-streambeats": "node --env-file=.env scripts/import-streambeats.mjs", + "import-streambeats:dry-run": "node --env-file=.env scripts/import-streambeats.mjs --dry-run" }, "lint-staged": { "*.{js,ts,mjs,vue}": [ @@ -85,6 +87,7 @@ "@playwright/test": "1.53.2", "@vue/test-utils": "2.4.6", "eslint": "9.30.0", + "googleapis": "^146.0.0", "happy-dom": "18.0.1", "lint-staged": "16.1.2", "ofetch": "1.4.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce8512a2..58ae086d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -200,6 +200,9 @@ importers: eslint: specifier: 9.30.0 version: 9.30.0(jiti@2.4.2) + googleapis: + specifier: ^146.0.0 + version: 146.0.0 happy-dom: specifier: 18.0.1 version: 18.0.1 @@ -4511,6 +4514,9 @@ packages: bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + bignumber.js@9.3.0: + resolution: {integrity: sha512-EM7aMFTXbptt/wZdMlBv2t8IViwQL+h6SLHosp8Yf0dqJMTnY6iL32opnAB6kAdL0SZPuvcAzFr31o0c/R3/RA==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -4570,6 +4576,9 @@ packages: resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} engines: {node: '>=8.0.0'} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-fill@1.0.0: resolution: {integrity: sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==} @@ -5473,6 +5482,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + editorconfig@1.0.4: resolution: {integrity: sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==} engines: {node: '>=14'} @@ -6104,6 +6116,14 @@ packages: resolution: {integrity: sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==} engines: {node: '>=10'} + gaxios@6.7.1: + resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==} + engines: {node: '>=14'} + + gcp-metadata@6.1.1: + resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==} + engines: {node: '>=14'} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -6270,6 +6290,22 @@ packages: engines: {node: '>=0.6.0'} hasBin: true + google-auth-library@9.15.1: + resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==} + engines: {node: '>=14'} + + google-logging-utils@0.0.2: + resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==} + engines: {node: '>=14'} + + googleapis-common@7.2.0: + resolution: {integrity: sha512-/fhDZEJZvOV3X5jmD+fKxMqma5q2Q9nZNSF3kn1F18tpxmA86BcTxAGBQdM0N89Z3bEaIs+HVznSmFJEAmMTjA==} + engines: {node: '>=14.0.0'} + + googleapis@146.0.0: + resolution: {integrity: sha512-NewqvhnBZOJsugCAOo636O0BGE/xY7Cg/v8Rjm1+5LkJCjcqAzLleJ6igd5vrRExJLSKrY9uHy9iKE7r0PrfhQ==} + engines: {node: '>=14.0.0'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -6295,6 +6331,10 @@ packages: gsap@3.13.0: resolution: {integrity: sha512-QL7MJ2WMjm1PHWsoFrAQH/J8wUeqZvMtHO58qdekHpCfhvhSL4gSiz6vJf5EeMP0LOn3ZCprL2ki/gjED8ghVw==} + gtoken@7.1.0: + resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==} + engines: {node: '>=14.0.0'} + gunzip-maybe@1.4.2: resolution: {integrity: sha512-4haO1M4mLO91PW57BMsDFf75UmwoRX0GkdD+Faw+Lr+r/OZrOCS0pIBwOL1xCKQqnQzbNFGgK2V2CpBUPeFNTw==} hasBin: true @@ -7038,6 +7078,9 @@ packages: resolution: {integrity: sha512-l4g6GZVHrsN+5SKkpOmGNSvho+saDZwXzj/xmcO0lJAgklzwsiqy70HS5tA9djcRvBEybZ9IF6R1MDFTEsaOGQ==} engines: {node: '>= 16'} + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -7087,6 +7130,12 @@ packages: resolution: {integrity: sha512-Qush0uP+G8ZScpGMZvHUiRfI0YBWuB3gVBYlI0v0vvOJt5FLicco+IkP0a50LqTTQhmts/m6tP5SWE+USyIvcQ==} engines: {node: '>=12.20'} + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.0: + resolution: {integrity: sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==} + jwt-decode@4.0.0: resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} engines: {node: '>=18'} @@ -10115,6 +10164,9 @@ packages: url-parse@1.5.10: resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + url-template@2.0.8: + resolution: {integrity: sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==} + urlpattern-polyfill@10.0.0: resolution: {integrity: sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg==} @@ -16535,6 +16587,8 @@ snapshots: dependencies: require-from-string: 2.0.2 + bignumber.js@9.3.0: {} + binary-extensions@2.3.0: {} bindings@1.5.0: @@ -16599,6 +16653,8 @@ snapshots: buffer-crc32@1.0.0: {} + buffer-equal-constant-time@1.0.1: {} + buffer-fill@1.0.0: {} buffer-from@1.1.2: {} @@ -17516,6 +17572,10 @@ snapshots: eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + editorconfig@1.0.4: dependencies: '@one-ini/wasm': 0.1.1 @@ -18348,6 +18408,26 @@ snapshots: fuse.js@7.1.0: {} + gaxios@6.7.1: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + is-stream: 2.0.1 + node-fetch: 2.7.0 + uuid: 9.0.1 + transitivePeerDependencies: + - encoding + - supports-color + + gcp-metadata@6.1.1: + dependencies: + gaxios: 6.7.1 + google-logging-utils: 0.0.2 + json-bigint: 1.0.0 + transitivePeerDependencies: + - encoding + - supports-color + gensync@1.0.0-beta.2: {} get-amd-module-type@6.0.1: @@ -18557,6 +18637,40 @@ snapshots: dependencies: minimist: 1.2.8 + google-auth-library@9.15.1: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 6.7.1 + gcp-metadata: 6.1.1 + gtoken: 7.1.0 + jws: 4.0.0 + transitivePeerDependencies: + - encoding + - supports-color + + google-logging-utils@0.0.2: {} + + googleapis-common@7.2.0: + dependencies: + extend: 3.0.2 + gaxios: 6.7.1 + google-auth-library: 9.15.1 + qs: 6.14.0 + url-template: 2.0.8 + uuid: 9.0.1 + transitivePeerDependencies: + - encoding + - supports-color + + googleapis@146.0.0: + dependencies: + google-auth-library: 9.15.1 + googleapis-common: 7.2.0 + transitivePeerDependencies: + - encoding + - supports-color + gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -18580,6 +18694,14 @@ snapshots: gsap@3.13.0: {} + gtoken@7.1.0: + dependencies: + gaxios: 6.7.1 + jws: 4.0.0 + transitivePeerDependencies: + - encoding + - supports-color + gunzip-maybe@1.4.2: dependencies: browserify-zlib: 0.1.4 @@ -19432,6 +19554,10 @@ snapshots: deeks: 3.1.0 doc-path: 4.1.1 + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.0 + json-buffer@3.0.1: {} json-lexer@1.2.0: {} @@ -19478,6 +19604,17 @@ snapshots: junk@4.0.1: {} + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.0: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + jwt-decode@4.0.0: {} keyv@4.5.4: @@ -23553,6 +23690,8 @@ snapshots: querystringify: 2.2.0 requires-port: 1.0.0 + url-template@2.0.8: {} + urlpattern-polyfill@10.0.0: {} urlpattern-polyfill@10.1.0: {} diff --git a/scripts/import-streambeats.mjs b/scripts/import-streambeats.mjs new file mode 100644 index 00000000..a935776e --- /dev/null +++ b/scripts/import-streambeats.mjs @@ -0,0 +1,646 @@ +#!/usr/bin/env node + +/** + * This script recursively crawls Harris Heller's StreamBeats Google Drive archive, + * preserving folder structure as metadata, and uploads tracks to Sanity CMS. + * + * Features: + * - Recursive folder traversal + * - Folder names preserved as metadata (collection/category info) + * - Enhanced metadata extraction from folder paths + * - WAV file support with folder-based metadata + * - Batch processing with progress tracking + */ + +import fs from 'fs' +import path from 'path' +import { pipeline } from 'stream/promises' +import { createClient } from '@sanity/client' +import { google } from 'googleapis' + +// Configuration +const config = { + googleDriveFolderId: '196AfI6vYiSwKqb7ATKKg4J_4PcwG84jC', // Root StreamBeats folder + downloadDir: './downloads/streambeats', + maxDownloads: parseInt(process.env.MAX_DOWNLOADS || '20') || 20, + maxDepth: 7, // Prevent infinite recursion + retryAttempts: 3, + retryDelay: 2000, +} + +// Sanity client configuration +const sanityClient = createClient({ + projectId: process.env.SANITY_STUDIO_PROJECT_ID || '9bj3w2vo', + dataset: process.env.SANITY_STUDIO_DATASET || 'production', + token: process.env.NUXT_SANITY_TOKEN, + apiVersion: '2025-02-10', + useCdn: false, +}) + +// Google Drive setup +const auth = new google.auth.GoogleAuth({ + keyFile: process.env.GOOGLE_SERVICE_ACCOUNT_KEY_FILE, + scopes: ['https://www.googleapis.com/auth/drive.readonly'], +}) + +const drive = google.drive({ version: 'v3', auth }) + +/** + * Get audio file duration - enhanced for WAV files + */ +async function getAudioDuration (filePath) { + try { + // Try ffprobe first for accurate duration + try { + const { execSync } = await import('child_process') + const command = `ffprobe -v quiet -show_entries format=duration -of csv="p=0" "${filePath}"` + const output = execSync(command, { encoding: 'utf-8', timeout: 10000 }).trim() + const duration = parseFloat(output) + + if (!isNaN(duration) && duration > 0) { + console.log(`šŸ“ Accurate duration for ${path.basename(filePath)}: ${Math.round(duration)}s`) + return Math.round(duration) + } + } + catch { + // ffprobe not available or failed + } + + // Fallback: improved estimation for different audio formats + const stats = fs.statSync(filePath) + const fileSizeMB = stats.size / (1024 * 1024) + const ext = path.extname(filePath).toLowerCase() + + let estimatedDuration + switch (ext) { + case '.wav': + // WAV files are uncompressed, roughly 10MB per minute for stereo 44.1kHz + estimatedDuration = Math.max(30, Math.floor(fileSizeMB * 6)) + break + case '.mp3': + // MP3 compressed, roughly 1MB per minute + estimatedDuration = Math.max(30, Math.floor(fileSizeMB * 60)) + break + case '.flac': + // FLAC compressed lossless, roughly 5MB per minute + estimatedDuration = Math.max(30, Math.floor(fileSizeMB * 12)) + break + default: + // Generic estimation + estimatedDuration = Math.max(30, Math.floor(fileSizeMB * 30)) + } + + console.log(`šŸ“ Estimated duration for ${path.basename(filePath)}: ${estimatedDuration}s (${fileSizeMB.toFixed(1)}MB ${ext})`) + return estimatedDuration + } + catch { + console.warn(`āš ļø Could not determine duration for ${filePath}, using default`) + return 180 // 3 minutes default + } +} + +/** + * Enhanced metadata extraction from folder path and filename + */ +function parseFileMetadata (fileName, folderPath = []) { + const nameWithoutExt = path.parse(fileName).name + const tags = new Set() + let collection = 'streambeats' + let category = '' + let subCategory = '' + + // Extract metadata from folder structure + if (folderPath.length > 0) { + // First folder level often indicates main collection + const mainFolder = folderPath[0].toLowerCase() + + if (mainFolder.includes('lofi') || mainFolder.includes('lo-fi')) { + collection = 'streambeats-lofi' + category = 'Lo-Fi' + tags.add('lofi').add('chill').add('relaxed') + } + else if (mainFolder.includes('chill')) { + collection = 'streambeats-chill' + category = 'Chill' + tags.add('chill').add('relaxed') + } + else if (mainFolder.includes('study')) { + collection = 'streambeats-study' + category = 'Study' + tags.add('study').add('focus').add('concentration') + } + else if (mainFolder.includes('gaming') || mainFolder.includes('game')) { + collection = 'streambeats-gaming' + category = 'Gaming' + tags.add('gaming').add('energetic') + } + else if (mainFolder.includes('hip') && mainFolder.includes('hop')) { + collection = 'streambeats-hiphop' + category = 'Hip Hop' + tags.add('hiphop').add('urban').add('beats') + } + else if (mainFolder.includes('electronic')) { + collection = 'streambeats-electronic' + category = 'Electronic' + tags.add('electronic').add('synth').add('digital') + } + else if (mainFolder.includes('ambient')) { + collection = 'streambeats-ambient' + category = 'Ambient' + tags.add('ambient').add('atmospheric').add('peaceful') + } + else { + // Use folder name as category + category = folderPath[0] + collection = `streambeats-${mainFolder.replace(/\s+/g, '-').toLowerCase()}` + } + + // Second level folder for subcategory + if (folderPath.length > 1) { + subCategory = folderPath[1] + const subFolder = folderPath[1].toLowerCase() + + // Extract tags from subcategory + if (subFolder.includes('upbeat') || subFolder.includes('energy')) { + tags.add('upbeat').add('energetic') + } + if (subFolder.includes('dark') || subFolder.includes('moody')) { + tags.add('dark').add('moody') + } + if (subFolder.includes('epic') || subFolder.includes('cinematic')) { + tags.add('epic').add('cinematic') + } + if (subFolder.includes('funk')) { + tags.add('funk').add('groove') + } + } + } + + // Extract additional metadata from filename + const lowerName = nameWithoutExt.toLowerCase() + + // Tempo indicators + if (lowerName.includes('slow') || lowerName.includes('calm')) { + tags.add('slow').add('calm') + } + if (lowerName.includes('fast') || lowerName.includes('quick') || lowerName.includes('rapid')) { + tags.add('fast').add('energetic') + } + if (lowerName.includes('medium') || lowerName.includes('mid')) { + tags.add('medium-tempo') + } + + // Mood indicators + if (lowerName.includes('happy') || lowerName.includes('uplifting')) { + tags.add('happy').add('positive') + } + if (lowerName.includes('sad') || lowerName.includes('melancholy')) { + tags.add('sad').add('melancholy') + } + if (lowerName.includes('intense') || lowerName.includes('dramatic')) { + tags.add('intense').add('dramatic') + } + + // Always add these base tags + tags.add('streambeats').add('harris-heller').add('royalty-free').add('copyright-free') + + // If no specific tags found, add generic ones + if (tags.size <= 4) { // Only base tags + tags.add('background').add('instrumental') + } + + return { + name: nameWithoutExt, + collection, + category, + subCategory, + folderPath: folderPath.join(' > '), + tags: Array.from(tags), + } +} + +/** + * Recursively list all folders and files in Google Drive + */ +async function listDriveFoldersAndFiles (folderId, folderPath = [], depth = 0) { + if (depth > config.maxDepth) { + console.warn(`āš ļø Max depth reached for folder: ${folderPath.join(' > ')}`) + return { folders: [], files: [] } + } + + try { + console.log(`šŸ“‚ Scanning folder: ${folderPath.join(' > ') || 'Root'} (depth: ${depth})`) + + const response = await drive.files.list({ + q: `'${folderId}' in parents and trashed=false`, + fields: 'files(id, name, mimeType, size, createdTime)', + pageSize: 1000, + }) + + const items = response.data.files || [] + const folders = items.filter(item => item.mimeType === 'application/vnd.google-apps.folder') + const audioFiles = items.filter(item => + item.mimeType && ( + item.mimeType.startsWith('audio/') + || item.name?.toLowerCase().match(/\.(mp3|wav|flac|m4a|aac|ogg)$/i) + ), + ) + + console.log(` šŸ“ Found ${folders.length} subfolders, ${audioFiles.length} audio files`) + + // Collect all files from this level + const allFiles = audioFiles.map(file => ({ + ...file, + folderPath: [...folderPath], + })) + + // Recursively process subfolders + for (const folder of folders) { + const subResult = await listDriveFoldersAndFiles( + folder.id, + [...folderPath, folder.name], + depth + 1, + ) + allFiles.push(...subResult.files) + } + + return { + folders: folders.map(f => ({ ...f, folderPath })), + files: allFiles, + } + } + catch (error) { + console.error(`āŒ Error scanning folder ${folderPath.join(' > ')}:`, error.message) + return { folders: [], files: [] } + } +} + +/** + * Check if track already exists in Sanity + */ +async function trackExists (name, folderPath) { + try { + const query = `*[_type == "audioTrack" && name == $name && folderPath == $folderPath][0]` + const existing = await sanityClient.fetch(query, { name, folderPath }) + return !!existing + } + catch { + return false + } +} + +/** + * Download file from Google Drive + */ +async function downloadFile (fileId, fileName, folderPath, downloadPath) { + // Create folder structure locally + const localFolderPath = path.join(downloadPath, ...folderPath) + if (!fs.existsSync(localFolderPath)) { + fs.mkdirSync(localFolderPath, { recursive: true }) + } + + const filePath = path.join(localFolderPath, fileName) + + // Skip if already exists + if (fs.existsSync(filePath)) { + console.log(`ā­ļø File already exists: ${path.join(...folderPath, fileName)}`) + return filePath + } + + console.log(`ā¬‡ļø Downloading: ${path.join(...folderPath, fileName)}`) + + const response = await drive.files.get({ + fileId, + alt: 'media', + }, { responseType: 'stream' }) + + const writeStream = fs.createWriteStream(filePath) + await pipeline(response.data, writeStream) + + console.log(`āœ… Downloaded: ${fileName}`) + return filePath +} + +/** + * Upload file to Sanity with enhanced metadata + */ +async function uploadToSanity (filePath, metadata) { + console.log(`šŸ“¤ Uploading to Sanity: ${metadata.name}`) + + // Check if already exists + if (await trackExists(metadata.name, metadata.folderPath)) { + console.log(`ā­ļø Track already exists: ${metadata.name} (${metadata.folderPath})`) + return null + } + + // Upload the audio file + const fileStream = fs.createReadStream(filePath) + const asset = await sanityClient.assets.upload('file', fileStream, { + filename: path.basename(filePath), + }) + + // Create the audioTrack document with enhanced metadata + const audioTrack = { + _type: 'audioTrack', + name: metadata.name, + artist: 'Harris Heller', + collection: metadata.collection, + category: metadata.category, + subCategory: metadata.subCategory, + folderPath: metadata.folderPath, + audioFile: { + _type: 'file', + asset: { + _type: 'reference', + _ref: asset._id, + }, + }, + duration: metadata.duration, + tags: metadata.tags, + volume: 0.7, + isActive: true, + notes: `Imported from StreamBeats collection on ${new Date().toISOString().split('T')[0]}. Original path: ${metadata.folderPath}`, + importedAt: new Date().toISOString(), + } + + const doc = await sanityClient.create(audioTrack) + console.log(`āœ… Created Sanity document: ${doc._id}`) + return doc +} + +/** + * Main import function with recursive crawling + */ +async function importStreamBeats () { + try { + console.log('šŸŽµ Enhanced StreamBeats Import Script with Recursive Crawling...') + console.log('='.repeat(70)) + + if (!process.env.NUXT_SANITY_TOKEN) { + throw new Error('SANITY_TOKEN environment variable is required') + } + + // Create download directory + if (!fs.existsSync(config.downloadDir)) { + fs.mkdirSync(config.downloadDir, { recursive: true }) + console.log(`šŸ“ Created download directory: ${config.downloadDir}`) + } + + // Recursively scan all folders and files + console.log('šŸ” Recursively scanning StreamBeats archive...') + const { files } = await listDriveFoldersAndFiles(config.googleDriveFolderId) + + if (files.length === 0) { + console.log('šŸ“­ No audio files found in the archive') + return + } + + console.log(`\nšŸŽÆ Found ${files.length} total audio files across all folders`) + console.log(`šŸ“Š Processing up to ${config.maxDownloads} files...\n`) + + // Group files by folder for better organization + const filesByFolder = new Map() + files.forEach(file => { + const folderKey = file.folderPath.join(' > ') || 'Root' + if (!filesByFolder.has(folderKey)) { + filesByFolder.set(folderKey, []) + } + filesByFolder.get(folderKey).push(file) + }) + + console.log('šŸ“‹ Files by folder:') + for (const [folder, folderFiles] of filesByFolder.entries()) { + console.log(` šŸ“ ${folder}: ${folderFiles.length} files`) + } + console.log('') + + // Distribute maxDownloads across folders more evenly + const filesToProcess = [] + const folderEntries = Array.from(filesByFolder.entries()) + + if (folderEntries.length > 0) { + // Calculate base files per folder + const filesPerFolder = Math.floor(config.maxDownloads / folderEntries.length) + const remainder = config.maxDownloads % folderEntries.length + + console.log(`šŸ“Š Strategy: ${filesPerFolder} files per folder + ${remainder} extra files (${folderEntries.length} folders)`) + + // Track how many files we've taken from each folder + const folderFileCounts = new Map() + + // First pass: take base amount from each folder + folderEntries.forEach(([folder, folderFiles]) => { + const filesToTake = Math.min(filesPerFolder, folderFiles.length) + filesToProcess.push(...folderFiles.slice(0, filesToTake)) + folderFileCounts.set(folder, filesToTake) + console.log(` šŸ“ ${folder}: taking ${filesToTake}/${folderFiles.length} files`) + }) + + // Second pass: distribute remainder files to folders that still have files + let extraFilesAdded = 0 + let folderIndex = 0 + + while (extraFilesAdded < remainder && folderEntries.length > 0) { + const [folder, folderFiles] = folderEntries[folderIndex] + const alreadyTaken = folderFileCounts.get(folder) || 0 + + // If this folder still has files available + if (alreadyTaken < folderFiles.length) { + filesToProcess.push(folderFiles[alreadyTaken]) + folderFileCounts.set(folder, alreadyTaken + 1) + extraFilesAdded++ + console.log(` šŸ“ ${folder}: adding 1 extra file (${alreadyTaken + 1}/${folderFiles.length})`) + } + + folderIndex = (folderIndex + 1) % folderEntries.length + + // Safety check: if all folders are exhausted, break + const hasAvailableFiles = folderEntries.some(([folder, folderFiles]) => { + const taken = folderFileCounts.get(folder) || 0 + return taken < folderFiles.length + }) + + if (!hasAvailableFiles) break + } + } + + console.log(`\nšŸŽÆ Selected ${filesToProcess.length} files for processing`) + console.log('') + + // Initialize counters + let successCount = 0 + let skippedCount = 0 + let errorCount = 0 + + for (const [index, file] of filesToProcess.entries()) { + try { + console.log(`\n[${index + 1}/${filesToProcess.length}] Processing: ${file.name}`) + console.log(` šŸ“ Path: ${file.folderPath.join(' > ') || 'Root'}`) + + // Download file + const filePath = await downloadFile( + file.id, + file.name, + file.folderPath, + config.downloadDir, + ) + + // Get metadata with folder information + const fileMetadata = parseFileMetadata(file.name, file.folderPath) + const duration = await getAudioDuration(filePath) + + const metadata = { + ...fileMetadata, + duration, + } + + console.log(`šŸ“‹ Metadata:`) + console.log(` šŸ·ļø Collection: ${metadata.collection}`) + console.log(` šŸ“‚ Category: ${metadata.category}`) + console.log(` šŸ“‚ Sub-category: ${metadata.subCategory}`) + console.log(` šŸŽÆ Tags: ${metadata.tags.join(', ')}`) + console.log(` ā±ļø Duration: ${metadata.duration}s`) + + // Upload to Sanity + const result = await uploadToSanity(filePath, metadata) + + if (result) { + successCount++ + } + else { + skippedCount++ + } + + // Clean up if requested + if (process.env.CLEANUP_DOWNLOADS === 'true') { + fs.unlinkSync(filePath) + console.log(`šŸ—‘ļø Cleaned up: ${file.name}`) + } + + // Rate limiting + await new Promise(resolve => setTimeout(resolve, 1000)) + } + catch (error) { + console.error(`āŒ Failed to process ${file.name}:`, error.message) + errorCount++ + continue + } + } + + // Summary + console.log('\n' + '='.repeat(70)) + console.log('šŸ Import completed!') + console.log(`āœ… Successfully imported: ${successCount} tracks`) + console.log(`ā­ļø Skipped (already exist): ${skippedCount} tracks`) + console.log(`āŒ Failed: ${errorCount} tracks`) + console.log(`šŸ“Š Total files in archive: ${files.length}`) + console.log(`šŸ“ Downloads saved to: ${config.downloadDir}`) + + if (successCount > 0) { + console.log('\nšŸŽ‰ StreamBeats tracks imported successfully!') + console.log('šŸ’” Folder structure preserved as metadata for better organization') + console.log('šŸ”„ Refresh your Sanity studio to see the new tracks') + } + } + catch (error) { + console.error('šŸ’„ Script failed:', error.message) + process.exit(1) + } +} + +/** + * Enhanced dry run with folder structure analysis + */ +async function dryRun () { + try { + console.log('šŸ” DRY RUN: Analyzing StreamBeats folder structure...\n') + + const { files } = await listDriveFoldersAndFiles(config.googleDriveFolderId) + + if (files.length === 0) { + console.log('šŸ“­ No audio files found') + return + } + + // Analyze folder structure + const folderStats = new Map() + const collectionStats = new Map() + let totalSize = 0 + + files.forEach(file => { + const folderPath = file.folderPath.join(' > ') || 'Root' + folderStats.set(folderPath, (folderStats.get(folderPath) || 0) + 1) + + const metadata = parseFileMetadata(file.name, file.folderPath) + collectionStats.set(metadata.collection, (collectionStats.get(metadata.collection) || 0) + 1) + + totalSize += parseInt(file.size || '0') || 0 + }) + + console.log('šŸ“Š ARCHIVE ANALYSIS:') + console.log('='.repeat(50)) + console.log(`šŸ“ Total files: ${files.length}`) + console.log(`šŸ’¾ Total size: ${(totalSize / (1024 * 1024 * 1024)).toFixed(2)} GB`) + + console.log('\nšŸ“‚ FOLDER STRUCTURE:') + console.log('-'.repeat(30)) + for (const [folder, count] of [...folderStats.entries()].sort()) { + console.log(` ${folder}: ${count} files`) + } + + console.log('\nšŸ·ļø DETECTED COLLECTIONS:') + console.log('-'.repeat(30)) + for (const [collection, count] of collectionStats.entries()) { + console.log(` ${collection}: ${count} tracks`) + } + + console.log('\nšŸ’” To import these files, run: pnpm import-streambeats:recursive') + } + catch (error) { + console.error('āŒ Dry run failed:', error.message) + } +} + +// CLI handling +const command = process.argv[2] + +if (command === '--dry-run' || command === '-d') { + dryRun() +} +else if (command === '--help' || command === '-h') { + console.log(` +šŸŽµ Enhanced StreamBeats Import Script with Recursive Crawling + +Usage: + node scripts/import-streambeats-recursive.mjs [command] + +Commands: + --dry-run, -d Analyze folder structure without downloading + --help, -h Show this help message + (no command) Run the recursive import process + +Environment Variables: + SANITY_TOKEN Sanity write token (required) + SANITY_STUDIO_PROJECT_ID Sanity project ID + SANITY_STUDIO_DATASET Sanity dataset (default: production) + GOOGLE_SERVICE_ACCOUNT_KEY_FILE Path to Google service account key + CLEANUP_DOWNLOADS Set to 'true' to delete files after upload + MAX_DOWNLOADS Maximum files to process (default: 20) + +Features: + āœ… Recursive folder crawling + āœ… Folder structure preserved as metadata + āœ… Enhanced metadata extraction from paths + āœ… WAV file support with accurate duration detection + āœ… Collection detection from folder names + āœ… Category and subcategory organization + āœ… Duplicate prevention with folder context + +Examples: + SANITY_TOKEN=your_token node scripts/import-streambeats-recursive.mjs --dry-run + SANITY_TOKEN=your_token MAX_DOWNLOADS=50 node scripts/import-streambeats-recursive.mjs +`) +} +else { + importStreamBeats() +} -- 2.51.2