carolnc's picture
# Product Requirements Document (PRD): CatCut
4580c1b verified
/**
* CatCut - Edit by Narrative
* Main Application Logic
* Text Controls Video.
*/
class CatCutApp {
constructor() {
this.currentView = 'story'; // 'story' or 'visual'
this.isPlaying = false;
this.currentTime = 0;
this.duration = 0;
this.segments = [];
this.clips = [];
this.lockedClips = new Set();
this.init();
}
init() {
this.setupEventListeners();
this.setupVideoPlayer();
this.setupTextEditor();
this.setupFab();
this.setupExport();
this.setupVoiceMode();
this.simulateLoading();
this.initializeMockData();
}
simulateLoading() {
setTimeout(() => {
const overlay = document.getElementById('loading-overlay');
overlay.style.opacity = '0';
setTimeout(() => {
overlay.style.display = 'none';
}, 300);
}, 1500);
}
initializeMockData() {
// Mock story segments
this.segments = [
{
id: 'segment-1',
clipId: 'clip-1',
timestamp: 0,
text: 'The morning sun filters through the window as I start my daily routine.',
keywords: ['morning', 'sun', 'window', 'routine'],
locked: false,
matched: true
},
{
id: 'segment-2',
clipId: 'clip-2',
timestamp: 5,
text: 'A perfect cup of coffee, the aroma filling the quiet space.',
keywords: ['coffee', 'aroma', 'quiet', 'space'],
locked: false,
matched: true
},
{
id: 'segment-3',
clipId: 'clip-3',
timestamp: 10,
text: 'Time to focus and create something meaningful today.',
keywords: ['focus', 'create', 'meaningful', 'today'],
locked: false,
matched: false
}
];
// Mock video clips
this.clips = [
{
id: 'clip-1',
name: 'Morning Window',
duration: 5,
thumbnail: 'http://static.photos/nature/120x67/42',
matched: true,
keywords: ['morning', 'sun', 'window']
},
{
id: 'clip-2',
name: 'Coffee Aroma',
duration: 5,
thumbnail: 'http://static.photos/food/120x67/133',
matched: true,
keywords: ['coffee', 'aroma', 'steam']
},
{
id: 'clip-3',
name: 'Workspace',
duration: 8,
thumbnail: 'http://static.photos/workspace/120x67/99',
matched: false,
keywords: ['work', 'desk', 'computer']
}
];
this.renderClips();
}
setupEventListeners() {
// View switching
document.addEventListener('viewChanged', (e) => {
this.switchView(e.detail.view);
});
// Theme switching
const themeToggle = document.querySelector('#theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', () => this.toggleTheme());
}
// Import button
const importBtn = document.getElementById('import-btn');
if (importBtn) {
importBtn.addEventListener('click', () => this.openImport());
}
// Add segment button
const addSegmentBtn = document.getElementById('add-segment-btn');
if (addSegmentBtn) {
addSegmentBtn.addEventListener('click', () => this.addSegment());
}
// AI Polish button
const aiPolishBtn = document.getElementById('ai-polish-btn');
if (aiPolishBtn) {
aiPolishBtn.addEventListener('click', () => this.aiPolish());
}
// Lock all button
const lockAllBtn = document.getElementById('lock-all-btn');
if (lockAllBtn) {
lockAllBtn.addEventListener('click', () => this.toggleLockAll());
}
// Segment locking
document.addEventListener('click', (e) => {
if (e.target.closest('.lock-btn')) {
const btn = e.target.closest('.lock-btn');
this.toggleSegmentLock(btn);
}
});
// AI suggestion application
document.addEventListener('click', (e) => {
if (e.target.textContent === 'Apply →') {
this.applyAISuggestion(e.target);
}
});
// Modal controls
const exportCancel = document.getElementById('export-cancel');
const exportConfirm = document.getElementById('export-confirm');
if (exportCancel) exportCancel.addEventListener('click', () => this.closeExport());
if (exportConfirm) exportConfirm.addEventListener('click', () => this.confirmExport());
}
setupVideoPlayer() {
const video = document.getElementById('main-video');
const playBtn = document.getElementById('play-btn');
const progress = document.getElementById('video-progress');
const emptyState = document.getElementById('empty-state');
if (!video) return;
// Video loaded
video.addEventListener('loadedmetadata', () => {
this.duration = video.duration;
if (emptyState) emptyState.style.display = 'none';
});
// Play/Pause toggle
if (playBtn) {
playBtn.addEventListener('click', () => {
if (this.isPlaying) {
video.pause();
} else {
video.play();
}
});
}
// Video playing
video.addEventListener('play', () => {
this.isPlaying = true;
if (playBtn) {
playBtn.innerHTML = '<i data-feather="pause" class="w-8 h-8 text-white"></i>';
feather.replace();
}
});
video.addEventListener('pause', () => {
this.isPlaying = false;
if (playBtn) {
playBtn.innerHTML = '<i data-feather="play" class="w-8 h-8 text-white ml-1"></i>';
feather.replace();
}
});
// Time update
video.addEventListener('timeupdate', () => {
this.currentTime = video.currentTime;
const progressPercent = (this.currentTime / this.duration) * 100;
if (progress) {
progress.style.width = `${progressPercent}%`;
}
this.highlightActiveSegment();
});
// Video ended
video.addEventListener('ended', () => {
this.isPlaying = false;
if (progress) progress.style.width = '0%';
if (playBtn) {
playBtn.innerHTML = '<i data-feather="play" class="w-8 h-8 text-white ml-1"></i>';
feather.replace();
}
});
}
setupTextEditor() {
const editor = document.getElementById('text-editor');
if (!editor) return;
// Make segments editable
editor.addEventListener('input', (e) => {
if (e.target.classList.contains('segment-text')) {
this.updateSegmentText(e.target);
}
});
// Segment clicking
editor.addEventListener('click', (e) => {
const segment = e.target.closest('.story-segment');
if (segment) {
this.selectSegment(segment);
}
});
}
setupFab() {
const fab = document.getElementById('fab-toggle-view');
if (!fab) return;
fab.addEventListener('click', () => {
this.toggleView();
});
}
setupExport() {
const exportBtn = document.querySelector('#export-btn');
if (exportBtn) {
exportBtn.addEventListener('click', () => this.openExport());
}
// Platform selection
document.addEventListener('click', (e) => {
if (e.target.closest('.platform-btn')) {
const btn = e.target.closest('.platform-btn');
this.selectPlatform(btn);
}
});
}
setupVoiceMode() {
const voiceBtn = document.getElementById('voice-record-btn');
if (!voiceBtn) return;
let isRecording = false;
voiceBtn.addEventListener('click', () => {
isRecording = !isRecording;
if (isRecording) {
voiceBtn.classList.add('voice-recording');
voiceBtn.innerHTML = '<i data-feather="square" class="w-4 h-4"></i><span>Stop Recording</span>';
feather.replace();
this.startVoiceRecording();
} else {
voiceBtn.classList.remove('voice-recording');
voiceBtn.innerHTML = '<i data-feather="mic" class="w-4 h-4"></i><span>Record Narration</span>';
feather.replace();
this.stopVoiceRecording();
}
});
}
toggleView() {
const newView = this.currentView === 'story' ? 'visual' : 'story';
this.currentView = newView;
// Dispatch custom event for components
document.dispatchEvent(new CustomEvent('viewChanged', {
detail: { view: newView }
}));
// Update FAB icon
this.updateFabIcon();
}
switchView(view) {
const narrativeDeck = document.getElementById('narrative-deck');
const videoStage = document.getElementById('video-stage');
if (view === 'visual') {
// Switch to visual/timeline view
narrativeDeck.style.flex = '0.8';
videoStage.style.flex = '1.5';
// Replace narrative deck content with timeline
if (!document.querySelector('clip-timeline')) {
const timeline = document.createElement('clip-timeline');
narrativeDeck.appendChild(timeline);
}
} else {
// Switch back to story view
narrativeDeck.style.flex = '1';
videoStage.style.flex = '1.2';
// Remove timeline if exists
const timeline = document.querySelector('clip-timeline');
if (timeline) {
timeline.remove();
}
}
}
updateFabIcon() {
const storyIcon = document.getElementById('story-icon');
const visualIcon = document.getElementById('visual-icon');
if (this.currentView === 'story') {
storyIcon.classList.add('active');
storyIcon.classList.remove('inactive');
visualIcon.classList.add('inactive');
visualIcon.classList.remove('active');
} else {
visualIcon.classList.add('active');
visualIcon.classList.remove('inactive');
storyIcon.classList.add('inactive');
storyIcon.classList.remove('active');
}
}
selectSegment(segmentEl) {
// Remove active from all segments
document.querySelectorAll('.story-segment').forEach(s => s.classList.remove('active'));
// Add active to clicked segment
segmentEl.classList.add('active');
// Sync video to segment
const timestamp = parseFloat(segmentEl.dataset.timestamp);
const video = document.getElementById('main-video');
if (video && !isNaN(timestamp)) {
video.currentTime = timestamp;
}
}
highlightActiveSegment() {
const segments = document.querySelectorAll('.story-segment');
segments.forEach((segment, index) => {
const startTime = parseFloat(segment.dataset.timestamp);
const endTime = index < segments.length - 1 ?
parseFloat(segments[index + 1].dataset.timestamp) :
this.duration;
if (this.currentTime >= startTime && this.currentTime < endTime) {
segment.classList.add('active');
} else {
segment.classList.remove('active');
}
});
}
toggleSegmentLock(btn) {
const segment = btn.closest('.story-segment');
const clipId = segment.dataset.clipId;
const isLocked = btn.dataset.locked === 'true';
btn.dataset.locked = !isLocked;
btn.innerHTML = !isLocked ?
'<i data-feather="lock" class="w-4 h-4"></i>' :
'<i data-feather="unlock" class="w-4 h-4"></i>';
feather.replace();
if (!isLocked) {
this.lockedClips.add(clipId);
segment.classList.add('locked');
} else {
this.lockedClips.delete(clipId);
segment.classList.remove('locked');
}
}
updateSegmentText(textEl) {
const segment = textEl.closest('.story-segment');
const segmentId = segment.dataset.clipId;
// Simulate AI re-matching
this.simulateAIMatching(segment, textEl.textContent);
// Update segment data
const segmentData = this.segments.find(s => s.clipId === segmentId);
if (segmentData) {
segmentData.text = textEl.textContent;
}
}
simulateAIMatching(segment, text) {
const matchStatus = segment.querySelector('.match-status');
if (matchStatus) {
matchStatus.textContent = '🔍 Analyzing...';
matchStatus.className = 'match-status searching';
setTimeout(() => {
matchStatus.textContent = '✓ Re-matched: ' + text.split(' ').slice(0, 3).join(', ');
matchStatus.className = 'match-status matched';
}, 1000);
}
}
renderClips() {
// This would be called by the timeline component
// For now, just a placeholder
}
addSegment() {
const editor = document.getElementById('text-editor');
const newSegment = document.createElement('div');
newSegment.className = 'story-segment';
newSegment.dataset.timestamp = this.segments.length * 5;
newSegment.dataset.clipId = `clip-${this.segments.length + 1}`;
newSegment.innerHTML = `
<div class="segment-controls">
<button class="lock-btn" data-locked="false">
<i data-feather="unlock" class="w-4 h-4"></i>
</button>
</div>
<p class="segment-text" contenteditable="true">New story segment. Describe what should appear here.</p>
<div class="segment-metadata">
<span class="match-status searching">🔍 Searching for matches...</span>
</div>
`;
editor.appendChild(newSegment);
feather.replace();
// Add to segments array
this.segments.push({
id: `segment-${this.segments.length + 1}`,
clipId: `clip-${this.segments.length + 1}`,
timestamp: this.segments.length * 5,
text: 'New story segment. Describe what should appear here.',
keywords: [],
locked: false,
matched: false
});
}
aiPolish() {
const btn = document.getElementById('ai-polish-btn');
const originalText = btn.innerHTML;
btn.innerHTML = '<i data-feather="loader" class="w-3 h-3 animate-spin"></i><span>Polishing...</span>';
feather.replace();
setTimeout(() => {
// Simulate AI rewriting text
const segments = document.querySelectorAll('.segment-text');
segments.forEach(textEl => {
if (!textEl.closest('.locked')) {
const currentText = textEl.textContent;
textEl.textContent = this.polishText(currentText);
}
});
btn.innerHTML = originalText;
feather.replace();
// Show success feedback
this.showNotification('AI successfully polished your story!', 'success');
}, 2000);
}
polishText(text) {
// Simple text enhancement simulation
const enhancements = [
'As golden rays illuminate the space, ',
'With aromatic steam rising, ',
'In this moment of clarity, ',
'The serene atmosphere whispers ',
'Every detail tells a story of '
];
const randomEnhancement = enhancements[Math.floor(Math.random() * enhancements.length)];
return randomEnhancement + text.toLowerCase();
}
toggleLockAll() {
const lockAllBtn = document.getElementById('lock-all-btn');
const isCurrentlyLocked = lockAllBtn.dataset.locked === 'true';
const lockButtons = document.querySelectorAll('.lock-btn');
lockButtons.forEach(btn => {
const segmentLocked = btn.dataset.locked === 'true';
if (!isCurrentlyLocked && !segmentLocked) {
btn.click();
} else if (isCurrentlyLocked && segmentLocked) {
btn.click();
}
});
lockAllBtn.dataset.locked = !isCurrentlyLocked;
lockAllBtn.innerHTML = !isCurrentlyLocked ?
'<i data-feather="unlock" class="w-3 h-3"></i><span>Unlock All</span>' :
'<i data-feather="lock" class="w-3 h-3"></i><span>Lock All</span>';
feather.replace();
}
applyAISuggestion(btn) {
const card = btn.closest('.ai-suggestion-card');
card.style.opacity = '0.5';
btn.textContent = '✓ Applied';
setTimeout(() => {
card.style.opacity = '1';
}, 500);
this.showNotification('AI suggestion applied successfully!', 'success');
}
startVoiceRecording() {
// Simulate voice recording
this.showNotification('Recording started... Speak your story!', 'info');
}
stopVoiceRecording() {
// Simulate transcription
this.showNotification('Transcribing your voice...', 'info');
setTimeout(() => {
const editor = document.getElementById('text-editor');
const voiceText = 'I woke up feeling energized today. The sunlight was perfect for filming. I grabbed my coffee and started creating.';
// Clear existing segments
editor.innerHTML = '';
this.segments = [];
// Add new segments from voice
const sentences = voiceText.split('.').filter(s => s.trim());
sentences.forEach((sentence, index) => {
if (sentence.trim()) {
const newSegment = document.createElement('div');
newSegment.className = 'story-segment';
newSegment.dataset.timestamp = index * 4;
newSegment.dataset.clipId = `clip-${index + 1}`;
newSegment.innerHTML = `
<div class="segment-controls">
<button class="lock-btn" data-locked="false">
<i data-feather="unlock" class="w-4 h-4"></i>
</button>
</div>
<p class="segment-text" contenteditable="true">${sentence.trim()}.</p>
<div class="segment-metadata">
<span class="match-status matched">✓ Matched from voice</span>
</div>
`;
editor.appendChild(newSegment);
this.segments.push({
id: `segment-${index + 1}`,
clipId: `clip-${index + 1}`,
timestamp: index * 4,
text: sentence.trim() + '.',
keywords: sentence.trim().toLowerCase().split(' '),
locked: false,
matched: true
});
}
});
feather.replace();
this.showNotification('Voice transcription complete! Video synced to your narration.', 'success');
}, 3000);
}
openImport() {
this.showNotification('Import dialog opened (simulation)', 'info');
// In real app, would open file picker
}
toggleTheme() {
const html = document.documentElement;
const currentTheme = html.getAttribute('data-theme');
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
html.setAttribute('data-theme', newTheme);
this.showNotification(`Switched to ${newTheme} mode`, 'info');
}
openExport() {
const modal = document.getElementById('export-modal');
modal.classList.add('active');
}
closeExport() {
const modal = document.getElementById('export-modal');
modal.classList.remove('active');
}
confirmExport() {
this.closeExport();
this.showNotification('Export started! Preparing your story...', 'info');
// Simulate export progress
let progress = 0;
const interval = setInterval(() => {
progress += Math.random() * 20;
if (progress >= 100) {
progress = 100;
clearInterval(interval);
this.showNotification('Export complete! Your video is ready.', 'success');
}
}, 500);
}
selectPlatform(btn) {
document.querySelectorAll('.platform-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
}
showNotification(message, type = 'info') {
// Create notification element
const notification = document.createElement('div');
notification.className = `fixed top-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg transform translate-x-full transition-transform duration-300`;
// Set colors based on type
const colors = {
success: 'bg-green-500 text-white',
error: 'bg-red-500 text-white',
info: 'bg-amber-500 text-white',
warning: 'bg-yellow-500 text-black'
};
notification.className += ` ${colors[type] || colors.info}`;
notification.textContent = message;
document.body.appendChild(notification);
// Animate in
setTimeout(() => {
notification.style.transform = 'translateX(0)';
}, 100);
// Auto remove
setTimeout(() => {
notification.style.transform = 'translateX(full)';
setTimeout(() => notification.remove(), 300);
}, 3000);
}
}
// Initialize app when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
window.catCutApp = new CatCutApp();
});