/*** * 将文件切割成片 * @param filename * @param uuid * @param data * @throws IOException */ default void divideToSegments(String filename, String uuid, byte[]data) throws IOException { DivideTask divideTask = new DivideTask(filename,uuid,data); Future<ImmutablePair<PlayList, List<TransportSegment>>> divideFuture = getThreadPool().submit(divideTask); String mediaId = String.format("media.%s",uuid); try { ImmutablePair<PlayList, List<TransportSegment>> plsAndTsFiles = divideFuture.get(30, TimeUnit.MINUTES); PlayList playlist = plsAndTsFiles.getLeft(); List<TransportSegment> segments = plsAndTsFiles.getRight(); //保存切片文件 saveSegments(segments); //保存播放列表 savePlayList(playlist); //放到缓存里 Map<String,String> mapping = new HashMap<>(); mapping.put("playlist",playlist.getContext()); //把原始文件放进去,方便以后下载 mapping.put("binary",Base64.getEncoder().encodeToString(Files.readAllBytes(Paths.get(filename)))); for (TransportSegment segment:segments) { String tsFileName = segment.getFilename(); byte[] bytes = segment.getBytes(); String binary = Base64.getEncoder().encodeToString(bytes); mapping.put(tsFileName,binary); } //切片以后的文件添加到缓存 getCacheService().setCacheMap(mediaId, mapping); //30分钟以后失效 getCacheService().expire(mediaId,7,TimeUnit.DAYS); } catch (InterruptedException| ExecutionException | TimeoutException e) { getLogger().error("文件切片失败:{}",e); } }
import lombok.Data; import lombok.NoArgsConstructor; import javax.persistence.Table; import java.time.LocalDateTime; import java.time.ZoneId; /*** * 转换后的文件切片 */ @Data @NoArgsConstructor @Table(name = "open_segment") public class TransportSegment { String uuid; /*** * 文件名 */ private String filename; /*** * 字节流 */ private byte[] bytes; private LocalDateTime createTime = LocalDateTime.now(ZoneId.of("+8")); private TransportSegment(Builder builder) { setUuid(builder.uuid); setFilename(builder.filename); setBytes(builder.bytes); setCreateTime(builder.createTime); } public static final class Builder { private String uuid; private String filename; private byte[] bytes; private LocalDateTime createTime = LocalDateTime.now(ZoneId.of("+8")); public Builder() { } public Builder uuid(String uuid) { this.uuid = uuid; return this; } public Builder filename(String filename) { this.filename = filename; return this; } public Builder bytes(byte[] bytes) { this.bytes = bytes; return this; } public Builder createTime(LocalDateTime createTime) { this.createTime = createTime; return this; } public TransportSegment build() { return new TransportSegment(this); } } }
import com.fasterxml.jackson.annotation.JsonFormat; import lombok.Data; import lombok.NoArgsConstructor; import org.springframework.format.annotation.DateTimeFormat; import java.time.LocalDateTime; import java.time.ZoneId; @NoArgsConstructor @Data public class PlayList { private String uuid; /*** * 播放时长 */ private Float duration; /*** * 播放列表内容 */ private String context; @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss",timezone = "GMT+8") private LocalDateTime createTime = LocalDateTime.now(ZoneId.of("+8")); private PlayList(Builder builder) { setUuid(builder.uuid); setDuration(builder.duration); setContext(builder.context); setCreateTime(builder.createTime); } public static final class Builder { private String uuid; private Float duration; private String context; private LocalDateTime createTime = LocalDateTime.now(ZoneId.of("+8")); public Builder() { } public Builder uuid(String uuid) { this.uuid = uuid; return this; } public Builder duration(Float duration) { this.duration = duration; return this; } public Builder context(String context) { this.context = context; return this; } public Builder createTime(LocalDateTime createTime) { this.createTime = createTime; return this; } public PlayList build() { return new PlayList(this); } } }
/** * 缓存Map * * @param key * @param dataMap * @return */ @Override public <T> HashOperations<String, String, T> setCacheMap(String key, Map<String, T> dataMap) { HashOperations hashOperations = redisTemplate.opsForHash(); if (null != dataMap) { for (Map.Entry<String, T> entry : dataMap.entrySet()) { String hashKey = entry.getKey(); if(hashKey !=null){ hashOperations.put(key, hashKey, entry.getValue()); } else { log.error("出错了:{},hash键为null@{}",entry.getValue()); } } } return hashOperations; }
@Override public Boolean expire(String key, long timeout, TimeUnit unit) { return redisTemplate.expire(key, timeout, unit); }
import lombok.Data; import lombok.NoArgsConstructor; import net.bramp.ffmpeg.probe.FFmpegStream; import org.apache.ibatis.type.JdbcType; import tk.mybatis.mapper.annotation.ColumnType; import javax.persistence.Table; import java.util.List; @Data @NoArgsConstructor @Table(name = "open_media") public class AudioMediaFile extends MediaFile { /*** * 流通道数 */ @ColumnType(column = "nb_streams",jdbcType = JdbcType.TINYINT) byte nbStreams; byte nbPrograms; Integer startTime; /*** * 格式名称 */ String formatName; /*** * 多媒体播放时长 */ Float duration; /*** * 比特率 */ Integer bitRate; @ColumnType(column = "probe_score",jdbcType = JdbcType.TINYINT) byte probeScore; /*** * 文件类型 */ @ColumnType(column = "type",jdbcType = JdbcType.TINYINT) byte type; List<FFmpegStream> streams; String metadata; private AudioMediaFile(Builder builder) { setUuid(builder.uuid); setName(builder.name); setData(builder.data); setMimeType(builder.mimeType); setStamp(builder.stamp); setSize(builder.size); setNbStreams(builder.nbStreams); setFormatName(builder.formatName); setDuration(builder.duration); setBitRate(builder.bitRate); setProbeScore(builder.probeScore); setType(builder.type); setStreams(builder.streams); setMetadata(builder.metadata); } public static final class Builder { private String uuid; private String name; private byte[] data; private String mimeType; private Long stamp; private Long size; private byte nbStreams; private String formatName; private Float duration; private Integer bitRate; private byte probeScore; private byte type; private List<FFmpegStream> streams; private String metadata; public Builder() { } public Builder uuid(String uuid) { this.uuid = uuid; return this; } public Builder name(String name) { this.name = name; return this; } public Builder data(byte[] data) { this.data = data; return this; } public Builder mimeType(String mimeType) { this.mimeType = mimeType; return this; } public Builder stamp(Long stamp) { this.stamp = stamp; return this; } public Builder size(Long size) { this.size = size; return this; } public Builder nbStreams(byte nbStreams) { this.nbStreams = nbStreams; return this; } public Builder formatName(String formatName) { this.formatName = formatName; return this; } public Builder duration(Float duration) { this.duration = duration; return this; } public Builder bitRate(Integer bitRate) { this.bitRate = bitRate; return this; } public Builder probeScore(byte probeScore) { this.probeScore = probeScore; return this; } public Builder type(byte type) { this.type = type; return this; } public Builder streams(List<FFmpegStream> streams) { this.streams = streams; return this; } public Builder metadata(String metadata) { this.metadata = metadata; return this; } public AudioMediaFile build() { return new AudioMediaFile(this); } } }
import lombok.Data; import lombok.NoArgsConstructor; import javax.persistence.GeneratedValue; import javax.persistence.Id; import javax.persistence.Table; /*** * 多媒体文件 */ @Data @NoArgsConstructor @Table(name = "open_media") public class MediaFile { /** * 音频文件 */ public static final byte TYPE_AUDIO = 0x1; public static final byte TYPE_VIDEO = 0x2; public static final byte TYPE_DATA1 = 0x4; public static final byte TYPE_DATA2 = 0x8; /*** * 文件唯一标识 */ @Id @GeneratedValue(generator = "JDBC") String uuid; /**** * 文件名 */ String name; /*** * 解析后的数据流 */ byte[] data; /*** * 多媒体文件类型 */ String mimeType; /*** * 创建文件的时间 */ Long stamp; /*** * 文件大小 */ Long size; private MediaFile(Builder builder) { setUuid(builder.uuid); setName(builder.name); setData(builder.data); setMimeType(builder.mimeType); setStamp(builder.stamp); setSize(builder.size); } public static final class Builder { private String uuid; private String name; private byte[] data; private String mimeType; private Long stamp; private Long size; public Builder() { } public Builder uuid(String uuid) { this.uuid = uuid; return this; } public Builder name(String name) { this.name = name; return this; } public Builder data(byte[] data) { this.data = data; return this; } public Builder mimeType(String mimeType) { this.mimeType = mimeType; return this; } public Builder stamp(Long stamp) { this.stamp = stamp; return this; } public Builder size(Long size) { this.size = size; return this; } public MediaFile build() { return new MediaFile(this); } } }
static String toJson(Object value) throws JsonProcessingException { ObjectMapper objectMapper =new ObjectMapper(); //属性值为null不输出 objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); //默认值的不输出 objectMapper.setSerializationInclusion(JsonInclude.Include.NON_DEFAULT); //反斜杠转义其他字符 objectMapper.configure(JsonParser.Feature.ALLOW_BACKSLASH_ESCAPING_ANY_CHARACTER,true); //所有键值用字符串形式包装起来 objectMapper.configure(JsonGenerator.Feature.WRITE_NUMBERS_AS_STRINGS,true); return objectMapper.writeValueAsString(value); }
import xxx.bean.AudioMediaFile; import xxx.bean.MediaFile; import xxx.bean.PlayList; import xxx.bean.TransportSegment; import xxx.service.MediaService; import lombok.extern.slf4j.Slf4j; import net.bramp.ffmpeg.FFmpeg; import net.bramp.ffmpeg.FFmpegExecutor; import net.bramp.ffmpeg.FFmpegUtils; import net.bramp.ffmpeg.FFprobe; import net.bramp.ffmpeg.builder.FFmpegBuilder; import net.bramp.ffmpeg.job.FFmpegJob; import net.bramp.ffmpeg.probe.FFmpegProbeResult; import net.bramp.ffmpeg.probe.FFmpegStream; import net.bramp.ffmpeg.progress.Progress; import net.bramp.ffmpeg.progress.ProgressListener; import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.tuple.ImmutablePair; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.time.LocalDateTime; import java.time.ZoneId; import java.time.ZoneOffset; import java.util.Arrays; import java.util.List; import java.util.Locale; import java.util.Optional; import java.util.concurrent.Callable; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; /*** * 文件切割线程任务 * divides it into a series of small media segments of equal duration. * @author dqk */ @Deprecated @Slf4j public class DivideTask implements Callable<ImmutablePair<PlayList, List<TransportSegment>>> { final Locale locale = Locale.US; final FFmpeg ffmpeg = new FFmpeg(); final FFprobe ffprobe = new FFprobe(); ImmutablePair<FFmpegProbeResult, AudioMediaFile> pair; String filename; String uuid; byte[] data; public DivideTask(ImmutablePair<FFmpegProbeResult,AudioMediaFile> pair) throws IOException { this.pair = pair; } public DivideTask(String filename,String uuid,byte[] data) throws IOException { this.filename = filename; this.uuid = uuid; this.data = data; //获取反序列化后文件的元数据信息 FFmpegProbeResult probeResult = ffprobe.probe(filename); long timestamp = LocalDateTime.now(ZoneId.of("UTC+8")).toInstant(ZoneOffset.ofHours(8)).toEpochMilli(); String metadata = MediaService.toJson(probeResult); AudioMediaFile.Builder builder = new AudioMediaFile.Builder() .name(filename) .uuid(uuid) .streams(probeResult.streams) .mimeType(probeResult.format.format_long_name) .type(MediaFile.TYPE_AUDIO) .stamp(timestamp) .bitRate(Long.valueOf(probeResult.format.bit_rate).intValue()) .duration(Double.valueOf(probeResult.format.duration).floatValue()) .formatName(probeResult.format.format_name) .nbStreams((byte) probeResult.format.nb_streams) .size(probeResult.format.size) .probeScore((byte) probeResult.format.probe_score) .mimeType(probeResult.format.format_long_name) .data(data) .metadata(metadata); this.pair = new ImmutablePair<>(probeResult,builder.build()); } public static String getString(InputStream stream) throws IOException { return IOUtils.toString(stream,"UTF-8"); } @Override public ImmutablePair<PlayList,List<TransportSegment>> call() throws Exception { FFmpegExecutor executor = new FFmpegExecutor(ffmpeg, ffprobe); final FFmpegProbeResult probe = pair.getLeft(); AudioMediaFile audioFile = pair.getRight(); final List<FFmpegStream> streams = probe.getStreams().stream().filter(fFmpegStream -> fFmpegStream.codec_type!=null).collect(Collectors.toList()); final Optional<FFmpegStream> audioStream = streams.stream().filter(fFmpegStream -> FFmpegStream.CodecType.AUDIO.equals(fFmpegStream.codec_type)).findFirst(); if(!audioStream.isPresent()) { log.error("未发现音频流"); } String filename = probe.format.filename; Path nioFile = Paths.get(filename); String directory = nioFile.getParent().toString(); String uuid = audioFile.getUuid(); String output = String.format("%s%sstream.m3u8",directory, File.separator); FFmpegBuilder builder = new FFmpegBuilder() .setInput(filename) .overrideOutputFiles(true) .addOutput(output) .setFormat("wav") .setAudioBitRate(audioStream.isPresent()?audioStream.get().bit_rate:0) .setAudioChannels(1) .setAudioCodec("aac") // using the aac codec .setAudioSampleRate(audioStream.get().sample_rate) .setAudioBitRate(audioStream.get().bit_rate) .setStrict(FFmpegBuilder.Strict.STRICT) .setFormat("hls") .addExtraArgs("-hls_wrap", "0", "-hls_time", "5", "-hls_list_size","0") .done(); FFmpegJob job = executor.createJob( builder, new ProgressListener() { // Using the FFmpegProbeResult determine the duration of the input final double duration_ns = probe.getFormat().duration * TimeUnit.SECONDS.toNanos(1); @Override public void progress(Progress progress) { double percentage = progress.out_time_ns / duration_ns; // Print out interesting information about the progress String consoleLog = String.format( locale, "[%.0f%%] status:%s frame:%d time:%s fps:%.0f speed:%.2fx", percentage * 100, progress.status, progress.frame, FFmpegUtils.toTimecode(progress.out_time_ns, TimeUnit.NANOSECONDS), progress.fps.doubleValue(), progress.speed); log.debug(consoleLog); } }); job.run(); if (job.getState() == FFmpegJob.State.FINISHED) { //排除的文件 String[] excludes = new String[]{ "wav","m3u8" }; List<TransportSegment> segments = Files.list(Paths.get(directory)).filter( path -> { String extension = getFileExtension(path.getFileName().toString()); return !Arrays.asList(excludes).contains(extension); } ).map(path -> { String name = path.getFileName().toString(); try { byte[] bytes = IOUtils.toByteArray(path.toUri()); TransportSegment segment = new TransportSegment .Builder() .bytes(bytes) .filename(name) .uuid(uuid) .build(); return segment; } catch (IOException e) { log.error("读取文件失败:{}",e); } return null; }).collect(Collectors.toList()); String context = getString(new FileInputStream(output)); PlayList playList = new PlayList.Builder() .context(context) .uuid(uuid) .duration(Double.valueOf(probe.format.duration).floatValue()) .build(); return new ImmutablePair<>(playList,segments); }else { log.error("文件分割发生不可预料的错误:{}"); } return null; } private static String getFileExtension(String fileName) { if (fileName.lastIndexOf(".") != -1 && fileName.lastIndexOf(".") != 0) { return fileName.substring(fileName.lastIndexOf(".") + 1); } else { return ""; } } }
最终生成结果
前端代码:
var ctlVolume =$("#volume"); //音量 var level = ctlVolume.attr("min")/ctlVolume.attr("max"); var player = videojs('example-video'); // player.ready(function() { // var _this = this // //速率 // var playbackRate = $("#playbackRate").val(); // var speed = parseFloat(playbackRate); // // var volume = parseFloat($("#volume").val()/100.0); // // setTimeout(function() { // _this.playbackRate(speed); // _this.volume(volume); // },20); // }); var data = response.data; var message = '消息:'+response.message+",code:"+response.code+",meta:"+JSON.stringify(data); console.info(message); player.src('/media/'+data.uuid+'.m3u8'); player.play();
@RequestMapping(value = "{uuid}.m3u8") public ResponseEntity<StreamingResponseBody> m3u8Generator(@PathVariable("uuid") String uuid){ String key = "media.".concat(uuid); Map<String, Object> cached = cacheService.getCacheMap(key); if(CollectionUtils.isEmpty(cached)) { return new ResponseEntity(null, HttpStatus.OK); } String playlist = (String) cached.get("playlist"); String[] lines = playlist.split(" "); //人为在每个MPEG-2 transport stream文件前面加上一个地址前缀 StringBuffer buffer = new StringBuffer(); StreamingResponseBody responseBody = new StreamingResponseBody() { @Override public void writeTo (OutputStream out) throws IOException { for(int i = 0; i < lines.length; i++) { String line = lines[i]; if(line.endsWith(".ts")) { buffer.append("/streaming/"); buffer.append(uuid); buffer.append("/"); buffer.append(line); }else { buffer.append(line); } buffer.append(" "); } out.write(buffer.toString().getBytes()); out.flush(); } }; return new ResponseEntity(responseBody, HttpStatus.OK); }
2020-1-7日更新
方法补充:getCacheService().setCacheMap(mediaId, mapping);
/** * 缓存Map * * @param key * @param dataMap * @return */ <T> HashOperations<String, String, T> setCacheMap(String key, Map<String, T> dataMap); /** * 缓存Map * * @param key * @param dataMap * @return */ @Override public <T> HashOperations<String, String, T> setCacheMap(String key, Map<String, T> dataMap) { HashOperations hashOperations = redisTemplate.opsForHash(); if (null != dataMap) { hashOperations.putAll(key, dataMap); } return hashOperations; }
saveSegments方法
<insert id="saveSegments"> INSERT INTO open_segment( uuid ,filename ,bytes ,create_time ) VALUES <foreach collection="list" item="item" index="index" separator=","> ( #{item.uuid} ,#{item.filename} ,#{item.bytes} ,#{item.createTime} ) </foreach> </insert>
savePlayList方法
<insert id="savePlayList" useGeneratedKeys="true" keyProperty="id"> insert into open_playlist(uuid, duration, context, create_time) values (#{uuid}, #{duration}, #{context}, #{createTime}) </insert>