Vibe coding with AI course ยท Module 10: TikTok Clone

Duet, Stitch, and Video Editing

5 min read
In this lesson6

TikTok's collaborative features - duet (split screen) and stitch (react to a clip) - are key to 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 reaction

Stitch 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="Write a caption..."
          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>
  );
}

Summary

Duet - split screen with original video Duet layouts - side-by-side, top-bottom Audio mix - balance between original & duet Stitch - use 1-5s clip + your reaction Clip selector - choose a moment from 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

You now have a complete TikTok clone with vertical feed, sounds, duet/stitch, and editing!

See you soon!

Code for this lesson: App.jsx
// Sounds Discovery Page
import { useState } from 'react';

const sounds = [
  { id: 1, name: "Pirate's Anthem", artist: 'Redbeard', useCount: 1250000, isTrending: true, icon: '' },
  { id: 2, name: 'Sea Shanty', artist: 'Morgan Crew', useCount: 890000, isTrending: true, icon: '' },
  { id: 3, name: 'Treasure Found', artist: 'Jack Sparrow', useCount: 2340000, isTrending: true, icon: '' },
  { id: 4, name: 'Battle Theme', artist: 'Blackbeard', useCount: 450000, isTrending: false, icon: '' }
];

export default function App() {
  const [activeTab, setActiveTab] = useState('trending');
  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 === 'trending') 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">
      {/* Header */}
      <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">Sounds</h1>

          <input
            type="text"
            value={searchQuery}
            onChange={(e) => setSearchQuery(e.target.value)}
            placeholder="Search sounds..."
            className="w-full bg-gray-900 text-white px-4 py-3 rounded-lg outline-none"
          />
        </div>

        {/* Tabs */}
        <div className="flex border-b border-gray-800">
          {['trending', 'favorites', 'discover'].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>

      {/* Sounds List */}
      <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">
            {/* Icon */}
            <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>

            {/* Info */}
            <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)} videos</span>
                {sound.isTrending && (
                  <span className="flex items-center gap-1 text-pink-500">
                    Trending
                  </span>
                )}
              </div>
            </div>

            {/* Actions */}
            <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">
                Use Sound
              </button>
              <button className="p-2 hover:bg-gray-800 rounded-full">Favorite</button>
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. What is a duet in TikTok?

  2. 2. What does the audioMix object describe in a duet?

These are 2 of 9 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Vertical ordering

    Order the duet layout type values as they appear in the definition (top to bottom):

  • Vertical ordering

    Order the fields returned by createDuet (as they appear in the code):

  • Code editor

    Duet recording interface

  • Horizontal ordering

    Arrange the steps of creating a stitch:

  • Vertical ordering

    Order the stitch creation phases in StitchCreator (top to bottom):

  • Code editor

    Video editing interface

  • Horizontal ordering

    Arrange the line creating the preview URL from videoBlob:

  • Click in order

    Build the read of the entered caption's character count (click in order):

  • Vertical ordering

    Order the video editor sections as they appear in the interface (top to bottom):

  • Click in order

    Build setting the duet layout to side-by-side (click in order):

  • Vertical ordering

    Order the full user flow in Treasure Reels (top to bottom):

  • Horizontal ordering

    Arrange the expression computing the end of the selected stitch clip:

  • Click in order

    Build the Sounds page heading in JSX (click in order):

Useful articles