hlassiege/darylpublic Fork
⑂ 4bbc583
Commits
⬇ Clone ▾
git clone https://git.rickub.com/hlassiege/daryl.git
git clone ssh://git@rickub.com/hlassiege/daryl.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

feat(montage): allow transitions with inserted a-roll clips by holding their edge frame

hlassiege committed 2026-11-05T13:53:50+01:00 Browse files
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
232232 """
233233 Découpe l'A-roll en morceaux, bornes en images de la timeline :
234234 {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}.
236237
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).
240241 """
241242 def frame(t: float) -> int:
242243 return round(t * fps)
@@ -252,11 +253,9 @@ def segments(aroll: list[dict], zooms: list[dict], fps: Fraction, source_duratio
252253 for i in range(1, len(aroll)):
253254 tr = aroll[i].get("transition")
254255 prev, cur = aroll[i - 1], aroll[i]
255- if not tr or prev.get("src") or cur.get("src"):
256+ if not tr:
256257 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)
260259 n = int(half * fps)
261260 if n >= 1:
262261 windows[cuts[i] - n] = (cuts[i] + n, i)
@@ -282,7 +281,10 @@ def segments(aroll: list[dict], zooms: list[dict], fps: Fraction, source_duratio
282281 if a in windows and windows[a][0] == b:
283282 i = windows[a][1]
284283 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
285286 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,
286288 "src": float(prev["in"] + (Fraction(a) / fps - Fraction(starts[i - 1]))),
287289 "fade": float(cur["in"] + (Fraction(a) / fps - Fraction(starts[i])))})
288290 else:
@@ -341,42 +343,59 @@ def segment_args(p: dict, seg: dict, dest: Path) -> list[str]:
341343 read = f"{float(length) + 0.5:.3f}"
342344 args = [tool("ffmpeg"), "-y", "-v", "error", "-nostats", "-progress", "pipe:1",
343345 "-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
344348 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"]]
346353
347- def prep(clip: dict) -> str:
354+ def prep(clip: dict, lead: float = 0.0) -> str:
348355 """
349356 Une entrée à la cadence et en couleurs BT.709 (un clip SD inséré est en BT.601) : un A-roll aux
350357 propriétés uniformes, sinon ffmpeg réinitialise ses filtres en cours de passe finale et y perd des images.
351358 """
352359 convert = ("scale=iw:ih:in_color_matrix=bt601:out_color_matrix=bt709:out_range=tv,"
353360 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 ""
354364 # ramené à 0 avant la cadence : après la recherche dans la source, la première image arrive un peu après 0,
355365 # fps la placerait une image plus loin, et le recollage laisserait un trou d'une image dans l'A-roll
356366 return (f"setpts=PTS-STARTPTS,fps={rate},{convert}"
357367 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}")
359370
360371 fit = (f"scale={w}:{h}:force_original_aspect_ratio=decrease:flags=lanczos,"
361372 f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,setsar=1,format=yuv420p")
362373 zoom = (zoom_filter(p["zooms"], seg["start"], fps) + ",") if seg["zoom"] else ""
363374 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}"
365375
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."""
368378 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}]"
370380 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}]")
372383
373384 if seg["fade"] is None:
374385 graph = [f"[0:v]{prep(clip_a)},{zoom}{fit}[v]", voice(0, clip_a, "a")]
375386 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]"]
380399 script = dest.with_suffix(".txt")
381400 script.write_text(";\n".join(graph), encoding="utf-8")
382401 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 autre239+ 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 continue257 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 aux356 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-roll365 # 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 {
6161 sourceVideo: "Source video",
6262 inSource: "{from} → {to} in the source · {dur}",
6363 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.",
6565 insertAroll: "→ A-roll",
6666 insertArollTitle: "Insert into the A-roll at {tc} (everything after moves along)",
6767 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 {
6161 sourceVideo: "Vidéo source",
6262 inSource: "{from} → {to} dans la source · {dur}",
6363 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.",
6565 insertAroll: "→ A-roll",
6666 insertArollTitle: "Insérer dans l’A-roll à {tc} (la suite est décalée d’autant)",
6767 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
184184 @dblclick="set<ArollClip>((x) => { delete x.gain_db; })">
185185 </label>
186186 <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')"
188188 :model-value="(item.data as ArollClip).transition" @update:model-value="(v) => set<ArollClip>((x) => { x.transition = v ?? null; })" />
189189 <p class="hint">{{ tr("editing.inspector.arollHint") }}</p>
190190 </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";
1818 import LayerVideo from "./LayerVideo.vue";
1919 import { releaseMedia } from "../../../lib/media";
2020 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,
2222 } from "./types";
2323
2424 const props = defineProps<{
@@ -107,35 +107,43 @@ const videoStyle = computed(() => ({
107107
108108 // --- transitions de l'A-roll : un second lecteur joue l'autre côté de la coupe (avant la coupe, le clip qui
109109 // 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);
111113 const cuts = computed(() => {
112- const starts = clipStarts(props.tl), total = props.view.source?.duration ?? 0;
114+ const starts = clipStarts(props.tl);
113115 return props.tl.aroll.flatMap((cur, i) => {
114116 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 }] : [];
119120 });
120121 });
122+// une image avant la fin : la dernière qu'un lecteur sait afficher
123+const FRAME = 1 / 30;
121124 const arollFx = computed(() => {
122125 const c = cuts.value.find((x) => Math.abs(t.value - x.at) < x.half);
123126 if (!c) return null;
124127 const f = fxFrame(c.fx, (t.value - (c.at - c.half)) / (2 * c.half), "pair");
125128 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);
126133 // A dessous, l'aplat, B dessus
127134 return {
128135 main: { ...(before ? f.a : f.b), zIndex: before ? 1 : 3 },
129136 ghost: { ...(before ? f.b : f.a), zIndex: before ? 3 : 1 },
130137 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,
132139 };
133140 });
134141 // 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 };
136144 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+});
139147
140148 // --- B-roll : les plans visibles à t, avec leurs transitions (deux à la fois pendant un enchaînement)
141149 const brolls = computed(() => {
@@ -310,8 +318,8 @@ defineExpose({ capture });
310318 <div class="aroll-box layer" :style="videoStyle" @click="player.toggle()">
311319 <video ref="el" class="aroll" preload="auto" playsinline :style="arollFx?.main" />
312320 <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" />
315323 </div>
316324 <template v-for="l in brollLayers" :key="l.id">
317325 <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 qui108 // --- 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 sur109 // 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 dessus133 // 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 prochaine141 // 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";
186186 const fxV = computed(() => {
187187 const out: { track: FxTrack; id: string; fx: FxEnd; name: string; a: number; b: number; centered: boolean }[] = [];
188188 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);
190190 if (span && visible(span[0], span[1])) out.push({ track, id, fx, name, a: span[0], b: span[1], centered });
191191 };
192192 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) {
375375 const centered = d.fx === "transition" || (d.fx === "enter" && !!prevJoined(d.origin, d.track as "broll" | "motion", d.id));
376376 let dur = centered ? 2 * Math.abs(t - (span[0] + span[1]) / 2) : d.fx === "enter" ? t - span[0] : span[1] - t;
377377 // 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);
379379 dur = Math.round(Math.max(FX_DUR[0], Math.min(max, dur)) * 20) / 20;
380380 if (d.fx === "transition") {
381381 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 jouer377 // 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):
207207 * ou entre deux plans collés, sinon dans les premières / dernières secondes du plan. null : pas de transition, ou
208208 * une sortie remplacée par l'enchaînement avec le plan suivant.
209209 */
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 {
211211 if (track === "aroll") {
212212 const i = tl.aroll.findIndex((c) => c.id === id), tr = tl.aroll[i]?.transition;
213213 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;
215215 return [cut - half, cut + half];
216216 }
217217 if (track !== "broll" && track !== "motion") return null;
@@ -235,16 +235,14 @@ export function fxSpan(tl: Timeline, track: Track, id: string, fx: FxEnd, source
235235
236236 /**
237237 * 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
239239 * deux ; une entrée ou une sortie, la moitié du plan.
240240 */
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 {
242242 if (track === "aroll") {
243243 const i = tl.aroll.findIndex((c) => c.id === id), prev = tl.aroll[i - 1], cur = tl.aroll[i];
244244 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);
248246 }
249247 const span = spanOf(tl, track, id);
250248 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, ou207 * 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é de237 * 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 des238+ * 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 lui245+ 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;