From 2f779272e061fbdf2725009f9e327c2f7a6b0a61 Mon Sep 17 00:00:00 2001 From: marcos ashton Date: Mon, 23 Mar 2026 16:03:40 +0000 Subject: [PATCH] libavformat/img2enc: add update_filemtime option Add a new boolean option -update_filemtime to the image2 muxer that sets each output file's modification time based on the creation_time metadata plus the frame's PTS offset. This is useful when extracting frames from dashcam or action camera footage where wall-clock timestamps should be preserved on the output files, allowing photo management tools to sort frames by capture time without post-processing. The option requires creation_time metadata to be set (via -metadata creation_time=...). If not present, a warning is logged and the option is silently disabled. When PTS is unavailable, the creation time is used as-is without frame offset. Uses utimes() on POSIX and _utime() on Windows to set file timestamps with microsecond and second precision respectively. Includes a FATE roundtrip test that writes frames with a known creation_time, reads them back using the demuxer's -ts_from_file option, and verifies the PTS values match the expected timestamps. Closes: https://code.ffmpeg.org/FFmpeg/FFmpeg/issues/22537 Signed-off-by: marcos ashton --- doc/muxers.texi | 6 ++ libavformat/img2enc.c | 98 ++++++++++++++++++++++++++++ tests/fate-run.sh | 15 +++++ tests/fate/image.mak | 10 ++- tests/ref/fate/img2-update-filemtime | 3 + 5 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 tests/ref/fate/img2-update-filemtime diff --git a/doc/muxers.texi b/doc/muxers.texi index 92d707ad9f..5dc46ff291 100644 --- a/doc/muxers.texi +++ b/doc/muxers.texi @@ -2654,6 +2654,12 @@ writing is completed. Default is disabled. @item protocol_opts @var{options_list} Set protocol options as a :-separated list of key=value parameters. Values containing the @code{:} special character must be escaped. + +@item update_filemtime @var{bool} +If set to 1, set each output file's modification time to the +@code{creation_time} metadata value plus the frame's PTS offset. +If @code{creation_time} is missing or unparsable, a warning is +logged and the option is ignored. Default value is 0. @end table @subsection Examples diff --git a/libavformat/img2enc.c b/libavformat/img2enc.c index b11f62d85d..1296da8ec7 100644 --- a/libavformat/img2enc.c +++ b/libavformat/img2enc.c @@ -21,16 +21,24 @@ */ #include +#ifdef _WIN32 +#include +#else +#include +#endif #include "config_components.h" +#include "libavutil/avutil.h" #include "libavutil/intreadwrite.h" #include "libavutil/avstring.h" #include "libavutil/bprint.h" #include "libavutil/dict.h" #include "libavutil/log.h" +#include "libavutil/mathematics.h" #include "libavutil/mem.h" #include "libavutil/opt.h" +#include "libavutil/parseutils.h" #include "libavutil/pixdesc.h" #include "libavutil/time_internal.h" #include "avformat.h" @@ -50,8 +58,36 @@ typedef struct VideoMuxData { const char *muxer; int use_rename; AVDictionary *protocol_opts; + int update_filemtime; + int64_t creation_ts; /**< creation_time in microseconds since epoch */ } VideoMuxData; +static void set_file_mtime(AVFormatContext *s, const char *path, int64_t ts_us) +{ + int64_t sec = ts_us / 1000000; + int64_t usec = ts_us % 1000000; + + if (usec < 0) { + sec--; + usec += 1000000; + } + +#ifdef _WIN32 + struct _utimbuf ut; + ut.actime = sec; + ut.modtime = sec; + if (_utime(path, &ut) < 0) +#else + struct timeval times[2] = { + { .tv_sec = sec, .tv_usec = usec }, + { .tv_sec = sec, .tv_usec = usec }, + }; + if (utimes(path, times) < 0) +#endif + av_log(s, AV_LOG_WARNING, + "Failed to set file modification time for %s\n", path); +} + static int write_header(AVFormatContext *s) { VideoMuxData *img = s->priv_data; @@ -75,6 +111,29 @@ static int write_header(AVFormatContext *s) } img->img_number = img->start_img_number; + if (img->update_filemtime) { + const char *proto = avio_find_protocol_name(s->url); + AVDictionaryEntry *entry; + int64_t parsed_ts; + + if (!proto || strcmp(proto, "file")) { + av_log(s, AV_LOG_WARNING, + "update_filemtime is only supported for local files, " + "it will be ignored\n"); + img->update_filemtime = 0; + } else { + entry = av_dict_get(s->metadata, "creation_time", NULL, 0); + if (!entry || av_parse_time(&parsed_ts, entry->value, 0) < 0) { + av_log(s, AV_LOG_WARNING, + "No valid creation_time metadata found, " + "update_filemtime will be ignored\n"); + img->update_filemtime = 0; + } else { + img->creation_ts = parsed_ts; + } + } + } + return 0; } @@ -143,6 +202,7 @@ static int write_packet(AVFormatContext *s, AVPacket *pkt) AVIOContext *pb[4] = {0}; char* target[4] = {0}; char* tmp[4] = {0}; + char* filepaths[4] = {0}; AVCodecParameters *par = s->streams[pkt->stream_index]->codecpar; const AVPixFmtDescriptor *desc = av_pix_fmt_desc_get(par->format); int ret, i; @@ -204,6 +264,14 @@ static int write_packet(AVFormatContext *s, AVPacket *pkt) goto fail; } + if (img->update_filemtime) { + filepaths[i] = av_strdup(filename.str); + if (!filepaths[i]) { + ret = AVERROR(ENOMEM); + goto fail; + } + } + if (!img->split_planes || i+1 >= desc->nb_components) break; filename.str[filename.len - 1] = "UVAx"[i]; @@ -246,6 +314,34 @@ static int write_packet(AVFormatContext *s, AVPacket *pkt) av_freep(&target[i]); } + if (img->update_filemtime) { + AVStream *st = s->streams[pkt->stream_index]; + int64_t frame_ts = img->creation_ts; + int skip = 0; + + if (pkt->pts != AV_NOPTS_VALUE) { + int64_t offset = av_rescale_q(pkt->pts, st->time_base, + AV_TIME_BASE_Q); + if (offset == INT64_MIN || + (offset > 0 && img->creation_ts > INT64_MAX - offset) || + (offset < 0 && img->creation_ts < INT64_MIN - offset)) { + av_log(s, AV_LOG_WARNING, + "Integer overflow computing file mtime, skipping\n"); + skip = 1; + } else { + frame_ts += offset; + } + } + + if (!skip) { + for (i = 0; i < 4 && filepaths[i]; i++) + set_file_mtime(s, filepaths[i], frame_ts); + } + } + + for (i = 0; i < FF_ARRAY_ELEMS(filepaths); i++) + av_freep(&filepaths[i]); + img->img_number++; return 0; @@ -255,6 +351,7 @@ fail: for (i = 0; i < FF_ARRAY_ELEMS(pb); i++) { av_freep(&tmp[i]); av_freep(&target[i]); + av_freep(&filepaths[i]); if (pb[i]) ff_format_io_close(s, &pb[i]); } @@ -281,6 +378,7 @@ static const AVOption muxoptions[] = { { "frame_pts", "use current frame pts for filename", OFFSET(frame_pts), AV_OPT_TYPE_BOOL, { .i64 = 0 }, 0, 1, ENC }, { "atomic_writing", "write files atomically (using temporary files and renames)", OFFSET(use_rename), AV_OPT_TYPE_BOOL, { .i64 = 0 }, 0, 1, ENC }, { "protocol_opts", "specify protocol options for the opened files", OFFSET(protocol_opts), AV_OPT_TYPE_DICT, {0}, 0, 0, ENC }, + { "update_filemtime", "set output file mtime from creation_time metadata plus frame offset", OFFSET(update_filemtime), AV_OPT_TYPE_BOOL, { .i64 = 0 }, 0, 1, ENC }, { NULL }, }; diff --git a/tests/fate-run.sh b/tests/fate-run.sh index a0aee7158d..d0726c64dd 100755 --- a/tests/fate-run.sh +++ b/tests/fate-run.sh @@ -503,6 +503,21 @@ lavf_image2pipe(){ do_avconv_crc $file -auto_conversion_filters $DEC_OPTS -f image2pipe -i $target_path/$file } +img2_update_filemtime(){ + outdir="tests/data/lavf" + file=${outdir}/img2_mtime_%03d.pgm + cleanfiles="$cleanfiles ${outdir}/img2_mtime_001.pgm ${outdir}/img2_mtime_002.pgm ${outdir}/img2_mtime_003.pgm" + ffmpeg -f lavfi -i "color=c=black:s=2x2:r=1:d=3,format=gray8" \ + -c:v pgm \ + -metadata creation_time="2024-01-01T00:00:00.000000Z" \ + -update_filemtime 1 \ + -y $file || return + probe -f image2 -ts_from_file sec \ + -show_entries packet=pts \ + -of csv=p=0 \ + -i $file +} + lavf_video(){ t="${test#lavf-}" outdir="tests/data/lavf" diff --git a/tests/fate/image.mak b/tests/fate/image.mak index be803094da..e9fe059ead 100644 --- a/tests/fate/image.mak +++ b/tests/fate/image.mak @@ -620,8 +620,16 @@ FATE_IMAGE += $(FATE_IMAGE-yes) FATE_IMAGE_PROBE += $(FATE_IMAGE_PROBE-yes) FATE_IMAGE_TRANSCODE += $(FATE_IMAGE_TRANSCODE-yes) +FATE_IMG2_MUXER-$(call ALLYES, LAVFI_INDEV COLOR_FILTER FORMAT_FILTER \ + PGM_ENCODER IMAGE2_MUXER IMAGE2_DEMUXER FILE_PROTOCOL FFPROBE) \ + += fate-img2-update-filemtime +fate-img2-update-filemtime: CMD = img2_update_filemtime +fate-img2-update-filemtime: REF = $(SRC_PATH)/tests/ref/fate/img2-update-filemtime + +FATE_FFMPEG_FFPROBE += $(FATE_IMG2_MUXER-yes) + FATE_SAMPLES_FFMPEG += $(FATE_IMAGE) FATE_SAMPLES_FFPROBE += $(FATE_IMAGE_PROBE) FATE_SAMPLES_FFMPEG_FFPROBE += $(FATE_IMAGE_TRANSCODE) -fate-image: $(FATE_IMAGE) $(FATE_IMAGE_PROBE) $(FATE_IMAGE_TRANSCODE) +fate-image: $(FATE_IMAGE) $(FATE_IMAGE_PROBE) $(FATE_IMAGE_TRANSCODE) $(FATE_IMG2_MUXER-yes) diff --git a/tests/ref/fate/img2-update-filemtime b/tests/ref/fate/img2-update-filemtime new file mode 100644 index 0000000000..630eccf559 --- /dev/null +++ b/tests/ref/fate/img2-update-filemtime @@ -0,0 +1,3 @@ +1704067200 +1704067201 +1704067202