Kurs vibe coding z AI · Moduł 10: TikTok Clone
Duet, Stitch i Video Editing
W tej lekcji6
TikTok's collaborative features - duet (split screen) i stitch (react to clip) - są kluczowe dla viral content!
Duet Feature
interface DuetVideo {
id: string;
originalVideoId: string;
originalVideo: Video;
duetVideoUrl: string; // right side video
layout: 'side-by-side' | 'top-bottom' | 'green-screen';
audioMix: {
originalVolume: number; // 0-1
duetVolume: number; // 0-1
};
createdAt: Date;
}
// Create duet
function createDuet(original: Video, duetRecording: Blob): DuetVideo {
return {
id: generateId(),
originalVideoId: original.id,
originalVideo: original,
duetVideoUrl: URL.createObjectURL(duetRecording),
layout: 'side-by-side',
audioMix: {
originalVolume: 0.7,
duetVolume: 1.0
},
createdAt: new Date()
};
}Duet Recorder
'use client';
import { useState, useRef } from 'react';
interface DuetRecorderProps {
originalVideo: Video;
onComplete: (duetVideo: DuetVideo) => void;
}
export default function DuetRecorder({
originalVideo,
onComplete
}: DuetRecorderProps) {
const [isRecording, setIsRecording] = useState(false);
const [layout, setLayout] = useState<'side-by-side' | 'top-bottom'>('side-by-side');
const originalVideoRef = useRef<HTMLVideoElement>(null);
const userVideoRef = useRef<HTMLVideoElement>(null);
const startRecording = () => {
// Start both videos simultaneously
originalVideoRef.current?.play();
setIsRecording(true);
// Record user video...
};
return (
<div className="h-screen bg-black flex flex-col">
{/* Layout Selector */}
<div className="bg-gray-900 p-3 flex gap-2">
<button
onClick={() => setLayout('side-by-side')}
className={`px-4 py-2 rounded ${
layout === 'side-by-side'
? 'bg-white text-black'
: 'bg-gray-700 text-white'
}`}
>
Side by Side
</button>
<button
onClick={() => setLayout('top-bottom')}
className={`px-4 py-2 rounded ${
layout === 'top-bottom'
? 'bg-white text-black'
: 'bg-gray-700 text-white'
}`}
>
Top & Bottom
</button>
</div>
{/* Video Preview */}
<div className={`flex-1 flex ${
layout === 'side-by-side' ? 'flex-row' : 'flex-col'
}`}>
{/* Original Video */}
<div className="flex-1 relative">
<video
ref={originalVideoRef}
src={originalVideo.videoUrl}
className="w-full h-full object-cover"
/>
<div className="absolute top-4 left-4 bg-black/50 px-3 py-2 rounded-full text-white text-sm">
Original
</div>
</div>
{/* User Video */}
<div className="flex-1 relative border-l-2 border-white">
<video
ref={userVideoRef}
autoPlay
playsInline
muted
className="w-full h-full object-cover"
/>
<div className="absolute top-4 right-4 bg-black/50 px-3 py-2 rounded-full text-white text-sm">
You
</div>
{isRecording && (
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2">
<div className="w-16 h-16 border-4 border-red-500 rounded-full animate-pulse"></div>
</div>
)}
</div>
</div>
{/* Controls */}
<div className="bg-black p-6">
<div className="flex justify-center">
<button
onClick={startRecording}
className="w-20 h-20 bg-red-500 rounded-full border-4 border-white flex items-center justify-center"
>
{isRecording ? (
<div className="w-8 h-8 bg-white rounded"></div>
) : (
<span className="text-white text-3xl">⏺</span>
)}
</button>
</div>
</div>
</div>
);
}Stitch Feature
interface StitchVideo {
id: string;
originalVideoId: string;
originalClip: {
videoUrl: string;
startTime: number; // which second to start
duration: number; // 1-5 seconds
};
reactionVideoUrl: string; // your reaction
createdAt: Date;
}
// Stitch allows using 1-5 second clip from original video,
// then adding your reactionStitch Creator
'use client';
import { useState } from 'react';
interface StitchCreatorProps {
originalVideo: Video;
onComplete: (stitch: StitchVideo) => void;
}
export default function StitchCreator({
originalVideo,
onComplete
}: StitchCreatorProps) {
const [clipStart, setClipStart] = useState(0);
const [clipDuration, setClipDuration] = useState(5);
const [phase, setPhase] = useState<'select-clip' | 'record-reaction'>('select-clip');
return (
<div className="min-h-screen bg-black text-white p-4">
{phase === 'select-clip' && (
<>
<h2 className="text-2xl font-bold mb-4">Select a clip (1-5 seconds)</h2>
{/* Video Preview */}
<video
src={originalVideo.videoUrl}
className="w-full rounded-lg mb-4"
controls
/>
{/* Timeline Selector */}
<div className="mb-6">
<div className="flex items-center justify-between mb-2 text-sm">
<span>Start: {clipStart.toFixed(1)}s</span>
<span>Duration: {clipDuration}s</span>
</div>
<input
type="range"
min={0}
max={originalVideo.duration - clipDuration}
step={0.1}
value={clipStart}
onChange={(e) => setClipStart(parseFloat(e.target.value))}
className="w-full"
/>
<input
type="range"
min={1}
max={5}
value={clipDuration}
onChange={(e) => setClipDuration(parseInt(e.target.value))}
className="w-full mt-2"
/>
</div>
<button
onClick={() => setPhase('record-reaction')}
className="w-full py-4 bg-red-500 rounded-full font-bold"
>
Next: Record Reaction
</button>
</>
)}
{phase === 'record-reaction' && (
<>
<h2 className="text-2xl font-bold mb-4">Record your reaction</h2>
<div className="space-y-4 mb-6">
{/* Selected Clip Preview */}
<div className="bg-gray-900 rounded-lg p-4">
<p className="text-sm text-gray-400 mb-2">Your selected clip:</p>
<p className="font-semibold">
{clipStart.toFixed(1)}s - {(clipStart + clipDuration).toFixed(1)}s
</p>
</div>
{/* Instructions */}
<div className="bg-blue-900/30 rounded-lg p-4 border border-blue-500">
<p className="text-sm">
The clip will play first, then you can record your reaction
</p>
</div>
</div>
<button className="w-full py-4 bg-red-500 rounded-full font-bold">
Start Recording
</button>
</>
)}
</div>
);
}Video Trimmer & Editor
'use client';
interface VideoEditorProps {
videoBlob: Blob;
onSave: (editedVideo: Blob, metadata: any) => void;
}
export default function VideoEditor({ videoBlob, onSave }: VideoEditorProps) {
const [trimStart, setTrimStart] = useState(0);
const [trimEnd, setTrimEnd] = useState(60);
const [filters, setFilters] = useState<Filter[]>([]);
const [caption, setCaption] = useState('');
const videoUrl = URL.createObjectURL(videoBlob);
return (
<div className="min-h-screen bg-black text-white p-4">
<h2 className="text-2xl font-bold mb-4">Edit Video</h2>
{/* Video Preview */}
<video
src={videoUrl}
className="w-full rounded-lg mb-6"
controls
/>
{/* Trim Timeline */}
<div className="mb-6">
<h3 className="font-bold mb-2">Trim</h3>
<div className="flex items-center gap-4">
<input
type="range"
min={0}
max={trimEnd}
value={trimStart}
onChange={(e) => setTrimStart(parseFloat(e.target.value))}
className="flex-1"
/>
<span className="text-sm">{trimStart.toFixed(1)}s</span>
</div>
<div className="flex items-center gap-4 mt-2">
<input
type="range"
min={trimStart}
max={60}
value={trimEnd}
onChange={(e) => setTrimEnd(parseFloat(e.target.value))}
className="flex-1"
/>
<span className="text-sm">{trimEnd.toFixed(1)}s</span>
</div>
</div>
{/* Filters */}
<div className="mb-6">
<h3 className="font-bold mb-2">Filters</h3>
<div className="flex gap-2 overflow-x-auto">
{['None', 'Vintage', 'B&W', 'Warm', 'Cool', 'Vibrant'].map(filter => (
<button
key={filter}
className="px-4 py-2 bg-gray-800 rounded-full whitespace-nowrap"
>
{filter}
</button>
))}
</div>
</div>
{/* Caption */}
<div className="mb-6">
<h3 className="font-bold mb-2">Caption</h3>
<textarea
value={caption}
onChange={(e) => setCaption(e.target.value)}
placeholder="Napisz podpis..."
className="w-full bg-gray-900 text-white p-3 rounded-lg resize-none"
rows={3}
/>
<p className="text-xs text-gray-500 mt-1">
{caption.length}/150 characters
</p>
</div>
{/* Post Button */}
<button
onClick={() => onSave(videoBlob, { trimStart, trimEnd, filters, caption })}
className="w-full py-4 bg-red-500 rounded-full font-bold"
>
Post
</button>
</div>
);
}Podsumowanie
Duet - split screen z original video Duet layouts - side-by-side, top-bottom Audio mix - balance between original & duet Stitch - use 1-5s clip + your reaction Clip selector - wybierz moment z original video Video trimmer - cut start/end Filters - vintage, B&W, warm, cool effects Caption editor - text + hashtags Simultaneous playback - original plays while recording duet
Masz teraz kompletny TikTok clone z vertical feed, sounds, duet/stitch i editing!
Do zobaczenia!
Kod do tej lekcji: App.jsx
// Strona odkrywania dźwięków
import { useState } from 'react';
const sounds = [
{ id: 1, name: "Hymn piratów", artist: 'Redbeard', useCount: 1250000, isTrending: true, icon: '' },
{ id: 2, name: 'Szanta morska', artist: 'Załoga Morgana', useCount: 890000, isTrending: true, icon: '' },
{ id: 3, name: 'Skarb odnaleziony', artist: 'Jack Sparrow', useCount: 2340000, isTrending: true, icon: '' },
{ id: 4, name: 'Motyw bitewny', artist: 'Czarnobrody', useCount: 450000, isTrending: false, icon: '' }
];
export default function App() {
const [activeTab, setActiveTab] = useState('popularne');
const [searchQuery, setSearchQuery] = useState('');
const filteredSounds = sounds.filter(sound => {
if (searchQuery) {
return sound.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
sound.artist.toLowerCase().includes(searchQuery.toLowerCase());
}
if (activeTab === 'popularne') return sound.isTrending;
return true;
});
const formatCount = (count) => {
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
if (count >= 1000) return `${(count / 1000).toFixed(1)}K`;
return count.toString();
};
return (
<div className="min-h-screen bg-black text-white">
{/* Nagłówek */}
<div className="sticky top-0 bg-black z-10 border-b border-gray-800">
<div className="p-4">
<h1 className="text-2xl font-bold mb-4">Dźwięki</h1>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Szukaj dźwięków..."
className="w-full bg-gray-900 text-white px-4 py-3 rounded-lg outline-none"
/>
</div>
{/* Zakładki */}
<div className="flex border-b border-gray-800">
{['popularne', 'ulubione', 'odkrywaj'].map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`flex-1 py-3 text-sm font-semibold capitalize ${
activeTab === tab ? 'border-b-2 border-white' : 'text-gray-500'
}`}
>
{tab}
</button>
))}
</div>
</div>
{/* Lista dźwięków */}
<div className="p-4 space-y-3">
{filteredSounds.map(sound => (
<div key={sound.id} className="bg-gray-900 rounded-lg p-4 flex items-center gap-4">
{/* Ikona */}
<div className="w-16 h-16 rounded-lg bg-gradient-to-r from-pink-500 to-purple-500 flex items-center justify-center text-4xl">
{sound.icon}
</div>
{/* Informacje */}
<div className="flex-1">
<h3 className="font-bold text-white">{sound.name}</h3>
<p className="text-sm text-gray-400">{sound.artist}</p>
<div className="flex items-center gap-3 mt-1 text-xs text-gray-500">
<span>{formatCount(sound.useCount)} filmów</span>
{sound.isTrending && (
<span className="flex items-center gap-1 text-pink-500">
Popularne
</span>
)}
</div>
</div>
{/* Akcje */}
<div className="flex flex-col gap-2">
<button className="px-4 py-2 bg-white text-black rounded-full text-sm font-semibold hover:bg-gray-200">
Użyj dźwięku
</button>
<button className="p-2 hover:bg-gray-800 rounded-full">Ulubione</button>
</div>
</div>
))}
</div>
</div>
);
}Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Czym jest duet w TikTok?
2. Co opisuje obiekt audioMix w duecie?
To 2 z 9 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Układanie w pionie
Uporządkuj wartości typu layout duetu w kolejności z definicji (z góry):
- Układanie w pionie
Uporządkuj pola zwracane przez createDuet (w kolejności z kodu):
- Edytor kodu
Duet recording interface
- Układanie w poziomie
Ułóż kroki tworzenia stitch:
- Układanie w pionie
Uporządkuj fazy tworzenia stitch w StitchCreator (od góry):
- Edytor kodu
Video editing interface
- Układanie w poziomie
Ułóż linię tworzącą URL podglądu z videoBlob:
- Klikanie w kolejności
Zbuduj odczyt liczby znaków wpisanego caption (klikaj po kolei):
- Układanie w pionie
Uporządkuj sekcje edytora wideo w kolejności z interfejsu (od góry):
- Klikanie w kolejności
Zbuduj ustawienie layoutu duetu na side-by-side (klikaj po kolei):
- Układanie w pionie
Uporządkuj pełny przepływ użytkownika w Treasure Reels (od góry):
- Układanie w poziomie
Ułóż wyrażenie obliczające koniec wybranego klipu stitch:
- Klikanie w kolejności
Zbuduj nagłówek strony Sounds w JSX (klikaj po kolei):