Repository navigation
Expand file tree
/
Copy pathindex.html
More file actions
318 lines (318 loc) · 34.6 KB
/
Copy pathindex.html
File metadata and controls
318 lines (318 loc) · 34.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
<!doctype html>
<html lang="en" prefix="og: https://ogp.me/ns#">
<head>
<meta charset="UTF-8">
<meta name="google-site-verification" content="stmx9rM_VNwnxUCegXnx4FLQRyo2hWu5NFOeR7dVJUo" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#a7a7a7">
<meta name="description" content="Create seamless video loops for Spotify Canvas and music visuals. Find loop candidates, choose a method, and export MP4, WebM, or GIF on your device.">
<meta name="robots" content="index, follow">
<title>PerfectLoop — Seamless Video Loop Editor</title>
<link rel="canonical" href="https://espmaniac.github.io/PerfectLoop/">
<meta property="og:type" content="website">
<meta property="og:site_name" content="PerfectLoop">
<meta property="og:title" content="PerfectLoop — Seamless Video Loop Editor">
<meta property="og:description" content="Create seamless video loops for Spotify Canvas and music visuals. Find loop candidates, choose a method, and export MP4, WebM, or GIF on your device.">
<meta property="og:url" content="https://espmaniac.github.io/PerfectLoop/">
<meta property="og:image" content="https://espmaniac.github.io/PerfectLoop/icon-512.png">
<meta property="og:image:type" content="image/png">
<meta property="og:image:width" content="512">
<meta property="og:image:height" content="512">
<meta property="og:image:alt" content="PerfectLoop PL monogram">
<link rel="icon" type="image/x-icon" href="./favicon.ico?v=2.1.2" sizes="16x16 32x32 48x48">
<link rel="icon" type="image/svg+xml" href="./favicon.svg?v=2.1.2" sizes="any">
<link rel="apple-touch-icon" href="./apple-touch-icon.png?v=2.1.2" sizes="180x180">
<link rel="manifest" href="./site.webmanifest?v=2.1.2">
<link rel="stylesheet" href="./style.css">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebSite",
"@id": "https://espmaniac.github.io/PerfectLoop/#website",
"url": "https://espmaniac.github.io/PerfectLoop/",
"name": "PerfectLoop",
"alternateName": "Perfect Loop",
"description": "Create seamless video loops for Spotify Canvas and music visuals. Find loop candidates, choose a method, and export MP4, WebM, or GIF on your device.",
"inLanguage": "en"
},
{
"@type": "WebPage",
"@id": "https://espmaniac.github.io/PerfectLoop/#webpage",
"url": "https://espmaniac.github.io/PerfectLoop/",
"name": "PerfectLoop — Seamless Video Loop Editor",
"description": "Create seamless video loops for Spotify Canvas and music visuals. Find loop candidates, choose a method, and export MP4, WebM, or GIF on your device.",
"inLanguage": "en",
"isPartOf": {
"@id": "https://espmaniac.github.io/PerfectLoop/#website"
},
"mainEntity": {
"@id": "https://espmaniac.github.io/PerfectLoop/#application"
}
},
{
"@type": "WebApplication",
"@id": "https://espmaniac.github.io/PerfectLoop/#application",
"url": "https://espmaniac.github.io/PerfectLoop/",
"name": "PerfectLoop",
"description": "Create seamless video loops for Spotify Canvas and music visuals. Find loop candidates, choose a method, and export MP4, WebM, or GIF on your device.",
"applicationCategory": "MultimediaApplication",
"applicationSubCategory": "Video loop editor",
"operatingSystem": "Any",
"browserRequirements": "Requires JavaScript, WebAssembly, and support for module workers.",
"inLanguage": "en",
"isAccessibleForFree": true,
"image": "https://espmaniac.github.io/PerfectLoop/icon-512.png",
"sameAs": "https://github.com/espmaniac/PerfectLoop",
"license": "https://github.com/espmaniac/PerfectLoop/blob/main/LICENSE",
"mainEntityOfPage": {
"@id": "https://espmaniac.github.io/PerfectLoop/#webpage"
},
"offers": {
"@type": "Offer",
"price": 0
},
"featureList": [
"Automatic loop discovery in a whole video or selected range",
"Natural cut, crossfade, offset dissolve, ping-pong, smooth ping-pong, and fade through black",
"Portrait, landscape, square, and custom output dimensions",
"MP4, WebM, and animated GIF exports",
"Export without an audio track",
"Video processing on your device with FFmpeg WebAssembly"
]
}
]
}
</script>
<script type="module" src="./js/start.js"></script>
</head>
<body>
<noscript>This video editor needs JavaScript enabled.</noscript>
<div class="app-shell" id="app">
<header class="app-header">
<div class="header-brand">
<a class="brand" href="./" aria-label="PerfectLoop home"><span class="brand-icon"><img src="./favicon.svg?v=2.1.2" width="44" height="44" alt=""></span><span>PerfectLoop</span></a>
</div>
<div class="header-hint" data-tone="tip" role="status" aria-live="polite" aria-atomic="true"><span data-icon="Info" data-size="16" aria-hidden="true"></span><span id="header-hint">Open a video to choose a range and build a loop.</span></div>
<div class="header-actions">
<button class="btn secondary" data-action="open" aria-label="Open video or photo"><span data-icon="FolderOpen" data-size="19"></span><span class="open-label">Open media</span></button>
<a class="icon-btn github-link" href="https://github.com/espmaniac/PerfectLoop" target="_blank" rel="noopener noreferrer" aria-label="Open PerfectLoop on GitHub (new tab)" title="PerfectLoop on GitHub"><span data-icon="Github" data-size="20"></span></a>
<button class="btn secondary render-btn" data-action="render" title="Retry generating the full result" disabled><span data-icon="Play" data-size="17"></span><span id="render-label">Retry result</span></button>
</div>
</header>
<input id="file-input" class="sr-only" type="file" accept="video/*,image/png,image/jpeg,image/webp,.png,.jpg,.jpeg,.webp,.mkv,.mov,.avi,.m4v,.webm,.mp4" aria-label="Choose source video or photo">
<input id="wallpaper-photo-input" class="sr-only" type="file" accept="image/png,image/jpeg,image/webp,.png,.jpg,.jpeg,.webp" aria-label="Choose wallpaper photo">
<main>
<h1 class="sr-only">Video loop editor</h1>
<div class="alert error" id="proxy-alert" role="alert" hidden><div><b>Compatible preview needed</b><p id="proxy-message"></p></div><button class="btn secondary" data-action="proxy"><span data-icon="Film" data-size="16"></span><span>Create proxy</span></button></div>
<div class="alert error" id="error-alert" role="alert" hidden><span id="error-message"></span><button class="icon-btn" data-action="dismiss-error" aria-label="Dismiss error"><span data-icon="X" data-size="16"></span></button></div>
<div class="notice" id="notice" role="status" hidden><span data-icon="CheckCircle2" data-size="15"></span><span id="notice-message"></span><button class="icon-btn" data-action="dismiss-notice" aria-label="Dismiss notice"><span data-icon="X" data-size="14"></span></button></div>
<div class="workbench">
<section class="controls-panel panel" aria-label="Loop tools">
<div class="controls-header"><h2 class="panel-title" id="tool-title">Loop method</h2><button class="text-btn mobile-preview-link" data-action="show-preview"><span data-icon="ArrowUp" data-size="14"></span>Back to preview</button></div>
<div class="tool-body" role="tabpanel" id="panel-edit" aria-labelledby="tab-edit">
<fieldset data-disable>
<div class="method-grid" id="methods"></div>
<div id="method-comparison" hidden>
<p class="micro" id="method-recommendation" role="status"></p>
<details class="disclosure"><summary>Method comparison</summary><p class="micro">Fit estimates compare the framed video before layers and repairs. Checks include the seam, internal joins, detail, and transition mismatch. Reverse and fade effects also change the original motion or brightness. Detail and frame activity near the seam are relative to Natural cut. Scores are estimates, not quality percentages.</p><div id="method-scores"></div></details>
<button class="btn secondary full method-compare-button" data-action="compare-methods"><span data-icon="ScanLine" data-size="16"></span>Compare & choose method</button>
</div>
<p class="method-summary" id="method-summary" hidden></p>
<div class="fields method-fields" id="method-fields"></div>
<details class="disclosure method-about"><summary>Loop settings & tips</summary><div class="disclosure-content" id="loop-settings"><p class="method-detail" id="method-detail"></p><p class="micro" id="overlap-note"></p></div></details>
</fieldset>
</div>
<div class="tool-body" role="tabpanel" id="panel-find" aria-labelledby="tab-find" hidden>
<fieldset data-disable>
<p class="panel-copy">Find loopable clips anywhere in the video or inside a selected search range. Edit Search from / Search to here or drag the timeline handles.</p>
<div class="fields two" id="search-range-fields"></div>
<div id="search-options"></div>
<button class="btn primary full" data-action="search"><span data-icon="Search" data-size="16"></span>Find loop candidates</button>
<details class="disclosure"><summary>How search works</summary><p class="micro">Search from / Search to limit where clips can be found. Min / Max loop length set each clip's source duration. Matches are ranked across the whole search range; selecting one sets the render range and preserves the search range. Long videos use adaptive sampling, up to 1,601 frames. The best coarse matches are refined around individual frames. Each result is checked for local image and motion changes at the seam. The complete range is also checked when it fits your length limits. Source fit is preliminary. Selecting a candidate compares the loop methods and chooses a recommendation. Watch several repeats before exporting.</p></details>
<div class="candidate-section" id="candidate-section" hidden><div class="candidate-head"><b id="candidate-count"></b><button class="text-btn" data-action="select-top" id="select-top"><span data-icon="ListChecks" data-size="16"></span><span>Select top 5</span></button></div><label class="toggle-row"><span>Hide similar ranges<small>Keep the best match when clips share at least 75% of the shorter range.</small></span><input type="checkbox" data-search="hideSimilar" checked><span class="switch" aria-hidden="true"></span></label><div class="candidates" id="candidates"></div><button class="btn secondary full" data-action="batch" id="batch-button" hidden></button></div>
</fieldset>
</div>
<div class="tool-body" role="tabpanel" id="panel-inspect" aria-labelledby="tab-inspect" hidden>
<fieldset data-disable>
<p class="panel-copy"><span id="inspection-mode">Selected source range</span>. Compare the last frame with the first, then watch several repeats.</p>
<button class="btn secondary full" data-action="inspect"><span data-icon="ScanLine" data-size="16"></span>Compare boundary frames</button>
<div id="seam-result"></div>
<details class="disclosure inspection-tips"><summary>What to look for</summary><p>Check the direction and speed of motion at the join. A matching still frame can hide a change in velocity. Bright areas in the difference map show change, including normal motion.</p><p>Ping-pong reverses direction; dissolves can show ghosting. A rendered preview uses the export's loop logic at a smaller resolution. Check the downloaded file at full size too.</p></details>
</fieldset>
</div>
<div class="tool-body" role="tabpanel" id="panel-layers" aria-labelledby="tab-layers" hidden>
<fieldset data-disable>
<div class="layer-add-actions">
<button class="btn secondary" data-action="layer-add-text"><span data-icon="Type" data-size="16"></span><span>Add text</span></button>
<button class="btn secondary" data-action="layer-add-image"><span data-icon="ImagePlus" data-size="16"></span><span>Add image</span></button>
</div>
<input id="layer-image-input" class="sr-only" type="file" accept="image/png,image/jpeg,image/webp,image/gif,.png,.jpg,.jpeg,.webp,.gif" aria-label="Choose layer image">
<input id="layer-font-input" class="sr-only" type="file" accept=".ttf,.otf,.woff,.woff2" multiple aria-label="Choose font files">
<p class="micro layer-image-hint">PNG, JPEG, WebP, or GIF. Animated images use the first frame. Up to 8 layers.</p>
<div class="layer-patch-actions"><button class="btn secondary full" data-action="layer-add-patch" data-disable><span data-icon="Crop" data-size="16"></span><span>Create patch from frame</span></button>
<button class="btn secondary full" data-action="layer-add-video-patch" data-disable><span data-icon="Film" data-size="16"></span><span>Create video patch</span></button></div><p class="micro">Video patches preserve motion inside a selected area. For a still patch, pause on a clean source frame, then select a fragment to cover a stationary defect.</p>
<p class="micro" id="layer-empty">Add text, an image, or a patch to your loop.</p>
<p class="micro">Use the arrows to change layer order. Move a layer below Video to place it behind the source; use Fit or reduce video scale to reveal it.</p><div class="layer-list" id="layer-list" role="list" aria-label="Layers, top to bottom"></div>
<div class="layer-controls" id="layer-controls" hidden></div>
<p class="micro layer-motion-help">Movement speed controls how many whole passes fit into each finished loop. Screen directions stay relative to the frame. Along rotation and Against rotation use the starting angle, even during rotation animation.</p>
</fieldset>
</div>
<div class="tool-body" role="tabpanel" id="panel-repair" aria-labelledby="tab-repair" hidden>
<p class="panel-copy">Improve an almost-looping clip while keeping motion moving forward. Analyze the selected timeline range first.</p>
<fieldset class="auto-repair-controls" data-disable>
<label class="field"><span>Auto repair</span><select id="auto-repair-mode" aria-label="Auto repair duration"><option value="keep">Keep duration</option><option value="trim">Allow trimming (up to 20%)</option></select></label>
<button class="btn primary full" data-action="auto-repair"><span data-icon="Sparkles" data-size="16"></span><span>Analyze & repair</span></button>
<p class="micro">Tests corrections and compares rendered motion before proposing a change. Review Before / After, then Apply. Keep duration may adjust playback speed slightly for seam blending.</p>
</fieldset>
<h3 class="repair-heading">Manual repair</h3>
<button class="btn secondary full" data-action="repair-analyze" data-disable><span data-icon="ScanLine" data-size="16"></span><span>Analyze loop</span></button>
<button class="text-btn repair-full" data-action="repair-full" data-disable><span data-icon="Expand" data-size="16"></span><span>Use the full video</span></button>
<p class="micro" id="repair-range"></p>
<div id="repair-report" role="status"><p class="micro">Analysis checks the framed source before layers and loop effects. It does not change the video.</p></div>
<button class="btn secondary full" id="repair-cut" data-action="repair-cut" hidden><span data-icon="Scissors" data-size="16"></span><span>Use suggested cut</span></button>
<fieldset id="repair-controls" disabled>
<h3 class="repair-heading">Flicker</h3>
<label class="toggle-row"><span>Reduce brightness flicker<small>Smooth sampled brightness changes across the cycle. Review scenes with intentional flashes.</small></span><input type="checkbox" id="repair-flicker"><span class="switch" aria-hidden="true"></span></label>
<h3 class="repair-heading">Alignment</h3>
<label class="toggle-row"><span>Correct small seam drift<small>Gradually shift the ending. A small fixed crop keeps the frame edges filled.</small></span><input type="checkbox" id="repair-alignment"><span class="switch" aria-hidden="true"></span></label>
<h3 class="repair-heading">Seam blending</h3>
<label class="toggle-row"><span>Blend the end into the start<small>Uses a forward dissolve. May double fine details and shortens the finished cycle.</small></span><input type="checkbox" id="repair-blend"><span class="switch" aria-hidden="true"></span></label>
<label class="field"><span>Overlap (seconds)</span><input id="repair-overlap" type="number" min="0.05" max="2" step="0.05" value="0.25"></label>
<label class="range-field"><span>Correction strength <output id="repair-strength-value">100%</output></span><input id="repair-strength" aria-label="Correction strength" type="range" min="0" max="100" step="5" value="100"></label>
<p class="micro" id="repair-duration"></p>
<button class="btn secondary full" data-action="repair-apply"><span data-icon="Check" data-size="16"></span><span>Apply repairs</span></button>
</fieldset>
<div class="repair-preview-actions">
<button class="btn secondary" data-action="repair-before" data-disable><span data-icon="Play" data-size="16"></span><span>Preview original</span></button>
<button class="btn primary" data-action="repair-preview"><span data-icon="Play" data-size="16"></span><span>Preview result</span></button>
</div>
<p class="micro" id="repair-preview-note">Preview buttons render the selected range. Compare the boundary over several repeats. Composition is a draft and does not show repairs.</p>
<button class="text-btn" data-action="repair-clear" data-disable><span data-icon="RotateCcw" data-size="16"></span><span>Remove repairs</span></button>
</div>
</section>
<section class="preview-panel panel" aria-label="Video preview">
<div class="preview-top">
<div class="tabs" role="tablist" aria-label="Loop tools">
<button role="tab" id="tab-edit" data-tab="edit" aria-controls="panel-edit" aria-selected="true" class="active"><span data-icon="SlidersHorizontal" data-size="17"></span>Edit loop</button>
<button role="tab" id="tab-find" data-tab="find" aria-controls="panel-find" aria-selected="false"><span data-icon="LocateFixed" data-size="18"></span>Auto find</button>
<button role="tab" id="tab-inspect" data-tab="inspect" aria-controls="panel-inspect" aria-selected="false"><span data-icon="Search" data-size="18"></span>Inspect seam</button>
<button role="tab" id="tab-repair" data-tab="repair" aria-controls="panel-repair" aria-selected="false"><span data-icon="SlidersHorizontal" data-size="18"></span>Repair loop</button>
<button role="tab" id="tab-layers" data-tab="layers" aria-controls="panel-layers" aria-selected="false"><span data-icon="Layers" data-size="18"></span>Layers</button>
</div>
</div>
<div id="wallpaper-screen-controls" hidden></div>
<div class="preview-stage" id="preview-stage">
<span class="preview-tag" id="preview-tag">SOURCE RANGE</span>
<canvas id="preview-canvas" tabindex="0" aria-label="Video frame preview"></canvas>
<div id="preview-output-frame" class="preview-output-frame" aria-hidden="true" hidden><span>Output frame</span></div>
<div id="preview-transform" class="preview-transform" role="group" aria-label="Preview transform controls" hidden><svg id="preview-selection" class="preview-selection" aria-hidden="true"></svg></div>
<div class="aspect-guides" id="aspect-guides" aria-hidden="true" hidden><span>Output aspect ratio</span></div>
<video id="source-video" class="decoder" playsinline muted preload="auto"></video>
<video id="loop-video" class="decoder" playsinline muted loop preload="auto"></video>
</div>
<div class="preview-framing" id="preview-framing" hidden>
<div id="preview-view-controls" class="preview-view-controls" role="group" aria-label="Editor view" hidden>
<button class="icon-btn" data-action="view-out" aria-label="Zoom out editor view" title="Zoom out editor view"><span data-icon="ZoomOut" data-size="16"></span></button>
<output id="preview-view-scale" aria-label="Editor view zoom">100%</output>
<button class="icon-btn" data-action="view-in" aria-label="Zoom in editor view" title="Zoom in editor view"><span data-icon="ZoomIn" data-size="16"></span></button>
<button class="text-btn" data-action="view-fit" title="Fit the full object and output frame in the editor"><span data-icon="Expand" data-size="16"></span><span>Fit view</span></button>
</div>
<p class="micro" id="preview-edit-hint">Drag the video to reposition it. Open Layers to move text and images.</p>
</div>
<div class="transport">
<div class="segmented preview-mode" aria-label="Preview comparison"><button data-preview-choice="original" aria-pressed="false">Original</button><button data-preview-choice="result" class="active" aria-pressed="true">Result</button></div>
<div class="playback-controls">
<button class="icon-btn" data-action="previous-frame" aria-label="Previous frame"><span data-icon="SkipBack" data-size="20"></span></button>
<button class="play-btn" data-action="play" aria-label="Play preview"><span data-icon="Play" data-size="23"></span></button>
<button class="icon-btn" data-action="next-frame" aria-label="Next frame"><span data-icon="SkipForward" data-size="20"></span></button>
</div>
<span class="transport-time" id="transport-time">00:00.000 / 00:06.000</span>
<div class="transport-options">
<button class="icon-btn" data-action="mute" aria-label="Unmute preview" title="Preview sound does not change export audio"><span data-icon="VolumeX" data-size="19"></span></button>
<button class="icon-btn" data-action="fullscreen" aria-label="Fullscreen preview"><span data-icon="Expand" data-size="19"></span></button>
</div>
</div>
<div class="preview-foot"><span><span data-icon="Repeat2" data-size="13"></span><span id="preview-foot-message">Render to check the finished seam</span></span><div><button class="icon-btn" data-action="guides" aria-label="Toggle aspect-ratio guide" title="Show the selected output aspect ratio" aria-pressed="false"><span data-icon="Crop" data-size="16"></span></button><button class="icon-btn" data-action="frame-png" aria-label="Save preview frame"><span data-icon="ImageDown" data-size="16"></span></button></div></div>
</section>
<fieldset class="timeline-fieldset" id="timeline-fieldset" hidden>
<section class="timeline-panel panel" id="timeline-panel" aria-label="Source video timeline">
<div class="timeline-head"><div class="source-meta" id="source-meta"><span>Loading the sample…</span></div><div class="timeline-actions">
<span id="range-duration"></span>
<div class="history-controls"><button class="icon-btn" data-action="undo" aria-label="Undo settings"><span data-icon="Undo2" data-size="16"></span></button><button class="icon-btn" data-action="redo" aria-label="Redo settings"><span data-icon="Redo2" data-size="16"></span></button><button class="icon-btn" data-action="reset" aria-label="Reset settings"><span data-icon="RotateCcw" data-size="16"></span></button></div>
<button class="text-btn" data-action="mark-in"><span data-icon="SkipBack" data-size="16"></span><span>Set in<kbd>I</kbd></span></button><button class="text-btn" data-action="mark-out"><span data-icon="SkipForward" data-size="16"></span><span>Set out<kbd>O</kbd></span></button><button class="icon-btn" data-action="zoom" aria-label="Zoom to selection"><span data-icon="ZoomIn" data-size="18"></span></button>
</div></div>
<p class="timeline-caption" id="timeline-caption" hidden></p>
<div class="ruler-labels" id="ruler-labels"></div>
<div class="filmstrip" id="filmstrip">
<div class="filmstrip-images" id="filmstrip-images" aria-hidden="true"></div>
<div class="selection-shade left" id="shade-left"></div><div class="selection-shade right" id="shade-right"></div>
<div class="selection-outline" id="selection-outline"></div>
<button class="trim-handle start" data-edge="start" aria-label="Drag start trim handle"><span></span></button><button class="trim-handle end" data-edge="end" aria-label="Drag end trim handle"><span></span></button>
<div class="playhead" id="playhead"></div>
</div>
<div class="trim-fields" id="trim-fields"></div>
<div class="timeline-result-summary" id="rendered-timeline-summary" hidden>
<span>Start <b>0.000<small>s</small></b></span>
<span>End <b><span id="timeline-preview-end">0.000</span><small>s</small></b></span>
<span>Duration <b><span id="timeline-preview-duration">0.000</span><small>s</small></b></span>
</div>
</section>
</fieldset>
<aside class="export-panel panel" aria-label="Video settings">
<div class="export-title"><h2 class="panel-title">Video settings</h2><span class="format-badge" id="format-badge">MP4</span></div>
<div class="wallpaper-destination"><div id="preset-field"></div><p class="preset-hint" id="preset-hint"></p></div>
<fieldset data-disable>
<div class="wallpaper-settings" id="wallpaper-settings" hidden>
<div id="wallpaper-device-field"></div>
<div class="wallpaper-photo-actions"><button class="btn secondary" data-action="open-photo"><span data-icon="ImagePlus" data-size="16"></span>Open photo</button><p class="micro">Turn a JPEG, PNG, or WebP photo into a gentle motion loop. You can add text and images in Layers.</p></div>
<label class="range-field"><span>Key photo <b id="wallpaper-key-time">1.000s</b></span><input type="range" min="0" max="100" step="1" data-setting="wallpaperPoster" data-number aria-label="Key photo position"></label>
<div id="wallpaper-download-field"></div>
<div class="wallpaper-duration-note"><p class="micro" id="wallpaper-range-note"></p><button class="text-btn" data-action="fit-duration"><span data-icon="Timer" data-size="16"></span><span>Fit to a 2-second cycle</span></button></div>
<details class="disclosure wallpaper-install-guide"><summary>Save the Live Photo to Apple Photos</summary><div class="disclosure-content micro"><p>PerfectLoop creates the Live Photo entirely in your browser. Export it on any device; importing the finished result into Apple Photos is a separate system step.</p><p><b>Direct PVT save:</b> where available, choose <b>Live Photo · PVT package</b> and select a destination folder. The site saves the real package directly, without an archive. A PVT is a package folder; Safari cannot save one directly and uses ZIP instead.</p><p><b>Try on iPhone or iPad:</b> save the ZIP to Files, touch and hold it, choose <b>Uncompress</b>, then open <b>live-photo.pvt</b>. Use <b>Save to Photos</b> if the system offers it. Verify one Live Photo and its motion. This import action depends on iOS and may not be available; it has not been verified on a physical iPhone by PerfectLoop. Windows/Linux users can transfer the ZIP to their iPhone for the same steps.</p><p><b>On a Mac:</b> import the saved PVT package into Photos, or unzip the download and import <b>live-photo.pvt</b>. If the package is not recognized, import both files inside it, <b>photo.jpg</b> and <b>photo.mov</b>, together. Verify one Live Photo, then sync with iCloud Photos or share that asset from Photos via AirDrop.</p><p>Browser exports cannot directly add a paired asset to Photos. The import is experimental, and modern iOS can show <b>Motion Not Available</b> when selecting an otherwise working Live Photo as wallpaper. Check on the target iPhone.</p><p>iOS creates a short Lock Screen wake effect from motion near the key photo. It can use only a small part of the video and change its speed and smoothness. Video playback on this site does not simulate that effect.</p><p>Lock Screen layout and Home Screen layout show the selected still photo. Home Screen wallpaper stays still. The phone frame, clock, and icons are preview overlays and are excluded from downloads.</p></div></details>
</div>
<div class="output-aspect">
<div class="control-label">Aspect ratio (output)<span data-icon="Info" data-size="14" title="Original and Result share the selected output proportions and video framing."></span></div>
<div class="segmented aspect-options" role="group" aria-label="Output aspect ratio">
<button data-aspect="original" aria-pressed="false">Original</button><button data-aspect="16:9" aria-pressed="false">16:9</button><button data-aspect="9:16" aria-pressed="true">9:16</button><button data-aspect="1:1" aria-pressed="false">1:1</button><button data-aspect="custom" aria-pressed="false">Custom</button>
</div>
<div class="fields two" id="dimension-fields" hidden></div>
<p class="dimension-note" id="dimension-note" hidden>Even values · 16–3840 px</p>
<div class="dimension-caption"><span id="dimension-caption">576 × 1024</span><span id="destination-label">Spotify Canvas</span></div>
</div>
<div class="fields format-fields" id="format-fields"></div>
<div class="audio-box" id="audio-fields"></div>
<details class="disclosure output-settings" id="output-settings"><summary>Advanced settings</summary><div class="disclosure-content">
<div id="quality-field"></div>
<div id="motion-fields"></div>
<details class="disclosure" id="video-export-options"><summary>More export options</summary><div class="disclosure-content" id="extra-export-fields"></div></details>
</div></details>
</fieldset>
<div class="export-footer">
<div class="export-actions">
<div class="output-summary"><div><span>Finished loop</span><b><span id="finished-duration">5.500</span><small>s</small></b></div><span id="output-meta"></span></div>
<div class="validation" id="validation" role="status" hidden></div>
<div class="export-valid" id="export-valid" hidden></div>
<p class="micro" id="long-loop-warning" hidden>Long loops take more time and memory to encode in a browser.</p>
<button class="btn primary full export-btn" data-action="export"><span data-icon="Upload" data-size="19"></span><span id="export-label">Export MP4</span></button>
<div class="download-result" id="download-result" hidden><span data-icon="CheckCircle2" data-size="16"></span><div><b>Your loop is ready</b><span id="download-meta"></span><button class="text-btn" data-action="download-again"><span data-icon="Download" data-size="16"></span><span>Download again</span></button></div></div>
</div>
</div>
</aside>
</div>
<div class="job-panel" id="job-panel" role="status" aria-live="polite" hidden><span data-icon="LoaderCircle" data-size="20" class="spin"></span><div><div class="job-label"><b id="job-message"></b><span id="job-percent"></span></div><progress id="job-progress" max="1" value="0" aria-label="Processing progress"></progress></div><button class="btn secondary" data-action="cancel"><span data-icon="X" data-size="16"></span><span>Cancel</span></button></div>
</main>
<footer><span><span data-icon="LockKeyhole" data-size="13"></span>Your files stay on your device.</span><div><a href="https://support.spotify.com/us/artists/article/canvas-guidelines/" target="_blank" rel="noreferrer">Canvas guidelines</a><button class="text-btn" data-action="help" aria-label="Help and keyboard shortcuts"><span data-icon="HelpCircle" data-size="15"></span>Help & shortcuts</button></div></footer>
<div class="drop-overlay" id="drop-overlay" hidden><span data-icon="Upload" data-size="38"></span><b>Drop media or fonts</b><span>Videos open as a source. Images become layers. Files stay on your device.</span></div>
<dialog class="help-dialog" id="help-dialog">
<div class="help-head"><h2>Make a better loop</h2><button class="icon-btn" data-action="close-help" aria-label="Close help"><span data-icon="X" data-size="20"></span></button></div>
<p>Open a video, select a range, and choose a method. Result updates automatically. Switch to Original to compare, and watch the full result repeat before exporting.</p>
<ul><li><b>Auto find</b> compares color, local image structure, and movement. The best five matches receive frame refinement. Sampling can miss very brief moments.</li><li><b>Crossfade</b> overlaps the tail and head; the output is shorter than the selection. Offset dissolve places that transition inside the clip.</li><li><b>Ping-pong</b> plays forward, then backward, nearly doubling the cycle duration without repeating turning-point frames. Smooth ping-pong slows down into the turns. GIF stores both directions in one cycle; Loop forever repeats that cycle.</li><li><b>Remove audio track</b> strips the audio stream. Muting the player only changes the preview.</li><li><b>Spotify Canvas</b> uses 3–8 seconds, exact 9:16, and 720–1080px height. The default 576×1024 is inside these limits.</li><li><b>Long videos</b> are sampled from the local file. Unsupported browser codecs can be converted to a playable proxy.</li></ul>
<div class="shortcut-grid"><span>Play / pause<kbd>Space</kbd></span><span>Set in / out<kbd>I / O</kbd></span><span>Step frames<kbd>← / →</kbd></span><span>Undo / redo<kbd>Ctrl+Z / Shift+Z</kbd></span></div>
<p class="micro">No algorithm can guarantee an invisible join for every scene. Natural cuts need repeating motion; dissolves can ghost; ping-pong changes direction. This studio gives you controls and diagnostics to choose the best result.</p>
<button class="btn primary full" data-action="close-help"><span data-icon="ArrowUp" data-size="16"></span><span>Back to the studio</span></button>
</dialog>
</div>
</body>
</html>