/*
* RED5 Open Source Flash Server - http://code.google.com/p/red5/
*
* Copyright 2006-2012 by respective authors (see below). All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.red5.io.flv.impl;
import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.channels.ClosedChannelException;
import java.util.GregorianCalendar;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.Semaphore;
import org.apache.mina.core.buffer.IoBuffer;
import org.red5.io.IStreamableFile;
import org.red5.io.ITag;
import org.red5.io.ITagReader;
import org.red5.io.ITagWriter;
import org.red5.io.amf.Input;
import org.red5.io.amf.Output;
import org.red5.io.flv.FLVHeader;
import org.red5.io.flv.IFLV;
import org.red5.io.object.Serializer;
import org.red5.io.utils.IOUtils;
import org.red5.server.api.Red5;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* A Writer is used to write the contents of a FLV file
*
* @author The Red5 Project (red5@osflash.org)
* @author Dominick Accattato (daccattato@gmail.com)
* @author Luke Hubbard, Codegent Ltd (luke@codegent.com)
* @author Tiago Jacobs (tiago@imdt.com.br)
* @author Paul Gregoire (mondain@gmail.com)
*/
public class FLVWriter implements ITagWriter {
private static Logger log = LoggerFactory.getLogger(FLVWriter.class);
/**
* Length of the flv header in bytes
*/
private final static int HEADER_LENGTH = 9;
/**
* Length of the flv tag in bytes
*/
private final static int TAG_HEADER_LENGTH = 11;
/**
* Position of the meta data tag in our file.
*/
private final static int META_POSITION = 13;
/**
* For now all recorded streams carry a stream id of 0.
*/
private final static byte[] DEFAULT_STREAM_ID = new byte[] { (byte) (0 & 0xff), (byte) (0 & 0xff), (byte) (0 & 0xff) };
/**
* FLV object
*/
private IFLV flv;
/**
* Number of bytes written
*/
private volatile long bytesWritten;
/**
* Position in file
*/
private int offset;
/**
* Position in file
*/
private int timeOffset;
/**
* Id of the video codec used.
*/
private volatile int videoCodecId = -1;
/**
* Id of the audio codec used.
*/
private volatile int audioCodecId = -1;
/**
* Sampling rate
*/
private volatile int soundRate;
/**
* Size of each audio sample
*/
private volatile int soundSize;
/**
* Mono (0) or stereo (1) sound
*/
private volatile boolean soundType;
/**
* Are we appending to an existing file?
*/
private boolean append;
/**
* Duration of the file.
*/
private int duration;
/**
* Size of video data
*/
private int videoDataSize = 0;
/**
* Size of audio data
*/
private int audioDataSize = 0;
/**
* Flv file.
*/
private RandomAccessFile file;
/**
* File to which stream data is stored without an flv header or metadata.
*/
private RandomAccessFile dataFile;
private Map<Long, ITag> metaTags = new HashMap<Long, ITag>();
// path to the original file passed to the writer
private String filePath;
private final Semaphore lock = new Semaphore(1, true);
/**
* Creates writer implementation with given file and last tag
*
* FLV.java uses this constructor so we have access to the file object
*
* @param file File output stream
* @param append true if append to existing file
*/
public FLVWriter(File file, boolean append) {
filePath = file.getAbsolutePath();
log.debug("Writing to: {}", filePath);
try {
this.append = append;
if (append) {
// if we are appending get the last tags timestamp to use as offset
timeOffset = FLVReader.getDuration(file);
// set duration to last timestamp value
duration = timeOffset;
log.debug("Duration: {}", timeOffset);
// grab the file we will append to
this.dataFile = new RandomAccessFile(file, "rws");
if (!file.exists() || !file.canRead() || !file.canWrite()) {
log.warn("File does not exist or cannot be accessed");
} else {
log.trace("File size: {} last modified: {}", file.length(), file.lastModified());
// update the bytes written so we write to the correct starting position
bytesWritten = file.length();
}
// if duration is 0 then we probably have larger issues with this file
if (duration == 0) {
// seek to where metadata would normally start
dataFile.seek(META_POSITION);
}
} else {
// temporary data file for storage of stream data
File dat = new File(filePath + ".ser");
if (dat.exists()) {
dat.delete();
dat.createNewFile();
}
this.dataFile = new RandomAccessFile(dat, "rws");
// the final version of the file will go here
this.file = new RandomAccessFile(file, "rws");
}
} catch (Exception e) {
log.error("Failed to create FLV writer", e);
}
}
/**
* Writes the header bytes
*
* @throws IOException Any I/O exception
*/
public void writeHeader() throws IOException {
FLVHeader flvHeader = new FLVHeader();
flvHeader.setFlagAudio(audioCodecId != -1 ? true : false);
flvHeader.setFlagVideo(videoCodecId != -1 ? true : false);
// create a buffer
ByteBuffer header = ByteBuffer.allocate(HEADER_LENGTH + 4); // FLVHeader (9 bytes) + PreviousTagSize0 (4 bytes)
flvHeader.write(header);
// write header to output channel
file.setLength(HEADER_LENGTH + 4);
if (header.hasArray()) {
log.debug("Header bytebuffer has a backing array");
file.write(header.array());
} else {
log.debug("Header bytebuffer does not have a backing array");
byte[] tmp = new byte[HEADER_LENGTH + 4];
header.get(tmp);
file.write(tmp);
}
bytesWritten = file.length();
assert ((HEADER_LENGTH + 4) - bytesWritten == 0);
log.debug("Header size: {} bytes written: {}", (HEADER_LENGTH + 4), bytesWritten);
header.clear();
header = null;
}
/**
* {@inheritDoc}
*/
public boolean writeTag(ITag tag) throws IOException {
try {
lock.acquire();
/*
* Tag header = 11 bytes
* |-|---|----|---|
* 0 = type
* 1-3 = data size
* 4-7 = timestamp
* 8-10 = stream id (always 0)
* Tag data = variable bytes
* Previous tag = 4 bytes (tag header size + tag data size)
*/
log.trace("writeTag: {}", tag);
long prevBytesWritten = bytesWritten;
log.trace("Previous bytes written: {}", prevBytesWritten);
// skip tags with no data
int bodySize = tag.getBodySize();
log.debug("Tag body size: {}", bodySize);
// ensure that the channel is still open
if (dataFile != null) {
log.debug("Current file position: {}", dataFile.getChannel().position());
// get the data type
byte dataType = tag.getDataType();
// if we're writing non-meta tags do seeking and tag size update
if (dataType != ITag.TYPE_METADATA) {
// get the current file offset
long fileOffset = dataFile.getFilePointer();
log.debug("Current file offset: {} expected offset: {}", fileOffset, prevBytesWritten);
if (fileOffset < prevBytesWritten) {
log.debug("Seeking to expected offset");
// it's necessary to seek to the length of the file
// so that we can append new tags
dataFile.seek(prevBytesWritten);
log.debug("New file position: {}", dataFile.getChannel().position());
}
} else {
tag.getBody().mark();
String metaType = Input.getString(tag.getBody());
log.debug("Metadata tag type: {}", metaType);
tag.getBody().reset();
if (!"onCuePoint".equals(metaType)) {
// store any incoming onMetaData tags until we close the file, allow onCuePoint tags to continue
metaTags.put(System.currentTimeMillis(), tag);
return true;
}
}
// set a var holding the entire tag size including the previous tag length
int totalTagSize = TAG_HEADER_LENGTH + bodySize + 4;
// resize
dataFile.setLength(dataFile.length() + totalTagSize);
// create a buffer for this tag
ByteBuffer tagBuffer = ByteBuffer.allocate(totalTagSize);
// get the timestamp
int timestamp = tag.getTimestamp() + timeOffset;
// allow for empty tag bodies
byte[] bodyBuf = null;
if (bodySize > 0) {
// create an array big enough
bodyBuf = new byte[bodySize];
// put the bytes into the array
tag.getBody().get(bodyBuf);
// get the audio or video codec identifier
if (dataType == ITag.TYPE_AUDIO) {
audioDataSize += bodySize;
if (audioCodecId == -1) {
int id = bodyBuf[0] & 0xff; // must be unsigned
audioCodecId = (id & ITag.MASK_SOUND_FORMAT) >> 4;
log.debug("Audio codec id: {}", audioCodecId);
switch ((id & ITag.MASK_SOUND_RATE) >> 2) {
case ITag.FLAG_RATE_5_5_KHZ:
soundRate = 5500;
break;
case ITag.FLAG_RATE_11_KHZ:
soundRate = 11000;
break;
case ITag.FLAG_RATE_22_KHZ:
soundRate = 22000;
break;
case ITag.FLAG_RATE_44_KHZ:
soundRate = 44000;
break;
}
log.debug("Sound rate: {}", soundRate);
switch ((id & ITag.MASK_SOUND_SIZE) >> 1) {
case ITag.FLAG_SIZE_8_BIT:
soundSize = 8;
break;
case ITag.FLAG_SIZE_16_BIT:
soundSize = 16;
break;
}
log.debug("Sound size: {}", soundSize);
soundType = (id & ITag.MASK_SOUND_TYPE) > 0;
log.debug("Sound type: {}", soundType);
}
} else if (dataType == ITag.TYPE_VIDEO) {
videoDataSize += bodySize;
if (videoCodecId == -1) {
int id = bodyBuf[0] & 0xff; // must be unsigned
videoCodecId = id & ITag.MASK_VIDEO_CODEC;
log.debug("Video codec id: {}", videoCodecId);
}
}
}
// Data Type
IOUtils.writeUnsignedByte(tagBuffer, dataType); //1
// Body Size - Length of the message. Number of bytes after StreamID to end of tag
// (Equal to length of the tag - 11)
IOUtils.writeMediumInt(tagBuffer, bodySize); //3
// Timestamp
IOUtils.writeExtendedMediumInt(tagBuffer, timestamp); //4
// Stream id
tagBuffer.put(DEFAULT_STREAM_ID); //3
log.trace("Tag buffer (after tag header) limit: {} remaining: {}", tagBuffer.limit(), tagBuffer.remaining());
// get the body if we have one
if (bodyBuf != null) {
tagBuffer.put(bodyBuf);
log.trace("Tag buffer (after body) limit: {} remaining: {}", tagBuffer.limit(), tagBuffer.remaining());
}
// we add the tag size
tagBuffer.putInt(TAG_HEADER_LENGTH + bodySize);
log.trace("Tag buffer (after prev tag size) limit: {} remaining: {}", tagBuffer.limit(), tagBuffer.remaining());
// flip so we can process from the beginning
tagBuffer.flip();
if (log.isDebugEnabled()) {
//StringBuilder sb = new StringBuilder();
//HexDump.dumpHex(sb, tagBuffer.array());
//log.debug("\n{}", sb);
}
// write the tag
dataFile.write(tagBuffer.array());
bytesWritten = dataFile.length();
log.trace("Tag written, check value: {} (should be 0)", (bytesWritten - prevBytesWritten) - totalTagSize);
tagBuffer.clear();
// update the duration
duration = Math.max(duration, timestamp);
log.debug("Writer duration: {}", duration);
// validate written amount
if ((bytesWritten - prevBytesWritten) != totalTagSize) {
log.debug("Not all of the bytes appear to have been written, prev-current: {}", (bytesWritten - prevBytesWritten));
}
return true;
} else {
// throw an exception and let them know the cause
throw new IOException("FLV write channel has been closed", new ClosedChannelException());
}
} catch (InterruptedException e) {
log.warn("Exception acquiring lock", e);
} finally {
lock.release();
}
return false;
}
/** {@inheritDoc}
*/
public boolean writeTag(byte type, IoBuffer data) throws IOException {
return false;
}
/** {@inheritDoc} */
public boolean writeStream(byte[] b) {
try {
dataFile.write(b);
return true;
} catch (IOException e) {
log.error("", e);
}
return false;
}
/**
* Write "onMetaData" tag to the file.
*
* @param duration Duration to write in milliseconds.
* @param videoCodecId Id of the video codec used while recording.
* @param audioCodecId Id of the audio codec used while recording.
* @throws IOException if the tag could not be written
*/
private void writeMetadataTag(double duration, int videoCodecId, int audioCodecId) throws IOException {
log.debug("writeMetadataTag - duration: {} video codec: {} audio codec: {}", new Object[] { duration, videoCodecId, audioCodecId });
IoBuffer buf = IoBuffer.allocate(1024);
buf.setAutoExpand(true);
Output out = new Output(buf);
out.writeString("onMetaData");
Map<Object, Object> params = new HashMap<Object, Object>();
params.put("server", Red5.getVersion().replaceAll("\\$", "").trim());
params.put("creationdate", GregorianCalendar.getInstance().getTime().toString());
params.put("duration", (Number) duration);
if (videoCodecId != -1) {
params.put("videocodecid", videoCodecId);
params.put("videodatarate", 8 * videoDataSize / 1024 / duration); //from bytes to kilobits
} else {
// place holder
params.put("novideocodec", 0);
}
if (audioCodecId != -1) {
params.put("audiocodecid", audioCodecId);
params.put("audiosamplerate", soundRate);
params.put("audiosamplesize", soundSize);
params.put("stereo", soundType);
params.put("audiodatarate", 8 * audioDataSize / 1024 / duration); //from bytes to kilobits
} else {
// place holder
params.put("noaudiocodec", 0);
}
// this is actual only supposed to be true if the last video frame is a keyframe
params.put("canSeekToEnd", true);
out.writeMap(params, new Serializer());
buf.flip();
int bodySize = buf.limit();
log.debug("Metadata size: {}", bodySize);
// set a var holding the entire tag size including the previous tag length
int totalTagSize = TAG_HEADER_LENGTH + bodySize + 4;
// resize
file.setLength(file.length() + totalTagSize);
// create a buffer for this tag
ByteBuffer tagBuffer = ByteBuffer.allocate(totalTagSize);
// get the timestamp
int timestamp = 0;
// create an array big enough
byte[] bodyBuf = new byte[bodySize];
// put the bytes into the array
buf.get(bodyBuf);
// Data Type
IOUtils.writeUnsignedByte(tagBuffer, ITag.TYPE_METADATA); //1
// Body Size - Length of the message. Number of bytes after StreamID to end of tag
// (Equal to length of the tag - 11)
IOUtils.writeMediumInt(tagBuffer, bodySize); //3
// Timestamp
IOUtils.writeExtendedMediumInt(tagBuffer, timestamp); //4
// Stream id
tagBuffer.put(DEFAULT_STREAM_ID); //3
log.trace("Tag buffer (after tag header) limit: {} remaining: {}", tagBuffer.limit(), tagBuffer.remaining());
// get the body
tagBuffer.put(bodyBuf);
log.trace("Tag buffer (after body) limit: {} remaining: {}", tagBuffer.limit(), tagBuffer.remaining());
// we add the tag size
tagBuffer.putInt(TAG_HEADER_LENGTH + bodySize);
log.trace("Tag buffer (after prev tag size) limit: {} remaining: {}", tagBuffer.limit(), tagBuffer.remaining());
// flip so we can process from the beginning
tagBuffer.flip();
// write the tag
file.write(tagBuffer.array());
bytesWritten = file.length();
tagBuffer.clear();
buf.clear();
}
/**
* Ends the writing process, then merges the data file with the flv file header and metadata.
*/
public void close() {
log.debug("close");
log.debug("Meta tags: {}", metaTags);
try {
lock.acquire();
if (!append) {
// write the file header
writeHeader();
// write the metadata with the final duration
writeMetadataTag(duration * 0.001d, videoCodecId, audioCodecId);
// set the data file the beginning
dataFile.seek(0);
file.getChannel().transferFrom(dataFile.getChannel(), bytesWritten, dataFile.length());
} else {
// TODO update duration
}
} catch (IOException e) {
log.error("IO error on close", e);
} catch (InterruptedException e) {
log.warn("Exception acquiring lock", e);
} finally {
try {
if (dataFile != null) {
// close the file
dataFile.close();
//TODO delete the data file
File dat = new File(filePath + ".ser");
if (dat.exists()) {
dat.delete();
}
}
} catch (IOException e) {
log.error("", e);
}
try {
if (file != null) {
// run a test on the flv if debugging is on
if (log.isDebugEnabled()) {
// debugging
try {
ITagReader reader = null;
if (flv != null) {
reader = flv.getReader();
}
if (reader == null) {
file.seek(0);
reader = new FLVReader(file.getChannel());
}
log.trace("reader: {}", reader);
log.debug("Has more tags: {}", reader.hasMoreTags());
ITag tag = null;
while (reader.hasMoreTags()) {
tag = reader.readTag();
log.debug("\n{}", tag);
}
} catch (IOException e) {
log.warn("", e);
}
}
// close the file
file.close();
}
} catch (IOException e) {
log.error("", e);
}
lock.release();
}
}
/** {@inheritDoc}
*/
public IStreamableFile getFile() {
return flv;
}
/**
* Setter for FLV object
*
* @param flv FLV source
*
*/
public void setFLV(IFLV flv) {
this.flv = flv;
}
/**
* {@inheritDoc}
*/
public int getOffset() {
return offset;
}
/**
* Setter for offset
*
* @param offset Value to set for offset
*/
public void setOffset(int offset) {
this.offset = offset;
}
/**
* {@inheritDoc}
*/
public long getBytesWritten() {
return bytesWritten;
}
}