Tiktok Slider image video

A photo post looks like a video in the TikTok feed, but there is no video behind it. Here is how I detect them, download the photos and music, and stitch them into a real MP4.

I run Clipzy, a free video downloader for TikTok, Instagram, YouTube, Facebook and Pinterest. One day a user pasted a TikTok link, tapped Download, and got an MP4 that played only music. No pictures at all.

The link was a photo post: a set of images you swipe through, with a song underneath. In the app it feels like a video. Under the hood it is not one.

This article walks through how I fixed it: spotting photo posts, saving the photos, and building an actual slideshow video with ffmpeg, including the bug that made my first slideshows completely silent.

Why photo posts break downloaders
Most downloaders look for one thing: a video URL. For a photo post, the API response still has a “play” URL, so the tool happily downloads it. The catch is what that URL points to. For a photo post, it is the audio track.

The response I got from my extraction source for a 5-photo post looked roughly like this (trimmed):

`{ "duration": 0, "play": "https://...tiktokcdn.../?mime_type=audio_mpeg...", "music_info": { "title": "original sound", "duration": 20 }, "images": [ "https://...photomode-image.jpeg?...", "https://...photomode-image.jpeg?...", "... 3 more" ] }` 

Three clues stand out: duration is 0, play has mime_type=audio_mpeg, and there is an images array. The pictures were there all along; my code just ignored them.

Step 1: Detect the photo post and keep the images
The fix in the extractor was small. Read images, keep only real URLs, and pass them through to the API response:

const images = Array.isArray(d.images) ? d.images.filter((u) => typeof u === 'string' && u.startsWith('http')) : []; return { title: title || (images.length ? 'TikTok Photos' : 'TikTok Video'), thumbnail, downloadUrl, ...(images.length ? { images } : {}), }; 

One more detail mattered. My preview pipeline races two extractors in parallel (Promise.any), and whichever answers first wins.

The second extractor does not understand photo posts and returns only the music, so it could win the race with the wrong answer. Photo links contain /photo/, so I route those straight to the extractor that returns the image list:

const extraction = /\/photo\//.test(url) ? extractViaTikwm(url, getProxyPool(), { timeoutMs: 6_500, maxAttempts: 2 }) : runParallelLayers(url, pipelineStart, requestId); 

On the frontend, an images array switches the result card from a quality slider to a photo grid, with a download button on every photo and a "Download all photos" button. That alone solved the main complaint. But a lot of people want the post as one video they can repost, so the next step was building one.

Screenshot From App
Step 2: Get the music (and the bug that made it silent)
My first version fetched the song with yt-dlp, the same path my “Audio (MP3)” button uses for normal videos. If that failed, the code built a silent slideshow, on purpose, so the user still got something.

It shipped. The video downloaded. And it had no sound.

The reason: yt-dlp usually can’t read TikTok /photo/ links, so the music step failed every time and the silent fallback kicked in quietly. Everything looked like it worked, which is the worst kind of bug.

The fix had two parts. First, try the source that actually understands photo posts. For these posts its stream is the song, so the normal TikTok download returns exactly the audio file I need. yt-dlp stays as a fallback. Second, never trust a file just because it downloaded: check that it really contains an audio stream before using it.

const musicSources: Array<[string, () => Promise<{ filePath: string }>]> = [ ['tikwm', () => downloadTikTokFile(sourceUrl)], ['yt-dlp', () => downloadWithYtDlp(sourceUrl, 'audio-mp3', undefined, 'tiktok')], ]; for (const [source, fetchMusic] of musicSources) { try { const { filePath } = await fetchMusic(); const seconds = await probeAudioDuration(filePath); if (seconds > 0) { musicPath = filePath; musicSeconds = seconds; break; } } catch (err) { logger.warn({ source, err: (err as Error).message }, '[slideshow] music source failed'); } } 

The audio check uses ffprobe twice: once to confirm there is an audio stream, once to read the length.

async function probeAudioDuration(file: string): Promise<number> { const streams = await ffprobe(['-v', 'error', '-select_streams', 'a', '-show_entries', 'stream=codec_type', '-of', 'csv=p=0', file]); if (!streams.includes('audio')) return 0; const out = await ffprobe(['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', file]); const n = parseFloat(out.trim()); return Number.isFinite(n) && n > 0 ? n : 0; } 

The lesson I keep relearning: when you add a graceful fallback, also add a log line that says the fallback happened. Otherwise “graceful” just means “silently wrong”.

Audio Result Page From App

Step 3: Decide how long each photo stays on screen
A slideshow should follow the song, so I spread the photos evenly over its length, within limits that feel natural: never less than 2.5 seconds per photo, never more than 5. With no music, each photo gets 3 seconds.

const perPhoto = musicSeconds > 0 ? clamp(musicSeconds / photoPaths.length, 2.5, 5) : 3; const total = +(perPhoto * photoPaths.length).toFixed(2); 

For my test post, 5 photos over a 20-second song works out to 4 seconds per photo.

Step 4: Build the video with ffmpeg
ffmpeg’s concat demuxer turns a list of images into a sequence. The list file gives each image a duration. One quirk: the last image has to be listed twice, or its duration is ignored.

const lines = photoPaths.flatMap((p) => [`file '${p}'`, `duration ${perPhoto.toFixed(2)}`]); lines.push(`file '${photoPaths[photoPaths.length - 1]}'`); await fs.promises.writeFile(listPath, lines.join('\n')); 

TikTok photos come in different shapes, so every frame is scaled to fit inside 1080×1920 and padded with black bars. That way nothing gets stretched or cropped:

const vf = [ 'scale=1080:1920:force_original_aspect_ratio=decrease', 'pad=1080:1920:(ow-iw)/2:(oh-ih)/2:color=black', 'setsar=1', 'fps=30', 'format=yuv420p', ].join(','); 

Then the full command. The music is looped with -stream_loop -1 in case the slideshow runs longer than the song, and -t cuts the output to the exact total length:

const args = ['-y', '-f', 'concat', '-safe', '0', '-i', listPath]; if (musicPath) args.push('-stream_loop', '-1', '-i', musicPath); args.push('-vf', vf, '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '23', '-r', '30'); if (musicPath) args.push('-c:a', 'aac', '-b:a', '128k', '-map', '0:v:0', '-map', '1:a:0'); else args.push('-an'); args.push('-t', String(total), '-movflags', '+faststart', outPath); 

A few choices worth explaining:

format=yuv420p and H.264 + AAC make the file play everywhere, including iPhones and WhatsApp.
-preset veryfast keeps encoding quick on a small VPS; slideshows are simple images, so the quality cost is tiny.
-movflags +faststart moves the index to the start of the file, so it starts playing before it has fully downloaded.
Step 5: Keep it fast and keep the server alive
ffmpeg is CPU-heavy, and my backend runs on a small VPS. Two things stop slideshows from hurting everything else:

A shared ffmpeg queue. The app already had a small semaphore that limits how many ffmpeg jobs run at once (two on a 2-vCPU box). Slideshows use the same queue, so a burst of requests waits its turn instead of overloading the server.
A cache. Each finished slideshow is stored under its source URL. The first request takes a few seconds; the second is instant.

await acquireFfmpeg(); try { await runFfmpeg(args); } finally { releaseFfmpeg(); } await videoCache.set(`${sourceUrl}::slideshow`, { filePath: outPath, title }); 

I also download photos one by one and skip any that fail, so one broken image doesn’t sink the whole video, and the temporary folder is always deleted in a finally block.

What I’d tell anyone building something similar
Read the whole API response, not just the field you expect. The images were in the response from day one.

Check the output, not just the exit code. A file that downloads fine can still be the wrong file, or have no audio.

Log your fallbacks. Silent fallbacks hide bugs for weeks.
Share expensive resources. One queue for every ffmpeg job is simpler and safer than limits per feature.

Today, when you paste a TikTok photo post into Clipzy’s TikTok downloader,

You get the photos in a grid, a “Download slideshow (MP4)” button that builds the video with its music, and an Audio button for just the song.

If you’re curious how the rest of the app is put together, I wrote about building Clipzy with Angular SSR too.

I’m Bilal, an Angular developer from Lahore building Clipzy on the side. If you’ve solved this differently, I’d love to hear how in the comments.