Allow final render from overlays and audio without video clips.
Generate a blank lavfi canvas at project resolution, composite overlays, mix audio tracks, and download without rewriting scene renders.
This commit is contained in:
@@ -3074,10 +3074,16 @@
|
||||
async function renderFinal() {
|
||||
if (!state.project) return;
|
||||
const rc = _renderClips();
|
||||
if (!rc.length) { alert("No generated clips to stitch yet — generate first."); return; }
|
||||
if (!confirm(
|
||||
`Stitch ${rc.length} clip(s) into one final video?\n\nThis replaces per-scene renders with a single stitched file on the timeline.`
|
||||
)) return;
|
||||
const totalSec = previewTotalSec();
|
||||
const graphicsOnly = !rc.length && hasOverlayOrAudioContent() && totalSec > 0;
|
||||
if (!rc.length && !graphicsOnly) {
|
||||
alert("Nothing to render yet — generate clips or add overlay/audio tracks first.");
|
||||
return;
|
||||
}
|
||||
const confirmMsg = graphicsOnly
|
||||
? `Render the overlay/audio timeline (${totalSec.toFixed(1)}s) to a final video?\n\nUses a blank canvas at project resolution (${state.project.width || 768}×${state.project.height || 512}).`
|
||||
: `Stitch ${rc.length} clip(s) into one final video?\n\nThis replaces per-scene renders with a single stitched file on the timeline.`;
|
||||
if (!confirm(confirmMsg)) return;
|
||||
const clips = rc.map((c) => ({
|
||||
filename: c.media?.filename, subfolder: c.media?.subfolder || "", type: c.media?.type || "output",
|
||||
bin_media_ref: c.bin_media_ref || "",
|
||||
@@ -3086,20 +3092,38 @@
|
||||
volume: c.volume, scene_id: c.sceneId,
|
||||
}));
|
||||
await flushSave(); // audio tracks / keep-original live on the project — render reads it from disk
|
||||
set({ gen: { state: "running", promptId: null, media: [], msg: `Stitching ${clips.length} clip(s)…` } });
|
||||
set({
|
||||
gen: {
|
||||
state: "running",
|
||||
promptId: null,
|
||||
media: [],
|
||||
msg: graphicsOnly ? `Rendering ${totalSec.toFixed(1)}s overlay/audio…` : `Stitching ${clips.length} clip(s)…`,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const queued = await API.renderFinal(state.project.id, clips);
|
||||
const r = queued?.job_id
|
||||
? await _pollRenderJob(queued.job_id, clips.length)
|
||||
? await _pollRenderJob(queued.job_id, clips.length || 1)
|
||||
: queued;
|
||||
// Play the stitched file across the whole timeline (single source, in-point 0).
|
||||
const order = state.project.scenes.filter((s) => !s.excluded);
|
||||
state.sceneRenders = {};
|
||||
state.sceneGhosts = [];
|
||||
_syncEditorStateToProject();
|
||||
let acc = 0;
|
||||
for (const sc of order) { state.sceneRenders[sc.id] = { media: r.media, inSec: acc }; acc += sceneDurationSec(sc); }
|
||||
set({ gen: { state: "done", promptId: null, media: [r.media], msg: `Final video rendered from ${r.clips} clip(s) — saving…` } });
|
||||
if (!graphicsOnly) {
|
||||
// Play the stitched file across the whole timeline (single source, in-point 0).
|
||||
const order = state.project.scenes.filter((s) => !s.excluded);
|
||||
state.sceneRenders = {};
|
||||
state.sceneGhosts = [];
|
||||
_syncEditorStateToProject();
|
||||
let acc = 0;
|
||||
for (const sc of order) { state.sceneRenders[sc.id] = { media: r.media, inSec: acc }; acc += sceneDurationSec(sc); }
|
||||
}
|
||||
set({
|
||||
gen: {
|
||||
state: "done",
|
||||
promptId: null,
|
||||
media: [r.media],
|
||||
msg: graphicsOnly
|
||||
? "Final overlay/audio video rendered — saving…"
|
||||
: `Final video rendered from ${r.clips} clip(s) — saving…`,
|
||||
},
|
||||
});
|
||||
const name = (state.project.name || "montage").replace(/[^\w.-]+/g, "_") + `_final_${_stamp()}.mp4`;
|
||||
await _saveBlobAs(API.resultUrl(state.project.id, r.media), name);
|
||||
} catch (e) {
|
||||
|
||||
+137
-85
@@ -552,11 +552,13 @@ _XFADE_MAP = {
|
||||
|
||||
|
||||
def _build_render_filter(clips: list, tracks: Optional[list] = None,
|
||||
keep_original: bool = True, base_input: int = 0) -> tuple[str, bool]:
|
||||
keep_original: bool = True, base_input: int = 0,
|
||||
*, blank_canvas: Optional[dict] = None) -> tuple[str, bool]:
|
||||
"""ffmpeg filter_complex for the final stitch.
|
||||
|
||||
Video — per clip: normalize to one canvas, then blur / fade in-out / virtual Ken-Burns
|
||||
zoom (zoompan, fixed output size). Fold left-to-right with xfade (overlap) or concat.
|
||||
When `blank_canvas` is set, input 0 is a lavfi color source (no scene clips).
|
||||
|
||||
Audio — per-clip original audio gets its volume; when keep_original the per-clip streams
|
||||
are folded alongside the video (acrossfade at crossfades, concat otherwise) into [aorig].
|
||||
@@ -570,73 +572,80 @@ def _build_render_filter(clips: list, tracks: Optional[list] = None,
|
||||
n = len(clips)
|
||||
parts: list[str] = []
|
||||
|
||||
# One canvas for the whole render (clips may have differing native sizes/fps — xfade and
|
||||
# concat require identical size/fps/sar, so every clip is normalized to this).
|
||||
cw = int(clips[0].get("w") or 0) or 768
|
||||
ch = int(clips[0].get("h") or 0) or 768
|
||||
cfps = float(clips[0].get("fps") or 0) or 25.0
|
||||
if blank_canvas:
|
||||
total = max(0.01, float(blank_canvas.get("dur") or 0.01))
|
||||
parts.append("[0:v]format=yuv420p,setsar=1[vbase]")
|
||||
acc_a = None
|
||||
else:
|
||||
if not clips:
|
||||
raise ValueError("clips required when blank_canvas is not set")
|
||||
# One canvas for the whole render (clips may have differing native sizes/fps — xfade and
|
||||
# concat require identical size/fps/sar, so every clip is normalized to this).
|
||||
cw = int(clips[0].get("w") or 0) or 768
|
||||
ch = int(clips[0].get("h") or 0) or 768
|
||||
cfps = float(clips[0].get("fps") or 0) or 25.0
|
||||
|
||||
for i, c in enumerate(clips):
|
||||
fx = c.get("fx") or {}
|
||||
dur = float(c.get("dur") or 0) or 0.0
|
||||
# Normalize first: fit into the canvas (letterbox), fixed fps + square pixels.
|
||||
vf: list[str] = [
|
||||
f"scale={cw}:{ch}:force_original_aspect_ratio=decrease",
|
||||
f"pad={cw}:{ch}:-1:-1:color=black",
|
||||
"setsar=1", f"fps={cfps:g}",
|
||||
]
|
||||
zoom = fx.get("zoom")
|
||||
if zoom in ("in", "out") and dur > 0:
|
||||
nframes = max(1, round(dur * cfps))
|
||||
z = zoompan_z_expr(zoom, fx, nframes)
|
||||
vf.append(
|
||||
f"zoompan=z='{z}':d=1:x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'"
|
||||
f":s={cw}x{ch}:fps={cfps:g}"
|
||||
)
|
||||
for i, c in enumerate(clips):
|
||||
fx = c.get("fx") or {}
|
||||
dur = float(c.get("dur") or 0) or 0.0
|
||||
# Normalize first: fit into the canvas (letterbox), fixed fps + square pixels.
|
||||
vf: list[str] = [
|
||||
f"scale={cw}:{ch}:force_original_aspect_ratio=decrease",
|
||||
f"pad={cw}:{ch}:-1:-1:color=black",
|
||||
"setsar=1", f"fps={cfps:g}",
|
||||
]
|
||||
zoom = fx.get("zoom")
|
||||
if zoom in ("in", "out") and dur > 0:
|
||||
nframes = max(1, round(dur * cfps))
|
||||
z = zoompan_z_expr(zoom, fx, nframes)
|
||||
vf.append(
|
||||
f"zoompan=z='{z}':d=1:x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'"
|
||||
f":s={cw}x{ch}:fps={cfps:g}"
|
||||
)
|
||||
vf.append("setsar=1")
|
||||
blur = float(fx.get("blur") or 0)
|
||||
if blur > 0:
|
||||
vf.append(f"gblur=sigma={blur * 20:.2f}")
|
||||
fi = float(fx.get("fade_in") or 0)
|
||||
if fi > 0:
|
||||
vf.append(f"fade=t=in:st=0:d={fi:.3f}")
|
||||
fo = float(fx.get("fade_out") or 0)
|
||||
if fo > 0 and dur > 0:
|
||||
vf.append(f"fade=t=out:st={max(0.0, dur - fo):.3f}:d={fo:.3f}")
|
||||
vf.append("format=yuv420p")
|
||||
vf.append("setsar=1")
|
||||
blur = float(fx.get("blur") or 0)
|
||||
if blur > 0:
|
||||
vf.append(f"gblur=sigma={blur * 20:.2f}")
|
||||
fi = float(fx.get("fade_in") or 0)
|
||||
if fi > 0:
|
||||
vf.append(f"fade=t=in:st=0:d={fi:.3f}")
|
||||
fo = float(fx.get("fade_out") or 0)
|
||||
if fo > 0 and dur > 0:
|
||||
vf.append(f"fade=t=out:st={max(0.0, dur - fo):.3f}:d={fo:.3f}")
|
||||
vf.append("format=yuv420p")
|
||||
vf.append("setsar=1")
|
||||
parts.append(f"[{i}:v:0]{','.join(vf)}[v{i}]")
|
||||
if keep_original:
|
||||
vol = float(c.get("volume", 1.0))
|
||||
af = "aformat=sample_fmts=fltp:channel_layouts=stereo"
|
||||
if abs(vol - 1.0) > 1e-3:
|
||||
af += f",volume={max(0.0, vol):.3f}"
|
||||
parts.append(f"[{i}:a:0]{af}[a{i}]")
|
||||
|
||||
# Fold video (and the original audio when kept) left-to-right.
|
||||
acc_v, acc_a = "[v0]", "[a0]"
|
||||
acc_dur = float(clips[0].get("dur") or 0) or 0.0
|
||||
for i in range(1, n):
|
||||
prev = clips[i - 1]
|
||||
trans = (prev.get("transition") or "").strip()
|
||||
td = float(prev.get("tdur") or 0)
|
||||
dur_i = float(clips[i].get("dur") or 0) or 0.0
|
||||
if trans in _XFADE_MAP and td > 0 and acc_dur > td:
|
||||
off = max(0.0, acc_dur - td)
|
||||
ov = f"[vx{i}]"
|
||||
parts.append(f"{acc_v}[v{i}]xfade=transition={_XFADE_MAP[trans]}:duration={td:.3f}:offset={off:.3f}{ov}")
|
||||
parts.append(f"[{i}:v:0]{','.join(vf)}[v{i}]")
|
||||
if keep_original:
|
||||
oa = f"[ax{i}]"; parts.append(f"{acc_a}[a{i}]acrossfade=d={td:.3f}{oa}"); acc_a = oa
|
||||
acc_v, acc_dur = ov, acc_dur + dur_i - td
|
||||
else:
|
||||
ov = f"[vc{i}]"
|
||||
parts.append(f"{acc_v}[v{i}]concat=n=2:v=1:a=0{ov}")
|
||||
if keep_original:
|
||||
oa = f"[ac{i}]"; parts.append(f"{acc_a}[a{i}]concat=n=2:v=0:a=1{oa}"); acc_a = oa
|
||||
acc_v, acc_dur = ov, acc_dur + dur_i
|
||||
vol = float(c.get("volume", 1.0))
|
||||
af = "aformat=sample_fmts=fltp:channel_layouts=stereo"
|
||||
if abs(vol - 1.0) > 1e-3:
|
||||
af += f",volume={max(0.0, vol):.3f}"
|
||||
parts.append(f"[{i}:a:0]{af}[a{i}]")
|
||||
|
||||
parts.append(f"{acc_v}null[vbase]")
|
||||
total = max(0.01, acc_dur)
|
||||
# Fold video (and the original audio when kept) left-to-right.
|
||||
acc_v, acc_a = "[v0]", "[a0]"
|
||||
acc_dur = float(clips[0].get("dur") or 0) or 0.0
|
||||
for i in range(1, n):
|
||||
prev = clips[i - 1]
|
||||
trans = (prev.get("transition") or "").strip()
|
||||
td = float(prev.get("tdur") or 0)
|
||||
dur_i = float(clips[i].get("dur") or 0) or 0.0
|
||||
if trans in _XFADE_MAP and td > 0 and acc_dur > td:
|
||||
off = max(0.0, acc_dur - td)
|
||||
ov = f"[vx{i}]"
|
||||
parts.append(f"{acc_v}[v{i}]xfade=transition={_XFADE_MAP[trans]}:duration={td:.3f}:offset={off:.3f}{ov}")
|
||||
if keep_original:
|
||||
oa = f"[ax{i}]"; parts.append(f"{acc_a}[a{i}]acrossfade=d={td:.3f}{oa}"); acc_a = oa
|
||||
acc_v, acc_dur = ov, acc_dur + dur_i - td
|
||||
else:
|
||||
ov = f"[vc{i}]"
|
||||
parts.append(f"{acc_v}[v{i}]concat=n=2:v=1:a=0{ov}")
|
||||
if keep_original:
|
||||
oa = f"[ac{i}]"; parts.append(f"{acc_a}[a{i}]concat=n=2:v=0:a=1{oa}"); acc_a = oa
|
||||
acc_v, acc_dur = ov, acc_dur + dur_i
|
||||
|
||||
parts.append(f"{acc_v}null[vbase]")
|
||||
total = max(0.01, acc_dur)
|
||||
|
||||
# Assemble the audio mix: original (if kept) + each inserted track (delayed/volume'd).
|
||||
mix: list[str] = []
|
||||
@@ -692,6 +701,30 @@ class RenderJobError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _audio_track_end_sec(t: dict) -> float:
|
||||
start = float(t.get("start_sec") or 0)
|
||||
for key in ("source_dur", "pinned_dur"):
|
||||
if t.get(key) is not None:
|
||||
return start + float(t[key])
|
||||
return start + 1.0
|
||||
|
||||
|
||||
def _timeline_duration_sec(proj) -> float:
|
||||
total = 0.0
|
||||
for ov in getattr(proj, "overlay_tracks", None) or []:
|
||||
end = float(ov.get("start_sec") or 0) + float(ov.get("duration_sec") or 0)
|
||||
total = max(total, end)
|
||||
for t in getattr(proj, "audio_tracks", None) or []:
|
||||
total = max(total, _audio_track_end_sec(t))
|
||||
return max(total, 0.01)
|
||||
|
||||
|
||||
def _has_graphics_export_content(proj) -> bool:
|
||||
if list(getattr(proj, "audio_tracks", None) or []):
|
||||
return True
|
||||
return any(float(ov.get("duration_sec") or 0) > 0 for ov in (getattr(proj, "overlay_tracks", None) or []))
|
||||
|
||||
|
||||
_render_jobs: dict[str, dict] = {}
|
||||
_export_jobs: dict[str, dict] = {}
|
||||
|
||||
@@ -772,8 +805,9 @@ def _ffmpeg_stitch_final(proj, clips: list) -> dict:
|
||||
import subprocess
|
||||
import time as _time
|
||||
|
||||
if not clips:
|
||||
raise RenderJobError("Nothing to render — no generated clips.")
|
||||
blank_mode = not clips
|
||||
if blank_mode and not _has_graphics_export_content(proj):
|
||||
raise RenderJobError("Nothing to render — add overlays, audio, or generated clips.")
|
||||
ff = shutil.which("ffmpeg")
|
||||
if not ff:
|
||||
raise RenderJobError("ffmpeg not found on PATH — install it to render the final video.")
|
||||
@@ -784,6 +818,12 @@ def _ffmpeg_stitch_final(proj, clips: list) -> dict:
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise RenderJobError(f"Output directory unavailable: {e}") from e
|
||||
|
||||
cw = int(getattr(proj, "width", 768) or 768)
|
||||
ch = int(getattr(proj, "height", 512) or 512)
|
||||
cfps = float(getattr(proj, "frame_rate", 25) or 25)
|
||||
blank_canvas = None
|
||||
n_video = 0
|
||||
|
||||
def _resolve(c):
|
||||
if c.get("bin_media_ref"):
|
||||
mp = media.path_for(c.get("bin_media_ref") or "")
|
||||
@@ -793,12 +833,21 @@ def _ffmpeg_stitch_final(proj, clips: list) -> dict:
|
||||
base = outdir if c.get("type", "output") == "output" else tempdir
|
||||
return os.path.join(base, c.get("subfolder", ""), c.get("filename", ""))
|
||||
|
||||
paths = [_resolve(c) for c in clips]
|
||||
missing = [p for p in paths if not os.path.isfile(p)]
|
||||
if missing:
|
||||
raise RenderJobError(f"{len(missing)} clip file(s) not found on disk — regenerate then render.")
|
||||
paths: list = []
|
||||
if blank_mode:
|
||||
duration = _timeline_duration_sec(proj)
|
||||
blank_canvas = {"w": cw, "h": ch, "fps": cfps, "dur": duration}
|
||||
n_video = 1
|
||||
else:
|
||||
paths = [_resolve(c) for c in clips]
|
||||
missing = [p for p in paths if not os.path.isfile(p)]
|
||||
if missing:
|
||||
raise RenderJobError(f"{len(missing)} clip file(s) not found on disk — regenerate then render.")
|
||||
cw = int(clips[0].get("w") or 0) or cw
|
||||
ch = int(clips[0].get("h") or 0) or ch
|
||||
n_video = len(clips)
|
||||
|
||||
keep_original = bool(getattr(proj, "keep_original_audio", True))
|
||||
keep_original = bool(getattr(proj, "keep_original_audio", True)) and not blank_mode
|
||||
clip_by_scene = {c["scene_id"]: c for c in clips if c.get("scene_id")}
|
||||
media_tracks: list = []
|
||||
separated_tracks: list = []
|
||||
@@ -861,14 +910,17 @@ def _ffmpeg_stitch_final(proj, clips: list) -> dict:
|
||||
out_name = f"funpack_final_{int(_time.time())}.mp4"
|
||||
out_path = os.path.join(tempdir, out_name)
|
||||
cmd = [ff, "-y"]
|
||||
for c, pth in zip(clips, paths):
|
||||
inn, dur = c.get("in"), c.get("dur")
|
||||
if inn is not None:
|
||||
cmd += ["-ss", f"{float(inn):.3f}"]
|
||||
if dur is not None:
|
||||
cmd += ["-t", f"{float(dur):.3f}"]
|
||||
cmd += ["-i", pth]
|
||||
n = len(clips)
|
||||
if blank_mode:
|
||||
cmd += ["-f", "lavfi", "-i", f"color=c=black:s={cw}x{ch}:r={cfps:g}:d={blank_canvas['dur']:.3f}"]
|
||||
else:
|
||||
for c, pth in zip(clips, paths):
|
||||
inn, dur = c.get("in"), c.get("dur")
|
||||
if inn is not None:
|
||||
cmd += ["-ss", f"{float(inn):.3f}"]
|
||||
if dur is not None:
|
||||
cmd += ["-t", f"{float(dur):.3f}"]
|
||||
cmd += ["-i", pth]
|
||||
n = n_video
|
||||
for t in media_tracks:
|
||||
if t.get("source_in") is not None and t.get("source_dur") is not None:
|
||||
cmd += ["-ss", f"{t['source_in']:.3f}", "-t", f"{t['source_dur']:.3f}", "-i", t["path"]]
|
||||
@@ -878,9 +930,6 @@ def _ffmpeg_stitch_final(proj, clips: list) -> dict:
|
||||
cmd += ["-ss", f"{t['source_in']:.3f}", "-t", f"{t['source_dur']:.3f}", "-i", t["path"]]
|
||||
tracks = media_tracks + separated_tracks
|
||||
|
||||
cw = int(clips[0].get("w") or 0) or int(getattr(proj, "width", 768) or 768)
|
||||
ch = int(clips[0].get("h") or 0) or int(getattr(proj, "height", 512) or 512)
|
||||
|
||||
def _overlay_image_path(media_ref: str | None) -> str | None:
|
||||
mp = media.path_for(media_ref or "")
|
||||
return str(mp) if mp is not None else None
|
||||
@@ -896,7 +945,10 @@ def _ffmpeg_stitch_final(proj, clips: list) -> dict:
|
||||
for pth in overlay_image_paths:
|
||||
cmd += ["-i", pth]
|
||||
|
||||
filt, has_audio = _build_render_filter(clips, tracks=tracks, keep_original=keep_original, base_input=n)
|
||||
filt, has_audio = _build_render_filter(
|
||||
clips, tracks=tracks, keep_original=keep_original, base_input=n,
|
||||
blank_canvas=blank_canvas,
|
||||
)
|
||||
filt_parts = filt.split(";") if filt else []
|
||||
ov_input_base = n + len(tracks)
|
||||
_append_overlay_filters(
|
||||
@@ -1506,8 +1558,8 @@ if web is not None and PromptServer is not None:
|
||||
proj = _project_or_404(req.match_info["pid"])
|
||||
body = await req.json() if req.can_read_body else {}
|
||||
clips = body.get("clips") or []
|
||||
if not clips:
|
||||
return web.json_response({"detail": "Nothing to render — no generated clips."}, status=400)
|
||||
if not clips and not _has_graphics_export_content(proj):
|
||||
return web.json_response({"detail": "Nothing to render — add overlays, audio, or generated clips."}, status=400)
|
||||
job_id = _uuid.uuid4().hex
|
||||
_render_jobs[job_id] = {"state": "queued"}
|
||||
asyncio.create_task(_run_render_job(job_id, proj, clips))
|
||||
|
||||
@@ -5,8 +5,10 @@ from pathlib import Path
|
||||
from movie_editor.backend import bridge
|
||||
from movie_editor.backend.timeline import Project, Scene
|
||||
from movie_editor.server import (
|
||||
_build_render_filter,
|
||||
_clip_bytes_for_media,
|
||||
_clip_needs_trim,
|
||||
_has_graphics_export_content,
|
||||
_parse_has_scenes,
|
||||
_parse_prompt_variants,
|
||||
_playback_render_from_query,
|
||||
@@ -16,6 +18,7 @@ from movie_editor.server import (
|
||||
_run_studio_inputs,
|
||||
_scene_playback_clip_spec,
|
||||
_shortcut_seed,
|
||||
_timeline_duration_sec,
|
||||
)
|
||||
|
||||
CONTINUE = "__funpack_continue__"
|
||||
@@ -331,4 +334,26 @@ def test_project_models_keeps_empty_slots(monkeypatch):
|
||||
p = _project(scenes=[{"id": "s1", "text": "a"}])
|
||||
p.models = {"slots": [], "full_control": False}
|
||||
assert _project_models(p)["slots"] == []
|
||||
assert _project_models(p)["slots"] != nodes.load_models()["slots"]
|
||||
assert _project_models(p)["slots"] != nodes.load_models()["slots"]
|
||||
|
||||
|
||||
def test_timeline_duration_sec_from_overlays_and_audio():
|
||||
p = _project(scenes=[])
|
||||
p.scenes = []
|
||||
p.overlay_tracks = [{"start_sec": 2, "duration_sec": 5}]
|
||||
p.audio_tracks = [{"start_sec": 10, "source_dur": 3}]
|
||||
assert _timeline_duration_sec(p) == 13
|
||||
assert _has_graphics_export_content(p) is True
|
||||
|
||||
|
||||
def test_build_render_filter_blank_canvas():
|
||||
filt, has_audio = _build_render_filter(
|
||||
[],
|
||||
tracks=[{"start_sec": 0, "volume": 1.0}],
|
||||
keep_original=False,
|
||||
base_input=1,
|
||||
blank_canvas={"w": 768, "h": 512, "fps": 25, "dur": 8.0},
|
||||
)
|
||||
assert "[0:v]format=yuv420p,setsar=1[vbase]" in filt
|
||||
assert "[aout]" in filt
|
||||
assert has_audio is True
|
||||
Reference in New Issue
Block a user