PR #24589 opened by michaelni URL: https://code.ffmpeg.org/FFmpeg/FFmpeg/pulls/24589 Patch URL: https://code.ffmpeg.org/FFmpeg/FFmpeg/pulls/24589.patch
avformat/concatdec: add an option creating a chapter per file ffplay: highlight the chapter list entry and button under the mouse ffplay: factor the chapter list hit test out of the click handler ffplay: filter the chapter list by typing while it is pinned ffplay: sort the chapter list by clickable column buttons ffplay: seek to a chapter list entry by clicking it ffplay: show a list of the chapters on screen ffplay: make seek_chapter take the chapter index ffplay: factor the current chapter lookup out of seek_chapter ffplay: seek to the previous chapter from the last one as well avfilter/vf_subtitles: replace the ass filter's script at runtime avfilter/vf_subtitles: let the ass filter take its script from an option >From 3d2bc3dd03b5aaf0b01b98ca2d5bdbf66ad56a14 Mon Sep 17 00:00:00 2001 From: Michael Niedermayer <[email protected]> Date: Sun, 20 Sep 2026 18:04:14 +0200 Subject: [PATCH 01/12] avfilter/vf_subtitles: let the ass filter take its script from an option The new "script" option holds the ASS script inline, as an alternative to reading it from "filename", so an application can render text it generated itself without writing a temporary file first. Assisted-by: Claude --- doc/filters.texi | 6 +++++- libavfilter/vf_subtitles.c | 27 +++++++++++++++++---------- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/doc/filters.texi b/doc/filters.texi index 0407c46917..7257668544 100644 --- a/doc/filters.texi +++ b/doc/filters.texi @@ -8629,9 +8629,13 @@ Substation Alpha) subtitles files. This filter accepts @option{filename}/@option{f}, @option{original_size}, @option{fontsdir}, and @option{alpha} from the @ref{subtitles} filter, plus the -following option: +following options: @table @option +@item script +The ASS script to render, given inline. Exactly one of @option{filename} and +@option{script} must be set. + @item shaping Set the shaping engine. diff --git a/libavfilter/vf_subtitles.c b/libavfilter/vf_subtitles.c index 72c621a807..be4118ab53 100644 --- a/libavfilter/vf_subtitles.c +++ b/libavfilter/vf_subtitles.c @@ -53,6 +53,7 @@ typedef struct AssContext { ASS_Renderer *renderer; ASS_Track *track; char *filename; + char *script; char *fontsdir; char *charenc; char *force_style; @@ -142,11 +143,6 @@ static av_cold int init(AVFilterContext *ctx) { AssContext *ass = ctx->priv; - if (!ass->filename) { - av_log(ctx, AV_LOG_ERROR, "No filename provided!\n"); - return AVERROR(EINVAL); - } - ass->library = ass_library_init(); if (!ass->library) { av_log(ctx, AV_LOG_ERROR, "Could not initialize libass.\n"); @@ -269,6 +265,7 @@ static const AVFilterPad ass_inputs[] = { static const AVOption ass_options[] = { COMMON_OPTIONS SHAPING_OPTIONS + {"script", "set the ASS script to render instead of reading a file", OFFSET(script), AV_OPT_TYPE_STRING, {.str = NULL}, 0, 0, FLAGS }, {NULL}, }; @@ -277,19 +274,24 @@ AVFILTER_DEFINE_CLASS(ass); static av_cold int init_ass(AVFilterContext *ctx) { AssContext *ass = ctx->priv; - int ret = init(ctx); + int ret; + if (!ass->filename == !ass->script) { + av_log(ctx, AV_LOG_ERROR, "Exactly one of filename and script must be set\n"); + return AVERROR(EINVAL); + } + ret = init(ctx); if (ret < 0) return ret; /* Initialize fonts */ ass_set_fonts(ass->renderer, NULL, NULL, 1, NULL, 1); - ass->track = ass_read_file(ass->library, ass->filename, NULL); + ass->track = ass->script ? ass_read_memory(ass->library, ass->script, strlen(ass->script), NULL) + : ass_read_file(ass->library, ass->filename, NULL); if (!ass->track) { - av_log(ctx, AV_LOG_ERROR, - "Could not create a libass track when reading file '%s'\n", - ass->filename); + av_log(ctx, AV_LOG_ERROR, "Could not create a libass track from %s\n", + ass->filename ? ass->filename : "the script"); return AVERROR(EINVAL); } return 0; @@ -368,6 +370,11 @@ static av_cold int init_subtitles(AVFilterContext *ctx) AVPacket pkt; AssContext *ass = ctx->priv; + if (!ass->filename) { + av_log(ctx, AV_LOG_ERROR, "No filename provided!\n"); + return AVERROR(EINVAL); + } + /* Init libass */ ret = init(ctx); if (ret < 0) -- 2.52.0 >From b8cbbc25ed6d024a909b6912502fc9e4e8587071 Mon Sep 17 00:00:00 2001 From: Michael Niedermayer <[email protected]> Date: Sun, 20 Sep 2026 18:04:56 +0200 Subject: [PATCH 02/12] avfilter/vf_subtitles: replace the ass filter's script at runtime The "script" option becomes a command, so an application can update the rendered text while the graph keeps running instead of rebuilding it, which would reload the fonts every time. Assisted-by: Claude --- doc/filters.texi | 5 +++++ libavfilter/vf_subtitles.c | 39 +++++++++++++++++++++++++++++--------- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/doc/filters.texi b/doc/filters.texi index 7257668544..1a9bbeff7e 100644 --- a/doc/filters.texi +++ b/doc/filters.texi @@ -8654,6 +8654,11 @@ and Thai. Requires libass to be built with HarfBuzz. The default is @code{auto}. @end table +@subsection Commands + +This filter supports the @option{script} option as @ref{commands}; the new +script replaces the one being rendered. + @section atadenoise Apply an Adaptive Temporal Averaging Denoiser to the video input. diff --git a/libavfilter/vf_subtitles.c b/libavfilter/vf_subtitles.c index be4118ab53..f90ac40e22 100644 --- a/libavfilter/vf_subtitles.c +++ b/libavfilter/vf_subtitles.c @@ -265,12 +265,29 @@ static const AVFilterPad ass_inputs[] = { static const AVOption ass_options[] = { COMMON_OPTIONS SHAPING_OPTIONS - {"script", "set the ASS script to render instead of reading a file", OFFSET(script), AV_OPT_TYPE_STRING, {.str = NULL}, 0, 0, FLAGS }, + {"script", "set the ASS script to render instead of reading a file", OFFSET(script), AV_OPT_TYPE_STRING, {.str = NULL}, 0, 0, FLAGS | AV_OPT_FLAG_RUNTIME_PARAM }, {NULL}, }; AVFILTER_DEFINE_CLASS(ass); +static int read_track(AVFilterContext *ctx) +{ + AssContext *ass = ctx->priv; + ASS_Track *track = ass->script ? ass_read_memory(ass->library, ass->script, strlen(ass->script), NULL) + : ass_read_file(ass->library, ass->filename, NULL); + + if (!track) { + av_log(ctx, AV_LOG_ERROR, "Could not create a libass track from %s\n", + ass->filename ? ass->filename : "the script"); + return AVERROR(EINVAL); + } + if (ass->track) + ass_free_track(ass->track); + ass->track = track; + return 0; +} + static av_cold int init_ass(AVFilterContext *ctx) { AssContext *ass = ctx->priv; @@ -287,14 +304,17 @@ static av_cold int init_ass(AVFilterContext *ctx) /* Initialize fonts */ ass_set_fonts(ass->renderer, NULL, NULL, 1, NULL, 1); - ass->track = ass->script ? ass_read_memory(ass->library, ass->script, strlen(ass->script), NULL) - : ass_read_file(ass->library, ass->filename, NULL); - if (!ass->track) { - av_log(ctx, AV_LOG_ERROR, "Could not create a libass track from %s\n", - ass->filename ? ass->filename : "the script"); - return AVERROR(EINVAL); - } - return 0; + return read_track(ctx); +} + +static int process_command(AVFilterContext *ctx, const char *cmd, const char *arg, + char *res, int res_len, int flags) +{ + int ret = ff_filter_process_command(ctx, cmd, arg, res, res_len, flags); + + if (ret < 0 || (ret = read_track(ctx)) < 0) + return ret; + return config_input(ctx->inputs[0]); } const FFFilter ff_vf_ass = { @@ -307,6 +327,7 @@ const FFFilter ff_vf_ass = { FILTER_INPUTS(ass_inputs), FILTER_OUTPUTS(ff_video_default_filterpad), FILTER_QUERY_FUNC2(query_formats), + .process_command = process_command, }; #endif -- 2.52.0 >From d1ab8e96b9f9ff919395cecb2ba76c44e5af7406 Mon Sep 17 00:00:00 2001 From: Michael Niedermayer <[email protected]> Date: Sun, 20 Sep 2026 18:08:21 +0200 Subject: [PATCH 03/12] ffplay: seek to the previous chapter from the last one as well The chapter lookup left the index one past the end when the position lay inside the last chapter, so page down restarted that chapter instead of moving to the previous one like it does everywhere else. Assisted-by: Claude --- fftools/ffplay.c | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/fftools/ffplay.c b/fftools/ffplay.c index b9d9641582..3330f09cd6 100644 --- a/fftools/ffplay.c +++ b/fftools/ffplay.c @@ -3438,13 +3438,11 @@ static void seek_chapter(VideoState *is, int incr) /* find the current chapter */ for (i = 0; i < is->ic->nb_chapters; i++) { AVChapter *ch = is->ic->chapters[i]; - if (av_compare_ts(pos, AV_TIME_BASE_Q, ch->start, ch->time_base) < 0) { - i--; + if (av_compare_ts(pos, AV_TIME_BASE_Q, ch->start, ch->time_base) < 0) break; - } } - i += incr; + i += incr - 1; i = FFMAX(i, 0); if (i >= is->ic->nb_chapters) return; -- 2.52.0 >From 3413c6a141d2e870e230b6783150c4bff2261c4e Mon Sep 17 00:00:00 2001 From: Michael Niedermayer <[email protected]> Date: Sun, 20 Sep 2026 18:08:33 +0200 Subject: [PATCH 04/12] ffplay: factor the current chapter lookup out of seek_chapter Assisted-by: Claude --- fftools/ffplay.c | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/fftools/ffplay.c b/fftools/ffplay.c index 3330f09cd6..c4692c83e4 100644 --- a/fftools/ffplay.c +++ b/fftools/ffplay.c @@ -3427,23 +3427,28 @@ static void refresh_loop_wait_event(VideoState *is, SDL_Event *event) { } } -static void seek_chapter(VideoState *is, int incr) +/* index of the chapter containing the current playback position, -1 before the first one */ +static int current_chapter(VideoState *is) { int64_t pos = get_master_clock(is) * AV_TIME_BASE; int i; - if (!is->ic->nb_chapters) - return; - - /* find the current chapter */ for (i = 0; i < is->ic->nb_chapters; i++) { AVChapter *ch = is->ic->chapters[i]; if (av_compare_ts(pos, AV_TIME_BASE_Q, ch->start, ch->time_base) < 0) break; } + return i - 1; +} - i += incr - 1; - i = FFMAX(i, 0); +static void seek_chapter(VideoState *is, int incr) +{ + int i; + + if (!is->ic->nb_chapters) + return; + + i = FFMAX(current_chapter(is) + incr, 0); if (i >= is->ic->nb_chapters) return; -- 2.52.0 >From 651d6da6e258b132d2956df9e7c4564fd513c448 Mon Sep 17 00:00:00 2001 From: Michael Niedermayer <[email protected]> Date: Sun, 20 Sep 2026 18:10:53 +0200 Subject: [PATCH 05/12] ffplay: make seek_chapter take the chapter index The callers pass the index relative to the current chapter themselves, so the function can serve seeks to an arbitrary chapter too. Assisted-by: Claude --- fftools/ffplay.c | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/fftools/ffplay.c b/fftools/ffplay.c index c4692c83e4..ae93c5b8d0 100644 --- a/fftools/ffplay.c +++ b/fftools/ffplay.c @@ -3441,14 +3441,9 @@ static int current_chapter(VideoState *is) return i - 1; } -static void seek_chapter(VideoState *is, int incr) +static void seek_chapter(VideoState *is, int i) { - int i; - - if (!is->ic->nb_chapters) - return; - - i = FFMAX(current_chapter(is) + incr, 0); + i = FFMAX(i, 0); if (i >= is->ic->nb_chapters) return; @@ -3526,14 +3521,14 @@ static void event_loop(VideoState *cur_stream) incr = 600.0; goto do_seek; } - seek_chapter(cur_stream, 1); + seek_chapter(cur_stream, current_chapter(cur_stream) + 1); break; case SDLK_PAGEDOWN: if (cur_stream->ic->nb_chapters <= 1) { incr = -600.0; goto do_seek; } - seek_chapter(cur_stream, -1); + seek_chapter(cur_stream, current_chapter(cur_stream) - 1); break; case SDLK_LEFT: incr = seek_interval ? -seek_interval : -10.0; -- 2.52.0 >From c793be9289d701395d05b5fec6a8eea791338f4a Mon Sep 17 00:00:00 2001 From: Michael Niedermayer <[email protected]> Date: Sun, 20 Sep 2026 18:30:29 +0200 Subject: [PATCH 06/12] ffplay: show a list of the chapters on screen Page up/down fade in a list of the chapters around the current one and fade it out again half a second after the last key press, "l" keeps it on screen, and while it is kept down/up move the selection. Enter seeks to the selected chapter and the mouse wheel moves the selection whenever the list is visible. Each entry shows the chapter's artist and title tags in their own columns, clipped to the column, and its length. The ass filter renders the list onto a transparent canvas the size of the panel, only when its content changes, and SDL composites the result as a texture whose alpha modulation does the fading. The video path is untouched and nothing is drawn while the list is hidden. The list is not available with the Vulkan renderer, which bypasses SDL rendering. Assisted-by: Claude --- configure | 2 +- doc/ffplay.texi | 11 +++ fftools/ffplay.c | 246 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 258 insertions(+), 1 deletion(-) diff --git a/configure b/configure index 035d4d090a..415d8b69d2 100755 --- a/configure +++ b/configure @@ -4367,7 +4367,7 @@ ffmpeg_select="aformat_filter anull_filter atrim_filter crop_filter ffmpeg_suggest="ole32 psapi shell32" ffplay_deps="avcodec avformat avfilter swscale swresample sdl2" ffplay_select="crop_filter transpose_filter hflip_filter vflip_filter rotate_filter" -ffplay_suggest="shell32 libplacebo vulkan" +ffplay_suggest="shell32 libplacebo vulkan ass_filter" ffprobe_deps="avcodec avformat" ffprobe_suggest="shell32" diff --git a/doc/ffplay.texi b/doc/ffplay.texi index 5273c158bd..008633d1a1 100644 --- a/doc/ffplay.texi +++ b/doc/ffplay.texi @@ -266,6 +266,17 @@ Seek backward/forward 1 minute. @item page down/page up Seek to previous/next chapter or backward/forward 10 minutes if no chapters. +The chapter list is shown briefly. + +@item l +Keep the chapter list on screen, or let it fade out again. While it is kept, +down/up move its selection. + +@item enter +Seek to the chapter selected in the chapter list while it is shown. + +@item mouse wheel +Move the chapter list selection while it is shown. @item right mouse click Seek to percentage in file corresponding to fraction of width. diff --git a/fftools/ffplay.c b/fftools/ffplay.c index ae93c5b8d0..298be3e251 100644 --- a/fftools/ffplay.c +++ b/fftools/ffplay.c @@ -105,6 +105,10 @@ const int program_birth_year = 2003; #define CURSOR_HIDE_DELAY 1000000 +#define CHAPTER_LIST_FADE_TIME 250000 +#define CHAPTER_LIST_HOLD_TIME 500000 +#define CHAPTER_LIST_ROWS 12 + #define USE_ONEPASS_SUBTITLE_RENDER 1 typedef struct MyAVPacketList { @@ -273,6 +277,15 @@ typedef struct VideoState { SDL_Texture *vis_texture; SDL_Texture *sub_texture; SDL_Texture *vid_texture; + SDL_Texture *chapter_texture; + SDL_Rect chapter_rect; + AVFilterGraph *chapter_graph; + AVFilterContext *chapter_sink; + int chapter_selected; + int chapter_pinned; + int64_t chapter_fade_start; + int64_t chapter_last_input; + double chapter_drawn_alpha; int subtitle_stream; AVStream *subtitle_st; @@ -1007,6 +1020,200 @@ static void draw_video_background(VideoState *is) } } +static double chapter_list_alpha(VideoState *is, int64_t now) +{ + double alpha = (now - is->chapter_fade_start) / (double)CHAPTER_LIST_FADE_TIME; + + if (!is->chapter_pinned) + alpha = FFMIN(alpha, 1 - (now - is->chapter_last_input - CHAPTER_LIST_HOLD_TIME) / (double)CHAPTER_LIST_FADE_TIME); + return av_clipd(alpha, 0, 1); +} + +static const char *chapter_tag(const AVChapter *chapter, const char *key) +{ + const AVDictionaryEntry *tag = av_dict_get(chapter->metadata, key, NULL, 0); + + return tag ? tag->value : ""; +} + +#define CHAPTER_LIST_EVENT "Dialogue: 0:00:00.00,9999:00:00.00," + +/* a text cell clipped to its column, with the characters libass would read as tags or line breaks blanked */ +static void bprint_chapter_cell(AVBPrint *script, const char *style, int x, int y, int right, int bottom, const char *text) +{ + av_bprintf(script, CHAPTER_LIST_EVENT "%s,{\\pos(%d,%d)\\clip(%d,%d,%d,%d)}", style, x, y, x, y, right, bottom); + for (; *text; text++) + av_bprint_chars(script, strchr("{}\\\r\n", *text) ? ' ' : *text, 1); + av_bprint_chars(script, '\n', 1); +} + +typedef struct ChapterListLayout { + int font, line, width, nb_rows, first; + int artist_x, title_x, length_x; +} ChapterListLayout; + +static ChapterListLayout chapter_list_layout(VideoState *is) +{ + ChapterListLayout l; + + l.font = FFMAX(is->height / 30, 12); + l.line = l.font * 3 / 2; + l.width = is->width * 3 / 5; + l.nb_rows = FFMIN(is->ic->nb_chapters, CHAPTER_LIST_ROWS); + l.first = av_clip(is->chapter_selected - l.nb_rows / 2, 0, is->ic->nb_chapters - l.nb_rows); + l.artist_x = l.font * 3; + l.length_x = l.width - l.font / 2; + l.title_x = l.artist_x + (l.length_x - l.font * 4 - l.artist_x) * 2 / 5; + return l; +} + +static void chapter_list_script(VideoState *is, AVBPrint *script) +{ + ChapterListLayout l = chapter_list_layout(is); + + is->chapter_rect = (SDL_Rect){ l.font, l.font, l.width, l.nb_rows * l.line + l.font }; + av_bprintf(script, + "[Script Info]\nScriptType: v4.00+\nPlayResX: %d\nPlayResY: %d\nWrapStyle: 2\n\n" + "[V4+ Styles]\n" + "Format: Name, Fontname, Fontsize, PrimaryColour, OutlineColour, Bold, Outline, Alignment\n" + "Style: Panel,Sans,%d,&H40000000,&H40000000,0,0,7\n" + "Style: Row,Sans,%d,&H00FFFFFF,&H00000000,0,%d,7\n" + "Style: Selected,Sans,%d,&H0080D0FF,&H00000000,-1,%d,7\n\n" + "[Events]\nFormat: Start, End, Style, Text\n" + CHAPTER_LIST_EVENT "Panel,{\\pos(0,0)\\p1}m 0 0 l %d 0 %d %d 0 %d{\\p0}\n", + is->chapter_rect.w, is->chapter_rect.h, l.font, l.font, l.font / 16 + 1, l.font, l.font / 16 + 1, + is->chapter_rect.w, is->chapter_rect.w, is->chapter_rect.h, is->chapter_rect.h); + for (int i = l.first; i < l.first + l.nb_rows; i++) { + AVChapter *chapter = is->ic->chapters[i]; + const char *style = i == is->chapter_selected ? "Selected" : "Row"; + int64_t length = av_rescale_q(chapter->end - chapter->start, chapter->time_base, av_make_q(1, 1)); + int y = l.font / 2 + (i - l.first) * l.line; + + av_bprintf(script, CHAPTER_LIST_EVENT "%s,{\\an9\\pos(%d,%d)}%d\n", style, l.font * 5 / 2, y, i + 1); + bprint_chapter_cell(script, style, l.artist_x, y, l.title_x - l.font / 2, y + l.line, chapter_tag(chapter, "artist")); + bprint_chapter_cell(script, style, l.title_x, y, l.length_x - l.font * 4, y + l.line, chapter_tag(chapter, "title")); + if (chapter->end == AV_NOPTS_VALUE || length < 0) + continue; + av_bprintf(script, CHAPTER_LIST_EVENT "%s,{\\an9\\pos(%d,%d)}", style, l.length_x, y); + if (length >= 3600) + av_bprintf(script, "%d:%02d:%02d\n", (int)(length / 3600), (int)(length / 60 % 60), (int)(length % 60)); + else + av_bprintf(script, "%d:%02d\n", (int)(length / 60), (int)(length % 60)); + } +} + +static int chapter_list_configure(VideoState *is, const char *script) +{ + const AVFilter *ass_filter = avfilter_get_by_name("ass"); + AVFilterContext *source, *ass; + char source_args[64]; + int ret; + + if (!ass_filter) { + av_log(NULL, AV_LOG_ERROR, "The chapter list needs the ass filter, which this build lacks\n"); + return AVERROR_FILTER_NOT_FOUND; + } + avfilter_graph_free(&is->chapter_graph); + if (!(is->chapter_graph = avfilter_graph_alloc())) + return AVERROR(ENOMEM); + snprintf(source_args, sizeof(source_args), "color=black@0:rate=1:size=%dx%d", is->chapter_rect.w, is->chapter_rect.h); + ass = avfilter_graph_alloc_filter(is->chapter_graph, ass_filter, "ass"); + is->chapter_sink = avfilter_graph_alloc_filter(is->chapter_graph, avfilter_get_by_name("buffersink"), "sink"); + if (!ass || !is->chapter_sink) + return AVERROR(ENOMEM); + if ((ret = avfilter_graph_create_filter(&source, avfilter_get_by_name("color"), "source", source_args, NULL, is->chapter_graph)) < 0 || + (ret = av_opt_set(ass, "script", script, AV_OPT_SEARCH_CHILDREN)) < 0 || + (ret = av_opt_set_int(ass, "alpha", 1, AV_OPT_SEARCH_CHILDREN)) < 0 || + (ret = avfilter_init_str(ass, NULL)) < 0 || + (ret = av_opt_set_array(is->chapter_sink, "pixel_formats", AV_OPT_SEARCH_CHILDREN, 0, 1, AV_OPT_TYPE_PIXEL_FMT, + &(enum AVPixelFormat){ AV_PIX_FMT_RGB32 })) < 0 || + (ret = avfilter_init_str(is->chapter_sink, NULL)) < 0 || + (ret = avfilter_link(source, 0, ass, 0)) < 0 || + (ret = avfilter_link(ass, 0, is->chapter_sink, 0)) < 0) + return ret; + return avfilter_graph_config(is->chapter_graph, NULL); +} + +static int chapter_list_render(VideoState *is) +{ + AVBPrint script; + AVFrame *frame = av_frame_alloc(); + void *pixels; + int pitch, ret; + + if (!frame) + return AVERROR(ENOMEM); + av_bprint_init(&script, 0, AV_BPRINT_SIZE_UNLIMITED); + chapter_list_script(is, &script); + if (!av_bprint_is_complete(&script)) + ret = AVERROR(ENOMEM); + else if (is->chapter_graph) + ret = avfilter_graph_send_command(is->chapter_graph, "ass", "script", script.str, NULL, 0, 0); + else + ret = chapter_list_configure(is, script.str); + if (ret >= 0) + ret = av_buffersink_get_frame(is->chapter_sink, frame); + if (ret >= 0 && (realloc_texture(&is->chapter_texture, SDL_PIXELFORMAT_ARGB8888, frame->width, frame->height, SDL_BLENDMODE_BLEND, 0) < 0 || + SDL_LockTexture(is->chapter_texture, NULL, &pixels, &pitch) < 0)) + ret = AVERROR_EXTERNAL; + if (ret >= 0) { + /* the ass filter composites onto the transparent canvas with premultiplied alpha, SDL blends straight alpha */ + for (int y = 0; y < frame->height; y++) { + const uint8_t *src = frame->data[0] + y * frame->linesize[0]; + uint8_t *dst = (uint8_t *)pixels + y * pitch; + + for (int x = 0; x < frame->width * 4; x += 4) { + for (int c = 0; c < 3; c++) + dst[x + c] = src[x + 3] ? FFMIN(src[x + c] * 255 / src[x + 3], 255) : 0; + dst[x + 3] = src[x + 3]; + } + } + SDL_UnlockTexture(is->chapter_texture); + } + if (ret < 0) { + av_log(NULL, AV_LOG_ERROR, "Could not render the chapter list: %s\n", av_err2str(ret)); + avfilter_graph_free(&is->chapter_graph); + } + av_frame_free(&frame); + av_bprint_finalize(&script, NULL); + return ret; +} + +static void chapter_list_show(VideoState *is, int selected) +{ + int64_t now = av_gettime_relative(); + + if (!renderer) + return; + is->chapter_fade_start = now - chapter_list_alpha(is, now) * CHAPTER_LIST_FADE_TIME; + is->chapter_last_input = now; + if (selected != is->chapter_selected || !is->chapter_texture) { + is->chapter_selected = selected; + chapter_list_render(is); + } +} + +static void chapter_list_move(VideoState *is, int delta) +{ + chapter_list_show(is, av_clip(is->chapter_selected + delta, 0, is->ic->nb_chapters - 1)); +} + +static int chapter_list_visible(VideoState *is) +{ + return chapter_list_alpha(is, av_gettime_relative()) > 0; +} + +static void chapter_list_draw(VideoState *is) +{ + double alpha = chapter_list_alpha(is, av_gettime_relative()); + + is->chapter_drawn_alpha = alpha; + if (alpha > 0 && is->chapter_texture) { + SDL_SetTextureAlphaMod(is->chapter_texture, alpha * 255); + SDL_RenderCopy(renderer, is->chapter_texture, NULL, &is->chapter_rect); + } +} + static void video_image_display(VideoState *is) { Frame *vp; @@ -1345,6 +1552,9 @@ static void stream_close(VideoState *is) SDL_DestroyTexture(is->vid_texture); if (is->sub_texture) SDL_DestroyTexture(is->sub_texture); + if (is->chapter_texture) + SDL_DestroyTexture(is->chapter_texture); + avfilter_graph_free(&is->chapter_graph); av_free(is); } @@ -1429,6 +1639,7 @@ static void video_display(VideoState *is) video_audio_display(is); else if (is->video_st) video_image_display(is); + chapter_list_draw(is); SDL_RenderPresent(renderer); } @@ -3421,6 +3632,8 @@ static void refresh_loop_wait_event(VideoState *is, SDL_Event *event) { if (remaining_time > 0.0) av_usleep((int64_t)(remaining_time * 1000000.0)); remaining_time = REFRESH_RATE; + if (chapter_list_alpha(is, av_gettime_relative()) != is->chapter_drawn_alpha) + is->force_refresh = 1; if (is->show_mode != SHOW_MODE_NONE && (!is->paused || is->force_refresh)) video_refresh(is, &remaining_time); SDL_PumpEvents(); @@ -3447,6 +3660,7 @@ static void seek_chapter(VideoState *is, int i) if (i >= is->ic->nb_chapters) return; + chapter_list_show(is, i); av_log(NULL, AV_LOG_VERBOSE, "Seeking to chapter %d.\n", i); stream_seek(is, av_rescale_q(is->ic->chapters[i]->start, is->ic->chapters[i]->time_base, AV_TIME_BASE_Q), 0, 0); @@ -3507,6 +3721,20 @@ static void event_loop(VideoState *cur_stream) case SDLK_t: stream_cycle_channel(cur_stream, AVMEDIA_TYPE_SUBTITLE); break; + case SDLK_l: + if (!cur_stream->ic->nb_chapters) + break; + cur_stream->chapter_pinned = !cur_stream->chapter_pinned; + if (cur_stream->chapter_pinned) + chapter_list_show(cur_stream, FFMAX(current_chapter(cur_stream), 0)); + else + cur_stream->chapter_last_input = av_gettime_relative() - CHAPTER_LIST_HOLD_TIME; + break; + case SDLK_RETURN: + case SDLK_KP_ENTER: + if (chapter_list_visible(cur_stream)) + seek_chapter(cur_stream, cur_stream->chapter_selected); + break; case SDLK_w: if (cur_stream->show_mode == SHOW_MODE_VIDEO && cur_stream->vfilter_idx < nb_vfilters - 1) { if (++cur_stream->vfilter_idx >= nb_vfilters) @@ -3537,9 +3765,17 @@ static void event_loop(VideoState *cur_stream) incr = seek_interval ? seek_interval : 10.0; goto do_seek; case SDLK_UP: + if (cur_stream->chapter_pinned) { + chapter_list_move(cur_stream, -1); + break; + } incr = 60.0; goto do_seek; case SDLK_DOWN: + if (cur_stream->chapter_pinned) { + chapter_list_move(cur_stream, 1); + break; + } incr = -60.0; do_seek: if (seek_by_bytes) { @@ -3570,6 +3806,10 @@ static void event_loop(VideoState *cur_stream) break; } break; + case SDL_MOUSEWHEEL: + if (chapter_list_visible(cur_stream)) + chapter_list_move(cur_stream, -event.wheel.y); + break; case SDL_MOUSEBUTTONDOWN: if (exit_on_mousedown) { do_exit(cur_stream); @@ -3635,6 +3875,9 @@ static void event_loop(VideoState *cur_stream) SDL_DestroyTexture(cur_stream->vis_texture); cur_stream->vis_texture = NULL; } + avfilter_graph_free(&cur_stream->chapter_graph); + if (cur_stream->chapter_texture) + chapter_list_render(cur_stream); if (vk_renderer) vk_renderer_resize(vk_renderer, screen_width, screen_height); av_fallthrough; @@ -3849,6 +4092,9 @@ void show_help_default(const char *opt, const char *arg) "left/right seek backward/forward by 10 seconds or a custom interval if -seek_interval is set\n" "down/up seek backward/forward 1 minute\n" "page down/page up seek to previous/next chapter or backward/forward 10 minutes if no chapters\n" + "l keep the chapter list on screen, down/up then move its selection\n" + "enter seek to the chapter selected in the chapter list while it is shown\n" + "mouse wheel move the chapter list selection while it is shown\n" "right mouse click seek to percentage in file corresponding to fraction of width\n" "left double-click toggle full screen\n" ); -- 2.52.0 >From 8eb07c0311c1b8dd8c8fcd5e53e36252a2444fad Mon Sep 17 00:00:00 2001 From: Michael Niedermayer <[email protected]> Date: Sun, 20 Sep 2026 21:01:47 +0200 Subject: [PATCH 07/12] ffplay: seek to a chapter list entry by clicking it Assisted-by: Claude --- doc/ffplay.texi | 3 +++ fftools/ffplay.c | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/doc/ffplay.texi b/doc/ffplay.texi index 008633d1a1..8360f5b995 100644 --- a/doc/ffplay.texi +++ b/doc/ffplay.texi @@ -278,6 +278,9 @@ Seek to the chapter selected in the chapter list while it is shown. @item mouse wheel Move the chapter list selection while it is shown. +@item left mouse click +Seek to the clicked chapter list entry. + @item right mouse click Seek to percentage in file corresponding to fraction of width. diff --git a/fftools/ffplay.c b/fftools/ffplay.c index 298be3e251..43d43b87eb 100644 --- a/fftools/ffplay.c +++ b/fftools/ffplay.c @@ -1203,6 +1203,24 @@ static int chapter_list_visible(VideoState *is) return chapter_list_alpha(is, av_gettime_relative()) > 0; } +static void seek_chapter(VideoState *is, int i); + +/* seeks to the entry under a click, returns whether the click hit the list at all */ +static int chapter_list_click(VideoState *is, int x, int y) +{ + ChapterListLayout l = chapter_list_layout(is); + int row; + + x -= is->chapter_rect.x; + y -= is->chapter_rect.y; + if (x < 0 || y < 0 || x >= is->chapter_rect.w || y >= is->chapter_rect.h) + return 0; + row = (y - l.font / 2) / l.line; + if (row < l.nb_rows) + seek_chapter(is, l.first + row); + return 1; +} + static void chapter_list_draw(VideoState *is) { double alpha = chapter_list_alpha(is, av_gettime_relative()); @@ -3815,6 +3833,9 @@ static void event_loop(VideoState *cur_stream) do_exit(cur_stream); break; } + if (event.button.button == SDL_BUTTON_LEFT && chapter_list_visible(cur_stream) && + chapter_list_click(cur_stream, event.button.x, event.button.y)) + break; if (event.button.button == SDL_BUTTON_LEFT) { static int64_t last_mouse_left_click = 0; if (av_gettime_relative() - last_mouse_left_click <= 500000) { @@ -4095,6 +4116,7 @@ void show_help_default(const char *opt, const char *arg) "l keep the chapter list on screen, down/up then move its selection\n" "enter seek to the chapter selected in the chapter list while it is shown\n" "mouse wheel move the chapter list selection while it is shown\n" + "left click seek to the clicked chapter list entry\n" "right mouse click seek to percentage in file corresponding to fraction of width\n" "left double-click toggle full screen\n" ); -- 2.52.0 >From 4e0255282dc1863287552763bebcae44e5e97b72 Mon Sep 17 00:00:00 2001 From: Michael Niedermayer <[email protected]> Date: Sun, 20 Sep 2026 21:05:12 +0200 Subject: [PATCH 08/12] ffplay: sort the chapter list by clickable column buttons Buttons above the list order it by chapter number, artist, title or duration. Clicking a column makes it the first sort key ahead of the ones clicked before, which still resolve ties, so # then Title orders by title with equal titles in chapter order. Clicking the first key again flips its order, shown by an arrow on its button. The rows become an array in display order, built when the input opens, so the selection can survive a re-sort, and the length of each chapter is kept with its row for the display and the sort to share. Assisted-by: Claude --- doc/ffplay.texi | 4 +- fftools/ffplay.c | 195 ++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 171 insertions(+), 28 deletions(-) diff --git a/doc/ffplay.texi b/doc/ffplay.texi index 8360f5b995..070c3ae42f 100644 --- a/doc/ffplay.texi +++ b/doc/ffplay.texi @@ -279,7 +279,9 @@ Seek to the chapter selected in the chapter list while it is shown. Move the chapter list selection while it is shown. @item left mouse click -Seek to the clicked chapter list entry. +Seek to the clicked chapter list entry, or sort the list by the clicked column +button. Clicking the same button again flips the order, and ties are resolved +by the columns clicked before. @item right mouse click Seek to percentage in file corresponding to fraction of width. diff --git a/fftools/ffplay.c b/fftools/ffplay.c index 43d43b87eb..4c693c12df 100644 --- a/fftools/ffplay.c +++ b/fftools/ffplay.c @@ -109,6 +109,26 @@ const int program_birth_year = 2003; #define CHAPTER_LIST_HOLD_TIME 500000 #define CHAPTER_LIST_ROWS 12 +enum { + CHAPTER_SORT_NUMBER, + CHAPTER_SORT_ARTIST, + CHAPTER_SORT_TITLE, + CHAPTER_SORT_LENGTH, + CHAPTER_SORT_NB +}; + +typedef struct ChapterSortKey { + int key; + int descending; +} ChapterSortKey; + +typedef struct ChapterRow { + int index; + const char *artist; + const char *title; + int64_t length; +} ChapterRow; + #define USE_ONEPASS_SUBTITLE_RENDER 1 typedef struct MyAVPacketList { @@ -281,6 +301,8 @@ typedef struct VideoState { SDL_Rect chapter_rect; AVFilterGraph *chapter_graph; AVFilterContext *chapter_sink; + ChapterRow *chapter_rows; + int nb_chapter_rows; int chapter_selected; int chapter_pinned; int64_t chapter_fade_start; @@ -352,6 +374,8 @@ static int loop = 1; static int framedrop = -1; static int infinite_buffer = -1; static enum ShowMode show_mode = SHOW_MODE_NONE; +/* the columns the chapter list is sorted by, the most recently clicked first */ +static ChapterSortKey chapter_sort[CHAPTER_SORT_NB] = { { CHAPTER_SORT_NUMBER }, { CHAPTER_SORT_ARTIST }, { CHAPTER_SORT_TITLE }, { CHAPTER_SORT_LENGTH } }; static const char *audio_codec_name; static const char *subtitle_codec_name; static const char *video_codec_name; @@ -1036,8 +1060,106 @@ static const char *chapter_tag(const AVChapter *chapter, const char *key) return tag ? tag->value : ""; } +/* case-insensitive, with missing tags after all others */ +static int compare_tags(const char *a, const char *b) +{ + return !*a != !*b ? !*a - !*b : av_strcasecmp(a, b); +} + +static int compare_chapter_rows(const void *a, const void *b) +{ + const ChapterRow *ra = a, *rb = b; + + for (int i = 0; i < CHAPTER_SORT_NB; i++) { + int cmp; + + switch (chapter_sort[i].key) { + case CHAPTER_SORT_ARTIST: cmp = compare_tags(ra->artist, rb->artist); break; + case CHAPTER_SORT_TITLE: cmp = compare_tags(ra->title, rb->title); break; + case CHAPTER_SORT_LENGTH: cmp = FFDIFFSIGN(ra->length, rb->length); break; + default: cmp = ra->index - rb->index; break; + } + if (cmp) + return chapter_sort[i].descending ? -cmp : cmp; + } + return 0; +} + +/* a column clicked again flips its order, any other becomes the first key ahead of the previous ones */ +static void chapter_sort_by(int key) +{ + int i = 0; + + while (chapter_sort[i].key != key) + i++; + if (i == 0) { + chapter_sort[0].descending ^= 1; + return; + } + memmove(&chapter_sort[1], &chapter_sort[0], i * sizeof(*chapter_sort)); + chapter_sort[0] = (ChapterSortKey){ key, 0 }; +} + +/* rebuilds the rows in the current sort order, keeping the selected chapter selected */ +static int chapter_list_update_rows(VideoState *is) +{ + int selected = is->nb_chapter_rows ? is->chapter_rows[is->chapter_selected].index : 0; + + if (!is->chapter_rows && !(is->chapter_rows = av_calloc(is->ic->nb_chapters, sizeof(*is->chapter_rows)))) + return AVERROR(ENOMEM); + is->nb_chapter_rows = 0; + for (int i = 0; i < is->ic->nb_chapters; i++) { + AVChapter *chapter = is->ic->chapters[i]; + ChapterRow row = { i, chapter_tag(chapter, "artist"), chapter_tag(chapter, "title"), -1 }; + + if (chapter->end != AV_NOPTS_VALUE && chapter->end > chapter->start) + row.length = av_rescale_q(chapter->end - chapter->start, chapter->time_base, av_make_q(1, 1)); + is->chapter_rows[is->nb_chapter_rows++] = row; + } + qsort(is->chapter_rows, is->nb_chapter_rows, sizeof(*is->chapter_rows), compare_chapter_rows); + is->chapter_selected = 0; + for (int row = 0; row < is->nb_chapter_rows; row++) + if (is->chapter_rows[row].index == selected) + is->chapter_selected = row; + return 0; +} + +/* the row showing the chapter, or the selected row if none does */ +static int chapter_row(VideoState *is, int chapter) +{ + for (int row = 0; row < is->nb_chapter_rows; row++) + if (is->chapter_rows[row].index == chapter) + return row; + return is->chapter_selected; +} + +static const struct { + const char *label; + int width; +} sort_buttons[CHAPTER_SORT_NB] = { + [CHAPTER_SORT_NUMBER] = { "#", 5 }, + [CHAPTER_SORT_ARTIST] = { "Artist", 8 }, + [CHAPTER_SORT_TITLE] = { "Title", 7 }, + [CHAPTER_SORT_LENGTH] = { "Duration", 10 }, +}; + +/* button widths are in half font sizes, a quarter apart */ +static int sort_button_x(int font, int button) +{ + int x = font / 2; + + for (int i = 0; i < button; i++) + x += sort_buttons[i].width * font / 2 + font / 4; + return x; +} + #define CHAPTER_LIST_EVENT "Dialogue: 0:00:00.00,9999:00:00.00," +static void bprint_chapter_box(AVBPrint *script, const char *style, int x, int y, int w, int h) +{ + av_bprintf(script, CHAPTER_LIST_EVENT "%s,{\\pos(%d,%d)\\p1}m 0 0 l %d 0 %d %d 0 %d{\\p0}\n", style, x, y, w, w, h, h); +} + /* a text cell clipped to its column, with the characters libass would read as tags or line breaks blanked */ static void bprint_chapter_cell(AVBPrint *script, const char *style, int x, int y, int right, int bottom, const char *text) { @@ -1059,8 +1181,8 @@ static ChapterListLayout chapter_list_layout(VideoState *is) l.font = FFMAX(is->height / 30, 12); l.line = l.font * 3 / 2; l.width = is->width * 3 / 5; - l.nb_rows = FFMIN(is->ic->nb_chapters, CHAPTER_LIST_ROWS); - l.first = av_clip(is->chapter_selected - l.nb_rows / 2, 0, is->ic->nb_chapters - l.nb_rows); + l.nb_rows = FFMIN(is->nb_chapter_rows, CHAPTER_LIST_ROWS); + l.first = av_clip(is->chapter_selected - l.nb_rows / 2, 0, is->nb_chapter_rows - l.nb_rows); l.artist_x = l.font * 3; l.length_x = l.width - l.font / 2; l.title_x = l.artist_x + (l.length_x - l.font * 4 - l.artist_x) * 2 / 5; @@ -1071,34 +1193,41 @@ static void chapter_list_script(VideoState *is, AVBPrint *script) { ChapterListLayout l = chapter_list_layout(is); - is->chapter_rect = (SDL_Rect){ l.font, l.font, l.width, l.nb_rows * l.line + l.font }; + is->chapter_rect = (SDL_Rect){ l.font, l.font, l.width, (l.nb_rows + 1) * l.line + l.font }; av_bprintf(script, "[Script Info]\nScriptType: v4.00+\nPlayResX: %d\nPlayResY: %d\nWrapStyle: 2\n\n" "[V4+ Styles]\n" "Format: Name, Fontname, Fontsize, PrimaryColour, OutlineColour, Bold, Outline, Alignment\n" "Style: Panel,Sans,%d,&H40000000,&H40000000,0,0,7\n" + "Style: Button,Sans,%d,&H80FFFFFF,&H80FFFFFF,0,0,7\n" "Style: Row,Sans,%d,&H00FFFFFF,&H00000000,0,%d,7\n" "Style: Selected,Sans,%d,&H0080D0FF,&H00000000,-1,%d,7\n\n" - "[Events]\nFormat: Start, End, Style, Text\n" - CHAPTER_LIST_EVENT "Panel,{\\pos(0,0)\\p1}m 0 0 l %d 0 %d %d 0 %d{\\p0}\n", - is->chapter_rect.w, is->chapter_rect.h, l.font, l.font, l.font / 16 + 1, l.font, l.font / 16 + 1, - is->chapter_rect.w, is->chapter_rect.w, is->chapter_rect.h, is->chapter_rect.h); - for (int i = l.first; i < l.first + l.nb_rows; i++) { - AVChapter *chapter = is->ic->chapters[i]; - const char *style = i == is->chapter_selected ? "Selected" : "Row"; - int64_t length = av_rescale_q(chapter->end - chapter->start, chapter->time_base, av_make_q(1, 1)); - int y = l.font / 2 + (i - l.first) * l.line; + "[Events]\nFormat: Start, End, Style, Text\n", + is->chapter_rect.w, is->chapter_rect.h, l.font, l.font, l.font, l.font / 16 + 1, l.font, l.font / 16 + 1); + bprint_chapter_box(script, "Panel", 0, 0, is->chapter_rect.w, is->chapter_rect.h); + for (int b = 0; b < FF_ARRAY_ELEMS(sort_buttons); b++) { + int x = sort_button_x(l.font, b), w = sort_buttons[b].width * l.font / 2; - av_bprintf(script, CHAPTER_LIST_EVENT "%s,{\\an9\\pos(%d,%d)}%d\n", style, l.font * 5 / 2, y, i + 1); - bprint_chapter_cell(script, style, l.artist_x, y, l.title_x - l.font / 2, y + l.line, chapter_tag(chapter, "artist")); - bprint_chapter_cell(script, style, l.title_x, y, l.length_x - l.font * 4, y + l.line, chapter_tag(chapter, "title")); - if (chapter->end == AV_NOPTS_VALUE || length < 0) + bprint_chapter_box(script, "Button", x, l.font / 2, w, l.font * 5 / 4); + av_bprintf(script, CHAPTER_LIST_EVENT "%s,{\\an5\\pos(%d,%d)}%s%s\n", chapter_sort[0].key == b ? "Selected" : "Row", + x + w / 2, l.font * 9 / 8, sort_buttons[b].label, + chapter_sort[0].key != b ? "" : chapter_sort[0].descending ? " \xe2\x96\xbc" : " \xe2\x96\xb2"); + } + for (int r = l.first; r < l.first + l.nb_rows; r++) { + const ChapterRow *row = &is->chapter_rows[r]; + const char *style = r == is->chapter_selected ? "Selected" : "Row"; + int y = l.font / 2 + (r - l.first + 1) * l.line; + + av_bprintf(script, CHAPTER_LIST_EVENT "%s,{\\an9\\pos(%d,%d)}%d\n", style, l.font * 5 / 2, y, row->index + 1); + bprint_chapter_cell(script, style, l.artist_x, y, l.title_x - l.font / 2, y + l.line, row->artist); + bprint_chapter_cell(script, style, l.title_x, y, l.length_x - l.font * 4, y + l.line, row->title); + if (row->length < 0) continue; av_bprintf(script, CHAPTER_LIST_EVENT "%s,{\\an9\\pos(%d,%d)}", style, l.length_x, y); - if (length >= 3600) - av_bprintf(script, "%d:%02d:%02d\n", (int)(length / 3600), (int)(length / 60 % 60), (int)(length % 60)); + if (row->length >= 3600) + av_bprintf(script, "%d:%02d:%02d\n", (int)(row->length / 3600), (int)(row->length / 60 % 60), (int)(row->length % 60)); else - av_bprintf(script, "%d:%02d\n", (int)(length / 60), (int)(length % 60)); + av_bprintf(script, "%d:%02d\n", (int)(row->length / 60), (int)(row->length % 60)); } } @@ -1195,7 +1324,7 @@ static void chapter_list_show(VideoState *is, int selected) static void chapter_list_move(VideoState *is, int delta) { - chapter_list_show(is, av_clip(is->chapter_selected + delta, 0, is->ic->nb_chapters - 1)); + chapter_list_show(is, av_clip(is->chapter_selected + delta, 0, is->nb_chapter_rows - 1)); } static int chapter_list_visible(VideoState *is) @@ -1215,9 +1344,17 @@ static int chapter_list_click(VideoState *is, int x, int y) y -= is->chapter_rect.y; if (x < 0 || y < 0 || x >= is->chapter_rect.w || y >= is->chapter_rect.h) return 0; - row = (y - l.font / 2) / l.line; - if (row < l.nb_rows) - seek_chapter(is, l.first + row); + row = (y - l.font / 2) / l.line - 1; + if (row >= 0 && row < l.nb_rows) + seek_chapter(is, is->chapter_rows[l.first + row].index); + for (int b = 0; row < 0 && b < FF_ARRAY_ELEMS(sort_buttons); b++) { + if (x < sort_button_x(l.font, b) || x >= sort_button_x(l.font, b + 1)) + continue; + chapter_sort_by(b); + chapter_list_update_rows(is); + chapter_list_show(is, is->chapter_selected); + chapter_list_render(is); + } return 1; } @@ -1573,6 +1710,7 @@ static void stream_close(VideoState *is) if (is->chapter_texture) SDL_DestroyTexture(is->chapter_texture); avfilter_graph_free(&is->chapter_graph); + av_freep(&is->chapter_rows); av_free(is); } @@ -3200,6 +3338,9 @@ static int read_thread(void *arg) if (ic->pb) ic->pb->eof_reached = 0; // FIXME hack, ffplay maybe should not use avio_feof() to test for the end + if (ic->nb_chapters && (ret = chapter_list_update_rows(is)) < 0) + goto fail; + if (seek_by_bytes < 0) seek_by_bytes = !(ic->iformat->flags & AVFMT_NO_BYTE_SEEK) && !!(ic->iformat->flags & AVFMT_TS_DISCONT) && @@ -3678,7 +3819,7 @@ static void seek_chapter(VideoState *is, int i) if (i >= is->ic->nb_chapters) return; - chapter_list_show(is, i); + chapter_list_show(is, chapter_row(is, i)); av_log(NULL, AV_LOG_VERBOSE, "Seeking to chapter %d.\n", i); stream_seek(is, av_rescale_q(is->ic->chapters[i]->start, is->ic->chapters[i]->time_base, AV_TIME_BASE_Q), 0, 0); @@ -3744,14 +3885,14 @@ static void event_loop(VideoState *cur_stream) break; cur_stream->chapter_pinned = !cur_stream->chapter_pinned; if (cur_stream->chapter_pinned) - chapter_list_show(cur_stream, FFMAX(current_chapter(cur_stream), 0)); + chapter_list_show(cur_stream, chapter_row(cur_stream, current_chapter(cur_stream))); else cur_stream->chapter_last_input = av_gettime_relative() - CHAPTER_LIST_HOLD_TIME; break; case SDLK_RETURN: case SDLK_KP_ENTER: if (chapter_list_visible(cur_stream)) - seek_chapter(cur_stream, cur_stream->chapter_selected); + seek_chapter(cur_stream, cur_stream->chapter_rows[cur_stream->chapter_selected].index); break; case SDLK_w: if (cur_stream->show_mode == SHOW_MODE_VIDEO && cur_stream->vfilter_idx < nb_vfilters - 1) { @@ -4116,7 +4257,7 @@ void show_help_default(const char *opt, const char *arg) "l keep the chapter list on screen, down/up then move its selection\n" "enter seek to the chapter selected in the chapter list while it is shown\n" "mouse wheel move the chapter list selection while it is shown\n" - "left click seek to the clicked chapter list entry\n" + "left click seek to the clicked chapter list entry, or sort the list by the clicked column, again to flip it\n" "right mouse click seek to percentage in file corresponding to fraction of width\n" "left double-click toggle full screen\n" ); -- 2.52.0 >From 93b4b2917983eb976a9b9644735ca414def949d3 Mon Sep 17 00:00:00 2001 From: Michael Niedermayer <[email protected]> Date: Sun, 20 Sep 2026 21:10:39 +0200 Subject: [PATCH 09/12] ffplay: filter the chapter list by typing while it is pinned While the list is pinned it owns the keyboard: typed text narrows the entries to those whose artist or title contains it, backspace erases, escape closes the list. The list is pinned on the release of its key so that the key's own text input does not start the search, and the canvas keeps its full size so a changing row count does not alter the script resolution libass scales to. Assisted-by: Claude --- doc/ffplay.texi | 4 +- fftools/ffplay.c | 114 +++++++++++++++++++++++++++++++++++------------ 2 files changed, 87 insertions(+), 31 deletions(-) diff --git a/doc/ffplay.texi b/doc/ffplay.texi index 070c3ae42f..e6b4b357ca 100644 --- a/doc/ffplay.texi +++ b/doc/ffplay.texi @@ -269,8 +269,8 @@ Seek to previous/next chapter or backward/forward 10 minutes if no chapters. The chapter list is shown briefly. @item l -Keep the chapter list on screen, or let it fade out again. While it is kept, -down/up move its selection. +Keep the chapter list on screen. While it is kept, typing filters the list by +artist and title, down/up move its selection and escape closes it. @item enter Seek to the chapter selected in the chapter list while it is shown. diff --git a/fftools/ffplay.c b/fftools/ffplay.c index 4c693c12df..9c86647a4e 100644 --- a/fftools/ffplay.c +++ b/fftools/ffplay.c @@ -303,6 +303,7 @@ typedef struct VideoState { AVFilterContext *chapter_sink; ChapterRow *chapter_rows; int nb_chapter_rows; + char chapter_search[64]; int chapter_selected; int chapter_pinned; int64_t chapter_fade_start; @@ -1044,6 +1045,9 @@ static void draw_video_background(VideoState *is) } } +static int current_chapter(VideoState *is); +static void seek_chapter(VideoState *is, int i); + static double chapter_list_alpha(VideoState *is, int64_t now) { double alpha = (now - is->chapter_fade_start) / (double)CHAPTER_LIST_FADE_TIME; @@ -1100,7 +1104,7 @@ static void chapter_sort_by(int key) chapter_sort[0] = (ChapterSortKey){ key, 0 }; } -/* rebuilds the rows in the current sort order, keeping the selected chapter selected */ +/* rebuilds the rows matching the search in the current sort order, keeping the selected chapter selected */ static int chapter_list_update_rows(VideoState *is) { int selected = is->nb_chapter_rows ? is->chapter_rows[is->chapter_selected].index : 0; @@ -1114,7 +1118,8 @@ static int chapter_list_update_rows(VideoState *is) if (chapter->end != AV_NOPTS_VALUE && chapter->end > chapter->start) row.length = av_rescale_q(chapter->end - chapter->start, chapter->time_base, av_make_q(1, 1)); - is->chapter_rows[is->nb_chapter_rows++] = row; + if (av_stristr(row.artist, is->chapter_search) || av_stristr(row.title, is->chapter_search)) + is->chapter_rows[is->nb_chapter_rows++] = row; } qsort(is->chapter_rows, is->nb_chapter_rows, sizeof(*is->chapter_rows), compare_chapter_rows); is->chapter_selected = 0; @@ -1170,7 +1175,7 @@ static void bprint_chapter_cell(AVBPrint *script, const char *style, int x, int } typedef struct ChapterListLayout { - int font, line, width, nb_rows, first; + int font, line, width, height, nb_rows, first; int artist_x, title_x, length_x; } ChapterListLayout; @@ -1183,6 +1188,7 @@ static ChapterListLayout chapter_list_layout(VideoState *is) l.width = is->width * 3 / 5; l.nb_rows = FFMIN(is->nb_chapter_rows, CHAPTER_LIST_ROWS); l.first = av_clip(is->chapter_selected - l.nb_rows / 2, 0, is->nb_chapter_rows - l.nb_rows); + l.height = (l.nb_rows + 1) * l.line + l.font; l.artist_x = l.font * 3; l.length_x = l.width - l.font / 2; l.title_x = l.artist_x + (l.length_x - l.font * 4 - l.artist_x) * 2 / 5; @@ -1193,7 +1199,7 @@ static void chapter_list_script(VideoState *is, AVBPrint *script) { ChapterListLayout l = chapter_list_layout(is); - is->chapter_rect = (SDL_Rect){ l.font, l.font, l.width, (l.nb_rows + 1) * l.line + l.font }; + is->chapter_rect = (SDL_Rect){ l.font, l.font, l.width, (CHAPTER_LIST_ROWS + 1) * l.line + l.font }; av_bprintf(script, "[Script Info]\nScriptType: v4.00+\nPlayResX: %d\nPlayResY: %d\nWrapStyle: 2\n\n" "[V4+ Styles]\n" @@ -1201,10 +1207,15 @@ static void chapter_list_script(VideoState *is, AVBPrint *script) "Style: Panel,Sans,%d,&H40000000,&H40000000,0,0,7\n" "Style: Button,Sans,%d,&H80FFFFFF,&H80FFFFFF,0,0,7\n" "Style: Row,Sans,%d,&H00FFFFFF,&H00000000,0,%d,7\n" - "Style: Selected,Sans,%d,&H0080D0FF,&H00000000,-1,%d,7\n\n" + "Style: Selected,Sans,%d,&H0080D0FF,&H00000000,-1,%d,7\n" + "Style: Hint,Sans,%d,&H00A0A0A0,&H00000000,0,%d,7\n\n" "[Events]\nFormat: Start, End, Style, Text\n", - is->chapter_rect.w, is->chapter_rect.h, l.font, l.font, l.font, l.font / 16 + 1, l.font, l.font / 16 + 1); - bprint_chapter_box(script, "Panel", 0, 0, is->chapter_rect.w, is->chapter_rect.h); + is->chapter_rect.w, is->chapter_rect.h, l.font, l.font, l.font, l.font / 16 + 1, l.font, l.font / 16 + 1, + l.font, l.font / 16 + 1); + bprint_chapter_box(script, "Panel", 0, 0, l.width, l.height); + if (is->chapter_pinned) + bprint_chapter_cell(script, *is->chapter_search ? "Row" : "Hint", sort_button_x(l.font, FF_ARRAY_ELEMS(sort_buttons)), + l.font / 2, l.length_x, l.font / 2 + l.line, *is->chapter_search ? is->chapter_search : "type to search"); for (int b = 0; b < FF_ARRAY_ELEMS(sort_buttons); b++) { int x = sort_button_x(l.font, b), w = sort_buttons[b].width * l.font / 2; @@ -1324,7 +1335,8 @@ static void chapter_list_show(VideoState *is, int selected) static void chapter_list_move(VideoState *is, int delta) { - chapter_list_show(is, av_clip(is->chapter_selected + delta, 0, is->nb_chapter_rows - 1)); + if (is->nb_chapter_rows) + chapter_list_show(is, av_clip(is->chapter_selected + delta, 0, is->nb_chapter_rows - 1)); } static int chapter_list_visible(VideoState *is) @@ -1332,7 +1344,54 @@ static int chapter_list_visible(VideoState *is) return chapter_list_alpha(is, av_gettime_relative()) > 0; } -static void seek_chapter(VideoState *is, int i); +/* appends the typed text to the search, or erases its last character when nothing was typed */ +static void chapter_list_search(VideoState *is, const char *typed) +{ + int len = strlen(is->chapter_search); + + if (typed) { + av_strlcat(is->chapter_search, typed, sizeof(is->chapter_search)); + } else if (len) { + while (len > 0 && (is->chapter_search[--len] & 0xC0) == 0x80) + ; + is->chapter_search[len] = 0; + } + chapter_list_update_rows(is); + chapter_list_render(is); +} + +static void chapter_list_pin(VideoState *is, int pinned) +{ + is->chapter_pinned = pinned; + if (pinned) { + SDL_StartTextInput(); + chapter_list_show(is, chapter_row(is, current_chapter(is))); + } else { + SDL_StopTextInput(); + is->chapter_last_input = av_gettime_relative() - CHAPTER_LIST_HOLD_TIME; + is->chapter_search[0] = 0; + chapter_list_update_rows(is); + chapter_list_render(is); + } +} + +/* keys while the list is pinned; typed characters arrive as text input instead */ +static void chapter_list_key(VideoState *is, SDL_Keycode key) +{ + switch (key) { + case SDLK_ESCAPE: chapter_list_pin(is, 0); break; + case SDLK_UP: chapter_list_move(is, -1); break; + case SDLK_DOWN: chapter_list_move(is, 1); break; + case SDLK_PAGEUP: seek_chapter(is, current_chapter(is) + 1); break; + case SDLK_PAGEDOWN: seek_chapter(is, current_chapter(is) - 1); break; + case SDLK_BACKSPACE: chapter_list_search(is, NULL); break; + case SDLK_RETURN: + case SDLK_KP_ENTER: + if (is->nb_chapter_rows) + seek_chapter(is, is->chapter_rows[is->chapter_selected].index); + break; + } +} /* seeks to the entry under a click, returns whether the click hit the list at all */ static int chapter_list_click(VideoState *is, int x, int y) @@ -1342,7 +1401,7 @@ static int chapter_list_click(VideoState *is, int x, int y) x -= is->chapter_rect.x; y -= is->chapter_rect.y; - if (x < 0 || y < 0 || x >= is->chapter_rect.w || y >= is->chapter_rect.h) + if (x < 0 || y < 0 || x >= l.width || y >= l.height) return 0; row = (y - l.font / 2) / l.line - 1; if (row >= 0 && row < l.nb_rows) @@ -3836,13 +3895,18 @@ static void event_loop(VideoState *cur_stream) refresh_loop_wait_event(cur_stream, &event); switch (event.type) { case SDL_KEYDOWN: - if (exit_on_keydown || event.key.keysym.sym == SDLK_ESCAPE || event.key.keysym.sym == SDLK_q) { + if (exit_on_keydown || (!cur_stream->chapter_pinned && + (event.key.keysym.sym == SDLK_ESCAPE || event.key.keysym.sym == SDLK_q))) { do_exit(cur_stream); break; } // If we don't yet have a window, skip all key events, because read_thread might still be initializing... if (!cur_stream->width) continue; + if (cur_stream->chapter_pinned) { + chapter_list_key(cur_stream, event.key.keysym.sym); + break; + } switch (event.key.keysym.sym) { case SDLK_f: toggle_full_screen(cur_stream); @@ -3880,15 +3944,6 @@ static void event_loop(VideoState *cur_stream) case SDLK_t: stream_cycle_channel(cur_stream, AVMEDIA_TYPE_SUBTITLE); break; - case SDLK_l: - if (!cur_stream->ic->nb_chapters) - break; - cur_stream->chapter_pinned = !cur_stream->chapter_pinned; - if (cur_stream->chapter_pinned) - chapter_list_show(cur_stream, chapter_row(cur_stream, current_chapter(cur_stream))); - else - cur_stream->chapter_last_input = av_gettime_relative() - CHAPTER_LIST_HOLD_TIME; - break; case SDLK_RETURN: case SDLK_KP_ENTER: if (chapter_list_visible(cur_stream)) @@ -3924,17 +3979,9 @@ static void event_loop(VideoState *cur_stream) incr = seek_interval ? seek_interval : 10.0; goto do_seek; case SDLK_UP: - if (cur_stream->chapter_pinned) { - chapter_list_move(cur_stream, -1); - break; - } incr = 60.0; goto do_seek; case SDLK_DOWN: - if (cur_stream->chapter_pinned) { - chapter_list_move(cur_stream, 1); - break; - } incr = -60.0; do_seek: if (seek_by_bytes) { @@ -3965,6 +4012,15 @@ static void event_loop(VideoState *cur_stream) break; } break; + case SDL_KEYUP: + /* on the release, so that the key's own text input does not start the search */ + if (event.key.keysym.sym == SDLK_l && cur_stream->width && !cur_stream->chapter_pinned && cur_stream->ic->nb_chapters) + chapter_list_pin(cur_stream, 1); + break; + case SDL_TEXTINPUT: + if (cur_stream->chapter_pinned) + chapter_list_search(cur_stream, event.text.text); + break; case SDL_MOUSEWHEEL: if (chapter_list_visible(cur_stream)) chapter_list_move(cur_stream, -event.wheel.y); @@ -4254,7 +4310,7 @@ void show_help_default(const char *opt, const char *arg) "left/right seek backward/forward by 10 seconds or a custom interval if -seek_interval is set\n" "down/up seek backward/forward 1 minute\n" "page down/page up seek to previous/next chapter or backward/forward 10 minutes if no chapters\n" - "l keep the chapter list on screen, down/up then move its selection\n" + "l keep the chapter list on screen; typing then filters it, down/up move its selection and escape closes it\n" "enter seek to the chapter selected in the chapter list while it is shown\n" "mouse wheel move the chapter list selection while it is shown\n" "left click seek to the clicked chapter list entry, or sort the list by the clicked column, again to flip it\n" -- 2.52.0 >From d5ee86088eace87a86039e79259762befe6ee4c8 Mon Sep 17 00:00:00 2001 From: Michael Niedermayer <[email protected]> Date: Sun, 20 Sep 2026 21:51:07 +0200 Subject: [PATCH 10/12] ffplay: factor the chapter list hit test out of the click handler Assisted-by: Claude --- fftools/ffplay.c | 40 ++++++++++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/fftools/ffplay.c b/fftools/ffplay.c index 9c86647a4e..cca0ded1dc 100644 --- a/fftools/ffplay.c +++ b/fftools/ffplay.c @@ -1393,28 +1393,40 @@ static void chapter_list_key(VideoState *is, SDL_Keycode key) } } -/* seeks to the entry under a click, returns whether the click hit the list at all */ +/* the row and the sort button under a window position, -1 for none; returns whether it is inside the list */ +static int chapter_list_hit(VideoState *is, const ChapterListLayout *l, int x, int y, int *row, int *button) +{ + int line; + + *row = *button = -1; + x -= is->chapter_rect.x; + y -= is->chapter_rect.y; + if (x < 0 || y < 0 || x >= l->width || y >= l->height) + return 0; + line = (y - l->font / 2) / l->line; + if (line > 0 && line <= l->nb_rows) + *row = l->first + line - 1; + for (int b = 0; line == 0 && b < FF_ARRAY_ELEMS(sort_buttons); b++) + if (x >= sort_button_x(l->font, b) && x < sort_button_x(l->font, b + 1)) + *button = b; + return 1; +} + +/* seeks to the entry under a click or sorts by the button under it, returns whether the click hit the list at all */ static int chapter_list_click(VideoState *is, int x, int y) { ChapterListLayout l = chapter_list_layout(is); - int row; + int row, button, inside = chapter_list_hit(is, &l, x, y, &row, &button); - x -= is->chapter_rect.x; - y -= is->chapter_rect.y; - if (x < 0 || y < 0 || x >= l.width || y >= l.height) - return 0; - row = (y - l.font / 2) / l.line - 1; - if (row >= 0 && row < l.nb_rows) - seek_chapter(is, is->chapter_rows[l.first + row].index); - for (int b = 0; row < 0 && b < FF_ARRAY_ELEMS(sort_buttons); b++) { - if (x < sort_button_x(l.font, b) || x >= sort_button_x(l.font, b + 1)) - continue; - chapter_sort_by(b); + if (row >= 0) + seek_chapter(is, is->chapter_rows[row].index); + if (button >= 0) { + chapter_sort_by(button); chapter_list_update_rows(is); chapter_list_show(is, is->chapter_selected); chapter_list_render(is); } - return 1; + return inside; } static void chapter_list_draw(VideoState *is) -- 2.52.0 >From 16de3a08f5105fa8e034e09eba784a0fffb2b169 Mon Sep 17 00:00:00 2001 From: Michael Niedermayer <[email protected]> Date: Sun, 20 Sep 2026 21:52:28 +0200 Subject: [PATCH 11/12] ffplay: highlight the chapter list entry and button under the mouse Mouse motion over the visible list re-renders it only when the hovered row or button changes, and the highlight is dropped when the list comes back after having been hidden. Assisted-by: Claude --- fftools/ffplay.c | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/fftools/ffplay.c b/fftools/ffplay.c index cca0ded1dc..f47996a133 100644 --- a/fftools/ffplay.c +++ b/fftools/ffplay.c @@ -305,6 +305,8 @@ typedef struct VideoState { int nb_chapter_rows; char chapter_search[64]; int chapter_selected; + int chapter_hover_row; + int chapter_hover_button; int chapter_pinned; int64_t chapter_fade_start; int64_t chapter_last_input; @@ -1208,10 +1210,12 @@ static void chapter_list_script(VideoState *is, AVBPrint *script) "Style: Button,Sans,%d,&H80FFFFFF,&H80FFFFFF,0,0,7\n" "Style: Row,Sans,%d,&H00FFFFFF,&H00000000,0,%d,7\n" "Style: Selected,Sans,%d,&H0080D0FF,&H00000000,-1,%d,7\n" - "Style: Hint,Sans,%d,&H00A0A0A0,&H00000000,0,%d,7\n\n" + "Style: Hint,Sans,%d,&H00A0A0A0,&H00000000,0,%d,7\n" + "Style: Hover,Sans,%d,&HC8FFFFFF,&HC8FFFFFF,0,0,7\n" + "Style: ButtonHover,Sans,%d,&H50FFFFFF,&H50FFFFFF,0,0,7\n\n" "[Events]\nFormat: Start, End, Style, Text\n", is->chapter_rect.w, is->chapter_rect.h, l.font, l.font, l.font, l.font / 16 + 1, l.font, l.font / 16 + 1, - l.font, l.font / 16 + 1); + l.font, l.font / 16 + 1, l.font, l.font); bprint_chapter_box(script, "Panel", 0, 0, l.width, l.height); if (is->chapter_pinned) bprint_chapter_cell(script, *is->chapter_search ? "Row" : "Hint", sort_button_x(l.font, FF_ARRAY_ELEMS(sort_buttons)), @@ -1219,7 +1223,7 @@ static void chapter_list_script(VideoState *is, AVBPrint *script) for (int b = 0; b < FF_ARRAY_ELEMS(sort_buttons); b++) { int x = sort_button_x(l.font, b), w = sort_buttons[b].width * l.font / 2; - bprint_chapter_box(script, "Button", x, l.font / 2, w, l.font * 5 / 4); + bprint_chapter_box(script, b == is->chapter_hover_button ? "ButtonHover" : "Button", x, l.font / 2, w, l.font * 5 / 4); av_bprintf(script, CHAPTER_LIST_EVENT "%s,{\\an5\\pos(%d,%d)}%s%s\n", chapter_sort[0].key == b ? "Selected" : "Row", x + w / 2, l.font * 9 / 8, sort_buttons[b].label, chapter_sort[0].key != b ? "" : chapter_sort[0].descending ? " \xe2\x96\xbc" : " \xe2\x96\xb2"); @@ -1229,6 +1233,8 @@ static void chapter_list_script(VideoState *is, AVBPrint *script) const char *style = r == is->chapter_selected ? "Selected" : "Row"; int y = l.font / 2 + (r - l.first + 1) * l.line; + if (r == is->chapter_hover_row) + bprint_chapter_box(script, "Hover", 0, y - l.font / 4, l.width, l.line); av_bprintf(script, CHAPTER_LIST_EVENT "%s,{\\an9\\pos(%d,%d)}%d\n", style, l.font * 5 / 2, y, row->index + 1); bprint_chapter_cell(script, style, l.artist_x, y, l.title_x - l.font / 2, y + l.line, row->artist); bprint_chapter_cell(script, style, l.title_x, y, l.length_x - l.font * 4, y + l.line, row->title); @@ -1325,6 +1331,8 @@ static void chapter_list_show(VideoState *is, int selected) if (!renderer) return; + if (!chapter_list_alpha(is, now)) + is->chapter_hover_row = is->chapter_hover_button = -1; is->chapter_fade_start = now - chapter_list_alpha(is, now) * CHAPTER_LIST_FADE_TIME; is->chapter_last_input = now; if (selected != is->chapter_selected || !is->chapter_texture) { @@ -1412,6 +1420,19 @@ static int chapter_list_hit(VideoState *is, const ChapterListLayout *l, int x, i return 1; } +static void chapter_list_hover(VideoState *is, int x, int y) +{ + ChapterListLayout l = chapter_list_layout(is); + int row, button; + + chapter_list_hit(is, &l, x, y, &row, &button); + if (row == is->chapter_hover_row && button == is->chapter_hover_button) + return; + is->chapter_hover_row = row; + is->chapter_hover_button = button; + chapter_list_render(is); +} + /* seeks to the entry under a click or sorts by the button under it, returns whether the click hit the list at all */ static int chapter_list_click(VideoState *is, int x, int y) { @@ -3689,6 +3710,7 @@ static VideoState *stream_open(const char *filename, is = av_mallocz(sizeof(VideoState)); if (!is) return NULL; + is->chapter_hover_row = is->chapter_hover_button = -1; is->last_video_stream = is->video_stream = -1; is->last_audio_stream = is->audio_stream = -1; is->last_subtitle_stream = is->subtitle_stream = -1; @@ -4062,6 +4084,8 @@ static void event_loop(VideoState *cur_stream) cursor_hidden = 0; } cursor_last_shown = av_gettime_relative(); + if (event.type == SDL_MOUSEMOTION && chapter_list_visible(cur_stream)) + chapter_list_hover(cur_stream, event.motion.x, event.motion.y); if (event.type == SDL_MOUSEBUTTONDOWN) { if (event.button.button != SDL_BUTTON_RIGHT) break; -- 2.52.0 >From 308a5e1a0db151f91bd4fc732dfcdd97b7e28461 Mon Sep 17 00:00:00 2001 From: Michael Niedermayer <[email protected]> Date: Sun, 20 Sep 2026 18:34:51 +0200 Subject: [PATCH 12/12] avformat/concatdec: add an option creating a chapter per file With chapter_per_file set, every file becomes a chapter named after its title tag or, failing that, its file name, so a player can present a playlist as a browsable list. Finding the chapter boundaries means opening every file up front, which also makes the total duration known and the output seekable. Assisted-by: Claude --- doc/demuxers.texi | 7 +++++++ libavformat/concatdec.c | 30 ++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/doc/demuxers.texi b/doc/demuxers.texi index 82c36a4594..a1f316aa86 100644 --- a/doc/demuxers.texi +++ b/doc/demuxers.texi @@ -242,6 +242,13 @@ expressed in microseconds. The duration metadata is only set if it is known based on the concat file. The default is 0. +@item chapter_per_file +If set to 1, add a chapter for each file, named after the @code{title} +metadata of the file or, failing that, its file name. Every file is opened +when the script is read to learn its duration, which also makes the total +duration known and the output seekable. +The default is 0. + @end table @subsection Examples diff --git a/libavformat/concatdec.c b/libavformat/concatdec.c index c57d1b649a..5960d00611 100644 --- a/libavformat/concatdec.c +++ b/libavformat/concatdec.c @@ -73,6 +73,7 @@ typedef struct { ConcatMatchMode stream_match_mode; unsigned auto_convert; int segment_time_metadata; + int chapter_per_file; } ConcatContext; static int concat_probe(const AVProbeData *probe) @@ -658,6 +659,31 @@ fail: return ret == AVERROR_EOF ? 0 : ret; } +static int add_file_chapters(AVFormatContext *avf) +{ + ConcatContext *cat = avf->priv_data; + + for (unsigned i = 0; i < cat->nb_files; i++) { + ConcatFile *file = &cat->files[i]; + AVDictionaryEntry *title; + int ret = open_file(avf, i); + + if (ret < 0) + return ret; + file->user_duration = file->duration; + title = av_dict_get(cat->avf->metadata, "title", NULL, 0); + if (!avpriv_new_chapter(avf, i, AV_TIME_BASE_Q, file->start_time, + file->duration == AV_NOPTS_VALUE ? AV_NOPTS_VALUE : file->start_time + file->duration, + title ? title->value : av_basename(file->url))) + return AVERROR(ENOMEM); + if (file->duration == AV_NOPTS_VALUE) { + av_log(avf, AV_LOG_WARNING, "Duration of '%s' unknown, no chapters for the files after it\n", file->url); + break; + } + } + return 0; +} + static int concat_read_header(AVFormatContext *avf) { ConcatContext *cat = avf->priv_data; @@ -672,6 +698,8 @@ static int concat_read_header(AVFormatContext *avf) av_log(avf, AV_LOG_ERROR, "No files to concat\n"); return AVERROR_INVALIDDATA; } + if (cat->chapter_per_file && (ret = add_file_chapters(avf)) < 0) + return ret; for (i = 0; i < cat->nb_files; i++) { if (cat->files[i].start_time == AV_NOPTS_VALUE) @@ -940,6 +968,8 @@ static const AVOption options[] = { OFFSET(auto_convert), AV_OPT_TYPE_BOOL, {.i64 = 1}, 0, 1, DEC }, { "segment_time_metadata", "output file segment start time and duration as packet metadata", OFFSET(segment_time_metadata), AV_OPT_TYPE_BOOL, {.i64 = 0}, 0, 1, DEC }, + { "chapter_per_file", "add a chapter for each file, opening them all up front to learn their durations", + OFFSET(chapter_per_file), AV_OPT_TYPE_BOOL, {.i64 = 0}, 0, 1, DEC }, { NULL } }; -- 2.52.0 _______________________________________________ ffmpeg-devel mailing list -- [email protected] To unsubscribe send an email to [email protected]
