feat(montage): allow transitions with inserted a-roll clips by holding their edge frame
4bbc583 parent: ac8b851 modified
backend/studio/modules/projects/render.py +39 -20 | @@ -232,11 +232,12 @@ def segments(aroll: list[dict], zooms: list[dict], fps: Fraction, source_duratio | ||
| 232 | 232 | """ |
| 233 | 233 | Découpe l'A-roll en morceaux, bornes en images de la timeline : |
| 234 | 234 | {start, end, clip: index du clip, src: début dans son média, fade: début de la plage entrante (transition) | None, |
| 235 | - clip_b: index du clip entrant, fx: la transition (xfade), zoom: bool}. | |
| 235 | + clip_b: index du clip entrant, fx: la transition (xfade), cut: la coupe (images depuis start), | |
| 236 | + held: un côté tient son image de bord, zoom: bool}. | |
| 236 | 237 | |
| 237 | - Une transition est centrée sur la coupe et mange la marge de part et d'autre (comme dans Resolve) : | |
| 238 | - sa durée est réduite si la source n'a pas assez de marge. Pas de transition avec un clip inséré d'un autre | |
| 239 | - média (comme dans l'interface). | |
| 238 | + Une transition est centrée sur la coupe et mange la marge de part et d'autre (comme dans Resolve), au plus | |
| 239 | + la moitié de chaque clip. Un côté sans assez de marge (un clip inséré utilisé en entier, le début ou la fin de | |
| 240 | + la source) tient sa première ou sa dernière image ; le son coupe alors net à la coupe (L-cut). | |
| 240 | 241 | """ |
| 241 | 242 | def frame(t: float) -> int: |
| 242 | 243 | return round(t * fps) |
| @@ -252,11 +253,9 @@ def segments(aroll: list[dict], zooms: list[dict], fps: Fraction, source_duratio | ||
| 252 | 253 | for i in range(1, len(aroll)): |
| 253 | 254 | tr = aroll[i].get("transition") |
| 254 | 255 | prev, cur = aroll[i - 1], aroll[i] |
| 255 | - if not tr or prev.get("src") or cur.get("src"): | |
| 256 | + if not tr: | |
| 256 | 257 | continue |
| 257 | - d = tr["dur"] | |
| 258 | - half = min(d / 2, (prev["out"] - prev["in"]) / 2, (cur["out"] - cur["in"]) / 2, | |
| 259 | - max(0.0, prev.get("length", source_duration) - prev["out"]), cur["in"]) | |
| 258 | + half = min(tr["dur"] / 2, (prev["out"] - prev["in"]) / 2, (cur["out"] - cur["in"]) / 2) | |
| 260 | 259 | n = int(half * fps) |
| 261 | 260 | if n >= 1: |
| 262 | 261 | windows[cuts[i] - n] = (cuts[i] + n, i) |
| @@ -282,7 +281,10 @@ def segments(aroll: list[dict], zooms: list[dict], fps: Fraction, source_duratio | ||
| 282 | 281 | if a in windows and windows[a][0] == b: |
| 283 | 282 | i = windows[a][1] |
| 284 | 283 | prev, cur = aroll[i - 1], aroll[i] |
| 284 | + reach = float(Fraction(cuts[i] - a) / fps) | |
| 285 | + held = (prev.get("length", source_duration) - prev["out"] < reach - 1e-3) or cur["in"] < reach - 1e-3 | |
| 285 | 286 | out.append({"start": a, "end": b, "zoom": zoomed, "fx": cur["transition"]["fx"], "clip": i - 1, "clip_b": i, |
| 287 | + "cut": cuts[i] - a, "held": held, | |
| 286 | 288 | "src": float(prev["in"] + (Fraction(a) / fps - Fraction(starts[i - 1]))), |
| 287 | 289 | "fade": float(cur["in"] + (Fraction(a) / fps - Fraction(starts[i])))}) |
| 288 | 290 | else: |
| @@ -341,42 +343,59 @@ def segment_args(p: dict, seg: dict, dest: Path) -> list[str]: | ||
| 341 | 343 | read = f"{float(length) + 0.5:.3f}" |
| 342 | 344 | args = [tool("ffmpeg"), "-y", "-v", "error", "-nostats", "-progress", "pipe:1", |
| 343 | 345 | "-ss", f"{seg['src']:.6f}", "-t", read, "-i", clip_a["path"]] |
| 346 | + # le clip entrant sans assez de marge avant : sa première image tenue jusqu'à son début | |
| 347 | + lead = max(0.0, -seg["fade"]) if seg["fade"] is not None else 0.0 | |
| 344 | 348 | if seg["fade"] is not None: |
| 345 | - args += ["-ss", f"{seg['fade']:.6f}", "-t", read, "-i", clip_b["path"]] | |
| 349 | + args += ["-ss", f"{max(0.0, seg['fade']):.6f}", "-t", read, "-i", clip_b["path"]] | |
| 350 | + if seg.get("held"): | |
| 351 | + # son coupé à la coupe : le son du clip entrant lu depuis son point d'entrée | |
| 352 | + args += ["-ss", f"{clip_b['in']:.6f}", "-t", read, "-i", clip_b["path"]] | |
| 346 | 353 | |
| 347 | - def prep(clip: dict) -> str: | |
| 354 | + def prep(clip: dict, lead: float = 0.0) -> str: | |
| 348 | 355 | """ |
| 349 | 356 | Une entrée à la cadence et en couleurs BT.709 (un clip SD inséré est en BT.601) : un A-roll aux |
| 350 | 357 | propriétés uniformes, sinon ffmpeg réinitialise ses filtres en cours de passe finale et y perd des images. |
| 351 | 358 | """ |
| 352 | 359 | convert = ("scale=iw:ih:in_color_matrix=bt601:out_color_matrix=bt709:out_range=tv," |
| 353 | 360 | if clip.get("colorspace") in BT601 else "") |
| 361 | + # `lead` : la première image tenue avant le début ; la dernière est tenue après la fin du fichier (une | |
| 362 | + # transition qui déborde d'un clip sans marge) | |
| 363 | + hold = f"tpad=start_mode=clone:start_duration={lead:.6f}," if lead > 0 else "" | |
| 354 | 364 | # ramené à 0 avant la cadence : après la recherche dans la source, la première image arrive un peu après 0, |
| 355 | 365 | # fps la placerait une image plus loin, et le recollage laisserait un trou d'une image dans l'A-roll |
| 356 | 366 | return (f"setpts=PTS-STARTPTS,fps={rate},{convert}" |
| 357 | 367 | f"setparams=colorspace=bt709:color_primaries=bt709:color_trc=bt709:range=tv," |
| 358 | - f"setsar=1,format=yuv420p,tpad=stop_mode=clone:stop_duration=0.5,trim=end_frame={n}") | |
| 368 | + f"setsar=1,format=yuv420p,{hold}tpad=stop_mode=clone:stop_duration={float(length) + 0.5:.3f}," | |
| 369 | + f"trim=end_frame={n}") | |
| 359 | 370 | |
| 360 | 371 | fit = (f"scale={w}:{h}:force_original_aspect_ratio=decrease:flags=lanczos," |
| 361 | 372 | f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,setsar=1,format=yuv420p") |
| 362 | 373 | zoom = (zoom_filter(p["zooms"], seg["start"], fps) + ",") if seg["zoom"] else "" |
| 363 | 374 | sound = f"aresample={AUDIO_RATE},aformat=sample_fmts=s16:channel_layouts=stereo,apad,atrim=end_sample={samples}" |
| 364 | - silence = f"anullsrc=r={AUDIO_RATE}:cl=stereo,atrim=end_sample={samples}" | |
| 365 | 375 | |
| 366 | - def voice(n: int, clip: dict, label: str) -> str: | |
| 367 | - """Le son d'une entrée, à son volume ; du silence si le fichier n'en a pas.""" | |
| 376 | + def voice(n: int, clip: dict, label: str, count: int = samples, after: str = "") -> str: | |
| 377 | + """Le son d'une entrée, à son volume, sur `count` échantillons ; du silence si le fichier n'en a pas.""" | |
| 368 | 378 | if not clip.get("audio", True): |
| 369 | - return f"{silence}[{label}]" | |
| 379 | + return f"anullsrc=r={AUDIO_RATE}:cl=stereo,atrim=end_sample={count}{after}[{label}]" | |
| 370 | 380 | gain = f"volume={clip['gain_db']}dB," if clip.get("gain_db") else "" |
| 371 | - return f"[{n}:a]{gain}{sound}[{label}]" | |
| 381 | + return (f"[{n}:a]{gain}aresample={AUDIO_RATE},aformat=sample_fmts=s16:channel_layouts=stereo,apad," | |
| 382 | + f"atrim=end_sample={count}{after}[{label}]") | |
| 372 | 383 | |
| 373 | 384 | if seg["fade"] is None: |
| 374 | 385 | graph = [f"[0:v]{prep(clip_a)},{zoom}{fit}[v]", voice(0, clip_a, "a")] |
| 375 | 386 | else: |
| 376 | - graph = [f"[0:v]{prep(clip_a)}[va]", f"[1:v]{prep(clip_b)}[vb]", | |
| 377 | - f"[va][vb]xfade=transition={seg['fx']}:duration={float(length):.6f}:offset=0,{zoom}{fit}[v]", | |
| 378 | - voice(0, clip_a, "aa"), voice(1, clip_b, "ab"), | |
| 379 | - f"[aa][ab]acrossfade=ns={samples}:c1=tri:c2=tri,{sound}[a]"] | |
| 387 | + graph = [f"[0:v]{prep(clip_a)}[va]", f"[1:v]{prep(clip_b, lead)}[vb]", | |
| 388 | + f"[va][vb]xfade=transition={seg['fx']}:duration={float(length):.6f}:offset=0,{zoom}{fit}[v]"] | |
| 389 | + if seg.get("held"): | |
| 390 | + # une image tenue d'un côté : le son coupe net à la coupe (L-cut), un fondu de 10 ms contre le clic | |
| 391 | + before = _samples(seg["start"] + seg["cut"], fps) - _samples(seg["start"], fps) | |
| 392 | + edge = min(AUDIO_RATE // 100, before, samples - before) | |
| 393 | + graph += [voice(0, clip_a, "aa", before, f",afade=t=out:start_sample={before - edge}:nb_samples={edge}"), | |
| 394 | + voice(2, clip_b, "ab", samples - before, f",afade=t=in:nb_samples={edge}"), | |
| 395 | + "[aa][ab]concat=n=2:v=0:a=1[a]"] | |
| 396 | + else: | |
| 397 | + graph += [voice(0, clip_a, "aa"), voice(1, clip_b, "ab"), | |
| 398 | + f"[aa][ab]acrossfade=ns={samples}:c1=tri:c2=tri,{sound}[a]"] | |
| 380 | 399 | script = dest.with_suffix(".txt") |
| 381 | 400 | script.write_text(";\n".join(graph), encoding="utf-8") |
| 382 | 401 | return args + ["-filter_complex_script", str(script), "-map", "[v]", "-map", "[a]", "-frames:v", str(n), |
| @@ -232,11 +232,12 @@ def segments(aroll: list[dict], zooms: list[dict], fps: Fraction, source_duratio | |||
| 232 | """ | 232 | """ |
| 233 | Découpe l'A-roll en morceaux, bornes en images de la timeline : | 233 | Découpe l'A-roll en morceaux, bornes en images de la timeline : |
| 234 | {start, end, clip: index du clip, src: début dans son média, fade: début de la plage entrante (transition) | None, | 234 | {start, end, clip: index du clip, src: début dans son média, fade: début de la plage entrante (transition) | None, |
| 235 | - clip_b: index du clip entrant, fx: la transition (xfade), zoom: bool}. | 235 | + clip_b: index du clip entrant, fx: la transition (xfade), cut: la coupe (images depuis start), |
| 236 | + held: un côté tient son image de bord, zoom: bool}. | ||
| 236 | 237 | ||
| 237 | - Une transition est centrée sur la coupe et mange la marge de part et d'autre (comme dans Resolve) : | 238 | + Une transition est centrée sur la coupe et mange la marge de part et d'autre (comme dans Resolve), au plus |
| 238 | - sa durée est réduite si la source n'a pas assez de marge. Pas de transition avec un clip inséré d'un autre | 239 | + la moitié de chaque clip. Un côté sans assez de marge (un clip inséré utilisé en entier, le début ou la fin de |
| 239 | - média (comme dans l'interface). | 240 | + la source) tient sa première ou sa dernière image ; le son coupe alors net à la coupe (L-cut). |
| 240 | """ | 241 | """ |
| 241 | def frame(t: float) -> int: | 242 | def frame(t: float) -> int: |
| 242 | return round(t * fps) | 243 | return round(t * fps) |
| @@ -252,11 +253,9 @@ def segments(aroll: list[dict], zooms: list[dict], fps: Fraction, source_duratio | |||
| 252 | for i in range(1, len(aroll)): | 253 | for i in range(1, len(aroll)): |
| 253 | tr = aroll[i].get("transition") | 254 | tr = aroll[i].get("transition") |
| 254 | prev, cur = aroll[i - 1], aroll[i] | 255 | prev, cur = aroll[i - 1], aroll[i] |
| 255 | - if not tr or prev.get("src") or cur.get("src"): | 256 | + if not tr: |
| 256 | continue | 257 | continue |
| 257 | - d = tr["dur"] | 258 | + half = min(tr["dur"] / 2, (prev["out"] - prev["in"]) / 2, (cur["out"] - cur["in"]) / 2) |
| 258 | - half = min(d / 2, (prev["out"] - prev["in"]) / 2, (cur["out"] - cur["in"]) / 2, | ||
| 259 | - max(0.0, prev.get("length", source_duration) - prev["out"]), cur["in"]) | ||
| 260 | n = int(half * fps) | 259 | n = int(half * fps) |
| 261 | if n >= 1: | 260 | if n >= 1: |
| 262 | windows[cuts[i] - n] = (cuts[i] + n, i) | 261 | windows[cuts[i] - n] = (cuts[i] + n, i) |
| @@ -282,7 +281,10 @@ def segments(aroll: list[dict], zooms: list[dict], fps: Fraction, source_duratio | |||
| 282 | if a in windows and windows[a][0] == b: | 281 | if a in windows and windows[a][0] == b: |
| 283 | i = windows[a][1] | 282 | i = windows[a][1] |
| 284 | prev, cur = aroll[i - 1], aroll[i] | 283 | prev, cur = aroll[i - 1], aroll[i] |
| 284 | + reach = float(Fraction(cuts[i] - a) / fps) | ||
| 285 | + held = (prev.get("length", source_duration) - prev["out"] < reach - 1e-3) or cur["in"] < reach - 1e-3 | ||
| 285 | out.append({"start": a, "end": b, "zoom": zoomed, "fx": cur["transition"]["fx"], "clip": i - 1, "clip_b": i, | 286 | out.append({"start": a, "end": b, "zoom": zoomed, "fx": cur["transition"]["fx"], "clip": i - 1, "clip_b": i, |
| 287 | + "cut": cuts[i] - a, "held": held, | ||
| 286 | "src": float(prev["in"] + (Fraction(a) / fps - Fraction(starts[i - 1]))), | 288 | "src": float(prev["in"] + (Fraction(a) / fps - Fraction(starts[i - 1]))), |
| 287 | "fade": float(cur["in"] + (Fraction(a) / fps - Fraction(starts[i])))}) | 289 | "fade": float(cur["in"] + (Fraction(a) / fps - Fraction(starts[i])))}) |
| 288 | else: | 290 | else: |
| @@ -341,42 +343,59 @@ def segment_args(p: dict, seg: dict, dest: Path) -> list[str]: | |||
| 341 | read = f"{float(length) + 0.5:.3f}" | 343 | read = f"{float(length) + 0.5:.3f}" |
| 342 | args = [tool("ffmpeg"), "-y", "-v", "error", "-nostats", "-progress", "pipe:1", | 344 | args = [tool("ffmpeg"), "-y", "-v", "error", "-nostats", "-progress", "pipe:1", |
| 343 | "-ss", f"{seg['src']:.6f}", "-t", read, "-i", clip_a["path"]] | 345 | "-ss", f"{seg['src']:.6f}", "-t", read, "-i", clip_a["path"]] |
| 346 | + # le clip entrant sans assez de marge avant : sa première image tenue jusqu'à son début | ||
| 347 | + lead = max(0.0, -seg["fade"]) if seg["fade"] is not None else 0.0 | ||
| 344 | if seg["fade"] is not None: | 348 | if seg["fade"] is not None: |
| 345 | - args += ["-ss", f"{seg['fade']:.6f}", "-t", read, "-i", clip_b["path"]] | 349 | + args += ["-ss", f"{max(0.0, seg['fade']):.6f}", "-t", read, "-i", clip_b["path"]] |
| 350 | + if seg.get("held"): | ||
| 351 | + # son coupé à la coupe : le son du clip entrant lu depuis son point d'entrée | ||
| 352 | + args += ["-ss", f"{clip_b['in']:.6f}", "-t", read, "-i", clip_b["path"]] | ||
| 346 | 353 | ||
| 347 | - def prep(clip: dict) -> str: | 354 | + def prep(clip: dict, lead: float = 0.0) -> str: |
| 348 | """ | 355 | """ |
| 349 | Une entrée à la cadence et en couleurs BT.709 (un clip SD inséré est en BT.601) : un A-roll aux | 356 | Une entrée à la cadence et en couleurs BT.709 (un clip SD inséré est en BT.601) : un A-roll aux |
| 350 | propriétés uniformes, sinon ffmpeg réinitialise ses filtres en cours de passe finale et y perd des images. | 357 | propriétés uniformes, sinon ffmpeg réinitialise ses filtres en cours de passe finale et y perd des images. |
| 351 | """ | 358 | """ |
| 352 | convert = ("scale=iw:ih:in_color_matrix=bt601:out_color_matrix=bt709:out_range=tv," | 359 | convert = ("scale=iw:ih:in_color_matrix=bt601:out_color_matrix=bt709:out_range=tv," |
| 353 | if clip.get("colorspace") in BT601 else "") | 360 | if clip.get("colorspace") in BT601 else "") |
| 361 | + # `lead` : la première image tenue avant le début ; la dernière est tenue après la fin du fichier (une | ||
| 362 | + # transition qui déborde d'un clip sans marge) | ||
| 363 | + hold = f"tpad=start_mode=clone:start_duration={lead:.6f}," if lead > 0 else "" | ||
| 354 | # ramené à 0 avant la cadence : après la recherche dans la source, la première image arrive un peu après 0, | 364 | # ramené à 0 avant la cadence : après la recherche dans la source, la première image arrive un peu après 0, |
| 355 | # fps la placerait une image plus loin, et le recollage laisserait un trou d'une image dans l'A-roll | 365 | # fps la placerait une image plus loin, et le recollage laisserait un trou d'une image dans l'A-roll |
| 356 | return (f"setpts=PTS-STARTPTS,fps={rate},{convert}" | 366 | return (f"setpts=PTS-STARTPTS,fps={rate},{convert}" |
| 357 | f"setparams=colorspace=bt709:color_primaries=bt709:color_trc=bt709:range=tv," | 367 | f"setparams=colorspace=bt709:color_primaries=bt709:color_trc=bt709:range=tv," |
| 358 | - f"setsar=1,format=yuv420p,tpad=stop_mode=clone:stop_duration=0.5,trim=end_frame={n}") | 368 | + f"setsar=1,format=yuv420p,{hold}tpad=stop_mode=clone:stop_duration={float(length) + 0.5:.3f}," |
| 369 | + f"trim=end_frame={n}") | ||
| 359 | 370 | ||
| 360 | fit = (f"scale={w}:{h}:force_original_aspect_ratio=decrease:flags=lanczos," | 371 | fit = (f"scale={w}:{h}:force_original_aspect_ratio=decrease:flags=lanczos," |
| 361 | f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,setsar=1,format=yuv420p") | 372 | f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,setsar=1,format=yuv420p") |
| 362 | zoom = (zoom_filter(p["zooms"], seg["start"], fps) + ",") if seg["zoom"] else "" | 373 | zoom = (zoom_filter(p["zooms"], seg["start"], fps) + ",") if seg["zoom"] else "" |
| 363 | sound = f"aresample={AUDIO_RATE},aformat=sample_fmts=s16:channel_layouts=stereo,apad,atrim=end_sample={samples}" | 374 | sound = f"aresample={AUDIO_RATE},aformat=sample_fmts=s16:channel_layouts=stereo,apad,atrim=end_sample={samples}" |
| 364 | - silence = f"anullsrc=r={AUDIO_RATE}:cl=stereo,atrim=end_sample={samples}" | ||
| 365 | 375 | ||
| 366 | - def voice(n: int, clip: dict, label: str) -> str: | 376 | + def voice(n: int, clip: dict, label: str, count: int = samples, after: str = "") -> str: |
| 367 | - """Le son d'une entrée, à son volume ; du silence si le fichier n'en a pas.""" | 377 | + """Le son d'une entrée, à son volume, sur `count` échantillons ; du silence si le fichier n'en a pas.""" |
| 368 | if not clip.get("audio", True): | 378 | if not clip.get("audio", True): |
| 369 | - return f"{silence}[{label}]" | 379 | + return f"anullsrc=r={AUDIO_RATE}:cl=stereo,atrim=end_sample={count}{after}[{label}]" |
| 370 | gain = f"volume={clip['gain_db']}dB," if clip.get("gain_db") else "" | 380 | gain = f"volume={clip['gain_db']}dB," if clip.get("gain_db") else "" |
| 371 | - return f"[{n}:a]{gain}{sound}[{label}]" | 381 | + return (f"[{n}:a]{gain}aresample={AUDIO_RATE},aformat=sample_fmts=s16:channel_layouts=stereo,apad," |
| 382 | + f"atrim=end_sample={count}{after}[{label}]") | ||
| 372 | 383 | ||
| 373 | if seg["fade"] is None: | 384 | if seg["fade"] is None: |
| 374 | graph = [f"[0:v]{prep(clip_a)},{zoom}{fit}[v]", voice(0, clip_a, "a")] | 385 | graph = [f"[0:v]{prep(clip_a)},{zoom}{fit}[v]", voice(0, clip_a, "a")] |
| 375 | else: | 386 | else: |
| 376 | - graph = [f"[0:v]{prep(clip_a)}[va]", f"[1:v]{prep(clip_b)}[vb]", | 387 | + graph = [f"[0:v]{prep(clip_a)}[va]", f"[1:v]{prep(clip_b, lead)}[vb]", |
| 377 | - f"[va][vb]xfade=transition={seg['fx']}:duration={float(length):.6f}:offset=0,{zoom}{fit}[v]", | 388 | + f"[va][vb]xfade=transition={seg['fx']}:duration={float(length):.6f}:offset=0,{zoom}{fit}[v]"] |
| 378 | - voice(0, clip_a, "aa"), voice(1, clip_b, "ab"), | 389 | + if seg.get("held"): |
| 379 | - f"[aa][ab]acrossfade=ns={samples}:c1=tri:c2=tri,{sound}[a]"] | 390 | + # une image tenue d'un côté : le son coupe net à la coupe (L-cut), un fondu de 10 ms contre le clic |
| 391 | + before = _samples(seg["start"] + seg["cut"], fps) - _samples(seg["start"], fps) | ||
| 392 | + edge = min(AUDIO_RATE // 100, before, samples - before) | ||
| 393 | + graph += [voice(0, clip_a, "aa", before, f",afade=t=out:start_sample={before - edge}:nb_samples={edge}"), | ||
| 394 | + voice(2, clip_b, "ab", samples - before, f",afade=t=in:nb_samples={edge}"), | ||
| 395 | + "[aa][ab]concat=n=2:v=0:a=1[a]"] | ||
| 396 | + else: | ||
| 397 | + graph += [voice(0, clip_a, "aa"), voice(1, clip_b, "ab"), | ||
| 398 | + f"[aa][ab]acrossfade=ns={samples}:c1=tri:c2=tri,{sound}[a]"] | ||
| 380 | script = dest.with_suffix(".txt") | 399 | script = dest.with_suffix(".txt") |
| 381 | script.write_text(";\n".join(graph), encoding="utf-8") | 400 | script.write_text(";\n".join(graph), encoding="utf-8") |
| 382 | return args + ["-filter_complex_script", str(script), "-map", "[v]", "-map", "[a]", "-frames:v", str(n), | 401 | return args + ["-filter_complex_script", str(script), "-map", "[v]", "-map", "[a]", "-frames:v", str(n), |
modified
web/src/i18n/locales/en/editing.ts +1 -1 | @@ -61,7 +61,7 @@ export default { | ||
| 61 | 61 | sourceVideo: "Source video", |
| 62 | 62 | inSource: "{from} → {to} in the source · {dur}", |
| 63 | 63 | arollHint: "Blade (B) or Ctrl+B to cut at the playhead; deleting a clip closes the gap.", |
| 64 | - insertedHint: "Clip inserted from another file, with its own sound; drag its edges to shorten it. No transition with it: a whoosh does the job.", | |
| 64 | + insertedHint: "Clip inserted from another file, with its own sound; drag its edges to shorten it. A transition with its neighbours holds its first or last frame if it is used whole.", | |
| 65 | 65 | insertAroll: "→ A-roll", |
| 66 | 66 | insertArollTitle: "Insert into the A-roll at {tc} (everything after moves along)", |
| 67 | 67 | scale: "Scale", |
| @@ -61,7 +61,7 @@ export default { | |||
| 61 | sourceVideo: "Source video", | 61 | sourceVideo: "Source video", |
| 62 | inSource: "{from} → {to} in the source · {dur}", | 62 | inSource: "{from} → {to} in the source · {dur}", |
| 63 | arollHint: "Blade (B) or Ctrl+B to cut at the playhead; deleting a clip closes the gap.", | 63 | arollHint: "Blade (B) or Ctrl+B to cut at the playhead; deleting a clip closes the gap.", |
| 64 | - insertedHint: "Clip inserted from another file, with its own sound; drag its edges to shorten it. No transition with it: a whoosh does the job.", | 64 | + insertedHint: "Clip inserted from another file, with its own sound; drag its edges to shorten it. A transition with its neighbours holds its first or last frame if it is used whole.", |
| 65 | insertAroll: "→ A-roll", | 65 | insertAroll: "→ A-roll", |
| 66 | insertArollTitle: "Insert into the A-roll at {tc} (everything after moves along)", | 66 | insertArollTitle: "Insert into the A-roll at {tc} (everything after moves along)", |
| 67 | scale: "Scale", | 67 | scale: "Scale", |
modified
web/src/i18n/locales/fr/editing.ts +1 -1 | @@ -61,7 +61,7 @@ export default { | ||
| 61 | 61 | sourceVideo: "Vidéo source", |
| 62 | 62 | inSource: "{from} → {to} dans la source · {dur}", |
| 63 | 63 | arollHint: "Lame (B) ou Ctrl+B pour couper à la tête de lecture ; supprimer un clip referme le trou.", |
| 64 | - insertedHint: "Clip inséré d’un autre média, avec son propre son ; tire ses bords pour le raccourcir. Pas de transition avec lui : un whoosh fait l’affaire.", | |
| 64 | + insertedHint: "Clip inséré d’un autre média, avec son propre son ; tire ses bords pour le raccourcir. Une transition avec ses voisins tient sa première ou sa dernière image s’il est utilisé en entier.", | |
| 65 | 65 | insertAroll: "→ A-roll", |
| 66 | 66 | insertArollTitle: "Insérer dans l’A-roll à {tc} (la suite est décalée d’autant)", |
| 67 | 67 | scale: "Échelle", |
| @@ -61,7 +61,7 @@ export default { | |||
| 61 | sourceVideo: "Vidéo source", | 61 | sourceVideo: "Vidéo source", |
| 62 | inSource: "{from} → {to} dans la source · {dur}", | 62 | inSource: "{from} → {to} dans la source · {dur}", |
| 63 | arollHint: "Lame (B) ou Ctrl+B pour couper à la tête de lecture ; supprimer un clip referme le trou.", | 63 | arollHint: "Lame (B) ou Ctrl+B pour couper à la tête de lecture ; supprimer un clip referme le trou.", |
| 64 | - insertedHint: "Clip inséré d’un autre média, avec son propre son ; tire ses bords pour le raccourcir. Pas de transition avec lui : un whoosh fait l’affaire.", | 64 | + insertedHint: "Clip inséré d’un autre média, avec son propre son ; tire ses bords pour le raccourcir. Une transition avec ses voisins tient sa première ou sa dernière image s’il est utilisé en entier.", |
| 65 | insertAroll: "→ A-roll", | 65 | insertAroll: "→ A-roll", |
| 66 | insertArollTitle: "Insérer dans l’A-roll à {tc} (la suite est décalée d’autant)", | 66 | insertArollTitle: "Insérer dans l’A-roll à {tc} (la suite est décalée d’autant)", |
| 67 | scale: "Échelle", | 67 | scale: "Échelle", |
modified
web/src/project/steps/montage/Inspector.vue +1 -1 | @@ -184,7 +184,7 @@ const SOUND_ROLES = computed(() => [["sfx", tr("editing.inspector.sound")], ["mu | ||
| 184 | 184 | @dblclick="set<ArollClip>((x) => { delete x.gain_db; })"> |
| 185 | 185 | </label> |
| 186 | 186 | <p v-if="(item.data as ArollClip).src" class="hint">{{ tr("editing.inspector.insertedHint") }}</p> |
| 187 | - <TransitionPicker v-else-if="tl.aroll[0]?.id !== item.data.id" :title="tr('transitions.withPrev')" | |
| 187 | + <TransitionPicker v-if="tl.aroll[0]?.id !== item.data.id" :title="tr('transitions.withPrev')" | |
| 188 | 188 | :model-value="(item.data as ArollClip).transition" @update:model-value="(v) => set<ArollClip>((x) => { x.transition = v ?? null; })" /> |
| 189 | 189 | <p class="hint">{{ tr("editing.inspector.arollHint") }}</p> |
| 190 | 190 | </template> |
| @@ -184,7 +184,7 @@ const SOUND_ROLES = computed(() => [["sfx", tr("editing.inspector.sound")], ["mu | |||
| 184 | @dblclick="set<ArollClip>((x) => { delete x.gain_db; })"> | 184 | @dblclick="set<ArollClip>((x) => { delete x.gain_db; })"> |
| 185 | </label> | 185 | </label> |
| 186 | <p v-if="(item.data as ArollClip).src" class="hint">{{ tr("editing.inspector.insertedHint") }}</p> | 186 | <p v-if="(item.data as ArollClip).src" class="hint">{{ tr("editing.inspector.insertedHint") }}</p> |
| 187 | - <TransitionPicker v-else-if="tl.aroll[0]?.id !== item.data.id" :title="tr('transitions.withPrev')" | 187 | + <TransitionPicker v-if="tl.aroll[0]?.id !== item.data.id" :title="tr('transitions.withPrev')" |
| 188 | :model-value="(item.data as ArollClip).transition" @update:model-value="(v) => set<ArollClip>((x) => { x.transition = v ?? null; })" /> | 188 | :model-value="(item.data as ArollClip).transition" @update:model-value="(v) => set<ArollClip>((x) => { x.transition = v ?? null; })" /> |
| 189 | <p class="hint">{{ tr("editing.inspector.arollHint") }}</p> | 189 | <p class="hint">{{ tr("editing.inspector.arollHint") }}</p> |
| 190 | </template> | 190 | </template> |
modified
web/src/project/steps/montage/Preview.vue +21 -13 | @@ -18,7 +18,7 @@ import { fxFrame, layersAt } from "../../../lib/transitions"; | ||
| 18 | 18 | import LayerVideo from "./LayerVideo.vue"; |
| 19 | 19 | import { releaseMedia } from "../../../lib/media"; |
| 20 | 20 | import { |
| 21 | - clipStarts, isImage, slideImage, tcd, TEXT_BORDER, TEXT_FADE, TEXT_FONT, type AnimState, type Beat, type MontageView, type TextNote, type Timeline, type Visual, | |
| 21 | + type ArollClip, clipStarts, isImage, slideImage, tcd, TEXT_BORDER, TEXT_FADE, TEXT_FONT, type AnimState, type Beat, type MontageView, type TextNote, type Timeline, type Visual, | |
| 22 | 22 | } from "./types"; |
| 23 | 23 | |
| 24 | 24 | const props = defineProps<{ |
| @@ -107,35 +107,43 @@ const videoStyle = computed(() => ({ | ||
| 107 | 107 | |
| 108 | 108 | // --- transitions de l'A-roll : un second lecteur joue l'autre côté de la coupe (avant la coupe, le clip qui |
| 109 | 109 | // arrive ; après, la fin prolongée de celui qui part). Mêmes bornes qu'au rendu (render.segments) : centrée sur |
| 110 | -// la coupe, réduite si la source n'a pas assez de marge. | |
| 110 | +// la coupe ; un côté sans assez de marge (un clip inséré utilisé en entier) tient son image de bord. | |
| 111 | +const clipUrl = (c: ArollClip) => (c.src ? props.view.media[c.src]?.url : props.view.source?.url) ?? ""; | |
| 112 | +const clipLength = (c: ArollClip) => (c.src ? props.view.media[c.src]?.duration ?? c.out : props.view.source?.duration ?? Infinity); | |
| 111 | 113 | const cuts = computed(() => { |
| 112 | - const starts = clipStarts(props.tl), total = props.view.source?.duration ?? 0; | |
| 114 | + const starts = clipStarts(props.tl); | |
| 113 | 115 | return props.tl.aroll.flatMap((cur, i) => { |
| 114 | 116 | const prev = props.tl.aroll[i - 1], tr = cur.transition; |
| 115 | - // pas de transition avec un clip inséré d'un autre média (comme fxMax) | |
| 116 | - if (!prev || !tr || prev.src || cur.src) return []; | |
| 117 | - const half = Math.min(tr.dur / 2, (prev.out - prev.in) / 2, (cur.out - cur.in) / 2, Math.max(0, total - prev.out), cur.in); | |
| 118 | - return half > 0.02 ? [{ fx: tr.fx, at: starts[i], half, out: prev.out, in: cur.in }] : []; | |
| 117 | + if (!prev || !tr) return []; | |
| 118 | + const half = Math.min(tr.dur / 2, (prev.out - prev.in) / 2, (cur.out - cur.in) / 2); | |
| 119 | + return half > 0.02 ? [{ fx: tr.fx, at: starts[i], half, prev, cur }] : []; | |
| 119 | 120 | }); |
| 120 | 121 | }); |
| 122 | +// une image avant la fin : la dernière qu'un lecteur sait afficher | |
| 123 | +const FRAME = 1 / 30; | |
| 121 | 124 | const arollFx = computed(() => { |
| 122 | 125 | const c = cuts.value.find((x) => Math.abs(t.value - x.at) < x.half); |
| 123 | 126 | if (!c) return null; |
| 124 | 127 | const f = fxFrame(c.fx, (t.value - (c.at - c.half)) / (2 * c.half), "pair"); |
| 125 | 128 | const before = t.value < c.at; |
| 129 | + // l'autre côté : le clip qui arrive (avant son début, sa première image), ou celui qui part (après sa fin, la dernière) | |
| 130 | + const want = before ? c.cur.in + (t.value - c.at) : c.prev.out + (t.value - c.at); | |
| 131 | + const last = clipLength(c.prev) - FRAME; | |
| 132 | + const time = before ? Math.max(0, want) : Math.min(last, want); | |
| 126 | 133 | // A dessous, l'aplat, B dessus |
| 127 | 134 | return { |
| 128 | 135 | main: { ...(before ? f.a : f.b), zIndex: before ? 1 : 3 }, |
| 129 | 136 | ghost: { ...(before ? f.b : f.a), zIndex: before ? 3 : 1 }, |
| 130 | 137 | veil: f.veil ? { ...f.veil, zIndex: 2 } : null, |
| 131 | - time: before ? c.in + (t.value - c.at) : c.out + (t.value - c.at), | |
| 138 | + url: clipUrl(before ? c.cur : c.prev), time, held: time !== want, | |
| 132 | 139 | }; |
| 133 | 140 | }); |
| 134 | 141 | // hors transition, le second lecteur attend déjà au début de la prochaine |
| 135 | -const ghostTime = computed(() => arollFx.value?.time ?? (() => { | |
| 142 | +const ghost = computed(() => { | |
| 143 | + if (arollFx.value) return { url: arollFx.value.url, time: arollFx.value.time }; | |
| 136 | 144 | const next = cuts.value.find((x) => x.at - x.half > t.value); |
| 137 | - return next ? next.in - next.half : 0; | |
| 138 | -})()); | |
| 145 | + return next ? { url: clipUrl(next.cur), time: Math.max(0, next.cur.in - next.half) } : null; | |
| 146 | +}); | |
| 139 | 147 | |
| 140 | 148 | // --- B-roll : les plans visibles à t, avec leurs transitions (deux à la fois pendant un enchaînement) |
| 141 | 149 | const brolls = computed(() => { |
| @@ -310,8 +318,8 @@ defineExpose({ capture }); | ||
| 310 | 318 | <div class="aroll-box layer" :style="videoStyle" @click="player.toggle()"> |
| 311 | 319 | <video ref="el" class="aroll" preload="auto" playsinline :style="arollFx?.main" /> |
| 312 | 320 | <div v-if="arollFx?.veil" class="veil" :style="arollFx.veil" /> |
| 313 | - <LayerVideo v-if="view.source && cuts.length" class="aroll ghost" :class="{ idle: !arollFx }" :src="view.source.url" | |
| 314 | - :time="ghostTime" :playing="playing && !!arollFx" :style="arollFx?.ghost" /> | |
| 321 | + <LayerVideo v-if="ghost" class="aroll ghost" :class="{ idle: !arollFx }" :src="ghost.url" | |
| 322 | + :time="ghost.time" :playing="playing && !!arollFx && !arollFx.held" :style="arollFx?.ghost" /> | |
| 315 | 323 | </div> |
| 316 | 324 | <template v-for="l in brollLayers" :key="l.id"> |
| 317 | 325 | <div v-if="l.veil && !l.over" class="veil layer" :style="l.veil" /> |
| @@ -18,7 +18,7 @@ import { fxFrame, layersAt } from "../../../lib/transitions"; | |||
| 18 | import LayerVideo from "./LayerVideo.vue"; | 18 | import LayerVideo from "./LayerVideo.vue"; |
| 19 | import { releaseMedia } from "../../../lib/media"; | 19 | import { releaseMedia } from "../../../lib/media"; |
| 20 | import { | 20 | import { |
| 21 | - clipStarts, isImage, slideImage, tcd, TEXT_BORDER, TEXT_FADE, TEXT_FONT, type AnimState, type Beat, type MontageView, type TextNote, type Timeline, type Visual, | 21 | + type ArollClip, clipStarts, isImage, slideImage, tcd, TEXT_BORDER, TEXT_FADE, TEXT_FONT, type AnimState, type Beat, type MontageView, type TextNote, type Timeline, type Visual, |
| 22 | } from "./types"; | 22 | } from "./types"; |
| 23 | 23 | ||
| 24 | const props = defineProps<{ | 24 | const props = defineProps<{ |
| @@ -107,35 +107,43 @@ const videoStyle = computed(() => ({ | |||
| 107 | 107 | ||
| 108 | // --- transitions de l'A-roll : un second lecteur joue l'autre côté de la coupe (avant la coupe, le clip qui | 108 | // --- transitions de l'A-roll : un second lecteur joue l'autre côté de la coupe (avant la coupe, le clip qui |
| 109 | // arrive ; après, la fin prolongée de celui qui part). Mêmes bornes qu'au rendu (render.segments) : centrée sur | 109 | // arrive ; après, la fin prolongée de celui qui part). Mêmes bornes qu'au rendu (render.segments) : centrée sur |
| 110 | -// la coupe, réduite si la source n'a pas assez de marge. | 110 | +// la coupe ; un côté sans assez de marge (un clip inséré utilisé en entier) tient son image de bord. |
| 111 | +const clipUrl = (c: ArollClip) => (c.src ? props.view.media[c.src]?.url : props.view.source?.url) ?? ""; | ||
| 112 | +const clipLength = (c: ArollClip) => (c.src ? props.view.media[c.src]?.duration ?? c.out : props.view.source?.duration ?? Infinity); | ||
| 111 | const cuts = computed(() => { | 113 | const cuts = computed(() => { |
| 112 | - const starts = clipStarts(props.tl), total = props.view.source?.duration ?? 0; | 114 | + const starts = clipStarts(props.tl); |
| 113 | return props.tl.aroll.flatMap((cur, i) => { | 115 | return props.tl.aroll.flatMap((cur, i) => { |
| 114 | const prev = props.tl.aroll[i - 1], tr = cur.transition; | 116 | const prev = props.tl.aroll[i - 1], tr = cur.transition; |
| 115 | - // pas de transition avec un clip inséré d'un autre média (comme fxMax) | 117 | + if (!prev || !tr) return []; |
| 116 | - if (!prev || !tr || prev.src || cur.src) return []; | 118 | + const half = Math.min(tr.dur / 2, (prev.out - prev.in) / 2, (cur.out - cur.in) / 2); |
| 117 | - const half = Math.min(tr.dur / 2, (prev.out - prev.in) / 2, (cur.out - cur.in) / 2, Math.max(0, total - prev.out), cur.in); | 119 | + return half > 0.02 ? [{ fx: tr.fx, at: starts[i], half, prev, cur }] : []; |
| 118 | - return half > 0.02 ? [{ fx: tr.fx, at: starts[i], half, out: prev.out, in: cur.in }] : []; | ||
| 119 | }); | 120 | }); |
| 120 | }); | 121 | }); |
| 122 | +// une image avant la fin : la dernière qu'un lecteur sait afficher | ||
| 123 | +const FRAME = 1 / 30; | ||
| 121 | const arollFx = computed(() => { | 124 | const arollFx = computed(() => { |
| 122 | const c = cuts.value.find((x) => Math.abs(t.value - x.at) < x.half); | 125 | const c = cuts.value.find((x) => Math.abs(t.value - x.at) < x.half); |
| 123 | if (!c) return null; | 126 | if (!c) return null; |
| 124 | const f = fxFrame(c.fx, (t.value - (c.at - c.half)) / (2 * c.half), "pair"); | 127 | const f = fxFrame(c.fx, (t.value - (c.at - c.half)) / (2 * c.half), "pair"); |
| 125 | const before = t.value < c.at; | 128 | const before = t.value < c.at; |
| 129 | + // l'autre côté : le clip qui arrive (avant son début, sa première image), ou celui qui part (après sa fin, la dernière) | ||
| 130 | + const want = before ? c.cur.in + (t.value - c.at) : c.prev.out + (t.value - c.at); | ||
| 131 | + const last = clipLength(c.prev) - FRAME; | ||
| 132 | + const time = before ? Math.max(0, want) : Math.min(last, want); | ||
| 126 | // A dessous, l'aplat, B dessus | 133 | // A dessous, l'aplat, B dessus |
| 127 | return { | 134 | return { |
| 128 | main: { ...(before ? f.a : f.b), zIndex: before ? 1 : 3 }, | 135 | main: { ...(before ? f.a : f.b), zIndex: before ? 1 : 3 }, |
| 129 | ghost: { ...(before ? f.b : f.a), zIndex: before ? 3 : 1 }, | 136 | ghost: { ...(before ? f.b : f.a), zIndex: before ? 3 : 1 }, |
| 130 | veil: f.veil ? { ...f.veil, zIndex: 2 } : null, | 137 | veil: f.veil ? { ...f.veil, zIndex: 2 } : null, |
| 131 | - time: before ? c.in + (t.value - c.at) : c.out + (t.value - c.at), | 138 | + url: clipUrl(before ? c.cur : c.prev), time, held: time !== want, |
| 132 | }; | 139 | }; |
| 133 | }); | 140 | }); |
| 134 | // hors transition, le second lecteur attend déjà au début de la prochaine | 141 | // hors transition, le second lecteur attend déjà au début de la prochaine |
| 135 | -const ghostTime = computed(() => arollFx.value?.time ?? (() => { | 142 | +const ghost = computed(() => { |
| 143 | + if (arollFx.value) return { url: arollFx.value.url, time: arollFx.value.time }; | ||
| 136 | const next = cuts.value.find((x) => x.at - x.half > t.value); | 144 | const next = cuts.value.find((x) => x.at - x.half > t.value); |
| 137 | - return next ? next.in - next.half : 0; | 145 | + return next ? { url: clipUrl(next.cur), time: Math.max(0, next.cur.in - next.half) } : null; |
| 138 | -})()); | 146 | +}); |
| 139 | 147 | ||
| 140 | // --- B-roll : les plans visibles à t, avec leurs transitions (deux à la fois pendant un enchaînement) | 148 | // --- B-roll : les plans visibles à t, avec leurs transitions (deux à la fois pendant un enchaînement) |
| 141 | const brolls = computed(() => { | 149 | const brolls = computed(() => { |
| @@ -310,8 +318,8 @@ defineExpose({ capture }); | |||
| 310 | <div class="aroll-box layer" :style="videoStyle" @click="player.toggle()"> | 318 | <div class="aroll-box layer" :style="videoStyle" @click="player.toggle()"> |
| 311 | <video ref="el" class="aroll" preload="auto" playsinline :style="arollFx?.main" /> | 319 | <video ref="el" class="aroll" preload="auto" playsinline :style="arollFx?.main" /> |
| 312 | <div v-if="arollFx?.veil" class="veil" :style="arollFx.veil" /> | 320 | <div v-if="arollFx?.veil" class="veil" :style="arollFx.veil" /> |
| 313 | - <LayerVideo v-if="view.source && cuts.length" class="aroll ghost" :class="{ idle: !arollFx }" :src="view.source.url" | 321 | + <LayerVideo v-if="ghost" class="aroll ghost" :class="{ idle: !arollFx }" :src="ghost.url" |
| 314 | - :time="ghostTime" :playing="playing && !!arollFx" :style="arollFx?.ghost" /> | 322 | + :time="ghost.time" :playing="playing && !!arollFx && !arollFx.held" :style="arollFx?.ghost" /> |
| 315 | </div> | 323 | </div> |
| 316 | <template v-for="l in brollLayers" :key="l.id"> | 324 | <template v-for="l in brollLayers" :key="l.id"> |
| 317 | <div v-if="l.veil && !l.over" class="veil layer" :style="l.veil" /> | 325 | <div v-if="l.veil && !l.over" class="veil layer" :style="l.veil" /> |
modified
web/src/project/steps/montage/Timeline.vue +2 -2 | @@ -186,7 +186,7 @@ type FxTrack = "aroll" | "broll" | "motion"; | ||
| 186 | 186 | const fxV = computed(() => { |
| 187 | 187 | const out: { track: FxTrack; id: string; fx: FxEnd; name: string; a: number; b: number; centered: boolean }[] = []; |
| 188 | 188 | const add = (track: FxTrack, id: string, fx: FxEnd, name: string, centered: boolean) => { |
| 189 | - const span = fxSpan(props.tl, track, id, fx, props.sourceDuration); | |
| 189 | + const span = fxSpan(props.tl, track, id, fx); | |
| 190 | 190 | if (span && visible(span[0], span[1])) out.push({ track, id, fx, name, a: span[0], b: span[1], centered }); |
| 191 | 191 | }; |
| 192 | 192 | for (const c of props.tl.aroll) if (c.transition) add("aroll", c.id, "transition", c.transition.fx, true); |
| @@ -375,7 +375,7 @@ function applyFx(tl: Timeline, d: NonNullable<typeof drag>, t: number) { | ||
| 375 | 375 | const centered = d.fx === "transition" || (d.fx === "enter" && !!prevJoined(d.origin, d.track as "broll" | "motion", d.id)); |
| 376 | 376 | let dur = centered ? 2 * Math.abs(t - (span[0] + span[1]) / 2) : d.fx === "enter" ? t - span[0] : span[1] - t; |
| 377 | 377 | // au pas de 0,05 s, sans dépasser ce que le rendu peut jouer |
| 378 | - const max = Math.min(FX_DUR[1], Math.floor(fxMax(d.origin, d.track, d.id, d.fx!, props.sourceDuration) * 20) / 20); | |
| 378 | + const max = Math.min(FX_DUR[1], Math.floor(fxMax(d.origin, d.track, d.id, d.fx!) * 20) / 20); | |
| 379 | 379 | dur = Math.round(Math.max(FX_DUR[0], Math.min(max, dur)) * 20) / 20; |
| 380 | 380 | if (d.fx === "transition") { |
| 381 | 381 | const c = tl.aroll.find((k) => k.id === d.id); |
| @@ -186,7 +186,7 @@ type FxTrack = "aroll" | "broll" | "motion"; | |||
| 186 | const fxV = computed(() => { | 186 | const fxV = computed(() => { |
| 187 | const out: { track: FxTrack; id: string; fx: FxEnd; name: string; a: number; b: number; centered: boolean }[] = []; | 187 | const out: { track: FxTrack; id: string; fx: FxEnd; name: string; a: number; b: number; centered: boolean }[] = []; |
| 188 | const add = (track: FxTrack, id: string, fx: FxEnd, name: string, centered: boolean) => { | 188 | const add = (track: FxTrack, id: string, fx: FxEnd, name: string, centered: boolean) => { |
| 189 | - const span = fxSpan(props.tl, track, id, fx, props.sourceDuration); | 189 | + const span = fxSpan(props.tl, track, id, fx); |
| 190 | if (span && visible(span[0], span[1])) out.push({ track, id, fx, name, a: span[0], b: span[1], centered }); | 190 | if (span && visible(span[0], span[1])) out.push({ track, id, fx, name, a: span[0], b: span[1], centered }); |
| 191 | }; | 191 | }; |
| 192 | for (const c of props.tl.aroll) if (c.transition) add("aroll", c.id, "transition", c.transition.fx, true); | 192 | for (const c of props.tl.aroll) if (c.transition) add("aroll", c.id, "transition", c.transition.fx, true); |
| @@ -375,7 +375,7 @@ function applyFx(tl: Timeline, d: NonNullable<typeof drag>, t: number) { | |||
| 375 | const centered = d.fx === "transition" || (d.fx === "enter" && !!prevJoined(d.origin, d.track as "broll" | "motion", d.id)); | 375 | const centered = d.fx === "transition" || (d.fx === "enter" && !!prevJoined(d.origin, d.track as "broll" | "motion", d.id)); |
| 376 | let dur = centered ? 2 * Math.abs(t - (span[0] + span[1]) / 2) : d.fx === "enter" ? t - span[0] : span[1] - t; | 376 | let dur = centered ? 2 * Math.abs(t - (span[0] + span[1]) / 2) : d.fx === "enter" ? t - span[0] : span[1] - t; |
| 377 | // au pas de 0,05 s, sans dépasser ce que le rendu peut jouer | 377 | // au pas de 0,05 s, sans dépasser ce que le rendu peut jouer |
| 378 | - const max = Math.min(FX_DUR[1], Math.floor(fxMax(d.origin, d.track, d.id, d.fx!, props.sourceDuration) * 20) / 20); | 378 | + const max = Math.min(FX_DUR[1], Math.floor(fxMax(d.origin, d.track, d.id, d.fx!) * 20) / 20); |
| 379 | dur = Math.round(Math.max(FX_DUR[0], Math.min(max, dur)) * 20) / 20; | 379 | dur = Math.round(Math.max(FX_DUR[0], Math.min(max, dur)) * 20) / 20; |
| 380 | if (d.fx === "transition") { | 380 | if (d.fx === "transition") { |
| 381 | const c = tl.aroll.find((k) => k.id === d.id); | 381 | const c = tl.aroll.find((k) => k.id === d.id); |
modified
web/src/project/steps/montage/types.ts +5 -7 | @@ -207,11 +207,11 @@ export function prevJoined(tl: Timeline, track: "broll" | "motion", id: string): | ||
| 207 | 207 | * ou entre deux plans collés, sinon dans les premières / dernières secondes du plan. null : pas de transition, ou |
| 208 | 208 | * une sortie remplacée par l'enchaînement avec le plan suivant. |
| 209 | 209 | */ |
| 210 | -export function fxSpan(tl: Timeline, track: Track, id: string, fx: FxEnd, sourceDuration = Infinity): [number, number] | null { | |
| 210 | +export function fxSpan(tl: Timeline, track: Track, id: string, fx: FxEnd): [number, number] | null { | |
| 211 | 211 | if (track === "aroll") { |
| 212 | 212 | const i = tl.aroll.findIndex((c) => c.id === id), tr = tl.aroll[i]?.transition; |
| 213 | 213 | if (i < 1 || !tr) return null; |
| 214 | - const cut = clipStarts(tl)[i], half = Math.min(tr.dur, fxMax(tl, track, id, fx, sourceDuration)) / 2; | |
| 214 | + const cut = clipStarts(tl)[i], half = Math.min(tr.dur, fxMax(tl, track, id, fx)) / 2; | |
| 215 | 215 | return [cut - half, cut + half]; |
| 216 | 216 | } |
| 217 | 217 | if (track !== "broll" && track !== "motion") return null; |
| @@ -235,16 +235,14 @@ export function fxSpan(tl: Timeline, track: Track, id: string, fx: FxEnd, source | ||
| 235 | 235 | |
| 236 | 236 | /** |
| 237 | 237 | * La plus longue durée qu'une transition peut jouer, comme au rendu : entre deux clips de l'A-roll, la moitié de |
| 238 | - * chacun et la marge de la source de part et d'autre de la coupe ; entre deux plans collés, le plus court des | |
| 238 | + * chacun (un côté sans assez de marge tient son image de bord) ; entre deux plans collés, le plus court des | |
| 239 | 239 | * deux ; une entrée ou une sortie, la moitié du plan. |
| 240 | 240 | */ |
| 241 | -export function fxMax(tl: Timeline, track: Track, id: string, fx: FxEnd, sourceDuration = Infinity): number { | |
| 241 | +export function fxMax(tl: Timeline, track: Track, id: string, fx: FxEnd): number { | |
| 242 | 242 | if (track === "aroll") { |
| 243 | 243 | const i = tl.aroll.findIndex((c) => c.id === id), prev = tl.aroll[i - 1], cur = tl.aroll[i]; |
| 244 | 244 | if (!prev || !cur) return 0; |
| 245 | - // un clip inséré d'un autre média n'a pas de marge connue : pas de transition avec lui | |
| 246 | - if (prev.src || cur.src) return 0; | |
| 247 | - return 2 * Math.min((prev.out - prev.in) / 2, (cur.out - cur.in) / 2, Math.max(0, sourceDuration - prev.out), cur.in); | |
| 245 | + return 2 * Math.min((prev.out - prev.in) / 2, (cur.out - cur.in) / 2); | |
| 248 | 246 | } |
| 249 | 247 | const span = spanOf(tl, track, id); |
| 250 | 248 | if (!span) return 0; |
| @@ -207,11 +207,11 @@ export function prevJoined(tl: Timeline, track: "broll" | "motion", id: string): | |||
| 207 | * ou entre deux plans collés, sinon dans les premières / dernières secondes du plan. null : pas de transition, ou | 207 | * ou entre deux plans collés, sinon dans les premières / dernières secondes du plan. null : pas de transition, ou |
| 208 | * une sortie remplacée par l'enchaînement avec le plan suivant. | 208 | * une sortie remplacée par l'enchaînement avec le plan suivant. |
| 209 | */ | 209 | */ |
| 210 | -export function fxSpan(tl: Timeline, track: Track, id: string, fx: FxEnd, sourceDuration = Infinity): [number, number] | null { | 210 | +export function fxSpan(tl: Timeline, track: Track, id: string, fx: FxEnd): [number, number] | null { |
| 211 | if (track === "aroll") { | 211 | if (track === "aroll") { |
| 212 | const i = tl.aroll.findIndex((c) => c.id === id), tr = tl.aroll[i]?.transition; | 212 | const i = tl.aroll.findIndex((c) => c.id === id), tr = tl.aroll[i]?.transition; |
| 213 | if (i < 1 || !tr) return null; | 213 | if (i < 1 || !tr) return null; |
| 214 | - const cut = clipStarts(tl)[i], half = Math.min(tr.dur, fxMax(tl, track, id, fx, sourceDuration)) / 2; | 214 | + const cut = clipStarts(tl)[i], half = Math.min(tr.dur, fxMax(tl, track, id, fx)) / 2; |
| 215 | return [cut - half, cut + half]; | 215 | return [cut - half, cut + half]; |
| 216 | } | 216 | } |
| 217 | if (track !== "broll" && track !== "motion") return null; | 217 | if (track !== "broll" && track !== "motion") return null; |
| @@ -235,16 +235,14 @@ export function fxSpan(tl: Timeline, track: Track, id: string, fx: FxEnd, source | |||
| 235 | 235 | ||
| 236 | /** | 236 | /** |
| 237 | * La plus longue durée qu'une transition peut jouer, comme au rendu : entre deux clips de l'A-roll, la moitié de | 237 | * La plus longue durée qu'une transition peut jouer, comme au rendu : entre deux clips de l'A-roll, la moitié de |
| 238 | - * chacun et la marge de la source de part et d'autre de la coupe ; entre deux plans collés, le plus court des | 238 | + * chacun (un côté sans assez de marge tient son image de bord) ; entre deux plans collés, le plus court des |
| 239 | * deux ; une entrée ou une sortie, la moitié du plan. | 239 | * deux ; une entrée ou une sortie, la moitié du plan. |
| 240 | */ | 240 | */ |
| 241 | -export function fxMax(tl: Timeline, track: Track, id: string, fx: FxEnd, sourceDuration = Infinity): number { | 241 | +export function fxMax(tl: Timeline, track: Track, id: string, fx: FxEnd): number { |
| 242 | if (track === "aroll") { | 242 | if (track === "aroll") { |
| 243 | const i = tl.aroll.findIndex((c) => c.id === id), prev = tl.aroll[i - 1], cur = tl.aroll[i]; | 243 | const i = tl.aroll.findIndex((c) => c.id === id), prev = tl.aroll[i - 1], cur = tl.aroll[i]; |
| 244 | if (!prev || !cur) return 0; | 244 | if (!prev || !cur) return 0; |
| 245 | - // un clip inséré d'un autre média n'a pas de marge connue : pas de transition avec lui | 245 | + return 2 * Math.min((prev.out - prev.in) / 2, (cur.out - cur.in) / 2); |
| 246 | - if (prev.src || cur.src) return 0; | ||
| 247 | - return 2 * Math.min((prev.out - prev.in) / 2, (cur.out - cur.in) / 2, Math.max(0, sourceDuration - prev.out), cur.in); | ||
| 248 | } | 246 | } |
| 249 | const span = spanOf(tl, track, id); | 247 | const span = spanOf(tl, track, id); |
| 250 | if (!span) return 0; | 248 | if (!span) return 0; |