diff options
author | Klaus Schmidinger <vdr@tvdr.de> | 2012-11-18 12:19:51 +0100 |
---|---|---|
committer | Klaus Schmidinger <vdr@tvdr.de> | 2012-11-18 12:19:51 +0100 |
commit | cca2cd35ad7ef20ae7d124e06d05e896c4d8f9b6 (patch) | |
tree | 21b5f90cfe4143d1fc2188663d671b7f92f05670 /cutter.c | |
parent | 5b4e1fa793506405d0d8bac47a36640a06340c80 (diff) | |
download | vdr-cca2cd35ad7ef20ae7d124e06d05e896c4d8f9b6.tar.gz vdr-cca2cd35ad7ef20ae7d124e06d05e896c4d8f9b6.tar.bz2 |
Improved editing TS recordings
Diffstat (limited to 'cutter.c')
-rw-r--r-- | cutter.c | 650 |
1 files changed, 487 insertions, 163 deletions
@@ -4,7 +4,7 @@ * See the main source file 'vdr.c' for copyright information and * how to reach the author. * - * $Id: cutter.c 2.15 2012/10/04 12:19:37 kls Exp $ + * $Id: cutter.c 2.16 2012/11/18 12:09:00 kls Exp $ */ #include "cutter.h" @@ -13,17 +13,261 @@ #include "remux.h" #include "videodir.h" +// --- cPacketBuffer --------------------------------------------------------- + +class cPacketBuffer { +private: + uchar *data; + int size; + int length; +public: + cPacketBuffer(void); + ~cPacketBuffer(); + void Append(uchar *Data, int Length); + ///< Appends Length bytes of Data to this packet buffer. + void Flush(uchar *Data, int &Length, int MaxLength); + ///< Flushes the content of this packet buffer into the given Data, starting + ///< at position Length, and clears the buffer afterwards. Length will be + ///< incremented accordingly. If Length plus the total length of the stored + ///< packets would exceed MaxLength, nothing is copied. + }; + +cPacketBuffer::cPacketBuffer(void) +{ + data = NULL; + size = length = 0; +} + +cPacketBuffer::~cPacketBuffer() +{ + free(data); +} + +void cPacketBuffer::Append(uchar *Data, int Length) +{ + if (length + Length >= size) { + int NewSize = (length + Length) * 3 / 2; + if (uchar *p = (uchar *)realloc(data, NewSize)) { + data = p; + size = NewSize; + } + else + return; // out of memory + } + memcpy(data + length, Data, Length); + length += Length; +} + +void cPacketBuffer::Flush(uchar *Data, int &Length, int MaxLength) +{ + if (Data && length > 0 && Length + length <= MaxLength) { + memcpy(Data + Length, data, length); + Length += length; + } + length = 0; +} + +// --- cPacketStorage -------------------------------------------------------- + +class cPacketStorage { +private: + cPacketBuffer *buffers[MAXPID]; +public: + cPacketStorage(void); + ~cPacketStorage(); + void Append(int Pid, uchar *Data, int Length); + void Flush(int Pid, uchar *Data, int &Length, int MaxLength); + }; + +cPacketStorage::cPacketStorage(void) +{ + for (int i = 0; i < MAXPID; i++) + buffers[i] = NULL; +} + +cPacketStorage::~cPacketStorage() +{ + for (int i = 0; i < MAXPID; i++) + delete buffers[i]; +} + +void cPacketStorage::Append(int Pid, uchar *Data, int Length) +{ + if (!buffers[Pid]) + buffers[Pid] = new cPacketBuffer; + buffers[Pid]->Append(Data, Length); +} + +void cPacketStorage::Flush(int Pid, uchar *Data, int &Length, int MaxLength) +{ + if (buffers[Pid]) + buffers[Pid]->Flush(Data, Length, MaxLength); +} + +// --- cDanglingPacketStripper ----------------------------------------------- + +class cDanglingPacketStripper { +private: + bool processed[MAXPID]; + cPatPmtParser patPmtParser; +public: + cDanglingPacketStripper(void); + bool Process(uchar *Data, int Length, int64_t FirstPts); + ///< Scans the frame given in Data and hides the payloads of any TS packets + ///< that either didn't start within this frame, or have a PTS that is + ///< before FirstPts. The TS packets in question are not physically removed + ///< from Data in order to keep any frame counts and PCR timestamps intact. + ///< Returns true if any dangling packets have been found. + }; + +cDanglingPacketStripper::cDanglingPacketStripper(void) +{ + memset(processed, 0x00, sizeof(processed)); +} + +bool cDanglingPacketStripper::Process(uchar *Data, int Length, int64_t FirstPts) +{ + bool Found = false; + while (Length >= TS_SIZE && *Data == TS_SYNC_BYTE) { + int Pid = TsPid(Data); + if (Pid == PATPID) + patPmtParser.ParsePat(Data, TS_SIZE); + else if (Pid == patPmtParser.PmtPid()) + patPmtParser.ParsePmt(Data, TS_SIZE); + else { + int64_t Pts = TsGetPts(Data, TS_SIZE); + if (Pts >= 0) + processed[Pid] = PtsDiff(FirstPts, Pts) >= 0; // Pts is at or after FirstPts + if (!processed[Pid]) { + TsHidePayload(Data); + Found = true; + } + } + Length -= TS_SIZE; + Data += TS_SIZE; + } + return Found; +} + +// --- cPtsFixer ------------------------------------------------------------- + +class cPtsFixer { +private: + int delta; // time between two frames + int64_t last; // the last (i.e. highest) video PTS value seen + int64_t offset; // offset to add to PTS values + bool fixCounters; // controls fixing the TS continuity counters (only from the second CutIn up) + uchar counter[MAXPID]; // the TS continuity counter for each PID + cPatPmtParser patPmtParser; +public: + cPtsFixer(void); + void Setup(double FramesPerSecond); + void Fix(uchar *Data, int Length, bool CutIn); + }; + +cPtsFixer::cPtsFixer(void) +{ + delta = 0; + last = -1; + offset = -1; + fixCounters = false; + memset(counter, 0x00, sizeof(counter)); +} + +void cPtsFixer::Setup(double FramesPerSecond) +{ + delta = int(round(PTSTICKS / FramesPerSecond)); +} + +void cPtsFixer::Fix(uchar *Data, int Length, bool CutIn) +{ + if (!patPmtParser.Vpid()) { + if (!patPmtParser.ParsePatPmt(Data, Length)) + return; + } + // Determine the PTS offset at the beginning of each sequence (except the first one): + if (CutIn && last >= 0) { + int64_t Pts = TsGetPts(Data, Length); + if (Pts >= 0) { + // offset is calculated so that Pts + offset results in last + delta: + offset = Pts - PtsAdd(last, delta); + if (offset <= 0) + offset = -offset; + else + offset = MAX33BIT + 1 - offset; + } + fixCounters = true; + } + // Keep track of the highest video PTS: + uchar *p = Data; + int len = Length; + while (len >= TS_SIZE && *p == TS_SYNC_BYTE) { + int Pid = TsPid(p); + if (Pid == patPmtParser.Vpid()) { + int64_t Pts = PtsAdd(TsGetPts(p, TS_SIZE), offset); // offset is taken into account here, to make last have the "new" value already! + if (Pts >= 0 && (last < 0 || PtsDiff(last, Pts) > 0)) + last = Pts; + } + // Adjust the TS continuity counter: + if (fixCounters) { + counter[Pid] = (counter[Pid] + 1) & TS_CONT_CNT_MASK; + TsSetContinuityCounter(p, counter[Pid]); + } + else + counter[Pid] = TsGetContinuityCounter(p); // collect initial counters + p += TS_SIZE; + len -= TS_SIZE; + } + // Apply the PTS offset: + if (offset > 0) { + uchar *p = Data; + int len = Length; + while (len >= TS_SIZE && *p == TS_SYNC_BYTE) { + // Adjust the various timestamps: + int64_t Pts = TsGetPts(p, TS_SIZE); + if (Pts >= 0) + TsSetPts(p, TS_SIZE, PtsAdd(Pts, offset)); + int64_t Dts = TsGetDts(p, TS_SIZE); + if (Dts >= 0) + TsSetDts(p, TS_SIZE, PtsAdd(Dts, offset)); + int64_t Pcr = TsGetPcr(p); + if (Pcr >= 0) { + int64_t NewPcr = Pcr + offset * PCRFACTOR; + if (NewPcr >= MAX27MHZ) + NewPcr -= MAX27MHZ + 1; + TsSetPcr(p, NewPcr); + } + p += TS_SIZE; + len -= TS_SIZE; + } + } +} + // --- cCuttingThread -------------------------------------------------------- class cCuttingThread : public cThread { private: const char *error; bool isPesRecording; + double framesPerSecond; cUnbufferedFile *fromFile, *toFile; cFileName *fromFileName, *toFileName; cIndexFile *fromIndex, *toIndex; cMarks fromMarks, toMarks; + int numSequences; off_t maxVideoFileSize; + off_t fileSize; + cPtsFixer ptsFixer; + bool suspensionLogged; + bool Throttled(void); + bool SwitchFile(bool Force = false); + bool LoadFrame(int Index, uchar *Buffer, bool &Independent, int &Length); + bool FramesAreEqual(int Index1, int Index2); + void GetPendingPackets(uchar *Buffer, int &Length, int Index, int64_t LastPts); + // Gather all non-video TS packets from Index upward that either belong to + // payloads that started before Index, or have a PTS that is before LastPts, + // and add them to the end of the given Data. + bool ProcessSequence(int LastEndIndex, int BeginIndex, int EndIndex, int NextBeginIndex); protected: virtual void Action(void); public: @@ -41,16 +285,25 @@ cCuttingThread::cCuttingThread(const char *FromFileName, const char *ToFileName) fromIndex = toIndex = NULL; cRecording Recording(FromFileName); isPesRecording = Recording.IsPesRecording(); - if (fromMarks.Load(FromFileName, Recording.FramesPerSecond(), isPesRecording) && fromMarks.Count()) { - fromFileName = new cFileName(FromFileName, false, true, isPesRecording); - toFileName = new cFileName(ToFileName, true, true, isPesRecording); - fromIndex = new cIndexFile(FromFileName, false, isPesRecording); - toIndex = new cIndexFile(ToFileName, true, isPesRecording); - toMarks.Load(ToFileName, Recording.FramesPerSecond(), isPesRecording); // doesn't actually load marks, just sets the file name - maxVideoFileSize = MEGABYTE(Setup.MaxVideoFileSize); - if (isPesRecording && maxVideoFileSize > MEGABYTE(MAXVIDEOFILESIZEPES)) - maxVideoFileSize = MEGABYTE(MAXVIDEOFILESIZEPES); - Start(); + framesPerSecond = Recording.FramesPerSecond(); + suspensionLogged = false; + fileSize = 0; + ptsFixer.Setup(framesPerSecond); + if (fromMarks.Load(FromFileName, framesPerSecond, isPesRecording) && fromMarks.Count()) { + numSequences = fromMarks.GetNumSequences(); + if (numSequences > 0) { + fromFileName = new cFileName(FromFileName, false, true, isPesRecording); + toFileName = new cFileName(ToFileName, true, true, isPesRecording); + fromIndex = new cIndexFile(FromFileName, false, isPesRecording); + toIndex = new cIndexFile(ToFileName, true, isPesRecording); + toMarks.Load(ToFileName, framesPerSecond, isPesRecording); // doesn't actually load marks, just sets the file name + maxVideoFileSize = MEGABYTE(Setup.MaxVideoFileSize); + if (isPesRecording && maxVideoFileSize > MEGABYTE(MAXVIDEOFILESIZEPES)) + maxVideoFileSize = MEGABYTE(MAXVIDEOFILESIZEPES); + Start(); + } + else + esyslog("no editing sequences found for %s", FromFileName); } else esyslog("no editing marks found for %s", FromFileName); @@ -65,169 +318,236 @@ cCuttingThread::~cCuttingThread() delete toIndex; } +bool cCuttingThread::Throttled(void) +{ + if (cIoThrottle::Engaged()) { + if (!suspensionLogged) { + dsyslog("suspending cutter thread"); + suspensionLogged = true; + } + return true; + } + else if (suspensionLogged) { + dsyslog("resuming cutter thread"); + suspensionLogged = false; + } + return false; +} + +bool cCuttingThread::LoadFrame(int Index, uchar *Buffer, bool &Independent, int &Length) +{ + uint16_t FileNumber; + off_t FileOffset; + if (fromIndex->Get(Index, &FileNumber, &FileOffset, &Independent, &Length)) { + fromFile = fromFileName->SetOffset(FileNumber, FileOffset); + if (fromFile) { + fromFile->SetReadAhead(MEGABYTE(20)); + int len = ReadFrame(fromFile, Buffer, Length, MAXFRAMESIZE); + if (len < 0) + error = "ReadFrame"; + else if (len != Length) + Length = len; + return error == NULL; + } + else + error = "fromFile"; + } + return false; +} + +bool cCuttingThread::SwitchFile(bool Force) +{ + if (fileSize > maxVideoFileSize || Force) { + toFile = toFileName->NextFile(); + if (!toFile) { + error = "toFile"; + return false; + } + fileSize = 0; + } + return true; +} + +bool cCuttingThread::FramesAreEqual(int Index1, int Index2) +{ + bool Independent; + uchar Buffer1[MAXFRAMESIZE]; + uchar Buffer2[MAXFRAMESIZE]; + int Length1; + int Length2; + if (LoadFrame(Index1, Buffer1, Independent, Length1) && LoadFrame(Index2, Buffer2, Independent, Length2)) { + if (Length1 == Length2) { + int Diffs = 0; + for (int i = 0; i < Length1; i++) { + if (Buffer1[i] != Buffer2[i]) { + if (Diffs++ > 10) // the continuity counters of the PAT/PMT packets may differ + return false; + } + } + return true; + } + } + return false; +} + +void cCuttingThread::GetPendingPackets(uchar *Data, int &Length, int Index, int64_t LastPts) +{ + bool Processed[MAXPID] = { false }; + int NumIndependentFrames = 0; + cPatPmtParser PatPmtParser; + cPacketStorage PacketStorage; + for (; NumIndependentFrames < 2; Index++) { + uchar Buffer[MAXFRAMESIZE]; + bool Independent; + int len; + if (LoadFrame(Index, Buffer, Independent, len)) { + if (Independent) + NumIndependentFrames++; + uchar *p = Buffer; + while (len >= TS_SIZE && *p == TS_SYNC_BYTE) { + int Pid = TsPid(p); + if (Pid == PATPID) + PatPmtParser.ParsePat(p, TS_SIZE); + else if (Pid == PatPmtParser.PmtPid()) + PatPmtParser.ParsePmt(p, TS_SIZE); + else if (!Processed[Pid]) { + int64_t Pts = TsGetPts(p, TS_SIZE); + if (Pts >= 0) { + int64_t d = PtsDiff(LastPts, Pts); + if (d <= 0) // Pts is before or at LastPts + PacketStorage.Flush(Pid, Data, Length, MAXFRAMESIZE); + if (d >= 0) { // Pts is at or after LastPts + NumIndependentFrames = 0; // we search until we find two consecutive I-frames without any more pending packets + Processed[Pid] = true; + } + } + if (!Processed[Pid]) + PacketStorage.Append(Pid, p, TS_SIZE); + } + len -= TS_SIZE; + p += TS_SIZE; + } + } + else + break; + } +} + +bool cCuttingThread::ProcessSequence(int LastEndIndex, int BeginIndex, int EndIndex, int NextBeginIndex) +{ + // Check for seamless connections: + bool SeamlessBegin = LastEndIndex >= 0 && FramesAreEqual(LastEndIndex, BeginIndex); + bool SeamlessEnd = NextBeginIndex >= 0 && FramesAreEqual(EndIndex, NextBeginIndex); + // Process all frames from BeginIndex (included) to EndIndex (excluded): + cDanglingPacketStripper DanglingPacketStripper; + int NumIndependentFrames = 0; + int64_t FirstPts = -1; + int64_t LastPts = -1; + for (int Index = BeginIndex; Running() && Index < EndIndex; Index++) { + uchar Buffer[MAXFRAMESIZE]; + bool Independent; + int Length; + if (LoadFrame(Index, Buffer, Independent, Length)) { + if (!isPesRecording) { + int64_t Pts = TsGetPts(Buffer, Length); + if (FirstPts < 0) + FirstPts = Pts; // the PTS of the first frame in the sequence + else if (LastPts < 0 || PtsDiff(LastPts, Pts) > 0) + LastPts = Pts; // the PTS of the frame that is displayed as the very last one of the sequence + } + // Fixup data at the beginning of the sequence: + if (!SeamlessBegin) { + if (isPesRecording) { + if (Index == BeginIndex) + cRemux::SetBrokenLink(Buffer, Length); + } + else if (NumIndependentFrames < 2) { + if (DanglingPacketStripper.Process(Buffer, Length, FirstPts)) + NumIndependentFrames = 0; // we search until we find two consecutive I-frames without any more dangling packets + } + } + // Fixup data at the end of the sequence: + if (!SeamlessEnd) { + if (Index == EndIndex - 1) { + if (!isPesRecording) + GetPendingPackets(Buffer, Length, EndIndex, LastPts + int(round(PTSTICKS / framesPerSecond))); // adding one frame length to fully cover the very last frame + } + } + // Fixup timestamps and continuity counters: + if (!isPesRecording) { + if (numSequences > 1) + ptsFixer.Fix(Buffer, Length, !SeamlessBegin && Index == BeginIndex); + } + // Every file shall start with an independent frame: + if (Independent) { + NumIndependentFrames++; + if (!SwitchFile()) + return false; + } + // Write index: + if (!toIndex->Write(Independent, toFileName->Number(), fileSize)) { + error = "toIndex"; + return false; + } + // Write data: + if (toFile->Write(Buffer, Length) < 0) { + error = "safe_write"; + return false; + } + fileSize += Length; + // Generate marks at the editing points in the edited recording: + if (numSequences > 0 && Index == BeginIndex) { + if (toMarks.Count() > 0) + toMarks.Add(toIndex->Last()); + toMarks.Add(toIndex->Last()); + toMarks.Save(); + } + } + else + return false; + } + return true; +} + void cCuttingThread::Action(void) { - cMark *Mark = fromMarks.First(); - if (Mark) { + if (cMark *BeginMark = fromMarks.GetNextBegin()) { fromFile = fromFileName->Open(); toFile = toFileName->Open(); if (!fromFile || !toFile) return; - fromFile->SetReadAhead(MEGABYTE(20)); - int Index = Mark->Position(); - Mark = fromMarks.Next(Mark); - off_t FileSize = 0; - int CurrentFileNumber = 0; - int LastIFrame = 0; - toMarks.Add(0); - toMarks.Save(); - uchar buffer[MAXFRAMESIZE], buffer2[MAXFRAMESIZE]; - int Length2; - bool CheckForSeamlessStream = false; - bool LastMark = false; - bool cutIn = true; - bool suspensionLogged = false; - while (Running()) { - uint16_t FileNumber; - off_t FileOffset; - int Length; - bool Independent; - + int LastEndIndex = -1; + while (BeginMark && Running()) { // Suspend cutting if we have severe throughput problems: - - if (cIoThrottle::Engaged()) { - if (!suspensionLogged) { - dsyslog("suspending cutter thread"); - suspensionLogged = true; - } + if (Throttled()) { cCondWait::SleepMs(100); continue; } - else if (suspensionLogged) { - dsyslog("resuming cutter thread"); - suspensionLogged = false; - } - // Make sure there is enough disk space: - AssertFreeDiskSpace(-1); - - // Read one frame: - - if (fromIndex->Get(Index++, &FileNumber, &FileOffset, &Independent, &Length)) { - if (FileNumber != CurrentFileNumber) { - fromFile = fromFileName->SetOffset(FileNumber, FileOffset); - if (fromFile) - fromFile->SetReadAhead(MEGABYTE(20)); - CurrentFileNumber = FileNumber; - } - if (fromFile) { - int len = ReadFrame(fromFile, buffer, Length, sizeof(buffer)); - if (len < 0) { - error = "ReadFrame"; - break; - } - if (len != Length) { - CurrentFileNumber = 0; // this re-syncs in case the frame was larger than the buffer - Length = len; - } - } - else { - error = "fromFile"; - break; - } + // Determine the actual begin and end marks, skipping any marks at the same position: + cMark *EndMark = fromMarks.GetNextEnd(BeginMark); + // Process the current sequence: + int EndIndex = EndMark ? EndMark->Position() : fromIndex->Last() + 1; + int NextBeginIndex = -1; + if (EndMark) { + if (cMark *NextBeginMark = fromMarks.GetNextBegin(EndMark)) + NextBeginIndex = NextBeginMark->Position(); } - else { - // Error, unless we're past the last cut-in and there's no cut-out - if (Mark || LastMark) - error = "index"; + if (!ProcessSequence(LastEndIndex, BeginMark->Position(), EndIndex, NextBeginIndex)) break; - } - - // Write one frame: - - if (Independent) { // every file shall start with an independent frame - if (LastMark) // edited version shall end before next I-frame - break; - if (FileSize > maxVideoFileSize) { - toFile = toFileName->NextFile(); - if (!toFile) { - error = "toFile 1"; + if (!EndMark) + break; // reached EOF + LastEndIndex = EndIndex; + // Switch to the next sequence: + BeginMark = fromMarks.GetNextBegin(EndMark); + if (BeginMark) { + // Split edited files: + if (Setup.SplitEditedFiles) { + if (!SwitchFile(true)) break; - } - FileSize = 0; - } - LastIFrame = 0; - // Compare the current frame with the previously stored one, to see if this is a seamlessly merged recording of the same stream: - if (CheckForSeamlessStream) { - if (Length == Length2) { - int diffs = 0; - for (int i = 0; i < Length; i++) { - if (buffer[i] != buffer2[i]) { - if (diffs++ > 10) - break; - } - } - if (diffs < 10) // the continuity counters of the PAT/PMT packets may differ - cutIn = false; // it's apparently a seamless stream, so no need for "broken" handling - } - CheckForSeamlessStream = false; - } - if (cutIn) { - if (isPesRecording) - cRemux::SetBrokenLink(buffer, Length); - else - TsSetTeiOnBrokenPackets(buffer, Length); - cutIn = false; } } - if (toFile->Write(buffer, Length) < 0) { - error = "safe_write"; - break; - } - if (!toIndex->Write(Independent, toFileName->Number(), FileSize)) { - error = "toIndex"; - break; - } - FileSize += Length; - if (!LastIFrame) - LastIFrame = toIndex->Last(); - - // Check editing marks: - - if (Mark && Index >= Mark->Position()) { - Mark = fromMarks.Next(Mark); - toMarks.Add(LastIFrame); - if (Mark) - toMarks.Add(toIndex->Last() + 1); - toMarks.Save(); - if (Mark) { - // Read the next frame, for later comparison with the first frame at this mark: - if (fromIndex->Get(Index, &FileNumber, &FileOffset, &Independent, &Length2)) { - if (FileNumber != CurrentFileNumber) - fromFile = fromFileName->SetOffset(FileNumber, FileOffset); - if (fromFile) { - int len = ReadFrame(fromFile, buffer2, Length2, sizeof(buffer2)); - if (len >= 0 && len == Length2) - CheckForSeamlessStream = true; - } - } - Index = Mark->Position(); - Mark = fromMarks.Next(Mark); - CurrentFileNumber = 0; // triggers SetOffset before reading next frame - cutIn = true; - if (Setup.SplitEditedFiles) { - toFile = toFileName->NextFile(); - if (!toFile) { - error = "toFile 2"; - break; - } - FileSize = 0; - } - } - else - LastMark = true; - } } Recordings.TouchUpdate(); } @@ -255,7 +575,7 @@ bool cCutter::Start(const char *FileName) cMarks FromMarks; FromMarks.Load(FileName, Recording.FramesPerSecond(), Recording.IsPesRecording()); - if (cMark *First = FromMarks.First()) + if (cMark *First = FromMarks.GetNextBegin()) Recording.SetStartTime(Recording.Start() + (int(First->Position() / Recording.FramesPerSecond() + 30) / 60) * 60); const char *evn = Recording.PrefixFileName('%'); @@ -343,13 +663,17 @@ bool CutRecording(const char *FileName) if (Recording.Name()) { cMarks Marks; if (Marks.Load(FileName, Recording.FramesPerSecond(), Recording.IsPesRecording()) && Marks.Count()) { - if (cCutter::Start(FileName)) { - while (cCutter::Active()) - cCondWait::SleepMs(CUTTINGCHECKINTERVAL); - return true; + if (Marks.GetNumSequences()) { + if (cCutter::Start(FileName)) { + while (cCutter::Active()) + cCondWait::SleepMs(CUTTINGCHECKINTERVAL); + return true; + } + else + fprintf(stderr, "can't start editing process\n"); } else - fprintf(stderr, "can't start editing process\n"); + fprintf(stderr, "'%s' has no editing sequences\n", FileName); } else fprintf(stderr, "'%s' has no editing marks\n", FileName); |